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
This commit is contained in:
2026-10-22 11:14:42 +02:00
parent 3f431a7f32
commit 1559e68b2f
2 changed files with 28 additions and 4 deletions
@@ -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<RingtoneChoice.Sound>()?.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) {
+8 -4
View File
@@ -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