mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(crash): show persistent crash reports on Home
This commit is contained in:
1 parent
378c8f3749
commit
cb51d08939
18 files changed
+904
-1
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user