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")