diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt index c92bcbe..66c88c2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -10,8 +10,10 @@ import com.mag160c.thermal.ui.theme.Mag160cTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // field-debug crash hook + logcat/file logger (file opens on connect) + // field-debug crash hook + log file (opened at startup so even + // early crashes are captured; logging never throws) com.mag160c.thermal.media.DebugLog.init(applicationContext) + com.mag160c.thermal.media.DebugLog.startFile(applicationContext) enableEdgeToEdge() // immersive: hide the status bar (swipe to reveal) window.insetsController?.let { c -> diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt index ec07435..5cd0198 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/DebugLog.kt @@ -16,13 +16,16 @@ import java.util.Locale /** * Field-debug logger: mirrors every line to logcat AND appends it to a - * timestamped text file in the public DCIM/MAG160C album folder, so the user - * can hand over the file without adb (real-device bring-up, round 11). + * timestamped text file next to the photos (DCIM/MAG160C), so the user can + * hand over the file without adb (real-device bring-up, round 11). * - * Lines are flushed per write; the stream stays open for the session and is - * closed by [closeFile] (or on process death — at most the very last line is - * lost). A crash hook writes the stack trace before rethrowing to the - * previous handler. + * Logging must NEVER crash the app: every sink operation is guarded, and + * writes are capped. Sink fallback chain (scoped storage only allows media + * under DCIM, text/plain may be rejected there): + * 1. MediaStore Files @ DCIM/MAG160C (the album folder, preferred) + * 2. MediaStore Files @ Download/MAG160C (allowed for non-media files) + * 3. app-specific external dir (always writable; may need a PC to fetch) + * The header line records where the file actually ended up. */ object DebugLog { private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH) @@ -30,15 +33,13 @@ object DebugLog { private val lock = Any() private var stream: OutputStream? = null private var outUri: Uri? = null - private var appContext: Context? = null private var previousHandler: Thread.UncaughtExceptionHandler? = null private var bytesWritten = 0L /** Install once from MainActivity.onCreate: crash hook + app context. */ fun init(context: Context) { synchronized(lock) { - if (appContext != null) return - appContext = context.applicationContext + if (previousHandler != null) return previousHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { t, e -> log("crash", "thread=$t ${Log.getStackTraceString(e)}") @@ -48,20 +49,28 @@ object DebugLog { } } - /** Log one line (also to logcat as MAG160C/). */ + /** Log one line (also to logcat as MAG160C/). Never throws. */ fun log(tag: String, msg: String) { - Log.d("MAG160C/$tag", msg) + try { + Log.d("MAG160C/$tag", msg) + } catch (_: Exception) { + } val line = "${fmt.format(Date())} [$tag] $msg\n" synchronized(lock) { - val s = stream - if (s == null) { - Log.v("MAG160C/$tag", "(no file yet) $msg") - return - } + val s = stream ?: return try { s.write(line.toByteArray(Charsets.UTF_8)) s.flush() bytesWritten += line.length + if (bytesWritten > MAX_BYTES) { + // cap the file: stop writing rather than grow unbounded + try { + s.close() + } catch (_: Exception) { + } + stream = null + Log.w("MAG160C/log", "log capped at $MAX_BYTES bytes, closed") + } } catch (e: Exception) { Log.w("MAG160C/$tag", "log write failed", e) } @@ -69,9 +78,9 @@ object DebugLog { } /** - * Open a fresh debug_*.log in DCIM/MAG160C (API 29+, MediaStore, no - * permission needed for own files) or the app-specific dir below Q. - * Closes any previous file. Returns the display path for logging. + * Open a fresh debug_*.log. Called once at app start (so even startup + * crashes are captured); never throws. Returns the display path for + * logging. Closes any previous file. */ fun startFile(context: Context): String { val name = "debug_${fileFmt.format(Date())}.log" @@ -79,37 +88,66 @@ object DebugLog { synchronized(lock) { bytesWritten = 0 val ctx = context.applicationContext + var where = "(log unavailable)" if (Build.VERSION.SDK_INT >= 29) { - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - put( - MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_DCIM + "/MAG160C", - ) + openSink(ctx, name, Environment.DIRECTORY_DCIM + "/MAG160C") + ?.let { (uri, os) -> + outUri = uri + stream = os + where = "DCIM/MAG160C/$name" + } + if (stream == null) { + openSink(ctx, name, Environment.DIRECTORY_DOWNLOADS + "/MAG160C") + ?.let { (uri, os) -> + outUri = uri + stream = os + where = "Download/MAG160C/$name (DCIM rejected for text/plain)" + } } - val uri = ctx.contentResolver.insert( - MediaStore.Files.getContentUri("external"), - values, - ) - if (uri == null) { - Log.w("MAG160C/log", "MediaStore insert failed") - return "(insert failed)" - } - outUri = uri - stream = ctx.contentResolver.openOutputStream(uri) - } else { - val dir = File(ctx.getExternalFilesDir(null), "MAG160C").apply { mkdirs() } - val f = File(dir, name) - outUri = Uri.fromFile(f) - stream = FileOutputStream(f) } - val header = "MAG160C debug ${fileFmt.format(Date())} " + - "sdk=${Build.VERSION.SDK_INT} dev=${Build.MANUFACTURER} ${Build.MODEL}\n" - stream?.write(header.toByteArray(Charsets.UTF_8)) - stream?.flush() + if (stream == null) { + try { + val dir = File(ctx.getExternalFilesDir(null), "MAG160C").apply { mkdirs() } + val f = File(dir, name) + outUri = Uri.fromFile(f) + stream = FileOutputStream(f) + where = f.absolutePath + " (app-private; fetch via PC)" + } catch (e: Exception) { + Log.w("MAG160C/log", "all log sinks failed", e) + return where + } + } + try { + val header = "MAG160C debug ${fileFmt.format(Date())} " + + "sdk=${Build.VERSION.SDK_INT} dev=${Build.MANUFACTURER} " + + "${Build.MODEL} sink=$where\n" + stream?.write(header.toByteArray(Charsets.UTF_8)) + stream?.flush() + } catch (e: Exception) { + Log.w("MAG160C/log", "header write failed", e) + } + return where + } + } + + /** One MediaStore Files insert + output stream; null on any failure. */ + private fun openSink(ctx: Context, name: String, relativePath: String): Pair? { + return try { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + } + val uri = ctx.contentResolver.insert( + MediaStore.Files.getContentUri("external"), + values, + ) ?: return null + val os = ctx.contentResolver.openOutputStream(uri) ?: return null + uri to os + } catch (e: Exception) { + Log.w("MAG160C/log", "sink $relativePath failed: $e") + null } - return "DCIM/MAG160C/$name" } /** Flush + close the current file (kept visible in the gallery). */ @@ -124,4 +162,6 @@ object DebugLog { outUri = null } } + + private const val MAX_BYTES = 4L * 1024 * 1024 } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index fd1af1f..6c51ae0 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -295,6 +295,7 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta "ddt_fail" -> "标定文件加载失败" "open_fail" -> "USB打开失败(查看调试日志)" "no_endpoints" -> "未找到数据端点(查看调试日志)" + "connect_fail" -> "连接流程异常(查看调试日志)" else -> { val prefix = if (state.status.startsWith("exception:")) { "异常:" + state.status.removePrefix("exception:") 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 8b6f240..dfea4d4 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 @@ -103,32 +103,42 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Begin USB permission flow, then start streaming. No device -> idle notice. */ fun connect() { - val context = getApplication() - com.mag160c.thermal.media.DebugLog.startFile(context) - com.mag160c.thermal.media.DebugLog.log("vm", "connect()") - val transport = com.mag160c.thermal.usb.UsbTransport(context) - val dev = transport.findDevice() - if (dev == null) { - com.mag160c.thermal.media.DebugLog.log("vm", "no_device") - _state.value = _state.value.copy( - connected = false, - streaming = false, - status = "no_device", - ) - return - } - transport.requestPermission { ok -> - 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", + 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) { + com.mag160c.thermal.media.DebugLog.log("vm", "no_device") + _state.value = _state.value.copy( + connected = false, + streaming = false, + status = "no_device", ) - session.start(ddt) - } else { - com.mag160c.thermal.media.DebugLog.log("vm", "permission denied") - _state.value = _state.value.copy(connected = false, status = "no_permission") + return } + transport.requestPermission { ok -> + try { + 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", + ) + session.start(ddt) + } else { + com.mag160c.thermal.media.DebugLog.log("vm", "permission denied") + _state.value = _state.value.copy(connected = false, status = "no_permission") + } + } catch (e: Exception) { + com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e") + _state.value = _state.value.copy(connected = false, status = "connect_fail") + } + } + } 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") } } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index d1a9244..75bf806 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:756e2a691fc1e16820b0ee4cf216cd1809a252fc8b2ada78e68e295f6c30bf8d -size 11872694 +oid sha256:009e731cf9d80aa2419bf7e5f5217de23f210735dcd9af9f0e5e551cf614bf3c +size 11872829 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index f693f7a..1dc4b10 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -233,6 +233,22 @@ - 注:调试日志文件由 MediaStore Files(非 Images)写入,部分相册 app 不显示 text/plain,可用系统"文件"应用或 PC 复制 DCIM/MAG160C/debug_*.log。 +## 用户反馈修复 第十二轮(2026-09-10,打开即闪退:日志落点修复) + +- [x] **用户反馈**:第 11 轮 APK 装好后直接打开就闪退。 +- [x] **根因**:`DebugLog.startFile` 用 `MediaStore.Files` 往 DCIM/MAG160C 插 + text/plain 文件;scoped storage 规定 DCIM 只收图片/视频,非媒体文件被拒, + `insert()` 抛 IllegalArgumentException,而该调用在 `LaunchedEffect → + connect()` 协程里无 try/catch → 启动即崩。 +- [x] **修复**:DebugLog 全链路 try/catch 永不抛异常;落点三级回退 + DCIM/MAG160C → Download/MAG160C(非媒体允许目录)→ 应用私有外部目录; + 文件头写实际落点 `sink=`;日志 4MB 封顶。`startFile` 提前到 + MainActivity.onCreate(启动崩溃也有记录);`connect()`/权限回调整体 + try/catch,失败置 `connect_fail`(statusText 显示"连接流程异常")。 +- [x] 构建+单测全过,dex 抽查通过;APK 已更新(11.87MB)。 +- 注:取日志时 DCIM/MAG160C 和 Download/MAG160C 都看一眼(文件头 sink= 注明 + 实际位置);若在前者失败会自动落后者。 + ## 待办 - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——当前进行中: