refactor: lift the ring machinery out of the alarm package

The audio player, the vibrator, the volume ramp and the audio-source policy
were never alarm-specific — timers need the same ones, and a second
MediaPlayer in the app would be a bug. Moved and renamed, no behaviour
changed: the moved tests differ by their package line and one constant name.

The ringtone picker moves to ui/common for the same reason.
This commit is contained in:
2026-09-12 16:48:07 +02:00
parent 5a01efb7bf
commit 5430e0c778
25 changed files with 860 additions and 102 deletions
@@ -12,9 +12,11 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.domain.alarm.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -43,8 +45,8 @@ class AlarmRingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { AlarmAudioPlayer(this) }
private val vibrator by lazy { AlarmVibrator(this) }
private val audio by lazy { RingAudioPlayer(this) }
private val vibrator by lazy { RingVibrator(this) }
private var wakeLock: PowerManager.WakeLock? = null
private var ringing: Job? = null
@@ -61,7 +61,7 @@ class SystemRingtonePreviewer @Inject constructor(
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).
// sound would outlive what asked for it (as `RingAudioPlayer` does).
if (stopped) {
release(prepared)
return
@@ -1,12 +1,12 @@
package de.jeanlucmakiola.clockula.domain.alarm
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* The ring cycle's constants. They are values rather than settings: v1 does not
* offer them to the user (docs/PLAN.md §4, slice plan D8/D19).
* The alarm ring cycle's constants. They are values rather than settings: v1
* does not offer them to the user (docs/PLAN.md §4). The audio chain's own
* constants live in `domain/ring/RingAudio`, shared with the timer's ring.
*/
object AlarmRing {
/**
@@ -20,10 +20,4 @@ object AlarmRing {
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
*/
val FIRE_GRACE: Duration = 2.minutes
/** How often the ringing service steps the volume ramp. */
val RAMP_TICK: Duration = 200.milliseconds
/** The ramp's floor — an alarm never starts at true silence. */
const val MIN_VOLUME: Float = 0.05f
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Ringtones
/** What the platform will currently let the app do. */
data class CapabilitySnapshot(
val canScheduleExactAlarms: Boolean,
@@ -29,12 +27,6 @@ object RingPresentationPolicy {
)
}
object RingFallbackPolicy {
/** True when the alarm must vibrate — including when it wants to but cannot make a sound. */
fun vibrationRequired(audioAvailable: Boolean, vibrateSetting: Boolean): Boolean =
vibrateSetting || !audioAvailable
}
enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
object SchedulingPolicy {
@@ -42,34 +34,3 @@ object SchedulingPolicy {
fun modeFor(canScheduleExactAlarms: Boolean): ScheduleMode =
if (canScheduleExactAlarms) ScheduleMode.EXACT_ALARM_CLOCK else ScheduleMode.INEXACT_FALLBACK
}
/** Long buzz, short gap, long buzz, longer gap — loudly rhythmic, not a doorbell. */
object AlarmVibrationPattern {
val TIMINGS: LongArray = longArrayOf(500L, 250L, 500L, 1_000L)
val AMPLITUDES: IntArray = intArrayOf(255, 0, 255, 0)
/** The index the pattern loops from: 0, so the whole pattern repeats. */
const val REPEAT_INDEX: Int = 0
}
enum class RingtoneSourceKind { ALARM_OWN, DEVICE_DEFAULT, BUILT_IN_DEFAULT }
object AudioSourcePolicy {
/**
* The audio sources to try, in order. **Empty** for the silent sentinel —
* which is what turns forced vibration on, so the alarm still does something.
*/
fun sourcesFor(ringtoneUri: String?): List<RingtoneSourceKind> = when {
Ringtones.isSilent(ringtoneUri) -> emptyList()
Ringtones.normalise(ringtoneUri) == null -> listOf(
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
else -> listOf(
RingtoneSourceKind.ALARM_OWN,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
}
}
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.clockula.domain.ring
import de.jeanlucmakiola.clockula.domain.Ringtones
enum class RingtoneSourceKind {
/** The ringing thing's own URI — an alarm's or a timer's. */
ALARM_OWN,
DEVICE_DEFAULT,
BUILT_IN_DEFAULT,
}
object AudioSourcePolicy {
/**
* The audio sources to try, in order. **Empty** for the silent sentinel —
* which is what turns forced vibration on, so the ring still does something.
*/
fun sourcesFor(ringtoneUri: String?): List<RingtoneSourceKind> = when {
Ringtones.isSilent(ringtoneUri) -> emptyList()
Ringtones.normalise(ringtoneUri) == null -> listOf(
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
else -> listOf(
RingtoneSourceKind.ALARM_OWN,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
)
}
}
object RingFallbackPolicy {
/** True when the ring must vibrate — including when it wants to sound but cannot. */
fun vibrationRequired(audioAvailable: Boolean, vibrateSetting: Boolean): Boolean =
vibrateSetting || !audioAvailable
}
/** Long buzz, short gap, long buzz, longer gap — loudly rhythmic, not a doorbell. */
object VibrationPattern {
val TIMINGS: LongArray = longArrayOf(500L, 250L, 500L, 1_000L)
val AMPLITUDES: IntArray = intArrayOf(255, 0, 255, 0)
/** The index the pattern loops from: 0, so the whole pattern repeats. */
const val REPEAT_INDEX: Int = 0
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.domain.ring
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* The audio chain's constants, shared by both ring paths. They moved out of
* `AlarmRing` when the timer's ring started using the same player: a constant
* named for one caller reads as that caller's private business (slice plan D8).
*/
object RingAudio {
/** How often a ringing service steps the volume ramp. */
val RAMP_TICK: Duration = 200.milliseconds
/** The ramp's floor — a ramping ring never starts at true silence. */
const val MIN_VOLUME: Float = 0.05f
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.domain.alarm
package de.jeanlucmakiola.clockula.domain.ring
import kotlin.time.Duration
@@ -7,13 +7,13 @@ import kotlin.time.Duration
* sounds like a steady climb rather than a jump at the end.
*/
object VolumeRamp {
/** `AlarmRing.MIN_VOLUME`..1f. Never throws. */
/** `RingAudio.MIN_VOLUME`..1f. Never throws. */
fun levelAt(elapsed: Duration, ramp: Duration): Float {
if (ramp <= Duration.ZERO || elapsed >= ramp) return 1f
if (elapsed <= Duration.ZERO) return AlarmRing.MIN_VOLUME
if (elapsed <= Duration.ZERO) return RingAudio.MIN_VOLUME
val progress = (elapsed / ramp).toFloat()
val level = AlarmRing.MIN_VOLUME + (1f - AlarmRing.MIN_VOLUME) * progress * progress
return level.coerceIn(AlarmRing.MIN_VOLUME, 1f)
val level = RingAudio.MIN_VOLUME + (1f - RingAudio.MIN_VOLUME) * progress * progress
return level.coerceIn(RingAudio.MIN_VOLUME, 1f)
}
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.alarm.ring
package de.jeanlucmakiola.clockula.ring
import android.content.Context
import android.media.AudioAttributes
@@ -7,10 +7,10 @@ import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AudioSourcePolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingtoneSourceKind
import de.jeanlucmakiola.clockula.domain.alarm.VolumeRamp
import de.jeanlucmakiola.clockula.domain.ring.AudioSourcePolicy
import de.jeanlucmakiola.clockula.domain.ring.RingAudio
import de.jeanlucmakiola.clockula.domain.ring.RingtoneSourceKind
import de.jeanlucmakiola.clockula.domain.ring.VolumeRamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,10 +27,10 @@ import kotlin.time.Duration.Companion.seconds
* which would edit the user's own alarm volume and leave it edited.
*
* [start] returns false when no audio source could be opened at all — which is
* what turns forced vibration on (`RingFallbackPolicy`), so the alarm is never
* silent.
* what turns forced vibration on (`RingFallbackPolicy`), so neither an alarm
* nor a timer is ever silent.
*/
class AlarmAudioPlayer(private val context: Context) {
class RingAudioPlayer(private val context: Context) {
private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java)
@@ -48,8 +48,8 @@ class AlarmAudioPlayer(private val context: Context) {
private var stopped: Boolean = false
/**
* Tries the alarm's ringtone, then the device default, then the built-in
* default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the
* 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
* moment the alarm rings is the worst possible moment to block the main
* thread with it.
*/
@@ -77,8 +77,8 @@ class AlarmAudioPlayer(private val context: Context) {
ramp = scope.launch {
var elapsed = Duration.ZERO
while (isActive && elapsed < rampFor) {
delay(AlarmRing.RAMP_TICK)
elapsed += AlarmRing.RAMP_TICK
delay(RingAudio.RAMP_TICK)
elapsed += RingAudio.RAMP_TICK
val level = VolumeRamp.levelAt(elapsed, rampFor)
runCatching { player?.setVolume(level, level) }
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.alarm.ring
package de.jeanlucmakiola.clockula.ring
import android.content.Context
import android.media.AudioAttributes
@@ -6,14 +6,14 @@ import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import de.jeanlucmakiola.clockula.domain.alarm.AlarmVibrationPattern
import de.jeanlucmakiola.clockula.domain.ring.VibrationPattern
/**
* The waveform, looped, with `USAGE_ALARM` attributes so Do Not Disturb treats
* it the way it treats the sound. `minSdk` is 29, so `VibratorManager` (API 31+)
* it the way it treats the sound. Shared by the alarm's ring and the timer's. `minSdk` is 29, so `VibratorManager` (API 31+)
* is guarded and the older accessor stands in below it.
*/
class AlarmVibrator(private val context: Context) {
class RingVibrator(private val context: Context) {
private val attributes: AudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
@@ -30,9 +30,9 @@ class AlarmVibrator(private val context: Context) {
fun start() {
val device = vibrator?.takeIf { it.hasVibrator() } ?: return
val effect = VibrationEffect.createWaveform(
AlarmVibrationPattern.TIMINGS,
AlarmVibrationPattern.AMPLITUDES,
AlarmVibrationPattern.REPEAT_INDEX,
VibrationPattern.TIMINGS,
VibrationPattern.AMPLITUDES,
VibrationPattern.REPEAT_INDEX,
)
runCatching {
@Suppress("DEPRECATION")
@@ -0,0 +1,184 @@
package de.jeanlucmakiola.clockula.ui.common
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.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
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.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* The alarm sound picker: App default, Silent, the user's own file when the
* 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
* closes the picker (D21).
*
* The SAF launcher is **not** registered here: this screen only exists while
* the ViewModel says the picker is open, and that flag does not survive process
* death — a result restored into a fresh process would have nowhere to land.
* [onPickFile] comes from the host, which registers it unconditionally (D20).
*/
@Composable
internal fun RingtonePickerScreen(
state: RingtonePickerUiState,
defaultUri: String?,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
onPickFile: () -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.ringtone_picker_title),
onDismiss = onDismiss,
scrollable = false,
) {
val rows = state.options.size + 1
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (state.deviceHasNoSounds) {
item {
Text(
text = stringResource(R.string.ringtone_none_available),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = GroupedListInset,
vertical = 8.dp,
),
)
}
}
if (state.loading) {
item {
CircularProgressIndicator(
modifier = Modifier.padding(GroupedListInset),
)
}
}
ringtoneOptions(state, rows, onChoose, onPreview, defaultUri)
item {
GroupedRow(
title = stringResource(R.string.ringtone_pick_file),
position = positionOf(rows - 1, rows),
onClick = onPickFile,
)
}
}
}
}
private fun LazyListScope.ringtoneOptions(
state: RingtonePickerUiState,
rows: Int,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
defaultUri: String?,
) {
state.options.forEachIndexed { index, option ->
item(key = optionKey(option)) {
RingtoneOptionRow(
option = option,
position = positionOf(index, rows),
selected = RingtonePicker.isSelected(option, state.selectedUri),
defaultUri = defaultUri,
defaultTitle = state.defaultTitle,
onChoose = onChoose,
onPreview = onPreview,
)
}
}
}
private fun optionKey(option: RingtoneChoice): String = when (option) {
RingtoneChoice.AppDefault -> "app-default"
RingtoneChoice.Silent -> "silent"
is RingtoneChoice.Sound -> option.uri
}
@Composable
private fun RingtoneOptionRow(
option: RingtoneChoice,
position: Position,
selected: Boolean,
defaultUri: String?,
defaultTitle: String?,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
) {
val unknown = stringResource(R.string.ringtone_unknown)
val deviceDefault = stringResource(R.string.ringtone_device_default)
val silentTitle = stringResource(R.string.ringtone_silent)
val title = when (option) {
RingtoneChoice.AppDefault -> stringResource(R.string.override_app_default)
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent)
is RingtoneChoice.Sound -> option.title.ifBlank { unknown }
}
val summary = when (option) {
// What "App default" currently resolves to, so inheriting is not blind.
// The title is the *resolved* one, not a scan of the device's tone list:
// a SAF-picked default is absent from that list, and the row would then
// claim the device default instead (M5 D16).
RingtoneChoice.AppDefault -> when {
defaultUri == null -> deviceDefault
Ringtones.isSilent(defaultUri) -> silentTitle
else -> defaultTitle ?: unknown
}
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent_summary)
is RingtoneChoice.Sound -> null
}
val previewUri = when (option) {
RingtoneChoice.AppDefault -> defaultUri
RingtoneChoice.Silent -> null
is RingtoneChoice.Sound -> option.uri
}
val chosenUri = when (option) {
RingtoneChoice.AppDefault -> null
RingtoneChoice.Silent -> Ringtones.SILENT_URI
is RingtoneChoice.Sound -> option.uri
}
GroupedRow(
title = title,
summary = summary,
position = position,
selected = selected,
trailing = when {
selected -> {
{ SelectedCheck() }
}
previewUri != null -> {
{
IconButton(onClick = { onPreview(previewUri) }) {
Icon(Icons.Rounded.PlayArrow, contentDescription = title)
}
}
}
else -> null
},
onClick = { onChoose(chosenUri) },
)
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.clockula.ui.common
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import de.jeanlucmakiola.clockula.domain.Ringtones
sealed interface RingtoneChoice {
data object AppDefault : RingtoneChoice
data object Silent : RingtoneChoice
data class Sound(val uri: String, val title: String) : RingtoneChoice
}
data class RingtonePickerUiState(
val loading: Boolean,
val options: List<RingtoneChoice>,
/** The alarm's stored override: null = App default, sentinel = Silent. */
val selectedUri: String?,
/** The device reported no alarm sounds at all (M5 D19). */
val deviceHasNoSounds: Boolean,
/**
* The app default's own resolved title, or null when nothing named it — so
* the "App default" row can say what inheriting means even when the default
* is a SAF-picked file the device's tone list does not contain (M5 D16).
*/
val defaultTitle: String?,
)
internal object RingtonePicker {
/** Pure. App default, Silent, the stored sound when absent from [deviceTones], then [deviceTones]. */
fun optionsFor(
deviceTones: List<RingtoneOption>,
storedUri: String?,
storedTitle: String?,
): List<RingtoneChoice> {
val stored = Ringtones.normalise(storedUri)
// The user's own sound sits directly under Silent when the device does
// not list it — a SAF-picked file would otherwise be invisible, and so
// uncheckable, in the picker that chose it (M5 D17).
val own = stored
?.takeUnless { Ringtones.isSilent(it) }
?.takeUnless { uri -> deviceTones.any { it.uri == uri } }
?.let { RingtoneChoice.Sound(it, storedTitle.orEmpty()) }
val sounds = (listOfNotNull(own) + deviceTones.map { RingtoneChoice.Sound(it.uri, it.title) })
.distinctBy { it.uri }
return listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent) + sounds
}
fun isSelected(option: RingtoneChoice, storedUri: String?): Boolean = when (option) {
RingtoneChoice.AppDefault -> storedUri == null
RingtoneChoice.Silent -> Ringtones.isSilent(storedUri)
is RingtoneChoice.Sound -> option.uri == storedUri
}
}