android: fix startup crash (MediaStore rejects text/plain in DCIM); log sink fallback DCIM->Download->app-private, logging never throws

This commit is contained in:
ZXCLI
2026-09-10 00:48:56 +08:00
parent ee317c7b25
commit dbb4f24608
6 changed files with 142 additions and 73 deletions
@@ -10,8 +10,10 @@ import com.mag160c.thermal.ui.theme.Mag160cTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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.init(applicationContext)
com.mag160c.thermal.media.DebugLog.startFile(applicationContext)
enableEdgeToEdge() enableEdgeToEdge()
// immersive: hide the status bar (swipe to reveal) // immersive: hide the status bar (swipe to reveal)
window.insetsController?.let { c -> window.insetsController?.let { c ->
@@ -16,13 +16,16 @@ import java.util.Locale
/** /**
* Field-debug logger: mirrors every line to logcat AND appends it to a * 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 * timestamped text file next to the photos (DCIM/MAG160C), so the user can
* can hand over the file without adb (real-device bring-up, round 11). * 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 * Logging must NEVER crash the app: every sink operation is guarded, and
* closed by [closeFile] (or on process death — at most the very last line is * writes are capped. Sink fallback chain (scoped storage only allows media
* lost). A crash hook writes the stack trace before rethrowing to the * under DCIM, text/plain may be rejected there):
* previous handler. * 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 { object DebugLog {
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH) private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH)
@@ -30,15 +33,13 @@ object DebugLog {
private val lock = Any() private val lock = Any()
private var stream: OutputStream? = null private var stream: OutputStream? = null
private var outUri: Uri? = null private var outUri: Uri? = null
private var appContext: Context? = null
private var previousHandler: Thread.UncaughtExceptionHandler? = null private var previousHandler: Thread.UncaughtExceptionHandler? = null
private var bytesWritten = 0L private var bytesWritten = 0L
/** Install once from MainActivity.onCreate: crash hook + app context. */ /** Install once from MainActivity.onCreate: crash hook + app context. */
fun init(context: Context) { fun init(context: Context) {
synchronized(lock) { synchronized(lock) {
if (appContext != null) return if (previousHandler != null) return
appContext = context.applicationContext
previousHandler = Thread.getDefaultUncaughtExceptionHandler() previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e -> Thread.setDefaultUncaughtExceptionHandler { t, e ->
log("crash", "thread=$t ${Log.getStackTraceString(e)}") log("crash", "thread=$t ${Log.getStackTraceString(e)}")
@@ -48,20 +49,28 @@ object DebugLog {
} }
} }
/** Log one line (also to logcat as MAG160C/<tag>). */ /** Log one line (also to logcat as MAG160C/<tag>). Never throws. */
fun log(tag: String, msg: String) { 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" val line = "${fmt.format(Date())} [$tag] $msg\n"
synchronized(lock) { synchronized(lock) {
val s = stream val s = stream ?: return
if (s == null) {
Log.v("MAG160C/$tag", "(no file yet) $msg")
return
}
try { try {
s.write(line.toByteArray(Charsets.UTF_8)) s.write(line.toByteArray(Charsets.UTF_8))
s.flush() s.flush()
bytesWritten += line.length 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) { } catch (e: Exception) {
Log.w("MAG160C/$tag", "log write failed", e) 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 * Open a fresh debug_*.log. Called once at app start (so even startup
* permission needed for own files) or the app-specific dir below Q. * crashes are captured); never throws. Returns the display path for
* Closes any previous file. Returns the display path for logging. * logging. Closes any previous file.
*/ */
fun startFile(context: Context): String { fun startFile(context: Context): String {
val name = "debug_${fileFmt.format(Date())}.log" val name = "debug_${fileFmt.format(Date())}.log"
@@ -79,37 +88,66 @@ object DebugLog {
synchronized(lock) { synchronized(lock) {
bytesWritten = 0 bytesWritten = 0
val ctx = context.applicationContext val ctx = context.applicationContext
var where = "(log unavailable)"
if (Build.VERSION.SDK_INT >= 29) { if (Build.VERSION.SDK_INT >= 29) {
val values = ContentValues().apply { openSink(ctx, name, Environment.DIRECTORY_DCIM + "/MAG160C")
put(MediaStore.MediaColumns.DISPLAY_NAME, name) ?.let { (uri, os) ->
put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") outUri = uri
put( stream = os
MediaStore.MediaColumns.RELATIVE_PATH, where = "DCIM/MAG160C/$name"
Environment.DIRECTORY_DCIM + "/MAG160C", }
) 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())} " + if (stream == null) {
"sdk=${Build.VERSION.SDK_INT} dev=${Build.MANUFACTURER} ${Build.MODEL}\n" try {
stream?.write(header.toByteArray(Charsets.UTF_8)) val dir = File(ctx.getExternalFilesDir(null), "MAG160C").apply { mkdirs() }
stream?.flush() 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<Uri, OutputStream>? {
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). */ /** Flush + close the current file (kept visible in the gallery). */
@@ -124,4 +162,6 @@ object DebugLog {
outUri = null outUri = null
} }
} }
private const val MAX_BYTES = 4L * 1024 * 1024
} }
@@ -295,6 +295,7 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta
"ddt_fail" -> "标定文件加载失败" "ddt_fail" -> "标定文件加载失败"
"open_fail" -> "USB打开失败(查看调试日志)" "open_fail" -> "USB打开失败(查看调试日志)"
"no_endpoints" -> "未找到数据端点(查看调试日志)" "no_endpoints" -> "未找到数据端点(查看调试日志)"
"connect_fail" -> "连接流程异常(查看调试日志)"
else -> { else -> {
val prefix = if (state.status.startsWith("exception:")) { val prefix = if (state.status.startsWith("exception:")) {
"异常:" + state.status.removePrefix("exception:") "异常:" + state.status.removePrefix("exception:")
@@ -103,32 +103,42 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/** 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 context = getApplication<Application>() try {
com.mag160c.thermal.media.DebugLog.startFile(context) com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
com.mag160c.thermal.media.DebugLog.log("vm", "connect()") 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) {
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",
)
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",
) )
session.start(ddt) return
} else {
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
_state.value = _state.value.copy(connected = false, status = "no_permission")
} }
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")
} }
} }
Binary file not shown.
+16
View File
@@ -233,6 +233,22 @@
- 注:调试日志文件由 MediaStore Files(非 Images)写入,部分相册 app 不显示 - 注:调试日志文件由 MediaStore Files(非 Images)写入,部分相册 app 不显示
text/plain,可用系统"文件"应用或 PC 复制 DCIM/MAG160C/debug_*.log。 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保存端到端)——当前进行中: - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——当前进行中: