From 087e15cb1979e97b3d900b8d781e43da6ab9b88f Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Thu, 10 Sep 2026 23:18:49 +0800 Subject: [PATCH] docs: execution plan for remaining backlog (written for no-vision executor model) --- docs/android_app/execution_plan.md | 401 +++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/android_app/execution_plan.md diff --git a/docs/android_app/execution_plan.md b/docs/android_app/execution_plan.md new file mode 100644 index 0000000..9b514b4 --- /dev/null +++ b/docs/android_app/execution_plan.md @@ -0,0 +1,401 @@ +# MAG160C 执行计划 —— session_state 剩余待办(为无视觉执行模型编写) + +> 本文件写给**没有视觉能力的执行模型**。所有 UI 位置已由主模型用精确 dp/颜色/ +> 对齐方式定义,照抄即可,不要自行发明布局。所有协议字节已由官方反编译源码 +> 确认(见 `analysis/magcx_official_flow.md`),不要修改协议常量。 +> +> **主模型验收标准**:每阶段结束时 `gradlew test` 全绿 + commit 存在 + 本文件 +> 对应阶段被勾选。阶段顺序固定:A → B → C → D → E → F → Z。 + +--- + +## 0. 全局约束(每个阶段开始前重读一遍) + +1. **禁止修改**: + - `android/app/src/main/AndroidManifest.xml` 的 `screenOrientation="portrait"` + - `MagProtocol.kt` 的命令字节序(`MagProtocolTest` 必须保持全绿) + - `IrSession.kt` 的连接握手序列(66b→66c→66f→[670]→673,800ms 超时) + - `LiveRenderer.kt` 的构图逻辑(图像恒 90°CW 钉死竖屏框架) + - `analysis/` 目录内容(只读参考) +2. **编码**:所有源码 UTF-8。python 脚本读写文件必须显式 `encoding='utf-8'`。 +3. **图标约定**:双弧圆 `M x,y a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0z` + 直线/矩形, + 不许用手绘贝塞尔曲线。 +4. **构建命令**(每完成一个逻辑单元就跑一次): + ```bash + cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \ + cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug test --no-daemon" + ``` +5. **提交**:每个阶段一个 commit,格式 `android: <一句话>`。禁止提交 + `build-artifacts/` 变更以外的无关文件;`build-artifacts/mag160c-app-debug.apk` + 在阶段末尾用新构建覆盖提交。 +6. **阶段完成后**:更新 `docs/android_app/session_state.md` 对应待办为已勾选, + 并在该文件追加一行进度记录(日期 + 阶段字母 + 一句话)。 +7. **失败处理**:同一编译错误尝试 3 次仍失败 → 停止该阶段,在 session_state + 记录卡点,跳到下一个独立阶段(依赖关系见各阶段"前置")。禁止大段重写 + 已工作文件。 +8. 参考文档(执行某阶段前必读对应部分): + - `analysis/magcx_official_flow.md`(协议权威) + - `docs/android_app/HANDOFF_DEVELOPMENT.md`(App 架构与约定) + - `docs/android_app/reverse_apk_features.md` §4(MDT 容器) + +--- + +## Phase A —— 真机数据通路补全 + 端到端自检清单(小,约 30 分钟) + +**前置**:无(USB 流已打通)。 + +### A1. GetLifeTime 命令支持 +文件:`MagProtocol.kt`、`IrSession.kt`。 + +1. `MagProtocol` 增加: + ```kotlin + const val CMD_GET_LIFETIME = 0x6BB6B675 // 已有,勿改值 + const val RSP_SEND_LIFETIME = 0x5BB5B561 + ``` + (0x5BB6B675 = 1807136373 = 官方 P2D_GetLifeTime;响应 = 官方 D2P_SendLifeTime + 0x5BB5B561,payload = {u32 magic(=0x5BB5B561), i32 开机毫秒},共 8 字节。) +2. `IrSession` 增加 `@Volatile var deviceLifetimeMs: Long = -1`(private set)。 +3. 在 `startInternal` 的第 3 步(GetCaliInfo)之后、obtainCali 之前插入: + ```kotlin + // 官方 getDevLifeTime:675 → 0x5BB5B561 {magic, ms} + writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_LIFETIME), "GetLifeTime") + readResp(conn, epResp, "GetLifeTime")?.let { r -> + if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.first >= 12 && + MagProtocol.u32(r.third, 0) == MagProtocol.RSP_SEND_LIFETIME + ) deviceLifetimeMs = MagProtocol.u32(r.third, 4).toLong() + } + DebugLog.log("session", "device lifetime=${deviceLifetimeMs}ms") + ``` + 失败不阻断(官方也是 log 后继续)。 +4. 在 `streamLoop` 的每 2s 心跳 stats 行尾追加 ` lifetime=${deviceLifetimeMs}`。 + +### A2. 标定文件一致性体检 +`obtainCali` 的 cache-hit 分支里,读出 bytes 后与 bundled 比较 MD5 +(`java.security.MessageDigest.getInstance("MD5")`),日志输出 +`cali cache vs bundled DDT: identical/differ`。不改变返回值(仍优先缓存)。 + +### A3. 端到端自检清单文档 +新建 `docs/android_app/real_device_checklist.md`,内容为用户可照做的步骤表: +插机授权 → 出流(预期日志行)→ 顶栏 FFC 点两次(预期 FFC ack 日志)→ +拍照(预期 `saved` + 相册 DCIM/MAG160C 出现 .jpg)→ 录像 10s 停止 +(预期 `rec_done` + MP4 出现)→ 媒体库页看到缩略图 → 分析页打开 MDT → +PDF 生成。每步标注"预期 DebugLog 行"(用现有日志文案)。 + +### 验证 +- `gradlew test` 全绿;安装 APK 到真机跑一遍 A3 清单(需要用户配合,无视觉 + 模型只保证编译与单测)。 + +**提交信息**:`android: lifetime query + cali consistency check + real-device checklist` + +--- + +## Phase B —— MDT 温度解码(离线分析增强,中) + +**前置**:无。**必读**:`media/Mdt.kt` 全文、`core/TempMath.kt` 全文、 +`ui/analyze/AnalyzeViewModel.kt` + `ui/analyze/AnalyzeViewer.kt` 全文。 + +### B1. MDT 读取器 +`media/Mdt.kt` 增加: +```kotlin +class MdtFile( + val jpg: ByteArray, + val info0: ByteArray?, // 0x38B BasePara1 + val info1: ByteArray?, + val framePixels: ByteArray?, // 38400B,19200×u16LE + val text: String?, +) +fun parse(bytes: ByteArray): MdtFile? +``` +解析规则(与 compose 互逆,全部小端): +1. 尾部 152B:校验 `u32(尾部偏移 fileLen-152) == SECTION_TAIL(0x5BB5B57B)`, + `u32(+4) == ddtOffset`;不合法返回 null。 +2. DDT 段自 ddtOffset:136B 段头(`u32(0)==SECTION_DDT(0x5BB5B55B)`, + `u32(4)==段体长度`),随后类型块循环:`{u32 magic, u32 len, data(对齐4)}`, + magic ∈ {BLOCK_INFO0, BLOCK_INFO1, BLOCK_FRAME, BLOCK_TXT},读到段体尾。 +3. jpg = bytes[0 .. 首块对齐前)(即 `bytes.copyOf(ddtOffset 对齐前实际 jpg 长度)`—— + 简化:jpg = `bytes.copyOfRange(0, firstNonJpgPad)`,实现为:从 0 找 + `FF D9`(JPEG EOI)最后一次出现位置 +2)。 +4. BLOCK_TXT 按 UTF-8 解码。 + +### B2. 温度图计算 +`core/TempMath.kt` 已有 `countsToTempMc(counts: Int): Int`(毫度)。新增: +```kotlin +fun tempMapFromPixels(pixels: ByteArray, w: Int = 160, h: Int): IntArray +``` +对每个 u16LE 像素调 countsToTempMc,返回 19200 毫度数组。加单测: +- 长度 19200;单调性抽样(对同像素递增 counts 输入断言递增输出); +- 4000 counts ≈ 20~60℃ 区间(已有测试同样断言)。 + +### B3. 分析页 UI(**精确规格,照抄**) +数据(`AnalyzeViewModel`): +- `var tempMap: IntArray? = null`(加载 MDT 成功且 framePixels 非空时填充) +- `var probe: Pair? = null`(传感器坐标 x∈[0,159], y∈[0,119]) +- `fun probeTempMc(x: Int, y: Int): Int?` = tempMap?.get(y*160+x) + +UI(`AnalyzeViewer.kt`,画在图像绘制层之上): +1. **温度条**:贴图像显示区(与热像 fitRect 同宽)顶部内侧,高 28dp,圆角 4dp, + 填充 `Color(0x99000000)`,内边距水平 12dp;白色 12sp 文本一行: + `中心 %.1f℃ 最低 %.1f℃ 最高 %.1f℃`(中心=像素(80,60),最低/最高扫全图, + 毫度 ÷1000 保留 1 位)。tempMap 为 null(纯 JPEG 文件)时不画。 +2. **点击测温**:`detectTapGestures`(若已有缩放手势则合并进现有 gesture, + 不要新增第二个 pointerInput 覆盖)——把点击点按"显示 rect → 传感器坐标" + 的固定 90° 逆映射换算(照抄 `LiveViewModel.tapImage` 内的 + `sy=(1-fx)*120; sx=fy*160` 三行),设置 probe;再次点击同一点(±6dp)清除。 + probe 标记:实心白圆半径 4dp + 外环描边 2dp 半径 9dp;标签矩形在触点右上 + 8dp,白底(0xF0FFFFFF)黑字 12sp 圆角 4dp,内容 `%.1f℃`;若右上超出色标条 + 左缘则翻转到左上。 +3. **温度条与标签随图像 fitRect 走**(缩放平移时按 fitRect 重新定位,不随 + 手势矩阵)。 + +### 验证 +- 新增 `MdtTest`:compose 一个含 info0+framePixels+text 的 MDT → parse → + 断言 jpg/frame/text/info0 字节一致;坏尾(改 1 字节)返回 null。 +- 真机验证留给用户(清单文档补一行"分析页顶部温度条出现")。 + +**提交信息**:`android: MDT temperature decode + analyzer probe UI` + +--- + +## Phase C —— 厂商 12 调色板精确提取(脚本,中) + +**前置**:无。**材料**:`C:\Tools\dl\magcx_libs\lib\armeabi\libcxsdk.so` +(372KB,官方 native 库)。 + +### C1. 先把 .so 入库 +```bash +mkdir -p analysis/sdk_re/android_app/bin +cp /c/Tools/dl/magcx_libs/lib/armeabi/libcxsdk.so analysis/sdk_re/android_app/bin/ +``` + +### C2. 提取脚本 `analysis/tools/extract_palettes.py` +逻辑(纯标准库,python3 显式 UTF-8): +1. 读入 .so 全部字节。 +2. 滑窗扫描:对每个 4 字节对齐偏移 o,检查连续 256 个 u32LE: + 统计满足 `(v >> 24) == 0xFF` 且 `(v & 0xFFFFFF) != 0` 的比例 ≥ 240/256 + → 记录候选 (o, 256)。允许运行起点为 o、o+4(调色板数组可能带表头)。 +3. 与 `OfficialTables.PALETTE256_ARGB`(铁虹,从 + `android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt` + 导出 256 个 int)比对:若某候选与已知铁虹完全一致 → 打印 "ironbow anchor + found at 0x%X"(这是提取器正确性的锚点)。相邻候选区段合并去重。 +4. 输出 `analysis/sdk_re/android_app/palette_candidates.json`: + `[{offset, hexPreview:[前16个], length}]`。 +5. 生成对比图 `analysis/sdk_re/android_app/palette_candidates.png`: + 每个候选画一行 256 色条(纯 zlib+struct 手写 PNG,无第三方依赖—— + 代码骨架:PNG = 签名 + IHDR + IDAT(zlib deflate of raw scanlines) + IEND, + 每行 256×8px 缩放为 2048×32)。 + +### C3. 判读与接线(需要用户看一眼 PNG) +1. 主模型/用户从 PNG 认出 12 个官方调色板对应的候选行(官方顺序: + 白热/黑热/铁虹/彩虹/琥珀/金秋/寒冬/热金属/喷射/红饱和/高对比/红热)。 +2. 执行模型把确认的候选按序写进 `core/Palettes.kt`: + - 保留 `NAMES` 顺序不变; + - 每个 `buildAll()` 条目来源标注注释:`// extracted @0x… libcxsdk` 或 + `// approximated (not found in binary)`; + - 铁虹必须仍是 `OfficialTables.PALETTE256_ARGB`(有锚点校验)。 +3. 单测 `PalettesTest`:12 个条目均长 256、非全同色、铁虹与 OfficialTables 相等。 + +### 回退 +若扫不到锚点(铁虹不在 .so 里):如实记录"未在 libcxsdk 静态数据中找到 +调色板表(可能运行时生成)",保留现有一套近似实现并在 `Palettes.kt` 头注释 +说明;本阶段只交付脚本与 JSON。 + +**提交信息**:`android: vendor palette extraction tooling (+exact tables if found)` + +--- + +## Phase D —— 云模块脚手架(Retrofit opt-in,小) + +**前置**:无。**原则**:默认关闭、零网络行为。 + +1. `gradle/libs.versions.toml` 增加 `retrofit = "2.11.0"`;`app/build.gradle.kts` + 增加 `implementation(libs.retrofit)` 与 `implementation("com.squareup.retrofit2:converter-gson:2.11.0")`。 +2. 新建 `cloud/CloudApi.kt`(接口+数据类,参照官方专业版云端 + cloudapi.magnity.com.cn,仅占位): + ```kotlin + interface CloudApi { + @POST("v1/account/login") fun login(@Body body: LoginReq): Call + @GET("v1/account/devices") fun devices(): Call + @POST("v1/account/taskfiles/{taskId}") fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call + @GET("v1/account/taskfiles/{taskId}") fun taskfiles(@Path("taskId") id: String): Call + } + object CloudClient { + val enabled: Boolean get() = AppSettings-enabled-flag (SharedPreferences "cloudEnabled", 默认 false) + fun api(): CloudApi = Retrofit.Builder().baseUrl("https://cloudapi.magnity.com.cn/") + .addConverterFactory(GsonConverterFactory.create()).build().create(CloudApi::class.java) + } + ``` + (enabled 为 false 时任何调用方不得构造 Retrofit —— 加运行时断言。) +3. `ui/settings/AppSettings.kt` 增加 `var cloudEnabled: Boolean`(默认 false)。 +4. 设置页 UI(**照抄**):在"语言"行之后、"关于"行之前插入 + `SettingRow("云同步", if (enabled) "已开启" else "已关闭") { dialog = "cloud" }`; + dialog 内容:说明文字"上传/任务同步需要账号,当前版本仅预留接口,不会发起 + 任何网络请求。"+ 按钮"开启/保持关闭"。 +5. release 混淆:`app/proguard-rules.pro` 追加 + `-keep class com.mag160c.thermal.cloud.** { *; }`。 + +**验证**:构建全绿;设置页出现该行(无视觉模型确保编译与文案)。 + +**提交信息**:`android: cloud module scaffold (retrofit, opt-in, disabled by default)` + +--- + +## Phase E —— 可见光 PIP 融合(中) + +**前置**:Phase B 已合入(同文件顶部逻辑)。**必读**:`ui/live/LiveScreen.kt` 全文。 + +### E1. 权限 +`AndroidManifest.xml` 增加: +```xml + + +``` +运行时权限:LiveScreen 内 +`rememberLauncherForActivityResult(RequestPermission())`,首次开启 PIP 时请求。 + +### E2. 状态(LiveViewModel.LiveState 增加) +```kotlin +val pipOn: Boolean = false, +val pipSizeIndex: Int = 1, // 0/1/2 → 96/128/160dp 宽 +val pipXf: Float = 1f, // PIP 左上角 X,相对视口宽 0..1 +val pipYf: Float = 0f, // 相对"热像可视区顶"高 0..1 +``` +加 `fun togglePip()` / `fun cyclePipSize()` / `fun setPipPos(xf, yf)`。 + +### E3. 相机组件 `ui/live/PipCameraView.kt` +AndroidView 包装 TextureView + Camera2 最小实现(完整骨架,执行模型不得 +改动结构只允许填注释处): +``` +class PipCameraEngine(val context, val textureView) : + TextureView.SurfaceTextureListener { + open(): 选第一个 BACK 相机 → CameraManager.openCamera + onSurfaceTextureAvailable → open → createCaptureSession(listOf(surface)) + → setRepeatingRequest(TEMPLATE_PREVIEW) + release(): session.close → camera.close → camera=null(幂等) + 错误全部 DebugLog("pip", ...) 并 release +} +``` +(~150 行。任何相机错误只记日志 + 隐藏 PIP,绝不崩溃:外层包 try/catch。) + +### E4. LiveScreen 布局(**精确坐标**) +1. **顶栏第 5 项**:现有 4 个 Box(weight 1f) 之后加第 5 个同构 Box: + Icon `ic_pip` + Text("画中画"),点击 `vm.togglePip()`,开启时 tint = + `colorScheme.primary`。新建 `res/drawable/ic_pip.xml`: + 双弧圆(24dp 画布 `M12,4 a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z` 描边) + + 右下小实心矩形 `M13,13 h6 v6 h-6 z`。 +2. **PIP 浮层**(画在 Box 内、快门区之前): + - 尺寸三档:宽 {96, 128, 160}dp,高 = 宽 × 3/4(相机 4:3 横向); + 默认 index=1(128×96dp)。 + - 位置:`left = viewport.left + pipXf*(viewport.width - pipW)`; + `top = viewport.top + pipYf*(viewport.height - pipH)`,viewport 取 + LiveRenderer 同款热像可视区(用 vm.uiTopPx/uiBottomPx 推算,Compose 侧 + 用 onSizeChanged 拿到的同一数值);初始 pipXf=1f、pipYf=0f(右上角)。 + 实际摆放再减去色标条占位:`right 边距额外 +32dp`(色标条 20dp+2×12dp), + 即初始右上角留出色标条位置。 + - 外观:`border(2.dp, Color.White, RoundedCornerShape(8.dp))`,内部 + TextureView `clipToOutline=true`。 + - 手势:`pointerInput` 里 `detectDragGestures` 拖动 → 更新 pipXf/pipYf + (clamp 0..1);`detectTapGestures(onTap = 循环尺寸, onDoubleTap = 关闭)`。 +3. 释放:`pipOn=false`、离开实时页、`ON_STOP` 时调用 engine.release()。 + +### 验证(无视觉) +- 编译 + 现有单测全绿;DebugLog:`pip: camera opened / released`。 +- 真机视觉验证项写入 `real_device_checklist.md`(Phase A 文档追加两行)。 + +**提交信息**:`android: visible-light PIP overlay (camera2, draggable, 3 sizes)` + +--- + +## Phase F —— 网络互连远程预览(大,放最后) + +**前置**:A/E 已合入。**设计定稿如下,不得更改协议参数。** + +### F1. 协议(`net/RemoteContract.kt`,纯 Kotlin,可单测) +- **发现**:UDP 47510。主机每 1000ms 向 `255.255.255.255:47510` 发一行 UTF-8 JSON: + `{"app":"mag160c-remote","role":"host","name":"","tcp":47511,"serial":160043865}` +- **控制**:TCP 47511,换行分隔 JSON(UTF-8,单行 ≤4KB): + - 客户端→主机:`{"cmd":"hello","name":"..."}` → 回 + `{"type":"welcome","w":160,"h":120,"fps":15,"serial":...}` + - `{"cmd":"start"}` → `{"type":"stream-start"}` 后开始二进制帧 + - `{"cmd":"stop"}` → `{"type":"stream-stop"}` + - `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}` +- **图像帧**(stream-start 之后,TCP 二进制流): + `[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素]` + 共 38412B/帧。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染** + (调色板/变倍全在客户端本地,无需回传)。 +- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。 + +### F2. 文件与职责 +1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、 + `encodeFramePacket(raw: ByteArray, counter: Int): ByteArray`、 + `FramePacketReader.feed(bytes): List`(流式重组)。 + —— 单测 `RemoteContractTest`:encode→feed 往返一致;截断/粘包两种输入。 +2. `net/RemoteHost.kt`(协程): + - `start(rawFrames: Flow)`:DatagramSocket 广播协程 + + ServerSocket(47511) accept 循环(单客户端,后来者拒绝写 busy); + hello→welcome;start→订阅 rawFrames 逐帧写 38412B 包; + stop→stream-stop。全部 IO 在 Dispatchers.IO,异常即断开清理。 + - `stop()`:关 socket。 +3. `IrSession.kt`:增加 + ```kotlin + @Volatile var rawHook: ((ByteArray) -> Unit)? = null + ``` + 在 streamLoop 中 `lastRawFrame = frameBuf.copyOf()` 之后调用 + `rawHook?.invoke(lastRawFrame!!)`(与 recorderHook 并列)。 +4. `net/RemoteClient.kt`: + - `startDiscovery(): Flow`(47510 收包 → 解析 JSON → 去重 3s); + - `connect(ip): RemoteSession`(TCP;`frames: Flow` 供渲染, + `send(cmd)`;断线 → flow 完成)。 +5. `ui/settings`:设置页"语言"与"关于"之间插两行: + `SettingRow("远程预览服务端", if(on) "已开启" else "已关闭")`(点击切换, + 开启时要求当前 USB 会话活跃——不活跃弹 AlertDialog 提示"先连接热像仪"); + `SettingRow("远程预览客户端", "查找主机")`(导航到 F4 列表页)。 +6. `ui/remote/RemoteClientListScreen.kt`(**照抄规格**): + - 顶部标题栏高 56dp:左返回箭头 icon(双弧圆+向左横线)、标题"远程预览"16sp; + - 主机卡片列表:每项高 64dp,水平 16dp 边距;左侧 24dp 双弧圆图标, + 主文本=主机名 16sp,副文本=`IP:47511` 12sp 灰;行尾 TextButton"连接"; + - 列表尾 8dp 间隔 + 全宽 48dp 高 OutlinedButton"重新扫描"; + - 分隔线 + 小标题"手动添加"(14sp 灰,左 16dp)+ Row(TextField + placeholder"例如 192.168.1.23", weight 1f + TextButton"连接"); + - 空态居中 Text"正在扫描局域网主机…"(扫描中)/"未发现主机"(10s 后)。 +7. `ui/remote/RemoteViewerScreen.kt`:**复制 LiveScreen 的骨架**(SurfaceView+ + LiveRenderer 同款构图、顶栏 4 项、底导航不变),差异点: + - 顶栏 FFC→发 `{"cmd":"ffc"}`;变倍/追踪/调色板→客户端本地(本地管线); + - 快门区仅保留中间一个红色圆形"断开"按钮(60dp,边 4dp #FFFF5252), + 点击断开并回列表;相册/拍照不显示; + - `RemoteViewerViewModel`:持有 RemoteSession + 本地 RenderPipeline+ + loadDdt(bundled),frames flow → `pipe.frame(frame, true, out)` → + latestFrame;温度刷新照抄 refreshTemps 的本地分支(probe 80,60)。 + - 断线:回列表 + Snackbar"连接已断开"。 +8. `LiveViewModel`:暴露 `val rawFrameFlow = MutableSharedFlow( + extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)`, + 在 `sessionListener.onFrameReady` 里 `session.lastRawFrame?.let { + rawFrameFlow.tryEmit(it) }`;设置页服务端开关 on → + `remoteHost.start(rawFrameFlow)`,off → stop。 +9. Manifest:``。 + +### F3. 验证 +- 单测:RemoteContractTest(含 3 帧连发+粘包拆包)。 +- 真机:两台手机同局域网(或手机+模拟器),清单文档追加"远程预览"步骤与 + 预期日志(host: `client connected from …`;client: `frames=…`)。 + +**提交信息**:`android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering)` + +--- + +## Phase Z —— 收尾 + +1. `docs/android_app/session_state.md`:待办全部勾选/更新,追加本轮总结段。 +2. `docs/android_app/HANDOFF_DEVELOPMENT.md`:§3 结构表加 `net/`、`cloud/`、 + `ui/remote/`;§7 待办刷新。 +3. 全量 `assembleDebug + test`;提交 APK;`git push origin main`。 +4. 汇报:各阶段 commit 列表 + 用户手测清单位置。 + +--- + +## 执行模型操作规程(摘要) + +- 每阶段:读本文件对应节 → 列 todo → 实现 → `gradlew test` 绿 → 按格式 + commit → 更新 session_state → 下一阶段。 +- 不确定就查 `analysis/magcx_official_flow.md` 与 `analysis/jadx_magcx/` 源码, + **不要发明协议字节**。 +- 禁止 push(由用户/主模型执行)。