From 430bf373e40bfb920de84dbaf85d68dd78143a5b Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sun, 6 Sep 2026 21:56:34 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E4=BF=AE=E5=A4=8D=E6=BA=90=E7=A0=81?= =?UTF-8?q?GBK=E7=BC=96=E7=A0=81=E5=AF=BC=E8=87=B4=E7=9A=84=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E4=B9=B1=E7=A0=81+=E9=87=8D=E7=BB=98=E5=9B=BE?= =?UTF-8?q?=E6=A0=87+USB=E6=8F=92=E5=85=A5=E8=87=AA=E5=8A=A8=E6=8E=88?= =?UTF-8?q?=E6=9D=83+=E5=AA=92=E4=BD=93=E6=9D=83=E9=99=90+=E5=A4=9A?= =?UTF-8?q?=E7=82=B9=E6=B5=8B=E6=B8=A9/=E8=89=B2=E6=A0=87=E6=9D=A1/?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 9 +++ .../kotlin/com/mag160c/thermal/ui/AppRoot.kt | 12 +-- .../thermal/ui/gallery/GalleryScreen.kt | 13 ++++ .../mag160c/thermal/ui/live/LiveRenderer.kt | 53 ++++++++++--- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 24 ++++++ .../mag160c/thermal/ui/live/LiveViewModel.kt | 74 ++++++++++++++++++- .../app/src/main/res/drawable/ic_analysis.xml | 4 +- .../app/src/main/res/drawable/ic_camera.xml | 5 +- android/app/src/main/res/drawable/ic_ffc.xml | 4 +- .../app/src/main/res/drawable/ic_gallery.xml | 4 +- .../app/src/main/res/drawable/ic_launcher.xml | 8 +- .../app/src/main/res/drawable/ic_palette.xml | 7 +- .../app/src/main/res/drawable/ic_record.xml | 5 +- .../app/src/main/res/drawable/ic_settings.xml | 7 +- android/app/src/main/res/drawable/ic_zoom.xml | 4 +- .../app/src/main/res/xml/device_filter.xml | 5 ++ android/gradle.properties | 1 + 17 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/res/xml/device_filter.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a6c3274..edd6afc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + + + + + 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 aa56c10..200303a 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 @@ -30,10 +30,10 @@ import com.mag160c.thermal.ui.settings.SettingsScreen private data class Tab(val label: String, val icon: Int) private val TABS = listOf( - Tab("ʵʱ", R.drawable.ic_ffc), - Tab("", R.drawable.ic_gallery), - Tab("", R.drawable.ic_analysis), - Tab("", R.drawable.ic_settings), + Tab("实时", R.drawable.ic_ffc), + Tab("相册", R.drawable.ic_gallery), + Tab("分析", R.drawable.ic_analysis), + Tab("设置", R.drawable.ic_settings), ) @Composable @@ -45,8 +45,8 @@ fun AppRoot() { val content: @Composable () -> Unit = { when (tab) { 0 -> LiveScreen() - 1 -> Placeholder("ý⣨׶4ʵ֣") - 2 -> Placeholder("MDT ߷׶5ʵ֣") + 1 -> Placeholder("媒体库(阶段4实现)") + 2 -> Placeholder("MDT 离线分析(阶段5实现)") else -> SettingsScreen() } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt index 2a45e96..60393eb 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt @@ -41,6 +41,19 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) { val context = LocalContext.current var showViewer by remember { mutableStateOf(false) } + // runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE) + val perm = if (android.os.Build.VERSION.SDK_INT >= 33) + android.Manifest.permission.READ_MEDIA_IMAGES + else android.Manifest.permission.READ_EXTERNAL_STORAGE + val launcher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), + ) { granted -> vm.refresh() } + LaunchedEffect(Unit) { + val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) == + android.content.pm.PackageManager.PERMISSION_GRANTED + if (granted) vm.refresh() else launcher.launch(perm) + } + Column(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().padding(8.dp), 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 a854d61..32ce4af 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 @@ -116,6 +116,46 @@ class LiveRenderer( textPaint.color = Color.WHITE } + private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { + textPaint.color = Color.WHITE + state.centerTempC?.let { + canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint) + } + if (state.maxTraceOn) { + drawTempMarker(canvas, state.maxPos, state.maxTempC) + } + drawTempMarker(canvas, state.minPos, state.minTempC) + for (p in state.probes) { + drawTempMarker(canvas, p.x + p.y * 160, p.tempC, p.label) + } + drawColorBar(canvas, state) + } + + /** Side color bar: palette gradient + min/max temperature labels. */ + private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) { + if (state.maxTempC == null || state.minTempC == null) return + val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex] + val barW = 14f + val barH = viewport.height() * 0.66f + val x = viewport.right - barW - 8f + val y0 = viewport.top + (viewport.height() - barH) / 2f + val seg = Paint() + val n = 64 + for (i in 0 until n) { + // top = hot (palette 255), bottom = cold (palette 0) + val c = pal[255 - i * 255 / (n - 1)] + val sy = y0 + barH * i / n + val ey = y0 + barH * (i + 1) / n + seg.color = c + canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg) + } + textPaint.color = Color.WHITE + val maxT = "%.1f".format(state.maxTempC) + val minT = "%.1f".format(state.minTempC) + canvas.drawText(maxT, x - textPaint.measureText(maxT) / 2, y0 - 4f, textPaint) + canvas.drawText(minT, x - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint) + } + private fun sensorToScreen(pos: Int): FloatArray { val x = pos % 160 val y = pos / 160 @@ -124,10 +164,10 @@ class LiveRenderer( return floatArrayOf(sx, sy) } - private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?) { + private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) { if (pos < 0 || tempC == null) return val p = sensorToScreen(pos) - val text = "%.1f℃".format(tempC) + val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC) val tw = textPaint.measureText(text) val cx = p[0] val cy = p[1] @@ -138,13 +178,4 @@ class LiveRenderer( canvas.drawCircle(cx, cy, 4f, textPaint) canvas.drawText(text, tx, ty, textPaint) } - - private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { - textPaint.color = Color.WHITE - state.centerTempC?.let { - canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint) - } - drawTempMarker(canvas, state.maxPos, state.maxTempC) - drawTempMarker(canvas, state.minPos, state.minTempC) - } } 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 f183662..6587eaf 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 @@ -1,6 +1,7 @@ package com.mag160c.thermal.ui.live import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -56,6 +58,19 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { .fillMaxSize(), ) { AndroidSurface(vm) + // tap overlay: add / remove probe points + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures { offset -> + vm.tapImage( + offset.x, offset.y, + size.width.toFloat(), size.height.toFloat(), + ) + } + }, + ) if (!state.connected) { Text( text = when (state.status) { @@ -92,6 +107,15 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) { Icon(painterResource(R.drawable.ic_zoom), contentDescription = "数码变倍 x${state.zoom}") } + Text( + text = if (state.maxTraceOn) "追踪·开" else "追踪", + style = MaterialTheme.typography.labelMedium, + color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable { vm.toggleMaxTrace() } + .padding(6.dp), + ) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = state.centerTempC?.let { "%.1f℃".format(it) } ?: "--", 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 d22d546..9155ca5 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 @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.StateFlow * Live view state machine: USB permission -> link -> stream -> OSD stats. */ class LiveViewModel(app: Application) : AndroidViewModel(app) { + data class ProbePoint(val x: Int, val y: Int, val label: String, val tempC: Float?) + data class LiveState( val connected: Boolean = false, val streaming: Boolean = false, @@ -24,6 +26,8 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val maxPos: Int = -1, val minPos: Int = -1, val identity: IrSession.CameraIdentity? = null, + val maxTraceOn: Boolean = true, + val probes: List = emptyList(), ) private val _state = MutableStateFlow(LiveState()) @@ -36,6 +40,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { private val session = IrSession(app) + private var usbReceiver: android.content.BroadcastReceiver? = null + + private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null + private val sessionListener = object : IrSession.Listener { override fun onStateChanged(state: IrSession.State, message: String?) { _state.value = _state.value.copy( @@ -56,6 +64,18 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { init { session.setListener(sessionListener) + // auto permission + start when the camera is plugged in while running + val ctx = getApplication() + usbReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { + connect() + } + } + ctx.registerReceiver( + usbReceiver, + android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED), + if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0, + ) // push frames into the MP4 recorder while recording session.recorderHook = { argb -> val rec = recorder @@ -95,6 +115,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) } + /** Toggle max-temperature trace marker. */ + fun toggleMaxTrace() { + _state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn) + } + /** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */ fun capturePhoto(context: android.content.Context) { val frame = latestFrame ?: return @@ -116,7 +141,49 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail") } - private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null + /** + * Tap on the live image: add a probe point, or delete an existing one when + * tapping near it. Coordinates are view pixels; converted via the same + * letterbox + zoom math the renderer uses. + */ + fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) { + val s = _state.value + if (!s.streaming) return + val ar = 4f / 3f + val viewRatio = viewW / viewH + val dstW: Float + val dstH: Float + if (viewRatio > ar) { + dstH = viewH + dstW = viewH * ar + } else { + dstW = viewW + dstH = viewW / ar + } + val left = (viewW - dstW) / 2f + val top = (viewH - dstH) / 2f + if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return + val srcW = 160f / s.zoom + val srcH = 120f / s.zoom + val sx = (screenX - left) / dstW * srcW + (160 - srcW) / 2f + val sy = (screenY - top) / dstH * srcH + (120 - srcH) / 2f + val sensorX = sx.toInt().coerceIn(0, 159) + val sensorY = sy.toInt().coerceIn(0, 119) + // near an existing probe? delete it instead of adding + val thr = dstW * 0.06f + val existing = s.probes.firstOrNull { p -> + kotlin.math.abs(p.x + 0.5f - sx) * (dstW / srcW) < thr && + kotlin.math.abs(p.y + 0.5f - sy) * (dstH / srcH) < thr + } + if (existing != null) { + _state.value = _state.value.copy(probes = _state.value.probes - existing) + return + } + val label = "Pt${s.probes.size + 1}" + val p = ProbePoint(sensorX, sensorY, label, null) + _state.value = _state.value.copy(probes = _state.value.probes + p) + refreshTemps() + } /** Toggle MP4 recording of the live stream. */ fun toggleRecording(context: android.content.Context) { @@ -159,12 +226,17 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { mxPos = i } } + // probe temperatures + val probes = _state.value.probes.map { p -> + p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f) + } _state.value = _state.value.copy( centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f }, maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null, minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null, maxPos = mxPos, minPos = mnPos, + probes = probes, ) } diff --git a/android/app/src/main/res/drawable/ic_analysis.xml b/android/app/src/main/res/drawable/ic_analysis.xml index 0b3d02e..ebc0509 100644 --- a/android/app/src/main/res/drawable/ic_analysis.xml +++ b/android/app/src/main/res/drawable/ic_analysis.xml @@ -1,3 +1,5 @@ - + + + diff --git a/android/app/src/main/res/drawable/ic_camera.xml b/android/app/src/main/res/drawable/ic_camera.xml index a4e09c4..6e5e363 100644 --- a/android/app/src/main/res/drawable/ic_camera.xml +++ b/android/app/src/main/res/drawable/ic_camera.xml @@ -1,4 +1,5 @@ - - + + + diff --git a/android/app/src/main/res/drawable/ic_ffc.xml b/android/app/src/main/res/drawable/ic_ffc.xml index 7b74525..a8794b2 100644 --- a/android/app/src/main/res/drawable/ic_ffc.xml +++ b/android/app/src/main/res/drawable/ic_ffc.xml @@ -1,3 +1,5 @@ - + + + diff --git a/android/app/src/main/res/drawable/ic_gallery.xml b/android/app/src/main/res/drawable/ic_gallery.xml index 43e248c..76661e7 100644 --- a/android/app/src/main/res/drawable/ic_gallery.xml +++ b/android/app/src/main/res/drawable/ic_gallery.xml @@ -1,3 +1,5 @@ - + + + diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml index 349308b..2ea4388 100644 --- a/android/app/src/main/res/drawable/ic_launcher.xml +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/android/app/src/main/res/drawable/ic_palette.xml b/android/app/src/main/res/drawable/ic_palette.xml index bcafd88..26c33fb 100644 --- a/android/app/src/main/res/drawable/ic_palette.xml +++ b/android/app/src/main/res/drawable/ic_palette.xml @@ -1,5 +1,6 @@ - - - + + + + diff --git a/android/app/src/main/res/drawable/ic_record.xml b/android/app/src/main/res/drawable/ic_record.xml index cf1c302..c589cf0 100644 --- a/android/app/src/main/res/drawable/ic_record.xml +++ b/android/app/src/main/res/drawable/ic_record.xml @@ -1 +1,4 @@ - + + + + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml index ee1a3c2..7e07468 100644 --- a/android/app/src/main/res/drawable/ic_settings.xml +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -1,3 +1,8 @@ - + + + + + + diff --git a/android/app/src/main/res/drawable/ic_zoom.xml b/android/app/src/main/res/drawable/ic_zoom.xml index 1200161..d8a6435 100644 --- a/android/app/src/main/res/drawable/ic_zoom.xml +++ b/android/app/src/main/res/drawable/ic_zoom.xml @@ -1,4 +1,4 @@ - - + + diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..f4e19cf --- /dev/null +++ b/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 599e9c6..432d31d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.caching=true +kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official