android: lock portrait - UI composition glued to phone frame (top/bottom bars + image area absolutely fixed)

This commit is contained in:
ZXCLI
2026-09-07 13:17:19 +08:00
parent 2c38537303
commit d0b133a895
14 changed files with 67 additions and 31 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -16,7 +16,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullSensor"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true">
<intent-filter>
@@ -36,9 +36,10 @@ private val TABS = listOf(
)
/**
* 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.
* App shell. The activity is portrait-locked, so the bottom navigation bar
* is glued to the phone's portrait bottom edge at all times (its absolute
* position never moves, however the phone is physically held). Tab content
* lives in a stable slot.
*/
@Composable
fun AppRoot() {
@@ -58,7 +59,7 @@ fun AppRoot() {
}
}
// bottom navigation overlay (both orientations)
// bottom navigation overlay (glued to the portrait bottom edge)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
@@ -2,7 +2,7 @@ package com.mag160c.thermal.ui
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
object UiInsets {
/** Bottom navigation overlay height in px (0 in landscape). */
/** Bottom navigation overlay height in px (portrait-locked app: constant). */
@Volatile
var navPx: Int = 0
}
@@ -63,7 +63,7 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
LazyVerticalGrid(columns = GridCells.Fixed(if (isLandscape()) 4 else 3)) {
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(items.size) { idx ->
val item = items[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
@@ -109,8 +109,3 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
}
}
}
@Composable
private fun isLandscape(): Boolean =
androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
@@ -11,11 +11,13 @@ import android.view.SurfaceView
/**
* Software canvas renderer for the live IR stream.
*
* 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.
* The activity is portrait-locked, so the composition below is glued to the
* phone's portrait frame: 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 however the phone is physically
* held, so the on-screen area always matches the thermal lens direction;
* OSD text stays screen-horizontal so the labels are always readable.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -43,10 +43,12 @@ import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView
/**
* Stable single-branch live view: one SurfaceView for the whole screen,
* a control TOP bar (always visible) and overlays anchored by orientation.
* Image content stays world-fixed across rotations (renderer handles the
* draw rotation from the display rotation).
* Stable single-branch live view: one SurfaceView for the whole screen, a
* control TOP bar (always visible) and the bottom navigation overlay.
* The activity is portrait-locked, so the whole composition (top bar /
* image region / bottom bar) is glued to the phone's portrait frame and
* never moves or rotates, however the phone is physically held — the on-
* screen area always matches the thermal lens direction.
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
@@ -86,7 +88,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
)
}
// ---- control TOP bar (always at top, both orientations) ----
// ---- control TOP bar (glued to the portrait top edge) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
Binary file not shown.
+12 -8
View File
@@ -22,7 +22,7 @@
- 适配 **Android 16API 36**;用户反馈旧专业版在 Android 16 上花屏,重写根治。
- 逆向证据:`docs/android_app/reverse_apk_features.md`4 APP 功能总表)、
`analysis/protocol_spec.md`USB 协议/温度算法全逆向)。
- 用户要求:现代 UI、横竖屏自适应、**不建立流氓文件夹**(媒体→MediaStore DCIM/MAG160C)、
- 用户要求:现代 UI、**构图钉死竖屏框架**(竖屏锁定,见 §4.3)、**不建立流氓文件夹**(媒体→MediaStore DCIM/MAG160C)、
无 32 位库(纯 Kotlin,无 NDK)。
## 2. 技术栈与工具链(已部署)
@@ -50,13 +50,13 @@
```
android/app/src/main/kotlin/com/mag160c/thermal/
├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸)
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒在底部(横竖屏一致
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
├─ ui/theme/Theme.kt M3 动态取色(Android12+
├─ ui/live/ ★实时画面(最核心)
│ ├─ LiveViewModel.kt 状态机/演示模式/多点测温/追踪开关/拍照/录像/inset上报
│ ├─ LiveScreen.kt 稳定单分支布局:顶栏控制条(恒顶部,适配挖孔屏)+SurfaceView
│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)固定不随手机转,
│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
│ 文字恒屏幕水平;色标条/圆圈标记/中心温OSD
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/备注回写/PDF报告)
@@ -100,13 +100,17 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
- 帧流:EP 0x81 bulk0x1BB1B11B 头 + 0x1C 起像素 + 尾部 shutter 于 +0x24。
- 相机信息块 0x5BB5B55B+0x00 pid、+0x08 序列号、+0x10 宽、+0x14 高、+0x18 fps。
### 4.3 实时画面渲染(本轮设计约定
- **图像恒旋转90°CW绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上),
**不随手机旋转**(手机怎么转图像区域都不动)。
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架
- **Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`)。
- 顶栏=控制项(FFC/变倍/追踪·开/调色板/拍照/录像/调色板名),恒在顶部,`safeDrawing` 顶部inset 适配挖孔屏。
- 底栏=导航恒在底部(横竖屏一致;**没有横屏左栏**)
- 顶栏=控制项(FFC/变倍/追踪·开/调色板/拍照/录像/调色板名),恒在顶部,`safeDrawing`
顶部inset 适配挖孔屏
- 底栏=导航恒在底部。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`;底导航高度经 `UiInsets.navPx`→`vm.uiBottomPx`,渲染器据此留白。
- 演示模式:无设备时用真实管线+DDT渲染合成帧(热块场景),demo温度用局部线性显示映射
(真机温度走管线数学;绝对温度标定待真机)。
+17
View File
@@ -121,6 +121,23 @@
用固定 90° 映射)
- [x] 完整交接文档:docs/android_app/HANDOFF_DEVELOPMENT.md(含给新模型的读取提示词)
## 用户反馈修复 第五轮(2026-09-07,布局约定最终定稿:构图钉死竖屏框架)
- [x] **用户澄清**:"顶栏底栏位置不变"指**绝对位置**——永远贴着手机竖屏的物理顶边
(挖孔侧)与物理底边;热像区域也**完全不动**。不是"横屏后显示区域跟着屏幕转":
那样屏显方向会和镜头实际方向对不上。
- [x] **根因与修复**:此前 fullSensor 下渲染"相对当前屏幕固定 90°CW",手机横过来时
屏幕本身转 90°,整个构图相对手机框架也转 90° → 与镜头方向脱节。修复 =
**Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转,构图(顶栏/热像区/底栏)永远钉在竖屏框架上;手机怎么物理旋转都一样。
- [x] 顺带清理:GalleryScreen 去掉 isLandscape() 死逻辑(恒 3 列);
AppRoot/LiveScreen/LiveRenderer/UiInsets 注释同步更新。
- [x] 模拟器四方向实测(0/90/180/270°,`adb emu rotate`):**四个方向截图逐字节一致**
(MD5 相同)——构图绝对固定;演示画面/顶栏/底栏/色标/标记/中文均正常
analysis/preview/preview_rot*_v5.png)。
- [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。
- 注:不要再改回 fullSensor/sensor 横屏;横竖屏适配类需求一律以"竖屏构图恒定"为准。
## 待办
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)