Compare commits
137
Commits
d48c2e68fa
...
caa8c50db3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa8c50db3 | ||
|
|
d957fdf23d | ||
|
|
c2a583fda7 | ||
|
|
df8be5e158 | ||
|
|
a0a1daa920 | ||
|
|
813bb52c03 | ||
|
|
fd7f5e8dcd | ||
|
|
48d09f7371 | ||
|
|
6f615be4ad | ||
|
|
a186e5afcd | ||
|
|
494e46a0c8 | ||
|
|
d81cd04b60 | ||
|
|
333a23d6be | ||
|
|
440d098c77 | ||
|
|
ea8e159b39 | ||
|
|
6aa046515c | ||
|
|
9c9b6171e0 | ||
|
|
2191166035 | ||
|
|
817fe77b17 | ||
|
|
74c0c278ef | ||
|
|
131a28c1be | ||
|
|
1a5552a221 | ||
|
|
8b182c999b | ||
|
|
b701859156 | ||
|
|
1a443c066f | ||
|
|
2dce78281a | ||
|
|
119197106b | ||
|
|
47165ce93f | ||
|
|
83a77c1bb0 | ||
|
|
69b436cffd | ||
|
|
bdcf780028 | ||
|
|
1559e68b2f | ||
|
|
2927585608 | ||
|
|
b8968f5e19 | ||
|
|
0218339161 | ||
|
|
dc23618c56 | ||
|
|
da16ab23cc | ||
|
|
99c052f94c | ||
|
|
26a2b78225 | ||
|
|
fe1c2a3d48 | ||
|
|
5c6ec88bb6 | ||
|
|
94f09e9e5a | ||
|
|
cb5cd5123b | ||
|
|
0213af1260 | ||
|
|
95bb2d14ec | ||
|
|
af70ee1b6a | ||
|
|
cba16c940c | ||
|
|
be2c0ada04 | ||
|
|
ba1172b822 | ||
|
|
4765b8e088 | ||
|
|
40c71a49f7 | ||
|
|
23f2ee94ea | ||
|
|
3b488ef6fc | ||
|
|
ddec2d8ed1 | ||
|
|
712b606779 | ||
|
|
9839520b7b | ||
|
|
beeca29f74 | ||
|
|
d744fa8d98 | ||
|
|
7adda73a79 | ||
|
|
5ad9bd02d6 | ||
|
|
c5de9becdf | ||
|
|
da9854abf8 | ||
|
|
fdb725e64c | ||
|
|
1e29226364 | ||
|
|
5f3b97b32a | ||
|
|
301ce2fec9 | ||
|
|
a88e696d52 | ||
|
|
697678b226 | ||
|
|
247dbcb751 | ||
|
|
93f6fdf98d | ||
|
|
b4af164826 | ||
|
|
b79b38bd2b | ||
|
|
62da327771 | ||
|
|
1dcd743e5b | ||
|
|
f570bdefb7 | ||
|
|
40b4c9e2e4 | ||
|
|
1253c0fc55 | ||
|
|
471cbdabd8 | ||
|
|
f46b7e1e37 | ||
|
|
af12b7bb34 | ||
|
|
1333001aa9 | ||
|
|
dedcbd5dfd | ||
|
|
b1a45db54a | ||
|
|
ef26d433e1 | ||
|
|
755150881b | ||
|
|
5b35ef65d4 | ||
|
|
f5388042c6 | ||
|
|
2feed2ee35 | ||
|
|
e43339c386 | ||
|
|
d70d0c78da | ||
|
|
706a384704 | ||
|
|
78da94d775 | ||
|
|
6239ca0477 | ||
|
|
d2e73901c1 | ||
|
|
17a44369b6 | ||
|
|
6d235e0974 | ||
|
|
1689794da6 | ||
|
|
9516dc1198 | ||
|
|
058fc27749 | ||
|
|
71875e95e4 | ||
|
|
0b7af0d57b | ||
|
|
ff49667d39 | ||
|
|
820317f9d9 | ||
|
|
7a86e5488e | ||
|
|
4cb4b6b6eb | ||
|
|
d2bf4349c5 | ||
|
|
cc2099c40a | ||
|
|
adc5a3bdd6 | ||
|
|
fb875ca71c | ||
|
|
fa5ce55503 | ||
|
|
65c86af313 | ||
|
|
84d31fe7f8 | ||
|
|
1c323d5b4c | ||
|
|
207475ef37 | ||
|
|
a4cec23e5c | ||
|
|
8910ab7fbf | ||
|
|
412e54db09 | ||
|
|
58c9bfebb1 | ||
|
|
fbc3a192bf | ||
|
|
785adf715b | ||
|
|
3f431a7f32 | ||
|
|
cac58a98db | ||
|
|
3003f75333 | ||
|
|
805da39074 | ||
|
|
85c12811b0 | ||
|
|
9af3d2b515 | ||
|
|
1823e885a1 | ||
|
|
3a9b7e16ea | ||
|
|
f9f2bdc9a8 | ||
|
|
7ca8d54b4a | ||
|
|
d7d958971f | ||
|
|
c99106321a | ||
|
|
9cd40d6813 | ||
|
|
99b357f4fb | ||
|
|
96fc9c19cf | ||
|
|
b883554dc4 | ||
|
|
4d29669222 |
+56
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -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
|
||||
|
||||
+17
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+14
@@ -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
Reference in New Issue
Block a user