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:screenOrientation="portrait"
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>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -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)
@@ -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
}
}
@@ -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}",
)
}
}
}
@@ -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(
@@ -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) {
@@ -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<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() {
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<Application>()
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)
}
}
@@ -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()
@@ -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
}
@@ -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