137 Commits
Author SHA1 Message Date
makiolaj caa8c50db3 chore(release): 1.0.0-beta.4
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 9m3s
2026-10-26 21:59:45 +01:00
makiolaj d957fdf23d Merge branch 'feat/20-swipe-delete-undo' into release/v1.0.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt
#	docs/ROADMAP.md
2026-10-26 21:54:31 +01:00
makiolaj c2a583fda7 fix(alarms): offer the delete Undo as the family's SnackChip
The alarm and timer lists show the Undo in floret-kit's SnackChip beside the FAB instead of the stock M3 snackbar, as Agendula and Calendula do.
2026-10-26 21:51:38 +01:00
makiolaj df8be5e158 Merge branch 'feat/26-quick-settings-tiles' into release/v1.0.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
#	docs/ROADMAP.md
2026-10-26 21:00:57 +01:00
makiolaj a0a1daa920 Merge branch 'feat/25-screen-saver' into release/v1.0.0
# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
2026-10-26 20:59:33 +01:00
makiolaj 813bb52c03 Merge branch 'feat/24-automatic-home-clock' into release/v1.0.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
2026-10-26 20:56:34 +01:00
makiolaj fd7f5e8dcd Merge branch 'feat/23-world-clock-seconds' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
2026-10-26 20:54:31 +01:00
makiolaj 48d09f7371 Merge branch 'feat/22-timer-vibrate-ramp' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
2026-10-26 20:54:01 +01:00
makiolaj 6f615be4ad Merge branch 'feat/21-schedule-date-pause' into release/v1.0.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt
2026-10-26 20:52:26 +01:00
makiolaj a186e5afcd Merge branch 'feat/19-alarm-set-snackbar' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	docs/ARCHITECTURE.md
2026-10-26 20:50:55 +01:00
makiolaj 494e46a0c8 fix(alarms): measure the upcoming Dismiss against the silence-after window 2026-10-26 20:50:40 +01:00
makiolaj d81cd04b60 Merge branch 'feat/18-volume-buttons' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
2026-10-26 20:49:43 +01:00
makiolaj 333a23d6be Merge branch 'feat/17-silence-after' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/BACKUP.md
2026-10-26 20:48:54 +01:00
makiolaj 440d098c77 Merge branch 'feat/16-upcoming-alarm-notification' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
2026-10-26 20:48:13 +01:00
makiolaj ea8e159b39 Merge branch 'feat/1-bedtime-mode' into release/v1.0.0
# Conflicts:
#	docs/ARCHITECTURE.md
2026-10-26 20:47:54 +01:00
makiolaj 6aa046515c Merge branch 'docs/27-wear-os-note' into release/v1.0.0 2026-10-26 20:47:36 +01:00
makiolaj 9c9b6171e0 Merge branch 'fix/14-review' into release/v1.0.0 2026-10-26 20:47:36 +01:00
makiolaj 2191166035 fix(alarms): show the alarm-set countdown as the family's SnackChip
The countdown used the stock Material snackbar, while Agendula and
Calendula confirm with floret-kit's SnackChip. It now opens as that chip:
bottom-start in the FAB's band on the list, ellipsising short of the FAB,
and in the editor's snackbar slot so it follows the predictive-back
preview. It fills the band's height, as an Undo chip does in the other
apps, and sits on the secondary container to stand apart from the cards.

The chip has no timer, so AlarmSetChipBand holds it for a short
snackbar's four seconds, stretched as the accessibility settings ask.
The hand-over is unchanged: a chip cut short stays pending for the next
resumed screen.

Beside the FAB there is room for about 308 dp, so the message is now
"Alarm in 7 hours and 12 minutes" rather than "Alarm set for … from now",
which would have lost its number to the ellipsis.

Refs #19
2026-10-26 20:21:56 +01:00
makiolaj 817fe77b17 fix(bedtime): record only accepted states, urgent wake-up, keep bedtime on old restores
A refused state change is now remembered as owed and forced by every pass
until it lands, instead of recording the opposite state, which could hide
the next real boundary. Any refusal, in the rule rewrite or the state
change, pulls the slot in to a retry, and the slot is armed in a finally
so a throwing step can't leave it unarmed. The wake-up that ends the mode
is armed allow-while-idle (one such fire a night), so Do Not Disturb no
longer lingers into the morning behind a doze deferral.

The manifest filter for the access-changed broadcast is gone: the
platform only delivers it to runtime-registered receivers, and the
screen's resume check covers it. A backup written before bedtime existed
now keeps the current bedtime settings, and a restore goes through the
engine's own field comparison, so a reminder-only difference leaves a
hand-switched mode alone.

Refs #1
2026-10-22 12:54:02 +02:00
makiolaj 74c0c278ef fix(bedtime): retry refused removals, state changes and rule rewrites
A removal the system refuses while turning bedtime off now keeps the slot
armed fifteen minutes out as its retry, instead of cancelling it and
leaving Do Not Disturb on until some unrelated launch. A refused state
change records the opposite state so the next pass retries it, and a
rule that was rewritten (which clears its condition on Android 10-14) is
switched regardless. A refused rewrite is remembered and retried by every
pass until it lands, rather than resting on the one edit that asked.

Refs #1
2026-10-22 12:41:15 +02:00
makiolaj 131a28c1be fix(bedtime): bounded fallback window, fresh-screen access check
Without exact-alarm access the slot falls back to a ten-minute window
instead of plain set(), whose slack could end an eight-hour night hours
late. A freshly opened bedtime screen reads whether bedtime is on from the
store, not from its still-loading state, so access granted while the
screen was closed creates the rule. A restore that rolled back no longer
forces the mode.

Refs #1
2026-10-22 12:30:26 +02:00
makiolaj 1a5552a221 fix(bedtime): keep the slot off the allow-while-idle ration
Android rations allow-while-idle wake-ups per app in doze, and the timer's
expiry slot and the alarm's fallback share that ration, so a 07:00
wake-up fire could hold back a timer due at 07:04. The bedtime slot is
now a plain exact alarm that doze may defer, which can move a mode
boundary but never a ring. A restore of unchanged bedtime settings no
longer forces the mode, and the screen skips its rule check while bedtime
is off.

Refs #1
2026-10-22 12:20:25 +02:00
makiolaj 8b182c999b fix(bedtime): small hours end at 06:00, retry daytime removals
Only a bedtime before 06:00 now counts as the small hours after the
chosen evening, so a day sleeper's 08:00 bedtime runs on the days picked.
A rule whose removal was refused while turning bedtime off in the daytime
is removed by the next pass, and a restored reminder length the picker
doesn't offer stays choosable.

Refs #1
2026-10-22 12:11:04 +02:00
makiolaj b701859156 fix(bedtime): name nights by their evening, retry a refused removal
A bedtime before noon now belongs to the evening before, so Sunday to
Thursday nights at 00:30 run early Monday to early Friday, not Saturday
night through Wednesday night. Turning bedtime off clears the "last
applied" state only once the rule is really gone, so a refused removal
is retried instead of leaving Do Not Disturb on. Reopening the screen
after granting access elsewhere creates the rule even if the system's
broadcast never arrived. The empty-mask rule lives in one place.

Refs #1
2026-10-22 12:02:39 +02:00
makiolaj 1a443c066f fix(bedtime): DST-safe windows, ordered edits, quieter passes
Whether the wake time is the same night or the next morning is now
decided on the local times, so a bedtime in a spring-forward gap no
longer turns a 45-minute window into a day-long one. Screen edits start
undispatched so they reach the engine's lock in the order they were made,
and an edit that rewrites the rule also re-asserts the mode, in case the
platform reset its state.

The launch-time bedtime pass runs even if an earlier engine pass failed,
a restore that never applied settings no longer forces the mode, and a
user who never turned bedtime on pays nothing for it at launch or boot.
The screen says when equal bedtime and wake-up times mean it never starts.

Refs #1
2026-10-22 11:53:39 +02:00
makiolaj 2dce78281a fix(bedtime): switch a recreated rule, keep manual overrides, finish edits
A rule recreated by a pass (deleted in Settings, access re-granted) now
gets its state set even when no boundary was crossed, and the applied
state is recorded only once the system accepted it. Edits that can't move
a boundary (reminder, effects) no longer re-assert the mode over a manual
override, and the screen's writes run on the application scope so leaving
right after a change can't cut it short. A late reminder counts the
minutes actually left, and one slot fire reads the zone once.

An equal bedtime and wake time is now no window instead of a day-long one,
which on every night meant Do Not Disturb never ended, and an empty days
mask in prefs reads as every night, as a backup's does. The rule-id extras
are translated to the bedtime action in MainActivity, keeping the routing
table keyed on the action alone.

Refs #1
2026-10-22 11:38:56 +02:00
makiolaj 119197106b fix(bedtime): follow boundaries, open from Settings, keep the user's policy
Settings opens a rule's configuration activity with no action, only the
component and the rule id, so MainActivity now routes on the rule-id
extras. Each engine pass reads the clock once, so the mode's state and the
next slot can't disagree around a boundary, and non-edit passes switch the
mode only when the schedule crossed a boundary since Clockula last set it
(persisted), which also catches up a wake-up missed during a force stop
while leaving a mode switched on by hand alone. A reminder fire that also
ends a window now turns the mode off, and a reminder the schedule moved
away from is withdrawn.

The rule is only rewritten when a field it carries changes, and a rewrite
keeps the existing policy if it still allows alarms, so Android 10-14 no
longer loses the user's Do Not Disturb edits. The bedtime receiver, screen
and fallback alarm call are guarded, the boot-path resync runs even if an
earlier engine failed, and a backup with no nights restores every night.

Refs #1
2026-10-22 11:29:33 +02:00
makiolaj 47165ce93f fix(worldclock): keep a ticking row's accessibility actions stable
With seconds on every row recomposed each second and rebuilt its custom
actions from new lambdas, so TalkBack saw changed semantics every
second. The callbacks are remembered per id and the action list per
label and callback. The prefs isolation test now uses a barrier on the
same flow, a weak settings assertion is gone, and a clock set is tested
to show at once and restart the minute tick.
2026-10-22 11:22:05 +02:00
makiolaj 83a77c1bb0 fix(settings): name the bundled sound in the default-sound rows
Picking "Timer expired" as the default alarm or timer sound showed "Custom" in Settings, because the summary knew only the device default and silence. It now names the bundled sound, as the editors already do.

ARCHITECTURE.md now names the exact bundled sentinel instead of a prefix the code does not recognise, lists clockula://media values in the alarm column, gives the timer column the same values, and says RingtoneCatalog lists the bundled sounds too.

Refs #14
2026-10-22 11:20:39 +02:00
makiolaj 69b436cffd feat(bedtime): add an opt-in bedtime mode under Settings
Clockula can now turn on a Do Not Disturb mode of its own at bedtime and
off at the wake-up time, on the nights the user picks, with an optional
reminder before it starts. Digital Wellbeing's Bedtime mode can't be
driven by a third-party app, so this is a separate rule: a typed mode
with grayscale, dimmed wallpaper, dark theme and hidden always-on display
on Android 15+, and a plain DND schedule rule on Android 10-14.

The rule's policy always allows alarms (and media). BedtimeEngine owns one
AlarmManager slot of its own, on its own receiver and request code, and
every bedtime call on the boot, time-change and launch paths runs after
the alarm and timer passes, guarded. The rule id is never stored: the
platform lists an app's own rules, so restores and reinstalls find or
recreate it. Turning bedtime off removes the rule. The screen walks the
user through granting DND access and explains the Wellbeing overlap.
Settings are part of the JSON backup; older files restore bedtime as off.

Refs #1
2026-10-22 11:19:38 +02:00
makiolaj bdcf780028 docs(roadmap): rewrap the Wear OS note
Refs #27
2026-10-22 11:15:52 +02:00
makiolaj 1559e68b2f docs(ringtones): describe the bundled tone and test its no-sounds banner
ARCHITECTURE.md still said every non-silent ringtone_uri is a content URI and listed the picker's rows without the bundled "Timer expired" sound, so a reader could Uri.parse the clockula://tone/ sentinel directly. It now names the sentinel, BundledTones as its only resolver, and where the tone sits in the picker.

A view-model test now covers a device whose only listed tone is the bundled one, which must still show the "no alarm sounds" banner. The existing test only checked an empty list, so it passed even if the bundled tone were counted as a device sound.

Refs #14
2026-10-22 11:14:42 +02:00
makiolaj 2927585608 docs(roadmap): name both data paths and F-Droid's real response
Refs #27
2026-10-22 11:13:55 +02:00
makiolaj b8968f5e19 docs(roadmap): settle the Wear OS note on what the docs say
Keep §5's own claim that only crash reports leave the phone, say the
dependency would ship in every channel, and keep the standalone app's
distribution caveat.

Refs #27
2026-10-22 11:12:18 +02:00
makiolaj 0218339161 fix(worldclock): let the seconds time grow on wide windows
The row's seconds time was capped at 168dp everywhere, so on a tablet a
larger font scale was shrunk back for no reason. The cap now grows to
45% of the window, with 168dp as the floor, and the fitted size is
recomputed when the text measurer changes. A row the column has not
re-seeded yet is skipped rather than crashing, and the source's KDoc
is re-wrapped.
2026-10-22 11:11:20 +02:00
makiolaj dc23618c56 docs(roadmap): drop the AlarmClock route from the Wear OS note
AlarmClock intents only reach apps on the same device and can't read
state, so they are no route for a watch. Cite PRIVACY.md §4 and §5
together and say "no product flavours" precisely.

Refs #27
2026-10-22 11:10:38 +02:00
makiolaj da16ab23cc docs(roadmap): quote the policy and list the AlarmClock route for Wear OS
Cite PRIVACY.md's own wording, name F-Droid's non-free-dependency rule,
and note that third-party watch apps can already drive Clockula through
the AlarmClock intents.

Refs #27
2026-10-22 11:08:53 +02:00
makiolaj 99c052f94c docs(roadmap): state the Wear OS blocker against the policy as written
PRIVACY.md already documents Auto Backup and crash reports as ways data
can leave the phone, and there is a single build rather than an F-Droid
one. Say that, and call the entry blocked rather than unscoped.

Refs #27
2026-10-22 11:07:07 +02:00
makiolaj 26a2b78225 docs(settings): say what SilenceAfter.MINUTES is for
Refs #17
2026-10-22 11:06:23 +02:00
makiolaj fe1c2a3d48 docs(roadmap): reduce the Wear OS note to the decision it needs
Any phone-to-watch channel sends data off the phone, which the privacy
policy rules out today, so the entry now states that decision and the
standalone alternative without speculating about notification bridging.

Refs #27
2026-10-22 11:05:53 +02:00
makiolaj 5c6ec88bb6 docs(architecture): say the timer-vibrate copy runs alongside the boot pass
It runs in its own coroutine with no ordering against the engines, not ahead of them.

Refs #22
2026-10-22 11:05:48 +02:00
makiolaj 94f09e9e5a feat(alarms): choose what the volume buttons do while ringing
A new setting in Settings > Alarms: while the ring screen shows, the
volume buttons change the volume (the default, and what they did
before), snooze, dismiss, or do nothing. It is app-wide, stored next to
the clock defaults, and included in the backup; a backup without it
restores "change the volume".

A press is made without looking, so VolumeButtonPolicy keeps it from
ending an alarm more finally than the ring screen would. Dismiss with a
dismiss challenge set snoozes instead, so the challenge still stands.
With no snoozes left, Snooze (and Dismiss behind a challenge) falls
back to changing the volume, never to the engine's refused-snooze path,
which dismisses. The key's snooze also asks the engine to keep ringing
if the limit turns out to be reached after all (AlarmEngine.snooze's new
dismissIfRefused). The ring screen decides whether a press is its own
on key down and keeps that for the release, leaves the keys alone once
another alarm has taken over the ring, and ignores a second snooze or
dismiss while the first is still running.

Only the ring screen handles the keys. While the ring is a heads-up over
another app, a background app gets no key events, and the one way
around that does not hold up: a remote-playback MediaSession whose
VolumeProvider takes the keys. On Android 13 and later
MediaSessionRecord.canHandleVolumeKey refuses such a session from an app
without a MediaRouter2 routing session, which Clockula's would be, so on
current Pixels the keys never reach it. Where it does work, it loses to
whatever app is in front or played last, shows a cast-volume slider and
a phantom media player, and takes media buttons while the alarm rings.
AOSP DeskClock and BlackyHawky's Clock also handle the keys in their
alarm activity only. ARCHITECTURE.md records the limitation.

Fixes #18
2026-10-22 11:04:39 +02:00
makiolaj cb5cd5123b docs(roadmap): keep the Wear OS note to what holds
Ringing notifications are foreground-service notifications, so making
them non-ongoing wouldn't bridge them; CompanionDeviceManager carries no
data; and watches pair through their makers' apps. Say only what holds
and note that a watch build has no F-Droid channel.

Refs #27
2026-10-22 11:03:44 +02:00
makiolaj 0213af1260 docs(roadmap): correct what a watch gets today
Ring and timer notifications are ongoing, which Wear OS doesn't bridge,
and bridging needs Google's Wear OS app anyway. Quote the privacy policy
exactly, drop the unchecked "only channel" claim, and list bridging the
ring as an option of its own.

Refs #27
2026-10-22 11:01:02 +02:00
makiolaj 95bb2d14ec docs(roadmap): record why a Wear OS companion is blocked on Play Services
A companion needs Google's Wearable Data Layer, which is a proprietary
service the privacy policy and the F-Droid build rule out. The roadmap
entry now records that constraint, what the bridged notifications
already give a watch, and the options if it is picked up.

Refs #27
2026-10-22 10:59:35 +02:00
makiolaj af70ee1b6a fix(screensaver): keep the fading frame when the clock returns to a spot
A spot the clock comes back to, after the time is set back, is now the
newest frame, so the copy fading out is never the one dropped. A
keyboard dock or a connected keyboard no longer recreates the in-app
screen saver either.
2026-10-22 09:15:07 +02:00
makiolaj cba16c940c fix(screensaver): fade only on a move, survive dock and theme changes
The in-app screen saver also handles uiMode, density, font scale and
locale changes in place, so docking it or the dark-theme schedule no
longer recreates it at night brightness. Only a move crossfades; a new
next alarm or a settings change redraws in place, and the outgoing copy
keeps its own minute. The AM/PM split skips quoted pattern text, and
rememberLocalTimeFormatter reads the same currentLocale() as the screen
saver.
2026-10-22 09:04:25 +02:00
makiolaj be2c0ada04 fix(screensaver): match night brightness across hosts, end on leave
The dream now turns its window down to the same near-zero brightness as
the in-app screen saver, not just to the system's dim level, and builds
its ComposeView from the window's context rather than the service's.
The in-app screen saver finishes once it is no longer shown, so waking
the phone shows the lock screen, and rotating it into a stand no longer
recreates it. The outgoing minute of the crossfade is hidden from screen
readers.
2026-10-22 08:51:40 +02:00
makiolaj ba1172b822 fix(alarms): keep a settling card from overshooting past its rest 2026-10-22 08:43:26 +02:00
makiolaj 4765b8e088 fix(screensaver): keep both screen saver activities out of the app's task
The settings screen Android's screen saver list opens gets a task of its
own and stays out of recents, so it never lands in or clears the app's
task, and its bars follow the app's theme. The on-demand screen saver is
noHistory, so leaving it by Home does not bring the app back to it later,
and it only touches the keep-screen-on flag when charging starts or stops.

The next-alarm line uses the same 12/24-hour rule as the big readout,
joined to the day by a translatable string; the minute's crossfade shows
the old minute fading out rather than two copies of the new one.
2026-10-22 08:39:07 +02:00
makiolaj 40c71a49f7 fix(timers): delete on the application scope, and wait for storage in the delete tests
The timer list's delete now outlives a back press the same way the alarm list's does. The instrumented delete and Undo tests wait for the row to change in storage, because that work runs outside Compose's idling.
2026-10-22 08:35:14 +02:00
makiolaj 23f2ee94ea fix(screensaver): start dim, lock on time and keep room to drift
Both hosts start at night-mode brightness and only brighten once the
settings say so, and the screen stays black until the settings and the
appearance are both read, so a dark room is never lit by a loading frame.
The on-demand screen saver lets the phone lock on its usual timeout and
stays up over the lock screen, so a phone left charging is not left
unlocked. The digital readout takes 70% of the shorter side, leaving
room to drift across it.

The next alarm is one localized date-time skeleton instead of a
hand-joined day and time, the date uses floret's localizedDateFormatter,
and the 12/24-hour pattern is shared with rememberLocalTimeFormatter.
2026-10-22 08:29:02 +02:00
makiolaj 3b488ef6fc fix(tiles): follow the quick-timer tile's whole model, and clear stranded timers
WidgetSync now redraws the quick-timer tile on its own model rather than
on the timers alone, so a duration restored from a backup reaches it too
and no writer has to ask for a redraw by hand.

A transient timer the tile created but never got running is deleted on
the next tap instead of sitting idle in the Timers list for good.

The tile model loses its unused action enum: an Active supertype carries
the timer id every reset needs.
2026-10-22 08:26:09 +02:00
makiolaj ddec2d8ed1 fix(alarms): open the ring screen when the editor's Delete is refused
Deleting a ringing alarm from the editor is refused, and a refused tap did nothing visible. It now opens the ring screen, the same way out the list's card offers.
2026-10-22 08:25:12 +02:00
makiolaj 712b606779 fix(alarms): keep the upcoming notice off watches, and check past the grace
The notice is local-only, so its Dismiss is never bridged to a watch where the phone's unlock does not apply. The closing and skip-spending checks land a second past the fire grace, since onFire still accepts a fire at exactly the grace.

Fixes #16
2026-10-22 08:21:33 +02:00
makiolaj 9839520b7b refactor(alarms): one date formatter, and a picker year range sized to its date
The list and editor now format a date through the same helper as the
widget, and the date pickers keep their default years unless a stored
date lies beyond them, instead of always spanning to 9999.
2026-10-22 08:20:19 +02:00
makiolaj beeca29f74 fix(tiles): redraw on clock and zone changes, and tighten the tile surfaces
The tiles' refresh now rides WidgetSync's collectors instead of a second
copy of them, and SystemEventReceiver asks both tiles to redraw on a time
or zone change, where the times they show move without any write.

The quick-timer tile remembers its timer before starting it, so a failed
start can't leave a timer it cannot see, and shows the timer's stored end
rather than one re-derived from two clocks on every read.

The tile preferences screen closes on anything but QS_TILE_PREFERENCES,
runs in its own task, and asks for a redraw after saving a duration. The
tiles and the next-alarm widget share one 12/24-hour time formatter and
the widgets' launch intent.
2026-10-22 08:19:49 +02:00
makiolaj d744fa8d98 fix(screensaver): address review findings
The dream's settings activity is named by class alone, so the gear opens
the installed build rather than always the production package. The
screen saver now formats time with the app's own 12/24-hour rule and the
shared currentLocale(), sizes the numerals with the AM/PM marker counted,
dates a next alarm a week or more away, and gives the analog face the
time as its description. The screen saver header in Settings > Clock
waits for its rows, and BackupSettings.screenSaver is required.
2026-10-22 08:18:35 +02:00
makiolaj 7adda73a79 fix(alarms): keep the editor's Delete off a ringing alarm too
With its confirmation gone, one tap on the editor's Delete would have
silenced a ringing alarm round its dismiss challenge, which the list's
swipe is already kept from doing; the ring screen is the way out. The
list's delete now runs on the application scope like the editor's, a
thrown-away card ignores a new drag, and the timers snackbar clears the
live pill on an empty tab.
2026-10-22 08:15:52 +02:00
makiolaj 5ad9bd02d6 fix(alarms): spend a pending skip only after its fire grace
The upcoming check that spends a skip dismissed in advance now wakes once the skipped occurrence's fire grace is over rather than at the occurrence itself, so it can never get ahead of a fire still in flight for it.

Fixes #16
2026-10-22 08:13:31 +02:00
makiolaj c5de9becdf fix(alarms): spend a date on the ring that used it, and harden date input
A dated one-shot whose time was moved later while it was snoozed rang a
second time that day, because closing the cycle only asked whether the
date was still ahead. It now keeps the alarm on only for a date after the
day that rang, and a one-shot switched off as its cycle closes lets its
date go.

The date pickers accept every year a stored date can have, so a far date
from a backup can no longer crash the editor. The widget formats its date
with the list's own formatter, and a backup's leftover date on the wrong
kind of alarm is ignored instead of rejecting the file.
2026-10-22 08:11:35 +02:00
makiolaj da9854abf8 feat(tiles): Quick Settings tiles for the next alarm and a quick timer
"Next alarm" reads the next-alarm widget's model and shows the weekday
and time of the next alarm, or "No alarms"; a tap opens the Alarms tab,
behind an unlock on a locked device, through the PendingIntent overload
of startActivityAndCollapse on API 34+.

"Quick timer" is the quick-timer widget's twin: at rest it shows its
duration and a tap starts a transient timer through the timer engine;
while that timer is active the tile is on, shows when it ends (or that
it is paused or done), and a tap resets it. It works on a locked device,
as the timer notification does. Its duration is a new backed-up setting,
chosen by long-pressing the tile, with the widget's bounds and picker.

Both are active tiles, redrawn by TileSync when the state they mirror
changes rather than bound on every panel open. No new permission.

Fixes #26
2026-10-22 08:09:14 +02:00
makiolaj fdb725e64c fix(alarms): spend a skip at its occurrence again
Keeping a skip armed through its fire grace let the editor's switch or another dismissal inside those two minutes ring the skipped alarm late, or be absorbed. The resolver goes back to spending the skip at its occurrence and recording it as handled, and the upcoming check wakes at an armed skip's occurrence so the list stops saying "skipping next" on time.

Fixes #16
2026-10-22 08:07:52 +02:00
makiolaj 1e29226364 feat(screensaver): add a screen saver with a dim night mode
A DreamService shows a full-screen clock, the date and the next alarm,
digital or as the world clock's analog face, on black. Night mode dims
every colour towards black and turns the display down; the clock moves
to a new spot each minute so nothing burns in. It wakes only on the
minute and on a time, zone or 12/24-hour change.

The tabs' settings gear becomes an overflow menu with "Screen saver" and
"Settings". An app cannot start the system's dream without a system
permission, so that item opens an activity with the same content, which
keeps the screen on only while charging. Style and night mode live in
Settings > Clock and in the dream's own settings screen, and are carried
in backups; older backups restore the defaults.

Fixes #25
2026-10-22 08:07:44 +02:00
makiolaj 5f3b97b32a fix(alarms): refuse a list delete of a ringing alarm in the engine
The list's delete re-checks for a ring under the engine lock, so a
swipe released as the alarm fires cannot silence it round its dismiss
challenge. An Undo whose snackbar is already up no longer goes stale
on a rotation after a long accessibility timeout, and a deleted card is
thrown clear of the screen edge.
2026-10-22 08:05:25 +02:00
makiolaj 301ce2fec9 fix(alarms): keep a one-shot on when it was given a date mid-snooze
Dismissing a snoozed one-shot switched it off even when the user had
just scheduled it for a later date from the editor, so the date never
rang. Closing a cycle now leaves a one-shot on while its date is still
ahead.
2026-10-22 08:01:36 +02:00
makiolaj a88e696d52 fix(alarms): spend a skip only after its fire grace
The resolver now keeps a skip armed until its occurrence is out of the fire grace too, so a reschedule in those two minutes cannot spend it early and let a fire already in flight ring the dismissed alarm; onFire refuses that fire by the armed watermark. The notifier also cancels notices it posted itself, which the platform may not list as active yet.

Fixes #16
2026-10-22 08:00:38 +02:00
makiolaj 697678b226 fix(alarms): drop an Undo offered while no list could show it
A delete can finish while no list is on screen, such as the app sent
home the instant the editor's Delete was tapped. Its Undo is now
dropped unshown once it is 30 seconds old, instead of surfacing hours
later. Alarms and timers share one UndoSlot for this. A card whose
swipe is re-enabled mid-settle still slides home.
2026-10-22 07:54:43 +02:00
makiolaj 247dbcb751 fix(alarms): name a pause's last day, and switch on an alarm given a date
"Paused, back on" named the day after the pause, which for a weekday
alarm can be a Saturday it never rings on. A pause under way now reads
"Paused through Wed, Oct 28".

Picking a date on a switched-off alarm switches it on, the pause picker
cannot confirm a range that is already over, a spent pause stored with
its ends reversed is cleared too, and the widget formats its date in the
app's own locale.
2026-10-22 07:53:11 +02:00
makiolaj 93f6fdf98d test(prefs): cover the automatic local clock's round trip and restore
Also reuse ZoneOffsetFormat.gmtLabel for the Etc/GMT names rather than
a second formatter, and bring the city-fallback docs up to date.
2026-10-22 07:52:47 +02:00
makiolaj b4af164826 fix(alarms): spend a dismissed skip on time, and ring a re-armed handled alarm
The upcoming check now also wakes once a noticed occurrence's fire grace has run out, so a notice cannot outlive a late fire, and once an armed skip's has, so the list stops saying "skipping next" about an occurrence already gone. onFire is back to checking only the armed skip: the resolver deliberately re-arms a handled occurrence that a clock moved backwards put back in the future, and that one must ring.

Fixes #16
2026-10-22 07:51:57 +02:00
makiolaj b79b38bd2b fix(worldclock): fix Etc/GMT names at the fallback, not per caller
The previous rule named every non-place id by its current offset, which
renamed legacy ids such as Japan, called Etc/UTC "GMT" and made a CET
row's name change at DST. Only the Etc/GMT+-n ids have a misleading
last segment, so ZoneCatalog.cityFallbackFor now flips just those to
their real offset. That also reaches the zone picker's device row,
which takes its name from the same fallback.
2026-10-22 07:44:54 +02:00
makiolaj 62da327771 fix(alarms): bring a card home when its swipe is disabled mid-gesture
A card whose alarm starts ringing during a drag or the throw-away
animation slides back instead of staying displaced, and the delete is
re-checked after the animation, so it cannot land on a ringing alarm.
2026-10-22 07:44:28 +02:00
makiolaj 1dcd743e5b fix(alarms): keep a skip on the next ring after any edit, and say when a pause ends
Moving the time or changing the days left a pending skip pointing at the
old sequence, so the skipped day could ring at its new time. Every editor
change that can move a fire time now re-arms the skip.

A pause under way now reads "Paused, back on Thu, Oct 29" instead of
naming its last day, which read as the day it ends before. Reopening a
begun pause keeps its start, the date pickers take their bounds from the
clock when they open, and the dismiss chooser shows the scheduled date and
pause like the list does.
2026-10-22 07:43:30 +02:00
makiolaj f570bdefb7 fix(alarms): keep the upcoming notice off a locked screen below Android 12
The Dismiss action's unlock requirement is honoured only from Android 12, so on 10 and 11 the notice is now secret on the secure lock screen rather than dismissible by whoever holds the phone.

Fixes #16
2026-10-22 07:41:05 +02:00
makiolaj 40b4c9e2e4 fix(worldclock): name offset-set zones by their real offset everywhere
The Etc/GMT-n naming fix only covered the local row. A home that
follows a device set by UTC offset still put "GMT-9" under a UTC+9
face. The rule now lives in the one place every row and the face take
their city from.
2026-10-22 07:38:49 +02:00
makiolaj 1253c0fc55 fix(alarms): no fire for an occurrence already accounted for
A natural fire whose instant equals the skip or the handled-occurrence watermark is ignored, so a dismissal racing a reschedule cannot ring. Dismissing a repeating alarm persists its resolution before arming the skip, so an unspent old skip cannot absorb it. The deferred notice sync runs even if starting the ring throws, the swipe record is one synchronised read-modify-write, and it stays out of backups.

Fixes #16
2026-10-21 20:26:50 +02:00
makiolaj 471cbdabd8 fix(alarms): re-arm a skip when the pause changes, and clear spent dates in place
A skip armed under one pause kept its watermark when the pause changed,
so removing the pause left the alarm silent until the old pause's end.
The engine now re-arms a pending skip after a pause edit.

Spent dates and pauses are cleared with targeted, conditional updates
instead of a whole-row rewrite. SET_ALARM ignores a date or pause that
has already gone by when looking for an alarm to re-use, the editor
re-reads the clock when a date picker opens, and the next-alarm widget
shows the year for a date in another year.
2026-10-21 20:25:28 +02:00
makiolaj f46b7e1e37 fix(worldclock): keep the local row's day label and legacy city names
The local row prepends "Local time" to the offset and day, so on a
phone a one-line summary cut off the day ("Tomorrow"), the part that
matters most on a long trip. It now wraps to two lines. Only Etc/
offsets and bare abbreviations are named by their offset; a legacy
place id such as US/Pacific keeps its place name. The picker's home
selection now derives from the same settings subscription the tab
uses.
2026-10-21 20:24:24 +02:00
makiolaj af12b7bb34 fix(alarms): keep a ringing card out of the swipe, and Undo off the ViewModel scope
A ringing alarm's card no longer swipes or offers Delete: it leads to
the ring screen, and a swipe must not get round the dismiss challenge.
A drag cancelled before release (a ring screen opening on top) never
deletes. Undo restores on the application scope, since a claimed Undo
has no second chance. Closing a cycle and deleting share one teardown.
2026-10-21 20:22:25 +02:00
makiolaj 1333001aa9 fix(worldclock): name an offset-set local row by its real offset
A device set by UTC offset rather than by city reports an id like
Etc/GMT-9, which is UTC+9; the local row took its last segment as the
city and read "GMT-9". It is now named by its actual offset. LocalClock
takes the already-resolved home zone rather than resolving it a second
time, the tab reads both of its settings from one subscription, and the
local row falls back to the unknown-zone sentence for TalkBack instead
of an empty time.
2026-10-21 20:18:10 +02:00
makiolaj dedcbd5dfd fix(alarms): a consumed skip stays handled, and only a swipe hides a notice
A skip spent on its occurrence now becomes the handled-occurrence watermark, so a second resolution inside the fire grace cannot ring the skipped alarm after all. The upcoming notice records a swipe through its delete intent rather than inferring one, so a reboot or a blocked channel no longer hides it for good; its Dismiss asks for an unlock, and a Dismiss for a switched-off alarm clears its notice.

Fixes #16
2026-10-21 20:18:05 +02:00
makiolaj b1a45db54a feat(alarms): schedule an alarm on a date, pause a repeating one
A one-shot alarm can now ring on a chosen date, and a repeating alarm
can be paused for a range of days. Both are local dates: the date is a
floor and the pause a hole in the occurrence sequence, so DST and zone
changes resolve as for any other alarm. A missed date behaves like a
missed one-shot, and each clears itself once it can no longer change a
candidate. Picking a repeat day clears the date, as in Google Clock;
"skip next" stays the quick version of a pause.

The editor uses the M3 date and date-range pickers, which offer no past
day. The list, the next-alarm widget and the self-check read the
resolver, so they follow. The schema goes to v4 with a migration, and
the backup format to v2, which still imports v1 files. SET_ALARM no
longer re-uses an alarm that carries a date or a pause.

