From 385788c93560de52a076f7d241c06f0da7600b18 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 20 Oct 2026 07:37:44 +0200 Subject: [PATCH] fix(android): refresh the phone's own row when it reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet card reads last-seen from the claw, and only a landed report moves it. Periodic work is deferred while the device idles — Doze stretches the fifteen-minute floor to hours — so a phone sitting in the human's hand could show itself as "last seen 3h ago" with nothing wrong on the claw. Kick a status run on resume, the same way the commands worker already is, and reload the fleet whenever a run lands. The landed-run signal is a new clock in StatusRepository, written in the same edit that saves the snapshot (which only happens once every post of the run succeeded), so the card reloads on a report that actually reached the claw. --- android/README.md | 11 ++++++++++- .../src/main/java/rs/rhema/phone/MainActivity.kt | 10 ++++++++-- .../java/rs/rhema/phone/data/StatusRepository.kt | 14 +++++++++++++- .../main/java/rs/rhema/phone/ui/PhonesScreen.kt | 9 ++++++++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/android/README.md b/android/README.md index be9ed6b..de20d14 100644 --- a/android/README.md +++ b/android/README.md @@ -95,7 +95,16 @@ manages the device fleet the claw keeps. it is. The heartbeat exists for the fleet's last-seen time: the claw stamps a device "seen" on whatever post lands, so a run with no transitions posts one anyway and the row stays fresh at the worker's - cadence instead of the last flip. + cadence instead of the last flip. A failed post ends the run anyway — + the snapshot is only saved when every post landed, so the next + scheduled run re-derives the whole transition set; `retry()` would + hand the cadence to WorkManager's exponential backoff (hours) and the + phone would read as gone. And because idle devices defer periodic work + (Doze stretches the fifteen-minute floor to hours), the app kicks a + status run on every foreground: a phone in hand reports then and + there, and the fleet card reloads on each landed run (`lastReportAt`) + so its own row reads from the new clock instead of the one it was + opened at. Every report is an observation on the claw: it lands in the phone tables (the ingest answers `{"ingested": "observed"}`) and a `phone` diff --git a/android/app/src/main/java/rs/rhema/phone/MainActivity.kt b/android/app/src/main/java/rs/rhema/phone/MainActivity.kt index f8187a3..da11dc5 100644 --- a/android/app/src/main/java/rs/rhema/phone/MainActivity.kt +++ b/android/app/src/main/java/rs/rhema/phone/MainActivity.kt @@ -32,6 +32,7 @@ import rs.rhema.phone.ui.RhemaTheme import rs.rhema.phone.ui.SessionsScreen import rs.rhema.phone.ui.SettingsScreen import rs.rhema.phone.work.enqueuePhoneCommandsNow +import rs.rhema.phone.work.enqueuePhoneStatusNow class MainActivity : ComponentActivity() { @@ -77,11 +78,16 @@ class MainActivity : ComponentActivity() { } } - // The control queue (phone commands) is freshest when the human is - // holding the phone; KEEP coalesces rapid resume bursts. + // Both queues are freshest when the human is holding the phone; KEEP + // coalesces rapid resume bursts. The status run is the one that matters + // to the fleet: periodic work is deferred while the device idles (Doze + // stretches the 15-minute floor to hours), so without this kick the + // phone's own row can say it was last seen hours ago while it sits in + // the human's hand. override fun onResume() { super.onResume() enqueuePhoneCommandsNow(applicationContext) + enqueuePhoneStatusNow(applicationContext) } } diff --git a/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt b/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt index a4eb8ef..358b092 100644 --- a/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt +++ b/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt @@ -3,6 +3,7 @@ package rs.rhema.phone.data import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -38,6 +39,7 @@ class StatusRepository(private val store: DataStore, private val js private object Keys { val snapshot = stringPreferencesKey("last_phone_snapshot") + val reportedAt = longPreferencesKey("last_phone_report_at") val notificationCursor = stringPreferencesKey("last_notification_id") } @@ -46,10 +48,20 @@ class StatusRepository(private val store: DataStore, private val js runCatching { json.decodeFromString(raw) }.getOrNull() } + // When the last run landed every post, epoch millis; 0 before the + // first one. The fleet card watches this so it reloads after a report + // lands: the last-seen it draws is the claw's clock, which only moves + // when a run has actually reached it. + val lastReportAt: Flow = + store.data.map { it[Keys.reportedAt] ?: 0L } + // Only called after every ingest post of a run succeeded, so a failed run // retries the whole transition set next time instead of swallowing it. suspend fun setSnapshot(snapshot: PhoneSnapshot) { - store.edit { it[Keys.snapshot] = json.encodeToString(snapshot) } + store.edit { + it[Keys.snapshot] = json.encodeToString(snapshot) + it[Keys.reportedAt] = System.currentTimeMillis() + } } // Cursor into the claw's notification list: the last id this device showed. diff --git a/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt index ecdf0a4..82e02ce 100644 --- a/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt +++ b/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt @@ -58,6 +58,11 @@ fun PhonesCard(container: AppContainer) { var forgetCandidate by remember { mutableStateOf(null) } var forgotName by remember { mutableStateOf(null) } val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "") + // The last run that landed every post. The fleet is read from the claw, + // so this device's last-seen only moves when a report has actually + // reached it; reloading on this flow keeps the phone from calling + // itself hours stale while it is in hand. + val lastReportAt by container.status.lastReportAt.collectAsState(initial = 0L) fun load() { scope.launch { @@ -73,7 +78,9 @@ fun PhonesCard(container: AppContainer) { } } - LaunchedEffect(Unit) { load() } + // First paint loads the fleet as-is; every landed status run reloads it, + // so the row this phone reads about itself carries the new last-seen. + LaunchedEffect(lastReportAt) { load() } RhemaCard(title = stringResource(R.string.card_phones), meta = stringResource(R.string.phones_hint)) { Column(