feat(domain): next-fire text, repeat summaries, ringtones and defaults

The countdown on a row is formatting, not scheduling: it turns the engine's
resolved instant into "in 9h 12m", rounding whole minutes up so a row never
reads a minute it has already passed. Repeat masks become their own short
summaries with the week starting where the locale says it does.

A ringtone is a URI, plus one explicit silent sentinel — "Silent" has to be a
choice a user can make, and it resolves to no audio source at all, which is
what forces vibration rather than a quiet alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:17 +02:00
co-authored by Claude Opus 5
parent 2905747a23
commit 52204d3e9c
16 changed files with 871 additions and 2 deletions
@@ -0,0 +1,21 @@
package de.jeanlucmakiola.clockula.domain
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.time.Instant
import kotlin.time.toJavaInstant
object AlarmDefaults {
/** The next whole hour in [zone], as a local time of day. Never "now". */
fun nextWholeHour(now: Instant, zone: ZoneId): TimeOfDay {
// An hour of *real* time, then truncated in the zone: on a spring-forward
// night 01:30 + 1h is 03:30 local, which truncates to the 03:00 that
// actually arrives. Adding an hour to the local reading would have
// offered 02:00, an hour that does not happen that night.
val local = now.toJavaInstant()
.atZone(zone)
.plusHours(1)
.truncatedTo(ChronoUnit.HOURS)
return TimeOfDay(local.hour, local.minute)
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
sealed interface RepeatSummary {
data object Once : RepeatSummary
data object EveryDay : RepeatSummary
data object Weekdays : RepeatSummary
data object Weekends : RepeatSummary
/** The mask's days, ordered from the locale's first day of week. Never empty. */
data class SomeDays(val days: List<DayOfWeek>) : RepeatSummary
}
object RepeatDaysSummary {
fun of(repeatDays: RepeatDays, firstDayOfWeek: DayOfWeek): RepeatSummary = when (repeatDays.mask) {
RepeatDays.NONE.mask -> RepeatSummary.Once
RepeatDays.EVERY_DAY.mask -> RepeatSummary.EveryDay
RepeatDays.WEEKDAYS.mask -> RepeatSummary.Weekdays
RepeatDays.WEEKENDS.mask -> RepeatSummary.Weekends
// Anything else is the days themselves, in the reader's own week order —
// "Mon, Wed, Fri" says more than any invented shorthand would.
else -> RepeatSummary.SomeDays(
RepeatDaysOrder.startingAt(firstDayOfWeek).filter { it in repeatDays },
)
}
}
object RepeatDaysOrder {
/** All seven days, starting at [firstDayOfWeek]. */
fun startingAt(firstDayOfWeek: DayOfWeek): List<DayOfWeek> =
List(DayOfWeek.entries.size) { firstDayOfWeek.plus(it.toLong()) }
}
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.domain
/** One selectable device sound: its content URI and the title to show. */
data class RingtoneOption(val uri: String, val title: String)
object Ringtones {
/** The reserved "no audio" choice. A private scheme, so no provider URI can collide. */
const val SILENT_URI: String = "clockula://silent"
fun isSilent(uri: String?): Boolean = uri == SILENT_URI
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
}
@@ -1,5 +1,7 @@
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,
@@ -50,3 +52,24 @@ object AlarmVibrationPattern {
/** 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,56 @@
package de.jeanlucmakiola.clockula.domain.format
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
sealed interface NextFireLabel {
data object NotScheduled : NextFireLabel
data object Imminent : NextFireLabel
data class InMinutes(val minutes: Int) : NextFireLabel
data class InHoursMinutes(val hours: Int, val minutes: Int) : NextFireLabel
data class InDaysHours(val days: Int, val hours: Int) : NextFireLabel
}
object NextFireFormat {
/** Anything nearer than this reads as [NextFireLabel.Imminent]. */
val IMMINENT_BELOW: Duration = 1.minutes
private const val MINUTES_PER_HOUR = 60
private const val HOURS_PER_DAY = 24
private const val MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR
private const val MILLIS_PER_MINUTE = 60_000L
fun labelFor(nextFire: Instant?, now: Instant): NextFireLabel =
if (nextFire == null) NextFireLabel.NotScheduled else labelFor(nextFire - now)
/** Whole minutes, rounded **up**. Zero and negative read as Imminent. */
fun labelFor(remaining: Duration): NextFireLabel {
if (remaining < IMMINENT_BELOW) return NextFireLabel.Imminent
// Rounded up, the direction `ClockFormat.countdown` rounds: "in 9h 12m"
// must mean *at least* nine hours and eleven-and-a-bit minutes.
val wholeMinutes = ceilMinutes(remaining)
return when {
wholeMinutes < MINUTES_PER_HOUR -> NextFireLabel.InMinutes(wholeMinutes)
wholeMinutes < MINUTES_PER_DAY -> NextFireLabel.InHoursMinutes(
hours = wholeMinutes / MINUTES_PER_HOUR,
minutes = wholeMinutes % MINUTES_PER_HOUR,
)
// Past a day the unit is an hour, so the rounding moves up with it:
// 6d 23h 59m reads "in 7d", not "in 6d 23h".
else -> ceilHours(remaining).let { hours ->
NextFireLabel.InDaysHours(days = hours / HOURS_PER_DAY, hours = hours % HOURS_PER_DAY)
}
}
}
private fun ceilMinutes(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE)
private fun ceilHours(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE * MINUTES_PER_HOUR)
private fun ceilUnits(remaining: Duration, unitMillis: Long): Int {
val millis = remaining.inWholeMilliseconds
return ((millis + unitMillis - 1) / unitMillis).toInt()
}
}
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import org.junit.jupiter.api.Test
/**
* A new alarm's time is the next whole hour *that actually arrives* — resolved
* in the device zone, so a spring-forward night cannot offer 02:00 (M5 D23).
*/
class AlarmDefaultsTest {
@Test
fun `a time inside the hour rounds up to the next one`() {
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T06:12"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(7, 0))
}
@Test
fun `exactly on the hour is never now`() {
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T06:00"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(7, 0))
}
@Test
fun `late in the evening wraps to midnight`() {
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T23:40"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(0, 0))
}
@Test
fun `exactly on the last hour of the day wraps to midnight`() {
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(0, 0))
}
@Test
fun `the spring-forward gap is skipped, because 02 00 never happens`() {
val time = AlarmDefaults.nextWholeHour(offsetInstant("2026-03-29T01:30+01:00"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(3, 0))
}
@Test
fun `the first pass of a fall-back night gets the hour it is in`() {
val time = AlarmDefaults.nextWholeHour(offsetInstant("2026-10-25T01:30+02:00"), BERLIN)
assertThat(time).isEqualTo(TimeOfDay(2, 0))
}
@Test
fun `the shape is the same in any zone`() {
val time = AlarmDefaults.nextWholeHour(instantAt(NEW_YORK, "2026-06-10T06:12"), NEW_YORK)
assertThat(time).isEqualTo(TimeOfDay(7, 0))
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
/** The day selector's column order is the locale's, not a hardcoded Monday (M5 D28). */
class RepeatDaysOrderTest {
@Test
fun `a monday-first locale runs monday to sunday`() {
val order = RepeatDaysOrder.startingAt(DayOfWeek.MONDAY)
assertThat(order).containsExactlyElementsIn(DayOfWeek.entries).inOrder()
}
@Test
fun `a sunday-first locale wraps saturday to the end`() {
val order = RepeatDaysOrder.startingAt(DayOfWeek.SUNDAY)
assertThat(order).containsExactly(
DayOfWeek.SUNDAY,
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY,
).inOrder()
}
@Test
fun `a saturday-first locale wraps two days to the end`() {
val order = RepeatDaysOrder.startingAt(DayOfWeek.SATURDAY)
assertThat(order).containsExactly(
DayOfWeek.SATURDAY,
DayOfWeek.SUNDAY,
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
).inOrder()
}
@Test
fun `every locale gets all seven days, beginning with its own`() {
for (first in DayOfWeek.entries) {
val order = RepeatDaysOrder.startingAt(first)
assertWithMessage("starting at %s", first)
.that(order.distinct().size to order.firstOrNull())
.isEqualTo(7 to first)
}
}
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
/**
* The four named masks get their own word; anything else is the days
* themselves, ordered from the locale's first day of week (M5 D28).
*/
class RepeatSummaryTest {
@Test
fun `no days at all is once`() {
val summary = RepeatDaysSummary.of(RepeatDays.NONE, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(RepeatSummary.Once)
}
@Test
fun `every day has its own word`() {
val summary = RepeatDaysSummary.of(RepeatDays.EVERY_DAY, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(RepeatSummary.EveryDay)
}
@Test
fun `monday to friday is weekdays`() {
val summary = RepeatDaysSummary.of(RepeatDays.WEEKDAYS, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(RepeatSummary.Weekdays)
}
@Test
fun `saturday and sunday is weekends`() {
val summary = RepeatDaysSummary.of(RepeatDays.WEEKENDS, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(RepeatSummary.Weekends)
}
@Test
fun `an arbitrary mask lists its days from monday when monday is first`() {
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(
RepeatSummary.SomeDays(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)),
)
}
@Test
fun `a sunday-first locale puts sunday before monday`() {
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)
val summary = RepeatDaysSummary.of(mask, DayOfWeek.SUNDAY)
assertThat(summary).isEqualTo(
RepeatSummary.SomeDays(listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY)),
)
}
@Test
fun `the same mask in a monday-first locale puts sunday last`() {
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(
RepeatSummary.SomeDays(listOf(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)),
)
}
@Test
fun `a single day is that day alone`() {
val summary = RepeatDaysSummary.of(RepeatDays.of(DayOfWeek.THURSDAY), DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(RepeatSummary.SomeDays(listOf(DayOfWeek.THURSDAY)))
}
@Test
fun `six of seven days is not shortened to a word`() {
val mask = RepeatDays.of(DayOfWeek.entries.toSet() - DayOfWeek.SUNDAY)
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
assertThat(summary).isEqualTo(
RepeatSummary.SomeDays(
listOf(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY,
),
),
)
}
@Test
fun `every mask in every locale summarises its own days, in locale order`() {
for (mask in 0..RepeatDays.ALL_MASK) {
val repeatDays = RepeatDays.fromMask(mask)
for (first in DayOfWeek.entries) {
val summary = RepeatDaysSummary.of(repeatDays, first)
val expectedOrder = RepeatDaysOrder.startingAt(first).filter { it in repeatDays }
assertWithMessage("mask %s from %s", mask, first)
.that(if (summary is RepeatSummary.SomeDays) summary.days else expectedOrder)
.isEqualTo(expectedOrder)
}
}
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* `null` already means "inherit the app default", so silence needs a value of
* its own — and a private scheme, so no provider URI can ever collide (M5 D18).
*/
class RingtonesTest {
@Test
fun `only the sentinel is silent`() {
val answers = listOf(
Ringtones.isSilent(Ringtones.SILENT_URI),
Ringtones.isSilent(null),
Ringtones.isSilent("content://media/42"),
)
assertThat(answers).containsExactly(true, false, false).inOrder()
}
@Test
fun `the sentinel's scheme is the app's own`() {
assertThat(Ringtones.SILENT_URI).startsWith("clockula://")
}
@Test
fun `normalise trims and turns blank into absent`() {
val answers = listOf(
Ringtones.normalise(" content://media/42 "),
Ringtones.normalise(""),
Ringtones.normalise(" "),
Ringtones.normalise(null),
)
assertThat(answers).containsExactly("content://media/42", null, null, null).inOrder()
}
@Test
fun `normalise passes the sentinel through unchanged`() {
assertThat(Ringtones.normalise(Ringtones.SILENT_URI)).isEqualTo(Ringtones.SILENT_URI)
}
}
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import de.jeanlucmakiola.clockula.domain.Ringtones
import org.junit.jupiter.api.Test
/**
* The fallback chain, as a value a JVM test can enumerate. Silence is an empty
* list — which is exactly what forces vibration, so a "silent" alarm still does
* something (M5 D18).
*/
class AudioSourcePolicyTest {
@Test
fun `no override falls through to the device's own defaults`() {
val sources = AudioSourcePolicy.sourcesFor(null)
assertThat(sources)
.containsExactly(RingtoneSourceKind.DEVICE_DEFAULT, RingtoneSourceKind.BUILT_IN_DEFAULT)
.inOrder()
}
@Test
fun `a blank override is no override`() {
val sources = AudioSourcePolicy.sourcesFor(" ")
assertThat(sources)
.containsExactly(RingtoneSourceKind.DEVICE_DEFAULT, RingtoneSourceKind.BUILT_IN_DEFAULT)
.inOrder()
}
@Test
fun `a chosen sound is tried first, with the defaults behind it`() {
val sources = AudioSourcePolicy.sourcesFor("content://media/42")
assertThat(sources).containsExactly(
RingtoneSourceKind.ALARM_OWN,
RingtoneSourceKind.DEVICE_DEFAULT,
RingtoneSourceKind.BUILT_IN_DEFAULT,
).inOrder()
}
@Test
fun `the silent sentinel offers no audio source at all`() {
val sources = AudioSourcePolicy.sourcesFor(Ringtones.SILENT_URI)
assertThat(sources).isEmpty()
}
@Test
fun `an alarm with no audio source still vibrates`() {
val sources = AudioSourcePolicy.sourcesFor(Ringtones.SILENT_URI)
val required = RingFallbackPolicy.vibrationRequired(
audioAvailable = sources.isNotEmpty(),
vibrateSetting = false,
)
assertThat(required).isTrue()
}
@Test
fun `the alarm's own sound is never repeated and never behind a default`() {
val inputs = listOf(null, "", "content://media/42", Ringtones.SILENT_URI)
for (input in inputs) {
val positions = AudioSourcePolicy.sourcesFor(input)
.withIndex()
.filter { it.value == RingtoneSourceKind.ALARM_OWN }
.map { it.index }
assertWithMessage("ALARM_OWN positions for %s", input)
.that(positions)
.isAnyOf(emptyList<Int>(), listOf(0))
}
}
}
@@ -0,0 +1,140 @@
package de.jeanlucmakiola.clockula.domain.format
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.T0
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* "in 9h 12m" must mean *at least* nine hours and eleven-and-a-bit minutes, so
* whole minutes round **up** — the direction `ClockFormat.countdown` already
* rounds (M5 D10). The wording is the composable's; the decision is here.
*/
class NextFireFormatTest {
private fun labelIn(remaining: Duration): NextFireLabel =
NextFireFormat.labelFor(nextFire = T0 + remaining, now = T0)
@Test
fun `no next fire is not scheduled`() {
val label = NextFireFormat.labelFor(nextFire = null, now = T0)
assertThat(label).isEqualTo(NextFireLabel.NotScheduled)
}
@Test
fun `a fire at this very moment is imminent`() {
val label = labelIn(Duration.ZERO)
assertThat(label).isEqualTo(NextFireLabel.Imminent)
}
@Test
fun `thirty seconds out is imminent`() {
val label = labelIn(30.seconds)
assertThat(label).isEqualTo(NextFireLabel.Imminent)
}
@Test
fun `fifty-nine seconds out is imminent`() {
val label = labelIn(59.seconds)
assertThat(label).isEqualTo(NextFireLabel.Imminent)
}
@Test
fun `a fire inside the grace window is imminent, never negative`() {
val label = NextFireFormat.labelFor(nextFire = T0 - 1.seconds, now = T0)
assertThat(label).isEqualTo(NextFireLabel.Imminent)
}
@Test
fun `exactly one minute out reads as one minute`() {
val label = labelIn(60.seconds)
assertThat(label).isEqualTo(NextFireLabel.InMinutes(1))
}
@Test
fun `sixty-one seconds rounds up to two minutes`() {
val label = labelIn(61.seconds)
assertThat(label).isEqualTo(NextFireLabel.InMinutes(2))
}
@Test
fun `fifty-nine whole minutes stays in minutes`() {
val label = labelIn(59.minutes)
assertThat(label).isEqualTo(NextFireLabel.InMinutes(59))
}
@Test
fun `the rounding up crosses the hour boundary`() {
val label = labelIn(59.minutes + 1.seconds)
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
}
@Test
fun `exactly one hour reads as one hour and no minutes`() {
val label = labelIn(60.minutes)
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
}
@Test
fun `nine hours and twelve minutes reads as itself`() {
val label = labelIn(9.hours + 12.minutes)
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(9, 12))
}
@Test
fun `a half minute inside the hour rounds up to the next minute`() {
val label = labelIn(9.hours + 11.minutes + 30.seconds)
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(9, 12))
}
@Test
fun `the last minute of the day stays in hours and minutes`() {
val label = labelIn(23.hours + 59.minutes)
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(23, 59))
}
@Test
fun `the rounding up crosses the day boundary`() {
val label = labelIn(23.hours + 59.minutes + 30.seconds)
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(1, 0))
}
@Test
fun `exactly one day reads as one day and no hours`() {
val label = labelIn(24.hours)
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(1, 0))
}
@Test
fun `a weekly alarm's range reads in days and hours`() {
val label = labelIn(6.days + 23.hours)
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(6, 23))
}
@Test
fun `the rounding up crosses the week boundary`() {
val label = labelIn(6.days + 23.hours + 59.minutes)
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(7, 0))
}
}
@@ -0,0 +1,109 @@
package de.jeanlucmakiola.clockula.testing
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import java.nio.file.Path
import java.time.ZoneId
import kotlin.time.Instant
/**
* The **real** [AlarmEngine] over the fake DAOs, the fake Android seams and a
* real DataStore under a `@TempDir` — the arrangement `AlarmRingViewModelTest`
* established, lifted so M5's two ViewModel suites do not repeat forty lines.
*/
class AlarmEngineHarness(
tempDir: Path,
storeScope: CoroutineScope,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
) {
val alarmDao: FakeAlarmDao = FakeAlarmDao()
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao(alarms = alarmDao)
val wallClock: FakeWallClock = FakeWallClock(now)
val zones: FakeZoneProvider = FakeZoneProvider(zone)
val scheduler: FakeAlarmScheduler = FakeAlarmScheduler()
val ring: FakeRingCoordinator = FakeRingCoordinator()
val notifier: FakeAlarmNotifier = FakeAlarmNotifier()
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
val ticker: FakeTicker = FakeTicker()
val alarms: AlarmRepositoryImpl = AlarmRepositoryImpl(alarmDao, wallClock)
val states: AlarmStateRepositoryImpl = AlarmStateRepositoryImpl(stateDao)
val settings: SettingsPrefs = SettingsPrefs(
PrefStore(
PreferenceDataStoreFactory.create(
scope = storeScope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
),
)
val engine: AlarmEngine = AlarmEngine(
alarms = alarms,
states = states,
settings = settings,
scheduler = scheduler,
ring = ring,
notifier = notifier,
rebootRepair = rebootRepair,
wallClock = wallClock,
zones = zones,
)
/** Inserts an alarm straight into storage, bypassing the engine. */
suspend fun givenAlarm(
id: Long = 1L,
hour: Int = 7,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.NONE,
enabled: Boolean = true,
skipNextOccurrence: Boolean = false,
label: String = "Wake up",
snoozeMinutes: Int? = null,
snoozeLimit: Int? = null,
): Alarm {
val alarm = alarmAt(
id = id,
hour = hour,
minute = minute,
repeatDays = repeatDays,
enabled = enabled,
skipNextOccurrence = skipNextOccurrence,
label = label,
snoozeMinutes = snoozeMinutes,
snoozeLimit = snoozeLimit,
)
alarmDao.insert(AlarmMapper.toEntity(alarm))
return alarm
}
/** Rings [alarmId] through the engine, exactly as a fire broadcast would. */
suspend fun givenRinging(alarmId: Long, at: Instant) {
wallClock.instant = at
engine.onFire(alarmId, at)
}
}
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
fun TestScope.alarmEngineHarness(
tempDir: Path,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
): AlarmEngineHarness = AlarmEngineHarness(
tempDir = tempDir,
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
now = now,
zone = zone,
)
@@ -51,10 +51,15 @@ class FakeAlarmScheduler(
var cancelNextCount: Int = 0
private set
/** How much AlarmManager traffic the slot has seen — D12's cheap interactions cost none. */
var scheduleNextCalls: Int = 0
private set
var cancelAutoSilenceCount: Int = 0
private set
override fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode {
scheduleNextCalls++
next = Registration(fireAt, alarmId)
return mode
}
@@ -21,10 +21,10 @@ import kotlinx.coroutines.flow.update
* abstract DAOs are *extended*, not reimplemented, so their `@Transaction`
* default bodies are the ones under test.
*/
class FakeAlarmDao(
open class FakeAlarmDao(
initial: List<AlarmEntity> = emptyList(),
nextId: Long = 1L,
) : AlarmDao {
) : AlarmDao() {
private val rows = MutableStateFlow(initial)
private var nextRowId = maxOf(nextId, (initial.maxOfOrNull { it.id } ?: 0L) + 1L)
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import kotlinx.coroutines.CompletableDeferred
/**
* The device's sound catalog, scripted: which tones it has, what it can name,
* what it can open right now, and whether a SAF grant can be made persistent.
*/
class FakeRingtoneCatalog(
var tones: List<RingtoneOption> = listOf(
RingtoneOption("content://media/1", "Argon"),
RingtoneOption("content://media/2", "Bright morning"),
RingtoneOption("content://media/3", "Cesium"),
),
var titles: Map<String, String?> = emptyMap(),
var unplayable: Set<String> = emptySet(),
var canPersist: Boolean = true,
) : RingtoneCatalog {
/** How many times the tone list was asked for, so "queried once" is assertable. */
var alarmTonesCalls: Int = 0
private set
/** How many provider round-trips the metadata cost, so "asked once" is assertable. */
var metadataCalls: Int = 0
private set
/**
* While set, [titleOf] suspends on it — a blocking provider, scripted: an
* unmounted card, or a document provider on a slow volume.
*/
var titleGate: CompletableDeferred<Unit>? = null
val persisted: MutableList<String> = mutableListOf()
override suspend fun alarmTones(): List<RingtoneOption> {
alarmTonesCalls++
return tones
}
override suspend fun titleOf(uri: String): String? {
metadataCalls++
titleGate?.await()
return if (uri in titles) titles[uri] else tones.firstOrNull { it.uri == uri }?.title
}
override suspend fun isPlayable(uri: String): Boolean {
metadataCalls++
return uri !in unplayable
}
override suspend fun persistPickedDocument(uri: String): Boolean {
persisted += uri
return canPersist
}
}
/** What the previewer was told to do, in order — the order is the contract (M5 D21). */
sealed interface PreviewEvent {
data class Played(val uri: String) : PreviewEvent
data object Stopped : PreviewEvent
}
class FakeRingtonePreviewer : RingtonePreviewer {
val events: MutableList<PreviewEvent> = mutableListOf()
override suspend fun play(uri: String) {
events += PreviewEvent.Played(uri)
}
override fun stop() {
events += PreviewEvent.Stopped
}
}
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
@@ -186,3 +188,31 @@ fun ringState(
handledOccurrence = handledOccurrence,
skippedOccurrence = skippedOccurrence,
)
/**
* One row of `AlarmEngine.upcoming()` as the list's pure builder sees it: the
* alarm, what the resolver made of it, and where that came from.
*/
fun upcomingAlarm(
id: Long = 1L,
hour: Int = 7,
minute: Int = 0,
label: String = "Wake up",
enabled: Boolean = true,
repeatDays: RepeatDays = RepeatDays.NONE,
skipNextOccurrence: Boolean = false,
nextFire: Instant? = null,
source: FireSource = FireSource.OCCURRENCE,
): UpcomingAlarm = UpcomingAlarm(
alarm = alarmAt(
id = id,
hour = hour,
minute = minute,
repeatDays = repeatDays,
enabled = enabled,
skipNextOccurrence = skipNextOccurrence,
label = label,
),
nextFire = nextFire,
source = source,
)