From 376b8389ea42f7e08d15a8f98cf0c0f72ae9ca0e Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sat, 12 Sep 2026 02:30:05 +0800 Subject: [PATCH] android: fix launchMode (singleTask) root cause of reconnect storm; drop SET_CONFIGURATION reset; fix analysis temp units, NUC block size, viewer back keys --- android/app/src/main/AndroidManifest.xml | 14 +- .../kotlin/com/mag160c/thermal/media/Mdt.kt | 8 +- .../com/mag160c/thermal/media/Mp4Recorder.kt | 15 +- .../thermal/ui/analyze/AnalyzeViewModel.kt | 18 +- .../thermal/ui/analyze/AnalyzeViewer.kt | 5 + .../thermal/ui/gallery/PhotoViewerScreen.kt | 3 + .../mag160c/thermal/ui/live/LiveViewModel.kt | 233 +++++++++++++++++- .../com/mag160c/thermal/usb/IrSession.kt | 135 +++++++++- .../com/mag160c/thermal/usb/UsbTransport.kt | 21 +- .../thermal/media/PhotoNucMappingTest.kt | 40 +++ build-artifacts/mag160c-app-debug.apk | 4 +- docs/android_app/session_state.md | 67 +++++ 12 files changed, 527 insertions(+), 36 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b702ab4..811d170 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,19 @@ android:exported="true" android:screenOrientation="portrait" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" - android:resizeableActivity="true"> + android:resizeableActivity="true" + android:launchMode="singleTask"> + diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt index 101a70d..16a56bc 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -122,7 +122,13 @@ object Mdt { } text?.let { emit(BLOCK_TXT, it) } probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) } - nucPixels?.let { if (it.size == 38400) emit(BLOCK_NUC, it) } + // The NUC block is 1:1 with the photo, so its size follows the photo + // dimensions (240x320 = 153600 bytes for the standard rotation) — the + // first version only accepted exactly 38400 and silently DROPPED the + // block, which is why the analysis screen had no data to measure. + nucPixels?.let { + if (it.size >= 38400 && it.size % 2 == 0) emit(BLOCK_NUC, it) + } val bodyBytes = body.toByteArray() val header = ByteArray(0x88) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt index 8035d9f..16371a8 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt @@ -131,6 +131,7 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { synchronized(lock) { val enc = encoder val f = outPath + var drainError: String? = null try { if (enc != null) { val idx = enc.dequeueInputBuffer(10_000) @@ -140,7 +141,13 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { drainLocked(enc, true) } } catch (e: Exception) { - DebugLog.log("rec", "drain on stop failed: ${e.javaClass.simpleName}: ${e.message}") + // The codec can already be in an error state by the time recording + // stops (e.g. a surface frame lost during an FFC). Keeping the log + // explicit lets a later reader tell "the tail is truncated" from + // "the file is fine": the muxer below still finalises whatever was + // written, so the MP4 stays playable. + drainError = "${e.javaClass.simpleName}: ${e.message}" + DebugLog.log("rec", "drain on stop failed (file kept): $drainError") } runCatching { muxer?.stop() } runCatching { muxer?.release() } @@ -150,7 +157,11 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { encoder = null runCatching { inputSurface?.release() } inputSurface = null - DebugLog.log("rec", "stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}") + DebugLog.log( + "rec", + "stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}" + + (drainError?.let { " (drain error, tail may be short)" } ?: ""), + ) return f } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt index 7d4fbd0..8eb6b61 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewModel.kt @@ -101,12 +101,26 @@ class AnalyzeViewModel( probes.clear() probes.addAll(loaded) if (counts != null) { - _minTempC.value = mn / 1000f - _maxTempC.value = mx / 1000f + // mn/mx are NUC COUNTS — they must go through the temperature + // curve like any other sample. The first version divided them + // by 1000 instead, so the panel showed 9.2/10.4 C for a + // 22.6-32.9 C scene while the centre readout (which did use + // the curve) was right. + _minTempC.value = TempMath.countsToTempMc(mn) / 1000f + _maxTempC.value = TempMath.countsToTempMc(mx) / 1000f _minPos.value = mnPos _maxPos.value = mxPos _centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120) } + // Log what the panel shows: the Compose text is drawn on a canvas + // and never appears in the view hierarchy, so a field log is the + // only way to verify the numbers from adb. + com.mag160c.thermal.media.DebugLog.log( + "analyze", + "loaded ${bmp?.width}x${bmp?.height} measurable=$hasTemperatureData " + + "min=${_minTempC.value} max=${_maxTempC.value} " + + "center=${_centerTempC.value} probes=${loaded.size}", + ) } } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt index 85ce0db..2938bfe 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/analyze/AnalyzeViewer.kt @@ -84,6 +84,11 @@ fun AnalyzeViewer( LaunchedEffect(Unit) { vm.load() } + // System back leaves the viewer instead of exiting the app. The viewer is a + // full-screen overlay, so without this the user could not get out with the + // back gesture (reported on device). + androidx.activity.compose.BackHandler(enabled = true) { onClose() } + Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) { // ---- slim title row ---- Row( diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt index 0f89640..57eb1a3 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/PhotoViewerScreen.kt @@ -55,6 +55,9 @@ fun PhotoViewerScreen( vm.fullImage(item) { b -> bmp = b } } + // System back closes the viewer rather than leaving the app + androidx.activity.compose.BackHandler(enabled = true) { onClose() } + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { val b = bmp if (b != null) { 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 8a52404..1ef621f 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 @@ -121,6 +121,46 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { private val sessionListener = object : IrSession.Listener { override fun onStateChanged(state: IrSession.State, message: String?) { com.mag160c.thermal.media.DebugLog.log("vm", "session state $state msg=$message") + when (state) { + IrSession.State.STREAMING -> { + // healthy again: clear the retry backoff. + // + // NOTE: connectInFlightUi and the connect lock are deliberately + // NOT released here. IrSession reports STREAMING before its + // `running` flag is set, so clearing them now opened a window + // where BOTH guards read false and another ATTACHED broadcast + // started a second session — which tore down the live one + // ("stopping stale previous session"), resetting the camera into + // a re-enumeration cascade. They are released when frames + // actually flow (see onFrameReady) or when the attempt ends. + connectFailures = 0 + nextConnectAllowedMs = 0 + } + IrSession.State.ERROR -> { + connectInFlightUi = false + releaseConnectLock(generationOfAttempt) + connectFailures++ + val delayMs = minOf(10_000L, 500L shl minOf(connectFailures, 5)) + nextConnectAllowedMs = android.os.SystemClock.elapsedRealtime() + delayMs + com.mag160c.thermal.media.DebugLog.log( + "vm", "next connect in ${delayMs} ms (failures=$connectFailures)", + ) + // Exactly one retry may be pending at a time. Retrying is what + // recovers a camera that rebooted itself; without it the UI sat + // on "no_handshake" forever after a single failure. + if (retryJob?.isActive != true) { + retryJob = viewModelScope.launch { + kotlinx.coroutines.delay(delayMs) + if (!session.isStreaming()) connect() + } + } + } + IrSession.State.IDLE -> { + connectInFlightUi = false + releaseConnectLock(generationOfAttempt) + } + else -> {} // LINKING: an attempt is under way + } _state.value = _state.value.copy( connected = state == IrSession.State.STREAMING, streaming = state == IrSession.State.STREAMING, @@ -131,6 +171,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { override fun onFrameReady(argb: IntArray) { if (framesRcvd == 0) { com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI") + // frames are flowing: the attempt truly succeeded, so a later + // ATTACHED broadcast may start a fresh attempt if this one dies + connectInFlightUi = false + releaseConnectLock(generationOfAttempt) } framesRcvd++ latestFrame = argb @@ -151,9 +195,34 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { init { session.setListener(sessionListener) val ctx = getApplication() - // auto permission + start when the camera is plugged in while running + // Auto-connect when the CAMERA is plugged in. The first version reacted to + // every ACTION_USB_DEVICE_ATTACHED without checking the vendor: on MIUI the + // system re-broadcasts attach events (and other USB devices appear), so a + // healthy stream kept being "reconfigured" — the heartbeat log showed the + // state flapping between streaming and no_device about once a second while + // frames were arriving normally. usbReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { + val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i) + val vid = dev?.vendorId ?: 0 + if (vid != 0x833C) { + // some other USB device (or an event we cannot attribute): + // never disturb the camera session for it + return + } + com.mag160c.thermal.media.DebugLog.log( + "vm", "usb attached vid=0x%04X".format(java.util.Locale.US, vid), + ) + // The camera is already there and working: this broadcast is not + // news. MIUI repeats ATTACHED roughly every 2 s for a device that + // is simply plugged in (measured on device), and acting on each one + // tore down a healthy stream over and over. + if (session.isStreaming() || connectInFlightUi) { + com.mag160c.thermal.media.DebugLog.log( + "vm", "attach ignored: session already up/starting", + ) + return + } connect() } } @@ -165,15 +234,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { // camera re-enumerates (power blip / reboot): release the dead session usbDetachReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { - val dev: android.hardware.usb.UsbDevice? = if (android.os.Build.VERSION.SDK_INT >= 33) { - i?.getParcelableExtra( - android.hardware.usb.UsbManager.EXTRA_DEVICE, - android.hardware.usb.UsbDevice::class.java, - ) - } else { - @Suppress("DEPRECATION") - i?.getParcelableExtra(android.hardware.usb.UsbManager.EXTRA_DEVICE) - } + val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i) com.mag160c.thermal.media.DebugLog.log( "vm", "usb detached vid=0x%04X".format(java.util.Locale.US, dev?.vendorId ?: 0), ) @@ -222,6 +283,83 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { // ---- LAN remote preview host (Phase F) ---- + /** + * Backoff after failed connects. On the device, a dead endpoint made every + * attempt fail instantly, and the attach broadcast + UI effect kept calling + * connect() — the log showed ~10 attempts/second at 107% CPU. Each failure + * now doubles the wait (up to 10 s), so a broken camera costs a retry, not a + * spin loop. A success resets it. + */ + private var connectFailures = 0 + private var nextConnectAllowedMs = 0L + + /** + * Monotonic attempt id. The USB permission callback fires LATER and is not + * covered by the debounce above, so a stale callback (from an attempt that + * has since been superseded) used to call session.start() directly — that is + * how the device ended up with several sessions racing. Only the newest + * attempt may start. + */ + private var connectGeneration = 0 + + /** + * True from the moment a connect is kicked off until its session is up or it + * has failed. [IrSession.isStreaming] only becomes true once frames flow, so + * without this the repeated ATTACHED broadcasts slip through the gap (measured: + * the state went LINKING -> STREAMING -> torn down by the next broadcast, + * forever, never stabilising). + */ + @Volatile + private var connectInFlightUi = false + + /** + * One retry job at a time. Without this, every failure scheduled another + * retry while older ones were still pending, so several connect() calls ran + * concurrently, each fighting for the same USB device (device log showed + * "GetParameter1 write=-1" from two different threads at the same instant). + */ + private var retryJob: kotlinx.coroutines.Job? = null + + /** + * Serialises connect attempts for the whole process. + * + * The flags above cannot fully cover it: the guard is tested on the main + * thread, connect() then suspends on permission/USB work, and the ATTACHED + * broadcast is delivered on the same main thread — so an interleaving still + * got through and two sessions raced for the camera (device log showed three + * concurrent LINKING/STREAMING sequences, each ending in no_handshake and a + * re-enumeration). A mutex makes the whole attempt atomic. + * + * Held from the start of an attempt until it RESOLVES (first frame, error, or + * permission refusal) — see [connectOwnerGeneration]. + */ + private val connectMutex = kotlinx.coroutines.sync.Mutex() + + /** + * Generation of the attempt that owns [connectMutex]. A stale attempt's ERROR + * (which arrives after a newer attempt has already started) must NOT release + * the lock that the newer attempt now holds — releasing it was exactly how the + * reconnect storm kept restarting. + */ + @Volatile + private var connectOwnerGeneration = -1 + + /** Release the lock only if [generation] still owns it. */ + private fun releaseConnectLock(generation: Int) { + if (generation == connectOwnerGeneration && connectMutex.isLocked) { + connectOwnerGeneration = -1 + runCatching { connectMutex.unlock() } + } + } + + /** + * Generation of the attempt the SESSION events belong to. Set when an attempt + * starts and used by the listener so that a late ERROR/IDLE from an older + * attempt cannot release a lock now owned by a newer one. + */ + @Volatile + private var generationOfAttempt = -1 + /** Begin USB permission flow, then start streaming. No device -> idle notice. */ fun connect() { val now = android.os.SystemClock.elapsedRealtime() @@ -229,46 +367,109 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced") return } + if (now < nextConnectAllowedMs) { + com.mag160c.thermal.media.DebugLog.log( + "vm", + "connect() backed off for ${(nextConnectAllowedMs - now)} ms " + + "(failures=$connectFailures)", + ) + return + } lastConnectMs = now + // MIUI broadcasts ATTACHED several times within milliseconds (measured: + // 3 events in 60 ms). Each one used to build a NEW session, and starting a + // session takes seconds, so they all raced. Refuse to start another while + // one is already up; the in-flight case is covered by IrSession's gate. + if (session.isStreaming()) { + com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: already streaming") + return + } + if (connectInFlightUi) { + com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: attempt in flight") + return + } + connectInFlightUi = true + val generation = ++connectGeneration + // Hold the lock until the attempt RESOLVES (streaming or failed), not just + // until this function returns: the USB permission callback is asynchronous, + // so releasing early let a second attempt start while the first was still + // working — three sessions then raced for the camera and every one ended in + // no_handshake, sending the device into a re-enumeration loop. + if (!connectMutex.tryLock()) { + com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: attempt already running") + connectInFlightUi = false + return + } + connectOwnerGeneration = generation + generationOfAttempt = generation try { com.mag160c.thermal.media.DebugLog.log("vm", "connect()") val context = getApplication() val transport = com.mag160c.thermal.usb.UsbTransport(context) val dev = transport.findDevice() if (dev == null) { + // do NOT report no_device while a session is running: the device + // list can transiently miss the camera during a re-enumeration, + // and overwriting the status made the UI claim "no camera" while + // frames were arriving (seen on device) + connectInFlightUi = false + if (session.isStreaming()) { + com.mag160c.thermal.media.DebugLog.log( + "vm", "no_device ignored: a session is already streaming", + ) + return + } com.mag160c.thermal.media.DebugLog.log("vm", "no_device") _state.value = _state.value.copy( connected = false, streaming = false, status = "no_device", ) + releaseConnectLock(generation) return } transport.requestPermission { ok -> try { + if (generation != connectGeneration) { + // superseded by a newer attempt: drop this callback + com.mag160c.thermal.media.DebugLog.log( + "vm", "stale permission callback (gen=$generation) ignored", + ) + return@requestPermission + } if (ok) { val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() } .getOrDefault(ByteArray(0)) com.mag160c.thermal.media.DebugLog.log( "vm", "permission ok, ddt ${ddt.size} bytes, starting session", ) + // NOTE: the connect lock stays held here — the session is + // only really up once frames arrive (onFrameReady releases + // it) or it fails (the ERROR branch releases it). session.start(ddt) } else { com.mag160c.thermal.media.DebugLog.log("vm", "permission denied") _state.value = _state.value.copy(connected = false, status = "no_permission") + connectInFlightUi = false + releaseConnectLock(generation) } } catch (e: Exception) { com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e") _state.value = _state.value.copy(connected = false, status = "connect_fail") + connectInFlightUi = false + releaseConnectLock(generation) } } } catch (e: Exception) { // logging/USB must never kill the UI (round-12 crash fix) com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e") _state.value = _state.value.copy(connected = false, status = "connect_fail") + connectMutex.unlock() + connectInFlightUi = false } } + /** Release the connect lock once the attempt has resolved. */ fun disconnect() = session.stop() fun triggerFfc() = session.triggerFfc() @@ -590,4 +791,16 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { session.destroy() super.onCleared() } + + /** Attach/detach intent -> USB device (older and newer API shapes). */ + private fun usbDeviceFrom(i: android.content.Intent?): android.hardware.usb.UsbDevice? = + if (android.os.Build.VERSION.SDK_INT >= 33) { + i?.getParcelableExtra( + android.hardware.usb.UsbManager.EXTRA_DEVICE, + android.hardware.usb.UsbDevice::class.java, + ) + } else { + @Suppress("DEPRECATION") + i?.getParcelableExtra(android.hardware.usb.UsbManager.EXTRA_DEVICE) + } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index 8c38fe9..2ae32b2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -74,6 +74,14 @@ class IrSession(context: Context) { private const val MIN_CALI_LEN = 65536 private const val MAX_CALI_LEN = 104857600 private const val CALI_NO_DATA_LIMIT_MS = 5000 + + /** + * Consecutive failed stream reads before the session gives up. With the + * backoff schedule this is roughly a minute of a dead link — long enough + * to survive a camera reboot, short enough that a zombie session does not + * block the next connection. + */ + private const val FAILURE_LIMIT = 2000 } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -82,6 +90,10 @@ class IrSession(context: Context) { private var listener: Listener? = null private var pipeline: RenderPipeline? = null private val running = AtomicBoolean(false) + + /** True while the stream reader thread is alive (for hand-over waits). */ + private val loopRunning = AtomicBoolean(false) + private var streaming = false private var identity = CameraIdentity(1, 0, 160, 120, 15) @@ -121,9 +133,22 @@ class IrSession(context: Context) { fun identitySnapshot(): CameraIdentity = identity.copy() - /** Connect + start the live stream. Must be called after USB permission. */ + /** + * Connect + start the live stream. Must be called after USB permission. + * + * Re-entrancy: the guard used to be [running], but that flag is only set once + * the stream is up — so a FAILING connect could be re-entered without limit. + * On the real device that produced a reconnect storm (~10 sessions/second, + * 107% CPU): each attempt failed on a dead endpoint, the caller retried, and + * the previous reader thread was still spinning. A dedicated flag now covers + * the whole connect attempt, and a failed attempt must clear it. + */ fun start(ddtBytes: ByteArray) { if (running.get()) return + if (!connectInFlight.compareAndSet(false, true)) { + DebugLog.log("session", "start ignored: a connect attempt is already in flight") + return + } scope.launch { try { startInternal(ddtBytes) @@ -133,11 +158,26 @@ class IrSession(context: Context) { transport.close() running.set(false) notify(State.ERROR, "exception:${e.javaClass.simpleName}") + } finally { + // cleared when the stream loop takes over (or the attempt failed), + // so the next genuine connect is not blocked + if (!running.get()) connectInFlight.set(false) } } } + /** True while a connect attempt is in flight (guards against storms). */ + private val connectInFlight = AtomicBoolean(false) + private fun startInternal(bundledDdt: ByteArray) { + // Never disturb a session that is already streaming. Callers can be + // trigger-happy (Android re-broadcasts ATTACHED while the camera is simply + // plugged in), and replacing a working session sends the camera through a + // reset/re-enumeration for no reason. + if (running.get()) { + DebugLog.log("session", "start ignored: already streaming") + return + } notify(State.LINKING, null) // one camera, one owner: a stale session holding the device would // otherwise be robbed by our claimInterface and both would stall @@ -145,6 +185,12 @@ class IrSession(context: Context) { if (prev !== this) { DebugLog.log("session", "stopping stale previous session") prev.stop() + // WAIT for the old reader to actually exit before claiming the + // camera. stop() only flips a flag, and with the new backoff the + // old loop may still be sleeping — a second live reader on the + // same endpoints makes both fail (observed on device: one healthy + // stream plus one "no data yet" zombie reporting forever). + prev.awaitStopped(2000) } } val dev = transport.findDevice() @@ -263,6 +309,8 @@ class IrSession(context: Context) { notify(State.STREAMING, null) notifyIdentity() running.set(true) + // the stream loop now owns the session lifecycle; connectInFlight is + // released in start()'s finally because running is set by then Thread.sleep(50) if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) { DebugLog.log("session", "START write failed") @@ -396,13 +444,23 @@ class IrSession(context: Context) { listener?.onIdentity(identitySnapshot()) } - /** Log endpoint status + clear a possible halt (usbfs timeout artifact). */ - private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int, failureCount: Int = 0) { + /** + * Log endpoint status + clear a possible halt (usbfs timeout artifact). + * Two control transfers per call, so a caller polling a dead endpoint must + * pass [quiet] to stop the log spam (the transfers still happen — clearing a + * halt is the only way back). + */ + private fun diagnoseEndpoint( + conn: UsbDeviceConnection, + epAddr: Int, + failureCount: Int = 0, + quiet: Boolean = false, + ) { val st = ByteArray(2) val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100) val halted = if (src == 2) (st[0].toInt() and 0x01) else -1 val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100) - if (failureCount <= 5 || failureCount % 100 == 0) { + if (!quiet && (failureCount <= 5 || failureCount % 100 == 0)) { DebugLog.log( "usb", "ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format( @@ -476,6 +534,7 @@ class IrSession(context: Context) { epResp: UsbEndpoint, ) { val conn: UsbDeviceConnection = transport.connection() ?: return + loopRunning.set(true) val stream = FrameStream(38400) val frameBuf = ByteArray(0x38 + 38400) val out = IntArray(320 * 240) @@ -487,6 +546,7 @@ class IrSession(context: Context) { var frameCount = 0 var renderCount = 0 var timeouts = 0 + var consecutiveFailures = 0 val t0 = android.os.SystemClock.elapsedRealtime() var lastLog = t0 var firstReads = 0 @@ -496,24 +556,59 @@ class IrSession(context: Context) { val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS) if (n <= 0) { timeouts++ - // usbfs marks the endpoint halted after a timed-out transfer; - // every later transfer then fails instantly until cleared. - diagnoseEndpoint(conn, epStream.address, timeouts) + consecutiveFailures++ + // A halted/dead endpoint makes bulkTransfer fail INSTANTLY (the + // 800 ms timeout is not spent), so this loop would spin at ~1000 + // iterations/second, each doing two control transfers inside + // diagnoseEndpoint — measured on a real device: 28000 "fail#" + // lines and 136% CPU while the camera was re-enumerating. Back + // off: try hard to recover for the first few attempts, then poll + // slowly (the reader keeps running so a replugged camera is + // picked up, but it no longer burns the battery). + val backoffMs = when { + consecutiveFailures <= 3 -> 0L // immediate: may be a stale halt + consecutiveFailures <= 100 -> 20L // fast recovery window (~2 s) + else -> 250L // dead link: idle poll + } + // Only run the (costly) endpoint diagnosis while it can help + diagnoseEndpoint( + conn, epStream.address, timeouts, + quiet = consecutiveFailures > 100, + ) + if (backoffMs > 0) { + try { + Thread.sleep(backoffMs) + } catch (_: InterruptedException) { + } + } val now = android.os.SystemClock.elapsedRealtime() if (readCount == 0 && now - t0 > 10000 && !noDataNotified) { noDataNotified = true notify(State.STREAMING, "no_stream_data") } + // Give up on a link that has been dead for a full minute: the + // camera is gone (or wedged), and a zombie session holding the + // device would block a fresh one after replug. + if (consecutiveFailures == FAILURE_LIMIT) { + DebugLog.log( + "stream", + "giving up after $consecutiveFailures consecutive failures " + + "(${now - t0} ms) -> closing session", + ) + notify(State.ERROR, "stream_dead") + break + } if (now - lastLog >= 2000) { DebugLog.log( "stream", "stats: reads=$readCount frames=$frameCount rendered=$renderCount " + - "timeouts=$timeouts (no data yet)", + "timeouts=$timeouts consecutive=$consecutiveFailures (no data yet)", ) lastLog = now } continue } + consecutiveFailures = 0 readCount++ if (firstReads < 3) { DebugLog.log( @@ -576,6 +671,11 @@ class IrSession(context: Context) { if (active === this) active = null sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg") transport.close() + // release the connect gate so a fresh session (e.g. after a replug or a + // recovered camera) is not blocked by this finished one + running.set(false) + connectInFlight.set(false) + loopRunning.set(false) notify(State.IDLE, null) } @@ -616,6 +716,25 @@ class IrSession(context: Context) { // (StopTransferImg -> close -> IDLE) } + /** + * Block until the reader loop has finished (or [timeoutMs] elapses). + * Used when a new session takes over: the old reader must be gone before the + * new one claims the endpoints, otherwise both compete for the same URBs. + */ + private fun awaitStopped(timeoutMs: Long) { + val deadline = android.os.SystemClock.elapsedRealtime() + timeoutMs + while (loopRunning.get() && android.os.SystemClock.elapsedRealtime() < deadline) { + try { + Thread.sleep(20) + } catch (_: InterruptedException) { + return + } + } + if (loopRunning.get()) { + DebugLog.log("session", "previous reader did not exit within ${timeoutMs} ms") + } + } + fun destroy() { stop() scope.cancel() diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt index 9fe4e48..883c425 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/UsbTransport.kt @@ -108,16 +108,17 @@ class UsbTransport(private val context: Context) { "usb", "claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}", ) - // Prefer configuration 2 when the device exposes it (vendor behavior). - if (dev.configurationCount > 1) { - val cfg = dev.getConfiguration(1) - // USB SET_CONFIGURATION request = 9 - val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500) - com.mag160c.thermal.media.DebugLog.log( - "usb", - "setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc", - ) - } + // DO NOT send SET_CONFIGURATION here. + // + // This used to "prefer configuration 2 (vendor behavior)" by issuing a + // USB SET_CONFIGURATION control request right after claiming the + // interface. That is a device-level reset: it invalidates the claim and + // makes the camera re-enumerate. Measured on a real phone: with the app + // stopped the camera sat at /dev/bus/usb/002/071 for 30 s; the moment the + // app started, the device number climbed 071 -> 077 while every command + // failed (write=-1). The official app never sends it either — its + // UsbCommunication only calls claimInterface (see + // analysis/sdk_re/android_app/jadx_magcx/.../UsbCommunication.java:234). return true } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt index d7f1445..cf22e29 100644 --- a/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt +++ b/android/app/src/test/kotlin/com/mag160c/thermal/media/PhotoNucMappingTest.kt @@ -120,6 +120,46 @@ class PhotoNucMappingTest { for (i in counts.indices) assertEquals("sample $i", counts[i], back[i]) } + /** + * Regression for the on-device failure (2026-09-12): the real NUC block is + * 1:1 with the PHOTO (240x320 = 153600 B), but compose() accepted only exactly + * 38400 bytes and silently dropped anything else — so every photo taken with + * the live orientation came out unmeasurable while the capture log still said + * "nuc=yes". + */ + @Test + fun fullSizePhotoNucBlockIsStored() { + val photoCounts = PhotoSaver.buildPhotoOrderedCounts( + IntArray(19200) { it }, PhotoSaver.Orientation(90, false, false), + ) + assertEquals("240x320 photo", 240 * 320, photoCounts.size) + val bytes = PhotoSaver.countsToBytes(photoCounts) + assertEquals("two bytes per photo pixel", 153600, bytes.size) + + val mdt = Mdt.compose( + jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()), + info0 = null, info1 = null, framePixels = ByteArray(38400), + nucPixels = bytes, + ) + val parsed = Mdt.parse(mdt)!! + assertTrue( + "a 153600-byte block must be stored, not silently dropped", + parsed.hasTemperatureData, + ) + assertEquals(153600, parsed.nucPixels!!.size) + } + + @Test + fun oddSizedNucBlocksAreRejected() { + // a malformed length would corrupt the u16 lookup; it must not be stored + val mdt = Mdt.compose( + jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()), + info0 = null, info1 = null, framePixels = null, + nucPixels = ByteArray(100), + ) + assertTrue("too small -> dropped", !Mdt.parse(mdt)!!.hasTemperatureData) + } + @Test fun photosWithoutNucAreReportedAsUnmeasurable() { // older files (and plain captures) must NOT be silently measurable: the diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 9b576d5..67c957c 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:a43b3960e41a04d0f8bf2c89f95559e532d4698ff7733fa734555b8cd7ee059a -size 12663188 +oid sha256:50e9db722933bab62525241b1d2fe912f93a2c65c1b6a8c8729d9d9ebf2b0567 +size 12764891 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 044ecc5..22d5cd8 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -489,6 +489,73 @@ "未连接"分支显示,正常出图时用户看不到任何反馈)。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 +## 用户反馈修复 第二十一轮(2026-09-12,无线 adb 真机调试:根因是 launchMode) + +**本轮最大的发现**:前几轮反复出现的"连接风暴/重连循环/相机每 2 秒重枚举" +(设备号 060→093 一路涨、CPU 136%、状态在 streaming/no_device 间抖动) +**根因是 `MainActivity` 少了 `android:launchMode="singleTask"`**。 + +- Manifest 给 MainActivity 声明了 `USB_DEVICE_ATTACHED` intent-filter,而 + **MIUI 在相机插着时会持续重复广播该事件**。默认 `standard` 启动模式下, + 每次广播都创建一个**新的 MainActivity 实例**——真机 `dumpsys` 实测同时存在 + **2 个实例**,各自持有自己的 ViewModel、IrSession、广播接收器,全部争抢同一台 + 相机;谁被挤掉谁就重新握手,于是相机被反复复位→重枚举。 +- 官方 App 的 manifest 正是 `launchMode=2 (singleTask)`(aapt 实测)。 +- 加上 `singleTask` 后实测:Activity 实例 1 个、**设备号 65 恒定 40 秒不变**、 + 状态稳定 streaming、fps 15.1、CPU 归零。 +- 排查过程中曾按症状加过多层防御(connect 互斥锁、代际守卫、attach 去重、 + 失败退避+自动重试、stream 循环退避)。这些**已一并保留**:它们各自修掉了 + 真实存在的小问题(见下),但都不是那个根因;根因只有 launchMode 一处。 + +**本轮真机(小米 22041211AC / Android 12 / MIUI)实测通过的功能**: + +- [x] 出流:冷启动一次成功,15fps、ref=true、timeouts=0 +- [x] **拍照**:`capture: nuc=yes rot=90 saved=true`;文件入 `DCIM/MAG160C/` +- [x] **照片含正确温度数据**:NUC 块 153600B / 76800 样本(照片分辨率 1:1)、 + 零空洞、温度 22.6~32.9℃(均值 25.9℃)——不再有 -161℃/145℃ +- [x] **录像**:开始→停止**不闪退**,MP4 2.2MB / 134 帧,`moov` box 完整可播 +- [x] **分析页温度正确**:`min=22.606 max=32.877 center=24.683`, + 与文件真值**逐位吻合** +- [x] **分析页加测温点 + 另存新照片**:生成 `_edit.jpg`,含 PROBES 块 + (`116,102,Pt1,24674` / `150,169,Pt2,24838`,数值合理)+ NUC 块保留 +- [x] **相册**:列出 4 张 → 点开全屏查看器(返回/删除)→ 返回键回列表 → + 删除确认对话框 → 删除后文件 4→3、列表同步 +- [x] **分析 tab 只列可测温照片**(旧的无 NUC 照片被过滤,符合设计) + +**本轮顺带修掉的真实缺陷**(都在真机上复现过): + +1. `UsbTransport.open()` 在 `claimInterface` 之后发 **SET_CONFIGURATION**—— + 这是设备级复位,会让相机立刻重新枚举。官方代码从不发(仅 claimInterface)。 + **已删除**。(实测:app 停止时相机 30 秒稳定不动,启动后设备号立刻开始爬升。) +2. 分析页 `_minTempC.value = mn / 1000f`——`mn` 是 NUC **counts** 却被当温度, + 显示 9.2/10.4℃(centre 走了正确路径所以是对的)。已改为过温度曲线。 +3. `Mdt.compose` 只接受 `nucPixels.size == 38400`,而真实块是照片分辨率 + (153600)→ **静默丢弃**,而 capture 日志仍打 `nuc=yes` 掩盖了它。 + 已改为按尺寸下限校验。 +4. 三个全屏查看器(相册查看器、分析查看器)**都没有 BackHandler**—— + 返回键直接退出 app。已补。 +5. USB attach 广播未校验 VID,任何 USB 事件都会触发 `connect()`;MIUI 重复 + 广播时把健康会话的状态覆盖成 `no_device`(画面在跑却显示"未检测到热像仪")。 + 已改为只认 0x833C 且已有会话时忽略。 +6. `streamLoop` 在端点 halt 后 `bulkTransfer` **立即返回失败**(不等超时), + 循环以约 1000 次/秒空转、每次两次控制传输——实测 28000 条失败日志、 + **CPU 136%**。已加三级退避(0/20/250ms)+ 日志静默 + 死链约 1 分钟后收尾。 +7. `connect()` 失败后无人重试(backoff 只拦截、不重发),UI 会永远停在 + `no_handshake`。已加单实例自动重试。 +8. `Mp4Recorder.stop()` 的收尾 drain 异常现在被记录说明(`file kept`), + 不再让人误以为文件没生成;实测该异常下 MP4 仍完整可播。 + +**调试方法记录**(供后续排查): +- 设备端日志:`/sdcard/Download/MAG160C/debug_*.log.txt`(DCIM 被 MIUI 拒收 + text/plain,自动回退到 Download),可 `adb pull` 取回 +- MIUI 禁止 adb 注入输入(`INJECT_EVENTS`),但设备有 **Magisk root**: + `adb shell su -c 'input tap X Y'` 可以,`uiautomator dump` 配合读控件坐标 +- 相机是否在重枚举:`dumpsys usb | grep -oE 'bus/usb/002/[0-9]+'` 连续采样看 + 设备号是否变化——这是判断"是硬件问题还是我们代码问题"的最快手段 + (app 停止时稳定、启动后爬升 = 我们的代码在复位设备) +- Activity 实例数:`dumpsys activity activities | grep 'Activities=\['` + 出现两个 MainActivity = launchMode 问题 + ## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面) 用户第三轮实机测试(含截图)报出以下问题,本轮全部处理: