ios#24, the Android half: the app can see its own crashes

Android 0.7 crashed on answering a call and it took somebody noticing. There
was nothing in the tree that read exit reasons, so a crash was something a
person had to report in prose.

ApplicationExitInfo is Android's own record: no library, no network call, no
identifier, the same shape of answer MetricKit is on the iPhone. It is stricter
here in one way. MetricKit delivers a payload once and it is gone, so that side
keeps a file; Android hands back the same list every time it is asked, so the
system is the store and this app writes nothing at all.

Ordinary exits are dropped rather than greyed out. A list of "you swiped the
app away" buries the one line somebody came to the screen for. REASON_SIGNALED
goes with them: in practice it is the system or a debugger sending SIGKILL, and
a real native crash arrives as REASON_CRASH_NATIVE.

setProcessStateSummary at launch stamps the running build into the 128 bytes
Android returns with an exit record, so a report can say which build died. By
the time anybody reads one the app has usually been updated.

Saved through the system file picker, not a share sheet. There is no
FileProvider here and a trace is too big to travel as intent text: past a few
hundred kilobytes that throws, which would fail on exactly the largest and most
useful report. Saving is also how a history backup leaves this app.

The dialog's layout came from the test. With everything in one scrolling column
the address and the save button sat below the explanation and off the bottom of
a small screen, so the two things somebody opened it to do were the two they had
to go looking for. Only the trace scrolls now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-15 14:40:58 -04:00
co-authored by Claude Opus 5
parent 7b4251c5d6
commit be03175996
7 changed files with 662 additions and 0 deletions
@@ -0,0 +1,211 @@
package io.flamenet.messenger.app
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* What this phone recorded about the app's own crashes, and nothing else.
*
* Until now a crash was something a person had to tell us about. Android 0.7
* crashed on answering a call and it took somebody noticing, which is a poor
* way to learn that a call fails on a device nobody here owns.
*
* `ApplicationExitInfo` is the answer that does not contradict the product.
* It is Android's own record, kept by the system, and it needs no library, no
* network call and no identifier. The iPhone side uses MetricKit for the same
* reason.
*
* NOTHING IS SENT ANYWHERE
*
* There is no upload, not behind a setting and not on a timer. A messenger
* that promises the server cannot read your messages cannot also mail itself
* diagnostics about your phone. Somebody who wants to send one opens it, reads
* it, and saves it deliberately.
*
* This writes nothing either, which is the one place it is stricter than the
* iPhone. MetricKit delivers a payload once and it is gone, so that side has
* to keep a file. Android hands back the same list every time it is asked, so
* the system is the store and this app keeps no copy of its own.
*/
object AppDiagnostics {
/**
* Android keeps a bounded history of its own, currently 16 records, and
* asking for more than it has is not an error. Asking for 0 means "all of
* them", which is what the OS is willing to give.
*/
private const val ASK_FOR_ALL = 0
/**
* A trace can run to hundreds of kilobytes, and the whole of one is on
* screen. Past this it is cut with a line saying so, because a screen that
* silently stops part way through a stack is worse than a short one.
*/
internal const val TRACE_LIMIT = 400_000
/** What went wrong, in words rather than in a constant's name. */
enum class Kind(val title: String) {
Crash("Crash"),
NativeCrash("Crash in a native library"),
Frozen("Frozen screen"),
OutOfMemory("Ran out of memory"),
Resources("Stopped for using too much"),
StartupFailure("Failed to start"),
}
/** One thing that went wrong, reduced to something a person can read. */
data class Report(
val kind: Kind,
/** When the process died, epoch millis. */
val at: Long,
/** Which build it was, from the summary set at launch. */
val appVersion: String,
/** One line, for the list. */
val summary: String,
/** The whole thing, for somebody who wants to read or send it. */
val detail: String,
) {
val id: String get() = "$at-${kind.name}"
}
/**
* Which exits are trouble.
*
* Most of the reasons Android reports are ordinary: the person swiped the
* app away, the system reclaimed it, the package updated. A list of those
* is noise that buries the one line somebody came to this screen for, so
* they are dropped rather than shown greyed out.
*
* `REASON_SIGNALED` is deliberately not here. In practice it is the system
* or a debugger sending SIGKILL, and a real native crash arrives as
* `REASON_CRASH_NATIVE` instead.
*/
fun kindOf(reason: Int): Kind? =
when (reason) {
ApplicationExitInfo.REASON_CRASH -> Kind.Crash
ApplicationExitInfo.REASON_CRASH_NATIVE -> Kind.NativeCrash
ApplicationExitInfo.REASON_ANR -> Kind.Frozen
ApplicationExitInfo.REASON_LOW_MEMORY -> Kind.OutOfMemory
ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> Kind.Resources
ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> Kind.StartupFailure
else -> null
}
/**
* The one line the list shows.
*
* Android's own `description` is usually the useful half, and it is
* sometimes null or blank, so the kind carries the sentence on its own
* when it has to.
*/
fun summarise(
kind: Kind,
description: String?,
): String {
val said = description?.trim().orEmpty()
return when {
said.isEmpty() -> defaultSummary(kind)
else -> said
}
}
private fun defaultSummary(kind: Kind): String =
when (kind) {
Kind.Crash -> "The app stopped without saying why."
Kind.NativeCrash -> "A library the app uses stopped it."
Kind.Frozen -> "The screen stopped responding and Android closed the app."
Kind.OutOfMemory -> "Android closed the app to free memory."
Kind.Resources -> "Android closed the app for using too much of something."
Kind.StartupFailure -> "The app could not finish starting."
}
/** The stamp used in the list and at the top of a saved file. */
fun stamp(at: Long): String =
SimpleDateFormat("d MMM yyyy, HH:mm", Locale.getDefault()).format(Date(at))
/**
* Record which build is running, so a report can say which one died.
*
* Android hands these 128 bytes back with the exit info, and it is the
* only way to know the version at the time of the crash: by the time
* anybody reads the report the app has usually been updated. Called from
* `MainActivity.onCreate`, so a crash before the first screen reports an
* unknown build rather than lying about a known one.
*/
fun noteVersion(context: Context) {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
?: return
runCatching {
manager.setProcessStateSummary(
AppUpdate.installedVersionName(context).toByteArray(Charsets.UTF_8),
)
}
}
/** Everything the system still remembers, newest first. */
fun reports(context: Context): List<Report> {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
?: return emptyList()
val raw =
runCatching {
manager.getHistoricalProcessExitReasons(
context.packageName,
ASK_FOR_ALL,
ASK_FOR_ALL,
)
}.getOrNull().orEmpty()
return raw.mapNotNull { info ->
val kind = kindOf(info.reason) ?: return@mapNotNull null
Report(
kind = kind,
at = info.timestamp,
appVersion = version(info),
summary = summarise(kind, info.description),
detail = detail(info, kind),
)
}
}
private fun version(info: ApplicationExitInfo): String =
info.processStateSummary
?.toString(Charsets.UTF_8)
?.takeIf { it.isNotBlank() }
?: "unknown"
/**
* The whole report, as the text a person saves and sends.
*
* A trace is only there for some kinds, so its absence is said out loud.
* Silence would read as an empty report rather than as a kind of exit
* Android does not keep a stack for.
*/
private fun detail(
info: ApplicationExitInfo,
kind: Kind,
): String {
val head =
buildString {
appendLine("Flamenet Messenger ${kind.title}")
appendLine(stamp(info.timestamp))
appendLine("Build ${version(info)}")
appendLine("Reason ${info.reason}, importance ${info.importance}")
info.description?.takeIf { it.isNotBlank() }?.let { appendLine(it) }
appendLine()
}
val trace =
runCatching {
info.traceInputStream?.bufferedReader()?.use { reader ->
val text = reader.readText()
if (text.length > TRACE_LIMIT) {
text.take(TRACE_LIMIT) + "\n\n[cut here, the trace is longer than this]"
} else {
text
}
}
}.getOrNull()
return head + (trace ?: "Android kept no stack trace for this kind of exit.")
}
}
@@ -102,6 +102,17 @@ object AppUpdate {
*/
const val DONATE_URL = "$OFFICIAL_SITE/donate"
/**
* Where a crash report goes when somebody chooses to send one.
*
* The project's mailbox rather than the relay's, for the same reason
* [DONATE_URL] is the project's site: a crash is a defect in this app, and
* whoever runs the relay this phone is signed in to did not write it and
* cannot fix it. Published at flamenetmessenger.com/contact, so it is an
* address somebody could have found without the app.
*/
const val SUPPORT_EMAIL = "support@flamenetmessenger.com"
/** What an explicit check found, including which host answered. */
sealed interface CheckResult {
data class Available(
@@ -859,6 +859,9 @@ fun ColumnScope.AccountScreen(
SectionBand()
SettingsSection { AppVersionSection(model) }
SectionBand()
SettingsSection { DiagnosticsSection() }
SectionBand()
SettingsSection { DonateSection() }
@@ -0,0 +1,221 @@
package io.flamenet.messenger.app
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Crashes and freezes this phone recorded about this app.
*
* The point of the section is the sentence at the top: none of it has been
* sent anywhere. Somewhere in every other app is a settings toggle admitting
* that diagnostics are already uploaded and offering to stop. This says the
* opposite, and it has to say it where somebody reads it rather than in a
* policy.
*/
@Composable
fun ColumnScope.DiagnosticsSection() {
val context = LocalContext.current
var reports by remember { mutableStateOf(emptyList<AppDiagnostics.Report>()) }
// Read every time the screen is built. Android keeps the list, so there is
// nothing to refresh and nothing to go stale.
LaunchedEffect(Unit) { reports = AppDiagnostics.reports(context) }
DiagnosticsSection(reports)
}
/**
* The same section over a list somebody else read, so a test can put reports
* on a phone that has never crashed.
*/
@Composable
internal fun ColumnScope.DiagnosticsSection(reports: List<AppDiagnostics.Report>) {
var open by remember { mutableStateOf<AppDiagnostics.Report?>(null) }
Text("App diagnostics", style = MaterialTheme.typography.titleMedium)
Text(
"Nothing here has left your phone. Android records a report when the app " +
"crashes or freezes, and this screen shows what it kept, so there is " +
"something to send if you ever want to. Sending one is something you do, " +
"not something that happens.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
if (reports.isEmpty()) {
Text(
"No crashes or freezes recorded.",
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("diagnostics-empty"),
)
return
}
reports.forEach { report ->
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable { open = report }
.padding(vertical = Space.xs)
.testTag("diagnostic-${report.id}"),
) {
Text(report.kind.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(
report.summary,
fontSize = 13.sp,
color = Palette.SecondaryText,
maxLines = 2,
)
Text(
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
fontSize = 11.sp,
color = Palette.SecondaryText,
)
}
}
open?.let { report ->
ReportDialog(report = report, onDismiss = { open = null })
}
}
/**
* One report in full, and the only way it ever leaves the phone.
*
* Saved through the system file picker rather than handed to a share sheet.
* The app has no `FileProvider` and a trace is too big to travel as intent
* text: past a few hundred kilobytes that throws, which would fail on exactly
* the largest and most useful report. Saving is also how a history backup
* leaves this app, so it is the gesture people here already know.
*/
@Composable
private fun ReportDialog(
report: AppDiagnostics.Report,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var saved by remember { mutableStateOf<String?>(null) }
var copied by remember { mutableStateOf(false) }
val saveLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain"),
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
saved =
runCatching {
context.contentResolver.openOutputStream(uri)?.use {
it.write(report.detail.toByteArray(Charsets.UTF_8))
} ?: error("nothing to write to")
}.fold(
onSuccess = { "Saved. Attach it to a message to the address above." },
onFailure = { e -> "Couldn't write the file: ${e.message}" },
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(report.kind.title) },
// Everything except the trace is fixed, and only the trace scrolls.
// With the whole dialog in one scrolling column the address and the
// save button sat below the explanation and off the bottom of a small
// screen, so the two things somebody opened this to do were the two
// they had to go looking for.
text = {
Column {
Text(
"${AppDiagnostics.stamp(report.at)} · build ${report.appVersion}",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
// Named before somebody sends it. It is the app's own functions
// and the kind of device, not anybody's conversation, and saying
// which is the difference between an informed share and a
// hopeful one.
Text(
"This report names the parts of Flamenet that were running and the kind " +
"of phone you have. It does not contain your messages, your contacts " +
"or your keys.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Row {
OutlinedButton(
onClick = {
clipboard.setText(AnnotatedString(AppUpdate.SUPPORT_EMAIL))
copied = true
},
modifier = Modifier.testTag("diagnostics-address"),
) { Text(if (copied) "Address copied" else AppUpdate.SUPPORT_EMAIL) }
Spacer(Modifier.width(Space.sm))
OutlinedButton(
onClick = { saveLauncher.launch(fileName(report)) },
modifier = Modifier.testTag("diagnostics-save"),
) { Text("Save…") }
}
saved?.let {
Spacer(Modifier.height(Space.xs))
Text(
it,
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("diagnostics-saved"),
)
}
Spacer(Modifier.height(Space.sm))
Text(
report.detail,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = Palette.SecondaryText,
modifier =
Modifier
.heightIn(max = 260.dp)
.verticalScroll(rememberScrollState())
.testTag("diagnostics-detail"),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/** A name somebody can find again in their downloads. */
internal fun fileName(report: AppDiagnostics.Report): String =
"flamenet-${report.kind.name.lowercase()}-${report.at}.txt"
@@ -91,6 +91,11 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
// Stamps the running build into the 128 bytes Android hands back with
// an exit record, so a crash report can say which build died. By the
// time anybody reads one the app has usually been updated.
AppDiagnostics.noteVersion(this)
val lock = AppLock(this)
setContent {
@@ -0,0 +1,131 @@
package io.flamenet.messenger.app
import android.app.ApplicationExitInfo
import androidx.test.core.app.ApplicationProvider
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* ios#24, the Android half: the app had no crash visibility at all.
*
* `ApplicationExitInfo` is Android's own record, kept by the system, needing
* no library, no network call and no identifier. It is the same shape of
* answer MetricKit is on the iPhone, and it is stricter in one way: this side
* writes nothing, because the system hands back the same list every time it is
* asked rather than delivering it once.
*
* The mapping is pure and is tested for what it is; whether a real crash shows
* up takes a phone that has crashed.
*/
@RunWith(RobolectricTestRunner::class)
class ACrashReportHasSomewhereToGoTest {
private val context = ApplicationProvider.getApplicationContext<android.app.Application>()
@Test
fun `the exits that mean trouble are kept`() {
assertEquals(
AppDiagnostics.Kind.Crash,
AppDiagnostics.kindOf(ApplicationExitInfo.REASON_CRASH),
)
assertEquals(
AppDiagnostics.Kind.NativeCrash,
AppDiagnostics.kindOf(ApplicationExitInfo.REASON_CRASH_NATIVE),
)
assertEquals(
AppDiagnostics.Kind.Frozen,
AppDiagnostics.kindOf(ApplicationExitInfo.REASON_ANR),
)
assertEquals(
AppDiagnostics.Kind.OutOfMemory,
AppDiagnostics.kindOf(ApplicationExitInfo.REASON_LOW_MEMORY),
)
}
@Test
fun `an ordinary exit is not trouble`() {
// A list of "you swiped the app away" buries the one line somebody came
// to this screen for.
for (reason in
listOf(
ApplicationExitInfo.REASON_USER_REQUESTED,
ApplicationExitInfo.REASON_EXIT_SELF,
ApplicationExitInfo.REASON_PACKAGE_UPDATED,
ApplicationExitInfo.REASON_OTHER,
// In practice the system or a debugger sending SIGKILL. A real
// native crash arrives as REASON_CRASH_NATIVE instead.
ApplicationExitInfo.REASON_SIGNALED,
)
) {
assertNull(AppDiagnostics.kindOf(reason), "reason $reason should not be shown")
}
}
@Test
fun `a missing description still says something`() {
for (kind in AppDiagnostics.Kind.entries) {
for (said in listOf(null, "", " ")) {
val line = AppDiagnostics.summarise(kind, said)
assertTrue(line.isNotBlank(), "$kind with ${said.toString()} gave a blank line")
}
}
assertEquals(
"Native crash in libwebrtc",
AppDiagnostics.summarise(AppDiagnostics.Kind.NativeCrash, " Native crash in libwebrtc "),
)
}
@Test
fun `reading the list needs nothing to exist`() {
// A phone that has never crashed is the normal case, and it must not
// throw on the way to an empty screen.
assertEquals(emptyList(), AppDiagnostics.reports(context))
}
@Test
fun `noting the version does not throw`() {
AppDiagnostics.noteVersion(context)
}
@Test
fun `a saved report is named so it can be found again`() {
val report =
AppDiagnostics.Report(
kind = AppDiagnostics.Kind.Frozen,
at = 1_757_000_000_000,
appVersion = "0.24",
summary = "anything",
detail = "anything",
)
assertEquals("flamenet-frozen-1757000000000.txt", fileName(report))
}
/**
* The promise the screen opens with, and the kind a later change breaks by
* accident rather than on purpose.
*/
@Test
fun `nothing uploads a report on its own`() {
for (name in listOf("AppDiagnostics.kt", "DiagnosticsSection.kt")) {
val src = File("src/main/kotlin/io/flamenet/messenger/app/$name")
assertTrue(src.isFile, "this test reads app/src/main/kotlin/.../$name")
val text = src.readText()
for (forbidden in listOf("OkHttp", "HttpURLConnection", "URL(", "Retrofit")) {
assertTrue(
!text.contains(forbidden),
"$name reaches the network via $forbidden",
)
}
}
}
@Test
fun `the address is the project's, and it is published`() {
assertEquals("support@flamenetmessenger.com", AppUpdate.SUPPORT_EMAIL)
assertTrue(!AppUpdate.SUPPORT_EMAIL.contains("relay."))
}
}
@@ -0,0 +1,80 @@
package io.flamenet.messenger.app
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Surface
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Tapping a crash opens it, and the open report says where to send it.
*
* The list rendering is separated from the reading for this: a phone running a
* test has never crashed, so the only way to see the populated screen is to
* hand it reports.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class TheCrashListOpensAReportTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private val frozen =
AppDiagnostics.Report(
kind = AppDiagnostics.Kind.Frozen,
at = 1_757_000_000_000,
appVersion = "0.24",
summary = "Input dispatching timed out, the main thread was busy.",
detail = "\"main\" prio=5 tid=1 Blocked\n at CallManager.answer\n",
)
private fun show(reports: List<AppDiagnostics.Report>) {
compose.setContent {
MessengerTheme { Surface { Column { DiagnosticsSection(reports) } } }
}
compose.waitForIdle()
}
@Test
fun `a phone with nothing wrong says so`() {
show(emptyList())
compose.onNodeWithTag("diagnostics-empty").assertIsDisplayed()
}
@Test
fun `the row carries the kind, the build and the day`() {
show(listOf(frozen))
compose.onNodeWithText("Frozen screen").assertIsDisplayed()
compose.onNodeWithText(frozen.summary).assertIsDisplayed()
}
@Test
fun `opening one offers the address and a way off the phone`() {
show(listOf(frozen))
compose.onNodeWithTag("diagnostic-${frozen.id}").performClick()
compose.waitForIdle()
// The address is the whole point of the dialog: without it somebody
// who wants to help has a blob and nowhere to send it.
compose.onNodeWithTag("diagnostics-address").assertIsDisplayed()
compose.onNodeWithTag("diagnostics-save").assertIsDisplayed()
}
@Test
fun `the open report says what is in it before anybody sends it`() {
show(listOf(frozen))
compose.onNodeWithTag("diagnostic-${frozen.id}").performClick()
compose.waitForIdle()
compose
.onNodeWithText(
"It does not contain your messages",
substring = true,
).assertIsDisplayed()
}
}