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
+8
View File
@@ -198,6 +198,14 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
pause, resume and reset right on the widget), and a clock that grows to show
your world clocks at larger sizes. Each one updates the moment something
changes — no polling, no stale numbers.
- Wake up to your own music: "Choose from a music app…" in the sound picker
lets any app that shares its library (VLC, AntennaPod and many others) play
your alarm or timer — a playlist, an album, a single track, or just whatever
it played last. The music app does the playing, so Clockula still has no
internet permission. If the app can't play — uninstalled, logged out,
offline, or stops partway — your alarm rings with the default sound instead.
It does the same when the music might not be heard: headphones or a
Bluetooth device connected, or Do Not Disturb muting media.
### Changed
- Tapping the alarm icon in the status bar, or the next-alarm line on the lock
+10
View File
@@ -50,6 +50,16 @@
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- "Choose from a music app…": package visibility for the apps that share
a library through Android's standard MediaBrowserService, and for
nothing else. A visibility declaration, not a permission — and no
network: the music app streams with its own. -->
<queries>
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
<application
android:name=".ClockulaApp"
android:allowBackup="true"
@@ -87,14 +87,23 @@ class AlarmRingService : Service() {
super.onDestroy()
}
private suspend fun ring(session: RingSession) {
private fun ring(session: RingSession) {
startForeground(session)
acquireWakeLock()
val audible = audio.start(scope, session.settings.ringtoneUri, session.settings.volumeRampSeconds)
// Silence is not one of the outcomes: with no audio source at all the
// alarm vibrates even when the user switched vibration off.
if (RingFallbackPolicy.vibrationRequired(audible, session.settings.vibrate)) vibrator.start()
// Vibration first: the sound may take seconds to open when another app
// plays it, and the ring must not be still until then.
if (RingFallbackPolicy.vibrationRequired(audioAvailable = true, vibrateSetting = session.settings.vibrate)) {
vibrator.start()
}
audio.start(
scope = scope,
ringtoneUri = session.settings.ringtoneUri,
rampSeconds = session.settings.volumeRampSeconds,
// Silence is not one of the outcomes: with no audio source at all
// the alarm vibrates even when the user switched vibration off.
onSilenced = vibrator::start,
)
}
/**
@@ -4,14 +4,16 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.ringtones.MediaLibrary
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
import de.jeanlucmakiola.clockula.data.ringtones.SystemMediaLibrary
import de.jeanlucmakiola.clockula.data.ringtones.SystemRingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.SystemRingtonePreviewer
import javax.inject.Singleton
/**
* The two ringtone seams. Everything platform-specific about naming, opening
* The ringtone seams, and the media-app library the picker browses. Everything platform-specific about naming, opening
* and previewing a sound is on the far side of one of them, which is what keeps
* the editor's ViewModel JVM-testable (M5 D26).
*/
@@ -26,4 +28,8 @@ abstract class RingtoneModule {
@Binds
@Singleton
abstract fun bindRingtonePreviewer(impl: SystemRingtonePreviewer): RingtonePreviewer
@Binds
@Singleton
abstract fun bindMediaLibrary(impl: SystemMediaLibrary): MediaLibrary
}
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.ComponentName
import android.content.Context
import android.media.browse.MediaBrowser
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* A connected [MediaBrowser] to [component], or null when the app refuses or
* fails. The one wrapper around the platform's callback-style connect, shared
* by the picker ([SystemMediaLibrary]) and the ring (`MediaAppRing`).
*
* Main thread only: the browser delivers its callbacks on the thread that
* connected it. A cancelled wait disconnects rather than leaving a binding
* behind. [onSuspended] is told when an established connection drops later.
*/
internal suspend fun connectMediaBrowser(
context: Context,
component: ComponentName,
onSuspended: () -> Unit = {},
): MediaBrowser? = suspendCancellableCoroutine { continuation ->
var created: MediaBrowser? = null
val callback = object : MediaBrowser.ConnectionCallback() {
override fun onConnected() {
if (continuation.isActive) continuation.resume(created)
}
override fun onConnectionFailed() {
if (continuation.isActive) continuation.resume(null)
}
override fun onConnectionSuspended() {
if (continuation.isActive) continuation.resume(null) else onSuspended()
}
}
val connecting = runCatching { MediaBrowser(context, component, callback, null) }.getOrNull()
created = connecting
if (connecting == null || runCatching { connecting.connect() }.isFailure) {
connecting?.let { runCatching { it.disconnect() } }
continuation.resume(null)
return@suspendCancellableCoroutine
}
continuation.invokeOnCancellation { runCatching { connecting.disconnect() } }
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.data.ringtones
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
/**
* Other apps' music libraries, as Android's `MediaBrowserService` shares them.
* The picker's seam: browsing for a ring sound. Playing one at ring time is
* the ring path's business, not this one's.
*/
interface MediaLibrary {
/** The apps that share a library, label-sorted. **Empty** rather than throwing. */
suspend fun apps(): List<MediaApp>
/**
* The children of [parentId] in [app]'s library, or of its root when
* [parentId] is null. **Null** when the app refuses — many big streaming
* apps answer only callers they have allowlisted.
*/
suspend fun children(app: MediaApp, parentId: String?): List<MediaEntry>?
/** Drops any open connection. Idempotent. */
fun release()
}
@@ -0,0 +1,128 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.media.browse.MediaBrowser
import android.service.media.MediaBrowserService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
import de.jeanlucmakiola.clockula.domain.ring.MediaRingPolicy
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
/**
* The platform's `MediaBrowser`, one connection at a time: the picker browses
* one app, and switching apps drops the previous connection. Seeing which apps
* export the service is what the manifest's `<queries>` entry is for — no
* permission is involved.
*
* Nothing throws outwards: an app that refuses, crashes or never answers is a
* null, and the picker says so.
*/
@Singleton
class SystemMediaLibrary @Inject constructor(
@param:ApplicationContext private val context: Context,
@param:IoDispatcher private val io: CoroutineDispatcher,
) : MediaLibrary {
private var browser: MediaBrowser? = null
private var connectedTo: MediaApp? = null
override suspend fun apps(): List<MediaApp> = withContext(io) {
runCatching {
val pm = context.packageManager
pm.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), 0)
.mapNotNull { it.serviceInfo }
.filter { it.packageName != context.packageName && it.exported }
// Launchable only: system plumbing exports the same service
// (Bluetooth's AVRCP browser) and is no music app to pick from.
.filter { pm.getLaunchIntentForPackage(it.packageName) != null }
.map { service ->
MediaApp(
packageName = service.packageName,
serviceClass = service.name,
label = service.applicationInfo.loadLabel(pm).toString(),
)
}
.distinctBy { it.packageName to it.serviceClass }
.sortedBy { it.label.lowercase() }
}.getOrDefault(emptyList())
}
override suspend fun children(app: MediaApp, parentId: String?): List<MediaEntry>? =
withContext(Dispatchers.Main) {
val connected = withTimeoutOrNull(MediaRingPolicy.CONNECT_TIMEOUT) { connect(app) }
?: return@withContext null
val parent = parentId ?: runCatching { connected.root }.getOrNull() ?: return@withContext null
withTimeoutOrNull(LOAD_TIMEOUT) { load(connected, parent) }
}
override fun release() {
browser?.let { runCatching { it.disconnect() } }
browser = null
connectedTo = null
}
/** Main thread. Reuses the open connection when it is to [app]. */
private suspend fun connect(app: MediaApp): MediaBrowser? {
browser?.takeIf { connectedTo == app && it.isConnected }?.let { return it }
release()
// Identity, not the app: a later connection to the same app must not be
// released by this one's suspension.
var self: MediaBrowser? = null
val connected = connectMediaBrowser(
context = context,
component = ComponentName(app.packageName, app.serviceClass),
onSuspended = { if (browser === self) release() },
) ?: return null
self = connected
browser = connected
connectedTo = app
return connected
}
/** Main thread. One subscription, dropped as soon as it has answered. */
private suspend fun load(connected: MediaBrowser, parentId: String): List<MediaEntry>? =
suspendCancellableCoroutine { continuation ->
val callback = object : MediaBrowser.SubscriptionCallback() {
override fun onChildrenLoaded(parentId: String, children: MutableList<MediaBrowser.MediaItem>) {
runCatching { connected.unsubscribe(parentId, this) }
if (continuation.isActive) continuation.resume(children.mapNotNull(::entryOf))
}
override fun onError(parentId: String) {
runCatching { connected.unsubscribe(parentId, this) }
if (continuation.isActive) continuation.resume(null)
}
}
if (runCatching { connected.subscribe(parentId, callback) }.isFailure) {
if (continuation.isActive) continuation.resume(null)
}
}
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry? {
val id = item.mediaId ?: return null
val description = item.description
return MediaEntry(
id = id,
title = description.title?.toString().orEmpty(),
subtitle = description.subtitle?.toString()?.takeIf { it.isNotBlank() },
browsable = item.isBrowsable,
playable = item.isPlayable,
)
}
private companion object {
val LOAD_TIMEOUT = 10.seconds
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
@@ -7,6 +8,8 @@ import android.media.RingtoneManager
import android.net.Uri
import android.provider.OpenableColumns
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -52,6 +55,14 @@ class SystemRingtoneCatalog @Inject constructor(
}
override suspend fun titleOf(uri: String): String? = withContext(io) {
// A sound another app plays carries its own title: naming it would
// otherwise mean connecting to that app on every editor render.
MediaSources.decode(uri)?.let { ref ->
if (ref.mediaId == null) return@withContext context.getString(R.string.media_resume_title, ref.app.label)
return@withContext listOf(ref.title, ref.app.label).filter { it.isNotBlank() }
.joinToString(" · ")
.ifEmpty { null }
}
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext null
// The ringtone's own title first; a SAF document has none, so its
// display name answers instead.
@@ -60,6 +71,17 @@ class SystemRingtoneCatalog @Inject constructor(
}
override suspend fun isPlayable(uri: String): Boolean = withContext(io) {
// Installed and still sharing its library is as much as can be known
// without asking the app to play; the ring falls back on the rest.
MediaSources.decode(uri)?.let { ref ->
return@withContext runCatching {
context.packageManager.getServiceInfo(
ComponentName(ref.app.packageName, ref.app.serviceClass),
0,
)
true
}.getOrDefault(false)
}
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
runCatching {
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
@@ -5,6 +5,7 @@ import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.sync.Mutex
@@ -51,6 +52,12 @@ class SystemRingtonePreviewer @Inject constructor(
/** Plays [uri] once over USAGE_ALARM, stopping anything already playing. */
override suspend fun play(uri: String) {
// Another app's track is not something to audition at alarm volume
// from a settings screen; the picker offers no preview for one.
if (MediaSources.isMediaSource(uri)) {
stop()
return
}
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return
// Stop *before* taking the lock: what is sounding now stops now, and an
// in-flight `prepare` is told its preview has been superseded.
@@ -0,0 +1,71 @@
package de.jeanlucmakiola.clockula.domain
import java.net.URLDecoder
import java.net.URLEncoder
/**
* A music app on the device that shares its library through Android's standard
* `MediaBrowserService` — the same door Android Auto and the system's media
* resumption use. [serviceClass] is the component that answers it, because an
* app may export more than one.
*/
data class MediaApp(val packageName: String, val serviceClass: String, val label: String)
/** One row of a media app's library: a folder ([browsable]), a playable item, or both. */
data class MediaEntry(
val id: String,
val title: String,
val subtitle: String?,
val browsable: Boolean,
val playable: Boolean,
)
/**
* A ring sound that is something another app plays: [mediaId] of [app]'s
* library, or — when [mediaId] is null — whatever that app last played.
* [title] is what the picker showed, kept so the editor can name the choice
* without reconnecting to the app.
*/
data class MediaSourceRef(val app: MediaApp, val mediaId: String?, val title: String)
/**
* The stored form of a [MediaSourceRef]: a URI under Clockula's private scheme,
* so it lives in the same `ringtone_uri` column, preference and backup field as
* every other sound, and no provider URI can collide with it. The interop door
* accepts only `content://` and `android.resource://`, so another app cannot
* hand Clockula one of these.
*/
object MediaSources {
const val PREFIX: String = "clockula://media?"
fun isMediaSource(uri: String?): Boolean = uri?.startsWith(PREFIX) == true
fun encode(ref: MediaSourceRef): String = PREFIX + buildList {
add("pkg" to ref.app.packageName)
add("svc" to ref.app.serviceClass)
add("app" to ref.app.label)
ref.mediaId?.let { add("id" to it) }
add("title" to ref.title)
}.joinToString("&") { (key, value) -> "$key=${URLEncoder.encode(value, UTF_8)}" }
/** Null for anything that is not a well-formed media source — never throws. */
fun decode(uri: String?): MediaSourceRef? {
if (!isMediaSource(uri)) return null
val params = runCatching {
uri!!.removePrefix(PREFIX).split('&').filter { it.isNotEmpty() }.associate { pair ->
val key = pair.substringBefore('=')
key to URLDecoder.decode(pair.substringAfter('=', ""), UTF_8)
}
}.getOrNull() ?: return null
val pkg = params["pkg"]?.takeIf { it.isNotBlank() } ?: return null
val svc = params["svc"]?.takeIf { it.isNotBlank() } ?: return null
return MediaSourceRef(
app = MediaApp(packageName = pkg, serviceClass = svc, label = params["app"].orEmpty()),
mediaId = params["id"]?.takeIf { it.isNotEmpty() },
title = params["title"].orEmpty(),
)
}
}
// The String overloads: the Charset ones are API 33, and minSdk is 29.
private const val UTF_8 = "UTF-8"
@@ -1,10 +1,14 @@
package de.jeanlucmakiola.clockula.domain.ring
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.Ringtones
enum class RingtoneSourceKind {
/** The ringing thing's own URI — an alarm's or a timer's. */
ALARM_OWN,
/** The ringing thing's own choice, played by another app (`MediaSources`). */
MEDIA_APP,
DEVICE_DEFAULT,
BUILT_IN_DEFAULT,
}
@@ -20,6 +24,14 @@ object AudioSourcePolicy {
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
// Another app plays it, so it can fail in ways a file cannot — not
// installed, logged out, offline. The defaults behind it are what
// keep that from being a silent alarm.
MediaSources.isMediaSource(ringtoneUri) -> listOf(
RingtoneSourceKind.MEDIA_APP,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
else -> listOf(
RingtoneSourceKind.ALARM_OWN,
RingtoneSourceKind.DEVICE_DEFAULT,
@@ -0,0 +1,112 @@
package de.jeanlucmakiola.clockula.domain.ring
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** What another app's session reports, reduced to what the watchdog needs. */
enum class MediaPlayback {
PLAYING,
/** Buffering, connecting or skipping — on the way back to [PLAYING], or stuck. */
TRANSITIONING,
STOPPED,
}
/**
* The rules for a ring another app plays. That app is a dependency Clockula
* cannot vouch for, so every wait is bounded and every failure ends in the
* ordinary alarm sound rather than in silence.
*/
object MediaRingPolicy {
/** How long the app's library service may take to accept the connection. */
val CONNECT_TIMEOUT: Duration = 5.seconds
/**
* How long, once asked, the app may take to actually start playing — a
* cold-started streaming app has to load and buffer first.
*/
val START_TIMEOUT: Duration = 15.seconds
/** How long an app that is already playing gets to honour the pause before the ring's own request. */
val PAUSE_SETTLE: Duration = 2.seconds
/** How often a playing ring checks the app is still playing. */
val WATCH_TICK: Duration = 1.seconds
/**
* How long playback may stay stopped before the ring falls back. Track
* changes pass through non-playing states briefly; a dropped connection or
* an end of playlist does not come back.
*/
val STALL_GRACE: Duration = 5.seconds
/**
* How long playback may go without *playing* at all — re-buffering
* included. A stream that lost its network buffers forever, and "buffering"
* must not be a way for an alarm to stay silent.
*/
val BUFFER_GRACE: Duration = 15.seconds
/** The watchdog's two clocks, advanced one tick at a time. */
data class Stall(val stopped: Duration = Duration.ZERO, val notPlaying: Duration = Duration.ZERO)
fun advance(stall: Stall, playback: MediaPlayback, tick: Duration): Stall = when (playback) {
MediaPlayback.PLAYING -> Stall()
MediaPlayback.TRANSITIONING -> Stall(stopped = Duration.ZERO, notPlaying = stall.notPlaying + tick)
MediaPlayback.STOPPED -> Stall(stopped = stall.stopped + tick, notPlaying = stall.notPlaying + tick)
}
fun shouldFallBack(stall: Stall): Boolean = stall.stopped >= STALL_GRACE || stall.notPlaying >= BUFFER_GRACE
/**
* The media-stream index a fully ramped ring plays at.
*
* Another app's playback is on the *media* stream, which the user may have
* at zero. The alarm's own loudness is what they chose for being woken, so
* the target is the alarm stream's fraction carried over to the media
* stream — never below 1, or a muted media stream would be a silent alarm.
*/
fun mediaVolumeTarget(alarmIndex: Int, alarmMax: Int, musicMax: Int): Int {
if (musicMax <= 0) return 0
val fraction = if (alarmMax > 0) alarmIndex.coerceIn(0, alarmMax).toFloat() / alarmMax else 1f
return ceil(fraction * musicMax).toInt().coerceIn(1, musicMax)
}
/** The index for a ramp [level] (`RingAudio.MIN_VOLUME`..1) on the way to [target]. */
fun mediaVolumeIndex(level: Float, target: Int): Int {
if (target <= 0) return 0
return (level.coerceIn(0f, 1f) * target).roundToInt().coerceIn(1, target)
}
}
/** Where the media stream would come out, as far as the ring needs to know. */
enum class AudioOutputKind {
/** The phone's speaker, or a device that is a speaker by its type. */
SPEAKER,
/** Headphones, headsets, hearing aids: audible to one person, maybe not on their head. */
PERSONAL,
/**
* External, and the type does not say what it is — a classic Bluetooth
* (A2DP) device is a speaker or headphones alike, and HDMI may be a TV that
* is off.
*/
UNCERTAIN,
/** Not a route media plays to (the earpiece, telephony). */
NOT_MEDIA,
}
/**
* Whether a ring another app plays will be heard. That 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.
* When either is in doubt the ring uses Clockula's own alarm sound instead.
*/
object MediaRoutePolicy {
fun mediaAudible(dndAllowsMedia: Boolean, outputs: List<AudioOutputKind>): Boolean =
dndAllowsMedia && outputs.none { it == AudioOutputKind.PERSONAL || it == AudioOutputKind.UNCERTAIN }
}
@@ -0,0 +1,148 @@
package de.jeanlucmakiola.clockula.ring
import android.content.ComponentName
import android.content.Context
import android.media.AudioManager
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.PlaybackState
import de.jeanlucmakiola.clockula.data.ringtones.connectMediaBrowser
import de.jeanlucmakiola.clockula.domain.MediaSourceRef
import de.jeanlucmakiola.clockula.domain.ring.MediaPlayback
import de.jeanlucmakiola.clockula.domain.ring.MediaRingPolicy
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
/**
* A ring that another app plays: Clockula connects to that app's
* `MediaBrowserService`, asks its session to play [ref], and reports what the
* session says. Clockula itself opens no audio and needs no network — the
* music app streams with its own permissions.
*
* Unlike [RingAudioPlayer]'s own player this one **does** move a stream volume:
* the other app plays on the media stream, which the user may have at zero, so
* the ramp is stepped into `STREAM_MUSIC`. [MediaRingRegistry] keeps the user's
* own level and puts it back when the last media ring ends.
*
* Main thread only: `MediaBrowser` delivers its callbacks on the thread that
* connected it, and both ring services run on `Dispatchers.Main`.
*/
internal class MediaAppRing(private val context: Context, private val ref: MediaSourceRef) {
private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java)
private var browser: MediaBrowser? = null
private var controller: MediaController? = null
/** The fully ramped media index, fixed when the ring starts. */
private var target: Int = 0
/** The index last written, so an unchanged ramp step writes nothing. */
private var lastIndex: Int? = null
private var holding: Boolean = false
/** The app's service went away mid-ring: nothing it reports any more is trustworthy. */
private var connectionLost: Boolean = false
private var stopped: Boolean = false
/**
* Connects, asks the app to play, and waits until it says it is playing.
* False on any failure along the way — the caller falls back — and false
* if [stop] arrived meanwhile.
*/
suspend fun start(): Boolean {
val connected = withTimeoutOrNull(MediaRingPolicy.CONNECT_TIMEOUT) {
connectMediaBrowser(
context = context,
component = ComponentName(ref.app.packageName, ref.app.serviceClass),
onSuspended = { connectionLost = true },
)
}
if (connected == null || stopped) {
connected?.let { runCatching { it.disconnect() } }
return false
}
browser = connected
val session = runCatching { MediaController(context, connected.sessionToken) }.getOrNull() ?: return false
controller = session
MediaRingRegistry.acquire(ref.app.packageName, audioManager)
holding = true
target = MediaRingPolicy.mediaVolumeTarget(
alarmIndex = audioManager.getStreamVolume(AudioManager.STREAM_ALARM),
alarmMax = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM),
musicMax = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
)
// Quiet before the first sample, so a ramp does not open with a burst.
setLevel(0f)
val controls = session.transportControls
// Already playing — the user's own music in that same app: pause it
// first, so that "playing" afterwards means the ring's request was
// honoured, not that nothing changed.
if (playbackState() == PlaybackState.STATE_PLAYING) {
runCatching { controls.pause() }
withTimeoutOrNull(MediaRingPolicy.PAUSE_SETTLE) {
while (!stopped && playbackState() == PlaybackState.STATE_PLAYING) delay(POLL_MILLIS)
}
}
if (stopped) return false
runCatching {
if (ref.mediaId != null) controls.playFromMediaId(ref.mediaId, null) else controls.play()
}.onFailure { return false }
return withTimeoutOrNull(MediaRingPolicy.START_TIMEOUT) {
// Strictly PLAYING here: an app stuck buffering a stream it cannot
// reach must not count as a ring that started.
while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) delay(POLL_MILLIS)
!stopped
} ?: false
}
fun playback(): MediaPlayback {
if (connectionLost) return MediaPlayback.STOPPED
return when (playbackState()) {
PlaybackState.STATE_PLAYING -> MediaPlayback.PLAYING
in TRANSITIONING_STATES -> MediaPlayback.TRANSITIONING
else -> MediaPlayback.STOPPED
}
}
/** One step of the ramp, `0f`..`1f`, mapped onto the media stream; writes only a changed index. */
fun setLevel(level: Float) {
if (!holding) return
val index = MediaRingPolicy.mediaVolumeIndex(level, target)
if (index == lastIndex) return
lastIndex = index
runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) }
}
/** Pauses the app (unless another ring still uses it), disconnects, releases the volume. Idempotent. */
fun stop() {
stopped = true
val last = if (holding) MediaRingRegistry.release(ref.app.packageName, audioManager) else false
holding = false
// Paused rather than stopped: the user's queue in that app is theirs,
// and pausing leaves it where the alarm found it.
if (last) controller?.let { runCatching { it.transportControls.pause() } }
controller = null
browser?.let { runCatching { it.disconnect() } }
browser = null
}
private fun playbackState(): Int? = runCatching { controller?.playbackState?.state }.getOrNull()
private companion object {
const val POLL_MILLIS = 250L
val TRANSITIONING_STATES = setOf(
PlaybackState.STATE_BUFFERING,
PlaybackState.STATE_CONNECTING,
PlaybackState.STATE_SKIPPING_TO_NEXT,
PlaybackState.STATE_SKIPPING_TO_PREVIOUS,
PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM,
)
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.ring
import android.media.AudioManager
/**
* Process-wide bookkeeping for the rings other apps play. An alarm and a timer
* can both be [MediaAppRing]s at once, and a ring service can restart its ring
* in place, so neither may treat the media volume it finds as the user's own,
* nor pause an app another ring is still using.
*
* Main thread only, like both ring services.
*/
internal object MediaRingRegistry {
/** The user's media volume from before the first live media ring; null while none is live. */
private var savedMusicVolume: Int? = null
/** Live media rings per package. */
private val holders = mutableMapOf<String, Int>()
fun acquire(packageName: String, audioManager: AudioManager) {
if (holders.isEmpty()) {
savedMusicVolume = runCatching { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
}
holders[packageName] = (holders[packageName] ?: 0) + 1
}
/**
* Drops one hold on [packageName]. True when no other live ring still uses
* that app, so it is the caller's to pause. The user's media volume comes
* back with the last hold.
*/
fun release(packageName: String, audioManager: AudioManager): Boolean {
val count = holders[packageName] ?: return false
if (count > 1) holders[packageName] = count - 1 else holders.remove(packageName)
if (holders.isEmpty()) {
savedMusicVolume?.let { saved ->
runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, saved, 0) }
}
savedMusicVolume = null
}
return packageName !in holders
}
}
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.clockula.ring
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import de.jeanlucmakiola.clockula.domain.ring.AudioOutputKind
import de.jeanlucmakiola.clockula.domain.ring.MediaRoutePolicy
/**
* The device half of [MediaRoutePolicy]: Do Not Disturb's media rule and where
* the media stream is routed. No permission is involved — a Bluetooth device's
* own class (speaker or headphones) would need `BLUETOOTH_CONNECT`, so a
* classic Bluetooth device counts as [AudioOutputKind.UNCERTAIN].
*/
internal class MediaRouteCheck(context: Context) {
private val audioManager: AudioManager? = context.getSystemService(AudioManager::class.java)
private val notifications: NotificationManager? = context.getSystemService(NotificationManager::class.java)
private val mediaAttributes: AudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
/** False whenever either half cannot be read: the fallback is a sound, not silence. */
fun mediaAudible(): Boolean = runCatching {
MediaRoutePolicy.mediaAudible(dndAllowsMedia(), outputs())
}.getOrDefault(false)
private fun dndAllowsMedia(): Boolean {
val manager = notifications ?: return true
return when (manager.currentInterruptionFilter) {
NotificationManager.INTERRUPTION_FILTER_ALL,
NotificationManager.INTERRUPTION_FILTER_UNKNOWN,
-> true
NotificationManager.INTERRUPTION_FILTER_PRIORITY -> {
val policy = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
manager.consolidatedNotificationPolicy
} else {
manager.notificationPolicy
}
policy.priorityCategories and NotificationManager.Policy.PRIORITY_CATEGORY_MEDIA != 0
}
// Alarms only, total silence: media is not something to count on.
else -> false
}
}
/**
* The media route itself where the platform can say (API 33), otherwise
* every connected output — an attached external device takes media over
* the speaker on every release this app supports.
*/
private fun outputs(): List<AudioOutputKind> {
val manager = audioManager ?: return emptyList()
val devices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
manager.getAudioDevicesForAttributes(mediaAttributes).map { it.type }
} else {
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).map { it.type }
}
return devices.map(::kindOf)
}
// The newer TYPE_ constants are compile-time ints; on an older release a
// device simply never reports them.
@SuppressLint("InlinedApi")
private fun kindOf(type: Int): AudioOutputKind = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE,
AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_LINE_ANALOG,
AudioDeviceInfo.TYPE_LINE_DIGITAL,
AudioDeviceInfo.TYPE_DOCK,
AudioDeviceInfo.TYPE_DOCK_ANALOG,
-> AudioOutputKind.SPEAKER
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_BROADCAST,
AudioDeviceInfo.TYPE_HEARING_AID,
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
-> AudioOutputKind.PERSONAL
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_TELEPHONY,
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
AudioDeviceInfo.TYPE_FM,
-> AudioOutputKind.NOT_MEDIA
// A2DP, HDMI, USB devices, car buses and anything newer than this list.
else -> AudioOutputKind.UNCERTAIN
}
}
@@ -7,7 +7,9 @@ import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.ring.AudioSourcePolicy
import de.jeanlucmakiola.clockula.domain.ring.MediaRingPolicy
import de.jeanlucmakiola.clockula.domain.ring.RingAudio
import de.jeanlucmakiola.clockula.domain.ring.RingtoneSourceKind
import de.jeanlucmakiola.clockula.domain.ring.VolumeRamp
@@ -24,16 +26,21 @@ import kotlin.time.Duration.Companion.seconds
/**
* `MediaPlayer` over `USAGE_ALARM`, plus the ramp loop. The ramp is stepped into
* [MediaPlayer.setVolume] and **never** into `AudioManager.setStreamVolume`,
* which would edit the user's own alarm volume and leave it edited.
* which would edit the user's own alarm volume and leave it edited. (A ring
* another app plays is the one exception — see [MediaAppRing].)
*
* [start] returns false when no audio source could be opened at all — which is
* what turns forced vibration on (`RingFallbackPolicy`), so neither an alarm
* nor a timer is ever silent.
* [start] does not wait for the sound: opening one can take seconds when
* another app plays it, and the caller has a notification to post and a
* vibration to start meanwhile. When no audio source could be opened at all it
* calls `onSilenced` — which is what turns forced vibration on
* (`RingFallbackPolicy`), so neither an alarm nor a timer is ever silent.
*/
class RingAudioPlayer(private val context: Context) {
private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java)
private val routes by lazy { MediaRouteCheck(context) }
private val attributes: AudioAttributes = AudioAttributes.Builder()
// USAGE_ALARM is what makes Do Not Disturb's alarm exemption apply.
.setUsage(AudioAttributes.USAGE_ALARM)
@@ -44,56 +51,156 @@ class RingAudioPlayer(private val context: Context) {
private var focusRequest: AudioFocusRequest? = null
private var ramp: Job? = null
/** Set by [stop], so a player that finished preparing after it is released too. */
private var stopped: Boolean = false
/** Set while another app plays the ring ([MediaAppRing]). */
private var mediaRing: MediaAppRing? = null
private var watchdog: Job? = null
/**
* Tries the ringing thing's own ringtone, then the device default, then the
* built-in default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the
* Which ring is live. [start] and [stop] both move it on, so work a ring
* left in flight — a `prepare`, a music app that answers late — sees it is
* no longer wanted and releases what it opened rather than starting it.
*/
private var generation: Int = 0
/**
* Starts the ring: the ringing thing's own sound, then the device default,
* then the built-in default. A sound another app plays ([MediaSources]) is
* tried first and falls back to the same chain — at the start when the app
* will not play or would not be heard, and mid-ring when it stops.
*
* [onSilenced] is called, at most once, when that chain found no sound at
* all; never after [stop].
*/
fun start(
scope: CoroutineScope,
ringtoneUri: String?,
rampSeconds: Int,
onSilenced: () -> Unit,
) {
stop()
val ring = generation
val rampFor = rampSeconds.coerceAtLeast(0).seconds
scope.launch {
val audible = startMedia(ring, scope, ringtoneUri, rampFor, onSilenced) ||
startPlayer(ring, scope, ringtoneUri, rampFor)
if (!audible && ring == generation) onSilenced()
}
}
fun stop() {
generation++
watchdog?.cancel()
watchdog = null
ramp?.cancel()
ramp = null
mediaRing?.stop()
mediaRing = null
player?.let(::release)
player = null
abandonFocus()
}
/** False when there is no media source, or it would not be heard, or the app would not play. */
private suspend fun startMedia(
ring: Int,
scope: CoroutineScope,
ringtoneUri: String?,
rampFor: Duration,
onSilenced: () -> Unit,
): Boolean {
val ref = MediaSources.decode(ringtoneUri) ?: return false
// Media plays where media is routed and as Do Not Disturb treats media:
// into headphones nobody is wearing, or muted. The app reports
// "playing" either way, so this is asked before rather than after.
if (!routes.mediaAudible()) return false
val media = MediaAppRing(context, ref)
mediaRing = media
if (media.start() && ring == generation) {
watchMedia(ring, scope, media, ringtoneUri, rampFor, onSilenced)
return true
}
media.stop()
if (mediaRing === media) mediaRing = null
return false
}
/**
* Prepares on IO, because [MediaPlayer.prepare] is blocking I/O and the
* moment the alarm rings is the worst possible moment to block the main
* thread with it.
*/
suspend fun start(scope: CoroutineScope, ringtoneUri: String?, rampSeconds: Int): Boolean {
stopped = false
private suspend fun startPlayer(
ring: Int,
scope: CoroutineScope,
ringtoneUri: String?,
rampFor: Duration,
): Boolean {
if (ring != generation) return false
val prepared = withContext(Dispatchers.IO) {
sources(ringtoneUri).firstNotNullOfOrNull { prepare(it) }
} ?: return false
// Stopped while we were preparing: release rather than start, or the ring
// would outlive the dismissal that asked for it.
if (stopped) {
if (ring != generation) {
release(prepared)
return false
}
player = prepared
requestFocus()
val rampFor = rampSeconds.coerceAtLeast(0).seconds
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
// The level is set before the first sample plays, so a ramp from silence
// does not begin with a burst at full volume.
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
runCatching { prepared.setVolume(initial, initial) }
runCatching { prepared.start() }
ramp = scope.launch {
ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } }
return true
}
/**
* Ramps the media stream and watches the other app keep playing — and keep
* being heard. A stall ([MediaRingPolicy.shouldFallBack]: the app crashed,
* lost its stream, reached the end of the playlist, someone paused it) or a
* route change (headphones connected, Do Not Disturb switched on) hands the
* ring to the ordinary chain, unramped: the user has been woken gently already.
*/
private fun watchMedia(
ring: Int,
scope: CoroutineScope,
media: MediaAppRing,
ringtoneUri: String?,
rampFor: Duration,
onSilenced: () -> Unit,
) {
media.setLevel(VolumeRamp.levelAt(Duration.ZERO, rampFor))
ramp = launchRamp(scope, rampFor, media::setLevel)
watchdog = scope.launch {
var stall = MediaRingPolicy.Stall()
while (isActive && ring == generation) {
delay(MediaRingPolicy.WATCH_TICK)
stall = MediaRingPolicy.advance(stall, media.playback(), MediaRingPolicy.WATCH_TICK)
if (MediaRingPolicy.shouldFallBack(stall) || !routes.mediaAudible()) break
}
if (!isActive || ring != generation) return@launch
ramp?.cancel()
ramp = null
media.stop()
mediaRing = null
if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO) && ring == generation) onSilenced()
}
}
/** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] until [rampFor] has passed. */
private fun launchRamp(scope: CoroutineScope, rampFor: Duration, setLevel: (Float) -> Unit): Job =
scope.launch {
var elapsed = Duration.ZERO
while (isActive && elapsed < rampFor) {
delay(RingAudio.RAMP_TICK)
elapsed += RingAudio.RAMP_TICK
val level = VolumeRamp.levelAt(elapsed, rampFor)
runCatching { player?.setVolume(level, level) }
setLevel(VolumeRamp.levelAt(elapsed, rampFor))
}
}
return true
}
fun stop() {
stopped = true
ramp?.cancel()
ramp = null
player?.let(::release)
player = null
abandonFocus()
}
/**
* The order is [AudioSourcePolicy]'s, so the decision — including the silent
@@ -104,6 +211,8 @@ class RingAudioPlayer(private val context: Context) {
AudioSourcePolicy.sourcesFor(ringtoneUri).mapNotNull { kind ->
when (kind) {
RingtoneSourceKind.ALARM_OWN -> ringtoneUri?.let(Uri::parse)
// Not a URI a player can open: [start] has already tried it.
RingtoneSourceKind.MEDIA_APP -> null
RingtoneSourceKind.DEVICE_DEFAULT ->
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
RingtoneSourceKind.BUILT_IN_DEFAULT ->
@@ -202,7 +202,7 @@ class TimerService : Service() {
return engine.serviceState(alreadyAlerted)
}
private suspend fun applyRing(decision: TimerRingDecision) {
private fun applyRing(decision: TimerRingDecision) {
when (decision) {
is TimerRingDecision.Sound -> {
// An unchanged decision does nothing: restarting the player on
@@ -211,12 +211,20 @@ class TimerService : Service() {
releaseRing()
sounding = decision.subjectId
acquireWakeLock()
val audible = audio.start(scope, decision.settings.ringtoneUri, decision.settings.rampSeconds)
// Silence is not one of the outcomes: with no audio source at
// all the timer vibrates even when vibration is switched off.
if (RingFallbackPolicy.vibrationRequired(audible, decision.settings.vibrate)) {
// Vibration first, and the sound without waiting for it: a sound
// another app plays can take seconds to open, and this runs
// inside the collector that posts the notification and its Stop.
if (RingFallbackPolicy.vibrationRequired(audioAvailable = true, vibrateSetting = decision.settings.vibrate)) {
vibrator.start()
}
audio.start(
scope = scope,
ringtoneUri = decision.settings.ringtoneUri,
rampSeconds = decision.settings.rampSeconds,
// Silence is not one of the outcomes: with no audio source at
// all the timer vibrates even when vibration is switched off.
onSilenced = vibrator::start,
)
}
// Suppressed by a ringing alarm, auto-silenced, or nothing expired:
@@ -0,0 +1,183 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
/**
* The "Choose from a music app…" picker, opened over [RingtonePickerScreen]:
* the apps that share a library, then that app's folders, down to a playable
* item. Back goes up one level; at the app list it closes.
*
* [onChoose] receives a `MediaSources` URI, which the host stores exactly as it
* stores any other sound.
*/
@Composable
internal fun MediaAppPickerScreen(
onChoose: (String) -> Unit,
onClose: () -> Unit,
viewModel: MediaAppPickerViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val activity = LocalActivity.current
LaunchedEffect(Unit) { viewModel.onOpen() }
// However the picker leaves — back, a choice, or its host closing under
// it — the app connection goes with it. A rotation is the one exit that
// comes straight back, and it keeps the user's place.
DisposableEffect(viewModel) {
onDispose { if (activity?.isChangingConfigurations != true) viewModel.onClose() }
}
val close = {
viewModel.onClose()
onClose()
}
val back = { if (!viewModel.onBack()) close() }
val choose = { entry: MediaEntry? ->
val uri = viewModel.uriFor(entry)
if (uri != null) {
viewModel.onClose()
onChoose(uri)
}
}
FullScreenPicker(
title = state.trail.lastOrNull()?.title?.ifBlank { null }
?: state.app?.label
?: stringResource(R.string.media_picker_title),
// The dialog routes both system back and the bar's arrow here, so
// either one goes up a level before it closes anything.
onDismiss = back,
scrollable = false,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
when {
state.loading -> item {
CircularProgressIndicator(modifier = Modifier.padding(GroupedListInset))
}
state.app == null -> appRows(state.apps, viewModel::onAppChosen)
state.refused -> item {
Hint(stringResource(R.string.media_picker_refused, state.app?.label.orEmpty()))
}
else -> libraryRows(
app = state.app!!,
atRoot = state.trail.isEmpty(),
entries = state.entries,
onOpen = viewModel::onFolderOpened,
onChoose = choose,
)
}
}
}
}
private fun LazyListScope.appRows(apps: List<MediaApp>, onAppChosen: (MediaApp) -> Unit) {
item {
Hint(
stringResource(
if (apps.isEmpty()) R.string.media_picker_no_apps else R.string.media_picker_explainer,
),
)
}
itemsIndexed(apps, key = { _, app -> app.packageName + "/" + app.serviceClass }) { index, app ->
GroupedRow(
title = app.label,
position = positionOf(index, apps.size),
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
onClick = { onAppChosen(app) },
)
}
}
private fun LazyListScope.libraryRows(
app: MediaApp,
atRoot: Boolean,
entries: List<MediaEntry>,
onOpen: (MediaEntry) -> Unit,
onChoose: (MediaEntry?) -> Unit,
) {
// "Resume" belongs to the app, not to a folder: offered once, at its root.
val resumeRows = if (atRoot) 1 else 0
val rows = resumeRows + entries.size
if (atRoot) {
item(key = "resume") {
GroupedRow(
title = stringResource(R.string.media_picker_resume),
summary = stringResource(R.string.media_picker_resume_summary, app.label),
position = positionOf(0, rows),
onClick = { onChoose(null) },
)
}
}
if (entries.isEmpty()) {
item { Hint(stringResource(R.string.media_picker_empty)) }
}
itemsIndexed(entries, key = { _, entry -> "entry:" + entry.id }) { index, entry ->
val title = entry.title.ifBlank { stringResource(R.string.ringtone_unknown) }
GroupedRow(
title = title,
summary = entry.subtitle,
position = positionOf(resumeRows + index, rows),
trailing = when {
// A playlist is often both: open it to pick a track, or take
// the whole thing from here.
entry.browsable && entry.playable -> {
{
IconButton(onClick = { onChoose(entry) }) {
Icon(
Icons.Rounded.Check,
contentDescription = stringResource(R.string.media_picker_use, title),
)
}
}
}
entry.browsable -> {
{ Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) }
}
else -> null
},
onClick = { if (entry.browsable) onOpen(entry) else onChoose(entry) },
)
}
}
@Composable
private fun Hint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
@@ -0,0 +1,122 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.ringtones.MediaLibrary
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
import de.jeanlucmakiola.clockula.domain.MediaSourceRef
import de.jeanlucmakiola.clockula.domain.MediaSources
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MediaAppPickerUiState(
val loading: Boolean = true,
/** Every app that shares a library; the first level of the picker. */
val apps: List<MediaApp> = emptyList(),
/** The app being browsed; null while choosing one. */
val app: MediaApp? = null,
/** The folders entered inside [app], outermost first. */
val trail: List<MediaEntry> = emptyList(),
val entries: List<MediaEntry> = emptyList(),
/** [app] would not share this level of its library. */
val refused: Boolean = false,
)
/**
* "Choose from a music app…": pick an app, walk its library, take a playable
* item — or take "resume", which plays whatever that app last played. The
* result is a [MediaSources] URI, stored exactly where a ringtone URI is.
*
* Its own ViewModel rather than a fourth job for each editor's: the three
* hosts (alarm, timer, settings) only ever see the URI it hands back.
*/
@HiltViewModel
class MediaAppPickerViewModel @Inject internal constructor(
private val library: MediaLibrary,
) : ViewModel() {
private val _state = MutableStateFlow(MediaAppPickerUiState())
val state: StateFlow<MediaAppPickerUiState> = _state.asStateFlow()
/** The level being loaded; a newer navigation supersedes it. */
private var loading: Job? = null
/** Whether the picker is showing; a rotation re-runs [onOpen] while it is. */
private var open: Boolean = false
/**
* Back to the app list, freshly read — apps come and go between openings.
* A no-op while already open, so a rotation keeps the user where they had
* browsed to.
*/
fun onOpen() {
if (open) return
open = true
library.release()
_state.value = MediaAppPickerUiState()
load { MediaAppPickerUiState(loading = false, apps = library.apps()) }
}
fun onAppChosen(app: MediaApp) = browse(app, trail = emptyList())
fun onFolderOpened(folder: MediaEntry) {
val current = _state.value
val app = current.app ?: return
browse(app, current.trail + folder)
}
/** Up one level. False at the app list, where back means leaving the picker. */
fun onBack(): Boolean {
val current = _state.value
val app = current.app ?: return false
if (current.trail.isEmpty()) {
loading?.cancel()
library.release()
_state.value = current.copy(loading = false, app = null, entries = emptyList(), refused = false)
} else {
browse(app, current.trail.dropLast(1))
}
return true
}
/** The URI for [entry], or for "resume" when [entry] is null; null while no app is open. */
fun uriFor(entry: MediaEntry?): String? {
val app = _state.value.app ?: return null
return MediaSources.encode(MediaSourceRef(app = app, mediaId = entry?.id, title = entry?.title.orEmpty()))
}
fun onClose() {
open = false
loading?.cancel()
library.release()
}
override fun onCleared() {
library.release()
super.onCleared()
}
private fun browse(app: MediaApp, trail: List<MediaEntry>) {
_state.value = _state.value.copy(loading = true, app = app, trail = trail, entries = emptyList(), refused = false)
load {
val children = library.children(app, trail.lastOrNull()?.id)
_state.value.copy(
loading = false,
entries = children.orEmpty().filter { it.browsable || it.playable },
refused = children == null,
)
}
}
private fun load(block: suspend () -> MediaAppPickerUiState) {
loading?.cancel()
loading = viewModelScope.launch { _state.value = block() }
}
}
@@ -13,10 +13,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -30,9 +35,9 @@ import de.jeanlucmakiola.floret.components.positionOf
* device does not list it, and the device's own alarm tones — over a
* `LazyColumn`, because a device's tone list can be hundreds of rows (M5 D17).
*
* "Choose from files…" is an action row rather than an option, so the option
* list stays a list of selectable states. Each unselected row can be
* auditioned through the play button; taking a row stops the preview and
* "Choose from files…" and "Choose from a music app…" are action rows rather
* than options, so the option list stays a list of selectable states. Each
* unselected row can be auditioned through the play button; taking a row stops the preview and
* closes the picker (D21).
*
* The SAF launcher is **not** registered here: this screen only exists while
@@ -60,7 +65,19 @@ internal fun RingtonePickerScreen(
onDismiss = onDismiss,
scrollable = false,
) {
val rows = state.options.size + 1
val rows = state.options.size + 2
// Saveable, so a rotation does not drop the user out of a library
// they have browsed into.
var choosingFromApp by rememberSaveable { mutableStateOf(false) }
if (choosingFromApp) {
MediaAppPickerScreen(
onChoose = { uri ->
choosingFromApp = false
onChoose(uri)
},
onClose = { choosingFromApp = false },
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
@@ -89,10 +106,18 @@ internal fun RingtonePickerScreen(
item {
GroupedRow(
title = stringResource(R.string.ringtone_pick_file),
position = positionOf(rows - 1, rows),
position = positionOf(rows - 2, rows),
onClick = onPickFile,
)
}
item {
GroupedRow(
title = stringResource(R.string.ringtone_pick_media_app),
summary = stringResource(R.string.ringtone_pick_media_app_summary),
position = positionOf(rows - 1, rows),
onClick = { choosingFromApp = true },
)
}
}
}
}
@@ -163,7 +188,7 @@ private fun RingtoneOptionRow(
RingtoneChoice.AppDefault -> defaultUri
RingtoneChoice.Silent -> null
is RingtoneChoice.Sound -> option.uri
}
}?.takeUnless(MediaSources::isMediaSource) // another app's track has no preview
val chosenUri = when (option) {
RingtoneChoice.AppDefault -> null
RingtoneChoice.Silent -> Ringtones.SILENT_URI
+11
View File
@@ -171,6 +171,17 @@
<string name="ringtone_unavailable">Unavailable \u2014 the default alarm sound will be used</string>
<string name="ringtone_unknown">Unknown sound</string>
<string name="ringtone_access_may_lapse">Clockula may lose access to this file later</string>
<string name="ringtone_pick_media_app">Choose from a music app\u2026</string>
<string name="ringtone_pick_media_app_summary">The app plays your alarm</string>
<string name="media_picker_title">Music apps</string>
<string name="media_picker_explainer">The chosen app plays the alarm itself. If it can\u2019t \u2014 not installed, logged out or offline \u2014 or if headphones or a Bluetooth device are connected, Clockula rings with the default alarm sound instead.</string>
<string name="media_picker_no_apps">No app on this device shares its music library with other apps.</string>
<string name="media_picker_refused">%1$s doesn\u2019t share its library with other apps.</string>
<string name="media_picker_empty">Nothing here.</string>
<string name="media_picker_resume">Resume where you left off</string>
<string name="media_picker_resume_summary">Whatever %1$s played last</string>
<string name="media_picker_use">Use %1$s</string>
<string name="media_resume_title">Resume in %1$s</string>
<!-- M7: the stopwatch tab -->
<string name="stopwatch_start">Start</string>
<string name="stopwatch_pause">Pause</string>
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* A media source lives in the same string column as every other sound, so its
* encoding has to survive anything a library can name — and decoding has to
* refuse, not throw, on anything else.
*/
class MediaSourcesTest {
private val app = MediaApp("org.videolan.vlc", "org.videolan.vlc.MediaBrowserService", "VLC")
@Test
fun `a chosen item survives the round trip`() {
val ref = MediaSourceRef(app, mediaId = "playlist:42", title = "Morning & Rise = 100%")
assertThat(MediaSources.decode(MediaSources.encode(ref))).isEqualTo(ref)
}
@Test
fun `resume carries no media id`() {
val ref = MediaSourceRef(app, mediaId = null, title = "")
assertThat(MediaSources.decode(MediaSources.encode(ref))).isEqualTo(ref)
}
@Test
fun `ids and titles with reserved characters, unicode and spaces survive`() {
val ref = MediaSourceRef(app, mediaId = "a b/c?d#e&f=g+h", title = "Morgenstimmung — Grieg ✿")
assertThat(MediaSources.decode(MediaSources.encode(ref))).isEqualTo(ref)
}
@Test
fun `the encoded form is recognised and other sounds are not`() {
val uri = MediaSources.encode(MediaSourceRef(app, mediaId = "x", title = "X"))
assertThat(MediaSources.isMediaSource(uri)).isTrue()
assertThat(MediaSources.isMediaSource("content://media/1")).isFalse()
assertThat(MediaSources.isMediaSource(Ringtones.SILENT_URI)).isFalse()
assertThat(MediaSources.isMediaSource(null)).isFalse()
}
@Test
fun `anything malformed decodes to null`() {
val inputs = listOf(
null,
"content://media/1",
Ringtones.SILENT_URI,
MediaSources.PREFIX,
MediaSources.PREFIX + "svc=x",
MediaSources.PREFIX + "pkg=x",
MediaSources.PREFIX + "pkg=%zz&svc=y",
)
for (input in inputs) assertThat(MediaSources.decode(input)).isNull()
}
}
@@ -2,6 +2,9 @@ package de.jeanlucmakiola.clockula.domain.ring
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaSourceRef
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.Ringtones
import org.junit.jupiter.api.Test
@@ -41,6 +44,19 @@ class AudioSourcePolicyTest {
).inOrder()
}
@Test
fun `a music app's sound is tried first, with the defaults behind it`() {
val uri = MediaSources.encode(
MediaSourceRef(MediaApp("org.example.player", "org.example.player.Library", "Player"), "id", "Song"),
)
assertThat(AudioSourcePolicy.sourcesFor(uri)).containsExactly(
RingtoneSourceKind.MEDIA_APP,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
).inOrder()
}
@Test
fun `the silent sentinel offers no audio source at all`() {
val sources = AudioSourcePolicy.sourcesFor(Ringtones.SILENT_URI)
@@ -0,0 +1,104 @@
package de.jeanlucmakiola.clockula.domain.ring
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ring.MediaRingPolicy.Stall
import org.junit.jupiter.api.Test
import kotlin.time.Duration
/**
* The rules that keep a ring another app plays from ever being a silent one:
* bounded waits, a watchdog that cannot be held off by "buffering", and a
* route check that refuses whenever the media stream may not be heard.
*/
class MediaRingPolicyTest {
private val tick = MediaRingPolicy.WATCH_TICK
private fun run(vararg playback: MediaPlayback): Stall =
playback.fold(Stall()) { stall, state -> MediaRingPolicy.advance(stall, state, tick) }
private fun repeat(state: MediaPlayback, over: Duration): Array<MediaPlayback> =
Array((over / tick).toInt()) { state }
@Test
fun `a brief stop is a track change, a long one is a failure`() {
val brief = run(*repeat(MediaPlayback.STOPPED, MediaRingPolicy.STALL_GRACE - tick))
val long = run(*repeat(MediaPlayback.STOPPED, MediaRingPolicy.STALL_GRACE))
assertThat(MediaRingPolicy.shouldFallBack(brief)).isFalse()
assertThat(MediaRingPolicy.shouldFallBack(long)).isTrue()
}
@Test
fun `buffering forever is a failure too`() {
val brief = run(*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE - tick))
val forever = run(*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE))
assertThat(MediaRingPolicy.shouldFallBack(brief)).isFalse()
assertThat(MediaRingPolicy.shouldFallBack(forever)).isTrue()
}
@Test
fun `alternating buffering and stopping cannot dodge the watchdog`() {
val states = Array((MediaRingPolicy.BUFFER_GRACE / tick).toInt()) { index ->
if (index % 2 == 0) MediaPlayback.TRANSITIONING else MediaPlayback.STOPPED
}
assertThat(MediaRingPolicy.shouldFallBack(run(*states))).isTrue()
}
@Test
fun `playing resets both clocks`() {
val stall = run(
*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE - tick),
MediaPlayback.PLAYING,
)
assertThat(stall).isEqualTo(Stall())
}
@Test
fun `the target is the alarm stream's share of the media stream`() {
// Alarm at 4 of 7, media max 15: ceil(4/7 * 15) = 9.
assertThat(MediaRingPolicy.mediaVolumeTarget(alarmIndex = 4, alarmMax = 7, musicMax = 15)).isEqualTo(9)
assertThat(MediaRingPolicy.mediaVolumeTarget(alarmIndex = 7, alarmMax = 7, musicMax = 15)).isEqualTo(15)
}
@Test
fun `the media stream is never left at zero`() {
assertThat(MediaRingPolicy.mediaVolumeTarget(alarmIndex = 0, alarmMax = 7, musicMax = 15)).isEqualTo(1)
assertThat(MediaRingPolicy.mediaVolumeIndex(0f, target = 9)).isEqualTo(1)
}
@Test
fun `the ramp climbs and never passes the target`() {
val levels = listOf(0f, 0.25f, 0.5f, 0.75f, 1f, 1.5f).map { MediaRingPolicy.mediaVolumeIndex(it, target = 15) }
assertThat(levels).isInOrder()
assertThat(levels.max()).isEqualTo(15)
}
@Test
fun `degenerate stream ranges do not throw`() {
assertThat(MediaRingPolicy.mediaVolumeTarget(alarmIndex = 3, alarmMax = 0, musicMax = 15)).isEqualTo(15)
assertThat(MediaRingPolicy.mediaVolumeTarget(alarmIndex = 3, alarmMax = 7, musicMax = 0)).isEqualTo(0)
assertThat(MediaRingPolicy.mediaVolumeIndex(1f, target = 0)).isEqualTo(0)
}
@Test
fun `media is used only on a speaker, and only when Do Not Disturb lets media through`() {
val speaker = listOf(AudioOutputKind.SPEAKER, AudioOutputKind.NOT_MEDIA)
assertThat(MediaRoutePolicy.mediaAudible(dndAllowsMedia = true, outputs = speaker)).isTrue()
assertThat(MediaRoutePolicy.mediaAudible(dndAllowsMedia = false, outputs = speaker)).isFalse()
}
@Test
fun `headphones or an unknown external device route the ring to the alarm sound`() {
for (kind in listOf(AudioOutputKind.PERSONAL, AudioOutputKind.UNCERTAIN)) {
val outputs = listOf(AudioOutputKind.SPEAKER, kind)
assertThat(MediaRoutePolicy.mediaAudible(dndAllowsMedia = true, outputs = outputs)).isFalse()
}
}
}
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.ringtones.MediaLibrary
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
/**
* Other apps' libraries, scripted: [libraries] maps an app to its folders by
* parent id (`null` = root). An app absent from it refuses, as the big
* streaming apps do.
*/
class FakeMediaLibrary(
var apps: List<MediaApp> = emptyList(),
var libraries: Map<MediaApp, Map<String?, List<MediaEntry>>> = emptyMap(),
) : MediaLibrary {
var releases: Int = 0
private set
override suspend fun apps(): List<MediaApp> = apps
override suspend fun children(app: MediaApp, parentId: String?): List<MediaEntry>? =
libraries[app]?.let { it[parentId].orEmpty() }
override fun release() {
releases++
}
}
@@ -0,0 +1,122 @@
package de.jeanlucmakiola.clockula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.MediaApp
import de.jeanlucmakiola.clockula.domain.MediaEntry
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.testing.FakeMediaLibrary
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
class MediaAppPickerViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val player = MediaApp("org.example.player", "org.example.player.Library", "Player")
private val streamer = MediaApp("com.example.stream", "com.example.stream.Browse", "Streamer")
private val playlists = MediaEntry("playlists", "Playlists", null, browsable = true, playable = false)
private val morning = MediaEntry("pl:1", "Morning", "12 songs", browsable = true, playable = true)
private val song = MediaEntry("song:7", "Sunrise", "Artist", browsable = false, playable = true)
private val junk = MediaEntry("junk", "Neither", null, browsable = false, playable = false)
private val library = FakeMediaLibrary(
apps = listOf(player, streamer),
libraries = mapOf(
player to mapOf(
null to listOf(playlists, junk),
"playlists" to listOf(morning),
"pl:1" to listOf(song),
),
),
)
private fun opened() = MediaAppPickerViewModel(library).also { it.onOpen() }
@Test
fun `opening lists the apps that share a library`() = runTest(main.dispatcher) {
val state = opened().state.value
assertThat(state.loading).isFalse()
assertThat(state.app).isNull()
assertThat(state.apps).containsExactly(player, streamer).inOrder()
}
@Test
fun `an app's root drops entries that are neither folders nor playable`() = runTest(main.dispatcher) {
val viewModel = opened()
viewModel.onAppChosen(player)
assertThat(viewModel.state.value.entries).containsExactly(playlists)
assertThat(viewModel.state.value.refused).isFalse()
}
@Test
fun `an app that will not share says so`() = runTest(main.dispatcher) {
val viewModel = opened()
viewModel.onAppChosen(streamer)
assertThat(viewModel.state.value.refused).isTrue()
assertThat(viewModel.state.value.entries).isEmpty()
}
@Test
fun `folders nest and back climbs out of them, then out of the app`() = runTest(main.dispatcher) {
val viewModel = opened()
viewModel.onAppChosen(player)
viewModel.onFolderOpened(playlists)
viewModel.onFolderOpened(morning)
assertThat(viewModel.state.value.trail).containsExactly(playlists, morning).inOrder()
assertThat(viewModel.state.value.entries).containsExactly(song)
assertThat(viewModel.onBack()).isTrue()
assertThat(viewModel.state.value.entries).containsExactly(morning)
assertThat(viewModel.onBack()).isTrue()
assertThat(viewModel.state.value.trail).isEmpty()
assertThat(viewModel.onBack()).isTrue()
assertThat(viewModel.state.value.app).isNull()
assertThat(viewModel.onBack()).isFalse()
}
@Test
fun `a chosen item encodes the app, the id and the title`() = runTest(main.dispatcher) {
val viewModel = opened()
viewModel.onAppChosen(player)
val ref = MediaSources.decode(viewModel.uriFor(song))!!
assertThat(ref.app).isEqualTo(player)
assertThat(ref.mediaId).isEqualTo("song:7")
assertThat(ref.title).isEqualTo("Sunrise")
}
@Test
fun `resume encodes no media id, and nothing is chosen before an app is`() = runTest(main.dispatcher) {
val viewModel = opened()
assertThat(viewModel.uriFor(null)).isNull()
viewModel.onAppChosen(player)
assertThat(MediaSources.decode(viewModel.uriFor(null))!!.mediaId).isNull()
}
@Test
fun `reopening while open keeps the place, reopening after close starts over`() = runTest(main.dispatcher) {
val viewModel = opened()
viewModel.onAppChosen(player)
viewModel.onOpen()
assertThat(viewModel.state.value.app).isEqualTo(player)
viewModel.onClose()
viewModel.onOpen()
assertThat(viewModel.state.value.app).isNull()
}
}
+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
+9
View File
@@ -80,6 +80,15 @@ own audio file through Android's system file picker. Clockula can then read
exactly that file, so it can play it when the alarm rings. It has no access to
your other files.
You can also let a music app on your device play the alarm. Clockula then sees
which installed apps share their music library through Android's standard media
interface, and nothing else about your installed apps. When you browse one,
Clockula reads the folder and track names that app chooses to share and stores
the item you pick. When the alarm rings, Clockula asks that app to play it. The
music app plays it with its own permissions and under its own privacy policy.
If it streams the music, the music app opens that connection, not Clockula.
Clockula still has no internet access.
### Notifications and the lock screen
Alarms, timers and the running stopwatch appear as local notifications, and a