From 1559e68b2fffeaed153feedb1a388758edb639e8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:14:42 +0200 Subject: [PATCH 1/2] docs(ringtones): describe the bundled tone and test its no-sounds banner ARCHITECTURE.md still said every non-silent ringtone_uri is a content URI and listed the picker's rows without the bundled "Timer expired" sound, so a reader could Uri.parse the clockula://tone/ sentinel directly. It now names the sentinel, BundledTones as its only resolver, and where the tone sits in the picker. A view-model test now covers a device whose only listed tone is the bundled one, which must still show the "no alarm sounds" banner. The existing test only checked an empty list, so it passed even if the bundled tone were counted as a device sound. Refs #14 --- .../ui/alarms/AlarmEditorViewModelTest.kt | 20 +++++++++++++++++++ docs/ARCHITECTURE.md | 12 +++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt index 4e9c0b9..69fe199 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.RingtoneOption import de.jeanlucmakiola.clockula.domain.Ringtones import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness @@ -522,6 +523,25 @@ class AlarmEditorViewModelTest { ).isEqualTo(listOf("content://docs/a") to true) } + @Test + fun `a device whose only sound is the bundled one still says it has none`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + catalog.tones = listOf(RingtoneOption(Ringtones.TIMER_EXPIRED_URI, "Timer expired")) + harness.givenAlarmWith() + val viewModel = editor(harness) + advanceUntilIdle() + + viewModel.onOpenRingtonePicker() + advanceUntilIdle() + + val picker = viewModel.ringtonePicker.value + assertThat( + picker?.options?.filterIsInstance()?.map { it.uri } to + picker?.deviceHasNoSounds, + ).isEqualTo(listOf(Ringtones.TIMER_EXPIRED_URI) to true) + } + @Test fun `choosing an option stops the preview and closes the picker`(@TempDir tempDir: Path) = runTest(main.dispatcher) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..192ed48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -85,7 +85,7 @@ kit module: the kit's roadmap keeps schedulers app-local. | Package | Holds | |---|---| -| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt`. Plain Kotlin, no Android | +| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent and bundled-tone sentinels), `RepeatSummary.kt`, `AlarmDefaults.kt`. Plain Kotlin, no Android | | `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 | | `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` | | `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` | @@ -167,7 +167,7 @@ JSON and M10's JSON backup all read the same vocabulary in a diff. | `enabled` | INTEGER | indexed; `enabled()` filters on it | | `repeat_days` | INTEGER | 7-bit mask, see below | | `skip_next_occurrence` | INTEGER | | -| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI | +| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), `clockula://tone/…` = a sound bundled in the APK (resolved by `BundledTones`, never `Uri.parse`d directly), anything else a content URI | | `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | @@ -1091,7 +1091,8 @@ Four more deliberate gaps the alarm engine leaves open: - **No direct-boot awareness**, so an alarm cannot ring in the window between a reboot and the first unlock (see §9). - **No bundled fallback ringtone.** The audio chain ends in forced vibration - (§11), which makes a shipped asset a path a real device cannot reach. + (§11). The app ships one sound, "Timer expired", but only as a choice in the + picker, not as a rung of the fallback chain. --- @@ -1589,7 +1590,10 @@ is pure and yields four kinds of row, de-duplicated by URI: 3. **the alarm's own sound when the device does not list it**: a SAF-picked file, or a URI another app wrote. Without this row the user's own selection would be invisible, and so uncheckable, in the picker that chose it; -4. the device's alarm tones, title-sorted. +4. the sounds from `RingtoneCatalog.alarmTones()`: the ones bundled in the APK + first (`BundledTones`, today only "Timer expired"), then the device's alarm + tones, title-sorted. "This device has no alarm sounds" is shown when every + listed tone is a bundled one. "Choose from files…" is an **action** row, not an option, so the pure list stays a list of selectable states. A picked document's read grant is made persistent From 83a77c1bb0c9026ca43730b1333375a94bf9069f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:20:39 +0200 Subject: [PATCH 2/2] fix(settings): name the bundled sound in the default-sound rows Picking "Timer expired" as the default alarm or timer sound showed "Custom" in Settings, because the summary knew only the device default and silence. It now names the bundled sound, as the editors already do. ARCHITECTURE.md now names the exact bundled sentinel instead of a prefix the code does not recognise, lists clockula://media values in the alarm column, gives the timer column the same values, and says RingtoneCatalog lists the bundled sounds too. Refs #14 --- .../jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt | 1 + docs/ARCHITECTURE.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index eaeda0d..9ed7680 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -385,5 +385,6 @@ private fun selfCheckSummary(problemCount: Int?): String = when { internal fun ringtoneSummary(uri: String?): String = when { uri == null -> stringResource(R.string.ringtone_device_default) Ringtones.isSilent(uri) -> stringResource(R.string.ringtone_silent) + Ringtones.isBundled(uri) -> stringResource(R.string.ringtone_timer_expired) else -> stringResource(R.string.settings_ringtone_custom) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 192ed48..294e9b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,7 +106,7 @@ kit module: the kit's roadmap keeps schedulers app-local. | `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo | | `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | | `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` | -| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries | +| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the bundled and device alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries | | `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) | | `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks; no schema or version change), `BackupRepository(+Impl)`, mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy | @@ -167,7 +167,7 @@ JSON and M10's JSON backup all read the same vocabulary in a diff. | `enabled` | INTEGER | indexed; `enabled()` filters on it | | `repeat_days` | INTEGER | 7-bit mask, see below | | `skip_next_occurrence` | INTEGER | | -| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), `clockula://tone/…` = a sound bundled in the APK (resolved by `BundledTones`, never `Uri.parse`d directly), anything else a content URI | +| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), `clockula://tone/timer_expired` = the sound bundled in the APK (resolved by `BundledTones`, never `Uri.parse`d directly), `clockula://media?…` = another app's track (§13), anything else a content URI | | `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | @@ -270,7 +270,7 @@ occurrence, so skipping it is dismissing it in advance. | `started_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only | | `ends_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only; **authoritative** | | `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only; **post-reboot fallback only** | -| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` | +| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri`; otherwise the same values as the alarm's `ringtone_uri` | | `sort_order` | INTEGER | indexed | | `delete_after_use` | INTEGER | v3 (M9): a transient timer the contract asked for. Deleted rather than returned to IDLE when it is dismissed (§17) | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |