From 58bb66f4c28fa8b7703f6dab647c80fbef8c109d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com>
Date: Thu, 29 Oct 2026 19:59:43 +0800
Subject: [PATCH] =?UTF-8?q?feat(android):=20=E9=98=B6=E6=AE=B5=201=20?=
=?UTF-8?q?=E5=AE=89=E5=8D=93=E4=B8=BB=E6=8E=A7=20App=20=E5=B7=A5=E7=A8=8B?=
=?UTF-8?q?=EF=BC=88=E5=B7=B2=E7=9C=9F=E6=9C=BA=E9=AA=8C=E6=94=B6=E5=87=BA?=
=?UTF-8?q?=E7=94=BB=E9=9D=A2=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Kotlin 层三个文件(刻意零第三方依赖,纯 View 布局——阶段 1 只验证链路):
- RustBridge.kt:android-ffi 的 JNI 声明 + 帧头拆解(25 字节定长头 + Annex-B 载荷)
- H264Decoder.kt:MediaCodec 同步模式 + Surface 直出;内置两条实测教训——
只用 getInputBuffer(i)(Exynos Quirk)、awaitingIdr 状态机(R-3 跳帧断参考链)
- MainActivity.kt:连接/解码循环/触摸绝对坐标映射;断开顺序遵守
「先停解码线程 join 后 destroy」的 FFI 纪律
构建链(build-android.ps1,一条命令:cargo ndk -> .so -> gradle assembleDebug -> 安装启动):
- 路径自适应( 定位仓库根),local.properties 自动生成
- gradle.properties 固定 JDK 17,不动全局 JAVA_HOME
真机验收(vivo S6 / 192.168.1.163 -> PC / 192.168.1.180:47800):
- agent 日志:peer connected + auth ok, desktop 2560x1440
- 手机截图:Windows 桌面(WPS 文件列表)已渲染在 SurfaceView 上
证据:evidence/screen-connected.png(本地,不入库)
已知环境限制(记录在个人层 baseline):本机 Windows 防火墙的 agent.exe
程序级放行规则对 UDP 入站不生效(临时关防火墙即通),QQPC 驱动级防护
静默拦 TCP 入站(管理员无法停止/杀死)。阶段 2 前需落定长期方案:
端口级防火墙规则或换开发机。
---
apps/android-viewer/.gitignore | 15 ++
apps/android-viewer/app/build.gradle.kts | 56 +++++
.../app/src/main/AndroidManifest.xml | 21 ++
.../kotlin/com/verify/remote/H264Decoder.kt | 159 +++++++++++++
.../kotlin/com/verify/remote/MainActivity.kt | 218 ++++++++++++++++++
.../kotlin/com/verify/remote/RustBridge.kt | 90 ++++++++
apps/android-viewer/build-android.ps1 | 85 +++++++
apps/android-viewer/build.gradle.kts | 4 +
apps/android-viewer/gradle.properties | 4 +
apps/android-viewer/settings.gradle.kts | 17 ++
10 files changed, 669 insertions(+)
create mode 100644 apps/android-viewer/.gitignore
create mode 100644 apps/android-viewer/app/build.gradle.kts
create mode 100644 apps/android-viewer/app/src/main/AndroidManifest.xml
create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/H264Decoder.kt
create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt
create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt
create mode 100644 apps/android-viewer/build-android.ps1
create mode 100644 apps/android-viewer/build.gradle.kts
create mode 100644 apps/android-viewer/gradle.properties
create mode 100644 apps/android-viewer/settings.gradle.kts
diff --git a/apps/android-viewer/.gitignore b/apps/android-viewer/.gitignore
new file mode 100644
index 0000000..52aece0
--- /dev/null
+++ b/apps/android-viewer/.gitignore
@@ -0,0 +1,15 @@
+# Gradle / Android 构建产物
+.gradle/
+build/
+app/build/
+local.properties
+
+# Rust 交叉编译产物(由 build-android.ps1 生成到 app/libs)
+app/libs/
+
+# 验证截图
+evidence/
+
+# IDE
+.idea/
+*.iml
diff --git a/apps/android-viewer/app/build.gradle.kts b/apps/android-viewer/app/build.gradle.kts
new file mode 100644
index 0000000..60daa44
--- /dev/null
+++ b/apps/android-viewer/app/build.gradle.kts
@@ -0,0 +1,56 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.verify.remote"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.verify.remote"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1-phase1"
+ // 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
+ ndk {
+ abiFilters += listOf("arm64-v8a")
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ // .so 由外部脚本(build-android.ps1)预先拷到 app/libs/arm64-v8a/,
+ // 不让 Gradle 调 cargo——构建职责分离:Rust 侧失败与 Gradle 侧失败可立刻区分。
+ sourceSets {
+ getByName("main") {
+ jniLibs.srcDirs("libs")
+ }
+ }
+
+ packaging {
+ jniLibs {
+ // AGP 默认已按 useLegacyPackaging 处理;保持默认压缩+extractNativeLibs=false
+ // 由 AGP 保证 STORED+对齐,手动链的坑(lessons 记录)在 Gradle 下不存在
+ useLegacyPackaging = false
+ }
+ }
+}
+
+dependencies {
+ // 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
+ // AndroidX 在阶段 2 做正式 UI 时再引入。
+}
diff --git a/apps/android-viewer/app/src/main/AndroidManifest.xml b/apps/android-viewer/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fb12be8
--- /dev/null
+++ b/apps/android-viewer/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/H264Decoder.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/H264Decoder.kt
new file mode 100644
index 0000000..ca42bba
--- /dev/null
+++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/H264Decoder.kt
@@ -0,0 +1,159 @@
+package com.verify.remote
+
+import android.media.MediaCodec
+import android.media.MediaFormat
+import android.util.Log
+import android.view.Surface
+import java.nio.ByteBuffer
+
+/**
+ * MediaCodec H.264 解码器封装(阶段 1:同步模式 + Surface 直出)。
+ *
+ * 两条实测教训直接写进实现(来源见个人层 lessons / baseline):
+ * 1. Exynos 等设备带 requires-allocate-on-input/output-ports Quirk,
+ * 必须用 getInputBuffer(i)/getOutputBuffer(i),不用已废弃的数组版 API。
+ * 2. 参考帧链断裂后 P 帧必然解不出:解码线程维护 awaitingIdr 状态,
+ * 只喂 IDR 之后的帧;错误后 flush + requestKeyframe 并等 IDR(R-3)。
+ *
+ * MediaFormat 不设 csd:码流每个 IDR 自带 SPS/PPS(dump_stream 实测
+ * "SPS PPS IDR" 结构),解码器从码流内联参数集自举。
+ */
+class H264Decoder(private val surface: Surface) {
+
+ @Volatile
+ var stopped = false
+ private set
+
+ private var codec: MediaCodec? = null
+ private val bufferInfo = MediaCodec.BufferInfo()
+
+ /** 等待 IDR 标志:true 时 P 帧一律不喂解码器 */
+ private var awaitingIdr = true
+
+ private var configuredW = 0
+ private var configuredH = 0
+
+ /** 解码一帧。返回 false = 该帧被丢弃(等 IDR 中或缓冲满),不视为错误。 */
+ fun queue(frame: RustBridge.Frame): Boolean {
+ // 等待 IDR:参考链未建立/已断裂时,P 帧喂进去只会产生错误
+ if (awaitingIdr && !frame.keyframe) return false
+ if (frame.keyframe) awaitingIdr = false
+
+ val c = ensureCodec(frame) ?: return false
+ return try {
+ val idx = c.dequeueInputBuffer(INPUT_TIMEOUT_US)
+ if (idx < 0) return false // 输入满:阶段 1 直接丢,由消费方决定
+ val inBuf: ByteBuffer = c.getInputBuffer(idx) ?: return false
+ inBuf.clear()
+ inBuf.put(frame.payload)
+ c.queueInputBuffer(idx, 0, frame.payload.size, frame.tsMs * 1000, 0)
+ drainOnce(c)
+ true
+ } catch (e: MediaCodec.CodecException) {
+ Log.e(TAG, "codec error isRecoverable=${e.isRecoverable}", e)
+ invalidate()
+ false
+ } catch (e: IllegalStateException) {
+ Log.e(TAG, "codec illegal state", e)
+ invalidate()
+ false
+ }
+ }
+
+ /** 解码错误/跳帧后调用:flush 复位解码器(三级恢复第一级),
+ * 置等待标志;调用方负责 requestKeyframe。 */
+ fun invalidate() {
+ awaitingIdr = true
+ try {
+ codec?.flush()
+ } catch (e: Exception) {
+ // flush 失败说明解码器不可恢复:销毁,等下一个 IDR 重建
+ Log.w(TAG, "flush failed, releasing codec", e)
+ releaseCodec()
+ }
+ }
+
+ /** 确保解码器就绪。只在 keyframe 到来时才配置/重建(首配与重配同路径)。 */
+ private fun ensureCodec(frame: RustBridge.Frame): MediaCodec? {
+ val c = codec
+ if (c != null && configuredW == frame.width && configuredH == frame.height) {
+ return c
+ }
+ if (c != null) {
+ Log.i(TAG, "size changed ${configuredW}x$configuredH -> ${frame.width}x${frame.height}, reconfigure")
+ releaseCodec()
+ }
+ if (codec == null && frame.keyframe) {
+ val name = pickDecoderName()
+ val nc = if (name.isEmpty()) MediaCodec.createDecoderByType(MIME)
+ else MediaCodec.createByCodecName(name)
+ val fmt = MediaFormat.createVideoFormat(MIME, frame.width, frame.height)
+ nc.configure(fmt, surface, null, 0)
+ nc.start()
+ codec = nc
+ configuredW = frame.width
+ configuredH = frame.height
+ Log.i(TAG, "decoder configured ${frame.width}x${frame.height} name=${nc.name}")
+ }
+ return codec
+ }
+
+ /** 优先硬解;查不到就返回空串让系统自选(避免选中软件解码器)。 */
+ private fun pickDecoderName(): String {
+ return try {
+ android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
+ .codecInfos
+ .firstOrNull { info ->
+ !info.isEncoder &&
+ info.supportedTypes.any { t -> t.equals(MIME, true) } &&
+ !info.name.startsWith("OMX.google.") &&
+ !info.name.startsWith("c2.android.")
+ }?.name ?: ""
+ } catch (e: Exception) {
+ ""
+ }
+ }
+
+ private fun drainOnce(c: MediaCodec) {
+ while (!stopped) {
+ val idx = c.dequeueOutputBuffer(bufferInfo, 0)
+ when {
+ idx == MediaCodec.INFO_TRY_AGAIN_LATER -> return
+ idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED ->
+ Log.i(TAG, "output format: ${c.outputFormat}")
+ idx >= 0 -> {
+ // Surface 直出:渲染由解码器完成,不取图像数据
+ c.releaseOutputBuffer(idx, true)
+ }
+ else -> return
+ }
+ }
+ }
+
+ fun release() {
+ stopped = true
+ releaseCodec()
+ }
+
+ private fun releaseCodec() {
+ try {
+ codec?.stop()
+ } catch (e: Exception) {
+ Log.w(TAG, "stop ignored", e)
+ }
+ try {
+ codec?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "release ignored", e)
+ }
+ codec = null
+ configuredW = 0
+ configuredH = 0
+ }
+
+ companion object {
+ private const val TAG = "H264Decoder"
+ private const val MIME = "video/avc"
+ private const val INPUT_TIMEOUT_US = 4_000L
+ }
+}
diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt
new file mode 100644
index 0000000..d9ddc2b
--- /dev/null
+++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt
@@ -0,0 +1,218 @@
+package com.verify.remote
+
+import android.app.Activity
+import android.os.Bundle
+import android.util.Log
+import android.view.MotionEvent
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.widget.TextView
+import kotlin.concurrent.thread
+
+/**
+ * 阶段 1 验收 App:输入 IP/端口/验证码 → 连 agent.exe → 显示桌面。
+ *
+ * 刻意用最简结构(纯代码布局、无 AndroidX、无第三方库):
+ * 阶段 1 的目标是打通「QUIC 收帧 → MediaCodec 解码 → Surface 显示」,
+ * UI 框架与交互打磨属于阶段 2/3。
+ *
+ * 线程模型(与 android-ffi 的锁纪律对应):
+ * - UI 线程:按钮连接/断开、触摸输入(nativeSend* 不持锁等待)
+ * - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
+ * - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
+ * (Rust 侧文档明确:解码线程存活期间 destroy 是 UB)
+ */
+class MainActivity : Activity() {
+
+ private lateinit var surfaceView: SurfaceView
+ private lateinit var statusView: TextView
+
+ @Volatile
+ private var handle = 0L
+
+ @Volatile
+ private var stopDecoding = false
+ private var decodeThread: Thread? = null
+
+ // 远端桌面尺寸(AuthOk 首帧头携带)
+ @Volatile
+ private var remoteW = 0
+ @Volatile
+ private var remoteH = 0
+
+ private var decoder: H264Decoder? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ surfaceView = SurfaceView(this).apply {
+ holder.addCallback(object : SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: SurfaceHolder) {}
+ override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ // Surface 没了解码器输出会失效:直接断开,下次重连
+ disconnect()
+ }
+ })
+ }
+ statusView = TextView(this).apply {
+ textSize = 13f
+ setPadding(24, 24, 24, 24)
+ setBackgroundColor(0xCC000000.toInt())
+ setTextColor(0xFFFFFFFF.toInt())
+ }
+
+ val root = android.widget.LinearLayout(this).apply {
+ orientation = android.widget.LinearLayout.VERTICAL
+ addView(
+ surfaceView,
+ android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
+ )
+ )
+ addView(
+ statusView,
+ android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+ }
+ setContentView(root)
+
+ // 阶段 1 交互:启动即自动连接(参数走 intent extra,由 adb 传入),
+ // 双击画面断开。正式输入界面与触摸映射在阶段 2/3。
+ showHint()
+ val i = intent
+ val host = i?.getStringExtra("host")
+ if (host != null) {
+ connect(host, i.getIntExtra("port", 47800), i.getStringExtra("code") ?: "123456")
+ }
+ }
+
+ private fun showHint() {
+ statusView.text = buildString {
+ append(if (handle != 0L) "已连接 ${remoteW}x$remoteH\n" else "未连接\n")
+ append("双击画面=断开;连接参数走 intent:\n")
+ append("am start -n com.verify.remote/.MainActivity ")
+ append("--es host --ei port 47800 --es code t")
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ when (event.action) {
+ MotionEvent.ACTION_UP -> {
+ val now = System.currentTimeMillis()
+ if (now - lastTap < 300) { // 双击:断开
+ disconnect()
+ return true
+ }
+ lastTap = now
+ }
+ MotionEvent.ACTION_MOVE -> {
+ // 阶段 1 输入:单指绝对坐标映射(远端桌面尺寸已知时)。
+ // 正式触摸交互(虚拟鼠标/手势)在阶段 2/3。
+ if (handle != 0L && remoteW > 0) {
+ val x = (event.x / surfaceView.width * 65535).toInt().coerceIn(0, 65535)
+ val y = (event.y / surfaceView.height * 65535).toInt().coerceIn(0, 65535)
+ RustBridge.nativeSendMouseMove(handle, x, y)
+ }
+ }
+ }
+ return super.onTouchEvent(event)
+ }
+
+ private var lastTap = 0L
+
+ private fun connect(host: String, port: Int, code: String) {
+ if (handle != 0L) return
+ statusView.text = "连接 $host:$port …"
+ thread(name = "connect") {
+ try {
+ val h = RustBridge.connect(host, port, code)
+ handle = h
+ runOnUiThread { showHint(); startDecodeLoop(h) }
+ } catch (e: Exception) {
+ Log.e(TAG, "connect failed", e)
+ runOnUiThread { statusView.text = "连接失败: ${e.message}" }
+ }
+ }
+ }
+
+ private fun startDecodeLoop(h: Long) {
+ stopDecoding = false
+ decodeThread = thread(name = "decode") {
+ // Surface 可能尚未创建好,等它
+ val surface = surfaceView.holder.surface
+ var tries = 0
+ while (!surface.isValid && tries++ < 100) Thread.sleep(50)
+ if (!surface.isValid) {
+ Log.e(TAG, "surface never became valid")
+ return@thread
+ }
+ val dec = H264Decoder(surface)
+ decoder = dec
+ var frames = 0L
+ var lastKeyReq = 0L
+ while (!stopDecoding) {
+ val f = try {
+ RustBridge.nextFrame(h)
+ } catch (e: Exception) {
+ Log.e(TAG, "nextFrame threw", e)
+ null
+ } ?: break
+ if (remoteW == 0) {
+ remoteW = f.width
+ remoteH = f.height
+ runOnUiThread { showHint() }
+ }
+ val fed = dec.queue(f)
+ if (!fed && f.keyframe.not() && System.currentTimeMillis() - lastKeyReq > 200) {
+ // 处于等 IDR 状态:限频请求关键帧
+ RustBridge.nativeRequestKeyframe(h)
+ lastKeyReq = System.currentTimeMillis()
+ }
+ frames++
+ if (frames % 120 == 0L) {
+ val rtt = RustBridge.rttMs(h)
+ runOnUiThread {
+ statusView.text = "已连接 ${remoteW}x$remoteH 帧=$frames RTT=${rtt}ms(双击断开)"
+ }
+ }
+ }
+ dec.release()
+ decoder = null
+ Log.i(TAG, "decode loop ended, frames=$frames")
+ runOnUiThread {
+ statusView.text = "会话结束(帧 $frames)"
+ showHint()
+ }
+ }
+ }
+
+ private fun disconnect() {
+ if (handle == 0L) return
+ stopDecoding = true
+ // 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
+ // 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,
+ // 6s 足够覆盖两者;超时仍 destroy 会 UB——阶段 2 在 Rust 侧加
+ // nativeShutdown(先关连接唤醒 nextFrame 再销毁)消除该竞态。
+ decodeThread?.join(6000)
+ decodeThread = null
+ val h = handle
+ handle = 0L
+ remoteW = 0
+ remoteH = 0
+ RustBridge.destroy(h)
+ showHint()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ disconnect()
+ }
+
+ companion object {
+ private const val TAG = "MainActivity"
+ }
+}
diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt
new file mode 100644
index 0000000..c0ce60f
--- /dev/null
+++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt
@@ -0,0 +1,90 @@
+package com.verify.remote
+
+/**
+ * viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
+ * 与 android-ffi/src/lib.rs 一一对应。
+ *
+ * 句柄纪律(Rust 侧文件头有完整说明):
+ * - nativeConnect 返回 >0 句柄;0 表示失败,错误消息取 [lastError]
+ * - destroy 后必须置 0;**destroy 前必须先停掉解码线程**(否则 use-after-free)
+ */
+object RustBridge {
+
+ private const val FRAME_HEADER_LEN = 25
+
+ init {
+ System.loadLibrary("androidffi")
+ }
+
+ private external fun nativeConnect(host: String, port: Int, code: String): Long
+ external fun nativeLastError(): String?
+ /** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
+ external fun nativeNextFrame(handle: Long): ByteArray?
+ external fun nativeStats(handle: Long): Long
+ external fun nativeSendMouseMove(handle: Long, x: Int, y: Int)
+ external fun nativeSendMouseButton(handle: Long, button: Int, down: Int)
+ external fun nativeSendWheel(handle: Long, dy: Int)
+ external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)
+ external fun nativeRequestKeyframe(handle: Long)
+ external fun nativeDestroy(handle: Long)
+
+ /** 连接。失败抛 IllegalStateException(带 Rust 侧错误消息)。 */
+ fun connect(host: String, port: Int, code: String): Long {
+ val h = nativeConnect(host, port, code)
+ if (h == 0L) {
+ throw IllegalStateException(nativeLastError() ?: "connect failed")
+ }
+ return h
+ }
+
+ fun destroy(handle: Long) {
+ if (handle != 0L) nativeDestroy(handle)
+ }
+
+ /** 解出的帧(头字段已拆,payload 可直接喂 MediaCodec) */
+ data class Frame(
+ val keyframe: Boolean,
+ val seq: Long,
+ val tsMs: Long,
+ val width: Int,
+ val height: Int,
+ val payload: ByteArray,
+ )
+
+ /** 取帧 + 拆头。null = 会话结束。 */
+ fun nextFrame(handle: Long): Frame? {
+ val raw = nativeNextFrame(handle) ?: return null
+ if (raw.size < FRAME_HEADER_LEN) return null
+ return Frame(
+ keyframe = raw[0] != 0.toByte(),
+ seq = readLong(raw, 1),
+ tsMs = readLong(raw, 9),
+ width = readInt(raw, 17),
+ height = readInt(raw, 21),
+ payload = raw.copyOfRange(FRAME_HEADER_LEN, raw.size),
+ )
+ }
+
+ fun rttMs(handle: Long): Long {
+ val packed = nativeStats(handle)
+ return if (packed < 0) 0 else (packed ushr 32)
+ }
+
+ fun framesIn(handle: Long): Long {
+ val packed = nativeStats(handle)
+ return if (packed < 0) 0 else packed and 0xFFFF_FFFF
+ }
+
+ private fun readLong(b: ByteArray, off: Int): Long {
+ var v = 0L
+ for (i in 0 until 8) v = v or ((b[off + i].toLong() and 0xFF) shl (8 * i))
+ return v
+ }
+
+ private fun readInt(b: ByteArray, off: Int): Int {
+ return (b[off].toInt() and 0xFF) or
+ ((b[off + 1].toInt() and 0xFF) shl 8) or
+ ((b[off + 2].toInt() and 0xFF) shl 16) or
+ ((b[off + 3].toInt() and 0xFF) shl 24)
+ }
+}
diff --git a/apps/android-viewer/build-android.ps1 b/apps/android-viewer/build-android.ps1
new file mode 100644
index 0000000..1baa0a7
--- /dev/null
+++ b/apps/android-viewer/build-android.ps1
@@ -0,0 +1,85 @@
+# Phase 1 build chain: Rust .so -> jniLibs -> Gradle debug APK -> install -> launch
+#
+# Keep this file ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; see lessons).
+#
+# Usage (from apps/android-viewer/):
+# powershell -ExecutionPolicy Bypass -File build-android.ps1 [-Host_ 192.168.1.5] [-Port 47800] [-Code t] [-SkipRust]
+# (param is Host_ because $Host is a PowerShell automatic variable)
+#
+# Prereqs (Windows dev box):
+# - Rust + Android targets + cargo-ndk + NDK (see personal docs/baseline.md toolchain table)
+# - JDK 17 + Gradle 8.14 (paths in $vars below, adjust to your machine)
+# - local.properties with sdk.dir (gitignored; template: sdk.dir=)
+
+param(
+ [string]$Host_ = "192.168.1.5",
+ [int]$Port = 47800,
+ [string]$Code = "t",
+ [switch]$SkipRust
+)
+$ErrorActionPreference = "Stop"
+
+# repo root = two levels up from this script (apps/android-viewer -> repo)
+$proj = $PSScriptRoot
+$repo = (Resolve-Path "$proj\..\..").Path
+$env_ = "D:\working\environment"
+$gradle = "D:\working\android\gradle\gradle-8.14.3\bin\gradle.bat"
+$adb = "D:\working\android\sdk\platform-tools\adb.exe"
+
+$env:RUSTUP_HOME = "$env_\rust"
+$env:CARGO_HOME = "$env_\cargo"
+$env:PATH = "$env_\mingw64\bin;$env_\cargo\bin;$env:PATH"
+$env:ANDROID_HOME = "D:\working\android\sdk"
+$env:ANDROID_NDK_HOME = "D:\working\android\sdk\ndk\30.0.16248370"
+$env:GRADLE_USER_HOME = "D:\working\android\gradle-home"
+$env:JAVA_HOME = "D:\working\java\jdk17"
+
+if (-not (Test-Path "$proj\local.properties")) {
+ Set-Content "$proj\local.properties" 'sdk.dir=D\:\\working\\android\\sdk' -Encoding ascii
+ Write-Host "generated local.properties (sdk.dir)"
+}
+
+if (-not $SkipRust) {
+ Write-Host "=== 1/4 cargo ndk: android-ffi (arm64 release) ===" -ForegroundColor Cyan
+ Push-Location $repo
+ # cargo writes progress to stderr; PS 5.1 + ErrorActionPreference=Stop turns
+ # every stderr line into a terminating error. Relax for native calls and
+ # judge by exit code (same for gradle below).
+ $ErrorActionPreference = "Continue"
+ cargo ndk -t arm64-v8a build -p android-ffi --release 2>&1 |
+ Select-String "error|Finished|Building"
+ if ($LASTEXITCODE -ne 0) { throw "cargo ndk build failed (exit $LASTEXITCODE)" }
+ $ErrorActionPreference = "Stop"
+ Pop-Location
+}
+
+Write-Host "=== 2/4 copy .so into app/libs ===" -ForegroundColor Cyan
+$so = "$repo\target\aarch64-linux-android\release\libandroidffi.so"
+if (-not (Test-Path $so)) { throw "missing $so (run without -SkipRust)" }
+New-Item -ItemType Directory -Force -Path "$proj\app\libs\arm64-v8a" | Out-Null
+Copy-Item $so "$proj\app\libs\arm64-v8a\libandroidffi.so" -Force
+Write-Host (" libandroidffi.so: {0:N0} KB" -f ((Get-Item $so).Length/1KB))
+
+Write-Host "=== 3/4 gradle assembleDebug ===" -ForegroundColor Cyan
+Push-Location $proj
+$ErrorActionPreference = "Continue"
+& $gradle :app:assembleDebug --console=plain 2>&1 | Select-Object -Last 25
+$code = $LASTEXITCODE
+$ErrorActionPreference = "Stop"
+Pop-Location
+if ($code -ne 0) { throw "gradle build failed (exit $code)" }
+
+$apk = "$proj\app\build\outputs\apk\debug\app-debug.apk"
+if (-not (Test-Path $apk)) { throw "apk not found: $apk" }
+Write-Host (" APK: {0:N0} KB" -f ((Get-Item $apk).Length/1KB))
+
+Write-Host "=== 4/4 install + launch ===" -ForegroundColor Cyan
+$ErrorActionPreference = "Continue"
+& $adb install -r $apk
+if ($LASTEXITCODE -ne 0) { throw "install failed (phone must allow USB install)" }
+& $adb logcat -c | Out-Null
+& $adb shell am start -n com.verify.remote/.MainActivity `
+ --es host $Host_ --ei port $Port --es code $Code | Out-Null
+$ErrorActionPreference = "Stop"
+Write-Host "launched. watch: adb logcat -s MainActivity:H264Decoder RustBridge:* viewer-core:* AndroidRuntime:E"
+Write-Host "after 10s capture screen: adb exec-out screencap -p > screen.png"
diff --git a/apps/android-viewer/build.gradle.kts b/apps/android-viewer/build.gradle.kts
new file mode 100644
index 0000000..659c0e4
--- /dev/null
+++ b/apps/android-viewer/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "8.9.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.20" apply false
+}
diff --git a/apps/android-viewer/gradle.properties b/apps/android-viewer/gradle.properties
new file mode 100644
index 0000000..4d3d28e
--- /dev/null
+++ b/apps/android-viewer/gradle.properties
@@ -0,0 +1,4 @@
+# 固定 JDK 17(不动全局 JAVA_HOME,遵守 2026-10-29 环境约束)
+org.gradle.java.home=D\:\\working\\java\\jdk17
+android.useAndroidX=false
+org.gradle.jvmargs=-Xmx2g
diff --git a/apps/android-viewer/settings.gradle.kts b/apps/android-viewer/settings.gradle.kts
new file mode 100644
index 0000000..bbd8c5b
--- /dev/null
+++ b/apps/android-viewer/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "remote-viewer"
+include(":app")