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:
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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user