Merge branch 'fix/14-review' into release/v1.0.0

This commit is contained in:
makiolaj committed 2026-10-26 20:47:36 +01:00
commit 9c9b6171e0
3 files changed
+31 -6

No files matched your search

@@ -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)
}
@@ -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) {
+10 -6
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` |
@@ -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), 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 |
@@ -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