From 431c4c2697b24f0da5e4fcd7853aa82ec2af963f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 12 Sep 2026 14:43:33 +0200 Subject: [PATCH] feat(data): the device ringtone catalogue and a single-tone previewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV --- .../clockula/data/di/RingtoneModule.kt | 29 +++++ .../data/ringtones/RingtoneCatalog.kt | 21 ++++ .../data/ringtones/RingtonePreviewer.kt | 9 ++ .../data/ringtones/SystemRingtoneCatalog.kt | 88 +++++++++++++++ .../data/ringtones/SystemRingtonePreviewer.kt | 103 ++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/di/RingtoneModule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtoneCatalog.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtoneCatalog.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RingtoneModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RingtoneModule.kt new file mode 100644 index 0000000..394af00 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RingtoneModule.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtoneCatalog.kt new file mode 100644 index 0000000..ed5ae35 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtoneCatalog.kt @@ -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 + + /** 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt new file mode 100644 index 0000000..60cc433 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtoneCatalog.kt new file mode 100644 index 0000000..44a2289 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtoneCatalog.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt new file mode 100644 index 0000000..c998995 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt @@ -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() } + } +}