diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt index 742e7d4..0db8475 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -1,10 +1,7 @@ package com.mag160c.thermal.ui -import android.content.res.Configuration -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -22,7 +19,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.mag160c.thermal.R @@ -40,86 +36,45 @@ private val TABS = listOf( ) /** - * App shell with an overlay navigation that adapts to orientation without - * recreating the tab content: bottom bar in portrait, left rail in landscape. + * App shell. The bottom navigation bar is always at the bottom in both + * orientations (top/bottom bars keep their positions; only their inner + * icons/text follow the screen). Tab content lives in a stable slot. */ @Composable fun AppRoot() { var tab by rememberSaveable { mutableStateOf(0) } - val isLandscape = - LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE Box(modifier = Modifier.fillMaxSize()) { - // ---- tab content (always the same slot; nav is an overlay) ---- - val bottomPad = if (isLandscape) 0.dp else 84.dp when (tab) { 0 -> LiveScreen() - 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { GalleryScreen() } - 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { GalleryScreen() } - else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { SettingsScreen() } } - // ---- overlay navigation ---- - if (isLandscape) { - // rail overlays the left letterbox margin: no bottom inset needed - UiInsets.navPx = 0 - Surface( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .align(Alignment.CenterStart) - .onSizeChanged { }, - ) { - Column( - modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - TABS.forEachIndexed { i, t -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clickable { tab = i } - .padding(vertical = 14.dp, horizontal = 8.dp), - ) { - Icon( - painterResource(t.icon), - contentDescription = t.label, - tint = if (tab == i) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - t.label, - style = MaterialTheme.typography.labelSmall, - color = if (tab == i) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } else { - Surface( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .align(Alignment.BottomCenter) - .onSizeChanged { UiInsets.navPx = it.height }, - ) { - NavigationBar(modifier = Modifier.fillMaxWidth()) { - TABS.forEachIndexed { i, t -> - NavigationBarItem( - selected = tab == i, - onClick = { tab = i }, - icon = { Icon(painterResource(t.icon), null) }, - label = { Text(t.label) }, - ) - } + // bottom navigation overlay (both orientations) + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { UiInsets.navPx = it.height }, + ) { + NavigationBar(modifier = Modifier.fillMaxWidth()) { + TABS.forEachIndexed { i, t -> + NavigationBarItem( + selected = tab == i, + onClick = { tab = i }, + icon = { Icon(painterResource(t.icon), null) }, + label = { Text(t.label) }, + ) } } } } -} +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 1acfafd..8426045 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -11,11 +11,11 @@ import android.view.SurfaceView /** * Software canvas renderer for the live IR stream. * - * The image CONTENT stays fixed in the world while the phone rotates: the - * draw rotation tracks the display rotation (LiveViewModel.drawRotationDeg). - * Available area = full canvas minus the control-bar (top) and nav (bottom) - * insets reported via [LiveViewModel.uiTopPx]/[uiBottomPx]. OSD icons/text - * stay upright in the current screen orientation. + * The thermal image is ALWAYS drawn rotated 90 deg CW (3:4 vertical) into a + * fixed fitted rect inside the available area (full screen minus the control + * top bar and the bottom navigation bar). The image region does NOT move or + * rotate with the phone; only the OSD text stays screen-horizontal so the + * labels are always readable. */ class LiveRenderer( private val surfaceView: SurfaceView, @@ -90,34 +90,20 @@ class LiveRenderer( val availH = bottom - top if (availH <= 0) return - val d = vm.drawRotationDeg() - val swap = d == 90 || d == 270 - // fit the DISPLAYED (rotated) image into the available area - var dstW: Float - var dstH: Float - if (swap) { - dstW = availW - dstH = availW * 4f / 3f - if (dstH > availH) { - dstH = availH - dstW = availH * 3f / 4f - } - } else { + // fit the 3:4 (rotated) image into the available rect (fixed orientation) + var dstW = availW + var dstH = availW * 4f / 3f + if (dstH > availH) { dstH = availH - dstW = availH * 4f / 3f - if (dstW > availW) { - dstW = availW - dstH = availW * 3f / 4f - } + dstW = availH * 3f / 4f } val left = (availW - dstW) / 2f val vpTop = top + (availH - dstH) / 2f viewport.set(left, vpTop, left + dstW, vpTop + dstH) + // draw the source bitmap rotated 90 deg CW around the viewport center val cx = viewport.centerX() val cy = viewport.centerY() - val w0 = if (swap) dstH else dstW // pre-rotation draw rect - val h0 = if (swap) dstW else dstH val zoom = vm.state.value.zoom val srcRect = if (zoom > 1) { val cw = 320 / zoom @@ -126,7 +112,10 @@ class LiveRenderer( } else null canvas.save() - canvas.rotate(d.toFloat(), cx, cy) + canvas.rotate(90f, cx, cy) + // pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport) + val w0 = dstH + val h0 = dstW val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f) if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint) else canvas.drawBitmap(bitmap, null, dst, paint) @@ -198,4 +187,4 @@ class LiveRenderer( } drawColorBar(canvas, state) } -} +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 3f46f6d..d409be2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -4,10 +4,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -36,7 +41,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.mag160c.thermal.R import com.mag160c.thermal.core.Palettes import android.view.SurfaceView -import android.view.Surface /** * Stable single-branch live view: one SurfaceView for the whole screen, @@ -60,14 +64,6 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { } } - // keep the renderer in sync with the physical display rotation - val rotation = remember(context) { - context.display?.rotation ?: Surface.ROTATION_0 - } - LaunchedEffect(rotation) { - vm.uiRotation = rotation - } - Box(modifier = Modifier.fillMaxSize()) { AndroidSurface(vm) Box( @@ -90,12 +86,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { ) } - // ---- control TOP bar (always visible, both orientations) ---- + // ---- control TOP bar (always at top, both orientations) ---- Surface( color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() + // cutout / status-bar safe area (punch-hole phones) + .windowInsetsPadding( + WindowInsets.safeDrawing + .only(WindowInsetsSides.Top), + ) .onSizeChanged { vm.uiTopPx = it.height }, ) { Row( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 66846d1..0c31e06 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -53,10 +53,6 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { @Volatile var uiBottomPx: Int = 0 - /** Display rotation (Surface.ROTATION_0/90/180/270), drives image orientation. */ - @Volatile - var uiRotation: Int = 0 - private val sessionListener = object : IrSession.Listener { override fun onStateChanged(state: IrSession.State, message: String?) { _state.value = _state.value.copy( @@ -242,9 +238,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** * Tap on the live image: add a probe point, or delete an existing one when - * tapping near it. Rotation-aware: the image draw rotation follows the - * display rotation (same mapping as LiveRenderer), insets carve the - * available area. + * tapping near it. The image is always displayed rotated 90 deg CW + * (3:4 vertical) and does NOT move with the phone; only OSD text follows + * the screen. Insets carve the available area. */ fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) { val s = _state.value @@ -252,41 +248,23 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val top = uiTopPx.toFloat() val bottom = viewH - uiBottomPx.toFloat() if (screenY < top || screenY > bottom) return - val d = drawRotationDeg() - val swap = d == 90 || d == 270 - // fit rect of the DISPLAYED (rotated) image inside the avail rect + // fit the 3:4 (rotated 90 CW) image into the available rect val availW = viewW val availH = bottom - top - var dstW: Float - var dstH: Float - if (swap) { - dstW = availW - dstH = availW * 4f / 3f - if (dstH > availH) { - dstH = availH - dstW = availH * 3f / 4f - } - } else { + var dstW = availW + var dstH = availW * 4f / 3f + if (dstH > availH) { dstH = availH - dstW = availH * 4f / 3f - if (dstW > availW) { - dstW = availW - dstH = availW * 3f / 4f - } + dstW = availH * 3f / 4f } val left = (availW - dstW) / 2f val top2 = top + (availH - dstH) / 2f if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return val fx = (screenX - left) / dstW val fy = (screenY - top2) / dstH - val sx: Float - val sy: Float - when (d) { - 0 -> { sx = fx * 160f; sy = fy * 120f } - 90 -> { sy = (1f - fx) * 120f; sx = fy * 160f } - 180 -> { sx = (1f - fx) * 160f; sy = (1f - fy) * 120f } - else -> { sx = (1f - fy) * 160f; sy = fx * 120f } - } + // inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160 + val sy = (1f - fx) * 120f + val sx = fy * 160f // near an existing probe (compare in screen space)? delete it instead val thr = dstW * 0.06f val existing = s.probes.firstOrNull { p -> @@ -310,45 +288,21 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { refreshTemps() } - /** Image draw rotation (deg CW) from the current display rotation. */ - fun drawRotationDeg(): Int = ((90 - uiRotation * 90) % 360 + 360) % 360 - - /** Screen coords of a sensor point under the current rotation + insets. */ + /** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */ fun probeToScreen(sx: Int, sy: Int): FloatArray { val viewW = uiViewW.toFloat().coerceAtLeast(1f) - val viewH = uiViewH.toFloat().coerceAtLeast(1f) - val top = uiTopPx.toFloat() - val availH = viewH - uiTopPx - uiBottomPx - val d = drawRotationDeg() - val swap = d == 90 || d == 270 - var dstW: Float - var dstH: Float - if (swap) { - dstW = viewW - dstH = viewW * 4f / 3f - if (dstH > availH) { - dstH = availH - dstW = availH * 3f / 4f - } - } else { + val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f) + var dstW = viewW + var dstH = viewW * 4f / 3f + if (dstH > availH) { dstH = availH - dstW = availH * 4f / 3f - if (dstW > viewW) { - dstW = viewW - dstH = viewW * 3f / 4f - } + dstW = availH * 3f / 4f } val left = (viewW - dstW) / 2f - val top2 = uiTopPx + (availH - dstH) / 2f - val fx: Float - val fy: Float - when (d) { - 0 -> { fx = sx / 160f; fy = sy / 120f } - 90 -> { fx = 1f - sy / 120f; fy = sx / 160f } - 180 -> { fx = 1f - sx / 160f; fy = 1f - sy / 120f } - else -> { fx = sy / 120f; fy = 1f - sx / 160f } - } - return floatArrayOf(left + fx * dstW, top2 + fy * dstH) + val top = uiTopPx + (availH - dstH) / 2f + val fx = 1f - sy / 120f + val fy = sx / 160f + return floatArrayOf(left + fx * dstW, top + fy * dstH) } /** Last known surface size, for probe screen mapping. */ diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index b4d6601..5f26c42 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:538b162be037aaef17b18b8953037d12792b3aad781b3bed5e3d26024309ab84 +oid sha256:cf2b7c9cd468d32ab443ebb54213ee0dc9744cd1f136342a4769f191c7955277 size 11806159 diff --git a/docs/android_app/HANDOFF_DEVELOPMENT.md b/docs/android_app/HANDOFF_DEVELOPMENT.md new file mode 100644 index 0000000..0c1302d --- /dev/null +++ b/docs/android_app/HANDOFF_DEVELOPMENT.md @@ -0,0 +1,168 @@ +# MAG160C 统一安卓APP — 完整开发交接文档(换模型用) + +> 生成时间:2026-09-07。给接手模型读取本文件即可恢复全部上下文。 +> 结合 `docs/android_app/session_state.md`(心跳)与 `docs/android_app/reverse_apk_features.md`(逆向总表)。 + +--- + +## 0. 给新模型的读取提示词 + +> 你是本项目的续任开发者。请先完整阅读 `C:\Project\MAG160C\docs\android_app\HANDOFF_DEVELOPMENT.md` +> 和 `C:\Project\MAG160C\docs\android_app\session_state.md`、`C:\Project\MAG160C\docs\android_app\reverse_apk_features.md`、 +> `C:\Project\MAG160C\analysis\protocol_spec.md`,再继续开发。项目根目录 `C:\Project\MAG160C` 是 git 仓库, +> 源码在 `android\` 子目录(Gradle 工程,package `com.mag160c.thermal`)。当前会话状态与待办见 session_state.md。 + +--- + +## 1. 项目目标与背景 + +- 把 MAG160C 热像仪(160×120, 15fps, USB VID 0x833C)的官方 4 个安卓APP + (普通版 MAG-Cx / 专业版 MAG-Mx / ThermoScope 数据分析 / CoreSdkSample)逆向整合 + 为一款现代化安卓APP。 +- 适配 **Android 16(API 36)**;用户反馈旧专业版在 Android 16 上花屏,重写根治。 +- 逆向证据:`docs/android_app/reverse_apk_features.md`(4 APP 功能总表)、 + `analysis/protocol_spec.md`(USB 协议/温度算法全逆向)。 +- 用户要求:现代 UI、横竖屏自适应、**不建立流氓文件夹**(媒体→MediaStore DCIM/MAG160C)、 + 无 32 位库(纯 Kotlin,无 NDK)。 + +## 2. 技术栈与工具链(已部署) + +- **纯 Kotlin + Jetpack Compose (Material 3)**,无 NDK、无 .so、无 native。 +- Gradle 8.14.3(`C:\Tools\gradle-8.14.3\bin\gradle.bat`),JDK Zulu 21(`C:\Program Files\Zulu\zulu-21`)。 +- Android SDK:`C:\Users\ZXC\AppData\Local\Android\Sdk`(platforms/android-36、build-tools 36.1.0、 + platform-tools/adb、模拟器 system-images android-36.1 google_apis_playstore x86_64、AVD `Medium_Phone_API_36.1`)。 +- 工程:`C:\Project\MAG160C\android\`;版本目录 `gradle\libs.versions.toml` + (AGP 8.11.1 / Kotlin 2.2.0 / Compose BOM 2025.06.01 / lifecycle 2.9.1 / junit 4.13.2)。 +- **磁盘轻量约定**:不装 NDK/CMake/zig(曾占用 2.4GB+,已卸载);全 Kotlin。 +- 模拟器实测命令(无窗口): + ```powershell + Start-Process "$env:LOCALAPPDATA\Android\Sdk\emulator\emulator.exe" -ArgumentList @('-avd','Medium_Phone_API_36.1','-no-window','-no-audio','-gpu','swiftshader_indirect') -WindowStyle Hidden + # 安装/启动/截图: + adb -s emulator-5554 install -r build-artifacts\mag160c-app-debug.apk + adb -s emulator-5554 shell am force-stop com.mag160c.thermal + adb -s emulator-5554 shell am start -n com.mag160c.thermal/.MainActivity + adb -s emulator-5554 exec-out screencap -p > analysis\preview\xxx.png # 注意:exec-out 才二进制安全 + adb -s emulator-5554 emu rotate # 旋转模拟器(每次90°) + ``` + +## 3. 源码结构与核心模块 + +``` +android/app/src/main/kotlin/com/mag160c/thermal/ +├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸) +├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒在底部(横竖屏一致) +├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白) +├─ ui/theme/Theme.kt M3 动态取色(Android12+) +├─ ui/live/ ★实时画面(最核心) +│ ├─ LiveViewModel.kt 状态机/演示模式/多点测温/追踪开关/拍照/录像/inset上报 +│ ├─ LiveScreen.kt 稳定单分支布局:顶栏控制条(恒顶部,适配挖孔屏)+SurfaceView +│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)固定不随手机转, +│ 文字恒屏幕水平;色标条/圆圈标记/中心温OSD +├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限) +├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/备注回写/PDF报告) +├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/关于,SharedPreferences私有) +├─ media/ +│ ├─ Mdt.kt MDT 容器格式(JPG + DDT段[info块+原始帧] + 152B Tail) +│ ├─ PhotoSaver.kt MediaStore 保存(DCIM/MAG160C,无权限也可写自有文件) +│ ├─ Mp4Recorder.kt MP4录像(Surface 输入 H.264,MediaCodec+MediaMuxer) +│ └─ PdfReport.kt PDF 巡检报告(PdfDocument) +├─ usb/ +│ ├─ UsbTransport.kt UsbManager 枚举(VID 0x833C)/权限/claim/EP +│ ├─ MagProtocol.kt 命令码 0x6BB6B6xx、响应 0x5BB5B5xx +│ └─ IrSession.kt 连接/启动流/读线程→FrameStream→RenderPipeline→回调;FFC回调 +├─ core/ +│ ├─ OfficialTables.kt 自动生成:PALETTE256_ARGB(官方铁虹)/T2E(646)/T2E274/E2T +│ ├─ Palettes.kt 12调色板(铁虹=官方,其余标准曲线近似) +│ ├─ RenderPipeline.kt ★官方渲染管线 Kotlin 移植(与C参考逐字节一致) +│ ├─ TempMath.kt 温度换算(counts→毫度,T2E) +│ └─ FrameStream.kt 流帧重组(0x1BB1B11B 搜索) +└─ task/TaskParser.kt 任务巡检 sqlite/xml 解析 +assets/mag160c.ddt 官方 DDT 标定文件(1.8MB,渲染必需) +res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形) +``` + +## 4. 核心知识(必须知道) + +### 4.1 渲染管线(已逐字节验证) +- `core/RenderPipeline.kt` 移植自 `csdk/src/mag160c_render.c`(官方管线,像素级验证)。 +- 输入:USB 帧(0x38 头 + 38400B u16 小端 + 尾部);输出 320×240 ARGB。 +- FFC 状态机:暖机20帧内暂停;第75帧强制FFC;FFC(0)→5隐藏→FFC(1)→4参考帧→4隐藏→正常。 +- **移植陷阱(已解决,改代码时务必保持)**: + 1. C 的 32 位无符号回绕:所有 `u32()` 掩码必须保留(cdf*denom、0xffc0000-iv7*0x40000、(v-lo)*S 等)。 + 2. lutRebuild 里 `u12` 的基址是常量 `iv7*0x10+0x10`,**不是**链式 u21v。 + 3. ByteArray 存 255=-1:判 0xFF 必须 `(b.toInt() and 0xFF)`。 + 4. 暖机窗口检查在 ffc_step **之前**(顺序不能改)。 +- 验证方式:`android/app/src/test/.../RenderPipelineTest.kt`(JVM 差分测试,参照 `analysis/render_offline.c` 的 C 输出,60帧序列与官方DDT)。 + +### 4.2 USB 协议(analysis/protocol_spec.md 全文) +- 命令:普通 4 字节 magic;FFC 8 字节 {magic, param}。 +- 初始化:66b→66c→66f(4B)→FFC(0)×2→300ms→START(73);停止 STOP(74)。 +- 帧流:EP 0x81 bulk,0x1BB1B11B 头 + 0x1C 起像素 + 尾部 shutter 于 +0x24。 +- 相机信息块 0x5BB5B55B:+0x00 pid、+0x08 序列号、+0x10 宽、+0x14 高、+0x18 fps。 + +### 4.3 实时画面渲染(本轮设计约定) +- **图像恒旋转90°CW绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上), + **不随手机旋转**(手机怎么转图像区域都不动)。 +- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射: + `fx=1-sy/120, fy=sx/160`)。 +- 顶栏=控制项(FFC/变倍/追踪·开/调色板/拍照/录像/调色板名),恒在顶部,`safeDrawing` 顶部inset 适配挖孔屏。 +- 底栏=导航恒在底部(横竖屏一致;**没有横屏左栏**)。 +- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`;底导航高度经 `UiInsets.navPx`→`vm.uiBottomPx`,渲染器据此留白。 +- 演示模式:无设备时用真实管线+DDT渲染合成帧(热块场景),demo温度用局部线性显示映射 + (真机温度走管线数学;绝对温度标定待真机)。 + +### 4.4 温度 +- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD, + 在真机上需按 DDT 标定核对绝对值(待办)。 +- 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)未实现(待真机文件对照)。 + +## 5. 构建 / 测试 / 提交 + +```powershell +# 构建(无 --daemon 单次,约1分钟) +cd C:\Project\MAG160C\android +$env:JAVA_HOME="C:\Program Files\Zulu\zulu-21" +& "C:\Tools\gradle-8.14.3\bin\gradle.bat" :app:assembleDebug --no-daemon +# 单元测试(管线差分验证) +& "C:\Tools\gradle-8.14.3\bin\gradle.bat" test --no-daemon +# 产物 +Copy-Item app\build\outputs\apk\debug\app-debug.apk C:\Project\MAG160C\build-artifacts\mag160c-app-debug.apk +# 提交(每里程碑一个 commit,不 push) +git -C C:\Project\MAG160C add -A +git -C C:\Project\MAG160C commit -m "android: ..." +``` + +## 6. 严重教训(反复踩坑,务必遵守) + +1. **编码**:所有源码必须 UTF-8。本机默认编码是 GBK! + - **python 读写文件必须显式 `encoding='utf-8'`**(`open(p,'w')` 裸写会变 GBK→编译出的中文全乱码)。 + - 检测/修复脚本:`C:\Users\ZXC\AppData\Local\Temp\opencode\fix_encoding.py`、 + `check_mojibake.py`。改完必须跑一遍确认全 OK,再构建。 + - gradle.properties 已有 `-Dfile.encoding=UTF-8`(gradle + kotlin daemon)。 +2. **图标**:手写 pathData 的弧线易坏,一律用双弧圆 `M x,y a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0z` + 直线/矩形。 +3. **SurfaceView 与旋转**:不要在横竖屏间切换不同布局分支(SurfaceView 会被销毁且 surface 不重建)。 + 用"稳定单分支 + 覆盖层"布局(当前实现即如此)。 +4. **写代码要谨慎**:本会话多次因急于成稿写出语法错误/残留死代码。每写一个文件后立即编译。 + +## 7. 当前状态与待办 + +**已完成**:核心管线移植(字节级验证)、USB 层、实时画面(竖图固定+顶栏控制+底栏导航+ +圆圈标记+大色标+演示模式)、拍照 MDT 容器→MediaStore、MP4 录像、媒体库、离线分析查看器、 +PDF 报告、任务解析、设置页、Android16 沉浸(状态栏隐藏+挖孔适配+fullSensor)、乱码多次修复。 + +**待办**(记录在 session_state.md): +1. 真机 USB 实测:温度绝对值标定、FFC/录像/MDT 保存端到端。 +2. 网络互连远程预览(用户需求:UDP 自动发现 + 手动内网 IP 连另一台插热像仪的手机)。 +3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)。 +4. 厂商 12 调色板精确提取(运行时抓取或逆向)。 +5. 可见光 PIP 融合、云模块(预留结构)。 + +## 8. 文件路径速查 + +- 心跳/状态:`docs/android_app/session_state.md` +- 逆向功能总表:`docs/android_app/reverse_apk_features.md` +- USB 协议/温度算法:`analysis/protocol_spec.md` +- C 参考实现(Kotlin 管线的蓝本):`csdk/src/mag160c_render.c`、`mag160c_temp.c`、`mag160c_ir.c` +- PC 参考工具:`analysis/render_offline.c`、`analysis/gen_kotlin_tables.py` +- 预览截图:`analysis/preview/preview_*.png` +- APK 产物:`build-artifacts/mag160c-app-debug.apk` \ No newline at end of file diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 5af694a..0643db2 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -110,13 +110,24 @@ encoding='utf-8';本次 LiveScreen.kt 双重乱码已重写恢复,dex 验证 CJK 正常 - [x] 模拟器验证:顶栏/左导航/图像/色标/标记布局正确,中文正常,状态栏隐藏生效 +## 用户反馈修复 第四轮(2026-09-07,最终布局约定) + +- [x] **顶栏/底栏位置固定**:顶栏=控制项(FFC/变倍/追踪/调色板/拍照/录像)恒在顶部; + 底栏=导航恒在底部。横竖屏都不移动(**取消横屏左栏**),图标文字跟随屏幕方向(恒可读) +- [x] **图像区域永不旋转**:图像恒 90°CW 绘制为 3:4 竖向,填满顶栏~底栏之间可用区, + 手机怎么转都不动;只转标记文字(文字恒屏幕水平,位置经 probeToScreen 固定90°映射) +- [x] **顶栏适配挖孔屏**:`WindowInsets.safeDrawing.only(Top)` 顶部安全区 padding +- [x] 去掉 display-rotation 依赖(uiRotation/drawRotationDeg 已删,tapImage/probeToScreen + 用固定 90° 映射) +- [x] 完整交接文档:docs/android_app/HANDOFF_DEVELOPMENT.md(含给新模型的读取提示词) + ## 待办 - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端) - 网络互连远程预览(用户需求:UDP自动发现+手动IP) - 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照) - 厂商12调色板精确提取(需运行时抓取) -- 预览图:analysis/preview/preview_landscape.png、preview_right.png(本轮) +- 可见光PIP融合、云模块(预留结构) ## 里程碑日志