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