diff --git a/android/README.md b/android/README.md index de20d14..695014e 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt b/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt index e0fa6b6..78323e3 100644 --- a/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt +++ b/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt @@ -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) } diff --git a/android/app/src/main/java/rs/rhema/phone/diagnostics/DiagLog.kt b/android/app/src/main/java/rs/rhema/phone/diagnostics/DiagLog.kt new file mode 100644 index 0000000..82c0c21 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/diagnostics/DiagLog.kt @@ -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() + + /** 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 = 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" +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt new file mode 100644 index 0000000..4579d8f --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt @@ -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?, 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(null) } + var clawError by remember { mutableStateOf(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), + ), + ) +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt index fdaf739..cd6ee42 100644 --- a/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt +++ b/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt @@ -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( diff --git a/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt index d0bd5fa..fbcb146 100644 --- a/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt +++ b/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt @@ -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) { diff --git a/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt index 7ca0d93..17f86d3 100644 --- a/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt +++ b/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt @@ -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() } } diff --git a/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt index d8cd298..8bea69c 100644 --- a/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt +++ b/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt @@ -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() } diff --git a/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt b/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt index bd406fe..a625815 100644 --- a/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt +++ b/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt @@ -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" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c19d7f2..a874ae2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -97,6 +97,51 @@ on Allow + Diagnostics + Background work, claw reachability, and the app log + Background work + The scheduled jobs and their next window + Status reporting + Notification poll + Command queue + next run in %1$s + %1$d attempts + checking + waiting + running + failed + blocked + cancelled + not scheduled + Report now + Runs the status worker immediately, network permitting. + Run + Claw and phone clocks + Phone last landed + When a full report last reached the claw, by this phone\'s own record. + Claw last heard + When the claw last stamped this device, by its fleet row. + never + 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. + 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. + No full report has landed yet. The first run posts everything at once; until then this is normal. + Checks + ok + Battery optimization + Unrestricted lets the 15-minute reports survive Doze and OEM app sleeping. + Unrestrict + Background restriction + Android is blocking all background work for this app until it is lifted. + Open app settings + Notifications + Needed for the claw\'s alerts and questions to come through. + Open settings + App log + Newest first, last 60 of 200 + Nothing logged yet. A run appears here within 15 minutes of the app being alive — or tap Report now. + Copy + Interactive Other keys