From cb51d089393eb0af431930192dd683b5296e5dfe Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:17:58 +0700 Subject: [PATCH] feat(crash): show persistent crash reports on Home --- android/app/src/main/AndroidManifest.xml | 2 +- .../com/zarz/spotiflac/AppCrashReporter.kt | 227 ++++++++++++++++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 17 ++ .../com/zarz/spotiflac/NativeCrashTrace.kt | 85 +++++++ .../zarz/spotiflac/SpotiFlacApplication.kt | 10 + .../zarz/spotiflac/AppCrashReporterTest.kt | 41 ++++ .../zarz/spotiflac/NativeCrashTraceTest.kt | 54 +++++ lib/l10n/arb/app_en.arb | 7 + lib/l10n/arb/app_id.arb | 7 + lib/main.dart | 6 + lib/models/app_crash_report.dart | 60 +++++ lib/providers/app_crash_provider.dart | 29 +++ lib/screens/home_tab.dart | 2 + lib/services/app_crash_service.dart | 24 ++ lib/services/platform_bridge.dart | 28 +++ lib/utils/logger.dart | 3 + lib/widgets/app_crash_banner.dart | 112 +++++++++ test/app_crash_report_test.dart | 191 +++++++++++++++ 18 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/AppCrashReporter.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/NativeCrashTrace.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/SpotiFlacApplication.kt create mode 100644 android/app/src/test/kotlin/com/zarz/spotiflac/AppCrashReporterTest.kt create mode 100644 android/app/src/test/kotlin/com/zarz/spotiflac/NativeCrashTraceTest.kt create mode 100644 lib/models/app_crash_report.dart create mode 100644 lib/providers/app_crash_provider.dart create mode 100644 lib/services/app_crash_service.dart create mode 100644 lib/widgets/app_crash_banner.dart create mode 100644 test/app_crash_report_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 260c3afc..1cbfaf97 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ ? = null + private val storageLock = Any() + private val handlingCrash = AtomicBoolean(false) + + fun isUnexpectedExit(reason: Int): Boolean = reason in setOf( + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + ApplicationExitInfo.REASON_ANR, + ApplicationExitInfo.REASON_LOW_MEMORY, + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE, + ApplicationExitInfo.REASON_SIGNALED, + ) + + fun shouldKeepSavedReport(savedAt: Long, exitAt: Long, currentStart: Long): Boolean = + savedAt >= currentStart || savedAt > exitAt + 5000L + + fun initialize(applicationContext: Context) { + context = applicationContext + sessionStartedAt = System.currentTimeMillis() + val prefs = context.getSharedPreferences("app_crash_reports", Context.MODE_PRIVATE) + val previousStart = prefs.getLong("session_started_at", 0L) + val previousVersion = prefs.getString("session_version", "Unknown") ?: "Unknown" + prefs.edit().putLong("session_started_at", sessionStartedAt) + .putString("session_version", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + .commit() + + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + try { + if (handlingCrash.compareAndSet(false, true)) { + val trace = BoundedWriter(MAX_TEXT) + error.printStackTrace(PrintWriter(trace)) + val report = baseReport(System.currentTimeMillis()) + .put("reason", "Unhandled Android exception") + .put("description", "${error.javaClass.name}: ${error.message.orEmpty()}".take(2000)) + .put("trace", "Thread: ${thread.name}\n$trace") + synchronized(storageLock) { writeJson("pending", report) } + } + } catch (_: Throwable) { + // A crash, especially OOM, must still reach Android's handler. + } finally { + if (previousHandler != null) { + previousHandler.uncaughtException(thread, error) + } else { + Process.killProcess(Process.myPid()) + } + } + } + + // Recover even if the user never reaches Home on this launch. Trace I/O + // must not block Application.onCreate or make the next launch an ANR. + val executor = Executors.newSingleThreadExecutor() + recovery = executor.submit { + try { + recoverPreviousExit(previousStart, previousVersion) + } catch (error: Exception) { + Log.w("AppCrashReporter", "Could not read Android exit diagnostics", error) + } + } + executor.shutdown() + } + + fun pendingReport(): Map? { + recovery?.get() + return synchronized(storageLock) { + val report = readJson("pending") ?: return@synchronized null + val prefs = context.getSharedPreferences("app_crash_reports", Context.MODE_PRIVATE) + if (report.optLong("timestamp") <= prefs.getLong("dismissed_at", 0L)) return@synchronized null + report.keys().asSequence().associateWith { key -> + report.get(key).takeUnless { it == JSONObject.NULL } + } + } + } + + fun dismissReport(id: String) { + recovery?.get() + synchronized(storageLock) { + val report = readJson("pending") ?: return + if (report.optString("id") != id) return + val prefs = context.getSharedPreferences("app_crash_reports", Context.MODE_PRIVATE) + check(prefs.edit().putLong("dismissed_at", report.optLong("timestamp")).commit()) + file("pending").delete() + } + } + + fun recordRuntimeError(error: String, stack: String, logs: String) { + val snapshot = JSONObject() + .put("session_started_at", sessionStartedAt) + .put("timestamp", System.currentTimeMillis()) + .put("error", error.take(4000)) + .put("stack", stack.take(48 * 1024)) + .put("logs", logs.takeLast(32 * 1024)) + synchronized(storageLock) { writeJson("runtime_error", snapshot) } + } + + private fun recoverPreviousExit(previousStart: Long, previousVersion: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || previousStart <= 0L) return + val manager = context.getSystemService(ActivityManager::class.java) + val exit = manager.getHistoricalProcessExitReasons(context.packageName, 0, 32) + .firstOrNull { + LibraryScanExitInfo.matches(it.processName, it.timestamp, context.packageName, previousStart) && + it.timestamp < sessionStartedAt + } ?: return + // Do not call force-stop, updates, or ordinary task removal a crash. + if (!isUnexpectedExit(exit.reason)) return + val prefs = context.getSharedPreferences("app_crash_reports", Context.MODE_PRIVATE) + if (exit.timestamp <= prefs.getLong("dismissed_at", 0L)) return + + val report = baseReport(exit.timestamp) + .put("version", previousVersion) + .put("reason", LibraryScanExitInfo.reasonLabel(exit.reason)) + .put("reason_code", exit.reason) + .put("description", exit.description.orEmpty().take(2000)) + .put("status", exit.status) + .put("pss_kb", exit.pss) + .put("rss_kb", exit.rss) + try { + exit.traceInputStream?.use { stream -> + val bytes = stream.takeBounded(MAX_TRACE_BYTES) + val trace = if (exit.reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { + NativeCrashTrace.decode(bytes) + } else { + bytes.toString(Charsets.UTF_8) + } + report.put("trace", trace.take(MAX_TEXT)) + } + } catch (error: Exception) { + report.put("trace_error", "Android crash trace unavailable: ${error.javaClass.simpleName}") + } + synchronized(storageLock) { + val stored = readJson("pending") + // A second startup crash can happen while the previous tombstone + // is being read. Never overwrite its newer synchronous report. + if (stored != null && shouldKeepSavedReport( + stored.optLong("timestamp"), exit.timestamp, sessionStartedAt, + ) + ) return + if (stored != null && stored.optLong("timestamp") >= previousStart && + stored.optString("trace").isNotEmpty() + ) { + report.put("trace", stored.optString("trace")) + report.put("description", stored.optString("description")) + } + val runtime = readJson("runtime_error") + if (runtime?.optLong("session_started_at") == previousStart) { + report.put("runtime_error", runtime.optString("error")) + .put("runtime_stack", runtime.optString("stack")) + .put("recent_logs", runtime.optString("logs")) + } + writeJson("pending", report) + } + } + + private fun baseReport(timestamp: Long): JSONObject = JSONObject() + .put("id", timestamp.toString()) + .put("timestamp", timestamp) + .put("version", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + .put("device", "${Build.MANUFACTURER} ${Build.MODEL}") + .put("android", Build.VERSION.RELEASE) + .put("sdk", Build.VERSION.SDK_INT) + .put("abis", Build.SUPPORTED_ABIS.joinToString()) + + private fun file(name: String) = AtomicFile(File(context.filesDir, "app_crash_$name.json")) + + private fun readJson(name: String): JSONObject? = try { + JSONObject(file(name).openRead().bufferedReader().use { it.readText() }) + } catch (_: Exception) { + null + } + + private fun writeJson(name: String, report: JSONObject) { + val target = file(name) + val output = target.startWrite() + try { + output.write(report.toString().toByteArray(Charsets.UTF_8)) + target.finishWrite(output) + } catch (error: Throwable) { + target.failWrite(output) + throw error + } + } + + private fun java.io.InputStream.takeBounded(limit: Int): ByteArray { + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (output.size() < limit) { + val count = read(buffer, 0, minOf(buffer.size, limit - output.size())) + if (count < 0) break + output.write(buffer, 0, count) + } + return output.toByteArray() + } + + private class BoundedWriter(private val limit: Int) : Writer() { + private val text = StringBuilder() + override fun write(chars: CharArray, offset: Int, length: Int) { + val count = minOf(length, limit - text.length) + if (count > 0) text.append(chars, offset, count) + } + override fun flush() = Unit + override fun close() = Unit + override fun toString(): String = text.toString() + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 76264749..f8711da1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import org.json.JSONTokener @@ -77,6 +78,9 @@ class MainActivity: FlutterFragmentActivity() { "scanSafTreeIncremental", "getLibraryScanProgress", "getLibraryScanExitInfo", + "getPendingAppCrash", + "dismissAppCrash", + "recordAppRuntimeError", "cancelLibraryScan", "pauseLibraryScan", "resumeLibraryScan", @@ -555,6 +559,19 @@ class MainActivity: FlutterFragmentActivity() { "getSafFileModTimes" -> handleGetSafFileModTimes(call, result) "getLibraryScanProgress" -> handleGetLibraryScanProgress(call, result) "getLibraryScanExitInfo" -> handleGetLibraryScanExitInfo(call, result) + "getPendingAppCrash" -> result.success(withContext(Dispatchers.IO) { AppCrashReporter.pendingReport() }) + "dismissAppCrash" -> { + val id = call.argument("id") ?: "" + withContext(Dispatchers.IO) { AppCrashReporter.dismissReport(id) } + result.success(null) + } + "recordAppRuntimeError" -> { + val error = call.argument("error") ?: "" + val stack = call.argument("stack") ?: "" + val logs = call.argument("logs") ?: "" + withContext(Dispatchers.IO) { AppCrashReporter.recordRuntimeError(error, stack, logs) } + result.success(null) + } "cancelLibraryScan" -> handleCancelLibraryScan(call, result) "pauseLibraryScan" -> handlePauseLibraryScan(call, result) "resumeLibraryScan" -> handleResumeLibraryScan(call, result) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeCrashTrace.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeCrashTrace.kt new file mode 100644 index 00000000..8469242a --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeCrashTrace.kt @@ -0,0 +1,85 @@ +package com.zarz.spotiflac + +/** Reads only diagnostic fields from Android's tombstone protobuf, never memory dumps. + * Schema: https://android.googlesource.com/platform/system/core/+/refs/heads/main/debuggerd/proto/tombstone.proto + */ +internal object NativeCrashTrace { + fun decode(bytes: ByteArray): String { + val tombstone = fields(bytes, setOf(5, 6, 10, 14, 15, 16)) + return buildString { + appendLine("Android native crash (tombstone)") + appendLine("pid: ${tombstone.number(5)}, crashed tid: ${tombstone.number(6)}") + tombstone.blobs(10).firstOrNull()?.let { + val signal = fields(it, setOf(1, 2, 3, 4, 8, 9)) + appendLine("Signal: ${signal.string(2)} (${signal.number(1)}), code: ${signal.string(4)} (${signal.number(3)})") + if (signal.number(8) != 0L) { + appendLine("Fault address: 0x${signal.number(9).toULong().toString(16)}") + } + } + tombstone.string(14).takeIf { it.isNotEmpty() }?.let { appendLine("Abort message: $it") } + for (cause in tombstone.blobs(15)) { + appendLine("Cause: ${fields(cause, setOf(1)).string(1)}") + } + val threads = tombstone.blobs(16).map { fields(it, setOf(1, 2)) } + .sortedBy { if (it.number(1) == tombstone.number(6)) 0 else 1 } + for (entry in threads.take(20)) { + val thread = fields(entry.blobs(2).firstOrNull() ?: continue, setOf(1, 2, 4, 7)) + appendLine("\nThread ${thread.number(1)}: ${thread.string(2)}") + for (note in thread.blobs(7)) appendLine(note.toString(Charsets.UTF_8)) + for ((index, frameBytes) in thread.blobs(4).take(64).withIndex()) { + val frame = fields(frameBytes, setOf(1, 4, 5, 6, 8)) + appendLine("#$index pc ${frame.number(1).toULong().toString(16)} ${frame.string(6)} (${frame.string(4)}+${frame.number(5)}) (BuildId: ${frame.string(8)})") + } + } + if (threads.isEmpty()) appendLine("Android did not provide thread backtraces in this trace.") + }.take(128 * 1024) + } + + private class Fields(val values: Map>) { + fun number(key: Int): Long = values[key]?.firstOrNull() as? Long ?: 0L + fun blobs(key: Int): List = values[key].orEmpty().filterIsInstance() + fun string(key: Int): String = blobs(key).firstOrNull()?.toString(Charsets.UTF_8).orEmpty() + } + + private fun fields(bytes: ByteArray, wanted: Set): Fields { + val values = mutableMapOf>() + var offset = 0 + fun varint(): Long { + var value = 0L + for (shift in 0..63 step 7) { + require(offset < bytes.size) { "Truncated tombstone" } + val byte = bytes[offset++].toInt() and 255 + value = value or ((byte and 127).toLong() shl shift) + if (byte and 128 == 0) return value + } + error("Invalid tombstone varint") + } + while (offset < bytes.size) { + try { + val tag = varint() + val key = (tag ushr 3).toInt() + require(key > 0) { "Invalid tombstone field" } + var value: Any? = null + when ((tag and 7).toInt()) { + 0 -> value = varint() + 1 -> offset += 8 + 2 -> { + val length = varint() + require(length >= 0 && length <= bytes.size - offset) { "Truncated tombstone field" } + if (key in wanted) value = bytes.copyOfRange(offset, offset + length.toInt()) + offset += length.toInt() + } + 5 -> offset += 4 + else -> error("Unsupported tombstone wire type") + } + require(offset <= bytes.size) { "Truncated tombstone field" } + if (key in wanted && value != null) values.getOrPut(key) { mutableListOf() }.add(value) + } catch (_: IllegalArgumentException) { + // A bounded read can end midway through an unrelated memory + // dump. Keep complete diagnostic fields preceding that dump. + break + } + } + return Fields(values) + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SpotiFlacApplication.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SpotiFlacApplication.kt new file mode 100644 index 00000000..2b0fdae2 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SpotiFlacApplication.kt @@ -0,0 +1,10 @@ +package com.zarz.spotiflac + +import android.app.Application + +class SpotiFlacApplication : Application() { + override fun onCreate() { + super.onCreate() + AppCrashReporter.initialize(this) + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/AppCrashReporterTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/AppCrashReporterTest.kt new file mode 100644 index 00000000..21d538d9 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/AppCrashReporterTest.kt @@ -0,0 +1,41 @@ +package com.zarz.spotiflac + +import android.app.ApplicationExitInfo +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppCrashReporterTest { + @Test + fun aSecondStartupCrashCannotBeOverwrittenByPreviousExitRecovery() { + assertTrue(AppCrashReporter.shouldKeepSavedReport(2250L, 2000L, 2200L)) + assertFalse(AppCrashReporter.shouldKeepSavedReport(1950L, 2000L, 2200L)) + } + + @Test + fun includesCrashesAnrAndOsResourceKills() { + for (reason in listOf( + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + ApplicationExitInfo.REASON_ANR, + ApplicationExitInfo.REASON_LOW_MEMORY, + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE, + ApplicationExitInfo.REASON_SIGNALED, + )) { + assertTrue(AppCrashReporter.isUnexpectedExit(reason)) + } + } + + @Test + fun normalExitsForceStopsAndUpdatesAreNotReportedAsCrashes() { + for (reason in listOf( + ApplicationExitInfo.REASON_EXIT_SELF, + ApplicationExitInfo.REASON_USER_REQUESTED, + ApplicationExitInfo.REASON_USER_STOPPED, + ApplicationExitInfo.REASON_PACKAGE_UPDATED, + ApplicationExitInfo.REASON_PACKAGE_STATE_CHANGE, + )) { + assertFalse(AppCrashReporter.isUnexpectedExit(reason)) + } + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeCrashTraceTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeCrashTraceTest.kt new file mode 100644 index 00000000..dc7aaab5 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeCrashTraceTest.kt @@ -0,0 +1,54 @@ +package com.zarz.spotiflac + +import java.io.ByteArrayOutputStream +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeCrashTraceTest { + @Test + fun decodesActualSignalAbortAndBacktraceFieldsWithoutMemoryDumpContents() { + val frame = number(1, 0x1234) + text(4, "decoder_abort") + number(5, 8) + + text(6, "/data/app/libaudio.so") + text(8, "abcd1234") + val thread = number(1, 22) + text(2, "audio-worker") + blob(4, frame) + val tombstone = number(5, 11) + number(6, 22) + + blob(10, number(1, 6) + text(2, "SIGABRT") + text(4, "SI_TKILL")) + + text(14, "allocator detected an invalid pointer") + + blob(16, number(1, 22) + blob(2, thread)) + text(17, "PRIVATE_MEMORY_DUMP") + val trace = NativeCrashTrace.decode(tombstone) + assertTrue(trace.contains("SIGABRT (6)")) + assertTrue(trace.contains("allocator detected an invalid pointer")) + assertTrue(trace.contains("Thread 22: audio-worker")) + assertTrue(trace.contains("pc 1234 /data/app/libaudio.so (decoder_abort+8)")) + assertTrue(trace.contains("BuildId: abcd1234")) + assertFalse(trace.contains("PRIVATE_MEMORY_DUMP")) + } + + @Test + fun keepsCrashDetailsWhenBoundedInputTruncatesALaterMemoryDump() { + val tombstone = text(14, "original fatal error") + varint((17L shl 3) or 2L) + varint(1000) + byteArrayOf(1) + assertTrue(NativeCrashTrace.decode(tombstone).contains("original fatal error")) + } + + @Test + fun crashedThreadIsShownBeforeOtherThreads() { + fun thread(id: Long) = blob(16, number(1, id) + blob(2, number(1, id) + text(2, "thread-$id"))) + val trace = NativeCrashTrace.decode(number(6, 99) + thread(10) + thread(99)) + assertTrue(trace.indexOf("Thread 99") < trace.indexOf("Thread 10")) + } + + private fun text(key: Int, value: String): ByteArray = blob(key, value.toByteArray()) + private fun blob(key: Int, value: ByteArray): ByteArray = + varint((key.toLong() shl 3) or 2L) + varint(value.size.toLong()) + value + private fun number(key: Int, value: Long): ByteArray = varint(key.toLong() shl 3) + varint(value) + private fun varint(value: Long): ByteArray { + val output = ByteArrayOutputStream() + var remaining = value + while (remaining > 127) { + output.write((remaining.toInt() and 127) or 128) + remaining = remaining ushr 7 + } + output.write(remaining.toInt()) + return output.toByteArray() + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1704acf0..ddff1bdd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7531,6 +7531,13 @@ "audioInfoCopied": "Audio info copied", "lyricsTextSize": "Lyrics text size", "lyricsTextPreview": "Follow every line of your favorite song", + "appCrashTitle": "The app previously crashed", + "appCrashMessage": "A crash report was saved. Copy it and send it to the developer to help investigate.", + "appCrashDetails": "View error and log", + "appCrashCopy": "Copy report for developer", + "appCrashDismiss": "Dismiss", + "appCrashCopyFailed": "Could not copy the crash report. Please try again.", + "appCrashDismissFailed": "Could not dismiss the crash report. Please try again.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 8dae59c6..802a0ff8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7274,6 +7274,13 @@ "audioInfoCopied": "Info audio disalin", "lyricsTextSize": "Ukuran teks lirik", "lyricsTextPreview": "Ikuti setiap baris lagu favoritmu", + "appCrashTitle": "Aplikasi sebelumnya mengalami crash", + "appCrashMessage": "Laporan crash telah disimpan. Salin dan kirimkan ke developer untuk membantu pemeriksaan.", + "appCrashDetails": "Lihat error dan log", + "appCrashCopy": "Salin laporan untuk developer", + "appCrashDismiss": "Tutup", + "appCrashCopyFailed": "Laporan crash belum bisa disalin. Silakan coba lagi.", + "appCrashDismissFailed": "Laporan crash belum bisa ditutup. Silakan coba lagi.", "optionsEmbeddedCoverSizeOriginal": "Resolusi asli", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/main.dart b/lib/main.dart index 8e56399a..2bd88495 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/services/app_orientation.dart'; +import 'package:spotiflac_android/services/app_crash_service.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; @@ -81,9 +82,13 @@ void main() { FlutterError.onError = (details) { previousOnError?.call(details); _log.e('Uncaught Flutter error: ${details.exceptionAsString()}'); + unawaited( + AppCrashService.recordRuntimeError(details.exception, details.stack), + ); }; WidgetsBinding.instance.platformDispatcher.onError = (error, stack) { _log.e('Uncaught platform error: $error'); + unawaited(AppCrashService.recordRuntimeError(error, stack)); return true; }; @@ -150,6 +155,7 @@ void main() { }, (error, stack) { _log.e('Uncaught zone error: $error'); + unawaited(AppCrashService.recordRuntimeError(error, stack)); }, ); } diff --git a/lib/models/app_crash_report.dart b/lib/models/app_crash_report.dart new file mode 100644 index 00000000..fb3bf71c --- /dev/null +++ b/lib/models/app_crash_report.dart @@ -0,0 +1,60 @@ +import 'package:spotiflac_android/utils/logger.dart'; + +class AppCrashReport { + AppCrashReport.fromMap(Map values) + : id = values['id'] as String, + timestamp = DateTime.fromMillisecondsSinceEpoch( + (values['timestamp'] as num).toInt(), + ), + reason = redactDiagnosticText(values['reason'] as String? ?? ''), + description = redactDiagnosticText( + values['description'] as String? ?? '', + ), + report = _formatReport(values); + + final String id; + final DateTime timestamp; + final String reason; + final String description; + final String report; + + static String _formatReport(Map values) { + final report = StringBuffer('SpotiFLAC Mobile crash report\n'); + report.writeln( + 'Crash time: ${DateTime.fromMillisecondsSinceEpoch((values['timestamp'] as num).toInt()).toUtc().toIso8601String()}', + ); + for (final (key, label) in const [ + ('version', 'App version'), + ('device', 'Device'), + ('android', 'Android'), + ('sdk', 'Android SDK'), + ('abis', 'ABIs'), + ('reason', 'Exit reason'), + ('reason_code', 'Android exit reason code'), + ('description', 'Original error'), + ('status', 'Exit status / signal'), + ('pss_kb', 'Last sampled PSS (KB)'), + ('rss_kb', 'Last sampled RSS (KB)'), + ]) { + final value = values[key]?.toString() ?? ''; + if (value.isNotEmpty) report.writeln('$label: $value'); + } + final trace = values['trace'] as String? ?? ''; + report.writeln( + '\nCrash stack trace:\n${trace.isEmpty ? 'Android did not provide a stack trace for this process exit.' : trace}', + ); + for (final (key, label) in const [ + ('trace_error', 'Trace availability'), + ( + 'runtime_error', + 'Last unhandled Dart/Flutter error before exit (context)', + ), + ('runtime_stack', 'Dart/Flutter stack trace'), + ('recent_logs', 'Recent app logs before that error'), + ]) { + final value = values[key] as String? ?? ''; + if (value.isNotEmpty) report.writeln('\n$label:\n$value'); + } + return redactDiagnosticText(report.toString()); + } +} diff --git a/lib/providers/app_crash_provider.dart b/lib/providers/app_crash_provider.dart new file mode 100644 index 00000000..321a04b8 --- /dev/null +++ b/lib/providers/app_crash_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/app_crash_report.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final appCrashProvider = + AsyncNotifierProvider( + AppCrashNotifier.new, + ); + +class AppCrashNotifier extends AsyncNotifier { + @override + Future build() async { + try { + final report = await PlatformBridge.getPendingAppCrash(); + return report == null ? null : AppCrashReport.fromMap(report); + } catch (error) { + AppLogger('AppCrash').e('Could not recover crash report: $error'); + return null; + } + } + + Future dismiss(String id) async { + await PlatformBridge.dismissAppCrash(id); + if (ref.mounted && state.value?.id == id) { + state = const AsyncData(null); + } + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d291d585..2894cce1 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -80,6 +80,7 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; import 'package:spotiflac_android/widgets/profile_avatar.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/widgets/app_crash_banner.dart'; export 'package:spotiflac_android/screens/extension_collection_screens.dart'; @@ -922,6 +923,7 @@ class _HomeTabState extends ConsumerState keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ _buildHeaderSliver(greeting: greeting), + if (_showsHome) const SliverToBoxAdapter(child: AppCrashBanner()), _buildHomeIntroSliver( sectionMotion: sectionMotion, hasResults: hasResults, diff --git a/lib/services/app_crash_service.dart b/lib/services/app_crash_service.dart new file mode 100644 index 00000000..e02224dd --- /dev/null +++ b/lib/services/app_crash_service.dart @@ -0,0 +1,24 @@ +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +/// Saves context for a later confirmed process crash. A handled Flutter error +/// alone must not produce a misleading "app crashed" message on the next launch. +class AppCrashService { + static Future recordRuntimeError( + Object error, + StackTrace? stack, + ) async { + try { + final entries = LogBuffer().entries; + await PlatformBridge.recordAppRuntimeError( + error: redactDiagnosticText(error.toString()), + stack: redactDiagnosticText(stack?.toString() ?? ''), + logs: formatLogEntries( + entries.skip(entries.length > 40 ? entries.length - 40 : 0), + ), + ); + } catch (_) { + // Never route a reporting failure back into the global error handler. + } + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 02abef40..55fc265f 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -2236,6 +2236,34 @@ class PlatformBridge { } } + // ---- Application crash diagnostics ---- + + static Future?> getPendingAppCrash() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return null; + return _decodeNullableMapResult( + await _channel.invokeMethod('getPendingAppCrash'), + 'getPendingAppCrash', + ); + } + + static Future dismissAppCrash(String id) async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + await _channel.invokeMethod('dismissAppCrash', {'id': id}); + } + + static Future recordAppRuntimeError({ + required String error, + required String stack, + required String logs, + }) async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + await _channel.invokeMethod('recordAppRuntimeError', { + 'error': error, + 'stack': stack, + 'logs': logs, + }); + } + // ---- Library scan ---- static Future?> getLibraryScanExitInfo({ diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 77aa0945..603ba1f8 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -74,6 +74,9 @@ String _redactSensitiveText(String value) { return redacted; } +/// Crash reports use the same secret filtering as the ordinary log export. +String redactDiagnosticText(String value) => _redactSensitiveText(value); + class LogEntry { final DateTime timestamp; final String level; diff --git a/lib/widgets/app_crash_banner.dart b/lib/widgets/app_crash_banner.dart new file mode 100644 index 00000000..5649a46e --- /dev/null +++ b/lib/widgets/app_crash_banner.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/app_crash_report.dart'; +import 'package:spotiflac_android/providers/app_crash_provider.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/widgets/expressive_button.dart'; + +class AppCrashBanner extends ConsumerWidget { + const AppCrashBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final crash = ref.watch(appCrashProvider).value; + if (crash == null) return const SizedBox.shrink(); + final colors = Theme.of(context).colorScheme; + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: AppContentCard( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.appCrashTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colors.error, + ), + ), + const SizedBox(height: 8), + Text(l10n.appCrashMessage), + const SizedBox(height: 8), + Text( + crash.description.isEmpty ? crash.reason : crash.description, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text(l10n.appCrashDetails), + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: SingleChildScrollView( + child: SelectableText(crash.report), + ), + ), + const SizedBox(height: 12), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ExpressiveButton( + tonal: true, + icon: const Icon(Icons.copy, size: 18), + onPressed: () => _copy(context, crash), + child: Text(l10n.appCrashCopy), + ), + ExpressiveButton( + text: true, + onPressed: () async { + try { + await ref + .read(appCrashProvider.notifier) + .dismiss(crash.id); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.appCrashDismissFailed)), + ); + } + } + }, + child: Text(l10n.appCrashDismiss), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _copy(BuildContext context, AppCrashReport crash) async { + final l10n = context.l10n; + try { + await Clipboard.setData(ClipboardData(text: crash.report)); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.logCopied))); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.appCrashCopyFailed))); + } + } + } +} diff --git a/test/app_crash_report_test.dart b/test/app_crash_report_test.dart new file mode 100644 index 00000000..a4eb5618 --- /dev/null +++ b/test/app_crash_report_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/app_crash_report.dart'; +import 'package:spotiflac_android/providers/app_crash_provider.dart'; +import 'package:spotiflac_android/services/app_crash_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/widgets/app_crash_banner.dart'; + +Map _report() => { + 'id': '123456', + 'timestamp': 123456, + 'reason': 'Native crash', + 'description': 'Fatal signal 6 (SIGABRT)', + 'version': '5.2.0 (2162)', + 'device': 'Example Phone', + 'android': '15', + 'sdk': 35, + 'trace': 'Abort message: invalid pointer\n#0 pc 1234 libdecoder.so', + 'runtime_error': 'StateError: request failed access_token=secret-value', + 'runtime_stack': '#0 Example.decode (example.dart:12)', +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + Map? stored; + Map? runtimeError; + String? clipboard; + var failDismiss = false; + + setUp(() { + stored = _report(); + runtimeError = null; + clipboard = null; + failDismiss = false; + messenger.setMockMethodCallHandler(backend, (call) async { + switch (call.method) { + case 'getPendingAppCrash': + return stored; + case 'dismissAppCrash': + if (failDismiss) throw PlatformException(code: 'disk_full'); + if ((call.arguments as Map)['id'] == stored?['id']) { + stored = null; + } + return null; + case 'recordAppRuntimeError': + runtimeError = call.arguments as Map; + return null; + } + return null; + }); + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboard = (call.arguments as Map)['text'] as String; + } + return null; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(backend, null); + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + LogBuffer().clear(); + }); + + test( + 'report contains original error, native and Dart stacks, and device info', + () { + final report = AppCrashReport.fromMap(_report()); + expect(report.report, contains('Example Phone')); + expect(report.report, contains('5.2.0 (2162)')); + expect(report.report, contains('Fatal signal 6 (SIGABRT)')); + expect(report.report, contains('#0 pc 1234 libdecoder.so')); + expect(report.report, contains('example.dart:12')); + expect(report.report, contains('[REDACTED]')); + expect(report.report, isNot(contains('secret-value'))); + }, + ); + + test('missing OS trace is explicit and does not fabricate a cause', () { + final report = AppCrashReport.fromMap(_report()..remove('trace')); + expect(report.report, contains('Android did not provide a stack trace')); + expect(report.report, contains('Fatal signal 6 (SIGABRT)')); + }); + + test( + 'pending report survives provider recreation until explicitly dismissed', + () async { + var container = ProviderContainer(); + expect((await container.read(appCrashProvider.future))?.id, '123456'); + container.dispose(); + container = ProviderContainer(); + addTearDown(container.dispose); + expect((await container.read(appCrashProvider.future))?.id, '123456'); + await container.read(appCrashProvider.notifier).dismiss('123456'); + expect(container.read(appCrashProvider).value, isNull); + expect(stored, isNull); + }, + ); + + test('failed dismissal retains the report for another attempt', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(appCrashProvider.future); + failDismiss = true; + await expectLater( + container.read(appCrashProvider.notifier).dismiss('123456'), + throwsA(isA()), + ); + expect(container.read(appCrashProvider).value?.id, '123456'); + }); + + test( + 'global Dart error capture preserves stacks and redacted recent logs', + () async { + LogBuffer().add( + LogEntry( + timestamp: DateTime(2026), + level: 'ERROR', + tag: 'Player', + message: 'decoder failed', + ), + ); + await AppCrashService.recordRuntimeError( + StateError('api_key=hidden-key'), + StackTrace.fromString('player.dart:42'), + ); + expect(runtimeError?['error'], contains('[REDACTED]')); + expect(runtimeError?['error'], isNot(contains('hidden-key'))); + expect(runtimeError?['stack'], contains('player.dart:42')); + expect(runtimeError?['logs'], contains('decoder failed')); + // Context capture alone does not create a confirmed crash report. + stored = null; + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(await container.read(appCrashProvider.future), isNull); + }, + ); + + for (final mornye in [false, true]) { + testWidgets('Home crash card copies full report and dismisses ($mornye)', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView(child: AppCrashBanner()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Aplikasi sebelumnya mengalami crash'), findsOneWidget); + expect(find.text('Fatal signal 6 (SIGABRT)'), findsOneWidget); + await tester.tap(find.text('Salin laporan untuk developer')); + await tester.pumpAndSettle(); + expect(clipboard, contains('Abort message: invalid pointer')); + expect(clipboard, contains('Example Phone')); + expect(clipboard, isNot(contains('secret-value'))); + expect(find.text('Aplikasi sebelumnya mengalami crash'), findsOneWidget); + await tester.tap(find.text('Tutup')); + await tester.pumpAndSettle(); + expect(find.text('Aplikasi sebelumnya mengalami crash'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('no crash means no Home card', (tester) async { + stored = null; + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp(home: Scaffold(body: AppCrashBanner())), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(AppCrashBanner), findsOneWidget); + expect(find.text('The app previously crashed'), findsNothing); + expect(find.byType(ExpansionTile), findsNothing); + }); +}