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:
@@ -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) {
|
||||
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",
|
||||
)
|
||||
}
|
||||
val uri = ctx.contentResolver.insert(
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
values,
|
||||
)
|
||||
if (uri == null) {
|
||||
Log.w("MAG160C/log", "MediaStore insert failed")
|
||||
return "(insert failed)"
|
||||
}
|
||||
openSink(ctx, name, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||
?.let { (uri, os) ->
|
||||
outUri = uri
|
||||
stream = ctx.contentResolver.openOutputStream(uri)
|
||||
} else {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
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}\n"
|
||||
"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,9 +103,9 @@ 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)
|
||||
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) {
|
||||
@@ -118,6 +118,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
return
|
||||
}
|
||||
transport.requestPermission { ok ->
|
||||
try {
|
||||
if (ok) {
|
||||
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||
.getOrDefault(ByteArray(0))
|
||||
@@ -129,6 +130,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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.
@@ -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保存端到端)——当前进行中:
|
||||
|
||||
Reference in New Issue
Block a user