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
|
## What it does
|
||||||
|
|
||||||
- **Settings**: a nav of submenus mirroring the web UI's settings
|
- **Settings**: a nav of submenus mirroring the web UI's settings
|
||||||
sidebar — the "Phone" group (Status reporting, Devices) and the "Claw"
|
sidebar — the "Phone" group (Status reporting, Devices, Diagnostics)
|
||||||
group (Model providers, Connection), one page per entry behind a back
|
and the "Claw" group (Model providers, Connection), one page per entry
|
||||||
arrow. First launch is the exception: it lands straight on the
|
behind a back arrow. First launch is the exception: it lands straight
|
||||||
connection page until a base URL is saved and validated with
|
on the connection page until a base URL is saved and validated with
|
||||||
`GET /healthz`. Stores the claw base URL and bearer token
|
`GET /healthz`. Stores the claw base URL and bearer token
|
||||||
(plus a generated, persistent device ID) in app-private DataStore. The
|
(plus a generated, persistent device ID) in app-private DataStore. The
|
||||||
**Devices** page is the **Phones** card, **Status reporting** and
|
**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
|
backend instances (`GET /v1/providers`), each one's live model
|
||||||
listing, and the active pair staged as a draft behind an explicit save
|
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).
|
(`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
|
Trade-off: the token is stored in plain DataStore rather than
|
||||||
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
||||||
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
|
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 android.content.Context
|
||||||
import rs.rhema.phone.data.AgentIdentityRepository
|
import rs.rhema.phone.data.AgentIdentityRepository
|
||||||
|
import rs.rhema.phone.diagnostics.DiagLog
|
||||||
import rs.rhema.phone.data.SessionCache
|
import rs.rhema.phone.data.SessionCache
|
||||||
import rs.rhema.phone.data.SettingsRepository
|
import rs.rhema.phone.data.SettingsRepository
|
||||||
import rs.rhema.phone.data.StatusRepository
|
import rs.rhema.phone.data.StatusRepository
|
||||||
@@ -38,5 +39,9 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val notifier = Notifier(appContext)
|
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)
|
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),
|
title = stringResource(R.string.settings_entry_devices),
|
||||||
onBack = { section = null },
|
onBack = { section = null },
|
||||||
) { PhonesCard(container) }
|
) { PhonesCard(container) }
|
||||||
|
SECTION_DIAGNOSTICS -> SettingsPage(
|
||||||
|
title = stringResource(R.string.settings_entry_diagnostics),
|
||||||
|
onBack = { section = null },
|
||||||
|
) { DiagnosticsContent(container) }
|
||||||
SECTION_PROVIDERS -> SettingsPage(
|
SECTION_PROVIDERS -> SettingsPage(
|
||||||
title = stringResource(R.string.settings_entry_providers),
|
title = stringResource(R.string.settings_entry_providers),
|
||||||
onBack = { section = null },
|
onBack = { section = null },
|
||||||
@@ -85,6 +89,7 @@ fun SettingsScreen(container: AppContainer, onConnected: () -> Unit = {}) {
|
|||||||
// rotation through them).
|
// rotation through them).
|
||||||
private const val SECTION_REPORTING = "reporting"
|
private const val SECTION_REPORTING = "reporting"
|
||||||
private const val SECTION_DEVICES = "devices"
|
private const val SECTION_DEVICES = "devices"
|
||||||
|
private const val SECTION_DIAGNOSTICS = "diagnostics"
|
||||||
private const val SECTION_PROVIDERS = "providers"
|
private const val SECTION_PROVIDERS = "providers"
|
||||||
private const val SECTION_CONNECTION = "connection"
|
private const val SECTION_CONNECTION = "connection"
|
||||||
|
|
||||||
@@ -105,6 +110,11 @@ private val SECTIONS = listOf(
|
|||||||
R.string.settings_entry_devices,
|
R.string.settings_entry_devices,
|
||||||
R.string.settings_entry_devices_desc,
|
R.string.settings_entry_devices_desc,
|
||||||
),
|
),
|
||||||
|
SettingsEntry(
|
||||||
|
SECTION_DIAGNOSTICS,
|
||||||
|
R.string.settings_entry_diagnostics,
|
||||||
|
R.string.settings_entry_diagnostics_desc,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import rs.rhema.phone.RhemaApp
|
import rs.rhema.phone.RhemaApp
|
||||||
|
import rs.rhema.phone.diagnostics.diagText
|
||||||
import rs.rhema.phone.net.ClawResult
|
import rs.rhema.phone.net.ClawResult
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
@@ -18,9 +19,20 @@ class NotificationsWorker(context: Context, params: WorkerParameters) : Coroutin
|
|||||||
val since = container.status.notificationCursor.first()
|
val since = container.status.notificationCursor.first()
|
||||||
|
|
||||||
val notifications = when (val result = container.api.notifications(deviceId, since)) {
|
val notifications = when (val result = container.api.notifications(deviceId, since)) {
|
||||||
is ClawResult.Ok -> result.value
|
is ClawResult.Ok -> {
|
||||||
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
container.diag.log(
|
||||||
is ClawResult.Network -> return Result.retry()
|
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) {
|
for (notification in notifications) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import rs.rhema.phone.RhemaApp
|
import rs.rhema.phone.RhemaApp
|
||||||
import rs.rhema.phone.di.AppContainer
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.diagnostics.diagText
|
||||||
import rs.rhema.phone.net.ClawResult
|
import rs.rhema.phone.net.ClawResult
|
||||||
import rs.rhema.phone.net.PhoneCommand
|
import rs.rhema.phone.net.PhoneCommand
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -23,16 +24,32 @@ class PhoneCommandWorker(context: Context, params: WorkerParameters) : Coroutine
|
|||||||
val deviceId = container.settings.deviceId()
|
val deviceId = container.settings.deviceId()
|
||||||
|
|
||||||
val commands = when (val result = container.api.phoneCommands(deviceId)) {
|
val commands = when (val result = container.api.phoneCommands(deviceId)) {
|
||||||
is ClawResult.Ok -> result.value
|
is ClawResult.Ok -> {
|
||||||
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
container.diag.log(
|
||||||
is ClawResult.Network -> return Result.retry()
|
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) {
|
for (command in commands) {
|
||||||
val (ok, detail) = execute(container, command)
|
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
|
// Stop at the first failed ack: the queue is ordered, later
|
||||||
// commands must not jump ahead of an un-acked one.
|
// commands must not jump ahead of an un-acked one.
|
||||||
if (container.api.postPhoneCommandResult(command.id, ok, detail) !is ClawResult.Ok) {
|
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()
|
return Result.retry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.work.CoroutineWorker
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import rs.rhema.phone.RhemaApp
|
import rs.rhema.phone.RhemaApp
|
||||||
import rs.rhema.phone.data.PhoneSnapshot
|
import rs.rhema.phone.data.PhoneSnapshot
|
||||||
|
import rs.rhema.phone.diagnostics.diagText
|
||||||
import rs.rhema.phone.net.ClawResult
|
import rs.rhema.phone.net.ClawResult
|
||||||
import rs.rhema.phone.sensors.LocationFix
|
import rs.rhema.phone.sensors.LocationFix
|
||||||
import rs.rhema.phone.sensors.ForegroundApp
|
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) {
|
for ((topic, payload) in transitions) {
|
||||||
val result = container.api.ingest(SOURCE, "phone:$deviceId:$topic", payload)
|
val result = container.api.ingest(SOURCE, "phone:$deviceId:$topic", payload)
|
||||||
// The snapshot stays unsaved, so the next scheduled run re-derives
|
// The snapshot stays unsaved, so the next scheduled run re-derives
|
||||||
// this whole transition set and posts it again. Retrying here
|
// this whole transition set and posts it again. Retrying here
|
||||||
// instead would push that run out on WorkManager's backoff, and a
|
// instead would push that run out on WorkManager's backoff, and a
|
||||||
// phone in a pocket would go quiet for hours.
|
// 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.status.setSnapshot(snapshot)
|
||||||
|
container.diag.log("status: posted ${transitions.joinToString { it.first }}")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import androidx.work.PeriodicWorkRequestBuilder
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
private const val PHONE_STATUS = "phone_status"
|
// The unique names double as the diagnostics screen's work rows: it reads
|
||||||
private const val NOTIFICATIONS = "notifications"
|
// the live WorkInfo for each, so the spellings live here and nowhere else.
|
||||||
private const val PHONE_COMMANDS = "phone_commands"
|
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_STATUS_KICK = "phone_status_kick"
|
||||||
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
|
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,51 @@
|
|||||||
<string name="reporting_on">on</string>
|
<string name="reporting_on">on</string>
|
||||||
<string name="reporting_allow">Allow</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_interactive">Interactive</string>
|
||||||
<string name="sessions_group_other">Other keys</string>
|
<string name="sessions_group_other">Other keys</string>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user