Files

420 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MAG160C 执行计划 —— session_state 剩余待办(为无视觉执行模型编写)
> **执行状态(2026-09-10**Phase A / B / C / D / E / F / Z **全部完成**
> 每阶段一个 commitA=06c1f30 B=656d419 C=b7a928e D=c34940e E=f8b3200
> F=512508e),44 个单测全绿,debug+release 双构建通过,APK 已更新。
> 唯一未完成项为**真机实测**(需用户配合,清单见
> `docs/android_app/real_device_checklist.md`)与红热调色板精确表(材料不足)。
> 详细记录与诚实记录的限制见 `docs/android_app/session_state.md`。
> 本文件写给**没有视觉能力的执行模型**。所有 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]→673800ms 超时)
- `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` §4MDT 容器)
---
## 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
0x5BB5B561payload = {u32 magic(=0x5BB5B561), i32 开机毫秒},共 8 字节。)
2. `IrSession` 增加 `@Volatile var deviceLifetimeMs: Long = -1`private set)。
3. 在 `startInternal` 的第 3 步(GetCaliInfo)之后、obtainCali 之前插入:
```kotlin
// 官方 getDevLifeTime675 → 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?, // 38400B19200×u16LE
val text: String?,
)
fun parse(bytes: ByteArray): MdtFile?
```
解析规则(与 compose 互逆,全部小端):
1. 尾部 152B:校验 `u32(尾部偏移 fileLen-152) == SECTION_TAIL(0x5BB5B57B)`
`u32(+4) == ddtOffset`;不合法返回 null。
2. DDT 段自 ddtOffset136B 段头(`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<Int, Int>? = 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<LoginResp>
@GET("v1/account/devices") fun devices(): Call<DeviceListResp>
@POST("v1/account/taskfiles/{taskId}") fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call<UploadResp>
@GET("v1/account/taskfiles/{taskId}") fun taskfiles(@Path("taskId") id: String): Call<TaskFilesResp>
}
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
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
```
运行时权限: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=1128×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":"<Build.MODEL>","tcp":47511,"serial":160043865}`
- **控制**TCP 47511,换行分隔 JSONUTF-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]`
`[u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400B 原始 u16LE 像素]`
头部 24B,共 **38424B/帧**。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
(调色板/变倍全在客户端本地,无需回传)。
- `flags` bit0 = 主机本帧是否渲染成功;`ffcPhase` = 主机管线 FFC 阶段
(0 正常 / 1 快门关闭 / 2 参考帧采集);`shutter` = 本帧相机温度(原始单位)。
- **为什么必须带这几项**2026-09-11 修订):NUC 表按快门温度插值,缺了它
counts 会饱和(真机表现为满屏噪声 + 读数 -161℃);参考帧还必须在客户端
按同一规则平均。早期版本只传像素、客户端独立跑 FFC 状态机,画面不正确。
- 客户端相应入口:`RenderPipeline.frameRemote(frame, phase, shutter, out)`。
- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。
- **单客户端**:已有客户端时,第二连接立即收到 `{"type":"busy"}` 并被关闭。
- **流中不写控制回复**stream-start 之后客户端处于帧模式,此时主机不得再写
JSON 行(会被当作帧内杂散字节);`ffc` 在流中执行但不回复。
### F2. 文件与职责
1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、
`encodeFramePacket(raw: ByteArray, counter: Int): ByteArray`、
`FramePacketReader.feed(bytes): List<ByteArray>`(流式重组)。
—— 单测 `RemoteContractTest`encode→feed 往返一致;截断/粘包两种输入。
2. `net/RemoteHost.kt`(协程):
- `start(rawFrames: Flow<ByteArray>)`DatagramSocket 广播协程 +
ServerSocket(47511) accept 循环(单客户端,后来者拒绝写 busy);
hello→welcomestart→订阅 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<HostInfo>`47510 收包 → 解析 JSON → 去重 3s);
- `connect(ip): RemoteSession`TCP`frames: Flow<ByteArray>` 供渲染,
`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<ByteArray>(
extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)`
在 `sessionListener.onFrameReady` 里 `session.lastRawFrame?.let {
rawFrameFlow.tryEmit(it) }`;设置页服务端开关 on →
`remoteHost.start(rawFrameFlow)`off → stop。
9. Manifest`<uses-permission android:name="android.permission.INTERNET" />`。
### 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(由用户/主模型执行)。