diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index afd6f72..b7e5a05 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -5,11 +5,13 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider +import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges import de.jeanlucmakiola.clockula.data.time.RealTicker import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock @@ -39,4 +41,7 @@ abstract class TimeModule { @Binds abstract fun bindTicker(impl: RealTicker): Ticker + + @Binds + abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt new file mode 100644 index 0000000..5e4f12c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.clockula.data.time + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.domain.time.ClockChanges +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * `TIME_SET`, `TIMEZONE_CHANGED` and `TIME_TICK`, through a receiver that is + * registered only while somebody collects. `TIME_TICK` cannot be declared in + * the manifest at all, and the other two already have their manifest receiver + * (`SystemEventReceiver`) for the alarm engine; this one is for a readout. + */ +@Singleton +class AndroidClockChanges @Inject constructor( + @param:ApplicationContext private val context: Context, +) : ClockChanges { + + override fun changes(): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_TIME_TICK) + } + // Protected system broadcasts still arrive at a non-exported receiver, + // and nothing else on the device gets to poke the tab. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt index 2bd5f17..ea784bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt @@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor( /** * One display name per id in [zoneIds], on the same memo. Batched for the - * same reason [entriesFor] is: the tab asks five times a second, and a - * dispatch and a lock per row would be a hundred and twenty-five of each. + * same reason [entriesFor] is: the tab asks on every tick (once a second with + * seconds on), and a dispatch and a lock per row would be twenty-five of each. */ suspend fun displayNamesOf(zoneIds: List, at: Instant): Map = withContext(io) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt index 557791c..f264fbf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt @@ -17,8 +17,8 @@ object Zones { /** * Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry - * `HashSet` on every call, and the world clock asks per row per 200 ms tick - * — twenty-four cities would be a hundred and twenty-five throwaway sets a + * `HashSet` on every call, and the world clock asks per row per tick — + * with seconds on, twenty-four cities would be twenty-five throwaway sets a * second. The device's tzdata arrives as an APEX module that takes effect * at reboot, so the answer cannot change under a running process. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index 2b1d575..8167665 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -49,3 +49,13 @@ interface Ticker { */ fun alignedTicks(unit: Duration): Flow } + +/** + * The system saying the wall clock may not read what a readout last drew: the + * time was set, the zone changed, or a minute passed while the device was + * awake. A readout on a long cadence merges this in so it does not wait out + * its own period to catch up. Emits only while collected. + */ +interface ClockChanges { + fun changes(): Flow +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt index 73724e3..028ba46 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt @@ -2,9 +2,9 @@ package de.jeanlucmakiola.clockula.ui.worldclock import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import java.time.temporal.ChronoUnit import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration internal object WorldClockDefaults { /** @@ -12,11 +12,17 @@ internal object WorldClockDefaults { * rather than up to a cadence late: the minute when seconds are off, the * second only when the user asked for them (D18, #23). */ - val MinuteTick: Duration = 1.minutes + val MinuteTick: Duration = ChronoUnit.MINUTES.duration.toKotlinDuration() - val SecondTick: Duration = 1.seconds + val SecondTick: Duration = ChronoUnit.SECONDS.duration.toKotlinDuration() - fun tickFor(showSeconds: Boolean): Duration = if (showSeconds) SecondTick else MinuteTick + /** + * The unit every time on the tab is truncated to. The tick is derived from + * it, so the readouts and the cadence cannot drift apart. + */ + fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES + + fun tickFor(showSeconds: Boolean): Duration = unitFor(showSeconds).duration.toKotlinDuration() val HeroFaceSize: Dp = 200.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt index 095d3fa..1c42776 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt @@ -52,6 +52,7 @@ internal fun WorldClockRow( row: WorldClockRowState, position: Position, formatTime: (LocalTime) -> String, + showSeconds: Boolean, dragHandle: Modifier, isDragging: Boolean, onMoveUp: () -> Unit, @@ -134,7 +135,15 @@ internal fun WorldClockRow( text = time, // Tabular, because M7 made the headline role tabular // app-wide: a column of times must not jitter as it ticks. - style = MaterialTheme.typography.headlineMedium, + // A size down with seconds, which add three or more glyphs + // the city name would otherwise pay for. + style = if (showSeconds) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.headlineMedium + }, + maxLines = 1, + softWrap = false, fontWeight = FontWeight.Bold, color = if (isDragging) { MaterialTheme.colorScheme.primary diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt index 6f729b3..e516355 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -11,7 +11,6 @@ import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat import java.time.LocalTime import java.time.ZoneId -import java.time.temporal.ChronoUnit import kotlin.time.Instant import kotlin.time.toJavaInstant @@ -74,7 +73,7 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, - showSeconds: Boolean = false, + showSeconds: Boolean, ): List = clocks.map { clock -> val known = clock.isKnownZone val comparison = if (known) { @@ -86,7 +85,7 @@ internal object WorldClockRows { id = clock.id, zoneId = clock.zoneId, name = clock.label ?: cityOf(clock.zoneId, entries), - time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(unitFor(showSeconds)), + time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(WorldClockDefaults.unitFor(showSeconds)), offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, zoneName = zoneNames[clock.zoneId], @@ -100,11 +99,11 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, - showSeconds: Boolean = false, + showSeconds: Boolean, ): HomeFaceState { // To the unit shown, as the rows are. Without seconds the face has no // second hand, so its hands and its readout move once a minute. - val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(unitFor(showSeconds)) + val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(WorldClockDefaults.unitFor(showSeconds)) return HomeFaceState( zoneId = homeZone.id, city = cityOf(homeZone.id, entries), @@ -114,9 +113,6 @@ internal object WorldClockRows { ) } - private fun unitFor(showSeconds: Boolean): ChronoUnit = - if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES - private fun cityOf(zoneId: String, entries: Map): String = entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index d20dc58..00dd802 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -117,17 +117,23 @@ fun WorldClockScreen( if (loaded.rows.isEmpty()) { EmptyWorldClocks() } else { + // The column is handed ids, not rows: it re-seeds its working + // order whenever its items change, and rows that tick (every + // second with seconds on) would snap a drag back mid-gesture. + val rowsById = loaded.rows.associateBy { it.id } ReorderableColumn( - items = loaded.rows, - keyOf = { it.id }, - onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) }, + items = loaded.rows.map { it.id }, + keyOf = { it }, + onReorder = viewModel::onReorder, modifier = Modifier.fillMaxWidth(), rowHeight = 80.dp, - ) { row, position, dragHandle, isDragging -> + ) { id, position, dragHandle, isDragging -> + val row = rowsById[id] ?: return@ReorderableColumn WorldClockRow( row = row, position = position, formatTime = formatTime, + showSeconds = loaded.showSeconds, dragHandle = dragHandle, isDragging = isDragging, onMoveUp = { viewModel.onMoveUp(row.id) }, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt index 2f6f4c6..8f7d729 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.ZoneProvider @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -30,6 +32,7 @@ internal class WorldClockSource @Inject constructor( private val zones: ZoneProvider, private val wallClock: WallClock, private val ticker: Ticker, + private val clockChanges: ClockChanges, ) { /** What the picker needs before it can draw a row; null while it is still being built. */ private data class Catalog(val entries: List, val deviceCity: String) @@ -42,9 +45,10 @@ internal class WorldClockSource @Inject constructor( /** * The tab's state. Both the instant and the device zone are re-read on - * **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED` - * free: the tab self-heals within one tick and `SystemEventReceiver` needs - * no new branch (D2). + * **every tick**, and [ClockChanges] is a tick of its own, so `TIME_SET` + * and `TIMEZONE_CHANGED` show at once rather than at the next minute, and + * `TIME_TICK` catches the minute up after a deep sleep stretched a `delay`. + * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: * once a minute, or once a second when the user turned seconds on (#23). @@ -55,7 +59,7 @@ internal class WorldClockSource @Inject constructor( fun state(): Flow = combine(worldClocks.worldClocks(), storedHomeZoneId, showSeconds, ::Triple) .flatMapLatest { (clocks, storedHome, seconds) -> - ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) + merge(ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)), clockChanges.changes()) .map { build(clocks, storedHome, seconds) } } .distinctUntilChanged() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt index 3a6f516..e997ac5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -38,7 +38,7 @@ class WorldClockViewModel @Inject internal constructor( /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ internal val state: StateFlow = source.state() - // `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and + // `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and // eager sharing would keep it running with nobody looking. The timeout // is what gives rotation its resilience, as on every other tab. .stateIn( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt index e696ff5..a9cd9f4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.testing +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.Ticker import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -44,3 +45,16 @@ class FakeTicker : Ticker { streams.toList().forEach { it.emit(Unit) } } } + +/** [ClockChanges] a test fires by hand, standing in for the system's broadcasts. */ +class FakeClockChanges : ClockChanges { + + private val stream = MutableSharedFlow(extraBufferCapacity = 64) + + override fun changes(): Flow = stream + + /** One `TIME_SET`/`TIMEZONE_CHANGED`/`TIME_TICK` to every collector. */ + suspend fun fire() { + stream.emit(Unit) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt index e823c70..a49f607 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt @@ -36,6 +36,7 @@ class WorldClockHarness( val wallClock: FakeWallClock = FakeWallClock(now) val zones: FakeZoneProvider = FakeZoneProvider() val ticker: FakeTicker = FakeTicker() + val clockChanges: FakeClockChanges = FakeClockChanges() val names: FakeZoneNames = FakeZoneNames() val store: PrefStore = PrefStore( @@ -58,6 +59,7 @@ class WorldClockHarness( zones = zones, wallClock = wallClock, ticker = ticker, + clockChanges = clockChanges, ) val viewModel: WorldClockViewModel = WorldClockViewModel( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt index 02595af..3ee6ac9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt @@ -243,7 +243,7 @@ class WorldClockRowsTest { /** §5.7 #16 */ @Test fun `the hero face is home, to the minute`() { - val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) + val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false) assertThat(home.zoneId).isEqualTo(berlin) assertThat(home.city).isEqualTo("Berlin") @@ -261,7 +261,7 @@ class WorldClockRowsTest { /** §5.7 #17 */ @Test fun `a home zone with no entry falls back to its last segment`() { - val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap()) + val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap(), showSeconds = false) assertThat(home.city).isEqualTo("Ho Chi Minh") assertThat(home.zoneName).isNull() @@ -270,8 +270,8 @@ class WorldClockRowsTest { /** §5.7 #18 */ @Test fun `the dial's morph carries whether it is the middle of the night there`() { - val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) - val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap()) + val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false) + val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap(), showSeconds = false) assertThat(night.dayFraction).isWithin(0.001f).of(0f) assertThat(day.dayFraction).isWithin(0.001f).of(1f) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt index 9843858..dbaf462 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt @@ -399,6 +399,21 @@ class WorldClockViewModelTest { assertThat(harness.loaded()).isEqualTo(before) } + /** #23: a minute-long cadence must not make a zone change wait a minute. */ + @Test + fun `a zone change shows at once even between minute ticks`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + advanceUntilIdle() + assertThat(harness.loaded().home.zoneId).isEqualTo("Europe/Berlin") + + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.clockChanges.fire() + advanceUntilIdle() + + assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo") + } + /** §5.9 #20, with #23's seconds on. */ @Test fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e6c8b3c..68b8cd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1427,7 +1427,9 @@ nothing but `delay`, which makes it testable under `runTest`'s virtual clock. after the immediate emission it waits `WallBoundaries.untilNext(now, unit)`, re-measured against `WallClock` after every wake, so it fires on the second or minute boundary itself and a long `delay` or a set clock does not accumulate. -The world clock is its one caller (§16, "The cadence"). +The world clock is its one caller (§16, "The cadence"), and it merges in +`ClockChanges`, the system's own word that the clock or zone moved, so a long +cadence never means a long wait for those. ### The dismiss challenge, and why it cannot strand anyone @@ -2453,11 +2455,15 @@ the flow stays `distinctUntilChanged`, which now only absorbs the odd early wake. The setting is read with the stored list and the home zone, so flipping it switches the cadence at once through the same `flatMapLatest`. -`TIME_SET` and `TIMEZONE_CHANGED` still self-heal within one tick, which with -seconds off is now up to a minute. That is only reachable by changing the -clock in system settings and returning within the five-second -`WhileSubscribed` grace; any longer and the flow restarts and reads the new -time at once. +A minute is too long to wait for `TIME_SET` or `TIMEZONE_CHANGED`, which can +arrive with the tab on screen (a network zone change on landing, split +screen). So `ClockChanges` (`AndroidClockChanges`, a receiver registered only +while the tab collects) is merged into the tick: those two, plus `TIME_TICK`, +make the state re-read the clock and the zone at once. `TIME_TICK` also +covers the one gap an aligned `delay` has, a deep sleep inside the +five-second `WhileSubscribed` grace, which stretches the wait because the +main looper's clock stops while the device sleeps. Every one of these is +an extra `build`, and `distinctUntilChanged` drops it if nothing moved. ### Twenty-four cities, and a reorder a screen reader can reach