replica(uhr): architecture and schema

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 14:58:56 +02:00
co-authored by Claude Opus 5.5
parent 3c9535e1d5
commit c163a6d578
2 changed files with 145 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# Architecture: Uhr (Android, ohne Google)
Basis: `recon.md`, `features.csv`. Same conventions as `wetter/`: one native app, no server, no accounts.
**No network permission at all.** World clock cities come from the system time zone database, sounds from the device and the app.
## Stack
| Schicht | Wahl | warum |
| --- | --- | --- |
| Sprache/UI | Kotlin + Jetpack Compose, Material 3 Expressive | same as Wetter, shared look and know-how |
| Persistenz | Room (alarms, instances, timers, cities) + DataStore (settings, stopwatch, bedtime) | relational where it matters, key-value for singletons |
| Alarme | `AlarmManager.setAlarmClock()` | exempt from Doze, shows the alarm icon in the status bar, the only API that is reliable for wake-up alarms |
| Klingeln | foreground service (type `systemExempted`/`mediaPlayback`) + full-screen-intent notification → ringing activity over the lock screen | the pattern Android expects for alarm clocks |
| Timer/Stoppuhr | state in DB/DataStore, `end_at` / `elapsedRealtime` based; foreground service with live notification only while running | survives process death; no ticking in the background |
| Widgets | Jetpack Glance + RemoteViews `TextClock`/`AnalogClock` | clocks tick without app updates |
| Bildschirmschoner | `DreamService` | system screensaver API |
| Ortsliste Weltuhr | `java.time.ZoneId` + localized names from ICU (`android.icu.util.TimeZone` display names, exemplar cities) | offline, no city API needed |
| Töne | `RingtoneManager` (system alarm sounds) + own files via `ACTION_OPEN_DOCUMENT` (persisted URI permission) + bundled CC0 sounds | no streaming services |
| Build/Verteilung | Gradle KTS, minSdk 29, targetSdk 36; release via `scripts/release.sh uhr` (Obtainium) | same pipeline as Wetter |
## Permissions
| permission | why |
| --- | --- |
| `USE_EXACT_ALARM` (API 33+), `SCHEDULE_EXACT_ALARM` (≤32) | alarm clock apps may use exact alarms without asking |
| `USE_FULL_SCREEN_INTENT` | ringing screen over the lock screen (Android 14+: check `canUseFullScreenIntent`, guide the user to the setting) |
| `POST_NOTIFICATIONS` | upcoming, snoozed, missed, timer, stopwatch |
| `FOREGROUND_SERVICE` (+ type) | ringing, running timers |
| `RECEIVE_BOOT_COMPLETED` | reschedule after reboot |
| `VIBRATE`, `WAKE_LOCK` | ringing |
No `INTERNET`, no location.
## Schema
See `schema.sql`: 4 tables (`alarms`, `alarm_instances`, `timers`, `cities`), singletons in DataStore. Access rules: single user, app-private storage, no RLS needed.
Hard constraints: one bedtime wake alarm (partial unique index); a running timer always has `end_at` (check constraint); one instance row per alarm (primary key).
## Interne API (Repositories / Use Cases)
| Aufruf | macht | Flow |
| --- | --- | --- |
| `AlarmRepo.observe()` | alarms + next instance, sorted by time | F01 |
| `AlarmRepo.upsert(alarm)` | save, then `Scheduler.reschedule(alarm)` | F01, F02 |
| `AlarmRepo.delete(id)` / `undo` | delete, cancel PendingIntent | F02 |
| `Scheduler.reschedule(alarm)` | compute next fire (days, skip_until, DST-safe via `ZonedDateTime`), write instance, `setAlarmClock` | all |
| `Scheduler.rescheduleAll()` | on boot, `TIME_SET`, `TIMEZONE_CHANGED`, app update | — |
| `Ringing.start(alarmId)` | foreground service: sound (gradual volume), vibration, full-screen activity, silence-after timeout | F03 |
| `Ringing.snooze()` / `dismiss()` | instance → snoozed (fire_at + snooze) / next occurrence | F03 |
| `AlarmRepo.dismissNext(id)` | skip the upcoming ring only | F04 |
| `TimerRepo.add/pause/resume/addMinute/reset/delete` | state transitions with `end_at`, schedule `setExactAndAllowWhileIdle` for the end | F05 |
| `Stopwatch.start/stop/lap/reset` | DataStore singleton | F06 |
| `CityRepo.search(q)` / `add` / `reorder` / `delete` | offline zone search | F07 |
| `Bedtime.save(schedule)` | writes bedtime + the wake alarm (`is_bedtime_wake`) + reminder alarm | F08 |
| `Sounds.list()` / `preview(uri)` | ringtones + bundled + picked files | F09 |
### Background
| Auslöser | macht |
| --- | --- |
| `setAlarmClock` fire → `AlarmReceiver` | start ringing service |
| 2 h before an alarm (inexact alarm) | "upcoming alarm" notification with "Dismiss now" |
| silence-after timeout | stop ringing, instance → missed, missed notification |
| `BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`, `MY_PACKAGE_REPLACED` | `rescheduleAll()` |
| timer end (exact alarm) | timer ringing |
| bedtime reminder (exact) | reminder notification |
## Was beißt
- **Time zones and DST:** alarms are wall-clock; the next fire is computed with `ZonedDateTime` in the current zone, so 7:00 stays 7:00 after a zone change; a time skipped by DST rings at the first valid minute after.
- **Doze / OEM battery killers:** only `setAlarmClock` is reliable; show a one-time hint for battery optimization on aggressive OEMs.
- **Android 14+ full-screen intent:** may be off for sideloaded apps → detect and explain, fall back to a heads-up notification that still rings.
- **Race conditions:** snooze and dismiss from the notification and the activity at the same time → all transitions go through one `Mutex` in the ringing service.
- **Process death:** timers and stopwatch keep only timestamps, never counters.
- **Two alarms at the same time:** ring both labels in one screen; the second does not cancel the first.
- **Sounds:** a picked file can disappear → fall back to the default sound, never ring silently by accident.
- **No network:** nothing to rate-limit, nothing to leak; app is F-Droid friendly.
## Build-Reihenfolge
1. **Vertical slice** (S01, S03, S11): add an alarm, it rings at the time with sound over the lock screen, stop. Tables `alarms`, `alarm_instances`. Proves AlarmManager + full-screen intent.
2. **Must alarms** (S02, S04, S15): expanded card, days, label, vibrate, sound picker, snooze, delete/undo, snoozed notification, settings for snooze/silence-after, reboot/time-change handling.
3. **Timer + stopwatch** (S08, S09, S12): keypad, multiple timers, ring progress, background notifications, laps. Table `timers`.
4. **World clock** (S06, S07): local time, cities offline, reorder/delete, analog/digital. Table `cities`.
5. **Design pass**: expressive cards, bottom bar, big time typography, slide-to-act.
6. **Widgets** (S16): digital, analog, next alarm, style options like Wetter.
7. **Should**: pause alarm, dismiss early, upcoming/missed notifications, gradual volume, volume buttons, bedtime (S10), own sound files, settings (S13).
8. **Could**: sunrise alarm, sleep sounds, screensaver (S14), world clock widget, automatic home clock.
+57
View File
@@ -0,0 +1,57 @@
-- Local SQLite schema (Room). No server, no accounts, no network: all data stays on the device.
-- Instants are INTEGER epoch milliseconds UTC. Alarm times are wall-clock (hour, minute) in the device zone.
CREATE TABLE alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
minute INTEGER NOT NULL CHECK (minute BETWEEN 0 AND 59),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
days INTEGER NOT NULL DEFAULT 0 CHECK (days BETWEEN 0 AND 127), -- bit 0 = Monday … bit 6 = Sunday; 0 = once
label TEXT NOT NULL DEFAULT '',
ringtone_uri TEXT, -- NULL = default alarm sound, 'silent' = none
vibrate INTEGER NOT NULL DEFAULT 1 CHECK (vibrate IN (0,1)),
skip_until TEXT, -- ISO date: paused until (inclusive)
is_bedtime_wake INTEGER NOT NULL DEFAULT 0 CHECK (is_bedtime_wake IN (0,1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX alarms_time ON alarms(hour, minute);
CREATE UNIQUE INDEX alarms_one_bedtime ON alarms(is_bedtime_wake) WHERE is_bedtime_wake = 1;
-- The next concrete ring of an alarm. Exactly one row per enabled alarm; rewritten after every change,
-- reboot, time or time zone change. Snooze and "dismiss early" live here, not on the alarm.
CREATE TABLE alarm_instances (
alarm_id INTEGER PRIMARY KEY REFERENCES alarms(id) ON DELETE CASCADE,
fire_at INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'scheduled'
CHECK (state IN ('scheduled','snoozed','ringing','missed','dismissed')),
updated_at INTEGER NOT NULL
);
CREATE INDEX alarm_instances_fire ON alarm_instances(state, fire_at);
CREATE TABLE timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
remaining_ms INTEGER NOT NULL CHECK (remaining_ms >= 0), -- valid while paused/idle
end_at INTEGER, -- set while running
state TEXT NOT NULL DEFAULT 'running' CHECK (state IN ('running','paused','ringing','idle')),
created_at INTEGER NOT NULL,
CHECK ((state = 'running') = (end_at IS NOT NULL))
);
CREATE INDEX timers_state ON timers(state, end_at);
CREATE TABLE cities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id TEXT NOT NULL UNIQUE, -- IANA, e.g. Europe/Paris
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX cities_sort ON cities(sort_order);
-- Single stopwatch and bedtime schedule live in DataStore (one instance each, no relations):
-- stopwatch: state (idle|running|stopped), started_at_elapsed, accumulated_ms, laps (json array of ms)
-- bedtime: enabled, bed_hour, bed_minute, days, reminder_min, sunrise, sleep_sound
-- settings: silence_after_min, snooze_min, alarm_volume, gradual_volume_s, volume_buttons,
-- week_start, home_zone, auto_home_clock, clock_style, show_seconds,
-- timer_sound, timer_vibrate, night_mode, theme