fix(kiosk): detect ANR/OOM/native crashes on restart via ApplicationExitInfo + dirty sentinel

This commit is contained in:
dadaloop82
2026-05-14 11:47:05 +00:00
parent 2d70e7a688
commit 1b7b271b43
3 changed files with 113 additions and 3 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 11 versionCode = 12
versionName = "1.7.0" versionName = "1.7.1"
} }
signingConfigs { signingConfigs {
@@ -1,8 +1,11 @@
package it.dadaloop.evershelf.kiosk package it.dadaloop.evershelf.kiosk
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import org.json.JSONObject import org.json.JSONObject
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -41,6 +44,8 @@ object ErrorReporter {
// SharedPreferences for crash persistence // SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors" private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json" private const val KEY_PENDING = "pending_crash_json"
private const val KEY_WAS_RUNNING = "was_running_dirty"
private const val KEY_LAST_EXIT_TS = "last_reported_exit_ts"
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
@@ -76,6 +81,9 @@ object ErrorReporter {
// Send any crash that was saved to prefs during a previous session // Send any crash that was saved to prefs during a previous session
sendPendingCrash() sendPendingCrash()
// Detect ANR / OOM / native crashes from the previous run
detectPreviousCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported // Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler() val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -96,6 +104,17 @@ object ErrorReporter {
} }
} }
/**
* Call from Activity.onDestroy() on a *clean* exit (back-pressed, settings, shutdown).
* Clears the dirty-launch sentinel so the next start does not report a false positive.
*/
fun markCleanStop() {
if (::appContext.isInitialized) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, false).apply()
}
}
/** /**
* Report a caught [Throwable] asynchronously (does not block UI thread). * Report a caught [Throwable] asynchronously (does not block UI thread).
*/ */
@@ -132,6 +151,96 @@ object ErrorReporter {
// ── Internal ───────────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────────
/**
* Detects whether the *previous* run of the app ended with a crash, ANR or OOM kill.
*
* On Android 11+ (API 30) we use [ActivityManager.getHistoricalProcessExitReasons] which
* gives the exact reason and (for Java crashes) a stack trace.
*
* On Android 710 we use a "dirty-launch sentinel": a boolean in SharedPreferences that is
* set to `true` on every start and `false` only when the activity is destroyed cleanly via
* [markCleanStop]. If it is still `true` on the next start, the previous run was not clean.
*/
private fun detectPreviousCrash() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
detectExitReasonApi30()
} else {
// API 2429: dirty-launch sentinel
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_WAS_RUNNING, false)) {
reportAsync(
type = "crash-sentinel",
message = "App was not cleanly shut down on previous run (ANR / OOM / native crash suspected).",
stack = "",
context = mapOf(
"device" to deviceInfo,
"note" to "Detected via dirty-launch sentinel (API ${Build.VERSION.SDK_INT})"
)
)
}
}
// Mark this launch as running — will be cleared by markCleanStop() on clean exit
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, true).apply()
}
@RequiresApi(Build.VERSION_CODES.R)
private fun detectExitReasonApi30() {
try {
val am = appContext.getSystemService(ActivityManager::class.java) ?: return
// Check the last 5 exits; stop at the first we already reported
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
if (exits.isEmpty()) return
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val lastReportedTs = prefs.getLong(KEY_LAST_EXIT_TS, 0L)
val crashReasons = setOf(
ApplicationExitInfo.REASON_CRASH,
ApplicationExitInfo.REASON_CRASH_NATIVE,
ApplicationExitInfo.REASON_ANR,
ApplicationExitInfo.REASON_LOW_MEMORY
)
var newestTs = lastReportedTs
for (exit in exits) {
if (exit.timestamp <= lastReportedTs) continue // already reported
if (exit.reason !in crashReasons) continue
val reasonName = when (exit.reason) {
ApplicationExitInfo.REASON_CRASH -> "crash-java"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash-native"
ApplicationExitInfo.REASON_ANR -> "anr"
ApplicationExitInfo.REASON_LOW_MEMORY -> "oom-kill"
else -> "exit-${exit.reason}"
}
val msg = exit.description?.takeIf { it.isNotEmpty() }
?: "${exit.processName ?: "app"} terminated (reason ${exit.reason})"
// Java crashes include a tombstone trace — read up to 4KB
var stack = ""
try {
exit.traceInputStream?.bufferedReader()?.use { stack = it.readText().take(4000) }
} catch (_: Exception) {}
val ctx = mutableMapOf<String, Any?>(
"device" to deviceInfo,
"reason" to exit.reason,
"process" to (exit.processName ?: ""),
"crash_ts" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp)),
"note" to "Detected via ApplicationExitInfo on restart (API ${Build.VERSION.SDK_INT})"
)
reportAsync(type = reasonName, message = msg, stack = stack, context = ctx)
if (exit.timestamp > newestTs) newestTs = exit.timestamp
}
if (newestTs > lastReportedTs) {
prefs.edit().putLong(KEY_LAST_EXIT_TS, newestTs).apply()
}
} catch (_: Exception) {}
}
private fun fingerprint(type: String, message: String): String { private fun fingerprint(type: String, message: String): String {
val key = "$type:${message.take(120)}" val key = "$type:${message.take(120)}"
return key.hashCode().toString(16) return key.hashCode().toString(16)
@@ -1040,6 +1040,7 @@ class KioskActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
ErrorReporter.markCleanStop()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
tts = null tts = null