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:
co-authored by
Claude Opus 5
parent
7b4251c5d6
commit
be03175996
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user