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