From 1559e68b2fffeaed153feedb1a388758edb639e8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:14:42 +0200 Subject: [PATCH] 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