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:
+67
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user