fix(android): refresh the phone's own row when it reports

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.
This commit is contained in:
vhaudiquet committed 2026-10-20 07:40:43 +02:00
1 parent 4a91635730
commit 385788c935
4 files changed
+39 -5

No files matched your search

+10 -1
View File
@@ -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 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 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 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 Every report is an observation on the claw: it lands in the phone
tables (the ingest answers `{"ingested": "observed"}`) and a `phone` tables (the ingest answers `{"ingested": "observed"}`) and a `phone`
@@ -32,6 +32,7 @@ import rs.rhema.phone.ui.RhemaTheme
import rs.rhema.phone.ui.SessionsScreen import rs.rhema.phone.ui.SessionsScreen
import rs.rhema.phone.ui.SettingsScreen import rs.rhema.phone.ui.SettingsScreen
import rs.rhema.phone.work.enqueuePhoneCommandsNow import rs.rhema.phone.work.enqueuePhoneCommandsNow
import rs.rhema.phone.work.enqueuePhoneStatusNow
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -77,11 +78,16 @@ class MainActivity : ComponentActivity() {
} }
} }
// The control queue (phone commands) is freshest when the human is // Both queues are freshest when the human is holding the phone; KEEP
// holding the phone; KEEP coalesces rapid resume bursts. // 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() { override fun onResume() {
super.onResume() super.onResume()
enqueuePhoneCommandsNow(applicationContext) enqueuePhoneCommandsNow(applicationContext)
enqueuePhoneStatusNow(applicationContext)
} }
} }
@@ -3,6 +3,7 @@ package rs.rhema.phone.data
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -38,6 +39,7 @@ class StatusRepository(private val store: DataStore<Preferences>, private val js
private object Keys { private object Keys {
val snapshot = stringPreferencesKey("last_phone_snapshot") val snapshot = stringPreferencesKey("last_phone_snapshot")
val reportedAt = longPreferencesKey("last_phone_report_at")
val notificationCursor = stringPreferencesKey("last_notification_id") val notificationCursor = stringPreferencesKey("last_notification_id")
} }
@@ -46,10 +48,20 @@ class StatusRepository(private val store: DataStore<Preferences>, private val js
runCatching { json.decodeFromString<PhoneSnapshot>(raw) }.getOrNull() runCatching { json.decodeFromString<PhoneSnapshot>(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<Long> =
store.data.map { it[Keys.reportedAt] ?: 0L }
// Only called after every ingest post of a run succeeded, so a failed run // 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. // retries the whole transition set next time instead of swallowing it.
suspend fun setSnapshot(snapshot: PhoneSnapshot) { 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. // Cursor into the claw's notification list: the last id this device showed.
@@ -58,6 +58,11 @@ fun PhonesCard(container: AppContainer) {
var forgetCandidate by remember { mutableStateOf<PhoneInfo?>(null) } var forgetCandidate by remember { mutableStateOf<PhoneInfo?>(null) }
var forgotName by remember { mutableStateOf<String?>(null) } var forgotName by remember { mutableStateOf<String?>(null) }
val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "") 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() { fun load() {
scope.launch { 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)) { RhemaCard(title = stringResource(R.string.card_phones), meta = stringResource(R.string.phones_hint)) {
Column( Column(