Fixes #21
2026-10-21 20:16:34 +02:00
makiolaj ef26d433e1 fix(alarms): let an unanswered Undo lapse, and make delete and restore atomic
A snackbar the screen leaves unanswered (another tab, Settings, the app
closing) now counts as expired, except across a rotation. Held back, it
could restore a snapshot from hours ago, ring a long-gone timer, or
land on top of a backup restored in the meantime. One shared
UndoSnackbarEffect does this for alarms and timers.

The engines' delete and restore run non-cancellable, so a caller
cancelled half-way cannot leave a restored alarm unscheduled. The
editor writes the label still in flight before it deletes, so Undo
brings back what was typed.

The swipe counts the touch-slop overshoot, keeps a card flung back
towards the start, slides a card home if its row is still there a
second after the delete, and no longer recomposes on every drag frame.
2026-10-21 20:13:09 +02:00
makiolaj 755150881b refactor(worldclock): share the row text between saved and local rows
The local-time row copied the saved row's name, summary and time
layout. Both now draw it through one RowText, so a change to how a
row looks lands on both. The Settings hub's Clock entry also names the
new switch, so it can be found from the list.
2026-10-21 20:11:46 +02:00
makiolaj 5b35ef65d4 fix(alarms): keep the upcoming Dismiss away from the fire
A fire broadcast already in flight when a skip was armed on its occurrence is now ignored, and a Dismiss that lands once the occurrence is due is stale. The notices are synced after the ring starts on the fire path, and a swiped-away notice stays gone across a process restart. A switched-off alarm answers NotFound.

