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