fix(worldclock): read the whole home readout and catch up after sleep

The home readout gave TalkBack its minute-precision time as a
description, which replaced "Home", the city and the zone name; it is
the node's text now. The clock-change receiver swaps TIME_TICK, which
doubled every minute's work, for SCREEN_ON, which covers a delay
stretched by deep sleep, and resets the cached default zone on
TIMEZONE_CHANGED so the rebuild cannot read the old one. Doc and KDoc
drift fixed.
This commit is contained in:
makiolaj committed 2026-10-21 19:14:08 +02:00
1 parent 65c86af313
commit adc5a3bdd6
8 files changed
+54 -38

No files matched your search

@@ -10,14 +10,17 @@ import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.util.TimeZone
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.
* `TIME_SET`, `TIMEZONE_CHANGED` and `SCREEN_ON`, through a receiver that is
* registered only while somebody collects. The first two already have their
* manifest receiver (`SystemEventReceiver`) for the alarm engine; this one is
* for a readout. `SCREEN_ON` cannot be declared in the manifest at all, and is
* here because a `delay` on the main looper does not count deep sleep: a
* readout left subscribed through a sleep would otherwise wake stale.
*/
@Singleton
class AndroidClockChanges @Inject constructor(
@@ -27,13 +30,18 @@ class AndroidClockChanges @Inject constructor(
override fun changes(): Flow<Unit> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The process's cached default zone is reset by a separate
// one-way call that can land after this broadcast; resetting it
// here too (as ActivityThread does) means the rebuild this
// triggers reads the new zone, not the old one.
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) TimeZone.setDefault(null)
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
addAction(Intent.ACTION_TIME_TICK)
addAction(Intent.ACTION_SCREEN_ON)
}
// Protected system broadcasts still arrive at a non-exported receiver,
// and nothing else on the device gets to poke the tab.
@@ -52,9 +52,10 @@ interface Ticker {
/**
* 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.
* time was set, the zone changed, or the screen came back on after a sleep
* that a `delay` does not count. A readout on a long cadence restarts its tick
* on this, so it does not wait out its own period to catch up. Emits only
* while collected.
*/
interface ClockChanges {
fun changes(): Flow<Unit>
@@ -8,16 +8,16 @@ import kotlin.time.toKotlinDuration
internal object WorldClockDefaults {
/**
* Aligned to the wall clock, so the readouts change on the boundary itself
* rather than up to a cadence late: the minute when seconds are off, the
* second only when the user asked for them (D18, #23).
*/
/**
* 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.
* The unit every time on the tab is truncated to: the minute, or the second
* only when the user asked for them (D18, #23).
*/
fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES
/**
* The tab's cadence, aligned to the wall clock through
* `Ticker.alignedTicks` so the readouts change on the boundary itself.
* Derived from [unitFor], so the readouts and the cadence cannot drift apart.
*/
fun tickFor(showSeconds: Boolean): Duration = unitFor(showSeconds).duration.toKotlinDuration()
val HeroFaceSize: Dp = 200.dp
@@ -139,17 +139,18 @@ internal fun WorldClockRow(
)
}
if (time != null) {
val timeStyle = if (showSeconds) {
MaterialTheme.typography.headlineSmall
} else {
MaterialTheme.typography.headlineMedium
}
Text(
text = time,
// Tabular, because M7 made the headline role tabular
// app-wide: a column of times must not jitter as it ticks.
// 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
},
style = timeStyle,
maxLines = 1,
softWrap = false,
// Capped, and shrunk to fit the cap, so at a large font
@@ -157,11 +158,7 @@ internal fun WorldClockRow(
// city name no width at all.
autoSize = TextAutoSize.StepBased(
minFontSize = TimeMinFontSize,
maxFontSize = if (showSeconds) {
MaterialTheme.typography.headlineSmall.fontSize
} else {
MaterialTheme.typography.headlineMedium.fontSize
},
maxFontSize = timeStyle.fontSize,
),
// The row's description already says the time, to the
// minute; the ticking text itself stays out of the tree.
@@ -28,6 +28,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -228,7 +230,9 @@ private fun HomeFace(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Spoken to the minute: with seconds on, a merged node whose text
// changed every second would be re-announced every second.
// changed every second would be re-announced every second. Set as
// the text, not a description, so the merge still reads "Home",
// the city and the zone name after it.
val spokenTime = formatSpokenTime(home.time)
Text(
text = formatTime(home.time),
@@ -237,7 +241,7 @@ private fun HomeFace(
textAlign = TextAlign.Center,
modifier = Modifier
.padding(bottom = 8.dp)
.clearAndSetSemantics { contentDescription = spokenTime },
.clearAndSetSemantics { text = AnnotatedString(spokenTime) },
)
// The face has no other label saying whose time it is, and a city
// here is otherwise indistinguishable from a city in the list below.
@@ -46,8 +46,8 @@ internal class WorldClockSource @Inject constructor(
* The tab's state. Both the instant and the device zone are re-read on
* **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and
* `TIMEZONE_CHANGED` show at once rather than at the next minute, the next
* wait is measured against the clock as it now reads, and `TIME_TICK`
* catches the minute up after a deep sleep stretched a `delay`. It sits
* wait is measured against the clock as it now reads, and `SCREEN_ON`
* catches up after a deep sleep stretched a `delay`. It sits
* outside the `flatMapLatest`, so its receiver is registered once per
* subscription rather than once per list edit, and `onStart` lets the
* first state through without waiting for a broadcast.
@@ -53,7 +53,7 @@ class FakeClockChanges : ClockChanges {
override fun changes(): Flow<Unit> = stream
/** One `TIME_SET`/`TIMEZONE_CHANGED`/`TIME_TICK` to every collector. */
/** One `TIME_SET`/`TIMEZONE_CHANGED`/`SCREEN_ON` to every collector. */
suspend fun fire() {
stream.emit(Unit)
}
+14 -8
View File
@@ -595,7 +595,8 @@ Four slices:
| Slice | Keys | Owner |
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id`, `world_clock_show_seconds` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>`; `world_clock_show_seconds` (#23) as its own `SettingsPrefs.worldClockSeconds`, so flipping a display option re-emits nothing alarms and timers watch |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -2444,8 +2445,9 @@ The second hand belongs to the switch rather than being always there: a hand
that steps every second is exactly what makes the tab wake every second, and
the point of the switch is that nobody pays for that without asking.
Both cadences are `Ticker.alignedTicks` (§12), `WorldClockDefaults.MinuteTick`
or `SecondTick`, so a tick lands on the wall-clock boundary itself rather than
Both cadences are `Ticker.alignedTicks` (§12), at
`WorldClockDefaults.tickFor(showSeconds)`, which is derived from the same
`unitFor` the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
up to a period late; a free-running one-second `delay` could leave the second
hand a full second behind the device clock. This replaces M8's 200 ms
free-running tick, which got within a fifth of a second only by waking five
@@ -2458,12 +2460,16 @@ it switches the cadence at once through the same `flatMapLatest`.
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) restarts the tick: those two, plus `TIME_TICK`, make
while the tab collects) restarts the tick: those two, plus `SCREEN_ON`, make
the state re-read the clock and the zone at once, and the restarted ticker
measures its next wait against the clock as it now reads. `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. The signal is combined
measures its next wait against the clock as it now reads. On
`TIMEZONE_CHANGED` the receiver also resets the process's cached default
zone itself, since the system's own reset is a separate call that can land
after the broadcast. `SCREEN_ON` 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.
(`TIME_TICK` would also do, but it fires on the same boundary as the minute
tick and would build every minute twice.) The signal is combined
with the stored list, the home zone and the setting *outside* the
`flatMapLatest`, so the receiver is registered once per subscription, not
once per list edit. Each broadcast costs one extra `build`, which