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:
@@ -47,7 +47,11 @@ class ShellInstrumentedTest {
|
||||
fun tappingATabSwapsTheContent() {
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.timers_empty)).assertIsDisplayed()
|
||||
// The empty Timers tab hosts the setup panel inline (D23), so "Start" is
|
||||
// what is on screen. `timers_set_title` is only the FAB's
|
||||
// `contentDescription`, which `onNodeWithText` does not match — and the
|
||||
// FAB is rendered only once a timer exists.
|
||||
composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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) }
|
||||
}
|
||||
+7
-7
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,18 @@ class ArchitectureRulesTest {
|
||||
private fun List<File>.containing(vararg needles: String): List<String> =
|
||||
filter { file -> needles.any { it in file.readText() } }.map { it.invariantSeparatorsPath }
|
||||
|
||||
/**
|
||||
* As [containing], but blind to comments: prose may name a platform class
|
||||
* it explains — `AlarmEditorViewModel`'s KDoc says "no AlarmManager
|
||||
* traffic" — while code that *uses* one is the offence.
|
||||
*/
|
||||
private fun List<File>.usingInCode(vararg needles: String): List<String> = filter { file ->
|
||||
file.readLines()
|
||||
.map { it.trim() }
|
||||
.filterNot { it.startsWith("//") || it.startsWith("*") || it.startsWith("/*") }
|
||||
.any { line -> needles.any { it in line } }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
@Test
|
||||
fun `Room does not leak above the data layer`() {
|
||||
val offenders = outsideDataLayer.containing("androidx.room")
|
||||
@@ -123,6 +135,7 @@ class ArchitectureRulesTest {
|
||||
val path = file.invariantSeparatorsPath
|
||||
path.contains("de/jeanlucmakiola/clockula/domain/alarm/") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") ||
|
||||
path.contains("de/jeanlucmakiola/clockula/system/")
|
||||
}
|
||||
@@ -171,4 +184,33 @@ class ArchitectureRulesTest {
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* M6 D31: the screen schedules nothing and decides no ring — the mirror of
|
||||
* "the screen re-derives none of the engine's time arithmetic".
|
||||
*/
|
||||
@Test
|
||||
fun `the screen schedules no timer and decides no ring`() {
|
||||
val ui = kotlinSources
|
||||
.filter { it.invariantSeparatorsPath.contains("de/jeanlucmakiola/clockula/ui/") }
|
||||
|
||||
val offenders = ui.usingInCode("TimerScheduler", "TimerService", "TimerRingPolicy", "AlarmManager")
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* M6 D31: "reuses M3's audio path" made mechanical. One `MediaPlayer`
|
||||
* wrapper for both ring paths and one for the picker's preview; a third
|
||||
* audio path cannot be added without the build failing. The `/ring/` match
|
||||
* covers the wrapper both before and after D8's package move.
|
||||
*/
|
||||
@Test
|
||||
fun `MediaPlayer lives only in the ring path and the ringtone catalog`() {
|
||||
val offenders = kotlinSources.containing("MediaPlayer").filterNot { path ->
|
||||
path.contains("/ring/") || path.contains("de/jeanlucmakiola/clockula/data/ringtones/")
|
||||
}
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,4 +387,151 @@ class TimerRepositoryTest {
|
||||
assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds)
|
||||
assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds)
|
||||
}
|
||||
|
||||
// --- M6: "+1 min" on an expired timer, and the two named edits (§5.9, D3/D12) ---
|
||||
|
||||
/** §5.9 #1 */
|
||||
@Test
|
||||
fun `adding time to an expired timer resumes it with exactly the extra`() = runTest {
|
||||
val id = startedTimer()
|
||||
elapsed.value = 1_400.seconds
|
||||
repository.markExpired(id)
|
||||
|
||||
elapsed.value = 2_000.seconds
|
||||
repository.addTime(id, 1.minutes)
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(
|
||||
listOf(
|
||||
timer.state,
|
||||
timer.remaining,
|
||||
timer.startedAtElapsedRealtime,
|
||||
timer.endsAtElapsedRealtime,
|
||||
timer.endsAtWallClock,
|
||||
),
|
||||
).containsExactly(
|
||||
TimerState.RUNNING,
|
||||
1.minutes,
|
||||
2_000.seconds,
|
||||
2_060.seconds,
|
||||
T0 + 1.minutes,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
/** §5.9 #2 */
|
||||
@Test
|
||||
fun `adding time to an expired timer leaves its configured duration alone`() = runTest {
|
||||
val id = startedTimer()
|
||||
repository.markExpired(id)
|
||||
|
||||
repository.addTime(id, 1.minutes)
|
||||
|
||||
assertThat(repository.find(id)!!.duration).isEqualTo(5.minutes)
|
||||
}
|
||||
|
||||
/** §5.9 #3 */
|
||||
@Test
|
||||
fun `setting an idle timer's duration moves its remaining with it`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
|
||||
|
||||
repository.setDuration(id, 90.seconds)
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(listOf(timer.duration, timer.remaining)).containsExactly(90.seconds, 90.seconds)
|
||||
assertThat(
|
||||
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
|
||||
).containsExactly(null, null, null)
|
||||
}
|
||||
|
||||
/** §5.9 #4 */
|
||||
@Test
|
||||
fun `a running timer's duration is not the caller's to change`() = runTest {
|
||||
startedTimer()
|
||||
val before = dao.stored.single()
|
||||
|
||||
repository.setDuration(before.id, 90.seconds)
|
||||
|
||||
assertThat(dao.stored.single()).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** §5.9 #5 */
|
||||
@Test
|
||||
fun `a paused timer's duration is not the caller's to change either`() = runTest {
|
||||
val id = startedTimer()
|
||||
elapsed.value = 1_100.seconds
|
||||
repository.pause(id)
|
||||
val before = dao.stored.single()
|
||||
|
||||
repository.setDuration(id, 90.seconds)
|
||||
|
||||
assertThat(dao.stored.single()).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** §5.9 #6 */
|
||||
@Test
|
||||
fun `a negative duration is stored as zero, never as a negative`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
|
||||
|
||||
repository.setDuration(id, -5.seconds)
|
||||
|
||||
assertThat(repository.find(id)!!.duration).isEqualTo(Duration.ZERO)
|
||||
}
|
||||
|
||||
/** §5.9 #7 */
|
||||
@Test
|
||||
fun `a ringtone is trimmed, and a blank one is absence`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
|
||||
|
||||
repository.setRingtoneUri(id, " content://x ")
|
||||
val trimmed = repository.find(id)!!.ringtoneUri
|
||||
repository.setRingtoneUri(id, " ")
|
||||
|
||||
assertThat(trimmed to repository.find(id)!!.ringtoneUri).isEqualTo("content://x" to null)
|
||||
}
|
||||
|
||||
/** §5.9 #8 */
|
||||
@Test
|
||||
fun `both new edits touch no anchor and go through the DAO transaction`() = runTest {
|
||||
val spy = TransactionSpyTimerDao()
|
||||
val repository = TimerRepositoryImpl(spy, elapsed, wallClock)
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
|
||||
repository.start(id)
|
||||
val anchors = repository.find(id)!!.let {
|
||||
listOf(it.startedAtElapsedRealtime, it.endsAtElapsedRealtime, it.endsAtWallClock)
|
||||
}
|
||||
|
||||
repository.setRingtoneUri(id, "content://x")
|
||||
repository.setDuration(id, 90.seconds)
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(
|
||||
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
|
||||
).isEqualTo(anchors)
|
||||
assertThat(spy.updatesOutsideATransaction).isEqualTo(0)
|
||||
}
|
||||
|
||||
/** §5.9 #9 — added with the review fix for finding 1. */
|
||||
@Test
|
||||
fun `re-anchoring writes nothing when the wall clock has not moved`() = runTest {
|
||||
val id = startedTimer()
|
||||
val before = repository.find(id)!!
|
||||
|
||||
repository.reanchorWallClocks()
|
||||
|
||||
assertThat(repository.find(id)).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** §5.9 #10 — added with the review fix for finding 1. */
|
||||
@Test
|
||||
fun `re-anchoring leaves a paused timer alone`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
|
||||
repository.start(id)
|
||||
repository.pause(id)
|
||||
val before = repository.find(id)!!
|
||||
|
||||
wallClock.advance(1.hours)
|
||||
repository.reanchorWallClocks()
|
||||
|
||||
assertThat(repository.find(id)).isEqualTo(before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
@@ -96,17 +97,4 @@ class RingPoliciesTest {
|
||||
// There is no third branch to fall into — "do not schedule" is not a mode.
|
||||
assertThat(ScheduleMode.entries).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() {
|
||||
val timings = AlarmVibrationPattern.TIMINGS
|
||||
val amplitudes = AlarmVibrationPattern.AMPLITUDES
|
||||
|
||||
assertThat(timings.size).isEqualTo(amplitudes.size)
|
||||
assertThat(timings.size).isGreaterThan(0)
|
||||
assertThat(AlarmVibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList())
|
||||
assertThat(timings.filter { it <= 0L }).isEmpty()
|
||||
assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty()
|
||||
assertThat(amplitudes.filter { it > 0 }).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The looped waveform, now shared by both ring paths (slice plan D8). Moved
|
||||
* here with its assertion unchanged: the pattern did not change, only where it
|
||||
* lives.
|
||||
*/
|
||||
class VibrationPatternTest {
|
||||
|
||||
@Test
|
||||
fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() {
|
||||
val timings = VibrationPattern.TIMINGS
|
||||
val amplitudes = VibrationPattern.AMPLITUDES
|
||||
|
||||
assertThat(timings.size).isEqualTo(amplitudes.size)
|
||||
assertThat(timings.size).isGreaterThan(0)
|
||||
assertThat(VibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList())
|
||||
assertThat(timings.filter { it <= 0L }).isEmpty()
|
||||
assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty()
|
||||
assertThat(amplitudes.filter { it > 0 }).isNotEmpty()
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -17,7 +17,7 @@ class VolumeRampTest {
|
||||
fun `the ramp starts at the floor rather than at silence`() {
|
||||
val level = VolumeRamp.levelAt(Duration.ZERO, ramp)
|
||||
|
||||
assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME)
|
||||
assertThat(level).isEqualTo(RingAudio.MIN_VOLUME)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -44,7 +44,7 @@ class VolumeRampTest {
|
||||
fun `a negative elapsed reading never falls below the floor`() {
|
||||
val level = VolumeRamp.levelAt((-5).seconds, ramp)
|
||||
|
||||
assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME)
|
||||
assertThat(level).isEqualTo(RingAudio.MIN_VOLUME)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,6 +60,6 @@ class VolumeRampTest {
|
||||
VolumeRamp.levelAt((-5).seconds + (35.seconds / 100) * step, ramp)
|
||||
}
|
||||
|
||||
assertThat(samples.filterNot { it >= AlarmRing.MIN_VOLUME && it <= 1f }).isEmpty()
|
||||
assertThat(samples.filterNot { it >= RingAudio.MIN_VOLUME && it <= 1f }).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,12 @@ class RecordingTimerRepository : TimerRepository {
|
||||
override suspend fun reset(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun addTime(id: Long, extra: Duration): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun markExpired(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun setDuration(id: Long, duration: Duration): Unit =
|
||||
TODO("not used by the reboot gate")
|
||||
override suspend fun setRingtoneUri(id: Long, uri: String?): Unit =
|
||||
TODO("not used by the reboot gate")
|
||||
override suspend fun reorder(idsInOrder: List<Long>): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun reanchorWallClocks(): Unit = TODO("not used by the reboot gate")
|
||||
}
|
||||
|
||||
/** A [StopwatchRepository] that records only the one call the reboot gate makes. */
|
||||
|
||||
@@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.snapshotAt
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -19,10 +22,16 @@ import kotlin.time.Duration
|
||||
* observing flow exactly as Room's would, and every mutating call is logged so
|
||||
* "the pill stopped it rather than deleting it" is assertable.
|
||||
*
|
||||
* It has no clock, so the anchors it writes are deliberately absent — a test
|
||||
* that cares about them hands the rows over with [set] instead.
|
||||
* Its clocks are injected and default to standing still, so the anchors it
|
||||
* writes are the ones the real repository would write from one reading of both
|
||||
* — which is what lets a real `TimerEngine` register a deadline over it (M6
|
||||
* D25). A test that wants a particular row still hands it over with [set].
|
||||
*/
|
||||
class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository {
|
||||
class FakeTimerRepository(
|
||||
initial: List<Timer> = emptyList(),
|
||||
private val elapsed: ElapsedRealtimeClock = FakeElapsedRealtimeClock(Duration.ZERO),
|
||||
private val wall: WallClock = FakeWallClock(T0),
|
||||
) : TimerRepository {
|
||||
|
||||
private val rows = MutableStateFlow(initial)
|
||||
private val log = mutableListOf<String>()
|
||||
@@ -72,14 +81,32 @@ class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository
|
||||
|
||||
override suspend fun start(id: Long) {
|
||||
log += "start($id)"
|
||||
mutate(id) { it.copy(state = TimerState.RUNNING) }
|
||||
val now = elapsed.elapsedRealtime()
|
||||
val wallNow = wall.now()
|
||||
mutate(id) { timer ->
|
||||
if (timer.state == TimerState.RUNNING) {
|
||||
timer
|
||||
} else {
|
||||
val left = if (timer.state == TimerState.PAUSED) timer.remaining else timer.duration
|
||||
timer.copy(
|
||||
state = TimerState.RUNNING,
|
||||
remaining = left,
|
||||
startedAtElapsedRealtime = now,
|
||||
endsAtElapsedRealtime = now + left,
|
||||
endsAtWallClock = wallNow + left,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pause(id: Long) {
|
||||
log += "pause($id)"
|
||||
val now = elapsed.elapsedRealtime()
|
||||
val wallNow = wall.now()
|
||||
mutate(id) {
|
||||
it.copy(
|
||||
state = TimerState.PAUSED,
|
||||
remaining = it.snapshotAt(now, wallNow).remaining,
|
||||
startedAtElapsedRealtime = null,
|
||||
endsAtElapsedRealtime = null,
|
||||
endsAtWallClock = null,
|
||||
@@ -110,11 +137,36 @@ class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository
|
||||
mutate(id) { it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO) }
|
||||
}
|
||||
|
||||
override suspend fun setDuration(id: Long, duration: Duration) {
|
||||
log += "setDuration($id)"
|
||||
mutate(id) { if (it.state == TimerState.IDLE) it.copy(duration = duration, remaining = duration) else it }
|
||||
}
|
||||
|
||||
override suspend fun setRingtoneUri(id: Long, uri: String?) {
|
||||
log += "setRingtoneUri($id)"
|
||||
mutate(id) { it.copy(ringtoneUri = uri?.trim()?.takeIf(String::isNotEmpty)) }
|
||||
}
|
||||
|
||||
override suspend fun reorder(idsInOrder: List<Long>) {
|
||||
log += "reorder(${idsInOrder.joinToString(",")})"
|
||||
rows.update { all -> all.map { it.copy(sortOrder = idsInOrder.indexOf(it.id)) } }
|
||||
}
|
||||
|
||||
override suspend fun reanchorWallClocks() {
|
||||
log += "reanchorWallClocks()"
|
||||
val now = elapsed.elapsedRealtime()
|
||||
val wallNow = wall.now()
|
||||
rows.update { all ->
|
||||
all.map { timer ->
|
||||
if (timer.state != TimerState.RUNNING) {
|
||||
timer
|
||||
} else {
|
||||
timer.copy(endsAtWallClock = wallNow + timer.snapshotAt(now, wallNow).remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun repairAfterReboot() {
|
||||
log += "repairAfterReboot()"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
@@ -216,3 +217,108 @@ fun upcomingAlarm(
|
||||
nextFire = nextFire,
|
||||
source = source,
|
||||
)
|
||||
|
||||
// --- M6: timer builders, beside `runningTimer()`'s every-field-distinct row ---
|
||||
|
||||
/**
|
||||
* One timer with only the fields M6 reasons about spelled out. The anchors are
|
||||
* passed, never derived, so a test can hand over the corrupt and stale
|
||||
* combinations a repository would never write.
|
||||
*/
|
||||
fun timerAt(
|
||||
id: Long = 1L,
|
||||
label: String = "Pasta",
|
||||
duration: Duration = 5.minutes,
|
||||
state: TimerState = TimerState.IDLE,
|
||||
remaining: Duration = duration,
|
||||
startedAtElapsedRealtime: Duration? = null,
|
||||
endsAtElapsedRealtime: Duration? = null,
|
||||
endsAtWallClock: Instant? = null,
|
||||
ringtoneUri: String? = null,
|
||||
sortOrder: Int = 0,
|
||||
): Timer = Timer(
|
||||
id = id,
|
||||
label = label,
|
||||
duration = duration,
|
||||
state = state,
|
||||
remaining = remaining,
|
||||
startedAtElapsedRealtime = startedAtElapsedRealtime,
|
||||
endsAtElapsedRealtime = endsAtElapsedRealtime,
|
||||
endsAtWallClock = endsAtWallClock,
|
||||
ringtoneUri = ringtoneUri,
|
||||
sortOrder = sortOrder,
|
||||
createdAt = T0,
|
||||
updatedAt = T0,
|
||||
)
|
||||
|
||||
fun idleTimer(
|
||||
id: Long = 1L,
|
||||
duration: Duration = 5.minutes,
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
duration = duration,
|
||||
state = TimerState.IDLE,
|
||||
remaining = duration,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
)
|
||||
|
||||
/** RUNNING with all three anchors derived from one reading of both clocks, [left] from now. */
|
||||
fun runningTimerWith(
|
||||
id: Long = 1L,
|
||||
left: Duration,
|
||||
elapsedNow: Duration = Duration.ZERO,
|
||||
wallNow: Instant = T0,
|
||||
duration: Duration = 5.minutes,
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
duration = duration,
|
||||
state = TimerState.RUNNING,
|
||||
remaining = left,
|
||||
startedAtElapsedRealtime = elapsedNow,
|
||||
endsAtElapsedRealtime = elapsedNow + left,
|
||||
endsAtWallClock = wallNow + left,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
)
|
||||
|
||||
fun pausedTimer(
|
||||
id: Long = 1L,
|
||||
remaining: Duration = 1.minutes,
|
||||
duration: Duration = 5.minutes,
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
duration = duration,
|
||||
state = TimerState.PAUSED,
|
||||
remaining = remaining,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
)
|
||||
|
||||
fun expiredTimer(
|
||||
id: Long = 1L,
|
||||
duration: Duration = 5.minutes,
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
duration = duration,
|
||||
state = TimerState.EXPIRED,
|
||||
remaining = Duration.ZERO,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.PreviewEvent
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtoneChoice
|
||||
import de.jeanlucmakiola.clockula.testing.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
package de.jeanlucmakiola.clockula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
@@ -138,4 +140,36 @@ class ShellNavigationTest {
|
||||
fun `back from a nested destination is not the shell's to intercept`() {
|
||||
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit)
|
||||
}
|
||||
|
||||
// --- M6 §5.15: the timer editor's route, and the notification's deep link ---
|
||||
|
||||
/** §5.15 #1 */
|
||||
@Test
|
||||
fun `the timer editor is no tab, so the timers tab stays selected`() {
|
||||
assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull()
|
||||
}
|
||||
|
||||
/** §5.15 #2 */
|
||||
@Test
|
||||
fun `the shell stands aside on back, leaving the timer editor to pop itself`() {
|
||||
assertThat(ShellNavigation.onBack(TimerRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
|
||||
}
|
||||
|
||||
/** §5.15 #3 */
|
||||
@Test
|
||||
fun `a timer notification asks for the timers tab`() {
|
||||
assertThat(ShellNavigation.tabForAction(TimerIntents.ACTION_SHOW_TIMERS))
|
||||
.isEqualTo(ClockulaDestination.TIMERS)
|
||||
}
|
||||
|
||||
/** §5.15 #4 */
|
||||
@Test
|
||||
fun `an absent or unknown action asks for no tab at all`() {
|
||||
val answers = listOf(
|
||||
ShellNavigation.tabForAction(null),
|
||||
ShellNavigation.tabForAction("android.intent.action.MAIN"),
|
||||
)
|
||||
|
||||
assertThat(answers).containsExactly(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,18 @@ import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.testing.FakeTicker
|
||||
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
|
||||
import de.jeanlucmakiola.clockula.testing.FakeWallClock
|
||||
import de.jeanlucmakiola.clockula.testing.TimerEngineSeams
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimer
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The pill's two buttons, and the promise behind them: the primary one pauses
|
||||
@@ -39,10 +42,21 @@ class ShellViewModelTest {
|
||||
private val now = 1.hours
|
||||
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
|
||||
|
||||
private val timers = FakeTimerRepository()
|
||||
private val timers = FakeTimerRepository(
|
||||
elapsed = FakeElapsedRealtimeClock(now),
|
||||
wall = FakeWallClock(wallInstant),
|
||||
)
|
||||
private val stopwatch = FakeStopwatchRepository()
|
||||
private val ticker = FakeTicker()
|
||||
|
||||
/**
|
||||
* The pill's timer branch goes through the **real** engine over the same
|
||||
* repository (M6 D25): without it, pausing from the pill would leave the
|
||||
* expiry slot pointing at a dead deadline. The repository's call log is
|
||||
* therefore still what the older cases assert on.
|
||||
*/
|
||||
private val seams = TimerEngineSeams(timers = timers, now = wallInstant, uptime = now)
|
||||
|
||||
private fun viewModel() = ShellViewModel(
|
||||
source = LivePillSource(
|
||||
timers = timers,
|
||||
@@ -53,6 +67,7 @@ class ShellViewModelTest {
|
||||
),
|
||||
timers = timers,
|
||||
stopwatch = stopwatch,
|
||||
timerEngine = seams.engine,
|
||||
)
|
||||
|
||||
private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState(
|
||||
@@ -69,6 +84,31 @@ class ShellViewModelTest {
|
||||
otherActiveTimers = 0,
|
||||
)
|
||||
|
||||
private fun pausedTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
|
||||
runningTimer().copy(
|
||||
id = id,
|
||||
state = TimerState.PAUSED,
|
||||
remaining = left,
|
||||
startedAtElapsedRealtime = null,
|
||||
endsAtElapsedRealtime = null,
|
||||
endsAtWallClock = null,
|
||||
)
|
||||
|
||||
private fun expiredTimerRow(id: Long = 7L): Timer = runningTimer().copy(
|
||||
id = id,
|
||||
state = TimerState.EXPIRED,
|
||||
remaining = Duration.ZERO,
|
||||
startedAtElapsedRealtime = null,
|
||||
endsAtElapsedRealtime = null,
|
||||
endsAtWallClock = null,
|
||||
)
|
||||
|
||||
private fun rowFor(mode: LivePillMode): Timer = when (mode) {
|
||||
LivePillMode.RUNNING -> runningTimerRow()
|
||||
LivePillMode.PAUSED -> pausedTimerRow()
|
||||
LivePillMode.EXPIRED -> expiredTimerRow()
|
||||
}
|
||||
|
||||
private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
|
||||
runningTimer().copy(
|
||||
id = id,
|
||||
@@ -95,6 +135,8 @@ class ShellViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `the primary action pauses a running timer`() = runTest(main.dispatcher) {
|
||||
timers.set(listOf(runningTimerRow()))
|
||||
|
||||
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
|
||||
|
||||
assertThat(timers.calls.last()).isEqualTo("pause(7)")
|
||||
@@ -102,6 +144,8 @@ class ShellViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) {
|
||||
timers.set(listOf(pausedTimerRow()))
|
||||
|
||||
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
|
||||
|
||||
assertThat(timers.calls.last()).isEqualTo("start(7)")
|
||||
@@ -132,7 +176,12 @@ class ShellViewModelTest {
|
||||
fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) {
|
||||
val viewModel = viewModel()
|
||||
|
||||
LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) }
|
||||
LivePillMode.entries.forEach { mode ->
|
||||
// Re-seeded, not re-created: `reset` is a no-op on a timer that is
|
||||
// already idle, and each mode is its own question.
|
||||
timers.set(listOf(rowFor(mode)))
|
||||
viewModel.onPillStop(timerState(mode = mode))
|
||||
}
|
||||
|
||||
assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)")
|
||||
}
|
||||
@@ -160,4 +209,53 @@ class ShellViewModelTest {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// --- M6 §5.14: the pill's timer branch goes through the engine (D25) ---
|
||||
|
||||
/** §5.14 #1 */
|
||||
@Test
|
||||
fun `pausing from the pill cancels the expiry slot with it`() = runTest(main.dispatcher) {
|
||||
timers.set(listOf(runningTimerRow()))
|
||||
seams.engine.resync()
|
||||
|
||||
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
|
||||
|
||||
assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt)
|
||||
.isEqualTo(TimerState.PAUSED to null)
|
||||
}
|
||||
|
||||
/** §5.14 #2 */
|
||||
@Test
|
||||
fun `resuming from the pill registers the deadline again`() = runTest(main.dispatcher) {
|
||||
timers.set(listOf(pausedTimerRow(left = 4.minutes)))
|
||||
|
||||
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
|
||||
|
||||
assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt)
|
||||
.isEqualTo(TimerState.RUNNING to now + 4.minutes)
|
||||
}
|
||||
|
||||
/** §5.14 #3 */
|
||||
@Test
|
||||
fun `stopping a sounding timer from the pill closes the ring session`() = runTest(main.dispatcher) {
|
||||
timers.set(listOf(expiredTimerRow()))
|
||||
seams.ringState.set(wallInstant - 30.seconds)
|
||||
|
||||
viewModel().onPillStop(timerState(mode = LivePillMode.EXPIRED))
|
||||
|
||||
assertThat(timers.timers().first().single().state to seams.ringState.current())
|
||||
.isEqualTo(TimerState.IDLE to null)
|
||||
}
|
||||
|
||||
/** §5.14 #4 */
|
||||
@Test
|
||||
fun `the stopwatch's half of the pill does not reach the timer engine`() = runTest(main.dispatcher) {
|
||||
val viewModel = viewModel()
|
||||
|
||||
viewModel.onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING))
|
||||
viewModel.onPillStop(stopwatchState(mode = LivePillMode.RUNNING))
|
||||
|
||||
assertThat(seams.scheduler.scheduled).isEmpty()
|
||||
assertThat(seams.service.transitions).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user