diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index 101100a..2c8528f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.clockula.system.RebootRepair import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.time.Duration.Companion.minutes @@ -116,9 +117,15 @@ class AlarmEngine @Inject constructor( ) } - /** Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id. */ - fun upcoming(): Flow> = - combine(alarms.alarms(), states.states()) { alarms, states -> + /** + * Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id. + * + * [refresh] is the re-read cadence: every emission re-resolves against a + * fresh `wallClock.now()`. The default resolves once per repository + * emission; a live readout passes a `Ticker`'s flow (M5 D8). + */ + fun upcoming(refresh: Flow = flowOf(Unit)): Flow> = + combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ -> val now = wallClock.now() val zone = zones.current() val byId = states.associateBy { it.alarmId } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt index f64e014..cafcf35 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt @@ -8,6 +8,8 @@ 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -93,11 +95,21 @@ class AlarmAudioPlayer(private val context: Context) { abandonFocus() } - private fun sources(ringtoneUri: String?): List = listOfNotNull( - ringtoneUri?.takeIf { it.isNotBlank() }?.let(Uri::parse), - RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM), - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM), - ) + /** + * The order is [AudioSourcePolicy]'s, so the decision — including the silent + * sentinel's empty list, which is what forces vibration (M5 D18) — lives in + * a pure object a JVM test can enumerate rather than in this file. + */ + private fun sources(ringtoneUri: String?): List = + AudioSourcePolicy.sourcesFor(ringtoneUri).mapNotNull { kind -> + when (kind) { + RingtoneSourceKind.ALARM_OWN -> ringtoneUri?.let(Uri::parse) + RingtoneSourceKind.DEVICE_DEFAULT -> + RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM) + RingtoneSourceKind.BUILT_IN_DEFAULT -> + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) + } + } /** * A prepared, not-yet-started player, or null. A source that throws has its diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt index 1e17b4b..2a75b31 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt @@ -39,6 +39,7 @@ import de.jeanlucmakiola.floret.prefs.PrefStore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope @@ -892,6 +893,74 @@ class AlarmEngineTest { assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday) assertThat(ring.ringing.value).isEqualTo(1L) } + + // --- M5: upcoming(refresh) (D8) --- + + @Test + fun `upcoming with its default refresh still re-emits on a repository change`(@TempDir dir: Path) = + runTest { + givenAlarm(id = 1L, hour = 7) + val engine = engine(dir) + + engine.upcoming().test { + assertThat(awaitItem().map { it.nextFire }).containsExactly(sevenToday) + + alarms.create(AlarmDraft(time = TimeOfDay(6, 0))) + + assertThat(awaitItem().map { it.nextFire }) + .containsExactly(offsetInstant("2026-06-10T06:00+02:00"), sevenToday) + .inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a refresh re-resolves against the new now, with no repository change`(@TempDir dir: Path) = + runTest { + givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val snoozeAt = wallClock.instant + 5.minutes + states.save(ringState(snoozedUntil = snoozeAt, snoozeCount = 1)) + val engine = engine(dir) + val refresh = MutableSharedFlow(replay = 1) + refresh.emit(Unit) + + engine.upcoming(refresh).test { + val snoozed = awaitItem().single() + assertThat(snoozed.source to snoozed.nextFire).isEqualTo(FireSource.SNOOZE to snoozeAt) + + // The snooze lapses with the passage of time alone: nothing was written. + wallClock.advance(20.minutes) + refresh.emit(Unit) + + val lapsed = awaitItem().single() + assertThat(lapsed.source to lapsed.nextFire) + .isEqualTo(FireSource.OCCURRENCE to sevenToday) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `collecting upcoming across several refreshes writes nothing`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + val engine = engine(dir) + val before = stateDao.stored + val writesBefore = stateDao.writes + val refresh = MutableSharedFlow(replay = 1) + refresh.emit(Unit) + + engine.upcoming(refresh).test { + awaitItem() + repeat(3) { + wallClock.advance(1.minutes) + refresh.emit(Unit) + awaitItem() + } + cancelAndIgnoreRemainingEvents() + } + + assertThat(stateDao.writes).isEqualTo(writesBefore) + assertThat(stateDao.stored).isEqualTo(before) + } } /**