android: fix launchMode (singleTask) root cause of reconnect storm; drop SET_CONFIGURATION reset; fix analysis temp units, NUC block size, viewer back keys

This commit is contained in:
ZXCLI
2026-09-12 02:30:05 +08:00
parent 4cb6e69ea4
commit 376b8389ea
12 changed files with 527 additions and 36 deletions
+13 -1
View File
@@ -28,7 +28,19 @@
android:exported="true" android:exported="true"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true"> android:resizeableActivity="true"
android:launchMode="singleTask">
<!-- launchMode=singleTask is REQUIRED here, not cosmetic.
This activity declares a USB_DEVICE_ATTACHED intent filter, and
MIUI re-broadcasts that attach event continuously while the camera
is plugged in. With the default (standard) mode each broadcast
started ANOTHER MainActivity instance — the device had two live
instances, each with its own ViewModel, IrSession and broadcast
receivers, all fighting over the same camera. That was the real
cause of the endless connect storm, the reconnect loop and the
camera re-enumerating every couple of seconds (device number
climbing 060 -> 093 in one session). The official app uses
launchMode=2 (singleTask) for exactly this reason. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -122,7 +122,13 @@ object Mdt {
} }
text?.let { emit(BLOCK_TXT, it) } text?.let { emit(BLOCK_TXT, it) }
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, 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 bodyBytes = body.toByteArray()
val header = ByteArray(0x88) val header = ByteArray(0x88)
@@ -131,6 +131,7 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
synchronized(lock) { synchronized(lock) {
val enc = encoder val enc = encoder
val f = outPath val f = outPath
var drainError: String? = null
try { try {
if (enc != null) { if (enc != null) {
val idx = enc.dequeueInputBuffer(10_000) 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) drainLocked(enc, true)
} }
} catch (e: Exception) { } 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?.stop() }
runCatching { muxer?.release() } runCatching { muxer?.release() }
@@ -150,7 +157,11 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
encoder = null encoder = null
runCatching { inputSurface?.release() } runCatching { inputSurface?.release() }
inputSurface = null 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 return f
} }
} }
@@ -101,12 +101,26 @@ class AnalyzeViewModel(
probes.clear() probes.clear()
probes.addAll(loaded) probes.addAll(loaded)
if (counts != null) { if (counts != null) {
_minTempC.value = mn / 1000f // mn/mx are NUC COUNTS — they must go through the temperature
_maxTempC.value = mx / 1000f // 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 _minPos.value = mnPos
_maxPos.value = mxPos _maxPos.value = mxPos
_centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120) _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}",
)
} }
} }
} }
@@ -84,6 +84,11 @@ fun AnalyzeViewer(
LaunchedEffect(Unit) { vm.load() } 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))) { Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
// ---- slim title row ---- // ---- slim title row ----
Row( Row(
@@ -55,6 +55,9 @@ fun PhotoViewerScreen(
vm.fullImage(item) { b -> bmp = b } 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)) { Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
val b = bmp val b = bmp
if (b != null) { if (b != null) {
@@ -121,6 +121,46 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private val sessionListener = object : IrSession.Listener { private val sessionListener = object : IrSession.Listener {
override fun onStateChanged(state: IrSession.State, message: String?) { override fun onStateChanged(state: IrSession.State, message: String?) {
com.mag160c.thermal.media.DebugLog.log("vm", "session state $state msg=$message") 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( _state.value = _state.value.copy(
connected = state == IrSession.State.STREAMING, connected = state == IrSession.State.STREAMING,
streaming = state == IrSession.State.STREAMING, streaming = state == IrSession.State.STREAMING,
@@ -131,6 +171,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
override fun onFrameReady(argb: IntArray) { override fun onFrameReady(argb: IntArray) {
if (framesRcvd == 0) { if (framesRcvd == 0) {
com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI") 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++ framesRcvd++
latestFrame = argb latestFrame = argb
@@ -151,9 +195,34 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
init { init {
session.setListener(sessionListener) session.setListener(sessionListener)
val ctx = getApplication<Application>() val ctx = getApplication<Application>()
// 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() { usbReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { 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() connect()
} }
} }
@@ -165,15 +234,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
// camera re-enumerates (power blip / reboot): release the dead session // camera re-enumerates (power blip / reboot): release the dead session
usbDetachReceiver = object : android.content.BroadcastReceiver() { usbDetachReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { 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) { val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i)
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)
}
com.mag160c.thermal.media.DebugLog.log( com.mag160c.thermal.media.DebugLog.log(
"vm", "usb detached vid=0x%04X".format(java.util.Locale.US, dev?.vendorId ?: 0), "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) ---- // ---- 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. */ /** Begin USB permission flow, then start streaming. No device -> idle notice. */
fun connect() { fun connect() {
val now = android.os.SystemClock.elapsedRealtime() 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") com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced")
return return
} }
if (now < nextConnectAllowedMs) {
com.mag160c.thermal.media.DebugLog.log(
"vm",
"connect() backed off for ${(nextConnectAllowedMs - now)} ms " +
"(failures=$connectFailures)",
)
return
}
lastConnectMs = now 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 { try {
com.mag160c.thermal.media.DebugLog.log("vm", "connect()") com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
val context = getApplication<Application>() val context = getApplication<Application>()
val transport = com.mag160c.thermal.usb.UsbTransport(context) val transport = com.mag160c.thermal.usb.UsbTransport(context)
val dev = transport.findDevice() val dev = transport.findDevice()
if (dev == null) { 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") com.mag160c.thermal.media.DebugLog.log("vm", "no_device")
_state.value = _state.value.copy( _state.value = _state.value.copy(
connected = false, connected = false,
streaming = false, streaming = false,
status = "no_device", status = "no_device",
) )
releaseConnectLock(generation)
return return
} }
transport.requestPermission { ok -> transport.requestPermission { ok ->
try { 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) { if (ok) {
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() } val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
.getOrDefault(ByteArray(0)) .getOrDefault(ByteArray(0))
com.mag160c.thermal.media.DebugLog.log( com.mag160c.thermal.media.DebugLog.log(
"vm", "permission ok, ddt ${ddt.size} bytes, starting session", "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) session.start(ddt)
} else { } else {
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied") com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
_state.value = _state.value.copy(connected = false, status = "no_permission") _state.value = _state.value.copy(connected = false, status = "no_permission")
connectInFlightUi = false
releaseConnectLock(generation)
} }
} catch (e: Exception) { } catch (e: Exception) {
com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e") com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e")
_state.value = _state.value.copy(connected = false, status = "connect_fail") _state.value = _state.value.copy(connected = false, status = "connect_fail")
connectInFlightUi = false
releaseConnectLock(generation)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
// logging/USB must never kill the UI (round-12 crash fix) // logging/USB must never kill the UI (round-12 crash fix)
com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e") com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e")
_state.value = _state.value.copy(connected = false, status = "connect_fail") _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 disconnect() = session.stop()
fun triggerFfc() = session.triggerFfc() fun triggerFfc() = session.triggerFfc()
@@ -590,4 +791,16 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
session.destroy() session.destroy()
super.onCleared() 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)
}
} }
@@ -74,6 +74,14 @@ class IrSession(context: Context) {
private const val MIN_CALI_LEN = 65536 private const val MIN_CALI_LEN = 65536
private const val MAX_CALI_LEN = 104857600 private const val MAX_CALI_LEN = 104857600
private const val CALI_NO_DATA_LIMIT_MS = 5000 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) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -82,6 +90,10 @@ class IrSession(context: Context) {
private var listener: Listener? = null private var listener: Listener? = null
private var pipeline: RenderPipeline? = null private var pipeline: RenderPipeline? = null
private val running = AtomicBoolean(false) 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 streaming = false
private var identity = CameraIdentity(1, 0, 160, 120, 15) private var identity = CameraIdentity(1, 0, 160, 120, 15)
@@ -121,9 +133,22 @@ class IrSession(context: Context) {
fun identitySnapshot(): CameraIdentity = identity.copy() 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) { fun start(ddtBytes: ByteArray) {
if (running.get()) return if (running.get()) return
if (!connectInFlight.compareAndSet(false, true)) {
DebugLog.log("session", "start ignored: a connect attempt is already in flight")
return
}
scope.launch { scope.launch {
try { try {
startInternal(ddtBytes) startInternal(ddtBytes)
@@ -133,11 +158,26 @@ class IrSession(context: Context) {
transport.close() transport.close()
running.set(false) running.set(false)
notify(State.ERROR, "exception:${e.javaClass.simpleName}") 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) { 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) notify(State.LINKING, null)
// one camera, one owner: a stale session holding the device would // one camera, one owner: a stale session holding the device would
// otherwise be robbed by our claimInterface and both would stall // otherwise be robbed by our claimInterface and both would stall
@@ -145,6 +185,12 @@ class IrSession(context: Context) {
if (prev !== this) { if (prev !== this) {
DebugLog.log("session", "stopping stale previous session") DebugLog.log("session", "stopping stale previous session")
prev.stop() 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() val dev = transport.findDevice()
@@ -263,6 +309,8 @@ class IrSession(context: Context) {
notify(State.STREAMING, null) notify(State.STREAMING, null)
notifyIdentity() notifyIdentity()
running.set(true) 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) Thread.sleep(50)
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) { if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
DebugLog.log("session", "START write failed") DebugLog.log("session", "START write failed")
@@ -396,13 +444,23 @@ class IrSession(context: Context) {
listener?.onIdentity(identitySnapshot()) 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 st = ByteArray(2)
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100) val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1 val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100) 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( DebugLog.log(
"usb", "usb",
"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format( "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, epResp: UsbEndpoint,
) { ) {
val conn: UsbDeviceConnection = transport.connection() ?: return val conn: UsbDeviceConnection = transport.connection() ?: return
loopRunning.set(true)
val stream = FrameStream(38400) val stream = FrameStream(38400)
val frameBuf = ByteArray(0x38 + 38400) val frameBuf = ByteArray(0x38 + 38400)
val out = IntArray(320 * 240) val out = IntArray(320 * 240)
@@ -487,6 +546,7 @@ class IrSession(context: Context) {
var frameCount = 0 var frameCount = 0
var renderCount = 0 var renderCount = 0
var timeouts = 0 var timeouts = 0
var consecutiveFailures = 0
val t0 = android.os.SystemClock.elapsedRealtime() val t0 = android.os.SystemClock.elapsedRealtime()
var lastLog = t0 var lastLog = t0
var firstReads = 0 var firstReads = 0
@@ -496,24 +556,59 @@ class IrSession(context: Context) {
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS) val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
if (n <= 0) { if (n <= 0) {
timeouts++ timeouts++
// usbfs marks the endpoint halted after a timed-out transfer; consecutiveFailures++
// every later transfer then fails instantly until cleared. // A halted/dead endpoint makes bulkTransfer fail INSTANTLY (the
diagnoseEndpoint(conn, epStream.address, timeouts) // 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() val now = android.os.SystemClock.elapsedRealtime()
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) { if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
noDataNotified = true noDataNotified = true
notify(State.STREAMING, "no_stream_data") 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) { if (now - lastLog >= 2000) {
DebugLog.log( DebugLog.log(
"stream", "stream",
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " + "stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
"timeouts=$timeouts (no data yet)", "timeouts=$timeouts consecutive=$consecutiveFailures (no data yet)",
) )
lastLog = now lastLog = now
} }
continue continue
} }
consecutiveFailures = 0
readCount++ readCount++
if (firstReads < 3) { if (firstReads < 3) {
DebugLog.log( DebugLog.log(
@@ -576,6 +671,11 @@ class IrSession(context: Context) {
if (active === this) active = null if (active === this) active = null
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg") sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
transport.close() 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) notify(State.IDLE, null)
} }
@@ -616,6 +716,25 @@ class IrSession(context: Context) {
// (StopTransferImg -> close -> IDLE) // (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() { fun destroy() {
stop() stop()
scope.cancel() scope.cancel()
@@ -108,16 +108,17 @@ class UsbTransport(private val context: Context) {
"usb", "usb",
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}", "claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
) )
// Prefer configuration 2 when the device exposes it (vendor behavior). // DO NOT send SET_CONFIGURATION here.
if (dev.configurationCount > 1) { //
val cfg = dev.getConfiguration(1) // This used to "prefer configuration 2 (vendor behavior)" by issuing a
// USB SET_CONFIGURATION request = 9 // USB SET_CONFIGURATION control request right after claiming the
val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500) // interface. That is a device-level reset: it invalidates the claim and
com.mag160c.thermal.media.DebugLog.log( // makes the camera re-enumerate. Measured on a real phone: with the app
"usb", // stopped the camera sat at /dev/bus/usb/002/071 for 30 s; the moment the
"setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc", // 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 return true
} }
@@ -120,6 +120,46 @@ class PhotoNucMappingTest {
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i]) 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 @Test
fun photosWithoutNucAreReportedAsUnmeasurable() { fun photosWithoutNucAreReportedAsUnmeasurable() {
// older files (and plain captures) must NOT be silently measurable: the // older files (and plain captures) must NOT be silently measurable: the
Binary file not shown.
+67
View File
@@ -489,6 +489,73 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。 "未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。 - [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,第三轮真机:分析测温/标注/相册/界面) ## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理: 用户第三轮实机测试(含截图)报出以下问题,本轮全部处理: