diff --git a/android/README.md b/android/README.md index 695014e..962a922 100644 --- a/android/README.md +++ b/android/README.md @@ -29,8 +29,10 @@ manages the device fleet the claw keeps. 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. + worker (its one-time job has a row of its own, and every worker + crash-lands in the log instead of dying silently), 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/ui/DiagnosticsScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt index 4579d8f..55e7c94 100644 --- a/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt +++ b/android/app/src/main/java/rs/rhema/phone/ui/DiagnosticsScreen.kt @@ -46,6 +46,7 @@ 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.PHONE_STATUS_KICK import rs.rhema.phone.work.NOTIFICATIONS import rs.rhema.phone.work.enqueuePhoneStatusNow @@ -98,18 +99,29 @@ 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 kickInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_STATUS_KICK).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_kick), + kickInfos, + // A one-time kick is absent until first used: "not scheduled" + // would read as an error when it is just the initial state. + absentLabel = stringResource(R.string.diag_state_never), + ) 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) }) { + Button(onClick = { + container.diag.log("diag: report now tapped") + enqueuePhoneStatusNow(context) + }) { Text(stringResource(R.string.diag_report_now)) } } @@ -117,13 +129,19 @@ private fun WorkCard(container: AppContainer) { } @Composable -private fun WorkRow(label: String, infos: List?, first: Boolean = false) { +private fun WorkRow( + label: String, + infos: List?, + first: Boolean = false, + absentLabel: String? = null, +) { // 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. + // the next window. An empty list means the schedule itself is gone — + // or, for a one-time row, simply that it has never been enqueued. val info = infos?.firstOrNull() val stateLabel = when { infos == null -> stringResource(R.string.diag_state_checking) - info == null -> stringResource(R.string.diag_state_not_scheduled) + info == null -> absentLabel ?: 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) @@ -133,12 +151,15 @@ private fun WorkRow(label: String, infos: List?, first: Boolean = fals 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 tint = when { + infos == null -> JadeiteWarn + info == null && absentLabel == null -> JadeiteError + else -> when (info?.state) { + WorkInfo.State.RUNNING -> JadeiteOk + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError + WorkInfo.State.BLOCKED -> JadeiteWarn + else -> JadeiteText3 + } } val detail = when { infos == null -> null 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 fbcb146..70faef0 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 @@ -6,6 +6,7 @@ import androidx.work.WorkerParameters import rs.rhema.phone.RhemaApp import rs.rhema.phone.diagnostics.diagText import rs.rhema.phone.net.ClawResult +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first // Periodic notification poll. Latency is bounded by the WorkManager interval @@ -13,7 +14,20 @@ import kotlinx.coroutines.flow.first // The chat screen is always live via SSE regardless. class NotificationsWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { + override suspend fun doWork(): Result = try { + pollOnce() + } catch (cancelled: CancellationException) { + (applicationContext as RhemaApp).container.diag.log("notify: cancelled") + throw cancelled + } catch (t: Throwable) { + (applicationContext as RhemaApp).container.diag.log( + "notify: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" + + t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(), + ) + Result.failure() + } + + private suspend fun pollOnce(): Result { val container = (applicationContext as RhemaApp).container val deviceId = container.settings.deviceId() val since = container.status.notificationCursor.first() 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 17f86d3..33a4c32 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 @@ -12,6 +12,7 @@ import rs.rhema.phone.net.PhoneCommand import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull +import kotlinx.coroutines.CancellationException // Periodic control-plane poll: the claw queues commands for this device // once their phone consent is granted; each is executed in order, then @@ -19,7 +20,20 @@ import kotlinx.serialization.json.contentOrNull // command queued and the next run repeats it — at-least-once delivery. class PhoneCommandWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { + override suspend fun doWork(): Result = try { + drainOnce() + } catch (cancelled: CancellationException) { + (applicationContext as RhemaApp).container.diag.log("commands: cancelled") + throw cancelled + } catch (t: Throwable) { + (applicationContext as RhemaApp).container.diag.log( + "commands: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" + + t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(), + ) + Result.failure() + } + + private suspend fun drainOnce(): Result { val container = (applicationContext as RhemaApp).container val deviceId = container.settings.deviceId() 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 8bea69c..334e068 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 @@ -9,6 +9,7 @@ import android.os.BatteryManager import android.os.Build import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import kotlinx.coroutines.CancellationException import rs.rhema.phone.RhemaApp import rs.rhema.phone.data.PhoneSnapshot import rs.rhema.phone.diagnostics.diagText @@ -36,7 +37,22 @@ import kotlinx.serialization.json.put // only bound write traffic, not model traffic. class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { + override suspend fun doWork(): Result = try { + reportOnce() + } catch (cancelled: CancellationException) { + (applicationContext as RhemaApp).container.diag.log("status: cancelled") + throw cancelled + } catch (t: Throwable) { + // A crash before the first log line used to be invisible: the row + // just sat "waiting" while every attempt died silently. + (applicationContext as RhemaApp).container.diag.log( + "status: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" + + t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(), + ) + Result.failure() + } + + private suspend fun reportOnce(): Result { val container = (applicationContext as RhemaApp).container val deviceId = container.settings.deviceId() // Suspend: the location read may wait out a fresh fix. 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 a625815..20c57d2 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 @@ -15,7 +15,7 @@ import java.time.Duration 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" +internal const val PHONE_STATUS_KICK = "phone_status_kick" private const val PHONE_COMMANDS_KICK = "phone_commands_kick" private fun networked() = Constraints.Builder() diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a874ae2..e4af900 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -104,6 +104,8 @@ Status reporting Notification poll Command queue + Report-now run + never run next run in %1$s %1$d attempts checking