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() {
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 ->
@@ -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/<tag>). */
/** Log one line (also to logcat as MAG160C/<tag>). 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<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). */
@@ -124,4 +162,6 @@ object DebugLog {
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" -> "标定文件加载失败"
"open_fail" -> "USB打开失败(查看调试日志)"
"no_endpoints" -> "未找到数据端点(查看调试日志)"
"connect_fail" -> "连接流程异常(查看调试日志)"
else -> {
val prefix = if (state.status.startsWith("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. */
fun connect() {
val context = getApplication<Application>()
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<Application>()
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")
}
}