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(