feat(crash): show persistent crash reports on Home

This commit is contained in:
zarzet committed 2026-10-19 16:17:58 +07:00
1 parent 378c8f3749
commit cb51d08939
18 files changed
+904 -1

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@
<application
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:name=".SpotiFlacApplication"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules_legacy"
@@ -0,0 +1,227 @@
package com.zarz.spotiflac
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import android.os.Process
import android.util.AtomicFile
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.io.PrintWriter
import java.io.Writer
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.atomic.AtomicBoolean
/** Persists diagnostics outside Dart, including process deaths during native work. */
internal object AppCrashReporter {
private const val MAX_TEXT = 128 * 1024
private const val MAX_TRACE_BYTES = 2 * 1024 * 1024
private lateinit var context: Context
private var sessionStartedAt = 0L
private var recovery: Future<*>? = 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<String, Any?>? {
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()
}
}
@@ -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<String>("id") ?: ""
withContext(Dispatchers.IO) { AppCrashReporter.dismissReport(id) }
result.success(null)
}
"recordAppRuntimeError" -> {
val error = call.argument<String>("error") ?: ""
val stack = call.argument<String>("stack") ?: ""
val logs = call.argument<String>("logs") ?: ""
withContext(Dispatchers.IO) { AppCrashReporter.recordRuntimeError(error, stack, logs) }
result.success(null)
}
"cancelLibraryScan" -> handleCancelLibraryScan(call, result)
"pauseLibraryScan" -> handlePauseLibraryScan(call, result)
"resumeLibraryScan" -> handleResumeLibraryScan(call, result)
@@ -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<Int, List<Any>>) {
fun number(key: Int): Long = values[key]?.firstOrNull() as? Long ?: 0L
fun blobs(key: Int): List<ByteArray> = values[key].orEmpty().filterIsInstance<ByteArray>()
fun string(key: Int): String = blobs(key).firstOrNull()?.toString(Charsets.UTF_8).orEmpty()
}
private fun fields(bytes: ByteArray, wanted: Set<Int>): Fields {
val values = mutableMapOf<Int, MutableList<Any>>()
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)
}
}
@@ -0,0 +1,10 @@
package com.zarz.spotiflac
import android.app.Application
class SpotiFlacApplication : Application() {
override fun onCreate() {
super.onCreate()
AppCrashReporter.initialize(this)
}
}
@@ -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))
}
}
}
@@ -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()
}
}
+7
View File
@@ -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"
+7
View File
@@ -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"
+6
View File
@@ -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));
},
);
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:spotiflac_android/utils/logger.dart';
class AppCrashReport {
AppCrashReport.fromMap(Map<String, dynamic> 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<String, dynamic> 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());
}
}
+29
View File
@@ -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, AppCrashReport?>(
AppCrashNotifier.new,
);
class AppCrashNotifier extends AsyncNotifier<AppCrashReport?> {
@override
Future<AppCrashReport?> 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<void> dismiss(String id) async {
await PlatformBridge.dismissAppCrash(id);
if (ref.mounted && state.value?.id == id) {
state = const AsyncData(null);
}
}
}
+2
View File
@@ -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<HomeTab>
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
_buildHeaderSliver(greeting: greeting),
if (_showsHome) const SliverToBoxAdapter(child: AppCrashBanner()),
_buildHomeIntroSliver(
sectionMotion: sectionMotion,
hasResults: hasResults,
+24
View File
@@ -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<void> 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.
}
}
}
+28
View File
@@ -2236,6 +2236,34 @@ class PlatformBridge {
}
}
// ---- Application crash diagnostics ----
static Future<Map<String, dynamic>?> getPendingAppCrash() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return null;
return _decodeNullableMapResult(
await _channel.invokeMethod('getPendingAppCrash'),
'getPendingAppCrash',
);
}
static Future<void> dismissAppCrash(String id) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
await _channel.invokeMethod<void>('dismissAppCrash', {'id': id});
}
static Future<void> recordAppRuntimeError({
required String error,
required String stack,
required String logs,
}) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
await _channel.invokeMethod<void>('recordAppRuntimeError', {
'error': error,
'stack': stack,
'logs': logs,
});
}
// ---- Library scan ----
static Future<Map<String, dynamic>?> getLibraryScanExitInfo({
+3
View File
@@ -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;
+112
View File
@@ -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<void> _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)));
}
}
}
}
+191
View File
@@ -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<String, dynamic> _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<String, dynamic>? stored;
Map<Object?, Object?>? 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<Object?, Object?>;
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<PlatformException>()),
);
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);
});
}