diff --git a/replica/uhr/architecture.md b/replica/uhr/architecture.md new file mode 100644 index 0000000..12170d3 --- /dev/null +++ b/replica/uhr/architecture.md @@ -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. diff --git a/replica/uhr/schema.sql b/replica/uhr/schema.sql new file mode 100644 index 0000000..bba091f --- /dev/null +++ b/replica/uhr/schema.sql @@ -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