feat(android): a diagnostics page answers why the fleet row is stale

A stale "last seen" had no client-side answer: a dead worker, a down
claw, and a claw that drops writes all looked identical from the
settings screen. The new page separates them: the WorkManager jobs'
live state (a cancelled schedule renders as "not scheduled"), the two
clocks — the phone's last landed report against the claw's last stamp
— whose disagreement names the broken end, auto-detected system checks
(battery optimization, background restriction, notifications) with
jumps into the exact settings page, a report-now kick, and a 200-line
in-memory flight recorder where every worker run logs its outcome.
This commit is contained in:
vhaudiquet committed 2026-10-25 10:28:38 +02:00
1 parent e4f4a3a10b
commit 7225dfddd3
10 files changed
+564 -14

No files matched your search

@@ -2,6 +2,7 @@ package rs.rhema.phone.di
import android.content.Context
import rs.rhema.phone.data.AgentIdentityRepository
import rs.rhema.phone.diagnostics.DiagLog
import rs.rhema.phone.data.SessionCache
import rs.rhema.phone.data.SettingsRepository
import rs.rhema.phone.data.StatusRepository
@@ -38,5 +39,9 @@ class AppContainer(context: Context) {
val notifier = Notifier(appContext)
// The workers' flight recorder: every run logs its outcome here, and
// the diagnostics screen reads it back.
val diag = DiagLog()
fun schedulePeriodicWork() = schedulePeriodicWork(appContext)
}
@@ -0,0 +1,46 @@
package rs.rhema.phone.diagnostics
import rs.rhema.phone.net.ClawResult
/**
* The app's in-memory flight recorder: a bounded ring of one-line events
* the workers and screens append to, newest last. It lives only as long
* as the process — which is exactly the point: "what did the background
* work actually do while nobody was watching" is the question the
* diagnostics screen answers from it, and a log that outlives the
* process would need storage and rotation this app does not otherwise
* want.
*/
class DiagLog(private val capacity: Int = 200) {
/** One recorded line: when it happened, and the line itself. */
data class Entry(val at: Long, val line: String)
private val lock = Any()
private val entries = ArrayDeque<Entry>()
/** Appends one line, dropping the oldest when the ring is full. */
fun log(line: String) {
val entry = Entry(System.currentTimeMillis(), line)
synchronized(lock) {
entries.addLast(entry)
while (entries.size > capacity) {
entries.removeFirst()
}
}
}
/** A copy of the ring, oldest first. */
fun snapshot(): List<Entry> = synchronized(lock) { entries.toList() }
}
/**
* The one failure vocabulary every worker logs: an HTTP status with the
* body's head (the claw's error shapes carry the reason there), or the
* network message. A token never appears here — bodies are error JSON.
*/
fun ClawResult<*>.diagText(): String = when (this) {
is ClawResult.HttpError -> "HTTP $code" + (body?.let { " — ${it.take(160)}" } ?: "")
is ClawResult.Network -> "network: $message"
is ClawResult.Ok -> "ok"
}
@@ -0,0 +1,388 @@
package rs.rhema.phone.ui
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.work.WorkInfo
import androidx.work.WorkManager
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import rs.rhema.phone.R
import rs.rhema.phone.di.AppContainer
import rs.rhema.phone.net.ClawResult
import rs.rhema.phone.work.PHONE_COMMANDS
import rs.rhema.phone.work.PHONE_STATUS
import rs.rhema.phone.work.NOTIFICATIONS
import rs.rhema.phone.work.enqueuePhoneStatusNow
/**
* The background-work health page: what the three scheduled jobs are
* doing, whether this phone's reports are actually landing on the claw
* (the two clocks' disagreement is the diagnosis between "the worker is
* dead", "the claw is down", and "the claw is up but not storing"), the
* system checks whose wrong setting silences the workers, and the
* workers' own log. Checks re-read on every ON_RESUME so returning from
* the settings app flips the row without a restart.
*/
@Composable
fun DiagnosticsContent(container: AppContainer) {
val lifecycleOwner = LocalLifecycleOwner.current
var resumeKey by remember { mutableIntStateOf(0) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) resumeKey++
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Four cards where every other settings page has one: the column owns
// the spacing the single-card pages never needed.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
WorkCard(container)
ClocksCard(container, resumeKey)
ChecksCard(resumeKey)
LogCard(container)
}
}
// ---------------------------------------------------------------- work
/**
* The three periodic jobs' live state, straight from WorkManager. An
* empty info list is the loud answer: the schedule is gone (cancelled,
* force-stopped, never enqueued) — the exact "silent for days" state the
* rest of the app cannot show.
*/
@Composable
private fun WorkCard(container: AppContainer) {
val context = LocalContext.current
val wm = remember { WorkManager.getInstance(context) }
val statusInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_STATUS).collectAsState(initial = null)
val notifyInfos by wm.getWorkInfosForUniqueWorkFlow(NOTIFICATIONS).collectAsState(initial = null)
val commandInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_COMMANDS).collectAsState(initial = null)
RhemaCard(title = stringResource(R.string.card_diag_work), meta = stringResource(R.string.diag_work_hint)) {
WorkRow(stringResource(R.string.diag_work_status), statusInfos, first = true)
WorkRow(stringResource(R.string.diag_work_notify), notifyInfos)
WorkRow(stringResource(R.string.diag_work_commands), commandInfos)
CardRow(
title = stringResource(R.string.diag_report_now_title),
description = stringResource(R.string.diag_report_now_desc),
) {
Button(onClick = { enqueuePhoneStatusNow(context) }) {
Text(stringResource(R.string.diag_report_now))
}
}
}
}
@Composable
private fun WorkRow(label: String, infos: List<WorkInfo>?, first: Boolean = false) {
// Periodic work holds one info; take the first for both the state and
// the next window. An empty list means the schedule itself is gone.
val info = infos?.firstOrNull()
val stateLabel = when {
infos == null -> stringResource(R.string.diag_state_checking)
info == null -> stringResource(R.string.diag_state_not_scheduled)
else -> when (info.state) {
WorkInfo.State.ENQUEUED -> stringResource(R.string.diag_state_enqueued)
WorkInfo.State.RUNNING -> stringResource(R.string.diag_state_running)
WorkInfo.State.SUCCEEDED -> stringResource(R.string.diag_state_enqueued)
WorkInfo.State.FAILED -> stringResource(R.string.diag_state_failed)
WorkInfo.State.BLOCKED -> stringResource(R.string.diag_state_blocked)
WorkInfo.State.CANCELLED -> stringResource(R.string.diag_state_cancelled)
}
}
val tint = when (info?.state) {
WorkInfo.State.RUNNING -> JadeiteOk
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError
WorkInfo.State.BLOCKED -> JadeiteWarn
null -> JadeiteWarn
else -> JadeiteText3
}
val detail = when {
infos == null -> null
info == null -> null
else -> listOfNotNull(
info.nextScheduleTimeMillis
.takeIf { it > 0 && it != Long.MAX_VALUE }
?.let { stringResource(R.string.diag_work_next_fmt, inMinutes(it)) },
info.runAttemptCount
.takeIf { it > 0 }
?.let { stringResource(R.string.diag_work_attempts_fmt, it) },
).joinToString(" · ").ifEmpty { null }
}
CardRow(title = label, description = detail, first = first) {
AppBadge(stateLabel, tint = tint)
}
}
// -------------------------------------------------------------- clocks
/**
* The two ends of the reporting pipeline, one clock each: when this
* phone last landed a full report (its own record), and when the claw
* last stamped this device (the fleet row's `last_seen`, re-read live).
* The callout names the disagreement, because that disagreement is the
* whole diagnosis.
*/
@Composable
private fun ClocksCard(container: AppContainer, resumeKey: Int) {
val lastReportAt by container.status.lastReportAt.collectAsState(initial = 0L)
// null = unchecked, -1 = checked and the claw has no row for us.
var clawSeen by remember { mutableStateOf<Long?>(null) }
var clawError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(resumeKey, lastReportAt) {
when (val result = container.api.phones()) {
is ClawResult.Ok -> {
val deviceId = container.settings.deviceId()
clawSeen = result.value.phones
.firstOrNull { it.device == deviceId }
?.lastSeen ?: -1L
clawError = null
}
is ClawResult.HttpError -> clawError = "HTTP ${result.code}"
is ClawResult.Network -> clawError = result.message
}
}
RhemaCard(title = stringResource(R.string.card_diag_clocks)) {
CardRow(
title = stringResource(R.string.diag_clock_phone),
description = stringResource(R.string.diag_clock_phone_desc),
first = true,
) {
AppBadge(if (lastReportAt > 0) clock(lastReportAt) else stringResource(R.string.diag_clock_never))
}
CardRow(
title = stringResource(R.string.diag_clock_claw),
description = stringResource(R.string.diag_clock_claw_desc),
) {
val seen = clawSeen
AppBadge(
when {
clawError != null -> stringResource(R.string.diag_clock_unreachable)
seen == null -> stringResource(R.string.diag_state_checking)
seen < 0 -> stringResource(R.string.diag_clock_never)
else -> clock(seen)
},
)
}
val currentError = clawError
val seen = clawSeen
when {
currentError != null -> AppCallout(
tone = CalloutTone.Error,
text = stringResource(R.string.diag_callout_unreachable, currentError),
)
// The claw is behind the phone by more than one cadence: posts
// are being sent and not stored. One cadence of drift is the
// schedule's normal jitter; anything more is a lie one of the
// two ends is telling.
seen != null && seen > 0 && lastReportAt > seen + NOT_LANDED_MS ->
AppCallout(
tone = CalloutTone.Warn,
text = stringResource(R.string.diag_callout_not_landing),
)
lastReportAt == 0L -> AppCallout(
tone = CalloutTone.Info,
text = stringResource(R.string.diag_callout_never_landed),
)
}
}
}
// -------------------------------------------------------------- checks
/**
* The system settings that silence background work, each auto-detected
* and each answered with a jump into the exact settings page when the
* setting is wrong. Nothing OS buries these three; finding them by hand
* is the part nobody does.
*/
@Composable
private fun ChecksCard(resumeKey: Int) {
val context = LocalContext.current
// Re-read on resume: the point of the fix buttons is coming back from
// the settings app, so the row must flip without a restart.
val powerOk = remember(resumeKey) {
context.getSystemService(PowerManager::class.java)
?.isIgnoringBatteryOptimizations(context.packageName) == true
}
val backgroundOk = remember(resumeKey) {
// Background restriction is API 28+; below it the answer is "no
// such switch", which reads as fine.
Build.VERSION.SDK_INT < 28 ||
context.getSystemService(ActivityManager::class.java)?.isBackgroundRestricted != true
}
val notificationsOk = remember(resumeKey) {
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() == true
}
RhemaCard(title = stringResource(R.string.card_diag_checks)) {
CardRow(
title = stringResource(R.string.diag_check_battery),
description = stringResource(R.string.diag_check_battery_desc),
first = true,
) {
if (powerOk) {
AppBadge(stringResource(R.string.diag_check_ok), tint = JadeiteOk)
} else {
OutlinedButton(onClick = {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}) {
Text(stringResource(R.string.diag_check_battery_fix))
}
}
}
CardRow(
title = stringResource(R.string.diag_check_background),
description = stringResource(R.string.diag_check_background_desc),
) {
if (backgroundOk) {
AppBadge(stringResource(R.string.diag_check_ok), tint = JadeiteOk)
} else {
OutlinedButton(onClick = { openAppDetails(context) }) {
Text(stringResource(R.string.diag_check_background_fix))
}
}
}
CardRow(
title = stringResource(R.string.diag_check_notifications),
description = stringResource(R.string.diag_check_notifications_desc),
) {
if (notificationsOk) {
AppBadge(stringResource(R.string.diag_check_ok), tint = JadeiteOk)
} else {
OutlinedButton(onClick = {
context.startActivity(
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName),
)
}) {
Text(stringResource(R.string.diag_check_notifications_fix))
}
}
}
}
}
// ----------------------------------------------------------------- log
/** The workers' flight recorder, newest first, with a copy-out button. */
@Composable
private fun LogCard(container: AppContainer) {
var entries by remember { mutableStateOf(container.diag.snapshot()) }
// The ring only grows while workers run; a light poll keeps the tail
// live without wiring a flow through the recorder.
LaunchedEffect(Unit) {
while (isActive) {
entries = container.diag.snapshot()
delay(2_000)
}
}
val clipboard = LocalClipboardManager.current
RhemaCard(title = stringResource(R.string.card_diag_log), meta = stringResource(R.string.diag_log_hint)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (entries.isEmpty()) {
Text(
stringResource(R.string.diag_log_empty),
style = MaterialTheme.typography.bodySmall,
color = JadeiteText3,
)
} else {
entries.reversed().take(60).forEach { entry ->
Text(
clock(entry.at) + " " + entry.line,
style = MaterialTheme.typography.bodySmall,
fontFamily = JadeiteMono,
color = JadeiteText2,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (entries.isNotEmpty()) {
OutlinedButton(onClick = {
clipboard.setText(
AnnotatedString(entries.joinToString("\n") { "${clock(it.at)} ${it.line}" }),
)
}) {
Text(stringResource(R.string.diag_log_copy))
}
}
}
}
}
}
// ------------------------------------------------------------ helpers
/** One cadence plus margin: more drift than this and the claw is lying. */
private const val NOT_LANDED_MS = 45 * 60_000L
/** The absolute stamp the diagnostics vocabulary speaks: local wall time. */
private fun clock(millis: Long): String =
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))
/** "in 7m"-style relative label, floor zero. */
private fun inMinutes(thenMillis: Long): String {
val minutes = ((thenMillis - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0)
return "${minutes}m"
}
private fun openAppDetails(context: Context) {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
),
)
}
@@ -72,6 +72,10 @@ fun SettingsScreen(container: AppContainer, onConnected: () -> Unit = {}) {
title = stringResource(R.string.settings_entry_devices),
onBack = { section = null },
) { PhonesCard(container) }
SECTION_DIAGNOSTICS -> SettingsPage(
title = stringResource(R.string.settings_entry_diagnostics),
onBack = { section = null },
) { DiagnosticsContent(container) }
SECTION_PROVIDERS -> SettingsPage(
title = stringResource(R.string.settings_entry_providers),
onBack = { section = null },
@@ -85,6 +89,7 @@ fun SettingsScreen(container: AppContainer, onConnected: () -> Unit = {}) {
// rotation through them).
private const val SECTION_REPORTING = "reporting"
private const val SECTION_DEVICES = "devices"
private const val SECTION_DIAGNOSTICS = "diagnostics"
private const val SECTION_PROVIDERS = "providers"
private const val SECTION_CONNECTION = "connection"
@@ -105,6 +110,11 @@ private val SECTIONS = listOf(
R.string.settings_entry_devices,
R.string.settings_entry_devices_desc,
),
SettingsEntry(
SECTION_DIAGNOSTICS,
R.string.settings_entry_diagnostics,
R.string.settings_entry_diagnostics_desc,
),
),
),
SettingsSection(
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import rs.rhema.phone.RhemaApp
import rs.rhema.phone.diagnostics.diagText
import rs.rhema.phone.net.ClawResult
import kotlinx.coroutines.flow.first
@@ -18,9 +19,20 @@ class NotificationsWorker(context: Context, params: WorkerParameters) : Coroutin
val since = container.status.notificationCursor.first()
val notifications = when (val result = container.api.notifications(deviceId, since)) {
is ClawResult.Ok -> result.value
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
is ClawResult.Network -> return Result.retry()
is ClawResult.Ok -> {
container.diag.log(
if (result.value.isEmpty()) "notify: idle" else "notify: ${result.value.size} delivered",
)
result.value
}
is ClawResult.HttpError -> {
container.diag.log("notify: ${result.diagText()}")
return if (result.code >= 500) Result.retry() else Result.failure()
}
is ClawResult.Network -> {
container.diag.log("notify: ${result.diagText()}")
return Result.retry()
}
}
for (notification in notifications) {
@@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import rs.rhema.phone.RhemaApp
import rs.rhema.phone.di.AppContainer
import rs.rhema.phone.diagnostics.diagText
import rs.rhema.phone.net.ClawResult
import rs.rhema.phone.net.PhoneCommand
import kotlinx.serialization.json.JsonObject
@@ -23,16 +24,32 @@ class PhoneCommandWorker(context: Context, params: WorkerParameters) : Coroutine
val deviceId = container.settings.deviceId()
val commands = when (val result = container.api.phoneCommands(deviceId)) {
is ClawResult.Ok -> result.value
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
is ClawResult.Network -> return Result.retry()
is ClawResult.Ok -> {
container.diag.log(
if (result.value.isEmpty()) "commands: idle" else "commands: ${result.value.size} queued",
)
result.value
}
is ClawResult.HttpError -> {
container.diag.log("commands: ${result.diagText()}")
return if (result.code >= 500) Result.retry() else Result.failure()
}
is ClawResult.Network -> {
container.diag.log("commands: ${result.diagText()}")
return Result.retry()
}
}
for (command in commands) {
val (ok, detail) = execute(container, command)
container.diag.log(
"commands: ${command.action} -> " +
(if (ok) "ok" else "failed: ${detail ?: "no detail"}"),
)
// Stop at the first failed ack: the queue is ordered, later
// commands must not jump ahead of an un-acked one.
if (container.api.postPhoneCommandResult(command.id, ok, detail) !is ClawResult.Ok) {
container.diag.log("commands: ack failed: ${command.id}, retry next run")
return Result.retry()
}
}
@@ -11,6 +11,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import rs.rhema.phone.RhemaApp
import rs.rhema.phone.data.PhoneSnapshot
import rs.rhema.phone.diagnostics.diagText
import rs.rhema.phone.net.ClawResult
import rs.rhema.phone.sensors.LocationFix
import rs.rhema.phone.sensors.ForegroundApp
@@ -150,15 +151,28 @@ class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineW
}
}
// The start line is the liveness beacon: the diagnostics screen
// distinguishes "worker ran and failed" (this line, then a failure
// line) from "worker never ran" (neither).
container.diag.log(
"status: run battery=${snapshot.batteryLevel ?: "?"} " +
"charging=${snapshot.charging ?: "?"} network=${snapshot.network ?: "?"} " +
"transitions=${transitions.size}",
)
for ((topic, payload) in transitions) {
val result = container.api.ingest(SOURCE, "phone:$deviceId:$topic", payload)
// The snapshot stays unsaved, so the next scheduled run re-derives
// this whole transition set and posts it again. Retrying here
// instead would push that run out on WorkManager's backoff, and a
// phone in a pocket would go quiet for hours.
if (result !is ClawResult.Ok) return Result.success()
if (result !is ClawResult.Ok) {
container.diag.log("status: $topic failed: ${result.diagText()}")
return Result.success()
}
}
container.status.setSnapshot(snapshot)
container.diag.log("status: posted ${transitions.joinToString { it.first }}")
return Result.success()
}
@@ -10,9 +10,11 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.time.Duration
private const val PHONE_STATUS = "phone_status"
private const val NOTIFICATIONS = "notifications"
private const val PHONE_COMMANDS = "phone_commands"
// The unique names double as the diagnostics screen's work rows: it reads
// the live WorkInfo for each, so the spellings live here and nowhere else.
internal const val PHONE_STATUS = "phone_status"
internal const val NOTIFICATIONS = "notifications"
internal const val PHONE_COMMANDS = "phone_commands"
private const val PHONE_STATUS_KICK = "phone_status_kick"
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
@@ -97,6 +97,51 @@
<string name="reporting_on">on</string>
<string name="reporting_allow">Allow</string>
<string name="settings_entry_diagnostics">Diagnostics</string>
<string name="settings_entry_diagnostics_desc">Background work, claw reachability, and the app log</string>
<string name="card_diag_work">Background work</string>
<string name="diag_work_hint">The scheduled jobs and their next window</string>
<string name="diag_work_status">Status reporting</string>
<string name="diag_work_notify">Notification poll</string>
<string name="diag_work_commands">Command queue</string>
<string name="diag_work_next_fmt">next run in %1$s</string>
<string name="diag_work_attempts_fmt">%1$d attempts</string>
<string name="diag_state_checking">checking</string>
<string name="diag_state_enqueued">waiting</string>
<string name="diag_state_running">running</string>
<string name="diag_state_failed">failed</string>
<string name="diag_state_blocked">blocked</string>
<string name="diag_state_cancelled">cancelled</string>
<string name="diag_state_not_scheduled">not scheduled</string>
<string name="diag_report_now_title">Report now</string>
<string name="diag_report_now_desc">Runs the status worker immediately, network permitting.</string>
<string name="diag_report_now">Run</string>
<string name="card_diag_clocks">Claw and phone clocks</string>
<string name="diag_clock_phone">Phone last landed</string>
<string name="diag_clock_phone_desc">When a full report last reached the claw, by this phone\'s own record.</string>
<string name="diag_clock_claw">Claw last heard</string>
<string name="diag_clock_claw_desc">When the claw last stamped this device, by its fleet row.</string>
<string name="diag_clock_never">never</string>
<string name="diag_clock_unreachable">unreachable</string>
<string name="diag_callout_unreachable">The claw is unreachable from this phone (%1$s). Reads and reports fail until it is back; nothing is lost — reporting resumes on the next run.</string>
<string name="diag_callout_not_landing">This phone is reporting, but the claw is not storing it. The claw is up but degraded — a full disk or a read-only database — or something between them swallows posts.</string>
<string name="diag_callout_never_landed">No full report has landed yet. The first run posts everything at once; until then this is normal.</string>
<string name="card_diag_checks">Checks</string>
<string name="diag_check_ok">ok</string>
<string name="diag_check_battery">Battery optimization</string>
<string name="diag_check_battery_desc">Unrestricted lets the 15-minute reports survive Doze and OEM app sleeping.</string>
<string name="diag_check_battery_fix">Unrestrict</string>
<string name="diag_check_background">Background restriction</string>
<string name="diag_check_background_desc">Android is blocking all background work for this app until it is lifted.</string>
<string name="diag_check_background_fix">Open app settings</string>
<string name="diag_check_notifications">Notifications</string>
<string name="diag_check_notifications_desc">Needed for the claw\'s alerts and questions to come through.</string>
<string name="diag_check_notifications_fix">Open settings</string>
<string name="card_diag_log">App log</string>
<string name="diag_log_hint">Newest first, last 60 of 200</string>
<string name="diag_log_empty">Nothing logged yet. A run appears here within 15 minutes of the app being alive — or tap Report now.</string>
<string name="diag_log_copy">Copy</string>
<string name="sessions_group_interactive">Interactive</string>
<string name="sessions_group_other">Other keys</string>