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:
1 parent
e4f4a3a10b
commit
7225dfddd3
10 files changed
+564
-14
No files matched your search
+15
-4
@@ -9,10 +9,10 @@ manages the device fleet the claw keeps.
|
||||
## What it does
|
||||
|
||||
- **Settings**: a nav of submenus mirroring the web UI's settings
|
||||
sidebar — the "Phone" group (Status reporting, Devices) and the "Claw"
|
||||
group (Model providers, Connection), one page per entry behind a back
|
||||
arrow. First launch is the exception: it lands straight on the
|
||||
connection page until a base URL is saved and validated with
|
||||
sidebar — the "Phone" group (Status reporting, Devices, Diagnostics)
|
||||
and the "Claw" group (Model providers, Connection), one page per entry
|
||||
behind a back arrow. First launch is the exception: it lands straight
|
||||
on the connection page until a base URL is saved and validated with
|
||||
`GET /healthz`. Stores the claw base URL and bearer token
|
||||
(plus a generated, persistent device ID) in app-private DataStore. The
|
||||
**Devices** page is the **Phones** card, **Status reporting** and
|
||||
@@ -20,6 +20,17 @@ manages the device fleet the claw keeps.
|
||||
backend instances (`GET /v1/providers`), each one's live model
|
||||
listing, and the active pair staged as a draft behind an explicit save
|
||||
(`PUT /v1/providers/active`; it applies from the claw's next turn).
|
||||
The **Diagnostics** page answers "why is the fleet row stale" from the
|
||||
phone's side: the three WorkManager jobs' live state (an empty info
|
||||
list — a cancelled schedule — is the loud answer), the two clocks
|
||||
(when this phone last landed a full report vs. when the claw last
|
||||
stamped the device; the claw-behind-phone callout is "reporting but
|
||||
not landing", the network-error one is "claw unreachable"),
|
||||
auto-detected system checks (battery optimization, background
|
||||
restriction, notifications) each with a jump into the exact settings
|
||||
page when the setting is wrong, a "report now" kick of the status
|
||||
worker, and the workers' in-memory flight recorder (a 200-line ring,
|
||||
newest first, copyable) where every run logs its outcome.
|
||||
Trade-off: the token is stored in plain DataStore rather than
|
||||
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
||||
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user