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() {
|
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.
@@ -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保存端到端)——当前进行中:
|
||||||
|
|||||||
Reference in New Issue
Block a user