feat(ringtones): let a music app play the alarm

"Choose from a music app…" in the sound picker browses any app that shares
its library through Android's MediaBrowserService and stores the choice as a
clockula://media URI in the existing ringtone field — no schema change, no
new permission, no internet. The music app streams with its own.

At ring time the app is asked to play through its media session. Every wait
is bounded and any failure falls back to the ordinary alarm sound: the app
won't connect or start, stalls or buffers too long, or the media stream
might not be heard (headphones or a classic Bluetooth device connected, Do
Not Disturb muting media). The ramp drives the media stream and the user's
media volume is restored when the last media ring ends.

RingAudioPlayer.start no longer suspends: vibration starts up front, the
timer collector is not held up, and a generation token keeps late work from
reviving a stopped ring.
This commit is contained in:
2026-10-02 20:44:58 +02:00
parent 767b9ed72d
commit f37c5f2f84
28 changed files with 1653 additions and 46 deletions
+67 -2
View File
@@ -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 |
| `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/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 |
@@ -666,7 +666,7 @@ Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's:
| `RepositoryModule` | `@Binds` for the five repository interfaces |
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
| `RingtoneModule` | `@Binds` for the two ringtone seams, `RingtoneCatalog` and `RingtonePreviewer`, and for `MediaLibrary` |
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding — it is a `@Singleton` `@Inject` class |
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
@@ -1579,6 +1579,71 @@ alarm will. One previewer, so a second preview stops the first; selecting an
option, closing the picker and `onCleared()` all stop it. Deliberately no audio
focus and no ramp: a preview is not an alarm.
### A sound another app plays
"Choose from a music app…" is a second action row. It opens
`MediaAppPickerScreen` over the picker, with its own `MediaAppPickerViewModel`
over the `MediaLibrary` seam, so none of the three hosts (alarm editor, timer
editor, settings) gains a line beyond the URI it receives through `onChoose`.
The picker lists the apps that export Android's standard `MediaBrowserService`
(the manifest's one `<queries>` entry makes them visible; no permission is
involved), walks the chosen app's library, and offers "Resume where you left
off" at its root.
**Storage is a URI, not a schema change.** `MediaSources` encodes the choice as
`clockula://media?pkg=…&svc=…&app=…&id=…&title=…`, under the same private
scheme as the silent sentinel. It therefore lives in `ringtone_uri`, the
default-sound preferences and the backup unchanged. The interop door accepts
only `content://` and `android.resource://` (`InteropLimits.RINGTONE_SCHEMES`),
so no other app can make Clockula bind to a service of its choosing. The
encoded title is what the editor shows, so rendering never connects to the app.
**Ring time.** `AudioSourcePolicy` puts `MEDIA_APP` in front of the usual
defaults. `RingAudioPlayer.start` does not wait for the sound: it launches the
attempt and returns, so the ring services post their notification and start
vibration meanwhile. A **generation** counter, bumped by every `start` and
`stop`, lets work left in flight (a `prepare`, an app that answers late) see
that it is no longer wanted. `onSilenced`, called only for the live
generation, replaces the old boolean result as the trigger for forced
vibration.
Before connecting, `MediaRouteCheck` asks whether media would be **heard**.
The other app plays as media, not as an alarm, so it gets neither the alarm's
Do Not Disturb exemption nor its forced speaker route, and its session reports
"playing" either way. `MediaRoutePolicy` refuses when Do Not Disturb does not
let media through, or when the media route includes headphones or an external
device whose type does not say what it is. A classic Bluetooth (A2DP) device
is a speaker or headphones alike, and telling them apart would need
`BLUETOOTH_CONNECT`. LE Audio speakers report their own type and pass. A
refusal rings the ordinary chain.
`MediaAppRing` connects through `connectMediaBrowser`, the one wrapper shared
with the picker. If the app is already playing, it pauses it first, so that
"playing" afterwards means the request was honoured. It then calls
`playFromMediaId` (or `play` for resume) and waits for `STATE_PLAYING`. Every
wait is bounded (`MediaRingPolicy`: 5 s to connect, 2 s for the pause, 15 s to
start). Once playing, a watchdog checks once per second on two clocks:
stopped for 5 s, or *not playing* (buffering included) for 15 s. Either one,
or a route check that now fails, stops the media ring and starts the ordinary
chain unramped.
**The one place Clockula moves a stream volume.** The other app plays on the
media stream, which the user may have at zero. `MediaAppRing` therefore steps
the ramp into `STREAM_MUSIC`, aiming at the alarm stream's share of the media
range (`MediaRingPolicy.mediaVolumeTarget`, never below 1, fixed at start), and
writes only when the index changes. `MediaRingRegistry` is process-wide: it
saves the user's level when the first media ring starts and restores it when
the last one ends, so an alarm and a timer ringing through music apps at once
cannot save each other's raised volume. It also pauses an app only when no
other live ring still uses it. No audio focus is requested, because the app
requests its own. Dismissal *pauses* rather than stops, so the user's queue
stays where the alarm found it. A process death mid-ring leaves the media
volume where the ramp had it.
There is no preview for these rows. Auditioning another app's track at alarm
volume from a settings screen would be rude, and the previewer ignores such a
URI.
### The rule a future reader will otherwise simplify away
**A ringing alarm is dismissed through the engine before it is edited, disabled