Fixes #16
2026-10-21 20:09:47 +02:00
makiolaj f5388042c6 feat(worldclock): show local time automatically while travelling
Issue #24 asks for Google Clock's "automatic home clock": a temporary
Home row while the device is somewhere the time differs from home. In
Google Clock that works because the hero clock is local time. Clockula
is the other way round: the hero face is home (the stored home zone, or
the device's zone when none is stored), every row is read against home,
and with a stored home the face already shows home time on a trip. A
Home row would only repeat the face; what goes missing while travelling
is the local time.

So the feature is turned round. With a home zone stored and the device's
zone on a different UTC offset right now, the tab shows the device's
zone as a temporary "Local time" row between the face and the saved
cities. It is never stored, sits outside the reorderable column, has no
move or remove actions and does not count towards the 24-city cap. The
comparison is the current UTC offset, as Google's is, so Paris with
home in Berlin shows nothing. The row is hidden when a saved city is
already on the device's offset. It is rebuilt on every tick from the
same device-zone read the face uses, so it appears and disappears
within a tick of a time-zone change while the tab is open.

Home that follows the device can never differ from it. Settings > Clock
gets an "Automatic local clock" switch (on by default) under the home
zone row; while home follows the device the row is dimmed and its hint
says it needs a home time zone. The switch is stored as
automatic_local_clock and included in backups. Files written before this
change restore it as on.

Fixes #24
2026-10-21 20:06:13 +02:00
makiolaj 2feed2ee35 feat(alarms): swipe to delete, with Undo
An alarm card swipes away towards the end edge, or deletes from its
"Delete" accessibility action, and the list's snackbar offers Undo.
The editor's Delete drops its confirmation for the same Undo, routed
through a singleton so the list can answer it after the editor pops.
Timer cards get the same Undo for their corner delete.

AlarmEngine.delete stops a ringing alarm and clears a snoozed one's
notification under the engine lock; restore puts the row back under
its own id with its ring state and reschedules. A ringing alarm comes
back dismissed, never ringing; a finished timer comes back reset.

The swipe is hand-rolled so the end-to-start drag stays with the tab
pager.

Fixes #20
2026-10-21 20:02:00 +02:00
makiolaj e43339c386 feat(alarms): notify before an alarm rings, with Dismiss
Two hours before a natural occurrence (AOSP DeskClock's lead), a silent
"Upcoming alarm" notification offers Dismiss, and the alarm's row shows
the same Dismiss as a chip. Dismissing is dismissUpcoming's own branches:
a repeating alarm skips that occurrence, a one-shot is switched off.

The notices are synced at the end of every reschedule, after the real
registration, and a third AlarmManager slot on its own request code
wakes when the next window opens. The sync is wrapped so a notification
failure can never stop a ring. Dismiss names the occurrence and is
refused once the alarm is ringing or has moved, so it can't get round
the dismiss challenge. No challenge is asked before the ring, as in
Google Clock.

Fixes #16
2026-10-21 20:01:21 +02:00
makiolaj d70d0c78da test(worldclock): make the seconds tests mean what they say
The no-re-emission check now waits for the write to be readable before
asserting, and the seconds switch has its own settings test instead of
riding in the clock-defaults one. WorldClockSource.state takes its
refresh signal without a default, and ARCHITECTURE.md's slice count
and alignedTicks guarantee match the code.
2026-10-21 19:57:19 +02:00
makiolaj 706a384704 fix(alarms): word the alarm-set snackbar once per announcement
Every write re-read the engine and re-worded a standing announcement,
so tapping Mon-Fri while the snackbar showed restarted it on each tap,
as did a label keystroke across a minute. The words now come from the
first read; later writes can only take the snackbar down, when the
alarm stops ringing. The settled announcement still replaces it once.

Deleting no longer retracts by hand: a deleted alarm already reads as
nothing to say.

Refs #19
2026-10-21 19:51:47 +02:00
makiolaj 78da94d775 fix(worldclock): leave a fitting margin and drop the zone-cache reset
The fitted seconds size keeps five percent spare, since hinted glyph
advances do not scale exactly. The receiver no longer resets the
process's default zone: that was speculative, and it changed global
state from a UI flow. The settings state takes the seconds flag without
a default, and a garbled KDoc sentence is fixed.
2026-10-21 19:48:30 +02:00
makiolaj 6239ca0477 refactor(settings): read a silence-after of an hour or more as Never
A stored value of 60 minutes or more now reads as Never, which rings that
long anyway. The picker no longer shows both "60 minutes" and "Never" for
the same window. Also drops a redundant null check in TimerRingPolicy.

Refs #17
2026-10-21 19:45:44 +02:00
makiolaj d2e73901c1 fix(alarms): let a silent alarm-set announcement go for good
An announcement whose alarm has nothing to say, such as one switched on
and straight back off, is now dropped when a screen reads it. Before,
it stood, and something else switching the alarm back on, such as an
AlarmClock intent, would have announced it after all. A settle job that
was cancelled while waiting for the monitor no longer writes.

Refs #19
2026-10-21 19:43:35 +02:00
makiolaj 17a44369b6 refactor(timers): settle timer vibration by copying the alarm default
Telling a fresh install from an updated one (package install times, the boot id, a string-encoded tri-state) was more machinery than the default is worth. Timer vibration is a plain boolean again, defaulting to on like the alarm default, and a startup copy writes the alarm default into it once, so no install's timers change on update. The copy runs in its own coroutine, off the engines' boot pass.

Fresh installs start with timer vibration on, matching alarms; the switch is one tap away in Settings > Timers.

Refs #22
2026-10-21 19:41:46 +02:00
makiolaj 6d235e0974 fix(worldclock): fit the seconds time through px, and leave the default wrapping
From Android 14 large font scales are non-linear, so scaling the sp
size by the width ratio still overflowed the cap; the size is now
worked out in px and converted back. Without seconds the row's time
wraps as it did before instead of ellipsizing.
2026-10-21 19:40:22 +02:00
makiolaj 1689794da6 fix(alarms): re-anchor a ring behind a moved clock, own the timer write
Fifth round of review fixes for the silence-after setting.

A ring whose start the clock has moved back behind is now re-anchored to
now, once, and stored. Before, the negative age lingered, and every later
re-measure gave the ring a fresh window. With that fixed, onAutoSilence
can safely re-arm again when the ring is still inside the window in
force, so a backstop queued behind a raised setting no longer ends the
ring early.

The timers' setting is now written through
TimerEngine.setTimerSilenceAfter, which records a session that has
already outrun the old window as lapsed, so raising the setting cannot
restart a timer that went quiet while no service pass was running. The
lapse rule and the lapsed anchor live in TimerRingPolicy, shared by the
policy and the engine.

Refs #17
2026-10-21 19:38:37 +02:00
makiolaj 9516dc1198 refactor(alarms): derive the alarm-set notice from the engine alone
The announcer now records only which alarm a user action set, and when.
What the snackbar says is derived from upcoming() while a screen shows
it, re-subscribed per announcement so the first read is from after the
write. That removes the second resolve path through the engine lock
(upcomingFor is gone), the publish lock, and the flash of a pre-write
countdown. The engine is observed only while an announcement stands.

Freshness is judged when a screen picks the announcement up, so one
already showing is not cut off half-way. Consuming matches a sequence
number. Latest action wins across alarms again: the sentence names no
alarm, so a settling announcement must never land on top of a newer
action on another one. Switching an alarm off announces nothing, and a
dismissal retracts before it skips.

Refs #19
2026-10-21 19:35:29 +02:00
makiolaj 058fc27749 fix(timers): apply a timer setting changed mid-ring
The service ignored a decision for the timer already sounding, so switching timer vibration off in Settings while a timer rang kept it buzzing until the ring ended. A decision whose settings changed now restarts the ring, its ramp picking up where it was.

The package-manager check for a fresh install also moved out of the settle's DataStore transaction.

Refs #22
2026-10-21 19:33:26 +02:00
makiolaj 71875e95e4 fix(worldclock): fit the seconds readouts without touching the default
The row's time is fitted only with seconds on, against both day periods
since an AM marker can be the wider one, and the style is built once by
the screen rather than per row. With seconds off the row is exactly as
before. The home readout stays on one line with seconds, shrinking to
fit instead of wrapping. SCREEN_ON is dropped: the tab cannot be seen
again without a resume, which already refreshes. §5 no longer claims a
clock change heals within one tick for free.
2026-10-21 19:32:44 +02:00
makiolaj 0b7af0d57b fix(alarms): let the backstop always close the ring it was armed for
Fourth round of review fixes for the silence-after setting.

onAutoSilence no longer re-arms a ring it measures as inside the window.
After a clock moved back, that check could push the backstop out again
and stretch the ring well past its window. Every writer of the window and
every clock change already re-arms the backstop under the engine lock, so
a backstop that fires has had its window, and it closes the ring as in v1.

A one-shot alarm the resolver switches off as missed also clears a
pending skip flag, as the resolver's other one-shot disable does.
SNOOZE_GRACE is its own ten-minute constant again, not tied to the
setting's default.

Refs #17
2026-10-21 19:28:09 +02:00
makiolaj ff49667d39 fix(alarms): keep the alarm-set notice live and editor writes in order
The notice now follows the engine's upcoming() list, so a snackbar
already on screen comes down when its alarm is switched off by another
path, and one whose ring moved, such as a skip armed from the
notification, shows the new countdown. Consuming matches the
announcement, not the words.

Editor writes that move a fire time, skip-next and delete run on the
application scope through one fair queue, entered undispatched so they
commit in tap order. Without it, "skip next" followed at once by
untapping the only repeat day could land out of order and leave a
one-shot with a skip armed, which disables it.

Refs #19
2026-10-21 19:26:24 +02:00
makiolaj 820317f9d9 fix(timers): tell a fresh install by its boot id and repair a garbled timer vibration
An empty store was a racy sign of a fresh install: the UI can write the store (the notification-permission flag) before the startup settle runs, and a fresh install would then vibrate its timers. The boot id is written only by the engines' pass, which runs after the settle in the same coroutine, and an Auto Backup restore brings the old device's.

Once settled, the startup pass costs one read and no package-manager call. A garbled stored value is replaced rather than left following the alarm default.

Refs #22
2026-10-21 19:24:52 +02:00
makiolaj 7a86e5488e fix(worldclock): refresh on resume and size the row's time once
With the tick a minute long, a language change left the city names in
the old language until the next minute; the screen now sends a refresh
on every resume. The row's time is sized once per format against a
wide sample instead of searched for every second, and ellipsizes as a
last resort rather than clipping. BackupSettings takes the setting
without a default, so a snapshot cannot drop it silently.
2026-10-21 19:22:47 +02:00
makiolaj 4cb4b6b6eb fix(alarms): keep clock changes and setting writes inside one window
Third round of review fixes for the silence-after setting.

A backstop that fires while the clock is still behind the ring's start
now closes the ring instead of re-arming in a loop until the wall clock
catches up. A clock change re-measures a ring in progress before it
reschedules: a clock moved back re-arms the backstop at most one window
from the moved clock, and one moved forward past the window closes the
ring.

The engine now owns the alarm setting's write. setAlarmSilenceAfter
stores it and re-measures the ring under one lock and without
cancellation, so a write can no longer land without the re-measure.

A snooze the resolver drops takes its "Snoozed until" notification with
it. SNOOZE_GRACE is derived from the default window, and the silence-after
picker moves to ui/settings, since it is not a per-alarm override.

Refs #17
2026-10-21 19:20:25 +02:00
makiolaj d2bf4349c5 fix(alarms): never announce an alarm that has since stopped ringing
A notice is now re-checked against the engine before a screen shows it,
so an alarm switched off or deleted by any path - the ring screen, a
notification, an intent, a restore - is never announced as set. A
retract takes the publish lock, so a publish already past its read
cannot put the notice back.

Settling is per alarm: switching one alarm off in the list no longer
cancels the repeat days still settling on another. The identical-words
dedupe is gone, since it could swallow a real confirmation.

The editor's writes that move a fire time, with their reschedule and
announcement, run on the application scope. Pick, OK, back could
otherwise cancel the reschedule half-way and leave a stored time the
system had not been given. upcomingFor() reads alarm and state under
the engine lock, so a fire cannot land between the two reads.

Refs #19
2026-10-21 19:18:19 +02:00
makiolaj cc2099c40a fix(timers): settle timer vibration in one transaction and announce its row as a switch
The settle read the store, then wrote it in a second step; a write in between could leave it deciding on a stale snapshot. It is one edit now, which also skips the write once the value exists.

The Vibrate row carries the switch role and its state, since the switch itself is display-only. The changelog says what an updated install gets, not just a fresh one.

Refs #22
2026-10-21 19:15:39 +02:00
makiolaj adc5a3bdd6 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.
2026-10-21 19:14:08 +02:00
makiolaj fb875ca71c fix(alarms): measure every ring against one window rule
Second round of review fixes for the silence-after setting.

A fire now clears any stale ringingSince before it re-resolves the slot.
Before, the resolver's new one-shot close could switch off, or delete, the
very alarm that was about to ring.

A one-shot alarm displaced by another alarm's fire is switched off, as on
every other close of an undismissed cycle, instead of being armed for the
same time tomorrow.

onAutoSilence, onSilenceAfterChanged and the reboot resume now share one
rule. A ring still inside the window has its backstop re-armed, measured
from when it started, or from now if the clock has moved back behind that
start, so a clock change cannot stretch a ring past one window.

Refs #17
2026-10-21 19:12:18 +02:00
makiolaj fa5ce55503 fix(alarms): settle and show the alarm-set notice where it can't be lost
The repeat-day settle ran on the editor's scope, so leaving mid-settle
needed a flush that a cleared scope could still race. The announcer now
owns the settle on the application scope. Screens show the notice only
while resumed, so a cross-fade never shows it twice and a backgrounded
app does not use it up unseen. The editor shows only notices about its
own alarm, since the sentence names none.

Deleting an alarm, or the AlarmClock chooser dismissing it, takes its
notice back. Freshness is measured on the monotonic clock, one instant
is used for both ends of the countdown, and an identical countdown
keeps the standing notice instead of flickering it.

Refs #19
2026-10-21 19:10:42 +02:00
makiolaj 65c86af313 fix(worldclock): keep seconds quiet for TalkBack and apart from the defaults
The time each row and the home readout give TalkBack stays at minute
precision, and the ticking text is kept out of the tree, so a focused
row is not re-read every second. The row's time is capped in width and
shrinks to fit, so a large font scale cannot crowd out the city name.

The setting is its own SettingsPrefs flow rather than a ClockDefaults
field, so flipping it re-emits nothing alarms and timers watch. The
clock-change receiver sits outside the flatMapLatest and restarts the
tick, registering once per subscription and re-measuring the wait
against the clock as it now reads.
2026-10-21 19:07:19 +02:00
makiolaj 84d31fe7f8 fix(timers): settle timer vibration outside the store's init and resume a ramp
The DataStore migration ran its write inside the store's initialisation, so a failed write (a full disk) would have failed every preference read, the alarm path's included. The timer-vibrate key is now nullable instead: unsettled reads as the alarm default, which is what timers did before, and ClockulaApp settles it once at the head of its startup pass, off only for a never-updated package with an empty store. A failed settle leaves the fallback in place.

A timer ring restarted mid-session (handed to a second expired timer, back after an alarm held it, a recreated service) now picks its ramp up from the session's elapsed time instead of either ramping again from a whisper or jumping straight to full volume. RingAudioPlayer takes the offset; alarms pass none.

The Vibrate row's switch is display-only, so a screen reader stops on the row once.

Refs #22
2026-10-21 19:06:50 +02:00
makiolaj 1c323d5b4c fix(alarms): apply silence-after mid-ring and close missed one-shots
Review fixes for the silence-after setting.

Changing the alarm's silence-after now re-measures a ring in progress at
once, through AlarmEngine.onSilenceAfterChanged: past the new window it
closes as missed, inside it the backstop moves. onAutoSilence also re-arms
instead of closing a ring that is still inside the current window, so a
backstop armed for a shorter window cannot end a ring early.

A cycle the resolver closes as missed (a ring past its window, or a
snooze that never fired) now disables a one-shot alarm, as the engine's
own close does. A short window made that path much easier to reach, and
the alarm was being armed for the same time the next day.

A late snooze is kept for the window or ten minutes, whichever is longer,
so a short window never makes a promised snooze easier to lose than in v1.

A timer that has gone quiet stays quiet when the setting is raised: the
lapsed anchor moves back past the hard stop. The timer's heads-up is now
keyed to the session anchor rather than the window's end, so editing the
setting mid-ring no longer counts as a new session and alerts again.

A negative stored value now reads as the default instead of Never. The
picker offers a stored value that matches no choice, and the wake-lock
timeout is one shared constant.

Refs #17
2026-10-21 19:04:08 +02:00
makiolaj 207475ef37 fix(alarms): keep the alarm-set notice until a screen has shown it
The usual way to set an alarm is pick, OK, back, and back disposed the
editor's snackbar a moment after it appeared. Day taps left within the
one-second settle were never announced at all, and a rotation threw a
showing snackbar away for good.

The announcer is now an app-wide singleton holding the notice as state
until a snackbar has run to the end, so the list picks up what the
editor could not finish showing. An editor closed mid-settle announces
on the way out. Announcing is serialised, so a quick on-then-off cannot
land the "on" read last, and a notice nobody showed within ten seconds
is dropped rather than shown stale. The engine's one-alarm read now
shares upcoming()'s preview helper.

Refs #19
2026-10-21 19:00:56 +02:00
makiolaj a4cec23e5c fix(worldclock): keep the seconds option from costing reorder and catch-up
Rows that tick every second re-seeded the reorderable column mid-drag,
so it is handed ids now. A zone or clock change no longer waits for the
next minute: TIME_SET, TIMEZONE_CHANGED and TIME_TICK are merged into
the tick through a new ClockChanges seam. With seconds on, the row's
time drops a size so the city name keeps its room. The truncation unit
and the tick come from one place, and stale 200 ms comments are gone.
2026-10-21 18:57:10 +02:00
makiolaj 8910ab7fbf fix(timers): keep a ramp from restarting mid-ring and a migration from guessing
A timer ring that hands over from one expired timer to another now starts at full volume instead of ramping again from near silence.

The timer-vibrate migration only treats an install as fresh when the package was never updated and the store is empty. An updated install whose store was replaced after corruption, or an Auto Backup restore onto a new device, keeps the alarm default as before.

The Vibrate row now says whether it is on, and only when it is off adds that a silent timer still vibrates.

Refs #22
2026-10-21 18:56:07 +02:00
makiolaj 412e54db09 feat(settings): let alarms and timers choose when they silence themselves
A ringing alarm and a finished timer used to stop after a fixed ten
minutes. Settings > Alarms and Settings > Timers now each have a "Silence
after" row with Google Clock's choices: 1, 5, 10, 15, 20 or 25 minutes,
or Never. Ten minutes stays the default, so nothing changes on upgrade.
The value is app-wide; a per-alarm override would need a new column, a
migration and an editor row, and is left out.

"Never" rings for an hour, then stops. The backstop is also what keeps a
phone left at home from ringing its battery flat, what bounds the ring
services' wake locks, and what stops a reboot hours later from resuming a
ring nobody was there for. A truly unbounded ring would give all of that
up, so Never maps to a one-hour hard stop, longer than any finite choice,
and the settings hint says so. Stored values clamp to 0..60 on read, on
write and from a backup.

The window is read everywhere the fixed constant was: the resolver's
ringing and late-snooze checks (which now take it without a default), the
backstop onFire arms, how late a snooze fire may arrive, whether an
interrupted ring is resumed after a reboot, upcoming(), the timer ring
policy, the lapsed-session check and the timer ring screen. Both ring
services' wake-lock timeouts now derive from the hard stop instead of a
hard-coded fifteen minutes.

When the resolver closes a ring (the window was shortened mid-ring, the
clock jumped, the alarm was switched off), the engine now also stops the
ring service and cancels the backstop. Before, it only cleared
ringingSince and left the sound to a backstop armed for the old window.

Both values are backed up as default_alarm_silence_after_minutes and
default_timer_silence_after_minutes; a file without them imports as ten
minutes.

Fixes #17
2026-10-21 18:52:25 +02:00
makiolaj 58c9bfebb1 feat(alarms): say how long until an alarm rings when you set it
After a user action in the app sets an alarm, a snackbar says how long
until it rings: "Alarm set for 7 hours and 12 minutes from now". The row
already said "in 7h 12m", but nothing drew the eye to it, so an AM/PM or
wrong-day slip went unnoticed until the morning.

It is raised by switching an alarm on in the list, and in the editor by
picking a time (or keeping a new alarm's default one), arming or
disarming the skip, and changing the repeat days. Day taps wait a second
to settle, so Mon-Fri is one snackbar rather than five; a newer
countdown always replaces the one on screen. Switching the announced
alarm back off takes it back. A switched-off, ringing or snoozed alarm
says nothing, and the AlarmClock intents never reach it, since those
already get the status-bar icon.

AlarmSetCountdown decides what it says, purely, with the row's rounding;
the engine gains upcomingFor(), a read-only resolve of one alarm.

Fixes #19
2026-10-21 18:51:49 +02:00
makiolaj fbc3a192bf feat(timers): give timers their own vibration and gradual volume
Timers vibrated by the alarm vibration default and never ramped. Both are
now the timers' own app-wide settings, next to the default timer sound in
Settings > Timers: a Vibrate switch and the same Gradual volume picker
alarms use. Timer.resolveSettings reads them instead of the alarm default
and the old TimerRing.RAMP_SECONDS constant.

A fresh install starts with timer vibration off, as Google Clock does, and
no ramp. An existing install keeps what its timers did: a one-shot DataStore
migration writes the new key from the alarm vibration default before the
first read, so nobody's kitchen timer silently stops buzzing on update. A
fresh install is told apart by an empty store (anything that has run once
has written its boot id); the migration writes the default there too, so it
never runs twice.

Backups carry both new fields. A file without them restores the alarm
vibration default as the timers' (what that install's timers did) and a
flat ring.

Silent plus vibration off still signals: Silent gives the player no
sources, and its onSilenced callback starts the vibrator whatever the
setting says. The settings row says so.

Fixes #22
2026-10-21 18:49:45 +02:00
makiolaj 785adf715b feat(worldclock): add an option to display time with seconds
One switch in Settings > Clock adds seconds to the home readout and
every city row, in the same 12/24-hour and locale form as before, and
gives the analog face its second hand. Off by default.

The tab now ticks on the wall-clock boundary through a new
Ticker.alignedTicks: once a minute with seconds off, once a second only
with them on, replacing the free-running 200 ms tick. The setting is in
backups as world_clock_show_seconds; older files import with it off.

Fixes #23
2026-10-21 18:48:15 +02:00
makiolaj 3f431a7f32 fix: keep pending crash reports out of Auto Backup
The backup rules were empty, despite their comments, so a pending crash
report followed a restore to a new phone and prompted there.
2026-10-21 17:40:29 +02:00
makiolaj cac58a98db fix: say why a restore failed in words that can be translated
The snackbar appended the codec's or the exception's own English text,
class names and all. Failures now carry a category (not a backup, from a
newer version, invalid data, too large, unreadable) that the screen maps
to a string resource; the detail stays in the result for tests.
2026-10-21 17:39:38 +02:00
makiolaj 3003f75333 fix: keep the built-in timer presets when a backup has none
A backup without the timer_presets field decoded it as an empty list,
which means "the user removed them all", so restoring it wiped the
built-in presets. A missing field now keeps them. BACKUP.md describes
this and the per-alarm clamps.
2026-10-21 17:37:11 +02:00
makiolaj 805da39074 fix: hold a timer's heads-up while an alarm is ringing
A timer whose ring was held back by a ringing alarm still popped a
heads-up over the alarm's screen, then alerted again once the alarm
ended. The heads-up now waits with the ring.
2026-10-21 17:36:35 +02:00
makiolaj 85c12811b0 fix: redraw the next-alarm widget at midnight
The widget worked out "Today" when it was drawn, and nothing redrew it
when the date changed, so a widget drawn on Monday night still named
Tuesday on Tuesday morning. It now shares the analog faces' midnight
redraw, renamed to MidnightRefresh, and redraws on a clock or zone change.
2026-10-21 17:35:39 +02:00
makiolaj 9af3d2b515 fix: label the ringtone preview button as a preview
TalkBack read the play button as the tone's name alone, right after the
row that already said it.
2026-10-21 17:34:13 +02:00
makiolaj 1823e885a1 chore: move floret-kit to the selected-row semantics fix 2026-10-21 17:34:13 +02:00
makiolaj 3a9b7e16ea fix: refresh the Settings problem count on return
The hub read the self-check once, so after fixing something in the
self-check screen it kept showing the old number of problems. It now
re-reads on every resume, as the self-check screen does.
2026-10-21 17:32:37 +02:00
makiolaj f9f2bdc9a8 fix: survive a refused background service start
A sticky restart of the timer or stopwatch service has no user action
behind it, so Android 12+ can refuse its foreground start, and the crash
repeated with every restart. The same could happen to the alarm ring
after an inexact fire, after its state was written. Refused starts now
stop the service, or post the ring screen's notification, instead of
crashing. The ring service is stopped with stopService, which cannot
throw from the background.
2026-10-21 17:31:26 +02:00
makiolaj 7ca8d54b4a fix: reschedule when exact alarms are allowed again
Granting "Alarms & reminders" again left the next alarm on the inexact
fallback until something else re-registered it. The system's permission
broadcast now reschedules alarms and timers.
2026-10-21 17:31:26 +02:00
makiolaj d7d958971f fix: fall back when a ring's player fails mid-ring
A MediaPlayer that errored after it started left the alarm or timer
silent, with no fallback and no forced vibration. The ring now moves on
to the next sound in the chain, and vibrates when none is left.
2026-10-21 17:28:34 +02:00
makiolaj c99106321a fix: bring back a swiped-away ring notification
From Android 14 a foreground service's notification can be swiped away on
an unlocked phone, which left a sounding alarm with no controls. The ring
notification now reposts itself when it is deleted, and tapping a ringing
alarm in the list opens its ring screen.
2026-10-21 17:28:08 +02:00
makiolaj 9cd40d6813 fix: don't flag another app's earlier alarm in the self-check
The system reports only the earliest alarm clock of any app, so an older
alarm left in the stock clock showed up as a problem the offered fix
couldn't solve. Another app's alarm at or before Clockula's next fire is
now fine; a missing registration, or anything later than ours, still is
a problem.
2026-10-21 17:26:50 +02:00
makiolaj 99b357f4fb fix: clamp an alarm's own snooze and ramp values when they are read
Backup import clamped the app-wide defaults but passed an alarm's own
snooze length, snooze limit and volume ramp through unchanged, so a
snooze of 0 re-rang at once and a negative limit turned snoozing off.
Both import and the database read now clamp them to the ranges the
pickers offer, and imported timer lengths to the setup range.
2026-10-21 17:25:55 +02:00
makiolaj 96fc9c19cf fix: keep the device awake until an alarm or timer starts ringing
The fire broadcast's wake lock ended with the receiver, and the ring
services only took their own after a database read, so a dozing device
could suspend in between and ring late. Both receivers now hold a short
timed wake lock across the hand-off, and the alarm service takes its own
before the read.
2026-10-21 17:25:39 +02:00
makiolaj b883554dc4 fix: keep the dismiss challenge behind the notification's Dismiss
The ringing notification's Dismiss ended the alarm directly, so a heads-up
or the lock screen got round a sum or hold challenge. With a challenge set,
Dismiss now opens the ring screen. Snooze is shown only while snoozes are
left, since a refused snooze dismisses.
2026-10-21 17:23:17 +02:00
makiolaj 4d29669222 fix: ring a snooze that is delivered late
A snooze stays pending for the ring window after it comes due, but the
fire was rejected after the two-minute grace and re-registered at the
same past instant, so the alarm looped on wakeups and never rang after a
reboot or a late delivery.
2026-10-21 17:22:33 +02:00
240 changed files with 16119 additions and 897 deletions
+56 -1
View File
@@ -19,8 +19,24 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
can play it vibrates.
- An alarm list that shows when each alarm will ring and marks the next one.
Changes apply as you make them, with no Save button.
- Setting an alarm, switching it on or changing its time or repeat days says
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
now", so an AM/PM or wrong-day slip shows at once.
- Swipe an alarm away to delete it, or use its Delete action with a screen
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
Undo brings it back with all its settings, and a snoozed alarm comes back
snoozed.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- An "Upcoming alarm" notification two hours before an alarm rings, with a
Dismiss button, so you can turn off today's alarm when you're already awake.
A repeating alarm skips just that once; a one-time alarm is switched off.
The same Dismiss shows on the alarm in the list. The notification is silent,
and turning it off in the system settings doesn't affect the alarm.
- Schedule a one-off alarm for a date, such as a flight next Thursday, and
pause a repeating alarm for a range of days, such as a holiday. The list
shows "Scheduled for Wed, Oct 28" or "Paused Oct 26 – 28", and a pause ends
on its own.
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
dismiss challenge (a quick sum or a two-second hold). Each one can follow the
app default, so changing a default later still reaches it.
@@ -32,19 +48,36 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
- A ring screen over the lock screen with snooze and dismiss. The dismiss
challenge always gives you a way out, works with a screen reader, and Back
can't silence an alarm.
- Choose what the volume buttons do while an alarm rings: change the volume
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
challenge by snoozing instead, and a snooze press with no snoozes left
changes the volume rather than ending the alarm. It works while the ring
screen is showing.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
from the timer, from the live pill and from the notification. A finished
timer turns the screen on and opens over the lock screen with Stop and
+1 min. Timers keep counting through a reboot and aren't affected by changes
to the clock.
- Timers have their own vibration switch and an optional gradual volume, both
apart from the alarms'. A kitchen timer can just beep while alarms still
vibrate; a silent timer still vibrates. Timer vibration starts out matching
the alarm vibration setting, so nothing changes until you change it.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
device's time-zone database, searchable in your language. Each city shows
the offset from your time and whether it's a different day there. Reorder
them by dragging or with a screen reader, and set a home time zone or follow
the device.
the device. "Display time with seconds" in Settings > Clock adds seconds to
every time on the tab and a second hand to the face.
- An automatic local clock while travelling. When you've set a home time zone
and your device is somewhere the time differs, the World clock tab adds a
"Local time" row under the home face. It goes away again once you're back
on home time, and stays hidden if one of your cities already shows that
time. It's Google Clock's "automatic home clock" turned round: Clockula's
face already shows home, so the clock that goes missing on a trip is the
local one. Turn it off in Settings › Clock.
- A live pill that shows whatever is counting from every tab.
- Swipe between tabs on phones. Tablets and foldables get a side rail.
- Other apps can set, show, snooze and dismiss alarms and timers through
@@ -52,13 +85,35 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
with Clockula. A voice-set alarm cleans up after itself once dismissed.
- Settings: theme, dynamic colour, language, and the defaults every new alarm
and timer inherits.
- Choose how long a ringing alarm or a finished timer keeps going before it
stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for
alarms and timers. Ten minutes stays the default, and Never still stops
after an hour so a phone left at home can't ring its battery flat.
- Back up alarms, timers, world clocks and settings to a JSON file, and restore
them on this device or another one.
- "Why might my alarm not ring?", a self-check that reads your device's real
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
whether the system has Clockula's next alarm. It links straight to the
setting that fixes each problem.
- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
its own at bedtime and off at your wake-up time, on the nights you choose,
with an optional reminder before it starts. On Android 15 and later it is a
real mode in Settings › Modes and can also turn the screen grayscale, dim the
wallpaper, switch to the dark theme and hide the always-on display. It needs
Do Not Disturb access, which the screen helps you grant, and it always lets
alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
which apps can't turn on.
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
analog and digital clocks that show your world clocks at larger sizes. Each
widget can follow the system theme or stay light or dark, and choose what a
tap opens.
- **Screen saver.** A full-screen digital or analog clock with the date and
your next alarm, for a phone on a charger or a dock. Night mode turns it
very dim for a dark bedroom, and the clock moves a little every minute so
nothing burns into the screen. Pick it in Android's screen saver settings,
or open it any time from the menu on each tab. Its settings are in Settings
→ Clock and are included in backups.
- Quick Settings tiles: "Next alarm" shows the day and time of your next alarm
and opens the Alarms tab, and "Quick timer" starts a timer with one tap,
shows when it ends, and resets it on a second tap. Long-press the quick
timer tile to choose its length.
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// versionCode is derived from versionName by scripts/version_info.sh
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
// committed one doesn't match. See docs/RELEASING.md.
versionCode = 1000003
versionName = "1.0.0-beta.3"
versionCode = 1000004
versionName = "1.0.0-beta.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -0,0 +1,399 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "5704da9a79231cc225da71c4f8a672f8",
"entities": [
{
"tableName": "alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0, `scheduled_date` INTEGER, `pause_start` INTEGER, `pause_end` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hour",
"columnName": "hour",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minute",
"columnName": "minute",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatDays",
"columnName": "repeat_days",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skipNextOccurrence",
"columnName": "skip_next_occurrence",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ringtoneUri",
"columnName": "ringtone_uri",
"affinity": "TEXT"
},
{
"fieldPath": "vibrate",
"columnName": "vibrate",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeMinutes",
"columnName": "snooze_minutes",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeLimit",
"columnName": "snooze_limit",
"affinity": "INTEGER"
},
{
"fieldPath": "volumeRampSeconds",
"columnName": "volume_ramp_seconds",
"affinity": "INTEGER"
},
{
"fieldPath": "dismissChallenge",
"columnName": "dismiss_challenge",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfterUse",
"columnName": "delete_after_use",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "scheduledDate",
"columnName": "scheduled_date",
"affinity": "INTEGER"
},
{
"fieldPath": "pauseStart",
"columnName": "pause_start",
"affinity": "INTEGER"
},
{
"fieldPath": "pauseEnd",
"columnName": "pause_end",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_alarms_enabled",
"unique": false,
"columnNames": [
"enabled"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)"
}
]
},
{
"tableName": "alarm_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `snoozed_until` INTEGER, `snooze_count` INTEGER NOT NULL, `ringing_since` INTEGER, `handled_occurrence` INTEGER, `skipped_occurrence` INTEGER, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "alarmId",
"columnName": "alarm_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "snoozedUntil",
"columnName": "snoozed_until",
"affinity": "INTEGER"
},
{
"fieldPath": "snoozeCount",
"columnName": "snooze_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ringingSince",
"columnName": "ringing_since",
"affinity": "INTEGER"
},
{
"fieldPath": "handledOccurrence",
"columnName": "handled_occurrence",
"affinity": "INTEGER"
},
{
"fieldPath": "skippedOccurrence",
"columnName": "skipped_occurrence",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"alarm_id"
]
},
"foreignKeys": [
{
"table": "alarms",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"alarm_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "timers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMillis",
"columnName": "duration_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remainingMillis",
"columnName": "remaining_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAtElapsedRealtimeMillis",
"columnName": "started_at_elapsed_realtime_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "endsAtElapsedRealtimeMillis",
"columnName": "ends_at_elapsed_realtime_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "endsAtWallClockMillis",
"columnName": "ends_at_wall_clock_millis",
"affinity": "INTEGER"
},
{
"fieldPath": "ringtoneUri",
"columnName": "ringtone_uri",
"affinity": "TEXT"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfterUse",
"columnName": "delete_after_use",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_timers_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
},
{
"tableName": "world_clocks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "zoneId",
"columnName": "zone_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_world_clocks_zone_id",
"unique": true,
"columnNames": [
"zone_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
},
{
"name": "index_world_clocks_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
},
{
"tableName": "stopwatch_laps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lapIndex",
"columnName": "lap_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splitMillis",
"columnName": "split_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cumulativeMillis",
"columnName": "cumulative_millis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stopwatch_laps_lap_index",
"unique": true,
"columnNames": [
"lap_index"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5704da9a79231cc225da71c4f8a672f8')"
]
}
}
@@ -13,7 +13,7 @@ import org.junit.Test
import org.junit.runner.RunWith
/**
* The only proof the v1 → v2 migration works. A migration that fails eats the
* The only proof the migrations work. A migration that fails eats the
* user's alarms, so it is validated against the exported schema rather than
* eyeballed. Device only; the build gate compiles it.
*/
@@ -138,6 +138,47 @@ class MigrationTest {
assertThat(timer!!.deleteAfterUse).isFalse()
}
@Test
fun theThirdMigrationValidatesAgainstTheExportedVersionFourSchema() {
helper.createDatabase(DB_NAME, 3).close()
val migrated = helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4)
assertThat(migrated.version).isEqualTo(4)
migrated.close()
}
@Test
fun alarmsWrittenAtVersionThreeSurviveUnscheduledAndUnpaused() {
helper.createDatabase(DB_NAME, 3).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4).close()
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
assertThat(alarm!!.label).isEqualTo("Gym")
assertThat(alarm.repeatDays).isEqualTo(31)
assertThat(alarm.scheduledDate).isNull()
assertThat(alarm.pauseStart).isNull()
assertThat(alarm.pauseEnd).isNull()
}
@Test
fun aDatabaseFromVersionOneMigratesAllTheWayToTheCurrentSchema() {
helper.createDatabase(DB_NAME, 1).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 4, true, *Migrations.ALL).close()
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
assertThat(alarm!!.deleteAfterUse).isFalse()
assertThat(alarm.scheduledDate).isNull()
}
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
alarmId = alarmId,
snoozedUntil = 1_700_000_100_000L,
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import android.text.format.DateFormat
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
import androidx.compose.ui.test.performTextReplacement
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
@@ -205,12 +207,38 @@ class AlarmsInstrumentedTest {
composeTestRule.onNodeWithText("Work").performClick()
composeTestRule.waitForIdle()
// No confirmation: the list underneath offers the Undo instead.
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
// The delete runs on the application scope, which Compose's idling
// does not track, so the test waits for storage rather than for idle.
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) == null }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
assertThat(stored(id)).isNull()
composeTestRule.onNodeWithText(string(R.string.action_undo)).assertIsDisplayed()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun theRowsDeleteActionRemovesItAndUndoBringsItBack() {
val id = givenAlarm()
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work")
.performCustomAccessibilityActionWithLabel(string(R.string.action_delete))
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) == null }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.action_undo)).performClick()
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) != null }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
}
private companion object {
/** Delete and restore run on the application scope, outside Compose's idling. */
const val STORAGE_TIMEOUT_MS = 5_000L
}
}
+112 -2
View File
@@ -50,6 +50,12 @@
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- The opt-in bedtime mode (issue #1): lists Clockula under Settings ›
Special app access › Do Not Disturb, where the user grants it by hand.
Without that grant nothing changes; with it, Clockula adds one Do Not
Disturb rule of its own, which always lets alarms through. -->
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- "Choose from a music app…": package visibility for the apps that share
a library through Android's standard MediaBrowserService, and for
nothing else. A visibility declaration, not a permission — and no
@@ -203,7 +209,7 @@
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
<!-- The AlarmManager slots arriving. Unexported: a PendingIntent the
app created is delivered regardless, and nothing else may fake a fire. -->
<receiver
android:name=".alarm.receiver.AlarmFireReceiver"
@@ -269,9 +275,61 @@
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter>
</receiver>
<!-- The bedtime slot arriving (issue #1). Unexported, like the alarm's
and the timer's: only the PendingIntent the app made reaches it. -->
<receiver
android:name=".bedtime.receiver.BedtimeReceiver"
android:exported="false" />
<!-- The screen saver (issue #25). The system binds it on a charger or a
dock once the user picks it in Settings → Display → Screen saver.
Exported, since the dream manager is another process, and guarded
by BIND_DREAM_SERVICE, a signature permission only the system
holds, so nothing else can bind it. -->
<service
android:name=".ui.screensaver.ClockulaDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/screen_saver_info" />
</service>
<!-- The screen saver's settings, opened by the gear beside it in the
system's screen saver list. Exported because only an exported
activity can be started by the Settings app; it reads no extras
and changes nothing a user could not change in-app. A task of its
own and out of recents, so opening it from Settings never lands
in, or clears, the app's own task. -->
<activity
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="@string/screen_saver_title"
android:taskAffinity=""
android:theme="@style/Theme.Clockula" />
<!-- The same screen saver on demand, from the tabs' overflow menu: an
app cannot start the system's dream itself. Not exported; it
finishes itself once no longer shown. Rotating it into a stand,
docking it, or the dark-theme schedule firing re-lays it out
rather than recreating it, which would flash black and reset its
brightness. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|fontScale|locale|layoutDirection"
android:exported="false"
android:theme="@style/Theme.Clockula.ScreenSaver" />
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
@@ -366,7 +424,7 @@
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
is the one refresh trigger for the data widgets, and the analog
widget's date is redrawn at midnight and on a clock or zone
change (widget/AnalogDateRefresh.kt). -->
change (widget/MidnightRefresh.kt). -->
<receiver
android:name=".widget.NextAlarmWidgetReceiver"
android:label="@string/widget_next_alarm_label"
@@ -438,6 +496,58 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_timer_info" />
</receiver>
<!-- #26: the two Quick Settings tiles. A TileService must be exported
for System UI to bind it, and BIND_QUICK_SETTINGS_TILE — a
signature permission only System UI holds — keeps anyone else
from binding it. Both are active tiles: they are redrawn only
when the app asks (`widget/WidgetSync.kt`), never on every panel
open. -->
<service
android:name=".tile.NextAlarmTileService"
android:exported="true"
android:icon="@drawable/widget_ic_alarm"
android:label="@string/tile_next_alarm_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
</service>
<service
android:name=".tile.QuickTimerTileService"
android:exported="true"
android:icon="@drawable/tile_ic_timer"
android:label="@string/tile_quick_timer_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<!-- What long-pressing either tile opens: the quick-timer tile's
duration, or the Alarms tab for the next-alarm one. Exported
because System UI starts it; it answers that one action and
closes on any other. A task of its own, so finishing it returns
to wherever the user was, not into an open Clockula. -->
<activity
android:name=".ui.tile.TilePreferencesActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -4,6 +4,10 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine
@@ -12,6 +16,7 @@ import de.jeanlucmakiola.clockula.widget.WidgetSync
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -38,9 +43,15 @@ class ClockulaApp : Application() {
@Inject
lateinit var stopwatchEngine: StopwatchEngine
@Inject
lateinit var bedtimeEngine: BedtimeEngine
@Inject
lateinit var widgetSync: WidgetSync
@Inject
lateinit var settings: SettingsPrefs
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -59,31 +70,59 @@ class ClockulaApp : Application() {
)
RingNotifications.createChannels(this)
UpcomingNotifications.createChannel(this)
TimerNotifications.createChannel(this)
StopwatchNotifications.createChannel(this)
AndroidBedtimeNotifier.createChannel(this)
// The same three steps as a BOOT_COMPLETED, and taken through the engine
// rather than one by one so the whole pass runs under the engine's lock:
// a cold start caused by the fire broadcast must not interleave its
// read-modify-write over `alarm_states` with the fire's own.
scope.launch {
engine.onBootCompleted()
// After the alarm engine's pass, and idempotent within a boot: the
// boot-id gate makes the second repair a no-op, so neither engine
// depends on the other's ordering (M6 D21). Process death costs the
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
// And the third, after the other two. Process death costs the
// stopwatch nothing — the run is in DataStore and the laps in Room —
// but the service may not have survived it, so this brings it back;
// and if this is a new boot the gate above has already repaired the
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
try {
launchPasses()
} finally {
// Last and guarded, like every bedtime call on a shared path,
// and in a finally so a failure above cannot cost it its pass:
// a force stop clears its AlarmManager slot too, and may have
// slept through a bedtime or wake-up, so both are caught up here.
runCatching { bedtimeEngine.resync() }
}
}
// Never completes on its own — it is the widgets' one refresh trigger
// for as long as the process lives (M10, docs/PLAN.md §9).
// Never completes on its own — it is the widgets' and the Quick
// Settings tiles' one refresh trigger for as long as the process
// lives (M10, docs/PLAN.md §9; #26).
scope.launch { widgetSync.run() }
scope.launch { settleTimerVibrate() }
}
/** Off the engines' pass: a preference copy has no business delaying an alarm. */
private suspend fun settleTimerVibrate() {
try {
settings.settleTimerVibrate()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// Until it lands timers vibrate, the default; the next launch tries again.
}
}
private suspend fun launchPasses() {
engine.onBootCompleted()
// After the alarm engine's pass, and idempotent within a boot: the
// boot-id gate makes the second repair a no-op, so neither engine
// depends on the other's ordering (M6 D21). Process death costs the
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
// And the third, after the other two. Process death costs the
// stopwatch nothing — the run is in DataStore and the laps in Room —
// but the service may not have survived it, so this brings it back;
// and if this is a new boot the gate above has already repaired the
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
}
}
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
@@ -165,7 +166,12 @@ class MainActivity : ComponentActivity() {
.getOrNull()
?.toList()
.orEmpty()
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds)
// Settings opens the bedtime rule's configuration activity (this one)
// with the rule id and no action; that is the bedtime screen (issue #1).
val forZenRule = intent.action == null &&
runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
val action = if (forZenRule) BedtimeIntents.ACTION_SHOW_BEDTIME else intent.action
return ShellNavigation.requestFor(action, alarmId, alarmIds)
}
override fun onResume() {
@@ -5,22 +5,26 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmSettings
import de.jeanlucmakiola.clockula.domain.alarm.AlarmDeletion
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolution
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSchedule
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotices
import de.jeanlucmakiola.clockula.domain.interop.InteropLimits
import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -29,6 +33,8 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
@@ -80,16 +86,55 @@ class AlarmEngine @Inject constructor(
suspend fun onAutoSilence(alarmId: Long): Unit = lock.withLock {
val state = states.state(alarmId)
if (state.ringingSince == null) return@withLock
val since = state.ringingSince ?: return@withLock
// A ring still inside the window in force keeps ringing: a backstop
// broadcast already queued when "Silence after" was raised must not end
// the time just granted. A negative age is a clock moved back whose
// TIME_SET has not been handled yet; the backstop has then had its
// window in real time, and closes the ring as in v1.
val age = wallClock.now() - since
if (age >= Duration.ZERO && rearmIfInsideWindowLocked(state)) return@withLock
// Missed, not snoozed: the ring stops, the cycle closes, the next
// occurrence is scheduled. There is no missed-alarm notification in v1.
closeCycleLocked(alarmId, state)
}
/** [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15). */
suspend fun snooze(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome =
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
/**
* Writes the alarms' "Silence after" — the engine is its only writer outside
* a backup restore — and measures a ring in progress against the new
* window at once: one already past it closes as missed, exactly as the
* backstop would close it, and one still inside it has its backstop moved
* to the new end. Measured from when the ring started, so changing the
* setting back and forth cannot buy a ring a fresh window.
*/
suspend fun setAlarmSilenceAfter(minutes: Int): Unit = withContext(NonCancellable) {
// Not cancellable: a write that lands without the re-measure would leave
// a ring in progress on the old window.
lock.withLock {
settings.setAlarmSilenceAfterMinutes(minutes)
remeasureRingLocked()
}
}
private suspend fun remeasureRingLocked() {
val state = states.ringing() ?: return
if (!rearmIfInsideWindowLocked(state)) closeCycleLocked(state.alarmId, state)
}
/**
* [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15).
*
* A snooze past the limit dismisses, unless [dismissIfRefused] is false:
* then nothing changes and the alarm keeps ringing. A volume key passes
* false (#18), since the press is blind and must not end the alarm.
*/
suspend fun snooze(
alarmId: Long,
minutesOverride: Int? = null,
dismissIfRefused: Boolean = true,
): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId, minutesOverride, dismissIfRefused) }
/**
* The `AlarmClock` contract's dismissal, and the chooser dialog's (M9 D14).
@@ -114,22 +159,116 @@ class AlarmEngine @Inject constructor(
// is written and the caller is told so.
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
// "Dismissing a repeating alarm only prevents the upcoming instance from
// ringing"; dismissing a single occurrence "effectively disables" it.
if (alarm.repeatDays.isRepeating) {
alarms.setSkipNextOccurrence(alarmId, true)
rescheduleLocked()
return@withLock DismissOutcome.SkippedNext(alarmId)
}
dismissComingLocked(alarm)
}
disableOrDeleteLocked(alarm)
/**
* The upcoming-alarm notification's Dismiss, and the row's chip (#16):
* [dismissUpcoming], but only for the occurrence at [fireAt] and only while
* it is still to come. A tap that lands after the alarm started ringing, or
* after it moved, is [DismissOutcome.Stale] and dismisses nothing — the ring
* screen and its dismiss challenge own a ringing alarm. No challenge is
* asked before the ring, as in AOSP's Clock: skipping tomorrow's alarm is
* already one switch away in the editor, unchallenged.
*/
suspend fun dismissUpcomingOccurrence(alarmId: Long, fireAt: Instant): DismissOutcome = lock.withLock {
val alarm = alarms.find(alarmId)?.takeIf { it.enabled } ?: run {
// Its notice, if one is still up, is put right on the way out.
rescheduleLocked()
return@withLock DismissOutcome.NotFound
}
val now = wallClock.now()
// Read-only: the resolver's state is discarded, and the reschedule below
// persists whatever it should. `fireAt > now` because the resolver keeps
// a just-passed occurrence through the fire grace: one already due is
// the fire's, not the notice's.
val resolution = AlarmResolver.resolve(
alarm,
states.state(alarmId),
now,
zones.current(),
settings.currentDefaults().alarmSilenceAfter,
)
if (resolution.nextFire != fireAt || !UpcomingNotices.isInWindow(fireAt, resolution.source, now)) {
// Whatever posted the stale notice is put right on the way out.
rescheduleLocked()
return@withLock DismissOutcome.Stale
}
dismissComingLocked(alarm)
}
/**
* The alarm's time, days or pause were just edited: a skip armed on the
* old shape of its sequence is re-armed on the new one, so "skip next"
* still means the next ring — not one the old pause jumped to, an
* occurrence of the old time, nor one a new pause already silences. A
* watermark already due is left for the resolver to consume as usual.
*/
suspend fun onScheduleEdited(alarmId: Long): ScheduledFire? = lock.withLock {
val state = states.state(alarmId)
val armed = state.skippedOccurrence
if (armed != null && armed > wallClock.now()) states.save(state.copy(skippedOccurrence = null))
rescheduleLocked()
DismissOutcome.Disabled(alarmId)
}
suspend fun dismiss(alarmId: Long): Unit =
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
/**
* The user's delete, from the list's swipe or the editor. Returns what an
* Undo would put back ([AlarmDeletion.snapshot]), or null when there is
* nothing to put back.
*
* On the engine, under its lock, for the same reason as [dismissUpcoming]:
* a ringing alarm's service and auto-silence backstop are global slots
* only the engine may touch, and a snoozed alarm's notification would
* otherwise outlive the row it belongs to. The ring state goes with the row
* (the foreign key cascades). Non-cancellable, like [restore]: a caller
* cancelled half-way must not leave the slot registered for a deleted row.
*
* [unlessRinging] is the list's delete: a swipe released just as the alarm
* fired, before the list knew, must not silence it round its dismiss
* challenge, so it writes nothing and returns null. Checked here, under the
* lock, because only here is the answer not already stale.
*/
suspend fun delete(alarmId: Long, unlessRinging: Boolean = false): DeletedAlarm? = lock.withLock {
withContext(NonCancellable) { deleteLocked(alarmId, unlessRinging) }
}
private suspend fun deleteLocked(alarmId: Long, unlessRinging: Boolean): DeletedAlarm? {
val alarm = alarms.find(alarmId) ?: return null
val state = states.state(alarmId)
if (unlessRinging && state.isRinging) return null
silenceLocked(alarmId, state)
alarms.delete(alarmId)
rescheduleLocked()
return AlarmDeletion.snapshot(alarm, state)
}
/**
* The Undo: the row back under its own id, its ring state with it, and the
* slot re-resolved, which is what re-registers it with AlarmManager. False
* when the id is taken, which writes nothing.
*
* A snooze comes back with its notification, but only if the resolver
* still holds it pending once the slot is re-resolved: one that lapsed
* while the alarm was gone is closed there, like any other.
*
* Non-cancellable: a row restored but never rescheduled would be an alarm
* on the list that AlarmManager does not know about.
*/
suspend fun restore(deleted: DeletedAlarm): Boolean = lock.withLock {
withContext(NonCancellable) { restoreLocked(deleted) }
}
private suspend fun restoreLocked(deleted: DeletedAlarm): Boolean {
if (!alarms.restore(deleted.alarm)) return false
if (deleted.state != AlarmRingState.initial(deleted.alarm.id)) states.save(deleted.state)
rescheduleLocked()
states.state(deleted.alarm.id).snoozedUntil?.let { until -> notifier.showSnoozed(deleted.alarm, until) }
return true
}
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
rebootRepair.repairIfRebooted()
@@ -137,10 +276,21 @@ class AlarmEngine @Inject constructor(
rescheduleLocked()
}
suspend fun onSystemTimeOrZoneChanged(): ScheduledFire? = reschedule()
/**
* The backstop is a wall-clock registration, so a clock change moves it. A
* ring in progress is re-measured first: a clock moved forward past its
* window closes it, one moved back re-arms it a window from now at most.
*/
suspend fun onSystemTimeOrZoneChanged(): ScheduledFire? = lock.withLock {
remeasureRingLocked()
rescheduleLocked()
}
suspend fun onPackageReplaced(): ScheduledFire? = reschedule()
/** An upcoming-alarm window opened (#16); the reschedule posts its notice. */
suspend fun onUpcomingWindow(): ScheduledFire? = reschedule()
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
@@ -158,12 +308,14 @@ class AlarmEngine @Inject constructor(
val since = state.ringingSince ?: return@withLock null
val alarm = alarms.find(state.alarmId) ?: return@withLock null
val resolved: AlarmSettings = alarm.resolveSettings(settings.currentDefaults())
val defaults = settings.currentDefaults()
val resolved: AlarmSettings = alarm.resolveSettings(defaults)
RingSession(
alarm = alarm,
settings = resolved,
startedAt = since,
canSnooze = state.snoozeCount < resolved.snoozeLimit,
volumeButtons = defaults.volumeButtons,
)
}
@@ -182,7 +334,12 @@ class AlarmEngine @Inject constructor(
* emission; a live readout passes a `Ticker`'s flow (M5 D8).
*/
fun upcoming(refresh: Flow<Unit> = flowOf(Unit)): Flow<List<UpcomingAlarm>> =
combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ ->
combine(
alarms.alarms(),
states.states(),
settings.defaults.map { it.alarmSilenceAfter }.distinctUntilChanged(),
refresh,
) { alarms, states, silenceAfter, _ ->
val now = wallClock.now()
val zone = zones.current()
val byId = states.associateBy { it.alarmId }
@@ -195,6 +352,7 @@ class AlarmEngine @Inject constructor(
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
now,
zone,
silenceAfter,
)
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
}
@@ -204,15 +362,25 @@ class AlarmEngine @Inject constructor(
)
}
private suspend fun rescheduleLocked(): ScheduledFire? {
/**
* [deferUpcomingTo] collects what the upcoming notices would be synced from
* instead of syncing them, for a caller with something more urgent to do
* first (the fire path).
*/
private suspend fun rescheduleLocked(deferUpcomingTo: MutableList<UpcomingSync>? = null): ScheduledFire? {
val now = wallClock.now()
val zone = zones.current()
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
var earliest: Pair<Alarm, AlarmResolution>? = null
val resolved = mutableListOf<UpcomingAlarm>()
val pendingSkips = mutableListOf<Instant>()
for (alarm in alarms.alarms().first()) {
val state = states.state(alarm.id)
val resolution = AlarmResolver.resolve(alarm, state, now, zone)
val resolution = AlarmResolver.resolve(alarm, state, now, zone, silenceAfter)
persist(alarm, state, resolution)
resolved += UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
resolution.state.skippedOccurrence?.let { pendingSkips += it }
val fireAt = resolution.nextFire ?: continue
val best = earliest
@@ -222,21 +390,78 @@ class AlarmEngine @Inject constructor(
if (isEarlier) earliest = alarm to resolution
}
val winner = earliest ?: run {
val winner = earliest
val scheduled = if (winner == null) {
scheduler.cancelNext()
return null
null
} else {
val fireAt = winner.second.nextFire!!
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
}
val fireAt = winner.second.nextFire!!
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
return ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
// After the real registration, never before it.
val upcoming = UpcomingSync(resolved, pendingSkips)
if (deferUpcomingTo == null) syncUpcomingLocked(upcoming, now) else deferUpcomingTo += upcoming
return scheduled
}
/**
* The upcoming-alarm notices and the wake-up for the next window (#16).
* Every path that moves an alarm — an edit, a delete, a switch, a skip, a
* snooze, a fire, a boot, a clock or zone change — already ends in
* [rescheduleLocked], so this is the one place they are kept true.
*
* Nothing here may cost an alarm, so a failure is swallowed rather than
* thrown into whichever entry point is rescheduling.
*/
private fun syncUpcomingLocked(upcoming: UpcomingSync, now: Instant) {
runCatching {
val plan = UpcomingNotices.plan(upcoming.resolved, now, upcoming.pendingSkips)
notifier.showUpcoming(plan.notices)
val next = plan.nextCheckAt
if (next == null) scheduler.cancelUpcomingCheck() else scheduler.scheduleUpcomingCheck(next)
}
}
/**
* "Dismissing a repeating alarm only prevents the upcoming instance from
* ringing"; dismissing a single occurrence "effectively disables" it.
*/
private suspend fun dismissComingLocked(alarm: Alarm): DismissOutcome {
if (alarm.repeatDays.isRepeating) {
// Persist the resolution first: an earlier skip that has gone by but
// not yet been spent in storage would otherwise absorb this one,
// and the occurrence just dismissed would ring.
rescheduleLocked()
alarms.setSkipNextOccurrence(alarm.id, true)
rescheduleLocked()
return DismissOutcome.SkippedNext(alarm.id)
}
disableOrDeleteLocked(alarm)
rescheduleLocked()
return DismissOutcome.Disabled(alarm.id)
}
private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome {
val now = wallClock.now()
val alarm = alarms.find(alarmId)
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
// re-resolve the slot and stay quiet.
if (alarm == null || !alarm.enabled || now - scheduledFor > AlarmRing.FIRE_GRACE) {
val state = states.state(alarmId)
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
// A snooze is kept for as long as the resolver keeps it pending, so one
// delivered late (after a reboot, or by Doze) still rings. Rejecting it
// here would only re-register the same past instant and fire again.
val lateBy = if (isSnoozeFire) AlarmRing.snoozeGrace(silenceAfter) else AlarmRing.FIRE_GRACE
// A skip armed on this very occurrence after its fire was already on its
// way: the user dismissed it in advance (#16), so it must not ring.
// Only the armed skip, not the handled-occurrence watermark: the
// resolver deliberately re-arms a handled occurrence that a clock moved
// backwards has put back in the future, and that one must ring.
val skipped = !isSnoozeFire && scheduledFor == state.skippedOccurrence
// Deleted, switched off, skipped, or a PendingIntent so stale the moment
// has gone: re-resolve the slot and stay quiet.
if (alarm == null || !alarm.enabled || skipped || now - scheduledFor > lateBy) {
rescheduleLocked()
return FireOutcome.Ignored
}
@@ -244,33 +469,48 @@ class AlarmEngine @Inject constructor(
// At most one alarm rings, and the newly-firing one takes over. The one
// it displaces keeps its handled-occurrence watermark, so it does not
// come back the moment the slot is re-resolved.
// Its cycle has closed undismissed, so a one-shot alarm is switched off
// as on every other such close, rather than armed for tomorrow.
states.ringing()?.takeIf { it.alarmId != alarmId }?.let { other ->
ring.stopRinging(other.alarmId)
states.save(other.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0))
alarms.find(other.alarmId)?.takeIf { !it.repeatDays.isRepeating }?.let { disableOrDeleteLocked(it) }
}
val state = states.state(alarmId)
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
// `ringingSince` is cleared too: this fire opens the ring afresh, and a
// stale value left behind would let the re-resolution below close (and
// switch off) the very alarm that is about to ring.
val opened = if (isSnoozeFire) {
// The cycle is the same one: the count and the watermark carry over.
state.copy(snoozedUntil = null)
state.copy(ringingSince = null, snoozedUntil = null)
} else {
state.copy(snoozedUntil = null, snoozeCount = 0, handledOccurrence = scheduledFor)
state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0, handledOccurrence = scheduledFor)
}
// The watermark is written before anything else can interrupt, and the
// slot is re-resolved while the alarm is not yet marked as ringing — so
// it holds this alarm's *next* occurrence rather than nothing at all.
states.save(opened)
notifier.clearSnoozed(alarmId)
rescheduleLocked()
// The upcoming notices wait until the ring has started: their binder
// calls must not stand between the fire and the sound.
val upcoming = mutableListOf<UpcomingSync>()
rescheduleLocked(deferUpcomingTo = upcoming)
states.save(states.state(alarmId).copy(ringingSince = now))
ring.startRinging(alarmId)
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
try {
states.save(states.state(alarmId).copy(ringingSince = now))
ring.startRinging(alarmId)
scheduler.scheduleAutoSilence(now + silenceAfter, alarmId)
} finally {
upcoming.forEach { syncUpcomingLocked(it, now) }
}
return FireOutcome.Ringing(alarmId)
}
private suspend fun snoozeLocked(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome {
private suspend fun snoozeLocked(
alarmId: Long,
minutesOverride: Int?,
dismissIfRefused: Boolean,
): SnoozeOutcome {
val state = states.state(alarmId)
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
@@ -278,7 +518,11 @@ class AlarmEngine @Inject constructor(
val resolved = alarm.resolveSettings(settings.currentDefaults())
// A limit of 0 means snoozing is off, not unlimited — and a refused
// snooze dismisses, so the dismiss path can never strand the user.
// The one exception is a caller that asked to keep ringing (a blind
// volume-key press, #18): it is told so and must keep the alarm's own
// way out on screen.
if (state.snoozeCount >= resolved.snoozeLimit) {
if (!dismissIfRefused) return SnoozeOutcome.Refused(alarmId)
closeCycleLocked(alarmId, state)
return SnoozeOutcome.LimitReached(alarmId)
}
@@ -302,25 +546,68 @@ class AlarmEngine @Inject constructor(
private suspend fun resumeRingingIfAnyLocked(): Long? {
val state = states.ringing() ?: return null
val since = state.ringingSince ?: return null
// Slept through it: leave it to the next resolution to close the cycle.
if (wallClock.now() - since >= AlarmRing.AUTO_SILENCE_AFTER) return null
val alarm = alarms.find(state.alarmId)?.takeIf { it.enabled } ?: return null
// Slept through it: leave it to the next resolution to close the cycle.
if (!rearmIfInsideWindowLocked(state)) return null
ring.startRinging(alarm.id)
// The backstop is measured from when the ring started, not from now, so
// a reboot does not buy the alarm another ten minutes.
scheduler.scheduleAutoSilence(since + AlarmRing.AUTO_SILENCE_AFTER, alarm.id)
return alarm.id
}
/** Everything the resolver asked to be written, and nothing it did not. */
/**
* The one rule for measuring a ring against "Silence after": still inside
* the window ⇒ the backstop is (re-)armed at its end and this returns true;
* run out ⇒ false, and the caller closes or drops it. Measured from when the
* ring started, so neither a reboot nor a setting change buys it a fresh
* window.
*
* A clock moved back behind that start is re-anchored to now and stored, so
* a negative age never lingers for a later re-measure to read as "a whole
* window left": a clock moved back adds at most the time already rung, once.
*/
private suspend fun rearmIfInsideWindowLocked(state: AlarmRingState): Boolean {
val stored = state.ringingSince ?: return false
val now = wallClock.now()
val since = if (now < stored) {
states.save(state.copy(ringingSince = now))
now
} else {
stored
}
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
if (now - since >= silenceAfter) return false
scheduler.scheduleAutoSilence(since + silenceAfter, state.alarmId)
return true
}
/**
* Everything the resolver asked to be written, and nothing it did not —
* plus stopping the sound of a ring it closed, which storage alone cannot do.
*/
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
if (resolution.state != before) states.save(resolution.state)
// A snooze the resolver dropped (it never fired, or the alarm was
// switched off) takes its "Snoozed until" notification with it.
if (before.snoozedUntil != null && resolution.state.snoozedUntil == null) notifier.clearSnoozed(alarm.id)
// The resolver closed a ring: its window ran out before the backstop
// fired ("Silence after" was shortened mid-ring, or the clock jumped),
// or the alarm was switched off while ringing. Storage now says it is
// not ringing, so the sound stops with it rather than waiting for a
// backstop registered against the old window.
if (before.ringingSince != null && resolution.state.ringingSince == null) {
ring.stopRinging(alarm.id)
scheduler.cancelAutoSilence()
}
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
// A transient alarm is deleted wherever a permanent one would be
// disabled: the cycle has closed either way (M9 D10).
if (resolution.disableAlarm) disableOrDeleteLocked(alarm)
// Conditional on the stored value still being the one resolved, so an
// editor that has just picked a new date or pause keeps it.
val date = alarm.scheduledDate
if (resolution.clearScheduledDate && date != null) alarms.clearScheduledDate(alarm.id, date)
val pause = alarm.pause
if (resolution.clearPause && pause != null) alarms.clearPause(alarm.id, pause)
}
/**
@@ -345,17 +632,47 @@ class AlarmEngine @Inject constructor(
* with its backstop intact.
*/
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
silenceLocked(alarmId, state)
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
if (closed != state) states.save(closed)
val alarm = alarms.find(alarmId)
if (alarm != null && !alarm.repeatDays.isRepeating) disableOrDeleteLocked(alarm)
if (alarm != null && !alarm.repeatDays.isRepeating) {
val zone = zones.current()
// A one-shot given a *later* date while it was snoozed still has
// that date to ring on, so this cycle closing does not switch it
// off (#21). A date on the day that just rang is the one this
// cycle used, whatever its time now says.
val ahead = AlarmSchedule.upcomingDate(alarm, wallClock.now(), zone)
val rangOn = state.handledOccurrence?.let { AlarmSchedule.today(it, zone) }
if (ahead == null || (rangOn != null && !ahead.isAfter(rangOn))) {
disableOrDeleteLocked(alarm)
// The date was spent on this ring: an alarm switched back on
// later is a plain one-shot, not "Scheduled for" a past day.
alarm.scheduledDate?.let { alarms.clearScheduledDate(alarm.id, it) }
}
}
rescheduleLocked()
}
/**
* Everything audible or visible an alarm's cycle holds: the ring service and
* its backstop if it is the one ringing, and its snooze notification. The
* one teardown, for closing a cycle and for deleting the alarm.
*/
private fun silenceLocked(alarmId: Long, state: AlarmRingState) {
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
}
}
/** What one reschedule pass hands the upcoming-alarm sync (#16). */
private class UpcomingSync(
val resolved: List<UpcomingAlarm>,
/** Armed skips' watermarks, so a check can spend each one on time. */
val pendingSkips: List<Instant>,
)
@@ -14,6 +14,15 @@ object AlarmIntents {
const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE"
const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS"
/** The upcoming-alarm window opening: re-resolve and post the notices (#16). */
const val ACTION_UPCOMING_CHECK: String = PREFIX + "action.ALARM_UPCOMING_CHECK"
/** The upcoming notification's Dismiss, which carries [EXTRA_FIRE_AT] (#16). */
const val ACTION_DISMISS_UPCOMING: String = PREFIX + "action.ALARM_DISMISS_UPCOMING"
/** The user swiped the upcoming notification away; it carries [EXTRA_FIRE_AT] (#16). */
const val ACTION_UPCOMING_SWIPED: String = PREFIX + "action.ALARM_UPCOMING_SWIPED"
/** Internal: "show me the alarms tab", the transport for `SHOW_ALARMS` and the next-alarm show intent. */
const val ACTION_SHOW_ALARMS: String = PREFIX + "action.SHOW_ALARMS"
@@ -25,6 +34,11 @@ object AlarmIntents {
const val REQUEST_SHOW: Int = 3
const val REQUEST_SNOOZE: Int = 4
const val REQUEST_DISMISS: Int = 5
const val REQUEST_REPOST: Int = 6
const val REQUEST_UPCOMING_CHECK: Int = 7
const val REQUEST_DISMISS_UPCOMING: Int = 8
const val REQUEST_SHOW_UPCOMING: Int = 9
const val REQUEST_UPCOMING_SWIPED: Int = 10
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_FIRE,
@@ -32,5 +46,10 @@ object AlarmIntents {
REQUEST_SHOW,
REQUEST_SNOOZE,
REQUEST_DISMISS,
REQUEST_REPOST,
REQUEST_UPCOMING_CHECK,
REQUEST_DISMISS_UPCOMING,
REQUEST_SHOW_UPCOMING,
REQUEST_UPCOMING_SWIPED,
)
}
@@ -1,11 +1,18 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
import kotlin.time.Instant
/** The snoozed notification — the one surface the engine posts directly. */
/** The snoozed and upcoming notifications — the surfaces the engine posts directly. */
interface AlarmNotifier {
fun showSnoozed(alarm: Alarm, until: Instant)
fun clearSnoozed(alarmId: Long)
/**
* Makes the upcoming-alarm notifications exactly [notices]: posts the new
* ones and removes any that are no longer due (#16). Never throws.
*/
fun showUpcoming(notices: List<UpcomingNotice>)
}
@@ -3,7 +3,7 @@ package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
import kotlin.time.Instant
/** The AlarmManager seam. Two slots, deliberately separate. */
/** The AlarmManager seam. Three slots, deliberately separate. */
interface AlarmScheduler {
/** Replaces the single next-alarm registration. Returns the mode actually used. */
fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode
@@ -14,6 +14,15 @@ interface AlarmScheduler {
fun cancelAutoSilence()
/**
* When the next upcoming-alarm window opens (#16). Its own PendingIntent and
* request code, never `setAlarmClock`: a housekeeping wake-up must not
* replace what the system shows as the user's next alarm.
*/
fun scheduleUpcomingCheck(at: Instant)
fun cancelUpcomingCheck()
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
fun systemNextAlarm(): Instant?
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.alarm.android
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
@@ -9,15 +10,18 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* The snoozed notification — the one surface the engine posts directly. A denied
* notification permission costs the user this line and nothing else: the snooze
* itself is a row in `alarm_states` and an AlarmManager registration.
* The snoozed and upcoming notifications — the surfaces the engine posts
* directly. A denied notification permission costs the user these lines and
* nothing else: the snooze itself is a row in `alarm_states` and an
* AlarmManager registration, and the upcoming Dismiss is also on the alarm row.
*/
@Singleton
class AndroidAlarmNotifier @Inject constructor(
@@ -47,4 +51,52 @@ class AndroidAlarmNotifier @Inject constructor(
override fun clearSnoozed(alarmId: Long) {
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
}
/**
* What this process last posted, per alarm, so an unchanged notice on
* screen is not re-sent. The engine calls [showUpcoming] under its lock, so
* the map needs no lock of its own.
*/
private val lastPosted = mutableMapOf<Long, UpcomingNotice>()
override fun showUpcoming(notices: List<UpcomingNotice>) {
runCatching {
val wantedIds = notices.map { UpcomingNotifications.notificationId(it.alarm.id) }.toSet()
// What is on screen, including what an earlier process posted.
val onScreen = context.getSystemService(NotificationManager::class.java)
.activeNotifications
.filter { it.tag == UpcomingNotifications.TAG }
.map { it.id }
.toSet()
// Also what this process posted: a notify is asynchronous, so one
// posted a moment ago may not be listed as active yet.
(onScreen + lastPosted.keys.map(UpcomingNotifications::notificationId))
.filterNot { it in wantedIds }
.forEach { notifications.cancel(UpcomingNotifications.TAG, it) }
lastPosted.keys.retainAll(notices.map { it.alarm.id }.toSet())
// A swipe is the user's word for that occurrence; the record keeps
// only the occurrences still noticed.
val stillSwiped = UpcomingNotifications.retainSwiped(
context,
notices.map(UpcomingNotifications::key).toSet(),
)
// POST_NOTIFICATIONS is a runtime permission from API 33. Denied, the
// user loses the notice and keeps the alarm and the row's Dismiss.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return@runCatching
}
for (notice in notices) {
if (UpcomingNotifications.key(notice) in stillSwiped) continue
val id = UpcomingNotifications.notificationId(notice.alarm.id)
if (id in onScreen && lastPosted[notice.alarm.id] == notice) continue
notifications.notify(UpcomingNotifications.TAG, id, UpcomingNotifications.build(context, notice))
lastPosted[notice.alarm.id] = notice
}
}
}
}
@@ -17,11 +17,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Two AlarmManager slots, deliberately separate. The user's next alarm goes
* Three AlarmManager slots, deliberately separate. The user's next alarm goes
* through `setAlarmClock`, which is the only variant that populates
* `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The
* auto-silence backstop goes through its own PendingIntent, so an internal
* housekeeping time can never overwrite what the system shows the user.
* housekeeping time can never overwrite what the system shows the user. The
* upcoming-alarm check (#16) is the third, on its own PendingIntent for the
* same reason.
*
* Translation only: which mode to use is [SchedulingPolicy]'s decision.
*/
@@ -64,6 +66,27 @@ class AndroidAlarmScheduler @Inject constructor(
override fun cancelAutoSilence() = manager.cancel(autoSilencePendingIntent(alarmId = 0L))
/**
* Exact where allowed, so the notice is up when its window opens; inexact
* otherwise, since a late notice costs nothing an alarm needs. Never
* `setAlarmClock`, and wrapped whole: nothing about the notice may throw
* into the engine's reschedule (#16).
*/
override fun scheduleUpcomingCheck(at: Instant) {
val pendingIntent = upcomingCheckPendingIntent()
val millis = at.toEpochMilliseconds()
runCatching {
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}.isSuccess
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}
}
override fun cancelUpcomingCheck() {
runCatching { manager.cancel(upcomingCheckPendingIntent()) }
}
override fun systemNextAlarm(): Instant? =
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
@@ -94,6 +117,15 @@ class AndroidAlarmScheduler @Inject constructor(
PENDING_INTENT_FLAGS,
)
private fun upcomingCheckPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
AlarmIntents.REQUEST_UPCOMING_CHECK,
Intent(context, AlarmFireReceiver::class.java).apply {
action = AlarmIntents.ACTION_UPCOMING_CHECK
},
PENDING_INTENT_FLAGS,
)
/**
* What the system opens when the user taps the status-bar alarm icon or the
* lockscreen's next-alarm line: the **alarms list**, which is what "show me
@@ -1,9 +1,13 @@
package de.jeanlucmakiola.clockula.alarm.android
import android.app.NotificationManager
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +22,7 @@ import javax.inject.Singleton
@Singleton
class ServiceRingCoordinator @Inject constructor(
@param:ApplicationContext private val context: Context,
private val capabilities: AlarmCapabilities,
) : RingCoordinator {
private val state = MutableStateFlow<Long?>(null)
@@ -26,11 +31,22 @@ class ServiceRingCoordinator @Inject constructor(
override fun startRinging(alarmId: Long) {
state.value = alarmId
AlarmRingService.start(context)
// Only an exact alarm exempts the start from Android 12's background
// limits. When the inexact fallback fired instead the start can be
// refused, and the ring screen is then the one thing left to show.
val started = runCatching { AlarmRingService.start(context) }.isSuccess
if (!started) {
context.getSystemService(NotificationManager::class.java).notify(
RingNotifications.RING_NOTIFICATION_ID,
RingNotifications.refused(context, RingPresentationPolicy.forCapabilities(capabilities.snapshot())),
)
}
}
override fun stopRinging(alarmId: Long) {
if (state.value == alarmId) state.value = null
AlarmRingService.stop(context)
// The notification a refused start posted has no service to take it down.
context.getSystemService(NotificationManager::class.java).cancel(RingNotifications.RING_NOTIFICATION_ID)
}
}
@@ -5,12 +5,14 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.time.Instant
import javax.inject.Inject
/** The notification's Snooze and Dismiss buttons. */
/** The notifications' Snooze and Dismiss buttons, the upcoming notice's included. */
@AndroidEntryPoint
class AlarmActionReceiver : HiltBroadcastReceiver() {
@@ -26,12 +28,21 @@ class AlarmActionReceiver : HiltBroadcastReceiver() {
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
val action = intent.action ?: return
if (action == AlarmIntents.ACTION_UPCOMING_SWIPED) {
// Bookkeeping only: the alarm itself is untouched.
UpcomingNotifications.recordSwiped(context, alarmId, intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L))
return
}
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId)
AlarmIntents.ACTION_DISMISS -> engine.dismiss(alarmId)
AlarmIntents.ACTION_DISMISS_UPCOMING -> engine.dismissUpcomingOccurrence(
alarmId = alarmId,
fireAt = Instant.fromEpochMilliseconds(intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L)),
)
}
} finally {
pendingResult.finish()
@@ -5,6 +5,7 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.ring.HandoffWakeLock
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -12,8 +13,9 @@ import kotlin.time.Instant
import javax.inject.Inject
/**
* The two AlarmManager slots arriving. Thin by design: every decision the engine
* could make is the engine's, so the receiver only unpacks and dispatches.
* The AlarmManager slots arriving: the fire, the backstop and the upcoming
* check. Thin by design: every decision the engine could make is the
* engine's, so the receiver only unpacks and dispatches.
*/
@AndroidEntryPoint
class AlarmFireReceiver : HiltBroadcastReceiver() {
@@ -32,6 +34,7 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
val action = intent.action ?: return
if (action == AlarmIntents.ACTION_FIRE) HandoffWakeLock.acquire(context, "clockula:alarm-fire")
val pendingResult = goAsync()
scope.launch {
try {
@@ -46,6 +49,8 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
)
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
AlarmIntents.ACTION_UPCOMING_CHECK -> engine.onUpcomingWindow()
}
} finally {
pendingResult.finish()
@@ -5,10 +5,13 @@ import android.content.Intent
import androidx.glance.appwidget.updateAll
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.AnalogDateRefresh
import de.jeanlucmakiola.clockula.widget.MidnightRefresh
import de.jeanlucmakiola.clockula.widget.NextAlarmWidget
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -17,9 +20,11 @@ import javax.inject.Inject
/**
* Everything that invalidates a registration: a reboot clears AlarmManager, an
* app update clears it too, and a clock or zone change moves what "07:00" means.
* All four are answered the same way — re-resolve from now.
* All four are answered the same way — re-resolve from now. So is the user
* granting "Alarms & reminders" again, which turns an inexact registration
* back into an exact one.
*
* Both engines are told, in that order. For the timers a clock change
* Both engines are told, in that order, and the bedtime schedule after them. For the timers a clock change
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
* the wall-clock fallback, which is what the notification's chronometer counts
* to and what a reboot would fall back on.
@@ -39,6 +44,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var stopwatchEngine: StopwatchEngine
@Inject
lateinit var bedtimeEngine: BedtimeEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -65,6 +73,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
stopwatchEngine.resync()
}
ACTION_EXACT_ALARM_PERMISSION_CHANGED -> {
engine.reschedule()
timerEngine.resync()
}
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
-> {
@@ -85,12 +98,28 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
// The analog faces' date is drawn for the old day or
// zone, and the midnight redraw armed for the old one.
AnalogClockWidget().updateAll(context)
AnalogDateRefresh.schedule(context)
NextAlarmWidget().updateAll(context)
MidnightRefresh.schedule(context)
// Both tiles show a wall-clock time in the zone they
// were drawn in, and the instant behind it need not
// have changed, so no write may reach `WidgetSync`.
requestAllTileUpdates(context)
}
}
} finally {
// Last, and on its own: every one of these events can move or
// clear the bedtime slot too, but nothing bedtime does may
// delay or break the alarm and timer passes above, and a
// failure up there must not cost bedtime its pass either.
runCatching { bedtimeEngine.resync() }
pendingResult.finish()
}
}
}
private companion object {
/** `AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED`, API 31+. */
const val ACTION_EXACT_ALARM_PERMISSION_CHANGED =
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -15,6 +17,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator
import kotlinx.coroutines.CoroutineScope
@@ -51,6 +54,9 @@ class AlarmRingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var ringing: Job? = null
/** The notification last posted, put back as it was when the user swipes it away. */
private var posted: Notification? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -58,6 +64,16 @@ class AlarmRingService : Service() {
stop()
return START_NOT_STICKY
}
if (intent?.action == ACTION_REPOST) {
// The ring is untouched; only its notification comes back. A
// service the system recreated has nothing to repost yet, so it
// starts the ring as any other start would.
posted?.let {
getSystemService(NotificationManager::class.java)
.notify(RingNotifications.RING_NOTIFICATION_ID, it)
return START_REDELIVER_INTENT
}
}
// The whole ring is released, not just the coroutine: cancelling the job
// alone would orphan a MediaPlayer the previous start had already
@@ -67,6 +83,8 @@ class AlarmRingService : Service() {
// foreground service a few seconds to post its notification, and a
// database read is not something to spend them on.
startForegroundPreparing()
// Also before the read: the fire broadcast's own wake lock is gone by now.
acquireWakeLock()
ringing = scope.launch {
val session = engine.ringSession()
if (session == null) {
@@ -89,7 +107,6 @@ class AlarmRingService : Service() {
private fun ring(session: RingSession) {
startForeground(session)
acquireWakeLock()
// Vibration first: the sound may take seconds to open when another app
// plays it, and the ring must not be still until then.
@@ -114,22 +131,31 @@ class AlarmRingService : Service() {
* screen.
*/
private fun startForegroundPreparing() {
startForeground(RingNotifications.preparing(this, presentation()))
startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent()))
}
private fun startForeground(session: RingSession) {
startForeground(
RingNotifications.ringing(
context = this,
alarm = session.alarm,
session = session,
presentation = presentation(),
repost = repostPendingIntent(),
),
)
}
private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
private fun repostPendingIntent(): PendingIntent = PendingIntent.getService(
this,
AlarmIntents.REQUEST_REPOST,
Intent(this, AlarmRingService::class.java).setAction(ACTION_REPOST),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun startForeground(notification: Notification) {
posted = notification
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
// exact-alarm permission and continuing an alarm in the background.
@@ -163,25 +189,30 @@ class AlarmRingService : Service() {
private fun stop() {
releaseRing()
posted = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
companion object {
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
private const val ACTION_REPOST = AlarmIntents.ACTION_DISMISS + ".SERVICE_REPOST"
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
/** A backstop on the backstop: the ring window is ten minutes. */
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
/** A backstop on the backstop, shared with the other ring service. */
private val WAKE_LOCK_TIMEOUT_MILLIS = SilenceAfter.WAKE_LOCK_TIMEOUT.inWholeMilliseconds
fun start(context: Context) {
context.startForegroundService(Intent(context, AlarmRingService::class.java))
}
/**
* `stopService` rather than a stop command: a command is a service
* start, which throws from the background once the service is gone,
* and a dismiss must never fail halfway through its writes.
*/
fun stop(context: Context) {
context.startService(
Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP),
)
context.stopService(Intent(context, AlarmRingService::class.java))
}
}
}
@@ -11,7 +11,9 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import java.time.Instant as JavaInstant
import java.time.LocalTime
@@ -80,7 +82,7 @@ object RingNotifications {
* closes when there is none, so a dismiss that lands before the service's
* read costs one brief screen wake, never a ring screen left up.
*/
fun preparing(context: Context, presentation: RingPresentation): Notification {
fun preparing(context: Context, presentation: RingPresentation, repost: PendingIntent): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -91,16 +93,50 @@ object RingNotifications {
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(show)
.setDeleteIntent(repost)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
/** The ringing notification, presented as [presentation] says the platform allows. */
/**
* Posted in place of the service when Android refused to start it: the
* ring screen through the full-screen intent where that is allowed, and a
* heads-up on the alarm channel where it is not. Better a silent alarm on
* screen than none at all.
*/
fun refused(context: Context, presentation: RingPresentation): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setContentIntent(show)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
/**
* The ringing notification, presented as [presentation] says the platform allows.
*
* Its actions never get round the ring screen's rules. Snooze appears only
* while snoozes are left, since a refused snooze dismisses; and an alarm
* with a dismiss challenge has its Dismiss open the ring screen, where the
* challenge is, rather than end the alarm from a heads-up or the lock screen.
*
* From Android 14 a foreground service's notification can be swiped away
* on an unlocked phone, ongoing or not. [repost] puts it back, so a
* sounding alarm never loses its controls.
*/
fun ringing(
context: Context,
alarm: Alarm,
session: RingSession,
presentation: RingPresentation,
repost: PendingIntent,
): Notification {
val alarm = session.alarm
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -112,16 +148,20 @@ object RingNotifications {
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(show)
.addAction(
.setDeleteIntent(repost)
if (session.canSnooze) {
builder.addAction(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_snooze),
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
)
.addAction(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_dismiss),
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
)
}
val dismiss = if (session.settings.dismissChallenge == DismissChallenge.NONE) {
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id)
} else {
show
}
builder.addAction(R.drawable.ic_notification, context.getString(R.string.alarm_action_dismiss), dismiss)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
@@ -176,7 +216,8 @@ object RingNotifications {
private fun formatTime(context: Context, hour: Int, minute: Int): String =
format(context, LocalTime.of(hour, minute))
private fun formatInstant(context: Context, instant: Instant): String = format(
/** Also the upcoming notice's (#16): one formatter for every alarm notification. */
internal fun formatInstant(context: Context, instant: Instant): String = format(
context,
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
)
@@ -0,0 +1,170 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
/**
* "Upcoming alarm" with a Dismiss, posted while an alarm is inside its window
* (#16). Silent and low-importance: it is a way out for someone already awake,
* not a second alarm. Its Dismiss is the engine's `dismissUpcomingOccurrence`
* for the one occurrence it names, so it can never end a ring that has already
* started; it asks for an unlock first (and below Android 12, where it cannot,
* the notice is kept off the secure lock screen), so a locked phone cannot be
* talked out of its alarm by whoever picks it up.
*/
object UpcomingNotifications {
const val CHANNEL_ID: String = "alarm_upcoming"
/** Tagged, so the per-alarm ids cannot collide with any other notification's. */
const val TAG: String = "alarm_upcoming"
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_alarm_upcoming_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_alarm_upcoming_description)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
fun notificationId(alarmId: Long): Int = alarmId.toInt()
fun build(context: Context, notice: UpcomingNotice): Notification {
val alarm = notice.alarm
val time = RingNotifications.formatInstant(context, notice.fireAt)
val text = if (alarm.label.isBlank()) {
time
} else {
context.getString(R.string.alarm_upcoming_text_labelled, time, alarm.label)
}
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_upcoming_title))
.setContentText(text)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_LOW)
// The Dismiss asks for an unlock from Android 12; below that the
// platform cannot, so there the notice stays off a locked screen.
.setVisibility(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
NotificationCompat.VISIBILITY_PUBLIC
} else {
NotificationCompat.VISIBILITY_SECRET
},
)
.setOnlyAlertOnce(true)
// Not bridged to a watch, where its Dismiss would skip the unlock.
.setLocalOnly(true)
.setSilent(true)
.setShowWhen(false)
// Several alarms in the window read in the order they ring.
.setSortKey(notice.fireAt.toEpochMilliseconds().toString().padStart(SORT_KEY_WIDTH, '0'))
.setContentIntent(showAlarmsPendingIntent(context))
.setDeleteIntent(swipedPendingIntent(context, notice))
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_dismiss),
dismissPendingIntent(context, notice),
).setAuthenticationRequired(true).build(),
)
.build()
}
/**
* The `id@fireAt` notices the user swiped away. Recorded only from the
* delete intent, which fires for a swipe or "clear all" and never for the
* app's own cancel, a reboot or a blocked channel — so a reschedule, even in
* a new process, never brings back a notice the user got rid of, and a
* notice the system took away does come back.
*/
@Synchronized
fun recordSwiped(context: Context, alarmId: Long, fireAtMillis: Long) {
val keys = read(context) + key(alarmId, fireAtMillis)
prefs(context).edit().putStringSet(SWIPED_KEY, keys).apply()
}
/**
* Drops the keys for occurrences no longer in [noticed], so the record stays
* small, and returns what is left. One synchronised read-modify-write, so a
* swipe recorded from the receiver meanwhile is never overwritten.
*/
@Synchronized
fun retainSwiped(context: Context, noticed: Set<String>): Set<String> {
val swiped = read(context)
val kept = swiped.intersect(noticed)
if (kept != swiped) prefs(context).edit().putStringSet(SWIPED_KEY, kept).apply()
return kept
}
private fun read(context: Context): Set<String> =
prefs(context).getStringSet(SWIPED_KEY, emptySet()).orEmpty().toSet()
fun key(notice: UpcomingNotice): String = key(notice.alarm.id, notice.fireAt.toEpochMilliseconds())
private fun key(alarmId: Long, fireAtMillis: Long): String = "$alarmId@$fireAtMillis"
private fun prefs(context: Context) = context.getSharedPreferences(RECORD_FILE, Context.MODE_PRIVATE)
/** The alarms list, the same door the status-bar alarm icon opens. */
private fun showAlarmsPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
AlarmIntents.REQUEST_SHOW_UPCOMING,
Intent(context, MainActivity::class.java).apply {
action = AlarmIntents.ACTION_SHOW_ALARMS
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun dismissPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent =
PendingIntent.getBroadcast(
context,
// Per alarm, so two notices' Dismiss buttons keep their own extras.
AlarmIntents.REQUEST_DISMISS_UPCOMING * REQUEST_CODE_STRIDE +
(notice.alarm.id % REQUEST_CODE_STRIDE).toInt(),
Intent(context, AlarmActionReceiver::class.java).apply {
action = AlarmIntents.ACTION_DISMISS_UPCOMING
putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id)
putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds())
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun swipedPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent =
PendingIntent.getBroadcast(
context,
AlarmIntents.REQUEST_UPCOMING_SWIPED * REQUEST_CODE_STRIDE +
(notice.alarm.id % REQUEST_CODE_STRIDE).toInt(),
Intent(context, AlarmActionReceiver::class.java).apply {
action = AlarmIntents.ACTION_UPCOMING_SWIPED
putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id)
putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds())
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Excluded from backup and device transfer: it describes this device's shade. */
private const val RECORD_FILE = "alarm_upcoming_notices"
private const val SWIPED_KEY = "swiped"
/** Matches `RingNotifications`: keeps a derived request code clear of the small ones. */
private const val REQUEST_CODE_STRIDE = 100_000
private const val SORT_KEY_WIDTH = 20
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.backup
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
@@ -26,6 +27,7 @@ class BackupEngine @Inject constructor(
private val settings: SettingsPrefs,
private val alarmEngine: AlarmEngine,
private val timerEngine: TimerEngine,
private val bedtimeEngine: BedtimeEngine,
private val wallClock: WallClock,
) {
@@ -93,6 +95,16 @@ class BackupEngine @Inject constructor(
}
if (failure == null) failure = reported else failure?.addSuppressed(reported)
}
// The bedtime settings came with the rest (or were rolled back
// with them), so the slot and the Do Not Disturb rule follow.
// Its platform calls are guarded already; a failure here is
// not a failed import. The engine compares against what was
// stored before (after a rollback, the same again), so only a
// real change to the schedule or the rule re-asserts the mode.
val previousBedtime = previousSettings.bedtime
if (settingsApplied && previousBedtime != null) {
runCatching { bedtimeEngine.resyncAfterRestore(previousBedtime) }
}
}
}
failure?.let { throw it }
@@ -103,5 +115,9 @@ class BackupEngine @Inject constructor(
dynamicColor = settings.dynamicColor.first(),
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
worldClockSeconds = settings.worldClockSeconds.first(),
screenSaver = settings.screenSaver.first(),
quickTileDuration = settings.quickTileDuration.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -0,0 +1,214 @@
package de.jeanlucmakiola.clockula.bedtime
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSchedule
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.ZoneId
import kotlin.math.ceil
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* The bedtime schedule (issue #1), orchestrated the way the other engines
* orchestrate theirs: plain Kotlin, the platform behind three seams.
*
* It owns one AlarmManager slot, armed for the schedule's next event (the
* reminder, bedtime, or the wake time), and one Do Not Disturb rule, which it
* creates while the schedule is on and Clockula has Do Not Disturb access,
* and removes when the schedule is turned off. Every entry point is an
* idempotent pass over "where does the schedule stand right now", so a late,
* missed or repeated fire settles into the right state.
*
* It never touches an alarm: the rule lets alarms through, and its slot is
* its own.
*/
@Singleton
class BedtimeEngine @Inject constructor(
private val settings: SettingsPrefs,
private val scheduler: BedtimeScheduler,
private val mode: BedtimeMode,
private val notifier: BedtimeNotifier,
private val wallClock: WallClock,
private val zones: ZoneProvider,
) {
private val lock = Mutex()
val bedtime: Flow<BedtimeSettings> = settings.bedtime
/**
* A change made on the bedtime screen: stored, then applied at once. The
* rule is rewritten when a field it carries changed. When the schedule
* or the rule changed, the mode is switched to what it says now, so
* turning bedtime on at 23:30 for a 23:00 bedtime starts it straight away
* (and a rewritten rule cannot be left off if the platform reset its
* state); a reminder edit leaves a mode switched by hand alone.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
val refreshRule = previous.ruleFields() != updated.ruleFields()
apply(
updated,
refreshRule = refreshRule,
force = refreshRule || previous.scheduleFields() != updated.scheduleFields(),
)
}
/**
* Everything that can invalidate the slot or the rule: app launch (a
* force stop clears the slot), a reboot, an app update, a clock or zone
* change, and Do Not Disturb access changing (a restore has
* [resyncAfterRestore]). The mode
* is only switched when the schedule crossed a boundary since Clockula
* last switched it, so a mode the user turned on or off by hand stays
* that way until the next bedtime or wake-up. [refreshRule] also rewrites
* the rule and re-asserts the mode's state.
*/
suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
apply(settings.currentBedtime(), refreshRule = refreshRule, force = refreshRule)
}
/**
* The slot arriving: the mode follows any boundary crossed, a due
* reminder is posted, and the next event is armed. A reminder fire alone
* crosses no boundary, so it leaves the mode as it is.
*/
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
val zone = zones.current()
apply(current, refreshRule = false, force = false, now = now, zone = zone)
BedtimeSchedule.dueReminder(current, now, zone)?.let { window ->
// What is actually left: a late fire must not promise the full lead.
val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1)
notifier.showReminder(window.start, minutesLeft)
}
}
/** Whether Clockula may change Do Not Disturb, for the screen's onboarding row. */
fun hasAccess(): Boolean = mode.hasAccess()
val supportsEffects: Boolean get() = mode.supportsEffects
fun ruleId(): String? = mode.ruleId()
/**
* One pass, over one reading of the clock: the mode's state and the next
* slot must agree about which side of a boundary "now" is on.
*/
private suspend fun apply(
current: BedtimeSettings,
refreshRule: Boolean,
force: Boolean,
now: Instant = wallClock.now(),
zone: ZoneId = zones.current(),
) {
if (!current.enabled) {
// Already off and cleaned up: nothing to do, which keeps every
// launch and boot of a user who never turned bedtime on cheap.
if (!force && !refreshRule && !settings.bedtimeAppliedActive() &&
(!mode.hasAccess() || mode.ruleId() == null)
) {
scheduler.cancel()
return
}
notifier.cancelReminder()
// Removing the rule ends the mode if it is on. Without access there
// is nothing Clockula may remove; revoking access in Settings
// already removed the rule.
val removed = !mode.hasAccess() || mode.uninstall()
if (removed) {
settings.setBedtimeAppliedActive(false)
scheduler.cancel()
} else {
// Refused: keep the slot as the retry, so the mode cannot stay
// on until some unrelated launch or reboot happens along.
scheduler.schedule(now + RETRY_AFTER, urgent = false)
}
return
}
val window = BedtimeSchedule.activeWindow(current, now, zone)
val next = BedtimeSchedule.nextEvent(current, now, zone)
// Anything below may throw or be refused; the slot is armed whatever
// happens, and pulled in to a retry when the mode is not where it
// should be.
var retry = true
try {
// A reminder that no longer points at a coming bedtime (it started,
// or the schedule changed under it) is stale.
if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder()
retry = mode.hasAccess() && !syncMode(current, active = window != null, refreshRule, force)
} finally {
arm(next, now, retry = retry, urgent = window != null && next == window.end)
}
}
/**
* Brings the rule and its state in line; false when something is still
* owed. Only what the system accepted is recorded as applied. A refused
* rewrite or state change is remembered as owed and forced by every pass
* until it lands, so a refusal neither hides the next real boundary nor
* rests on the one caller that asked.
*/
private suspend fun syncMode(current: BedtimeSettings, active: Boolean, refreshRule: Boolean, force: Boolean): Boolean {
val stale = refreshRule || settings.bedtimeRuleStale()
val outcome = mode.install(current, refresh = stale)
if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED)
// A rule that was just (re)created or rewritten may start off
// (Android 10 to 14 clear the condition on an update), so it is
// switched regardless of what was last applied.
val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED
val owed = settings.bedtimeStateOwed()
var stateOk = true
if (force || reset || owed || active != settings.bedtimeAppliedActive()) {
stateOk = mode.setActive(active)
if (stateOk) settings.setBedtimeAppliedActive(active)
if (stateOk != !owed) settings.setBedtimeStateOwed(!stateOk)
}
return outcome != InstallOutcome.FAILED && stateOk
}
/**
* The next event, or sooner as a retry. Only the wake-up boundary, which
* ends the mode, is [urgent]: one allow-while-idle fire a night, so the
* mode cannot linger into the morning behind a doze deferral.
*/
private fun arm(next: Instant?, now: Instant, retry: Boolean, urgent: Boolean) {
val retryAt = now + RETRY_AFTER
when {
retry && (next == null || retryAt < next) -> scheduler.schedule(retryAt, urgent = false)
next != null -> scheduler.schedule(next, urgent = urgent)
else -> scheduler.cancel()
}
}
/**
* After a backup restore: the same field comparison an edit makes, against
* what was stored before, so restoring the same schedule (or one that only
* differs in its reminder) leaves a hand-switched mode alone.
*/
suspend fun resyncAfterRestore(previous: BedtimeSettings) = lock.withLock {
val current = settings.currentBedtime()
val refreshRule = previous.ruleFields() != current.ruleFields()
apply(current, refreshRule = refreshRule, force = refreshRule || previous.scheduleFields() != current.scheduleFields())
}
private companion object {
val RETRY_AFTER = 15.minutes
}
/** What the system rule is built from; the reminder is not part of it. */
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
/** What decides whether the mode should be on right now. */
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.bedtime
/**
* The bedtime slot's broadcast. Internal and unexported, like the alarm's and
* the timer's: only the PendingIntent this app created can deliver it.
*/
object BedtimeIntents {
const val ACTION_SLOT: String = "de.jeanlucmakiola.clockula.bedtime.action.SLOT"
/** Opens the bedtime screen, from the reminder notification. */
const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
/**
* The extras Settings puts on the rule's configuration activity
* (`MainActivity`) when the user taps "Clockula" on the mode's page:
* `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` and
* `ConditionProviderService.EXTRA_RULE_ID`. Settings sends no action with
* them, only the explicit component, so their presence is the signal.
* Spelled out so the shell's pure routing table needs no Android import.
*/
val ZEN_RULE_EXTRAS: List<String> = listOf(
"android.app.extra.AUTOMATIC_RULE_ID",
"android.service.notification.extra.RULE_ID",
)
/**
* Apart from every alarm and timer request code, although the explicit
* receiver and the action already keep these PendingIntents distinct.
*/
const val REQUEST_SLOT: Int = 40
const val REQUEST_SHOW: Int = 41
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.bedtime
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import kotlin.time.Instant
/**
* The AlarmManager seam: one slot of its own, on its own receiver and request
* code, so nothing bedtime does can replace or cancel an alarm's or a timer's
* registration.
*/
interface BedtimeScheduler {
/**
* [urgent] is the one event a night that may draw on the platform's
* allow-while-idle ration: the wake-up that ends the mode.
*/
fun schedule(at: Instant, urgent: Boolean)
fun cancel()
}
/**
* The Do Not Disturb seam: Clockula's one automatic zen rule (a "mode" on
* Android 15 and later). Every call is a no-op without Do Not Disturb access,
* and none of them throws: a failure here must never stop the slot from being
* rescheduled.
*/
interface BedtimeMode {
/** Whether the user granted Do Not Disturb access ("Special app access"). */
fun hasAccess(): Boolean
/** Whether the mode can carry screen effects (Android 15 and later). */
val supportsEffects: Boolean
/**
* Creates the rule when it is missing. [refresh] also rewrites an existing
* one from [settings]; otherwise an existing rule is left exactly as it is.
*/
fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome
/** Turns the mode on or off; false when there is no rule or the system refused. */
fun setActive(active: Boolean): Boolean
/** Removes the rule, which also ends it if it is on; false when the system refused. */
fun uninstall(): Boolean
/** The rule's id, for a deep link into the system's settings; null when there is none. */
fun ruleId(): String?
}
/** What [BedtimeMode.install] did. */
enum class InstallOutcome { UNCHANGED, CREATED, UPDATED, FAILED }
/** The reminder notification. */
interface BedtimeNotifier {
fun showReminder(start: Instant, minutesBefore: Int)
fun cancelReminder()
}
@@ -0,0 +1,183 @@
package de.jeanlucmakiola.clockula.bedtime.android
import android.app.AutomaticZenRule
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.net.Uri
import android.os.Build
import android.service.notification.Condition
import android.service.notification.ZenDeviceEffects
import android.service.notification.ZenPolicy
import androidx.annotation.RequiresApi
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
import de.jeanlucmakiola.clockula.bedtime.InstallOutcome
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import java.time.LocalTime
import javax.inject.Inject
import javax.inject.Singleton
/**
* Clockula's one automatic zen rule. On Android 15 and later it is a mode in
* Settings › Modes, typed as a time schedule, under Clockula's name and icon,
* carrying the screen effects the user picked. On Android 10 to 14 it is a
* plain Do Not Disturb schedule rule.
*
* Digital Wellbeing's own Bedtime mode is off limits: the platform refuses
* `TYPE_BEDTIME` from any package but the system's wellbeing app, and no app
* can read its schedule. This is a separate mode next to it.
*
* **Alarms always get through.** The rule's [ZenPolicy] allows alarms (and
* media, for a sleep sound) and leaves everything else to the device's own Do
* Not Disturb defaults. A rule needs an owner service or a configuration
* activity; [MainActivity] is the latter, and it opens the bedtime screen when
* Settings opens it for the rule. It is already exported, so no new
* component is exposed for this.
*
* Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules]
* lists only this app's own rules, and the condition id tells Clockula's apart,
* so a reinstall, a cleared app or a restore onto another device finds or
* recreates it without a stale id. Every call is guarded: a revoked access or
* an OEM quirk costs the mode, never the caller.
*/
@Singleton
class AndroidBedtimeMode @Inject constructor(
@param:ApplicationContext private val context: Context,
) : BedtimeMode {
private val manager: NotificationManager = context.getSystemService(NotificationManager::class.java)
private val conditionId: Uri = Uri.Builder()
.scheme(Condition.SCHEME)
.authority(context.packageName)
.appendPath("bedtime")
.build()
override val supportsEffects: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome =
guarded(InstallOutcome.FAILED) {
val ours = ownRules()
// Only ever one. A second (two passes racing on another process,
// say) is removed rather than left to fight the first.
ours.keys.drop(1).forEach(manager::removeAutomaticZenRule)
val existing = ours.entries.firstOrNull()
if (existing == null) {
manager.addAutomaticZenRule(rule(settings, enabled = true))
return@guarded InstallOutcome.CREATED
}
if (refresh) {
// Keeps what the user set for the rule in Settings: its on/off
// switch (a change here must not re-enable a rule they
// disabled) and who may interrupt, which Android 10 to 14
// would otherwise overwrite. Android 15 keeps both itself.
manager.updateAutomaticZenRule(
existing.key,
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
)
return@guarded InstallOutcome.UPDATED
}
InstallOutcome.UNCHANGED
}
override fun setActive(active: Boolean): Boolean =
guarded(false) {
val id = ownRules().keys.firstOrNull() ?: return@guarded false
val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
val summary = context.getString(R.string.bedtime_mode_name)
val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// A schedule's own transition, so Android 15 keeps a user's manual
// override of the mode until the schedule's next one.
Condition(conditionId, summary, state, Condition.SOURCE_SCHEDULE)
} else {
Condition(conditionId, summary, state)
}
manager.setAutomaticZenRuleState(id, condition)
true
}
override fun uninstall(): Boolean =
guarded(false) {
ownRules().keys.forEach(manager::removeAutomaticZenRule)
true
}
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
private fun ownRules(): Map<String, AutomaticZenRule> =
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
/**
* [kept] is the existing rule's policy, reused as long as it lets alarms
* through: a user's edit in Settings survives, and an edit that blocked
* alarms is the one thing replaced.
*/
private fun rule(settings: BedtimeSettings, enabled: Boolean, kept: ZenPolicy? = null): AutomaticZenRule {
val name = context.getString(R.string.bedtime_mode_name)
val configuration = ComponentName(context, MainActivity::class.java)
val policy = kept?.takeIf { it.priorityCategoryAlarms == ZenPolicy.STATE_ALLOW }
?: ZenPolicy.Builder()
.allowAlarms(true)
.allowMedia(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
modeRule(name, configuration, policy, settings, enabled)
} else {
AutomaticZenRule(
name,
null,
configuration,
conditionId,
policy,
NotificationManager.INTERRUPTION_FILTER_PRIORITY,
enabled,
)
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private fun modeRule(
name: String,
configuration: ComponentName,
policy: ZenPolicy,
settings: BedtimeSettings,
enabled: Boolean,
): AutomaticZenRule = AutomaticZenRule.Builder(name, conditionId)
.setType(AutomaticZenRule.TYPE_SCHEDULE_TIME)
.setConfigurationActivity(configuration)
.setZenPolicy(policy)
.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
.setDeviceEffects(
ZenDeviceEffects.Builder()
.setShouldDisplayGrayscale(settings.effects.grayscale)
.setShouldDimWallpaper(settings.effects.dimWallpaper)
.setShouldUseNightMode(settings.effects.darkTheme)
.setShouldSuppressAmbientDisplay(settings.effects.hideAlwaysOnDisplay)
.build(),
)
.setTriggerDescription(
context.getString(
R.string.bedtime_mode_trigger,
formatTime(settings.bedtime),
formatTime(settings.wakeTime),
),
)
.setIconResId(R.drawable.ic_bedtime)
.setManualInvocationAllowed(true)
.setEnabled(enabled)
.build()
private fun formatTime(time: TimeOfDay): String = formatBedtimeTime(context, LocalTime.of(time.hour, time.minute))
private inline fun <T> guarded(fallback: T, block: () -> T): T = try {
block()
} catch (_: RuntimeException) {
fallback
}
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.bedtime.android
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import javax.inject.Inject
import javax.inject.Singleton
/**
* The "bedtime soon" reminder, on a channel of its own so it can be silenced
* without touching anything an alarm or timer posts. Denied the notification
* permission, the user loses the reminder and nothing else.
*/
@Singleton
class AndroidBedtimeNotifier @Inject constructor(
@param:ApplicationContext private val context: Context,
private val zones: ZoneProvider,
) : BedtimeNotifier {
private val notifications = NotificationManagerCompat.from(context)
override fun showReminder(start: Instant, minutesBefore: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
val time = formatBedtimeTime(context, start.toJavaInstant().atZone(zones.current()).toLocalTime())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_bedtime)
.setContentTitle(
context.resources.getQuantityString(R.plurals.bedtime_reminder_title, minutesBefore, minutesBefore),
)
.setContentText(context.getString(R.string.bedtime_reminder_text, time))
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
.setContentIntent(showPendingIntent())
.build()
runCatching { notifications.notify(NOTIFICATION_ID, notification) }
}
override fun cancelReminder() = notifications.cancel(NOTIFICATION_ID)
private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity(
context,
BedtimeIntents.REQUEST_SHOW,
Intent(context, MainActivity::class.java)
.setAction(BedtimeIntents.ACTION_SHOW_BEDTIME)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
companion object {
const val CHANNEL_ID: String = "bedtime_reminder"
/** Next to the other fixed ids (1_001..1_004), below the snoozed alarms' 2_000 + id. */
private const val NOTIFICATION_ID: Int = 1_005
/** Idempotent; called from the app class on every start, like the other channels. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_bedtime_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = context.getString(R.string.channel_bedtime_description)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.clockula.bedtime.android
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
import de.jeanlucmakiola.clockula.bedtime.receiver.BedtimeReceiver
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
* that slot is the status bar's "next alarm", and bedtime is not an alarm.
*
* Only the wake-up that ends the mode is allow-while-idle, so Do Not Disturb
* cannot linger hours into the morning behind a doze deferral. That is one
* such fire a night, well within the per-app ration the timer's expiry slot
* also uses (alarms themselves use `setAlarmClock`, which is not rationed).
* Bedtime's start, the reminder and retries are exact but doze-deferrable:
* a late start in a dozing, untouched phone costs nothing. It is its own
* PendingIntent (its own receiver, action and request code), so arming or
* cancelling it can never touch an alarm's or a timer's registration.
*/
@Singleton
class AndroidBedtimeScheduler @Inject constructor(
@param:ApplicationContext private val context: Context,
) : BedtimeScheduler {
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
override fun schedule(at: Instant, urgent: Boolean) {
val millis = at.toEpochMilliseconds()
val pendingIntent = slotPendingIntent()
if (urgent) {
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}.isSuccess
if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
return
}
val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess
// Guarded too: bedtime losing its slot is survivable, a crash in the
// receiver's process is not.
// A bounded window rather than `set`, whose slack grows with the
// distance to the trigger and could end an 8-hour night hours late.
if (!exact) {
runCatching { manager.setWindow(AlarmManager.RTC_WAKEUP, millis, FALLBACK_WINDOW_MILLIS, pendingIntent) }
}
}
override fun cancel() = manager.cancel(slotPendingIntent())
private companion object {
/** The platform's own minimum window from Android 12 on. */
const val FALLBACK_WINDOW_MILLIS = 10 * 60_000L
}
private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
BedtimeIntents.REQUEST_SLOT,
Intent(context, BedtimeReceiver::class.java).setAction(BedtimeIntents.ACTION_SLOT),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.bedtime.android
import android.content.Context
import android.text.format.DateFormat
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* The same two steps `ui/common/AlarmTimeFormatter.kt` takes — the device's
* 12/24-hour choice, then the locale's short form — for the two bedtime
* strings the system shows outside the app: the mode's trigger line and the
* reminder notification.
*/
internal fun formatBedtimeTime(context: Context, time: LocalTime): String {
val formatter = if (DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
return formatter.format(time)
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.clockula.bedtime.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeMode
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeScheduler
import javax.inject.Singleton
/** The three seams `BedtimeEngine` sees, which keep it JVM-testable. */
@Module
@InstallIn(SingletonComponent::class)
abstract class BedtimeModule {
@Binds
@Singleton
abstract fun bindBedtimeScheduler(impl: AndroidBedtimeScheduler): BedtimeScheduler
@Binds
@Singleton
abstract fun bindBedtimeMode(impl: AndroidBedtimeMode): BedtimeMode
@Binds
@Singleton
abstract fun bindBedtimeNotifier(impl: AndroidBedtimeNotifier): BedtimeNotifier
}
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.clockula.bedtime.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The bedtime slot arriving. Do Not Disturb access changing is not heard
* here: the platform delivers `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED`
* to runtime-registered receivers only, so the bedtime screen re-reads access
* on every `ON_RESUME` instead, and any later pass picks a grant up.
*/
@AndroidEntryPoint
class BedtimeReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: BedtimeEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
// Guarded: a platform refusal here must not crash the process,
// which is the process an alarm may be about to ring in.
if (action == BedtimeIntents.ACTION_SLOT) runCatching { engine.onSlotFired() }
} finally {
pendingResult.finish()
}
}
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
@@ -24,6 +25,10 @@ abstract class AlarmDao {
@Insert
abstract suspend fun insert(alarm: AlarmEntity): Long
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertIfAbsent(alarm: AlarmEntity): Long
@Update
abstract suspend fun update(alarm: AlarmEntity)
@@ -36,6 +41,24 @@ abstract class AlarmDao {
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
/** Clears the date only while it is still [expected], so a newly picked one survives. */
@Query(
"UPDATE alarms SET scheduled_date = NULL, updated_at = :updatedAt " +
"WHERE id = :id AND scheduled_date = :expected",
)
abstract suspend fun clearScheduledDate(id: Long, expected: Long, updatedAt: Long)
/**
* Clears the pause only while it is still [expectedStart]..[expectedEnd],
* in either order: the mapper reads a reversed row as the ordered range.
*/
@Query(
"UPDATE alarms SET pause_start = NULL, pause_end = NULL, updated_at = :updatedAt " +
"WHERE id = :id AND MIN(pause_start, pause_end) = :expectedStart " +
"AND MAX(pause_start, pause_end) = :expectedEnd",
)
abstract suspend fun clearPause(id: Long, expectedStart: Long, expectedEnd: Long, updatedAt: Long)
/**
* Reads the row, applies [transform] and writes the result back inside one
* transaction. A missing row, or a null result, writes nothing. [transform]
@@ -7,7 +7,8 @@ import androidx.room.PrimaryKey
/**
* The `alarms` row. Primitives only — every enum is stored as [Enum.name], every
* instant as epoch millis and the repeat set as a 7-bit mask, so the whole
* instant as epoch millis, every local date as an epoch day and the repeat set
* as a 7-bit mask, so the whole
* entity/domain translation lives in [AlarmMapper] where it can be tested.
*/
@Entity(tableName = "alarms", indices = [Index("enabled")])
@@ -28,4 +29,9 @@ data class AlarmEntity(
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
/** v4: `LocalDate.toEpochDay()` — a calendar date, so no zone is baked in. */
@ColumnInfo(name = "scheduled_date") val scheduledDate: Long? = null,
/** v4: both ends inclusive epoch days; one without the other reads as no pause. */
@ColumnInfo(name = "pause_start") val pauseStart: Long? = null,
@ColumnInfo(name = "pause_end") val pauseEnd: Long? = null,
)
@@ -1,34 +1,45 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.prefs.toEnum
import java.time.LocalDate
import kotlin.time.Instant
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
object AlarmMapper {
fun toDomain(entity: AlarmEntity): Alarm = Alarm(
id = entity.id,
time = TimeOfDay.clamped(entity.hour, entity.minute),
label = entity.label,
enabled = entity.enabled,
repeatDays = RepeatDays.fromMask(entity.repeatDays),
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes,
snoozeLimit = entity.snoozeLimit,
volumeRampSeconds = entity.volumeRampSeconds,
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
deleteAfterUse = entity.deleteAfterUse,
)
fun toDomain(entity: AlarmEntity): Alarm {
val repeatDays = RepeatDays.fromMask(entity.repeatDays)
return Alarm(
id = entity.id,
time = TimeOfDay.clamped(entity.hour, entity.minute),
label = entity.label,
enabled = entity.enabled,
repeatDays = repeatDays,
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
deleteAfterUse = entity.deleteAfterUse,
// A date belongs to a one-shot alarm and a pause to a repeating one; the
// other combination is never written, so reading it as absent is safe.
scheduledDate = epochDay(entity.scheduledDate).takeUnless { repeatDays.isRepeating },
pause = pauseOf(entity.pauseStart, entity.pauseEnd).takeIf { repeatDays.isRepeating },
)
}
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
@@ -49,6 +60,9 @@ object AlarmMapper {
createdAt = alarm.createdAt.toEpochMilliseconds(),
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
deleteAfterUse = alarm.deleteAfterUse,
scheduledDate = alarm.scheduledDate?.toEpochDay(),
pauseStart = alarm.pause?.start?.toEpochDay(),
pauseEnd = alarm.pause?.end?.toEpochDay(),
)
/** id = 0, createdAt = updatedAt = [now]. */
@@ -70,4 +84,17 @@ object AlarmMapper {
updatedAt = now.toEpochMilliseconds(),
deleteAfterUse = draft.deleteAfterUse,
)
/** Out of [AlarmDates]' range is corrupt, and reads as no date at all. */
private fun epochDay(value: Long?): LocalDate? {
if (value == null) return null
if (value !in AlarmDates.MIN.toEpochDay()..AlarmDates.MAX.toEpochDay()) return null
return LocalDate.ofEpochDay(value)
}
private fun pauseOf(start: Long?, end: Long?): PauseRange? {
val from = epochDay(start) ?: return null
val until = epochDay(end) ?: return null
return PauseRange.ordered(from, until)
}
}
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.PauseRange
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
interface AlarmRepository {
fun alarms(): Flow<List<Alarm>>
@@ -12,9 +14,25 @@ interface AlarmRepository {
suspend fun create(draft: AlarmDraft): Long
suspend fun update(alarm: Alarm)
suspend fun delete(id: Long)
/**
* Puts a deleted alarm back under its own id, timestamps and all — the
* Undo after a delete. `alarms.id` is `AUTOINCREMENT`, so no new alarm can
* have claimed it; should the id be taken all the same, nothing is written
* and the answer is false, rather than a crash or an overwrite.
*/
suspend fun restore(alarm: Alarm): Boolean
suspend fun setEnabled(id: Long, enabled: Boolean)
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
/**
* Drops a spent date or pause, but only while the stored one is still
* [expected]: a column-targeted write, so no other field is rewritten
* and a value the editor has just picked is never cleared.
*/
suspend fun clearScheduledDate(id: Long, expected: LocalDate)
suspend fun clearPause(id: Long, expected: PauseRange)
/**
* Transactional read-modify-write. Stamps `updated_at` from the wall clock
* and keeps the id, so a concurrent `setEnabled` from the engine cannot be
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@@ -36,12 +38,21 @@ class AlarmRepositoryImpl @Inject constructor(
override suspend fun delete(id: Long) = dao.deleteById(id)
override suspend fun restore(alarm: Alarm): Boolean =
dao.insertIfAbsent(AlarmMapper.toEntity(alarm)) != -1L
override suspend fun setEnabled(id: Long, enabled: Boolean) =
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
override suspend fun clearScheduledDate(id: Long, expected: LocalDate) =
dao.clearScheduledDate(id, expected.toEpochDay(), wallClock.now().toEpochMilliseconds())
override suspend fun clearPause(id: Long, expected: PauseRange) =
dao.clearPause(id, expected.start.toEpochDay(), expected.end.toEpochDay(), wallClock.now().toEpochMilliseconds())
override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) {
val updatedAt = wallClock.now().toEpochMilliseconds()
dao.updateWithin(id) { entity ->
@@ -1,26 +1,50 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.time.LocalDate
import java.time.format.DateTimeParseException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/** The result of [BackupCodec.decode]: either a validated [BackupContent], or why not. */
/**
* Why a restore failed, in words a user can act on. The codec's own four, plus
* the two the caller meets around it: the file would not open, or the import
* itself failed after the file was valid.
*/
enum class BackupProblem { NOT_A_BACKUP, NEWER_VERSION, INVALID_DATA, TOO_LARGE, UNREADABLE, NOT_APPLIED }
/**
* The result of [BackupCodec.decode]: either a validated [BackupContent], or
* why not — [problem] for the user, [reason] the specific detail, in English,
* for tests and logs.
*/
sealed interface BackupResult {
data class Success(val content: BackupContent) : BackupResult
data class Failure(val reason: String) : BackupResult
data class Failure(val problem: BackupProblem, val reason: String) : BackupResult
}
/**
@@ -48,13 +72,15 @@ object BackupCodec {
json.encodeToString(BackupDocument.serializer(), content.toDocument())
fun decode(text: String): BackupResult {
if (text.length > MAX_DOCUMENT_CHARS) return BackupResult.Failure("backup exceeds the 8 MiB limit")
if (text.length > MAX_DOCUMENT_CHARS) {
return BackupResult.Failure(BackupProblem.TOO_LARGE, "backup exceeds the 8 MiB limit")
}
val document = try {
json.decodeFromString(BackupDocument.serializer(), text)
} catch (e: SerializationException) {
return BackupResult.Failure("not a valid backup file: ${e.message}")
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "not a valid backup file: ${e.message}")
} catch (e: IllegalArgumentException) {
return BackupResult.Failure("not a valid backup file: ${e.message}")
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "not a valid backup file: ${e.message}")
}
return document.toContent()
}
@@ -85,6 +111,9 @@ object BackupCodec {
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge?.name,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate?.toString(),
pauseStart = pause?.start?.toString(),
pauseEnd = pause?.end?.toString(),
)
private fun BackupTimer.toDto() = TimerDto(
@@ -110,57 +139,125 @@ object BackupCodec {
volumeRampSeconds = defaults.volumeRampSeconds,
alarmRingtoneUri = defaults.alarmRingtoneUri,
timerRingtoneUri = defaults.timerRingtoneUri,
timerVibrate = defaults.timerVibrate,
timerVolumeRampSeconds = defaults.timerVolumeRampSeconds,
dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId,
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
volumeButtons = defaults.volumeButtons.name,
automaticLocalClock = defaults.automaticLocalClock,
worldClockSeconds = worldClockSeconds,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
bedtime = bedtime?.toDto(),
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
)
private fun BedtimeSettings.toDto() = BedtimeDto(
enabled = enabled,
bedtimeMinutes = bedtime.minutesOfDay,
wakeMinutes = wakeTime.minutesOfDay,
days = days.mask,
reminderMinutes = reminderMinutes,
grayscale = effects.grayscale,
dimWallpaper = effects.dimWallpaper,
darkTheme = effects.darkTheme,
hideAlwaysOnDisplay = effects.hideAlwaysOnDisplay,
)
/**
* Forgiving, like the other settings: an out-of-range time or reminder is
* clamped rather than failing the import.
*/
private fun BedtimeDto.toBedtime() = BedtimeSettings(
enabled = enabled,
bedtime = minutesToTime(bedtimeMinutes),
wakeTime = minutesToTime(wakeMinutes),
days = BedtimeLimits.nights(days),
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
effects = BedtimeEffects(
grayscale = grayscale,
dimWallpaper = dimWallpaper,
darkTheme = darkTheme,
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
),
)
private fun minutesToTime(minutes: Int): TimeOfDay {
val clamped = minutes.coerceIn(0, 24 * 60 - 1)
return TimeOfDay(clamped / 60, clamped % 60)
}
// --- decode + validate ---
private fun BackupDocument.toContent(): BackupResult {
if (format != BACKUP_FORMAT) return BackupResult.Failure("unknown format: '$format'")
if (version != BACKUP_VERSION) return BackupResult.Failure("unsupported backup version: $version")
if (format != BACKUP_FORMAT) {
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "unknown format: '$format'")
}
if (version !in BACKUP_MIN_VERSION..BACKUP_VERSION) {
val problem = if (version > BACKUP_VERSION) BackupProblem.NEWER_VERSION else BackupProblem.INVALID_DATA
return BackupResult.Failure(problem, "unsupported backup version: $version")
}
if (alarms.size > MAX_ALARMS || timers.size > MAX_TIMERS || worldClocks.size > MAX_WORLD_CLOCKS) {
return BackupResult.Failure("backup contains too many records")
return BackupResult.Failure(BackupProblem.TOO_LARGE, "backup contains too many records")
}
val alarms = mutableListOf<BackupAlarm>()
for ((index, dto) in this.alarms.withIndex()) {
if (dto.hour !in 0..23 || dto.minute !in 0..59) {
return BackupResult.Failure(
BackupProblem.INVALID_DATA,
"alarms[$index]: hour/minute out of range (${dto.hour}:${dto.minute})",
)
}
if (dto.label.length > MAX_LABEL_CHARS || (dto.ringtoneUri?.length ?: 0) > MAX_URI_CHARS) {
return BackupResult.Failure("alarms[$index]: label or ringtone URI is too long")
return BackupResult.Failure(
BackupProblem.TOO_LARGE,
"alarms[$index]: label or ringtone URI is too long",
)
}
val repeatDays = RepeatDays.fromMask(dto.repeatDays)
val dates = dto.dates(repeatDays.isRepeating)
?: return BackupResult.Failure(
BackupProblem.INVALID_DATA,
"alarms[$index]: scheduled_date or pause is not a valid date range",
)
alarms += BackupAlarm(
time = TimeOfDay(dto.hour, dto.minute),
label = dto.label,
enabled = dto.enabled,
repeatDays = RepeatDays.fromMask(dto.repeatDays),
repeatDays = repeatDays,
skipNextOccurrence = dto.skipNextOccurrence,
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = dto.vibrate,
snoozeMinutes = dto.snoozeMinutes,
snoozeLimit = dto.snoozeLimit,
volumeRampSeconds = dto.volumeRampSeconds,
// The same clamps the defaults pass through: an alarm's own
// override is no freer than the setting it overrides.
snoozeMinutes = dto.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
snoozeLimit = dto.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
volumeRampSeconds = dto.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE),
deleteAfterUse = dto.deleteAfterUse,
scheduledDate = dates.scheduledDate,
pause = dates.pause,
)
}
val timers = mutableListOf<BackupTimer>()
for ((index, dto) in this.timers.withIndex()) {
if (dto.durationMillis < 0) {
return BackupResult.Failure("timers[$index]: negative duration")
return BackupResult.Failure(BackupProblem.INVALID_DATA, "timers[$index]: negative duration")
}
if (dto.label.length > MAX_LABEL_CHARS || (dto.ringtoneUri?.length ?: 0) > MAX_URI_CHARS) {
return BackupResult.Failure("timers[$index]: label or ringtone URI is too long")
return BackupResult.Failure(
BackupProblem.TOO_LARGE,
"timers[$index]: label or ringtone URI is too long",
)
}
timers += BackupTimer(
label = dto.label,
duration = dto.durationMillis.milliseconds,
duration = dto.durationMillis.milliseconds.coerceIn(TimerPresets.MIN, TimerPresets.MAX),
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
sortOrder = dto.sortOrder,
deleteAfterUse = dto.deleteAfterUse,
@@ -170,10 +267,13 @@ object BackupCodec {
val worldClocks = mutableListOf<BackupWorldClock>()
for ((index, dto) in this.worldClocks.withIndex()) {
if (!Zones.isValid(dto.zoneId)) {
return BackupResult.Failure("worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'")
return BackupResult.Failure(
BackupProblem.INVALID_DATA,
"worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'",
)
}
if ((dto.label?.length ?: 0) > MAX_LABEL_CHARS) {
return BackupResult.Failure("worldClocks[$index]: label is too long")
return BackupResult.Failure(BackupProblem.TOO_LARGE, "worldClocks[$index]: label is too long")
}
worldClocks += BackupWorldClock(
zoneId = dto.zoneId,
@@ -185,9 +285,9 @@ object BackupCodec {
val settingsDto = settings
if ((settingsDto.alarmRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
(settingsDto.timerRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
settingsDto.timerPresets.size > 1_000
(settingsDto.timerPresets?.size ?: 0) > 1_000
) {
return BackupResult.Failure("settings contain oversized values")
return BackupResult.Failure(BackupProblem.TOO_LARGE, "settings contain oversized values")
}
val settingsContent = BackupSettings(
themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM),
@@ -199,10 +299,24 @@ object BackupCodec {
volumeRampSeconds = settingsDto.volumeRampSeconds,
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate,
timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds,
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
// Clamped on apply, by the preference, like every other default.
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
automaticLocalClock = settingsDto.automaticLocalClock,
),
timerPresets = settingsDto.timerPresets.map { it.milliseconds },
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
worldClockSeconds = settingsDto.worldClockSeconds,
screenSaver = ScreenSaverSettings(
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
nightMode = settingsDto.screenSaverNightMode,
),
quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
bedtime = settingsDto.bedtime?.toBedtime(),
)
return BackupResult.Success(
@@ -215,4 +329,36 @@ object BackupCodec {
),
)
}
private class AlarmDatesContent(val scheduledDate: LocalDate?, val pause: PauseRange?)
/**
* Null when a date is present but malformed, out of [AlarmDates]' range, or
* a pause has only one end or ends before it starts. Strict, like the
* alarm's own hour and minute: a date is the alarm's meaning, not a
* preference that can degrade to a default. Only the fields the alarm can
* carry are read — a date belongs to a one-shot, a pause to a repeating
* alarm, as the mapper reads them — so a leftover on the other kind is
* dropped unread rather than able to reject the file.
*/
private fun AlarmDto.dates(repeating: Boolean): AlarmDatesContent? {
if (!repeating) {
val scheduled = scheduledDate?.let { parseDate(it) ?: return null }
return AlarmDatesContent(scheduled, null)
}
val startText = pauseStart
val endText = pauseEnd
if (startText == null && endText == null) return AlarmDatesContent(null, null)
if (startText == null || endText == null) return null
val start = parseDate(startText) ?: return null
val end = parseDate(endText) ?: return null
if (end.isBefore(start)) return null
return AlarmDatesContent(null, PauseRange(start, end))
}
private fun parseDate(text: String): LocalDate? = try {
LocalDate.parse(text).takeIf(AlarmDates::isValid)
} catch (e: DateTimeParseException) {
null
}
}
@@ -1,11 +1,16 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** `docs/BACKUP.md`'s top-level constants. */
const val BACKUP_FORMAT: String = "clockula-backup"
const val BACKUP_VERSION: Int = 1
const val BACKUP_VERSION: Int = 2
/** The oldest version this build still reads. v1 is v2 without the alarm dates. */
const val BACKUP_MIN_VERSION: Int = 1
/** The wire shape of `clockula-backup-v1.json`. See `docs/BACKUP.md`. */
@Serializable
@@ -35,6 +40,10 @@ data class AlarmDto(
@SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null,
@SerialName("dismiss_challenge") val dismissChallenge: String? = null,
@SerialName("delete_after_use") val deleteAfterUse: Boolean = false,
/** v2: ISO-8601 local dates (`2026-10-28`). */
@SerialName("scheduled_date") val scheduledDate: String? = null,
@SerialName("pause_start") val pauseStart: String? = null,
@SerialName("pause_end") val pauseEnd: String? = null,
)
/** Config only — never state, the running anchors, or `ends_at_wall_clock`. */
@@ -69,7 +78,56 @@ data class SettingsDto(
@SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15,
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
/**
* Absent in a file from before timers had their own vibration; such a file
* restores [vibrate] here, which is what its timers vibrated by.
*/
@SerialName("default_timer_vibrate") val timerVibrate: Boolean? = null,
@SerialName("default_timer_volume_ramp_seconds") val timerVolumeRampSeconds: Int = 0,
@SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE",
@SerialName("home_zone_id") val homeZoneId: String? = null,
@SerialName("timer_presets") val timerPresets: List<Long> = emptyList(),
@SerialName("volume_buttons_while_ringing") val volumeButtons: String = "CONTROL_VOLUME",
/**
* Minutes, or 0 for "Never". Absent in a file from before the setting
* existed, which is v1's fixed ten minutes.
*/
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
/** Absent in files from before #24, which keeps the default: on. */
@SerialName("automatic_local_clock") val automaticLocalClock: Boolean = true,
/** Absent in files from before it existed, which keeps the default: off. */
@SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false,
/**
* Absent is not empty: an empty list is a user who removed every preset,
* while a file without the field (an older or hand-written one) keeps the
* built-in ones.
*/
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
val bedtime: BedtimeDto? = null,
/** Absent in files from before the screen saver; they restore its defaults. */
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
/** Absent (a file from before #26) or out of range keeps the tile's default. */
@SerialName("quick_tile_duration_millis") val quickTileDurationMillis: Long? = null,
)
/**
* The bedtime schedule (issue #1). Times are minutes since midnight, like the
* `BedtimePrefs` keys; `days` is the same 7-bit mask an alarm's `repeat_days` is.
*/
@Serializable
data class BedtimeDto(
val enabled: Boolean = DEFAULT_BEDTIME.enabled,
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = DEFAULT_BEDTIME.bedtime.minutesOfDay,
@SerialName("wake_minutes") val wakeMinutes: Int = DEFAULT_BEDTIME.wakeTime.minutesOfDay,
val days: Int = DEFAULT_BEDTIME.days.mask,
@SerialName("reminder_minutes") val reminderMinutes: Int = DEFAULT_BEDTIME.reminderMinutes,
val grayscale: Boolean = DEFAULT_BEDTIME.effects.grayscale,
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = DEFAULT_BEDTIME.effects.dimWallpaper,
@SerialName("dark_theme") val darkTheme: Boolean = DEFAULT_BEDTIME.effects.darkTheme,
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = DEFAULT_BEDTIME.effects.hideAlwaysOnDisplay,
)
/** A missing field restores what a fresh install has. */
private val DEFAULT_BEDTIME = BedtimeSettings()
@@ -70,6 +70,8 @@ private fun Alarm.toBackup() = BackupAlarm(
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate,
pause = pause,
)
private fun Timer.toBackup() = BackupTimer(
@@ -100,6 +102,9 @@ private fun BackupAlarm.toEntity(nowMillis: Long) = AlarmEntity(
createdAt = nowMillis,
updatedAt = nowMillis,
deleteAfterUse = deleteAfterUse,
scheduledDate = scheduledDate?.toEpochDay(),
pauseStart = pause?.start?.toEpochDay(),
pauseEnd = pause?.end?.toEpochDay(),
)
/** Imported timers always land IDLE with the full configured duration (`docs/BACKUP.md`). */
@@ -15,7 +15,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
/**
* Clockula's own storage. Version 3; the schema is exported to `app/schemas/`
* Clockula's own storage. Version 4; the schema is exported to `app/schemas/`
* and committed, so every later migration is reviewable and testable.
*
* No `TypeConverters` — every column is a primitive and the whole translation
@@ -30,7 +30,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
WorldClockEntity::class,
LapEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
abstract class ClockulaDatabase : RoomDatabase() {
@@ -44,5 +44,16 @@ object Migrations {
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
/** v3 → v4: `scheduled_date`, `pause_start` and `pause_end` on `alarms` (#21). */
val MIGRATION_3_4: Migration = object : Migration(3, 4) {
override fun migrate(connection: SQLiteConnection) {
// Nullable with no default, as the entity declares them: every alarm
// written before v4 reads as unscheduled and unpaused.
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `scheduled_date` INTEGER")
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_start` INTEGER")
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_end` INTEGER")
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
}
@@ -5,12 +5,16 @@ 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.AndroidHourFormat
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.HourFormat
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -39,4 +43,10 @@ abstract class TimeModule {
@Binds
abstract fun bindTicker(impl: RealTicker): Ticker
@Binds
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
@Binds
abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
}
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
import de.jeanlucmakiola.floret.prefs.intPref
/**
* The bedtime schedule's on-disk keys (issue #1). Configuration only: the
* id of the system mode Clockula created is not stored at all, because the
* platform already lists an app's own rules (`AndroidBedtimeMode`), and a
* stored id would go stale after a restore onto another device.
*/
object BedtimePrefs {
private val DEFAULT = BedtimeSettings()
val enabled: Pref<Boolean> = booleanPref("bedtime_enabled", DEFAULT.enabled)
val bedtime: Pref<TimeOfDay> = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay()
val wakeTime: Pref<TimeOfDay> = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
/**
* The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set.
* The screen never lets the last night go, so an empty mask (corrupt, or
* written around it) reads as every night, as a backup's does.
*/
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
.map(
decode = BedtimeLimits::nights,
encode = { it.mask },
)
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
.map(
decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
)
val grayscale: Pref<Boolean> = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale)
val dimWallpaper: Pref<Boolean> = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper)
val darkTheme: Pref<Boolean> = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme)
val hideAlwaysOnDisplay: Pref<Boolean> =
booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay)
/**
* Whether Clockula last switched the mode on or off: device state, not
* configuration, so it is not in the JSON backup. It lets a pass switch
* the mode only when the schedule crossed a boundary since, and leave a
* mode the user switched by hand alone otherwise.
*/
val appliedActive: Pref<Boolean> = booleanPref("bedtime_applied_active", false)
/** A rule rewrite the system refused, still owed; device state like the above. */
val ruleStale: Pref<Boolean> = booleanPref("bedtime_rule_stale", false)
/** A state change the system refused, still owed; device state like the above. */
val stateOwed: Pref<Boolean> = booleanPref("bedtime_state_owed", false)
/** Minutes since midnight on disk, clamped into a day both ways. */
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
decode = { minutes ->
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
TimeOfDay(clamped / 60, clamped % 60)
},
encode = { it.minutesOfDay },
)
private const val MINUTES_PER_DAY = 24 * 60
}
@@ -1,7 +1,10 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
import de.jeanlucmakiola.floret.prefs.enumPref
@@ -15,25 +18,69 @@ import de.jeanlucmakiola.floret.prefs.nullableStringPref
*/
object ClockPrefs {
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(1..60)
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(ClockLimits.SNOOZE_MINUTES)
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(0..10)
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(ClockLimits.SNOOZE_LIMIT)
val vibrate: Pref<Boolean> = booleanPref("default_vibrate", true)
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60)
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
val alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent()
/**
* The timers' own vibration (#22). Until it existed a timer vibrated
* exactly when [vibrate] said so; `SettingsPrefs.settleTimerVibrate` copies
* that over once, so nobody's timers change on update. The default is
* [vibrate]'s, for the moment before that lands.
*/
val timerVibrate: Pref<Boolean> = booleanPref("default_timer_vibrate", true)
val timerVolumeRampSeconds: Pref<Int> =
intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
val dismissChallenge: Pref<DismissChallenge> =
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
/**
* How long a ringing alarm, and a finished timer, keep going before they
* stop on their own (#17): minutes, or [SilenceAfter.NEVER], sanitised both
* ways through [SilenceAfter.sanitise]. An absent key reads as v1's fixed
* ten minutes, so an upgrade changes nothing.
*/
val alarmSilenceAfterMinutes: Pref<Int> =
intPref("default_alarm_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
val timerSilenceAfterMinutes: Pref<Int> =
intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
/** No `default_` prefix: no alarm overrides it. */
val volumeButtons: Pref<VolumeButtonAction> =
enumPref("volume_buttons_while_ringing", VolumeButtonAction.CONTROL_VOLUME)
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
.map(decode = Zones::normalise, encode = Zones::normalise)
/**
* The world clock tab's readouts to the second, with a second hand on the
* face (#23). Off by default: it is what makes the tab wake every second
* rather than every minute. Not part of `ClockDefaults`, which alarms and
* timers inherit from, so flipping it re-emits nothing they watch.
*/
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
/**
* The World clock tab's temporary local-time row while the device is in a
* zone whose time differs from home (#24). On by default, as Google Clock's
* automatic home clock is.
*/
val automaticLocalClock: Pref<Boolean> = booleanPref("automatic_local_clock", true)
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
import de.jeanlucmakiola.floret.prefs.enumPref
/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */
object ScreenSaverPrefs {
val style: Pref<ScreenSaverStyle> = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
val nightMode: Pref<Boolean> = booleanPref("screen_saver_night_mode", false)
}
@@ -1,8 +1,15 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
@@ -62,14 +69,36 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
dismissChallenge = dismissChallenge,
)
},
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
combine(
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
store.flow(ClockPrefs.timerSilenceAfterMinutes),
store.flow(ClockPrefs.volumeButtons),
) { alarmMinutes, timerMinutes, volumeButtons -> Triple(alarmMinutes, timerMinutes, volumeButtons) },
combine(
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.timerVibrate),
store.flow(ClockPrefs.timerVolumeRampSeconds),
) { timerRingtoneUri, timerVibrate, timerVolumeRampSeconds ->
Triple(timerRingtoneUri, timerVibrate, timerVolumeRampSeconds)
},
combine(
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
store.flow(ClockPrefs.automaticLocalClock),
) { alarmRingtoneUri, homeZoneId, automaticLocalClock ->
Triple(alarmRingtoneUri, homeZoneId, automaticLocalClock)
},
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), (alarmRingtoneUri, homeZoneId, automaticLocalClock) ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
timerVibrate = timerVibrate,
timerVolumeRampSeconds = timerVolumeRampSeconds,
homeZoneId = homeZoneId,
alarmSilenceAfterMinutes = alarmSilenceAfter,
timerSilenceAfterMinutes = timerSilenceAfter,
volumeButtons = volumeButtons,
automaticLocalClock = automaticLocalClock,
)
}.distinctUntilChanged()
@@ -90,9 +119,21 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds
prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri
prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri
prefs[ClockPrefs.timerVibrate] = backup.defaults.timerVibrate
prefs[ClockPrefs.timerVolumeRampSeconds] = backup.defaults.timerVolumeRampSeconds
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
prefs[ClockPrefs.automaticLocalClock] = backup.defaults.automaticLocalClock
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
prefs[TimerPrefs.quickTileDuration] = backup.quickTileDuration
}
}
@@ -108,11 +149,48 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri)
suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled)
/**
* Gives the timers' vibration a value of its own, once (#22): the alarm
* default, which is what every timer vibrated by until then. From here on
* the two move apart. A no-op once the key exists.
*/
suspend fun settleTimerVibrate() {
store.edit { prefs ->
// Raw keys for the reads: the kit reads a Pref only from a store, not
// from a transaction's preferences. Both are plain boolean prefs.
if (booleanPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit
prefs[ClockPrefs.timerVibrate] =
prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default
}
}
suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds)
suspend fun setDismissChallenge(challenge: DismissChallenge) =
store.set(ClockPrefs.dismissChallenge, challenge)
suspend fun setAlarmSilenceAfterMinutes(minutes: Int) =
store.set(ClockPrefs.alarmSilenceAfterMinutes, minutes)
suspend fun setTimerSilenceAfterMinutes(minutes: Int) =
store.set(ClockPrefs.timerSilenceAfterMinutes, minutes)
suspend fun setVolumeButtons(action: VolumeButtonAction) = store.set(ClockPrefs.volumeButtons, action)
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
suspend fun setAutomaticLocalClock(enabled: Boolean) =
store.set(ClockPrefs.automaticLocalClock, enabled)
// --- world clock display (#23) ---
/** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */
val worldClockSeconds: Flow<Boolean> = store.flow(ClockPrefs.worldClockSeconds)
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
// --- timer presets (M6) ---
/**
@@ -133,6 +211,91 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
}
// --- bedtime (issue #1) ---
/** Distinct-until-changed, composed from the individual keys like [defaults]. */
val bedtime: Flow<BedtimeSettings> = combine(
combine(
store.flow(BedtimePrefs.enabled),
store.flow(BedtimePrefs.bedtime),
store.flow(BedtimePrefs.wakeTime),
store.flow(BedtimePrefs.days),
store.flow(BedtimePrefs.reminderMinutes),
) { enabled, bedtime, wakeTime, days, reminderMinutes ->
BedtimeSettings(
enabled = enabled,
bedtime = bedtime,
wakeTime = wakeTime,
days = days,
reminderMinutes = reminderMinutes,
)
},
store.flow(BedtimePrefs.grayscale),
store.flow(BedtimePrefs.dimWallpaper),
store.flow(BedtimePrefs.darkTheme),
store.flow(BedtimePrefs.hideAlwaysOnDisplay),
) { partial, grayscale, dimWallpaper, darkTheme, hideAlwaysOnDisplay ->
partial.copy(
effects = BedtimeEffects(
grayscale = grayscale,
dimWallpaper = dimWallpaper,
darkTheme = darkTheme,
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
),
)
}.distinctUntilChanged()
suspend fun currentBedtime(): BedtimeSettings = bedtime.first()
/** Every bedtime key in one transaction. `BedtimeEngine` is the only writer. */
suspend fun setBedtime(settings: BedtimeSettings) {
store.edit { prefs -> prefs.putBedtime(settings) }
}
suspend fun bedtimeAppliedActive(): Boolean = store.get(BedtimePrefs.appliedActive)
suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active)
suspend fun bedtimeRuleStale(): Boolean = store.get(BedtimePrefs.ruleStale)
suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale)
suspend fun bedtimeStateOwed(): Boolean = store.get(BedtimePrefs.stateOwed)
suspend fun setBedtimeStateOwed(owed: Boolean) = store.set(BedtimePrefs.stateOwed, owed)
private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
this[BedtimePrefs.enabled] = settings.enabled
this[BedtimePrefs.bedtime] = settings.bedtime
this[BedtimePrefs.wakeTime] = settings.wakeTime
this[BedtimePrefs.days] = settings.days
this[BedtimePrefs.reminderMinutes] = settings.reminderMinutes
this[BedtimePrefs.grayscale] = settings.effects.grayscale
this[BedtimePrefs.dimWallpaper] = settings.effects.dimWallpaper
this[BedtimePrefs.darkTheme] = settings.effects.darkTheme
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
}
// --- screen saver (issue #25) ---
/** Built once and composed from its two keys, for the same reasons [defaults] is. */
val screenSaver: Flow<ScreenSaverSettings> = combine(
store.flow(ScreenSaverPrefs.style),
store.flow(ScreenSaverPrefs.nightMode),
::ScreenSaverSettings,
).distinctUntilChanged()
suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style)
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
// --- quick-timer tile (#26) ---
/** What the quick-timer Quick Settings tile starts; bounded as the quick-timer widget's duration is. */
val quickTileDuration: Flow<Duration> = store.flow(TimerPrefs.quickTileDuration)
suspend fun setQuickTileDuration(value: Duration) = store.set(TimerPrefs.quickTileDuration, value)
// --- first-launch bookkeeping (M4) ---
/**
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
@@ -41,4 +42,22 @@ object TimerPrefs {
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
encode = { instant -> instant?.toEpochMilliseconds() },
)
/**
* The duration the quick-timer Quick Settings tile starts (#26). The
* widget's own bounds and default (`QuickTimerWidgetSettings`), sanitised
* on write as well as on read, so a stored value is always one the tile
* can start. A setting, so it is in the backup.
*/
val quickTileDuration: Pref<Duration> = nullableLongPref("quick_tile_duration_millis").map(
decode = ::parseQuickTimerDuration,
encode = { duration -> parseQuickTimerDuration(duration.inWholeMilliseconds).inWholeMilliseconds },
)
/**
* The transient timer the quick-timer tile last started, so the tile can
* show and control that one. Volatile state, like [ringSoundingSince]: it
* names a row that is deleted on reset, and stays out of the backup.
*/
val quickTileTimerId: Pref<Long?> = nullableLongPref("quick_tile_timer_id")
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.text.format.DateFormat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.HourFormat
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` and `TIMEZONE_CHANGED`, through a receiver that is registered
* only while somebody collects. Both already have their manifest receiver
* (`SystemEventReceiver`) for the alarm engine; this one is for a readout on
* screen, which must not wait out its cadence to show either.
*/
@Singleton
class AndroidClockChanges @Inject constructor(
@param:ApplicationContext private val context: Context,
) : ClockChanges {
override fun changes(): Flow<Unit> = 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)
}
// 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) }
}
}
/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
class AndroidHourFormat @Inject constructor(
@ApplicationContext private val context: Context,
) : HourFormat {
override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.clockula.data.time
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallBoundaries
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -10,7 +12,7 @@ import javax.inject.Singleton
/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */
@Singleton
class RealTicker @Inject constructor() : Ticker {
class RealTicker @Inject constructor(private val wallClock: WallClock) : Ticker {
/**
* Cold, and per-collector: two readouts on different cadences get two
@@ -24,4 +26,18 @@ class RealTicker @Inject constructor() : Ticker {
delay(period)
}
}
/**
* Cold and per-collector, as [ticks] is. The wait is re-measured against
* the wall clock after every emission rather than added up, so a `delay`
* that ran a little long does not accumulate. A wall clock set mid-wait is
* only caught up after that wait ends; a caller that cannot wait that long
* restarts the flow on `ClockChanges`, as the world clock does.
*/
override fun alignedTicks(unit: Duration): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(WallBoundaries.untilNext(wallClock.now(), unit))
}
}
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.data.prefs.TimerPrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which transient timer the quick-timer Quick Settings tile last started
* (#26) — the tile's counterpart of the quick-timer widget's per-instance
* Glance state. One volatile record, so DataStore rather than Room, and out
* of the backup for the reason [TimerRingStateStore] gives.
*/
@Singleton
class QuickTileStateStore @Inject constructor(private val store: PrefStore) {
/** Built once, distinct-until-changed (ARCHITECTURE.md §6). */
val timerId: Flow<Long?> = store.flow(TimerPrefs.quickTileTimerId)
suspend fun currentTimerId(): Long? = store.get(TimerPrefs.quickTileTimerId)
suspend fun setTimerId(id: Long?) = store.set(TimerPrefs.quickTileTimerId, id)
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.timers
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
@@ -27,6 +28,10 @@ abstract class TimerDao {
@Insert
abstract suspend fun insert(timer: TimerEntity): Long
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertIfAbsent(timer: TimerEntity): Long
@Update
abstract suspend fun update(timer: TimerEntity)
@@ -13,6 +13,13 @@ interface TimerRepository {
suspend fun rename(id: Long, label: String)
suspend fun delete(id: Long)
/**
* Puts a deleted timer back under its own id, anchors and place in the
* list — the Undo after a delete. False, writing nothing, should that id
* be taken (`timers.id` is `AUTOINCREMENT`, so no new timer can have it).
*/
suspend fun restore(timer: Timer): Boolean
/** Starts an IDLE/EXPIRED timer from its configured duration, resumes a PAUSED one. No-op when RUNNING. */
suspend fun start(id: Long)
suspend fun pause(id: Long)
@@ -43,6 +43,9 @@ class TimerRepositoryImpl @Inject constructor(
override suspend fun delete(id: Long) = dao.deleteById(id)
override suspend fun restore(timer: Timer): Boolean =
dao.insertIfAbsent(TimerMapper.toEntity(timer)) != -1L
override suspend fun start(id: Long) {
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
@@ -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<String>, at: Instant): Map<String, String?> =
withContext(io) {
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
import java.time.LocalDate
import kotlin.time.Instant
/** A local time of day. Always valid; use [clamped] at a data boundary. */
@@ -69,6 +70,39 @@ value class RepeatDays private constructor(val mask: Int) {
}
}
/**
* A run of whole local dates a repeating alarm stays quiet on, both ends
* inclusive. Dates, not instants: "paused from the 26th to the 28th" means the
* same three calendar days whatever zone the phone is in when they arrive.
*/
data class PauseRange(val start: LocalDate, val end: LocalDate) {
init {
require(!end.isBefore(start)) { "pause ends before it starts: $start..$end" }
}
operator fun contains(date: LocalDate): Boolean = !date.isBefore(start) && !date.isAfter(end)
companion object {
/** Never throws: the two ends are put in order. */
fun ordered(a: LocalDate, b: LocalDate): PauseRange =
if (b.isBefore(a)) PauseRange(b, a) else PauseRange(a, b)
}
}
/**
* The dates an alarm can be scheduled on or paused for. Anything outside is
* corrupt, never a choice: the pickers offer nothing near either end, and
* keeping clear of `LocalDate`'s own limits means date arithmetic on a stored
* value can never overflow.
*/
object AlarmDates {
val MIN: LocalDate = LocalDate.of(2000, 1, 1)
val MAX: LocalDate = LocalDate.of(9999, 12, 31)
fun isValid(date: LocalDate): Boolean = !date.isBefore(MIN) && !date.isAfter(MAX)
}
/** The optional barrier between a ringing alarm and dismissing it. */
enum class DismissChallenge { NONE, MATH, HOLD }
@@ -93,6 +127,13 @@ data class Alarm(
val updatedAt: Instant,
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
val deleteAfterUse: Boolean,
/**
* A one-shot alarm's date: it rings on this local date and not before.
* Only ever set on a non-repeating alarm — picking a repeat day clears it.
*/
val scheduledDate: LocalDate? = null,
/** A repeating alarm's holiday: no occurrence on these dates. Only ever set on a repeating alarm. */
val pause: PauseRange? = null,
)
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
@@ -1,8 +1,23 @@
package de.jeanlucmakiola.clockula.domain
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import kotlin.time.Duration
/**
* The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen.
*
* The two silence-after values are app-wide only: no alarm or timer overrides
* them (#17). They are stored as [SilenceAfter] minutes; read them as a window
* through [alarmSilenceAfter] and [timerSilenceAfter]. [volumeButtons] has no
* per-alarm override either (#18); it lives here because it is edited and
* backed up with the rest of the alarm settings.
*
* [vibrate] and [volumeRampSeconds] are the alarms'; timers have their own
* [timerVibrate] and [timerVolumeRampSeconds], because a kitchen timer that
* should just beep is not an alarm that should wake someone. They start as
* timers have always rung: vibrating like the alarms' default, unramped.
*/
data class ClockDefaults(
val snoozeMinutes: Int = 10,
@@ -11,6 +26,30 @@ data class ClockDefaults(
val volumeRampSeconds: Int = 15,
val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null,
val timerVibrate: Boolean = true,
val timerVolumeRampSeconds: Int = 0,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val homeZoneId: String? = null,
)
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
/** The World clock tab's temporary local-time row while travelling (#24). */
val automaticLocalClock: Boolean = true,
) {
/** How long a ringing alarm rings before it is silenced as missed. */
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
/** How long a finished timer sounds before the noise stops. */
val timerSilenceAfter: Duration get() = SilenceAfter.window(timerSilenceAfterMinutes)
}
/**
* The ranges a user can choose from, for the defaults and for an alarm's own
* overrides alike. Anything read back from disk or a backup is clamped into
* them, so a value no picker offers never reaches the engine.
*/
object ClockLimits {
val SNOOZE_MINUTES: IntRange = 1..60
val SNOOZE_LIMIT: IntRange = 0..10
val VOLUME_RAMP_SECONDS: IntRange = 0..60
}
@@ -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.
*/
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
/** What an Undo puts back: the alarm's row and its ring state, as of the delete. */
data class DeletedAlarm(val alarm: Alarm, val state: AlarmRingState)
/**
* What a deleted alarm comes back as. Pure, so the "no stale ring" rule is
* decided once, here, and the engine only carries it out.
*/
object AlarmDeletion {
/**
* The snapshot an Undo restores, or null when there is nothing to restore.
*
* Everything the user set comes back as it was (label, sound, challenge,
* skip-next), and so does the ring state: a snoozed alarm is snoozed again,
* and the handled-occurrence watermark still stops the occurrence just
* closed from ringing twice.
*
* A *ringing* alarm is the exception. Deleting it stopped the ring, which is
* a dismissal in all but name, so it comes back as a dismissal would have
* left it: cycle closed, a one-shot switched off, never ringing again. A
* transient one-shot (M9 D10) would have been deleted by that dismissal
* anyway, so there is nothing to undo.
*/
fun snapshot(alarm: Alarm, state: AlarmRingState): DeletedAlarm? {
if (!state.isRinging) return DeletedAlarm(alarm, state)
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
if (alarm.repeatDays.isRepeating) return DeletedAlarm(alarm, closed)
if (alarm.deleteAfterUse) return null
return DeletedAlarm(alarm.copy(enabled = false), closed)
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
@@ -33,6 +34,13 @@ object AlarmOccurrences {
* alarm is just "the next daily candidate", and what makes it one-shot is
* what happens after it rings.
*
* [notBefore] is a scheduled date: no candidate on an earlier local date.
* It is a floor, not a filter, so the sequence stays infinite — a date that
* has already gone by (the phone was off all day) leaves the alarm behaving
* as the plain one-shot it would be without one. [pause] drops every
* candidate whose local date falls inside it; it is jumped, not walked, so
* a corrupt pause centuries long costs nothing.
*
* Two local dates can collapse onto one instant (a skipped calendar day, a
* gap), so a candidate that did not advance is dropped and the caller may
* rely on `drop(1)` meaning "the one after".
@@ -42,13 +50,21 @@ object AlarmOccurrences {
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): Sequence<Instant> = Sequence {
iterator {
// One day back, because a gap can push a candidate from the previous
// local date past [after].
var date = after.localDate(zone).minusDays(1)
var date = firstDateFor(after, zone).let { first ->
if (notBefore != null && notBefore.isAfter(first)) notBefore else first
}
var previous: Instant? = null
while (true) {
if (pause != null && date in pause) {
date = pause.end.plusDays(1)
continue
}
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
val candidate = at(date, time, zone)
val last = previous
@@ -67,7 +83,16 @@ object AlarmOccurrences {
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
): Instant? = sequence(time, repeatDays, after, zone).firstOrNull()
notBefore: LocalDate? = null,
pause: PauseRange? = null,
): Instant? = sequence(time, repeatDays, after, zone, notBefore, pause).firstOrNull()
/**
* The earliest local date [sequence] looks at for [after]. A scheduled date
* or a pause ending before it can no longer change a single candidate, which
* is what makes it safe to clear one there.
*/
fun firstDateFor(after: Instant, zone: ZoneId): LocalDate = after.localDate(zone).minusDays(1)
private fun Instant.localDate(zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
@@ -18,6 +18,10 @@ data class AlarmResolution(
val state: AlarmRingState,
val clearSkipFlag: Boolean = false,
val disableAlarm: Boolean = false,
/** The scheduled date can no longer change a candidate: drop it from the row. */
val clearScheduledDate: Boolean = false,
/** The pause has ended: drop it from the row. */
val clearPause: Boolean = false,
)
/**
@@ -27,6 +31,10 @@ data class AlarmResolution(
*
* The precedence is fixed and total: disabled, then ringing, then snoozed, then
* the natural occurrence.
*
* `autoSilenceAfter` is the "Silence after" setting's window
* (`ClockDefaults.alarmSilenceAfter`). It has no default, so no caller can
* measure a ring against a window the user did not choose.
*/
object AlarmResolver {
fun resolve(
@@ -34,8 +42,31 @@ object AlarmResolver {
state: AlarmRingState,
now: Instant,
zone: ZoneId,
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
autoSilenceAfter: Duration,
fireGrace: Duration = AlarmRing.FIRE_GRACE,
): AlarmResolution {
// A date or a pause that lies wholly before the first date the
// candidates are drawn from cannot change one of them, so clearing it
// changes nothing but the row — on any branch, a disabled alarm too.
// Measured from the same `now - fireGrace` the candidates are, so a
// pause ending at midnight cannot let its last day's occurrence ring
// late through the grace window.
val firstDate = AlarmOccurrences.firstDateFor(now - fireGrace, zone)
val scheduledDate = alarm.scheduledDate
val pause = alarm.pause
return resolveRing(alarm, state, now, zone, autoSilenceAfter, fireGrace).copy(
clearScheduledDate = scheduledDate != null && !scheduledDate.isAfter(firstDate),
clearPause = pause != null && pause.end.isBefore(firstDate),
)
}
private fun resolveRing(
alarm: Alarm,
state: AlarmRingState,
now: Instant,
zone: ZoneId,
autoSilenceAfter: Duration,
fireGrace: Duration,
): AlarmResolution {
// 1. Disabled: every volatile field goes, except the dismissal watermark
// — re-enabling an alarm must not un-suppress an occurrence already rung.
@@ -68,8 +99,11 @@ object AlarmResolver {
// 3. Snooze. A snooze promised before a reboot is kept, even if it came
// due while the device was off; one older than the ring window is gone.
// Never sooner than SNOOZE_GRACE, so a short "Silence after" cannot make
// a snooze easier to lose than it was in v1.
val snoozedUntil = working.snoozedUntil
val snoozePending = snoozedUntil != null && now - snoozedUntil < autoSilenceAfter
val snoozeGrace = AlarmRing.snoozeGrace(autoSilenceAfter)
val snoozePending = snoozedUntil != null && now - snoozedUntil < snoozeGrace
if (snoozedUntil != null && !snoozePending) {
working = working.copy(snoozedUntil = null, snoozeCount = 0)
}
@@ -89,6 +123,22 @@ object AlarmResolver {
}
}
// A cycle that has just closed as missed (a ring past its window, or a
// snooze that never fired) closes a one-shot alarm for good, exactly as
// the engine's own close does. Falling through would hand it the same
// time tomorrow.
val closedAsMissed = ringingSince != null || snoozedUntil != null
if (closedAsMissed && !alarm.repeatDays.isRepeating) {
return AlarmResolution(
alarmId = alarm.id,
nextFire = null,
source = FireSource.NONE,
state = working.copy(skippedOccurrence = null),
clearSkipFlag = alarm.skipNextOccurrence,
disableAlarm = true,
)
}
// 4. The natural occurrence, with the skip watermark on top.
if (!alarm.skipNextOccurrence) {
return AlarmResolution(
@@ -120,7 +170,17 @@ object AlarmResolver {
// cannot ring on its way out through the grace window.
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
source = FireSource.OCCURRENCE,
state = working.copy(skippedOccurrence = watermark.takeUnless { consumed }),
// A consumed skip becomes the handled-occurrence watermark: with both
// the flag and the skip watermark gone, nothing else would stop the
// next resolution inside the fire grace from ringing it after all.
state = if (consumed) {
working.copy(
skippedOccurrence = null,
handledOccurrence = maxOf(watermark, working.handledOccurrence ?: watermark),
)
} else {
working.copy(skippedOccurrence = watermark)
},
clearSkipFlag = consumed,
)
}
@@ -139,7 +199,14 @@ object AlarmResolver {
fireGrace: Duration,
): Sequence<Instant> {
val handled = state.handledOccurrence
return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone)
return AlarmOccurrences.sequence(
time = alarm.time,
repeatDays = alarm.repeatDays,
after = now - fireGrace,
zone = zone,
notBefore = alarm.scheduledDate,
pause = alarm.pause,
)
.filterNot { handled != null && it <= handled && it <= now }
}
}
@@ -4,20 +4,27 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The alarm ring cycle's constants. They are values rather than settings: v1
* does not offer them to the user (docs/PLAN.md §4). The audio chain's own
* constants live in `domain/ring/RingAudio`, shared with the timer's ring.
* The alarm ring cycle's constants. They are values rather than settings
* (docs/PLAN.md §4). How long an alarm rings before it is silenced as missed
* became a setting in #17: `ClockDefaults.alarmSilenceAfter`. The audio
* chain's own constants live in `domain/ring/RingAudio`, shared with the
* timer's ring.
*/
object AlarmRing {
/**
* How long an undismissed alarm rings before it is silenced as missed.
* AOSP DeskClock's default, and the window a resumed ring is measured against.
*/
val AUTO_SILENCE_AFTER: Duration = 10.minutes
/**
* How late a fire may arrive and still count. A fire delayed by doze, a slow
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
*/
val FIRE_GRACE: Duration = 2.minutes
/**
* The least time a late snooze is still kept: v1's fixed ring window. A
* snooze delayed by a reboot or by Doze is kept for the "Silence after"
* window or this, whichever is longer, so a short window never makes a
* promised snooze easier to lose.
*/
val SNOOZE_GRACE: Duration = 10.minutes
/** How late a snooze may come due and still ring, given the ring window. */
fun snoozeGrace(silenceAfter: Duration): Duration = maxOf(silenceAfter, SNOOZE_GRACE)
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
/**
* What an alarm's scheduled date and pause mean *right now*, for the surfaces
* that describe an alarm. The resolver decides when it rings; this only keeps
* the words beside it honest. A stored date or pause outlives its usefulness
* by up to a day before the resolver clears it, and in that window it must not
* be shown as though it still applied.
*/
object AlarmSchedule {
/**
* The local date [now] falls on in [zone].
*/
fun today(now: Instant, zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
/**
* A one-shot alarm's scheduled date while its occurrence is still ahead.
* Null once it has gone by: the alarm then rings like a plain one-shot,
* and saying "Scheduled for" a day in the past would be a lie.
*/
fun upcomingDate(alarm: Alarm, now: Instant, zone: ZoneId): LocalDate? {
if (alarm.repeatDays.isRepeating) return null
val date = alarm.scheduledDate ?: return null
return date.takeIf { isAhead(it, alarm.time, now, zone) }
}
/** Whether [time] on [date] is still to come — the one test a scheduled date must pass. */
fun isAhead(date: LocalDate, time: TimeOfDay, now: Instant, zone: ZoneId): Boolean =
AlarmOccurrences.at(date, time, zone) > now
/** A repeating alarm's pause while any day of it is still to come — today included. */
fun currentPause(alarm: Alarm, now: Instant, zone: ZoneId): PauseRange? {
if (!alarm.repeatDays.isRepeating) return null
val pause = alarm.pause ?: return null
return pause.takeUnless { it.end.isBefore(today(now, zone)) }
}
/**
* The first date a one-shot alarm at [time] can be scheduled on: the date
* of its next occurrence. Today while today's [time] is still ahead,
* tomorrow once it has gone by — so the picker cannot offer a date whose
* alarm would already be in the past.
*/
fun earliestDate(time: TimeOfDay, now: Instant, zone: ZoneId): LocalDate {
// Every day matches RepeatDays.NONE, so the sequence is never empty.
return today(AlarmOccurrences.sequence(time, RepeatDays.NONE, now, zone).first(), zone)
}
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import kotlin.time.Instant
/**
* What "Alarm in 7 hours and 12 minutes" says once the user has
* set an alarm, or null when it should say nothing (#19).
*
* Only a natural occurrence is announced. A switched-off alarm will not ring at
* all; a ringing or snoozed one is answering the snooze, not the change the
* user just made, and "Alarm in 9 minutes" would read as a mistake.
*/
object AlarmSetCountdown {
/** Pure. The rounding is [NextFireFormat]'s, so the chip and the row agree. */
fun of(upcoming: UpcomingAlarm?, now: Instant): NextFireLabel? {
// A switched-off alarm resolves to FireSource.NONE, so this one check
// also covers it.
if (upcoming?.source != FireSource.OCCURRENCE) return null
val nextFire = upcoming.nextFire ?: return null
return NextFireFormat.labelFor(nextFire, now)
}
}
@@ -23,6 +23,8 @@ data class RingSession(
val settings: AlarmSettings,
val startedAt: Instant,
val canSnooze: Boolean,
/** The app-wide volume-button setting, read with the rest of the session. */
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
)
sealed interface FireOutcome {
@@ -43,6 +45,12 @@ sealed interface DismissOutcome {
/** Unknown id, or an alarm with no upcoming instance. Nothing was written. */
data object NotFound : DismissOutcome
/**
* The occurrence a notice or row named is no longer the one to come: it
* started ringing, or the alarm moved. Nothing was dismissed (#16).
*/
data object Stale : DismissOutcome
}
sealed interface SnoozeOutcome {
@@ -50,5 +58,9 @@ sealed interface SnoozeOutcome {
/** The limit was already reached; the alarm was dismissed instead. */
data class LimitReached(val alarmId: Long) : SnoozeOutcome
/** The limit was already reached and the caller asked for the alarm to keep ringing. */
data class Refused(val alarmId: Long) : SnoozeOutcome
data object NotRinging : SnoozeOutcome
}
@@ -0,0 +1,84 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** One alarm about to ring, offered for dismissal before it does (#16). */
data class UpcomingNotice(
val alarm: Alarm,
/** The occurrence the notice is about; a dismissal names it, so a stale tap cannot reach a later one. */
val fireAt: Instant,
)
/**
* What the upcoming-alarm surface should show now, and when it next has to
* look again. [nextCheckAt] is null when there is nothing left to look at.
*/
data class UpcomingNoticePlan(
val notices: List<UpcomingNotice>,
val nextCheckAt: Instant?,
)
/**
* The "upcoming alarm" window: the stretch before an alarm rings in which it
* can be dismissed without opening the app (#16). Pure; the engine and the
* alarm list both ask it, so the notification and the row's chip agree.
*/
object UpcomingNotices {
/**
* How long before the ring the notice appears. AOSP DeskClock posts its
* "Upcoming alarm" notification two hours ahead, which is also what Google
* Clock is reported to do. Long enough to cover waking early; the notice
* is silent, so being early costs nothing. A value, not a setting: the
* notification channel is the off switch.
*/
val LEAD: Duration = 2.hours
/**
* Only a natural occurrence still to come. A snoozed alarm already has its
* own notification with a Dismiss, and a ringing one is the ring screen's,
* where the dismiss challenge is.
*/
fun isInWindow(nextFire: Instant?, source: FireSource, now: Instant, lead: Duration = LEAD): Boolean =
source == FireSource.OCCURRENCE && nextFire != null && nextFire > now && nextFire - now <= lead
/**
* Notices ordered by when they ring, then id. The next check is the
* earliest of three instants still to come:
*
* - a window opening, so its notice goes up;
* - a noticed occurrence's fire grace running out, so a notice whose fire
* came late (Doze, no exact alarms) does not outlive its alarm;
* - an armed skip's ([pendingSkips]) fire grace running out, so a skip
* dismissed in advance is spent on time and the list stops saying
* "skipping next" about an occurrence that has gone by.
*
* Both of the last two wait out the grace rather than land on the
* occurrence itself: a fire for it still in flight is refused as too late
* by then, so the check can never race it.
*/
fun plan(
upcoming: List<UpcomingAlarm>,
now: Instant,
pendingSkips: List<Instant> = emptyList(),
lead: Duration = LEAD,
fireGrace: Duration = AlarmRing.FIRE_GRACE,
): UpcomingNoticePlan {
val notices = upcoming
.filter { isInWindow(it.nextFire, it.source, now, lead) }
.map { UpcomingNotice(it.alarm, it.nextFire!!) }
.sortedWith(compareBy({ it.fireAt }, { it.alarm.id }))
val openings = upcoming
.filter { it.source == FireSource.OCCURRENCE }
.mapNotNull { entry -> entry.nextFire?.let { it - lead } }
// Strictly past the grace: `onFire` still accepts a fire at exactly it.
val afterGrace = fireGrace + 1.seconds
val closings = notices.map { it.fireAt + afterGrace }
val skipsSpent = pendingSkips.map { it + afterGrace }
val nextCheckAt = (openings + closings + skipsSpent).filter { it > now }.minOrNull()
return UpcomingNoticePlan(notices, nextCheckAt)
}
}
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
/**
* What the user asked the volume buttons to do while an alarm rings (#18).
* App-wide, not per alarm: it is a habit of the hand, not a property of one
* alarm. [CONTROL_VOLUME] is the default and is what Clockula did before the
* setting existed.
*/
enum class VolumeButtonAction { CONTROL_VOLUME, SNOOZE, DISMISS, NOTHING }
/** What one press actually does, once the ring's own rules have had their say. */
enum class VolumeKeyEffect {
/** Not ours: the system changes the volume as it would anyway. */
PASS_THROUGH,
/** Swallowed, so the volume stays where it is and the alarm keeps ringing ("Do nothing" only). */
IGNORE,
SNOOZE,
DISMISS,
}
/**
* Turns the setting into an effect for one ringing alarm. The press is blind
* (the point is not having to look at the screen), so it must never end the
* alarm more finally than the ring screen itself would:
*
* - **Dismiss** dismisses only when the alarm has no dismiss challenge. A
* button that skipped the sum or the hold would make the challenge
* pointless, so with one set it snoozes instead, and the challenge waits for
* the next ring.
* - **Snooze** with no snoozes left (and Dismiss behind a challenge with none
* left) falls back to changing the volume. The engine answers a refused
* snooze from the Snooze button by dismissing, which is right for a button
* the user can see is gone, and wrong for a press made with eyes closed: the
* alarm would end for good without the user knowing. Swallowing the key
* instead would take away the one thing it could still do. The caller also
* asks the engine to keep ringing (`AlarmEngine.snooze`'s
* `dismissIfRefused`), so a budget that ran out after this was decided does
* not end the alarm either.
*/
object VolumeButtonPolicy {
fun effect(
action: VolumeButtonAction,
canSnooze: Boolean,
challenge: DismissChallenge,
): VolumeKeyEffect = when (action) {
VolumeButtonAction.CONTROL_VOLUME -> VolumeKeyEffect.PASS_THROUGH
VolumeButtonAction.NOTHING -> VolumeKeyEffect.IGNORE
VolumeButtonAction.SNOOZE -> snoozeOrVolume(canSnooze)
VolumeButtonAction.DISMISS ->
if (challenge == DismissChallenge.NONE) VolumeKeyEffect.DISMISS else snoozeOrVolume(canSnooze)
}
fun effect(session: RingSession): VolumeKeyEffect =
effect(session.volumeButtons, session.canSnooze, session.settings.dismissChallenge)
private fun snoozeOrVolume(canSnooze: Boolean): VolumeKeyEffect =
if (canSnooze) VolumeKeyEffect.SNOOZE else VolumeKeyEffect.PASS_THROUGH
}
@@ -2,9 +2,14 @@ package de.jeanlucmakiola.clockula.domain.backup
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
import kotlin.time.Instant
@@ -35,6 +40,8 @@ data class BackupAlarm(
val volumeRampSeconds: Int?,
val dismissChallenge: DismissChallenge?,
val deleteAfterUse: Boolean,
val scheduledDate: LocalDate? = null,
val pause: PauseRange? = null,
)
data class BackupTimer(
@@ -56,4 +63,11 @@ data class BackupSettings(
val dynamicColor: Boolean,
val defaults: ClockDefaults,
val timerPresets: List<Duration>,
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
val screenSaver: ScreenSaverSettings,
/** What the quick-timer Quick Settings tile starts (#26). */
val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.clockula.domain.bedtime
import de.jeanlucmakiola.clockula.domain.alarm.AlarmOccurrences
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import kotlin.time.toJavaInstant
/** One night of bedtime: on from [start] (inclusive) to [end] (exclusive). */
data class BedtimeWindow(val start: Instant, val end: Instant) {
operator fun contains(instant: Instant): Boolean = instant >= start && instant < end
}
/**
* Where a bedtime schedule stands at a given instant. Like the alarm path,
* every window is re-resolved from local times and the zone, never by adding
* a fixed length to the last one, so a DST night is as long as the clock
* says it is.
*/
object BedtimeSchedule {
/**
* The window for the night of [date], if [date] is one of the schedule's
* nights. A night is named by its evening, as people name it: a bedtime
* in the small hours (before 06:00; 00:30, say) is *after* that evening,
* so "Thursday night" at 00:30 starts on Friday's calendar date. A later
* one (a day sleeper's 08:00) is on the day itself. It ends at the first wake time after it starts, so a wake time
* before the bedtime means the next morning. An equal bedtime and wake
* time is no window at all: read as a whole day, every night selected
* would join into a Do Not Disturb that never ends.
*/
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null
// Next morning or same day is decided on the local times, before DST
// resolution: a bedtime in a spring-forward gap shifts later and must
// not push a same-night wake time to the following day.
val startDate = if (settings.bedtime.minutesOfDay < SMALL_HOURS_END) date.plusDays(1) else date
val endDate =
if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1)
val start = AlarmOccurrences.at(startDate, settings.bedtime, zone)
val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone)
return if (end > start) BedtimeWindow(start, end) else null
}
/** The window [now] falls in, or null when it is daytime or the schedule is off. */
fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
if (!settings.enabled) return null
val today = today(now, zone)
// A window is at most a day long and starts on its night's date or
// the day after, so one that contains now belongs to one of the last
// three nights.
return listOf(today, today.minusDays(1), today.minusDays(2))
.firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
}
/** The first window that starts strictly after [now], within the coming week. */
fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
if (!settings.enabled) return null
val today = today(now, zone)
return (-1L..7L)
.firstNotNullOfOrNull { offset ->
windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
}
}
/**
* The upcoming window whose reminder is due at [now]: [now] lies between
* its reminder time and its start. Null when reminders are off, or when
* [now] is already inside a window (the reminder has nothing left to say).
*/
fun dueReminder(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
if (!settings.enabled || settings.reminderMinutes <= 0) return null
if (activeWindow(settings, now, zone) != null) return null
val next = nextWindow(settings, now, zone) ?: return null
return next.takeIf { now >= reminderAt(settings, next) }
}
/**
* When the schedule next needs attention: the end of the current window,
* the next window's reminder or the next window's start, whichever comes
* first. Null when there is nothing to schedule.
*/
fun nextEvent(settings: BedtimeSettings, now: Instant, zone: ZoneId): Instant? {
if (!settings.enabled) return null
val active = activeWindow(settings, now, zone)
val next = nextWindow(settings, now, zone)
val reminder = next
?.takeIf { settings.reminderMinutes > 0 }
?.let { reminderAt(settings, it) }
?.takeIf { it > now }
return listOfNotNull(active?.end, reminder, next?.start).minOrNull()
}
private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
window.start - settings.reminderMinutes.minutes
private const val SMALL_HOURS_END = 6 * 60
private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.clockula.domain.bedtime
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
/**
* The bedtime schedule (issue #1): an opt-in Do Not Disturb mode Clockula
* turns on at [bedtime] and off at [wakeTime]. [days] are the nights the
* mode *starts* on, so a Friday-only schedule runs from Friday evening into
* Saturday morning.
*/
data class BedtimeSettings(
val enabled: Boolean = false,
val bedtime: TimeOfDay = TimeOfDay(22, 30),
val wakeTime: TimeOfDay = TimeOfDay(7, 0),
val days: RepeatDays = RepeatDays.EVERY_DAY,
/** How long before [bedtime] to post a reminder; 0 is off. */
val reminderMinutes: Int = 0,
val effects: BedtimeEffects = BedtimeEffects(),
)
/**
* The screen effects a mode can carry on Android 15 and later
* (`ZenDeviceEffects`). Earlier versions keep the setting and ignore it.
*/
data class BedtimeEffects(
val grayscale: Boolean = true,
val dimWallpaper: Boolean = true,
val darkTheme: Boolean = true,
val hideAlwaysOnDisplay: Boolean = false,
)
object BedtimeLimits {
/** The reminder lead times the picker offers, in minutes; 0 is off. */
val REMINDER_CHOICES: List<Int> = listOf(0, 15, 30, 45, 60)
/** Anything read from disk or a backup is clamped into this. */
val REMINDER_MINUTES: IntRange = 0..120
/**
* The nights from a stored mask. The screen never lets the last night go,
* so an empty mask (corrupt, or written around it) reads as every night,
* on disk and in a backup alike.
*/
fun nights(mask: Int): RepeatDays = RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.ClockLimits
/** The bounds an extra from another process is read against (M9 D5, D19). */
object InteropLimits {
const val MAX_LABEL_LENGTH: Int = 256
@@ -12,7 +14,7 @@ object InteropLimits {
val TIMER_SECONDS: IntRange = 1..86_400
/** `ClockPrefs`' own clamp: an intent cannot ask for a snooze the user could not choose. */
val SNOOZE_MINUTES: IntRange = 1..60
val SNOOZE_MINUTES: IntRange = ClockLimits.SNOOZE_MINUTES
val RINGTONE_SCHEMES: List<String> = listOf("content://", "android.resource://")
}
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.clockula.domain.ring
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* How long a ring keeps going before it stops on its own: the "Silence after"
* setting, one for alarms and one for timers (#17). Stored as whole minutes,
* with [NEVER] for the option that has no user-facing limit.
*
* "Never" is not unbounded. The auto-silence backstop is also what stops a
* phone left at home from ringing until its battery is flat, and what keeps a
* reboot from resuming a ring that started hours ago, so "Never" rings until
* [HARD_STOP] — longer than any finite choice — and then stops as any other
* window would.
*/
object SilenceAfter {
/** The stored value for "Never". Zero, because no window can be zero minutes long. */
const val NEVER: Int = 0
/** The default, for alarms and timers alike: AOSP DeskClock's, and v1's fixed value. */
const val DEFAULT_MINUTES: Int = 10
/**
* The longest a ring ever lasts, in minutes, and what "Never" means in
* practice. The settings hints say "an hour" in words; change them with it.
*/
const val HARD_STOP_MINUTES: Int = 60
val HARD_STOP: Duration = HARD_STOP_MINUTES.minutes
/**
* How long the ring services hold their wake locks: a backstop on the
* backstop, past the longest window there is, so a ring is never cut
* short by its own wake lock.
*/
val WAKE_LOCK_TIMEOUT: Duration = HARD_STOP + 5.minutes
/** The range a stored value is measured against: below it is corrupt, and [sanitise] stores the hard stop itself as [NEVER]. */
val MINUTES: IntRange = NEVER..HARD_STOP_MINUTES
/** The choices the pickers offer, in order: Google Clock's, so nothing here surprises. */
val CHOICES: List<Int> = listOf(1, 5, 10, 15, 20, 25, NEVER)
/**
* What a stored value is read and written as, from a picker, a hand-edited
* file or a backup. The hard stop or longer is "Never", which rings that
* long anyway, so the picker never shows two names for one window. Negative is nothing anyone chose, so it is the default rather
* than the clamp's floor, which would be "Never".
*/
fun sanitise(minutes: Int): Int = when {
minutes < MINUTES.first -> DEFAULT_MINUTES
minutes >= HARD_STOP_MINUTES -> NEVER
else -> minutes
}
/** The ring window [minutes] stands for, sanitised first so the engines never see an unofferable value. */
fun window(minutes: Int): Duration {
val sane = sanitise(minutes)
return if (sane == NEVER) HARD_STOP else sane.minutes
}
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.domain.screensaver
import java.time.LocalDate
/**
* How the screen saver names the day of the next alarm. A weekday alone
* ("Thu") is only unambiguous for the coming six days: a weekly alarm whose
* time has passed today next rings in seven, and a skipped one in fourteen,
* and either would read as today's. Those carry their date as well.
*/
object ScreenSaverAlarmDay {
private const val DAYS_A_WEEKDAY_NAMES = 7L
fun weekdayIsEnough(fireDate: LocalDate, today: LocalDate): Boolean =
fireDate.isBefore(today.plusDays(DAYS_A_WEEKDAY_NAMES))
}
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.clockula.domain.screensaver
import kotlin.time.Instant
/**
* Where the screen saver's clock sits, so a face left up all night never
* burns into an OLED panel: it moves to a new spot once a minute, as AOSP's
* DeskClock screen saver does.
*
* A position is a pair of fractions of the *free* space — the screen minus
* the clock itself — so `0f` is flush with the left/top edge and `1f` with
* the right/bottom one, at any screen and clock size. Derived from the minute
* alone rather than from a random source, so the clock lands on the same spot
* however often the minute is re-read (a recomposition, a time change) and
* moves only when the minute does.
*/
object ScreenSaverDrift {
data class Position(val x: Float, val y: Float)
/** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */
fun positionAt(now: Instant): Position {
val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE)
val bits = mix(minute)
return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16))
}
/** The low 24 bits of [bits] as a fraction in `[0, 1]`. */
private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24
/**
* SplitMix64's finaliser: consecutive minutes land on unrelated spots,
* where a plain modulo would walk the clock across the screen in a line.
*/
private fun mix(seed: Long): Long {
var z = seed + GOLDEN_GAMMA
z = (z xor (z ushr 30)) * MIX_1
z = (z xor (z ushr 27)) * MIX_2
return z xor (z ushr 31)
}
private const val SECONDS_PER_MINUTE = 60L
private const val MASK_24 = 0xFFFFFFL
private const val GOLDEN_GAMMA = -0x61c8864680b583ebL
private const val MIX_1 = -0x40a7b892e31b1a47L
private const val MIX_2 = -0x6b2fb644ecceee15L
}
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.domain.screensaver
/** The screen saver's two faces: the time as numerals, or the world clock's dial. */
enum class ScreenSaverStyle { DIGITAL, ANALOG }
/**
* What the screen saver shows. [nightMode] is Google Clock's "very dim
* display, for dark rooms": dim colours on black and the display itself
* turned down, for a phone on a bedside table.
*/
data class ScreenSaverSettings(
val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL,
val nightMode: Boolean = false,
)
@@ -155,9 +155,9 @@ object SelfCheck {
/**
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
* alarms: Priority and Alarms-only modes leave the system's own alarm
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
* and never changes DND itself — it only reports what the user already has
* (`docs/PLAN.md` §4).
* exemption in effect. The check never changes DND itself — it only
* reports what the user already has (`docs/PLAN.md` §4). The opt-in
* bedtime mode does change it, with a rule that always allows alarms.
*/
private fun dnd(state: DeviceState): CheckResult {
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
@@ -186,21 +186,24 @@ object SelfCheck {
/**
* Compares Clockula's own computed next fire against what the system
* actually has registered. No alarm scheduled is not a problem; an
* unknown owner is given the benefit of the doubt (the platform does not
* always expose it), but a *confirmed* foreign owner, a missing system
* registration, or a mismatched time all mean the registration this app
* made is not the one the system will honour.
* actually has registered. The system reports only the earliest alarm
* clock of *any* app, so another app's alarm at or before Clockula's hides
* Clockula's registration rather than disproving it, and is not a problem.
* No alarm scheduled is not a problem either. A missing registration, our
* own at the wrong time, or anything later than our next fire all mean the
* registration this app made is not the one the system will honour.
*/
private fun nextAlarm(state: DeviceState): CheckResult {
val expected = state.expectedNextFire
val system = state.systemNextAlarm
val owner = state.systemNextAlarmOwnerPackage
val ok = when {
expected == null -> true
state.systemNextAlarm == null -> false
owner != null && owner != state.ownPackageName -> false
state.systemNextAlarm != expected -> false
else -> true
system == null -> false
system == expected -> true
system > expected -> false
// Earlier than ours: someone else's, unless the owner is confirmed as us.
else -> owner != state.ownPackageName
}
return CheckResult(
SelfCheckId.NEXT_ALARM,
@@ -0,0 +1,69 @@
package de.jeanlucmakiola.clockula.domain.tile
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter
import kotlin.time.Duration
import kotlin.time.Instant
/**
* What the quick-timer Quick Settings tile shows. The tile is the quick-timer
* widget's twin (`domain/widget/QuickTimerWidgetSettings.kt`): one duration,
* and the transient timer it last started while that one is still active.
*
* Nothing here ticks. A tile is redrawn only when the app asks it to be, so a
* running timer shows when it **ends** — an absolute wall-clock time that
* cannot go stale between the writes that refresh it — never a countdown.
*/
sealed interface QuickTimerTileModel {
/** Offering to start a timer of [duration]; a tap starts one. */
data class AtRest(val duration: Duration) : QuickTimerTileModel
/**
* Its timer [timerId] active. A tap resets it — which, for a transient
* timer, stops it (or its ring) and deletes it, as the widget's second
* tap does.
*/
sealed interface Active : QuickTimerTileModel {
val timerId: Long
}
/** Its timer counting down, due at [endsAt]. */
data class Running(override val timerId: Long, val endsAt: Instant) : Active
/** Its timer paused with [remaining] left. */
data class Paused(override val timerId: Long, val remaining: Duration) : Active
/** Its timer ringing. */
data class Expired(override val timerId: Long) : Active
}
object QuickTimerTilePresenter {
/**
* [timerId] is the timer the tile last started; once it is deleted or back
* to idle, the tile is at rest again. The same selection as the widget's,
* so the two can never disagree about whether a quick timer is "theirs".
*/
fun present(
duration: Duration,
timerId: Long?,
timers: List<Timer>,
elapsedRealtime: Duration,
wallClock: Instant,
): QuickTimerTileModel {
val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active
?: return QuickTimerTileModel.AtRest(duration)
return when (active.mode) {
// The stored end, which the engine re-anchors on a clock change,
// rather than one re-derived per read: two clocks read a moment
// apart would make every emission a "change".
TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, active.endsAtWallClock ?: (wallClock + active.remaining))
TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining)
TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id)
// Never active, so never reached; at rest is the honest reading.
TimerMode.IDLE -> QuickTimerTileModel.AtRest(duration)
}
}
}
@@ -40,4 +40,27 @@ interface ZoneProvider {
*/
interface Ticker {
fun ticks(period: Duration): Flow<Unit>
/**
* Emits once immediately, then just after every wall-clock instant that is
* a whole multiple of [unit] ([WallBoundaries.untilNext]). For a readout of
* whole seconds or minutes: it wakes exactly when the readout changes and
* not otherwise, so it is both on time and as rare as it can be.
*/
fun alignedTicks(unit: Duration): Flow<Unit>
}
/**
* The device's clock being changed under a running readout: the time set by
* hand or by the network, the zone, or the 12/24-hour choice, which the
* platform announces as a time change too. Emits once per change while
* collected. Injected, so a test hands the change over instead of making one.
*/
interface ClockChanges {
fun changes(): Flow<Unit>
}
/** The device's 12/24-hour choice, read afresh on every call. */
interface HourFormat {
fun is24Hour(): Boolean
}
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* How long a minute-resolution readout may sleep: until the next wall-clock
* minute begins, so it changes on the minute rather than up to a minute late,
* and wakes once a minute rather than on a fixed sub-second cadence.
*/
object MinuteBoundary {
private const val MILLIS_PER_MINUTE = 60_000L
/**
* From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a
* boundary is a whole minute away, never zero, so a caller looping on it
* cannot spin.
*/
fun untilNext(now: Instant): Duration {
val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE)
return (MILLIS_PER_MINUTE - intoMinute).milliseconds
}
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Where the next whole second (or minute) of the wall clock falls. A readout
* that shows whole units changes exactly at these instants, so waking there
* rather than on a free-running cadence is what keeps it on time without
* waking more often than it changes.
*/
object WallBoundaries {
/**
* How long from [now] until the next instant that is a whole multiple of
* [unit] since the epoch. Always in `(0, unit]`: standing exactly on a
* boundary waits for the *next* one, so a loop over this never spins.
*
* Every zone in use today is offset from UTC by whole minutes, so a
* boundary of the epoch is a boundary of every zone's local second and
* minute alike.
*/
fun untilNext(now: Instant, unit: Duration): Duration {
val unitMillis = unit.inWholeMilliseconds
require(unitMillis > 0) { "unit must be at least a millisecond: $unit" }
val into = Math.floorMod(now.toEpochMilliseconds(), unitMillis)
return (unitMillis - into).milliseconds
}
}
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.domain.timer
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import kotlin.time.Duration
import kotlin.time.Instant
/** What a deleted timer comes back as when the user taps Undo. Pure. */
object TimerDeletion {
/**
* The row an Undo restores, or null when there is nothing to restore.
*
* A running or paused timer comes back exactly as it was. Its anchors are
* on the monotonic clock, so a running one has kept counting down while it
* was gone, and one whose end passed in the meantime rings on restore, as
* it would have done had it never been deleted.
*
* A timer that had already *finished* is the exception. Deleting it
* silenced it, which is a dismissal, so it comes back reset rather than
* ringing a second time. A transient one (M9 D10) would have been deleted
* by that dismissal anyway, so there is nothing to undo.
*/
fun snapshot(timer: Timer, elapsedRealtime: Duration, wallClock: Instant): Timer? {
if (!timer.snapshotAt(elapsedRealtime, wallClock).hasExpired) return timer
if (timer.deleteAfterUse) return null
return timer.copy(
state = TimerState.IDLE,
remaining = timer.duration,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
}
}
@@ -22,10 +22,16 @@ data class TimerNotificationState(
/** One notification for however many timers: the subject, and a count (D17). */
object TimerNotificationPolicy {
/** Null when nothing is active. [active] is [TimerReadings.active]'s output. */
/**
* Null when nothing is active. [active] is [TimerReadings.active]'s output.
* [ringSuppressed] is a ringing alarm holding the timer's ring back: its
* heads-up waits too, rather than popping over the alarm's screen and
* then, when the alarm ends, a second time.
*/
fun stateFor(
active: List<TimerReading>,
alreadyAlerted: Boolean,
ringSuppressed: Boolean = false,
): TimerNotificationState? {
val subject = TimerReadings.subject(active) ?: return null
return TimerNotificationState(
@@ -46,7 +52,7 @@ object TimerNotificationPolicy {
actions = actionsFor(subject.mode),
// An expiry heads-ups exactly once; a countdown update never does,
// and there is no second notification and no channel gymnastics.
alertOnce = subject.mode != TimerMode.EXPIRED || alreadyAlerted,
alertOnce = subject.mode != TimerMode.EXPIRED || alreadyAlerted || ringSuppressed,
)
}
@@ -5,23 +5,27 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* The timer ring's constants. Values rather than settings: v1 does not offer
* them to the user (docs/PLAN.md §5, slice plan D11).
* The timer ring's constants. Values rather than settings (docs/PLAN.md §5,
* slice plan D11). How long an unacknowledged timer sounds before the noise
* stops became a setting in #17, `ClockDefaults.timerSilenceAfter`: its own
* rather than the alarm's, because coupling the two would make one number
* answer two questions. The ramp was one of them until it became
* `ClockDefaults.timerVolumeRampSeconds` (#22).
*/
object TimerRing {
/**
* How long an unacknowledged timer sounds before the noise stops (D11).
* The same value as the alarm's and its own constant, free to diverge:
* coupling the two would make one number answer two questions.
*/
val AUTO_SILENCE_AFTER: Duration = 10.minutes
/** What "+1 min" grants (D12). */
val ADD_TIME: Duration = 1.minutes
/** A timer's ring does not ramp: it is set by someone awake (D11). */
const val RAMP_SECONDS: Int = 0
/** The floor under every `delay` the service takes, so a rounding error cannot spin it. */
val WAKE_FLOOR: Duration = 100.milliseconds
/**
* How far into its ramp a ring is, from what is left of its [window]
* (the "Silence after" setting's, opened when the session first sounded). A ring
* restarted mid-session — handed to another expired timer, back after an
* alarm held it, or a recreated service — carries on from there rather than
* ramping again from a whisper. Zero for a session that has not sounded.
*/
fun rampElapsed(silenceIn: Duration?, window: Duration): Duration =
silenceIn?.let { (window - it).coerceAtLeast(Duration.ZERO) } ?: Duration.ZERO
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.clockula.domain.timer
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import kotlin.time.Duration
import kotlin.time.Instant
sealed interface TimerRingDecision {
@@ -16,8 +18,13 @@ sealed interface TimerRingDecision {
data class Sound(
val subjectId: Long,
val settings: TimerSettings,
/** When the window closes: the session anchor + [TimerRing.AUTO_SILENCE_AFTER]. */
/** When the window closes: the session anchor + [ClockDefaults.timerSilenceAfter]. */
val silenceAt: Instant,
/**
* The session anchor: when its audio opened. The session's identity
* (D10), which [silenceAt] is not, since "Silence after" can move it.
*/
val openedAt: Instant,
) : TimerRingDecision
}
@@ -29,6 +36,17 @@ data class TimerRingOutcome(
/** The whole audio arbitration, as a pure function of current state (D9–D11). */
object TimerRingPolicy {
/** Whether a session anchored at [soundingSince] has run out of [window] by [now]. The one rule, for every reader. */
fun isLapsed(soundingSince: Instant?, now: Instant, window: Duration): Boolean =
soundingSince != null && now - soundingSince >= window
/**
* What a lapsed session's anchor is stored as: moved back past the longest
* window there is, so it stays run out whatever "Silence after" becomes.
*/
fun lapsedAnchor(soundingSince: Instant, now: Instant): Instant =
minOf(soundingSince, now - SilenceAfter.HARD_STOP)
/**
* [expired] is `TimerReadings.expired`'s output, so its first element is
* already the subject (D10). Nothing here is an event: a timer suppressed
@@ -49,10 +67,12 @@ object TimerRingPolicy {
// Auto-silence before suppression: a window that has run out stays run
// out, and an alarm ringing over it must not hand it a fresh one.
if (soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER) {
val window = defaults.timerSilenceAfter
if (soundingSince != null && now - soundingSince >= window) {
return TimerRingOutcome(
TimerRingDecision.AutoSilenced(subject.timer.id),
soundingSince = soundingSince,
// Raising the setting later cannot start a silenced timer ringing again.
soundingSince = lapsedAnchor(soundingSince, now),
)
}
@@ -67,13 +87,14 @@ object TimerRingPolicy {
// The anchor is the instant the audio *opened*, so a second timer
// expiring mid-session inherits the window rather than buying a fresh
// ten minutes (D10).
// one (D10).
val anchor = soundingSince ?: now
return TimerRingOutcome(
TimerRingDecision.Sound(
subjectId = subject.timer.id,
settings = subject.timer.resolveSettings(defaults),
silenceAt = anchor + TimerRing.AUTO_SILENCE_AFTER,
silenceAt = anchor + window,
openedAt = anchor,
),
soundingSince = anchor,
)
@@ -12,11 +12,12 @@ data class TimerSettings(
/**
* The ringtone is the timer's own when it has one, the app default otherwise;
* vibration is app-wide, and the ramp is always zero — a timer's ring does not
* ramp, so the ramp is not a setting to inherit (D11, D27).
* vibration and the ramp are the timers' own app-wide settings, never the
* alarms' (D27, #22). Neither has a per-timer override.
*/
fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings(
ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri,
vibrate = defaults.vibrate,
rampSeconds = TimerRing.RAMP_SECONDS,
vibrate = defaults.timerVibrate,
rampSeconds = defaults.timerVolumeRampSeconds,
)
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.domain.widget
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Instant
@@ -19,10 +20,19 @@ data class NextAlarmWidgetModel(
val dayOfWeek: DayOfWeek? = null,
val isToday: Boolean = false,
val label: String = "",
/**
* The fire's date, when a weekday alone would be ambiguous: a week or more
* away, as a scheduled date or the end of a pause can put it. Null within
* the coming week, where "Thursday" can only mean one day.
*/
val date: LocalDate? = null,
)
object NextAlarmWidgetPresenter {
/** Today plus the six days after it: the span in which a weekday names one date. */
private const val DAYS_A_WEEKDAY_NAMES = 7L
/** The first [UpcomingAlarm] with a resolved fire time — `AlarmEngine.upcoming()`'s own sort already puts it first. */
fun present(upcoming: List<UpcomingAlarm>, now: Instant, zone: ZoneId): NextAlarmWidgetModel {
val next = upcoming.firstOrNull { it.nextFire != null } ?: return NextAlarmWidgetModel(hasAlarm = false)
@@ -36,6 +46,8 @@ object NextAlarmWidgetPresenter {
dayOfWeek = zonedFire.dayOfWeek,
isToday = zonedFire.toLocalDate() == zonedNow.toLocalDate(),
label = next.alarm.label,
date = zonedFire.toLocalDate()
.takeUnless { it.isBefore(zonedNow.toLocalDate().plusDays(DAYS_A_WEEKDAY_NAMES)) },
)
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.domain.worldclock
import de.jeanlucmakiola.clockula.domain.Zones
import java.time.ZoneId
import kotlin.time.Instant
object WorldClocks {
/**
@@ -39,3 +40,48 @@ object HomeZone {
?.let { runCatching { ZoneId.of(it) }.getOrNull() }
?: deviceZone
}
/**
* The automatic local clock (#24): the device's own zone as a temporary row
* while it is somewhere the time differs from home.
*
* It is Google Clock's "automatic home clock" turned round. Google's hero is
* local time and adds home as a row; Clockula's hero face *is* home, so home is
* already on screen while travelling and the clock that goes missing is the
* local one.
*/
object LocalClock {
/**
* [deviceZone] when the tab should show it as a temporary row, else null.
*
* "Different" means a different UTC offset **at [at]**, as Google decides
* it: Paris while home is Berlin shows no row, and neither does London in
* winter while home is Lisbon. It is asked again on every tick, so a DST
* change on either side moves it with the clock.
*
* [homeZone] is `HomeZone.resolve`'s answer. With no home stored it *is*
* [deviceZone], so a home that follows the device never shows a row.
*
* Null when [enabled] is off, when [deviceZone] is not a zone id the
* tzdata lists (a bare offset, which no row could be read against), and
* when [savedZoneIds] already holds a known zone at the device's offset:
* that row already shows the local time, and a second one would be a
* duplicate.
*/
fun zoneToShow(
enabled: Boolean,
homeZone: ZoneId,
deviceZone: ZoneId,
savedZoneIds: List<String>,
at: Instant,
): ZoneId? {
if (!enabled || !Zones.isValid(deviceZone.id)) return null
val deviceOffset = ZoneComparisons.utcOffsetMinutes(deviceZone, at)
if (ZoneComparisons.utcOffsetMinutes(homeZone, at) == deviceOffset) return null
val alreadyShown = savedZoneIds.any { id ->
Zones.isValid(id) && ZoneComparisons.utcOffsetMinutes(ZoneId.of(id), at) == deviceOffset
}
return deviceZone.takeUnless { alreadyShown }
}
}
@@ -57,13 +57,32 @@ object ZoneCatalog {
/**
* The last `/`-segment with underscores replaced by spaces, trimmed; the
* input trimmed when that is blank. Never throws.
* input trimmed when that is blank. `Etc/GMT±n` is the exception: it is
* named by its real offset instead. Never throws.
*/
fun cityFallbackFor(zoneId: String): String {
etcOffsetName(zoneId)?.let { return it }
val segment = zoneId.substringAfterLast('/').replace('_', ' ').trim()
return segment.ifBlank { zoneId.trim() }
}
/**
* `Etc/GMT-9` is UTC**+**9: POSIX signs these ids the other way round, so
* their last segment would name the zone with the wrong sign. A device set
* by UTC offset rather than by city (Android 13+ offers it) reports one, so
* the face and the local row name it by its real offset ("GMT+9").
*/
private fun etcOffsetName(zoneId: String): String? {
val match = ETC_OFFSET.matchEntire(zoneId.trim()) ?: return null
val hours = match.groupValues[2].toInt()
val sign = if (match.groupValues[1] == "-") 1 else -1
return ZoneOffsetFormat.gmtLabel(sign * hours * MINUTES_PER_HOUR)
}
private const val MINUTES_PER_HOUR = 60
private val ETC_OFFSET = Regex("""Etc/GMT([+-])(\d{1,2})""")
/** By `ZoneSearch.fold(city)` ascending, then [ZoneEntry.zoneId] ascending. */
fun sorted(entries: List<ZoneEntry>): List<ZoneEntry> =
entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId }))
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSchedule
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequest
import de.jeanlucmakiola.clockula.domain.interop.AlarmMatching
@@ -21,7 +22,9 @@ import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.flow.first
import java.time.ZoneId
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -73,7 +76,9 @@ class AlarmClockHandler @Inject constructor(
return InteropOutcome.OpenAlarmEditor(id)
}
val existing = alarms.alarms().first().filter { it.matches(spec) }.minByOrNull { it.id }
val now = wallClock.now()
val zone = zones.current()
val existing = alarms.alarms().first().filter { it.matches(spec, now, zone) }.minByOrNull { it.id }
if (existing != null) {
// "The alarm is always enabled", and a pending skip would silently
// eat the very occurrence the caller just asked for (D11). The
@@ -100,9 +105,18 @@ class AlarmClockHandler @Inject constructor(
return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.ALARMS)
}
/** The five fields the contract's extras can set, and nothing else (D11). */
private fun Alarm.matches(spec: AlarmSpec): Boolean =
time == spec.time &&
/**
* The five fields the contract's extras can set, and nothing else (D11) —
* on an alarm with no date and no pause. The contract cannot express
* either, so an alarm carrying one is not "identical": re-using it would
* either leave the caller's alarm silent until the pause ends, or throw
* away a holiday the user set. A new alarm costs neither. One that has
* gone by, and only waits to be cleared, no longer counts.
*/
private fun Alarm.matches(spec: AlarmSpec, now: Instant, zone: ZoneId): Boolean =
AlarmSchedule.upcomingDate(this, now, zone) == null &&
AlarmSchedule.currentPause(this, now, zone) == null &&
time == spec.time &&
repeatDays == spec.repeatDays &&
label == spec.label &&
ringtoneUri == spec.ringtoneUri &&
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.clockula.ring
import android.content.Context
import android.os.PowerManager
/**
* Keeps the CPU awake across the gap between an AlarmManager broadcast and the
* service it starts. The platform's own wake lock ends with the receiver, and
* the service takes its own only after a database read, so without this a
* dozing device can suspend in between and ring minutes late.
*
* Timed rather than released: the service holds its own lock well before the
* timeout, and a lock that outlives a crashed hand-off cannot leak.
*/
object HandoffWakeLock {
private const val TIMEOUT_MILLIS = 30_000L
fun acquire(context: Context, tag: String) {
val power = context.getSystemService(PowerManager::class.java) ?: return
runCatching {
power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag).apply {
setReferenceCounted(false)
acquire(TIMEOUT_MILLIS)
}
}
}
}
@@ -72,19 +72,26 @@ class RingAudioPlayer(private val context: Context) {
*
* [onSilenced] is called, at most once, when that chain found no sound at
* all; never after [stop].
*
* [rampFrom] picks the ramp up partway, for a ring that is restarted rather
* than new — a timer ring handed from one timer to another, or back after an
* alarm held it — so the volume carries on rather than dropping back to a
* whisper. Past the ramp's end it starts at full volume.
*/
fun start(
scope: CoroutineScope,
ringtoneUri: String?,
rampSeconds: Int,
onSilenced: () -> Unit,
rampFrom: Duration = Duration.ZERO,
) {
stop()
val ring = generation
val rampFor = rampSeconds.coerceAtLeast(0).seconds
val rampStart = rampFrom.coerceAtLeast(Duration.ZERO)
scope.launch {
val audible = startMedia(ring, scope, ringtoneUri, rampFor, onSilenced) ||
startPlayer(ring, scope, ringtoneUri, rampFor)
val audible = startMedia(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced) ||
startPlayer(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced)
if (!audible && ring == generation) onSilenced()
}
}
@@ -108,6 +115,7 @@ class RingAudioPlayer(private val context: Context) {
scope: CoroutineScope,
ringtoneUri: String?,
rampFor: Duration,
rampStart: Duration,
onSilenced: () -> Unit,
): Boolean {
val ref = MediaSources.decode(ringtoneUri) ?: return false
@@ -126,7 +134,7 @@ class RingAudioPlayer(private val context: Context) {
false
}
if (started && ring == generation) {
watchMedia(ring, scope, media, ringtoneUri, rampFor, onSilenced)
watchMedia(ring, scope, media, ringtoneUri, rampFor, rampStart, onSilenced)
return true
}
media.stop()
@@ -138,15 +146,23 @@ class RingAudioPlayer(private val context: Context) {
* Prepares on IO, because [MediaPlayer.prepare] is blocking I/O and the
* moment the alarm rings is the worst possible moment to block the main
* thread with it.
*
* A player that errors once it is playing (the media server died, the file
* went away) hands the ring to the sources after its own, unramped, and to
* [onSilenced] when none is left — a ring never goes quiet mid-alarm.
*/
private suspend fun startPlayer(
ring: Int,
scope: CoroutineScope,
ringtoneUri: String?,
rampFor: Duration,
rampStart: Duration,
onSilenced: () -> Unit,
from: Int = 0,
): Boolean {
if (ring != generation) return false
for (uri in sources(ringtoneUri)) {
for ((index, uri) in sources(ringtoneUri).withIndex()) {
if (index < from) continue
val prepared = withContext(Dispatchers.IO) { prepare(uri) } ?: continue
// Stopped while preparing: never start an obsolete ring.
if (ring != generation) {
@@ -154,7 +170,7 @@ class RingAudioPlayer(private val context: Context) {
return false
}
requestFocus()
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
val initial = VolumeRamp.levelAt(rampStart, rampFor)
val started = runCatching {
prepared.setVolume(initial, initial)
prepared.start()
@@ -165,7 +181,21 @@ class RingAudioPlayer(private val context: Context) {
continue
}
player = prepared
ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } }
prepared.setOnErrorListener { failed, _, _ ->
if (player === failed && ring == generation) {
ramp?.cancel()
ramp = null
player = null
release(failed)
abandonFocus()
scope.launch {
val next = startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced, index + 1)
if (!next && ring == generation) onSilenced()
}
}
true
}
ramp = launchRamp(scope, rampFor, rampStart) { level -> runCatching { player?.setVolume(level, level) } }
return true
}
return false
@@ -184,10 +214,11 @@ class RingAudioPlayer(private val context: Context) {
media: MediaAppRing,
ringtoneUri: String?,
rampFor: Duration,
rampStart: Duration,
onSilenced: () -> Unit,
) {
media.setLevel(VolumeRamp.levelAt(Duration.ZERO, rampFor))
ramp = launchRamp(scope, rampFor, media::setLevel)
media.setLevel(VolumeRamp.levelAt(rampStart, rampFor))
ramp = launchRamp(scope, rampFor, rampStart, media::setLevel)
watchdog = scope.launch {
var stall = MediaRingPolicy.Stall()
while (isActive && ring == generation) {
@@ -200,14 +231,19 @@ class RingAudioPlayer(private val context: Context) {
ramp = null
media.stop()
mediaRing = null
if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO) && ring == generation) onSilenced()
if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced) && ring == generation) onSilenced()
}
}
/** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] until [rampFor] has passed. */
private fun launchRamp(scope: CoroutineScope, rampFor: Duration, setLevel: (Float) -> Unit): Job =
/** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] from [from] until [rampFor] has passed. */
private fun launchRamp(
scope: CoroutineScope,
rampFor: Duration,
from: Duration,
setLevel: (Float) -> Unit,
): Job =
scope.launch {
var elapsed = Duration.ZERO
var elapsed = from
while (isActive && elapsed < rampFor) {
delay(RingAudio.RAMP_TICK)
elapsed += RingAudio.RAMP_TICK
@@ -79,7 +79,16 @@ class StopwatchService : Service() {
// Before any suspension point, and on the first path only: the platform
// gives a foreground service a few seconds to post its notification.
if (!wentForeground) startForeground(StopwatchNotifications.preparing(this))
if (!wentForeground) {
// A sticky restart after a process death has no user action behind
// it, and Android 12+ may refuse it a foreground start. The stored
// anchors keep the time, and the next resync brings the service back.
val started = runCatching { startForeground(StopwatchNotifications.preparing(this)) }.isSuccess
if (!started) {
stopSelf(startId)
return START_NOT_STICKY
}
}
if (collector == null) {
collector = scope.launch {
@@ -117,7 +126,6 @@ class StopwatchService : Service() {
}
private fun startForeground(notification: Notification) {
wentForeground = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// specialUse, and deliberately **not** systemExempted: that type's
// justification is alarm functionality continuing in the background,
@@ -131,6 +139,7 @@ class StopwatchService : Service() {
} else {
startForeground(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID, notification)
}
wentForeground = true
}
private fun stop() {
@@ -0,0 +1,95 @@
package de.jeanlucmakiola.clockula.tile
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.TextStyle
import javax.inject.Inject
/**
* The next-alarm Quick Settings tile (#26): "Next alarm" over the day and
* time of the next one, or "No alarms"; a tap opens the Alarms tab.
*
* It reads the next-alarm widget's own model ([NextAlarmWidgetPresenter]),
* so the tile, the widget and the Alarms tab cannot disagree about which
* alarm is next. The day is always the weekday's name, never "Today": an
* active tile is redrawn only when an alarm changes (`widget/WidgetSync.kt`)
* or the clock or zone does (`SystemEventReceiver`), and
* "Today" would go stale at midnight with nothing to redraw it.
*/
@AndroidEntryPoint
class NextAlarmTileService : TileService() {
@Inject lateinit var alarmEngine: AlarmEngine
@Inject lateinit var wallClock: WallClock
@Inject lateinit var zones: ZoneProvider
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listening: Job? = null
override fun onTileAdded() {
super.onTileAdded()
requestTileUpdate(this, NextAlarmTileService::class.java)
}
override fun onStartListening() {
super.onStartListening()
listening?.cancel()
listening = scope.launch {
alarmEngine.upcoming()
.map { NextAlarmWidgetPresenter.present(it, wallClock.now(), zones.current()) }
.collect(::show)
}
}
override fun onStopListening() {
listening?.cancel()
listening = null
super.onStopListening()
}
override fun onClick() {
super.onClick()
openApp(AlarmIntents.ACTION_SHOW_ALARMS)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun show(model: NextAlarmWidgetModel) {
val tile = qsTile ?: return
val label = getString(R.string.tile_next_alarm_label)
val day = model.dayOfWeek
if (!model.hasAlarm || day == null) {
render(tile, Tile.STATE_INACTIVE, label, getString(R.string.tile_next_alarm_empty), R.drawable.widget_ic_alarm_off)
return
}
val locale = resources.configuration.locales[0]
val subtitle = getString(
R.string.tile_next_alarm_subtitle,
day.getDisplayName(TextStyle.SHORT, locale),
tileTime(LocalTime.of(model.hour, model.minute)),
)
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.widget_ic_alarm)
}
}
@@ -0,0 +1,112 @@
package de.jeanlucmakiola.clockula.tile
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.timer.QuickTileTimer
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.time.ZonedDateTime
import javax.inject.Inject
import kotlin.time.toJavaInstant
/**
* The quick-timer Quick Settings tile (#26): the quick-timer widget's twin.
* At rest it shows the duration it starts; a tap starts a transient timer of
* that length. While that timer is active the tile is on and shows when it
* ends (or that it is paused, or done), and a tap resets it — all decided by
* [QuickTileTimer], which this only renders and forwards to.
*
* Works on a locked device, deliberately: starting or stopping a kitchen
* timer is something the timer notification already lets anyone do from the
* lock screen, and asking for an unlock first would make the tile slower
* than the notification it sits above. Long-pressing it opens
* `ui/tile/TilePreferencesActivity.kt`, where its duration is chosen.
*
* Starting the timer brings up its foreground service through the engine, as
* every other start does. Should the platform refuse that start, the timer
* still runs and still rings — the AlarmManager slot is the timekeeper, the
* service only its readout (`timer/android/ServiceTimerHandle.kt`).
*/
@AndroidEntryPoint
class QuickTimerTileService : TileService() {
@Inject lateinit var quickTile: QuickTileTimer
@Inject lateinit var zones: ZoneProvider
/** The tap outlives the binding: the panel may unbind the moment it collapses. */
@Inject
@ApplicationScope
lateinit var appScope: CoroutineScope
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listening: Job? = null
override fun onTileAdded() {
super.onTileAdded()
requestTileUpdate(this, QuickTimerTileService::class.java)
}
override fun onStartListening() {
super.onStartListening()
listening?.cancel()
listening = scope.launch { quickTile.model.collect(::show) }
}
override fun onStopListening() {
listening?.cancel()
listening = null
super.onStopListening()
}
override fun onClick() {
super.onClick()
appScope.launch { quickTile.onTap() }
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun show(model: QuickTimerTileModel) {
val tile = qsTile ?: return
val label = getString(R.string.tile_quick_timer_label)
when (model) {
is QuickTimerTileModel.AtRest -> {
val duration = ClockFormat.countdown(model.duration)
render(
tile,
Tile.STATE_INACTIVE,
label,
duration,
R.drawable.tile_ic_timer,
)
}
is QuickTimerTileModel.Running -> {
val endsAt = ZonedDateTime.ofInstant(model.endsAt.toJavaInstant(), zones.current()).toLocalTime()
val subtitle = getString(R.string.tile_quick_timer_until, tileTime(endsAt))
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
is QuickTimerTileModel.Paused -> {
val subtitle = getString(R.string.timer_paused_at, ClockFormat.countdown(model.remaining))
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
is QuickTimerTileModel.Expired -> {
val subtitle = getString(R.string.widget_timer_expired)
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
}
}
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.tile
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
import java.time.LocalTime
/**
* Opens the app on the shell request [action] names (`ui/shell/ShellNavigation`)
* and collapses the panel. On a locked device the user unlocks first: the app
* is not something to open over the keyguard, and the system would only ask
* for the unlock itself, after the panel had already gone.
*
* API 34 refuses the `Intent` overload of `startActivityAndCollapse` and takes
* a `PendingIntent` instead; everything below it only has the `Intent` one.
*/
internal fun TileService.openApp(action: String) {
// The widgets' own launch intent, so a tile and a widget land the same way.
val intent = openAppIntent(this, action)
val launch = Runnable {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startIntentAndCollapse(intent)
}
}
if (isLocked) unlockAndRun(launch) else launch.run()
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun TileService.startIntentAndCollapse(intent: Intent) = startActivityAndCollapse(intent)
/**
* Writes one rendering onto [tile]. The subtitle is API 29, which is
* `minSdk`. TalkBack reads the label, the subtitle and the on/off state the
* panel derives from [state], so there is no separate description to keep in
* step with them.
*/
internal fun TileService.render(
tile: Tile,
state: Int,
label: String,
subtitle: String,
icon: Int,
) {
tile.state = state
tile.label = label
tile.subtitle = subtitle
tile.icon = Icon.createWithResource(this, icon)
tile.updateTile()
}
/** The app's one 12/24-hour time format, as the next-alarm widget shows it. */
internal fun Context.tileTime(time: LocalTime): String = localTimeFormatter(this).format(time)
/**
* Asks the system to bind [service] and let it redraw. Both tiles are
* *active* tiles (`META_DATA_ACTIVE_TILE` in the manifest), so this is the
* only way they are ever refreshed outside a click — which is exactly the
* point: the panel opening costs the app nothing. `widget/WidgetSync.kt`
* calls it on every change the tiles mirror. A tile that is not in the
* panel makes this a no-op; a refusal is harmless for the same reason, so it
* never throws.
*/
internal fun requestTileUpdate(context: Context, service: Class<out TileService>) {
runCatching { TileService.requestListeningState(context, ComponentName(context, service)) }
}
/** Both tiles, for a change that moves what their times mean rather than the state behind them. */
internal fun requestAllTileUpdates(context: Context) {
requestTileUpdate(context, NextAlarmTileService::class.java)
requestTileUpdate(context, QuickTimerTileService::class.java)
}
@@ -0,0 +1,90 @@
package de.jeanlucmakiola.clockula.timer
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.QuickTileStateStore
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTilePresenter
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* The quick-timer Quick Settings tile's state and its one tap (#26), with no
* Android type in sight: `tile/QuickTimerTileService.kt` only renders [model]
* and forwards the click here.
*
* At rest a tap creates a transient timer at the tile's duration, remembers
* its id and starts it — the same create-then-start as `QuickTimerReceiver`
* and the Timers screen's quick start. On an active timer a tap resets it,
* which for a transient timer means stop (or stop ringing) and delete, so the
* tile is at rest again: the widget's second tap. Everything goes through the
* [TimerEngine], so the AlarmManager slot and the foreground service follow
* exactly as they do for every other path.
*/
@Singleton
class QuickTileTimer @Inject constructor(
private val timers: TimerRepository,
private val engine: TimerEngine,
private val settings: SettingsPrefs,
private val state: QuickTileStateStore,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
) {
/** Re-derived on every write that could change it — never on a tick. */
val model: Flow<QuickTimerTileModel> = combine(
settings.quickTileDuration,
state.timerId,
timers.timers(),
) { duration, timerId, all ->
QuickTimerTilePresenter.present(duration, timerId, all, elapsed.elapsedRealtime(), wall.now())
}
/**
* Serialised, so a double tap's second half sees the first half's timer
* instead of starting another one the tile could not show.
*/
suspend fun onTap(): Unit = mutex.withLock {
when (val current = model.first()) {
is QuickTimerTileModel.AtRest -> {
dropStrandedTimer()
val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true))
// Remembered before it starts, so a start that throws, or a
// process that dies in between, never leaves a running timer
// the tile can neither see nor stop. It also makes the start
// the last write, the one `WidgetSync` redraws the tile on.
state.setTimerId(id)
engine.start(id)
}
is QuickTimerTileModel.Active -> reset(current.timerId)
}
}
/**
* A transient timer the tile created but never got running — a start that
* threw, or a process that died in between — would sit idle in the Timers
* list for good, since only a reset deletes one and a reset of an idle
* timer does nothing. The next tap clears it before making another.
*/
private suspend fun dropStrandedTimer() {
val id = state.currentTimerId() ?: return
val timer = timers.find(id) ?: return
if (timer.deleteAfterUse && timer.state == TimerState.IDLE) engine.delete(id)
}
private suspend fun reset(timerId: Long) {
engine.reset(timerId)
state.setTimerId(null)
}
private val mutex = Mutex()
}
@@ -3,9 +3,11 @@ package de.jeanlucmakiola.clockula.timer
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.timer.TimerDeletion
import de.jeanlucmakiola.clockula.domain.timer.TimerExpiry
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationPolicy
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
@@ -14,9 +16,11 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.domain.timer.TimerRingPolicy
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
@@ -115,6 +119,23 @@ class TimerEngine @Inject constructor(
suspend fun serviceState(alreadyAlerted: Boolean): TimerServiceState =
lock.withLock { serviceStateLocked(alreadyAlerted) }
/**
* Writes the timers' "Silence after". A session that has already run out of
* the old window is recorded as lapsed first — even when no service pass was
* there to see it go — so raising the setting cannot start it sounding
* again. Not cancellable, so the write never lands without that record.
*/
suspend fun setTimerSilenceAfter(minutes: Int): Unit = withContext(NonCancellable) {
lock.withLock {
val anchor = ringState.current()
val now = wall.now()
if (anchor != null && TimerRingPolicy.isLapsed(anchor, now, settings.currentDefaults().timerSilenceAfter)) {
ringState.set(TimerRingPolicy.lapsedAnchor(anchor, now))
}
settings.setTimerSilenceAfterMinutes(minutes)
}
}
/** Starts IDLE/EXPIRED from the configured duration, resumes PAUSED. No-op when RUNNING. */
suspend fun start(timerId: Long): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
@@ -181,10 +202,31 @@ class TimerEngine @Inject constructor(
* Deletes, then resyncs — so a *ringing* timer cannot be deleted into a
* stuck ring (D2). It is on the engine and not on a ViewModel-plus-resync
* path deliberately: the wrong call should be impossible to write.
*
* Returns what an Undo would put back ([TimerDeletion.snapshot]), or null
* when there is nothing to put back. Non-cancellable, like [restore]: a
* caller cancelled half-way must not leave the slot or the service behind.
*/
suspend fun delete(timerId: Long): Unit = lock.withLock {
timers.delete(timerId)
resyncLocked()
suspend fun delete(timerId: Long): Timer? = lock.withLock {
withContext(NonCancellable) {
val timer = timers.find(timerId)
timers.delete(timerId)
resyncLocked()
timer?.let { TimerDeletion.snapshot(it, elapsed.elapsedRealtime(), wall.now()) }
}
}
/**
* The Undo: the row back under its own id, then the same resync every verb
* ends with, which hands the expiry slot and the service the restored timer.
* False when the id is taken, which writes nothing.
*/
suspend fun restore(timer: Timer): Boolean = lock.withLock {
withContext(NonCancellable) {
if (!timers.restore(timer)) return@withContext false
resyncLocked()
true
}
}
/**
@@ -266,7 +308,8 @@ class TimerEngine @Inject constructor(
*/
private suspend fun dropLapsedSessionLocked() {
val anchor = ringState.current() ?: return
if (wall.now() - anchor >= TimerRing.AUTO_SILENCE_AFTER) ringState.set(null)
val window = settings.currentDefaults().timerSilenceAfter
if (TimerRingPolicy.isLapsed(anchor, wall.now(), window)) ringState.set(null)
}
private suspend fun serviceStateLocked(alreadyAlerted: Boolean): TimerServiceState {
@@ -289,7 +332,11 @@ class TimerEngine @Inject constructor(
val sounding = outcome.decision as? TimerRingDecision.Sound
return TimerServiceState(
notification = TimerNotificationPolicy.stateFor(active, alreadyAlerted),
notification = TimerNotificationPolicy.stateFor(
active,
alreadyAlerted,
ringSuppressed = outcome.decision is TimerRingDecision.Suppressed,
),
ring = outcome.decision,
// Floored, so a rounding error cannot spin the service's loop (D20).
expiresIn = TimerExpiry.nextDeadline(rows, now, wallNow)
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.ring.HandoffWakeLock
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
@@ -28,6 +29,7 @@ class TimerExpiryReceiver : HiltBroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
HandoffWakeLock.acquire(context, "clockula:timer-expiry")
val pendingResult = goAsync()
scope.launch {
try {
@@ -14,9 +14,12 @@ import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.domain.timer.TimerSettings
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
@@ -34,6 +37,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
@@ -91,15 +95,18 @@ class TimerService : Service() {
/** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */
private var sounding: Long? = null
/** What [sounding] rings with, so a settings change mid-ring is applied rather than ignored. */
private var soundingSettings: TimerSettings? = null
/** Owned here, scoped to the ring session it was spent in: the rule itself is pure (D17). */
private var alreadyAlerted: Boolean = false
/**
* The auto-silence window the heads-up above was spent inside. A window is
* one ring session (D10), so a *new* session is a new event and gets its
* own one heads-up — even while an earlier timer stays EXPIRED.
* The anchor of the ring session the heads-up above was spent inside. A
* *new* session is a new event and gets its own one heads-up (D10) — even
* while an earlier timer stays EXPIRED.
*/
private var alertedWindow: Instant? = null
private var alertedSession: Instant? = null
/**
* `startForeground` is for going foreground, not for posting: every engine
@@ -136,7 +143,16 @@ class TimerService : Service() {
// a database read is not something to spend them on — but a service that
// is already foreground has a real notification up, and replacing it with
// the placeholder on every verb is a visible flicker (D6).
if (!wentForeground) startForeground(TimerNotifications.preparing(this))
if (!wentForeground) {
// A sticky restart after a process death has no user action behind
// it, and Android 12+ may refuse it a foreground start. The stored
// anchors keep the time, and the next resync brings the service back.
val started = runCatching { startForeground(TimerNotifications.preparing(this)) }.isSuccess
if (!started) {
stopSelf(startId)
return START_NOT_STICKY
}
}
if (collector == null) {
collector = scope.launch {
@@ -180,7 +196,7 @@ class TimerService : Service() {
return
}
applyRing(state.ring)
applyRing(state.ring, state.silenceIn)
post(notification, sounding = state.ring is TimerRingDecision.Sound)
scheduleDelays(state)
progressRefresh?.cancel()
@@ -195,8 +211,10 @@ class TimerService : Service() {
}
/**
* A ring session's window is its identity (D10), so when the window moves the
* flag that spends the session's one heads-up moves with it. The state is
* A ring session's anchor is its identity (D10), so when a new session opens
* the flag that spends the session's one heads-up moves with it. The anchor
* rather than the window's end, which a "Silence after" edit can move
* mid-session without making it a new one. The state is
* asked for again, because it was computed from the answer that has just
* changed — a second pass rather than a notification that says the right
* thing one trigger too late.
@@ -205,22 +223,25 @@ class TimerService : Service() {
// Only a sounding decision carries a window; a suppressed or silenced one
// must not clear the record, or an alarm ringing across a session would
// buy it a second heads-up (D9).
val window = (state.ring as? TimerRingDecision.Sound)?.silenceAt ?: return state
val reopened = alreadyAlerted && window != alertedWindow
alertedWindow = window
val session = (state.ring as? TimerRingDecision.Sound)?.openedAt ?: return state
val reopened = alreadyAlerted && session != alertedSession
alertedSession = session
if (!reopened) return state
alreadyAlerted = false
return engine.serviceState(alreadyAlerted)
}
private fun applyRing(decision: TimerRingDecision) {
private fun applyRing(decision: TimerRingDecision, silenceIn: Duration?) {
when (decision) {
is TimerRingDecision.Sound -> {
// An unchanged decision does nothing: restarting the player on
// every emission would stutter the ring.
if (sounding == decision.subjectId) return
// every emission would stutter the ring. A changed setting (the
// user switched timer vibration off mid-ring, say) restarts it,
// the ramp picking up where it was.
if (sounding == decision.subjectId && soundingSettings == decision.settings) return
releaseRing()
sounding = decision.subjectId
soundingSettings = decision.settings
acquireWakeLock()
// Vibration first, and the sound without waiting for it: a sound
// another app plays can take seconds to open, and this runs
@@ -232,6 +253,7 @@ class TimerService : Service() {
scope = scope,
ringtoneUri = decision.settings.ringtoneUri,
rampSeconds = decision.settings.rampSeconds,
rampFrom = TimerRing.rampElapsed(silenceIn, window = decision.silenceAt - decision.openedAt),
// Silence is not one of the outcomes: with no audio source at
// all the timer vibrates even when vibration is switched off.
onSilenced = vibrator::start,
@@ -295,7 +317,6 @@ class TimerService : Service() {
}
private fun startForeground(notification: Notification) {
wentForeground = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted, the same type and justification as the alarm's
// ringing service: an app holding an exact-alarm permission,
@@ -308,6 +329,7 @@ class TimerService : Service() {
} else {
startForeground(notificationId, notification)
}
wentForeground = true
}
private fun acquireWakeLock() {
@@ -322,6 +344,7 @@ class TimerService : Service() {
/** The whole ring released, not just the audio: an orphaned looping player cannot be stopped. */
private fun releaseRing() {
sounding = null
soundingSettings = null
audio.stop()
vibrator.stop()
wakeLock?.let { if (it.isHeld) runCatching { it.release() } }
@@ -354,8 +377,8 @@ class TimerService : Service() {
companion object {
private const val WAKE_LOCK_TAG = "clockula:timer-ring"
/** A backstop on the backstop: the ring window is ten minutes. */
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
/** A backstop on the backstop, shared with the other ring service. */
private val WAKE_LOCK_TIMEOUT_MILLIS = SilenceAfter.WAKE_LOCK_TIMEOUT.inWholeMilliseconds
fun start(context: Context) {
context.startForegroundService(Intent(context, TimerService::class.java))
@@ -0,0 +1,217 @@
package de.jeanlucmakiola.clockula.ui.alarms
import android.content.Context
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.AlarmDates
import de.jeanlucmakiola.clockula.domain.PauseRange
import java.time.LocalDate
import java.util.Formatter
import java.util.Locale
/**
* The M3 pickers speak UTC-midnight millis; the app speaks [LocalDate]. The
* translation is UTC on both sides, so a picked day is the same calendar day
* whatever zone the phone is in.
*/
internal object PickerDates {
private const val MILLIS_PER_DAY = 86_400_000L
fun toMillis(date: LocalDate): Long = date.toEpochDay() * MILLIS_PER_DAY
fun toDate(utcMillis: Long): LocalDate = LocalDate.ofEpochDay(Math.floorDiv(utcMillis, MILLIS_PER_DAY))
/** [earliest] onwards, and never past [AlarmDates.MAX]: nothing in the past can be picked. */
@OptIn(ExperimentalMaterial3Api::class)
fun from(earliest: LocalDate): SelectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val date = toDate(utcTimeMillis)
return !date.isBefore(earliest) && AlarmDates.isValid(date)
}
override fun isSelectableYear(year: Int): Boolean = year >= earliest.year
}
/**
* The picker's default years, stretched to [selected] when an imported
* date lies beyond them: an initial selection outside the range is
* refused by the picker state with a throw.
*/
fun yearRange(earliest: LocalDate, selected: LocalDate? = null): IntRange =
earliest.year..maxOf(DatePickerDefaults.YearRange.last, selected?.year ?: earliest.year)
}
/**
* Picks the one date a one-shot alarm rings on. [earliest] is the date of the
* alarm's next occurrence, so neither a past day nor today-after-the-time-has-
* gone can be chosen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ScheduleDatePickerDialog(
initial: LocalDate?,
earliest: LocalDate,
onConfirm: (LocalDate) -> Unit,
onRemove: (() -> Unit)?,
onDismiss: () -> Unit,
) {
val selectable = remember(earliest) { PickerDates.from(earliest) }
val start = initial?.takeUnless { it.isBefore(earliest) }
val state = rememberDatePickerState(
initialSelectedDateMillis = start?.let(PickerDates::toMillis),
initialDisplayedMonthMillis = PickerDates.toMillis(start ?: earliest),
yearRange = PickerDates.yearRange(earliest, start),
selectableDates = selectable,
)
val picked = state.selectedDateMillis
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = { picked?.let { onConfirm(PickerDates.toDate(it)) } },
enabled = picked != null,
) { Text(stringResource(R.string.action_ok)) }
},
dismissButton = { DismissButtons(onRemove = onRemove, onDismiss = onDismiss) },
) {
DatePicker(
state = state,
title = {
Text(
text = stringResource(R.string.editor_schedule),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
)
},
)
}
}
/**
* Picks the days a repeating alarm stays quiet on. A start with no end is a
* one-day pause. A pause already under way opens with its own start, so
* reopening it to check it and confirming does not rewrite the days already
* behind it. (Tapping a day starts a new range, as in any M3 range picker.)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PauseRangePickerDialog(
initial: PauseRange?,
today: LocalDate,
onConfirm: (PauseRange) -> Unit,
onRemove: (() -> Unit)?,
onDismiss: () -> Unit,
) {
val start = initial?.start
val end = initial?.end
val earliest = start?.let { minOf(it, today) } ?: today
val selectable = remember(earliest) { PickerDates.from(earliest) }
val state = rememberDateRangePickerState(
initialSelectedStartDateMillis = start?.let(PickerDates::toMillis),
initialSelectedEndDateMillis = end?.let(PickerDates::toMillis),
initialDisplayedMonthMillis = PickerDates.toMillis(maxOf(start ?: today, today)),
yearRange = PickerDates.yearRange(earliest, end),
selectableDates = selectable,
)
val pickedStart = state.selectedStartDateMillis
val pickedEnd = state.selectedEndDateMillis ?: pickedStart
// A begun pause may keep its start in the past, but a range that is over
// before today would pause nothing, so it cannot be confirmed.
val confirmable = pickedEnd != null && !PickerDates.toDate(pickedEnd).isBefore(today)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
if (pickedStart != null && pickedEnd != null) {
onConfirm(PauseRange.ordered(PickerDates.toDate(pickedStart), PickerDates.toDate(pickedEnd)))
}
},
enabled = confirmable,
) { Text(stringResource(R.string.action_ok)) }
},
dismissButton = { DismissButtons(onRemove = onRemove, onDismiss = onDismiss) },
) {
DateRangePicker(
state = state,
modifier = Modifier.weight(1f),
title = {
Text(
text = stringResource(R.string.editor_pause),
modifier = Modifier.padding(start = 64.dp, end = 12.dp, top = 16.dp),
)
},
)
}
}
/** Cancel, and — once there is something to take away — Remove beside it. */
@Composable
private fun DismissButtons(onRemove: (() -> Unit)?, onDismiss: () -> Unit) {
Row {
if (onRemove != null) {
TextButton(onClick = onRemove) { Text(stringResource(R.string.action_remove)) }
}
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
}
/** "Wed, Oct 28" in the reader's locale; the year only when it is not this one. */
@Composable
internal fun scheduledDateText(date: LocalDate): String {
val context = LocalContext.current
val configuration = LocalConfiguration.current
return remember(date, configuration) { AlarmDateText.weekdayDate(context, Locale.getDefault(), date) }
}
/** "Oct 26 – 28", "Oct 30 – Nov 2" or "Oct 26": the platform's own localised range. */
@Composable
internal fun pauseRangeText(pause: PauseRange): String = formatDates(pause.start, pause.end, 0)
@Composable
private fun formatDates(start: LocalDate, end: LocalDate, extraFlags: Int): String {
val context = LocalContext.current
val configuration = LocalConfiguration.current
return remember(start, end, extraFlags, configuration) {
AlarmDateText.format(context, Locale.getDefault(), start, end, extraFlags)
}
}
/** The platform's localised dates, for the list, the editor and the widget alike. */
internal object AlarmDateText {
/** "Wed, Oct 28"; the year only when it is not this one. */
fun weekdayDate(context: Context, locale: Locale, date: LocalDate): String =
format(context, locale, date, date, DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_ABBREV_WEEKDAY)
fun format(context: Context, locale: Locale, start: LocalDate, end: LocalDate, extraFlags: Int): String {
// Noon, not midnight: an end at exactly midnight reads to the platform
// as "up to the start of that day" and would lose the range's last day.
val noon = DateUtils.HOUR_IN_MILLIS * 12
return DateUtils.formatDateRange(
context,
Formatter(StringBuilder(), locale),
PickerDates.toMillis(start) + noon,
PickerDates.toMillis(end) + noon,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_MONTH or extraFlags,
"UTC",
).toString()
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.ui.alarms
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
@@ -7,13 +8,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -21,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerScreen
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -39,9 +40,10 @@ import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import java.time.LocalDate
/** Which override picker is open. Null is "none"; the ringtone picker is the ViewModel's. */
private enum class OpenPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
private enum class OpenPicker { SCHEDULE_DATE, PAUSE, VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
/**
* The alarm editor: every control writes at once, so there is no Save button,
@@ -57,8 +59,6 @@ fun AlarmEditorScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle()
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
val formatTime = rememberAlarmTimeFormatter()
// Registered unconditionally, and deliberately **not** inside the
// `picker?.let` below: the picker's open flag is ViewModel state that does
@@ -79,14 +79,35 @@ fun AlarmEditorScreen(
if (state is AlarmEditorUiState.Gone) onBack()
}
val context = LocalContext.current
LaunchedEffect(viewModel) {
// Delete is refused while the alarm rings; its ring screen, with the
// dismiss challenge, is the way out, as it is from the list's card.
viewModel.openRingScreen.collect {
context.startActivity(
Intent(context, AlarmRingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
CollapsingScaffold(
title = stringResource(R.string.editor_title),
onBack = onBack,
modifier = modifier,
predictiveBack = true,
// In the snackbar slot, so it follows the predictive-back preview, and
// lifted over the live pill, which floats above every screen.
snackbarHost = {
AlarmSetChipBand(
notice = viewModel.alarmSetNotice,
onShown = viewModel::onAlarmSetNoticeShown,
modifier = Modifier.padding(bottom = LocalLivePillInset.current),
)
},
actions = {
if (state is AlarmEditorUiState.Editing) {
IconButton(onClick = { confirmingDelete = true }) {
// No confirmation: the list underneath offers an Undo instead.
IconButton(onClick = viewModel::onDelete) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.editor_delete),
@@ -103,18 +124,6 @@ fun AlarmEditorScreen(
)
}
val editing = state as? AlarmEditorUiState.Editing
if (confirmingDelete && editing != null) {
DeleteConfirmation(
time = formatTime(editing.time),
onConfirm = {
confirmingDelete = false
viewModel.onDelete()
},
onDismiss = { confirmingDelete = false },
)
}
picker?.let { pickerState ->
val editing = state as? AlarmEditorUiState.Editing
RingtonePickerScreen(
@@ -144,22 +153,47 @@ private fun AlarmEditorContent(
// about losing a half-entered time).
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
// True until the picker a new alarm opens on is first closed: cancelling
// that one still leaves a freshly set alarm to announce (#19).
var pickingNewAlarm by rememberSaveable { mutableStateOf(openTimePicker) }
// Epoch days, so they survive a rotation; read from the clock when a date
// picker opens, never from a render that may be minutes old.
var earliestDay by rememberSaveable { mutableStateOf(editing.earliestScheduleDate.toEpochDay()) }
var todayDay by rememberSaveable { mutableStateOf(editing.today.toEpochDay()) }
val openDatePicker = { picker: OpenPicker ->
val bounds = viewModel.datePickerBounds()
earliestDay = bounds.earliestScheduleDate.toEpochDay()
todayDay = bounds.today.toEpochDay()
viewModel.onOpenDatePicker()
openPicker = picker
}
TimeCard(time = editing.time, onClick = { pickingTime = true })
Spacer(Modifier.height(16.dp))
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
GroupedSectionHeader(stringResource(R.string.editor_repeat))
// The days, then the date a one-shot rings on — or, for a repeating
// alarm, its pause and the skip: one group, so the three ways of saying
// "not that day" sit together.
val repeatRows = if (editing.canSkipNext) 3 else 2
RepeatDaySelector(
repeatDays = editing.repeatDays,
onDayToggled = viewModel::onDayToggled,
position = if (editing.canSkipNext) Position.Top else Position.Alone,
position = positionOf(0, repeatRows),
)
if (editing.canSkipNext) {
GroupedRow(
title = stringResource(R.string.editor_pause),
summary = editing.pause?.let { pauseRangeText(it) }
?: stringResource(R.string.editor_pause_none),
position = positionOf(1, repeatRows),
onClick = { openDatePicker(OpenPicker.PAUSE) },
)
GroupedRow(
title = stringResource(R.string.editor_skip_next),
summary = stringResource(R.string.editor_skip_next_summary),
position = Position.Bottom,
position = positionOf(2, repeatRows),
trailing = {
Switch(
checked = editing.skipNextOccurrence,
@@ -168,6 +202,14 @@ private fun AlarmEditorContent(
},
onClick = { viewModel.onSkipNextChanged(!editing.skipNextOccurrence) },
)
} else {
GroupedRow(
title = stringResource(R.string.editor_schedule),
summary = editing.scheduledDate?.let { scheduledDateText(it) }
?: stringResource(R.string.editor_schedule_none),
position = positionOf(1, repeatRows),
onClick = { openDatePicker(OpenPicker.SCHEDULE_DATE) },
)
}
GroupedSectionHeader(stringResource(R.string.editor_section_sound))
@@ -225,12 +267,47 @@ private fun AlarmEditorContent(
onConfirm = { hour, minute ->
viewModel.onTimePicked(hour, minute)
pickingTime = false
pickingNewAlarm = false
},
onDismiss = {
if (pickingNewAlarm) viewModel.onNewAlarmTimeKept()
pickingTime = false
pickingNewAlarm = false
},
onDismiss = { pickingTime = false },
)
}
when (openPicker) {
null -> Unit
OpenPicker.SCHEDULE_DATE -> ScheduleDatePickerDialog(
initial = editing.scheduledDate,
earliest = LocalDate.ofEpochDay(earliestDay),
onConfirm = {
viewModel.onScheduledDateChosen(it)
openPicker = null
},
onRemove = editing.scheduledDate?.let {
{
viewModel.onScheduledDateChosen(null)
openPicker = null
}
},
onDismiss = { openPicker = null },
)
OpenPicker.PAUSE -> PauseRangePickerDialog(
initial = editing.pause,
today = LocalDate.ofEpochDay(todayDay),
onConfirm = {
viewModel.onPauseChosen(it)
openPicker = null
},
onRemove = editing.pause?.let {
{
viewModel.onPauseChosen(null)
openPicker = null
}
},
onDismiss = { openPicker = null },
)
OpenPicker.VIBRATE -> VibratePicker(
stored = editing.vibrate.value.takeUnless { editing.vibrate.inherited },
default = editing.defaults.vibrate,
@@ -325,23 +402,3 @@ private fun SoundRow(
onClick = onClick,
)
}
/**
* A confirmation that names the alarm, deliberately instead of an undo chip:
* an accidentally deleted alarm is discovered at 07:00, not now, so the cheap
* moment to ask is before (M5 D29).
*/
@Composable
private fun DeleteConfirmation(time: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.editor_delete_title)) },
text = { Text(stringResource(R.string.editor_delete_message, time)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
},
)
}

Some files were not shown because too many files have changed in this diff Show More