feat(data): the device ringtone catalogue and a single-tone previewer

The catalogue closes its cursor and survives a device with no alarm tones at
all: the picker is never empty, because "Silent" and the app default are
always offerable. A chosen URI that later becomes unreadable is disclosed on
the row rather than quietly rewritten — the user should know their alarm
cannot play what they picked.

The previewer serialises behind a mutex and releases a tone that finished
preparing after a stop, so two taps cannot leave two alarm sounds playing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:33 +02:00
co-authored by Claude Opus 5
parent 8002651e3f
commit 431c4c2697
5 changed files with 250 additions and 0 deletions
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.clockula.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
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
* 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).
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RingtoneModule {
@Binds
@Singleton
abstract fun bindRingtoneCatalog(impl: SystemRingtoneCatalog): RingtoneCatalog
@Binds
@Singleton
abstract fun bindRingtonePreviewer(impl: SystemRingtonePreviewer): RingtonePreviewer
}
@@ -0,0 +1,21 @@
package de.jeanlucmakiola.clockula.data.ringtones
import de.jeanlucmakiola.clockula.domain.RingtoneOption
interface RingtoneCatalog {
/** The device's alarm sounds, title-sorted. **Empty** rather than throwing. */
suspend fun alarmTones(): List<RingtoneOption>
/** A display title for [uri], or null when the device cannot name it. */
suspend fun titleOf(uri: String): String?
/** Whether [uri] can be opened for reading right now. False rather than throwing. */
suspend fun isPlayable(uri: String): Boolean
/**
* Takes a persistable read grant on a SAF-picked document.
* True when the grant was taken; **false does not mean "reject"** — the
* caller still stores the URI and discloses that access may lapse.
*/
suspend fun persistPickedDocument(uri: String): Boolean
}
@@ -0,0 +1,9 @@
package de.jeanlucmakiola.clockula.data.ringtones
interface RingtonePreviewer {
/** Plays [uri] once over USAGE_ALARM, stopping anything already playing. */
suspend fun play(uri: String)
/** Idempotent, and safe to call off a coroutine. */
fun stop()
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.OpenableColumns
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The device's own alarm sounds, through `RingtoneManager` and the
* `ContentResolver`. The one documented exception to "a repository takes no
* dispatcher": a raw provider query dispatches nothing of its own, and every
* call here is blocking I/O on a cursor.
*
* Nothing throws outwards. A stripped ROM, a revoked media grant or an
* unmounted card yields an empty list, a null title or `false` — the picker
* discloses that and the alarm's audio still falls through to the device
* default (M5 D19).
*/
@Singleton
class SystemRingtoneCatalog @Inject constructor(
@param:ApplicationContext private val context: Context,
@param:IoDispatcher private val io: CoroutineDispatcher,
) : RingtoneCatalog {
override suspend fun alarmTones(): List<RingtoneOption> = withContext(io) {
runCatching {
val manager = RingtoneManager(context).apply { setType(RingtoneManager.TYPE_ALARM) }
// `.use`: the cursor owns a `CursorWindow` of hundreds of rows on a
// stock ROM, and the picker can be opened again and again. Leaving
// it to the finalizer leaks one window per opening and trips
// StrictMode's leaked-closable detectors.
manager.cursor.use { cursor ->
buildList {
while (cursor.moveToNext()) {
val position = cursor.position
val uri = manager.getRingtoneUri(position)?.toString() ?: continue
val title = cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX) ?: continue
add(RingtoneOption(uri, title))
}
}
}
}.getOrDefault(emptyList()).sortedBy { it.title.lowercase() }
}
override suspend fun titleOf(uri: String): String? = withContext(io) {
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.
runCatching { RingtoneManager.getRingtone(context, parsed)?.getTitle(context) }.getOrNull()
?: displayName(parsed)
}
override suspend fun isPlayable(uri: String): Boolean = withContext(io) {
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
runCatching {
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
}.getOrDefault(false)
}
override suspend fun persistPickedDocument(uri: String): Boolean = withContext(io) {
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
runCatching {
context.contentResolver.takePersistableUriPermission(
parsed,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
true
}.getOrDefault(false)
}
private fun displayName(uri: Uri): String? {
if (uri.scheme != ContentResolver.SCHEME_CONTENT) return uri.lastPathSegment
return runCatching {
context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
}.getOrNull()
}
}
@@ -0,0 +1,103 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* A preview over the same `USAGE_ALARM` / `CONTENT_TYPE_SONIFICATION`
* attributes the ring service uses, so a preview sounds exactly like the alarm
* will — at the user's own alarm volume, and treated the same way by Do Not
* Disturb.
*
* Deliberately **no** audio-focus request and **no** volume ramp: a preview is
* not an alarm, and a transient focus gain from a settings screen would be rude
* (M5 D21). It is non-looping and releases itself on completion.
*/
@Singleton
class SystemRingtonePreviewer @Inject constructor(
@param:ApplicationContext private val context: Context,
@param:IoDispatcher private val io: CoroutineDispatcher,
) : RingtonePreviewer {
private val attributes: AudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
@Volatile
private var player: MediaPlayer? = null
/** Set by [stop], so a player that finished preparing after it is released too. */
@Volatile
private var stopped: Boolean = false
/**
* One preview at a time: two taps in quick succession would otherwise both
* pass a null [player], both suspend in `prepare`, and then both `start` —
* two alarm tones at once, with the first `MediaPlayer` overwritten and
* never released (M5 D21).
*/
private val lock = Mutex()
/** Plays [uri] once over USAGE_ALARM, stopping anything already playing. */
override suspend fun play(uri: String) {
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.
stop()
lock.withLock {
stopped = false
// `prepare` is blocking I/O, which is the whole reason this is suspending.
val prepared = withContext(io) { prepare(parsed) } ?: return
// Stopped while we were preparing — by the next preview, by taking a
// row, or by leaving the picker: release rather than start, or the
// sound would outlive what asked for it (as `AlarmAudioPlayer` does).
if (stopped) {
release(prepared)
return
}
player = prepared
prepared.setOnCompletionListener { finished ->
release(finished)
if (player === finished) player = null
}
runCatching { prepared.start() }
}
}
/** Idempotent, and safe to call off a coroutine. */
override fun stop() {
stopped = true
player?.let(::release)
player = null
}
private fun prepare(uri: Uri): MediaPlayer? {
val candidate = MediaPlayer()
return runCatching {
candidate.setAudioAttributes(attributes)
candidate.setDataSource(context, uri)
candidate.isLooping = false
candidate.prepare()
candidate
}.getOrElse {
release(candidate)
null
}
}
private fun release(player: MediaPlayer) {
runCatching { player.stop() }
runCatching { player.release() }
}
}