diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1d0acd7..7a5cc62 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -71,6 +71,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= applicationScope.async { handler.handle(request) }
+
+ lifecycleScope.launch {
+ // A caller's broken intent must not take the app down mid-alarm, and
+ // the write is already independent of this activity.
+ val outcome = runCatching { work.await() }.getOrDefault(InteropOutcome.Done)
+ InteropLaunch.intentFor(this@AlarmClockActivity, outcome)?.let(::startActivity)
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt
new file mode 100644
index 0000000..c0d789d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt
@@ -0,0 +1,192 @@
+package de.jeanlucmakiola.clockula.interop
+
+import de.jeanlucmakiola.clockula.alarm.AlarmEngine
+import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
+import de.jeanlucmakiola.clockula.data.timers.TimerRepository
+import de.jeanlucmakiola.clockula.domain.Alarm
+import de.jeanlucmakiola.clockula.domain.AlarmDefaults
+import de.jeanlucmakiola.clockula.domain.AlarmDraft
+import de.jeanlucmakiola.clockula.domain.Timer
+import de.jeanlucmakiola.clockula.domain.TimerDraft
+import de.jeanlucmakiola.clockula.domain.TimerState
+import de.jeanlucmakiola.clockula.domain.alarm.FireSource
+import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequest
+import de.jeanlucmakiola.clockula.domain.interop.AlarmMatching
+import de.jeanlucmakiola.clockula.domain.interop.AlarmSearch
+import de.jeanlucmakiola.clockula.domain.interop.AlarmSpec
+import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome
+import de.jeanlucmakiola.clockula.domain.interop.InteropSurface
+import de.jeanlucmakiola.clockula.domain.interop.TimerSearch
+import de.jeanlucmakiola.clockula.domain.time.WallClock
+import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
+import de.jeanlucmakiola.clockula.timer.TimerEngine
+import kotlinx.coroutines.flow.first
+import kotlin.time.Duration
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The orchestrator for the `AlarmClock` contract: a parsed request in, every
+ * write through a repository or an engine, an [InteropOutcome] out. Plain
+ * Kotlin — no `android.*` import may ever appear in this file (M9 D1, D28).
+ */
+@Singleton
+class AlarmClockHandler @Inject constructor(
+ private val alarms: AlarmRepository,
+ private val timers: TimerRepository,
+ private val alarmEngine: AlarmEngine,
+ private val timerEngine: TimerEngine,
+ private val wallClock: WallClock,
+ private val zones: ZoneProvider,
+) {
+ suspend fun handle(request: AlarmClockRequest): InteropOutcome = when (request) {
+ is AlarmClockRequest.SetAlarm -> setAlarm(request.spec, request.skipUi)
+ is AlarmClockRequest.SetTimer -> setTimer(request.length, request.label, request.skipUi)
+ is AlarmClockRequest.Show -> InteropOutcome.Show(request.surface)
+ is AlarmClockRequest.DismissAlarm -> dismissAlarm(request.search)
+ is AlarmClockRequest.SnoozeAlarm -> snooze(request.minutes)
+ is AlarmClockRequest.DismissTimer -> dismissTimer(request.search)
+ // Garbage aimed at an exported activity opens nothing (D24).
+ AlarmClockRequest.Unsupported -> InteropOutcome.Done
+ }
+
+ /**
+ * Complete ⇒ write; incomplete ⇒ create the alarm it *can* and open its
+ * editor. `SKIP_UI` never means "skip validation": nothing is written and
+ * hidden, and an incomplete spec always puts the user in front of the alarm
+ * (D6). The editor edits a persisted row (M5 D2), so the row is created
+ * first — the same create-then-navigate the FAB does.
+ */
+ private suspend fun setAlarm(spec: AlarmSpec, skipUi: Boolean): InteropOutcome {
+ val time = spec.time ?: run {
+ val id = alarms.create(
+ AlarmDraft(
+ time = AlarmDefaults.nextWholeHour(wallClock.now(), zones.current()),
+ label = spec.label,
+ repeatDays = spec.repeatDays,
+ ringtoneUri = spec.ringtoneUri,
+ vibrate = spec.vibrate,
+ ),
+ )
+ alarmEngine.reschedule()
+ // Never transient: the user is about to take ownership of this row.
+ return InteropOutcome.OpenAlarmEditor(id)
+ }
+
+ val existing = alarms.alarms().first().filter { it.matches(spec) }.minByOrNull { it.id }
+ if (existing != null) {
+ // "The alarm is always enabled", and a pending skip would silently
+ // eat the very occurrence the caller just asked for (D11). The
+ // contract is explicit that a re-used alarm is not removed after
+ // use, so `deleteAfterUse` is left exactly as it was.
+ alarms.setEnabled(existing.id, true)
+ alarms.setSkipNextOccurrence(existing.id, false)
+ } else {
+ alarms.create(
+ AlarmDraft(
+ time = time,
+ label = spec.label,
+ repeatDays = spec.repeatDays,
+ ringtoneUri = spec.ringtoneUri,
+ vibrate = spec.vibrate,
+ // "If a time of day is specified, and EXTRA_SKIP_UI is true,
+ // and the alarm is not repeating, the implementation should
+ // remove this alarm after it has been dismissed" (D10).
+ deleteAfterUse = skipUi && !spec.repeatDays.isRepeating,
+ ),
+ )
+ }
+ alarmEngine.reschedule()
+ return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.ALARMS)
+ }
+
+ /** The five fields the contract's extras can set, and nothing else (D11). */
+ private fun Alarm.matches(spec: AlarmSpec): Boolean =
+ time == spec.time &&
+ repeatDays == spec.repeatDays &&
+ label == spec.label &&
+ ringtoneUri == spec.ringtoneUri &&
+ vibrate == spec.vibrate
+
+ /**
+ * "This action always starts the timer", so `SKIP_UI` changes only whether a
+ * window opens. With no usable length nothing is written: a timer has a
+ * natural commit point and the keypad is a real "activity capable of setting
+ * a timer" (D7).
+ */
+ private suspend fun setTimer(length: Duration?, label: String, skipUi: Boolean): InteropOutcome {
+ if (length == null) return InteropOutcome.ComposeTimer
+
+ // "An identical, *unused* timer may be re-used": restarting somebody's
+ // running countdown is destructive, so only an IDLE one qualifies.
+ val existing = timers.timers().first()
+ .filter { it.state == TimerState.IDLE && it.matches(length, label) }
+ .minByOrNull { it.id }
+ val id = existing?.id
+ ?: timers.create(TimerDraft(duration = length, label = label, deleteAfterUse = skipUi))
+ timerEngine.start(id)
+ return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.TIMERS)
+ }
+
+ private fun Timer.matches(length: Duration, label: String): Boolean =
+ duration == length && this.label == label
+
+ /**
+ * Which alarm, and when to ask instead of acting. `ALL` means "all of them",
+ * so it never asks; every other search with two or more matches hands the
+ * question to the user rather than guessing (D12).
+ */
+ private suspend fun dismissAlarm(search: AlarmSearch): InteropOutcome {
+ val stored = alarms.alarms().first()
+ val ringingId = if (search == AlarmSearch.Next) alarmEngine.ringSession()?.alarm?.id else null
+ val nextId = if (search == AlarmSearch.Next) nextToFire() else null
+ val ids = AlarmMatching.matches(search, stored, ringingId, nextId)
+
+ return when {
+ // Nothing matched: nothing is written, and the user is shown the
+ // list so they can see why.
+ ids.isEmpty() -> InteropOutcome.Show(InteropSurface.ALARMS)
+ ids.size > 1 && AlarmMatching.asksWhenAmbiguous(search) ->
+ InteropOutcome.ChooseAlarmToDismiss(ids)
+ else -> {
+ for (id in ids) alarmEngine.dismissUpcoming(id)
+ InteropOutcome.Done
+ }
+ }
+ }
+
+ /** Read-only: `upcoming()` never writes, so asking who is next writes nothing. */
+ private suspend fun nextToFire(): Long? = alarmEngine.upcoming().first()
+ .firstOrNull { it.nextFire != null && it.source != FireSource.RESUMED_RING }
+ ?.alarm
+ ?.id
+
+ /**
+ * The duration applies to the currently ringing alarm only, and nothing is
+ * written when nothing is ringing (D15).
+ */
+ private suspend fun snooze(minutes: Int?): InteropOutcome {
+ val ringingId = alarmEngine.ringSession()?.alarm?.id ?: return InteropOutcome.Done
+ alarmEngine.snooze(ringingId, minutes)
+ return InteropOutcome.Done
+ }
+
+ /**
+ * A URI we cannot read names no timer, and "all of them" is not a safe
+ * guess: it would dismiss — and for a transient timer delete — every
+ * expired timer the user had left standing. Nothing is written and the
+ * Timers tab opens, the same answer a `DISMISS_ALARM` that matched nothing
+ * gives.
+ */
+ private suspend fun dismissTimer(search: TimerSearch): InteropOutcome = when (search) {
+ TimerSearch.AllExpired -> {
+ timerEngine.dismissAllExpired()
+ InteropOutcome.Done
+ }
+ is TimerSearch.ById -> {
+ timerEngine.dismissExpired(search.timerId)
+ InteropOutcome.Done
+ }
+ TimerSearch.Unusable -> InteropOutcome.Show(InteropSurface.TIMERS)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt
new file mode 100644
index 0000000..45ada59
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt
@@ -0,0 +1,23 @@
+package de.jeanlucmakiola.clockula.interop
+
+import android.content.Intent
+import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
+import de.jeanlucmakiola.clockula.domain.interop.IntentExtras
+
+/** `Intent` → [IntentExtras]: the contract's keys only, types kept intact (M9 D4). */
+object IntentExtrasReader {
+ /** Only `AlarmClockContract.EXTRA_KEYS` are lifted. Never throws. */
+ @Suppress("DEPRECATION")
+ fun read(intent: Intent): IntentExtras {
+ // `Bundle.get` is the only API that returns a heterogeneous value with
+ // its type intact. The typed getters hide "wrong type" behind a default
+ // and cannot tell it from "absent" — which is precisely the distinction
+ // this boundary is about (D4). The unbundling itself is guarded: a
+ // Parcelable an unrelated app sent can fail to unmarshal.
+ val extras = runCatching { intent.extras }.getOrNull() ?: return IntentExtras.EMPTY
+ val values = AlarmClockContract.EXTRA_KEYS
+ .filter { runCatching { extras.containsKey(it) }.getOrDefault(false) }
+ .associateWith { runCatching { extras.get(it) }.getOrNull() }
+ return IntentExtras(values)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt
new file mode 100644
index 0000000..90396da
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt
@@ -0,0 +1,25 @@
+package de.jeanlucmakiola.clockula.interop
+
+/**
+ * The **internal** transport from the exported door to `MainActivity`. The
+ * platform's own action strings never reach the shell — the door translates
+ * them — so a process death that recreates `MainActivity` with its original
+ * intent replays navigation and never a write (M9 D1).
+ */
+object InteropIntents {
+ private const val PREFIX = "de.jeanlucmakiola.clockula."
+
+ const val ACTION_OPEN_ALARM_EDITOR: String = PREFIX + "action.OPEN_ALARM_EDITOR"
+ const val ACTION_COMPOSE_TIMER: String = PREFIX + "action.COMPOSE_TIMER"
+ const val ACTION_CHOOSE_ALARM_TO_DISMISS: String = PREFIX + "action.CHOOSE_ALARM_TO_DISMISS"
+
+ const val EXTRA_ALARM_ID: String = PREFIX + "extra.INTEROP_ALARM_ID"
+ const val EXTRA_ALARM_IDS: String = PREFIX + "extra.INTEROP_ALARM_IDS"
+
+ /** All three, for the distinctness assertions. */
+ val ACTIONS: List = listOf(
+ ACTION_OPEN_ALARM_EDITOR,
+ ACTION_COMPOSE_TIMER,
+ ACTION_CHOOSE_ALARM_TO_DISMISS,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt
new file mode 100644
index 0000000..eb89add
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt
@@ -0,0 +1,48 @@
+package de.jeanlucmakiola.clockula.interop
+
+import android.content.Context
+import android.content.Intent
+import de.jeanlucmakiola.clockula.MainActivity
+import de.jeanlucmakiola.clockula.alarm.AlarmIntents
+import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome
+import de.jeanlucmakiola.clockula.domain.interop.InteropSurface
+import de.jeanlucmakiola.clockula.timer.TimerIntents
+
+/** What an outcome asks `MainActivity` to show, if anything (M9 D1). */
+object InteropLaunch {
+ /** The internal action an outcome asks MainActivity for. Null for [InteropOutcome.Done]. */
+ fun actionFor(outcome: InteropOutcome): String? = when (outcome) {
+ // `SKIP_UI` means "bypass any intermediate UI", and opening the app
+ // because somebody aimed garbage at an exported activity would be a
+ // free way to take over the user's screen (D24, D25).
+ InteropOutcome.Done -> null
+ is InteropOutcome.Show -> when (outcome.surface) {
+ InteropSurface.ALARMS -> AlarmIntents.ACTION_SHOW_ALARMS
+ InteropSurface.TIMERS -> TimerIntents.ACTION_SHOW_TIMERS
+ }
+ is InteropOutcome.OpenAlarmEditor -> InteropIntents.ACTION_OPEN_ALARM_EDITOR
+ InteropOutcome.ComposeTimer -> InteropIntents.ACTION_COMPOSE_TIMER
+ is InteropOutcome.ChooseAlarmToDismiss -> InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS
+ }
+
+ /**
+ * Android-side: the whole Intent, or null when nothing should be shown.
+ *
+ * `MainActivity` is `singleTop`, so `NEW_TASK` brings an existing task
+ * forward and `onNewIntent` replays the request — the write has already
+ * happened, and what travels here is navigation and nothing else (D1).
+ */
+ fun intentFor(context: Context, outcome: InteropOutcome): Intent? {
+ val action = actionFor(outcome) ?: return null
+ return Intent(context, MainActivity::class.java).apply {
+ setAction(action)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (outcome is InteropOutcome.OpenAlarmEditor) {
+ putExtra(InteropIntents.EXTRA_ALARM_ID, outcome.alarmId)
+ }
+ if (outcome is InteropOutcome.ChooseAlarmToDismiss) {
+ putExtra(InteropIntents.EXTRA_ALARM_IDS, outcome.alarmIds.toLongArray())
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 939bd76..5aa70ef 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -4,4 +4,8 @@
- @android:color/transparent
- true
+
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
new file mode 100644
index 0000000..a24baa5
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -0,0 +1,118 @@
+package de.jeanlucmakiola.clockula
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
+import org.junit.jupiter.api.Test
+import java.io.File
+
+/**
+ * The manifest is where this milestone can be silently wrong: an exported
+ * component with no permission is a door anyone can push, a permission on
+ * `MainActivity` makes the app unlaunchable, and an intent-filter with no
+ * `` element never matches an intent that has one. None of that fails a
+ * compiler, so it fails here (M9 D28).
+ */
+class ManifestRulesTest {
+
+ private val manifest: File = listOf(File("src/main/AndroidManifest.xml"), File("app/src/main/AndroidManifest.xml"))
+ .firstOrNull { it.isFile }
+ ?: error("Could not resolve the manifest from ${File("").absolutePath}")
+
+ private val text: String = manifest.readText()
+
+ /**
+ * The `` … `` block whose `android:name` is [name], or
+ * the empty string when there is none — so a missing declaration reads as a
+ * plain assertion failure rather than an exception from the helper.
+ */
+ private fun activityBlock(name: String): String {
+ val blocks = Regex("|)").findAll(text).map { it.value }
+ return blocks.firstOrNull { it.contains("android:name=\"$name\"") }.orEmpty()
+ }
+
+ @Test
+ fun `exactly two components are exported`() {
+ val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|\\1>)")
+ .findAll(text)
+ .map { it.value }
+ .filter { it.contains("android:exported=\"true\"") }
+ .map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
+ .toList()
+
+ assertThat(exported).containsExactly(".MainActivity", ".interop.AlarmClockActivity")
+ }
+
+ @Test
+ fun `the door demands the caller's own alarm permission`() {
+ assertThat(activityBlock(".interop.AlarmClockActivity"))
+ .contains("android:permission=\"com.android.alarm.permission.SET_ALARM\"")
+ }
+
+ @Test
+ fun `the launcher activity demands no permission`() {
+ assertThat(activityBlock(".MainActivity")).doesNotContain("android:permission")
+ }
+
+ @Test
+ fun `every action the contract answers is named in the manifest`() {
+ val missing = AlarmClockContract.ACTIONS.filterNot { text.contains("android:name=\"$it\"") }
+
+ assertThat(missing).isEmpty()
+ }
+
+ @Test
+ fun `the door declares one filter without data and one for its own scheme`() {
+ val filters = Regex("")
+ .findAll(activityBlock(".interop.AlarmClockActivity"))
+ .map { it.value }
+ .toList()
+
+ assertThat(filters).hasSize(2)
+ assertThat(filters.filterNot { it.contains("|)")
+ .findAll(text)
+ .map { it.value }
+ .filterNot { it.contains("android:exported=\"false\"") }
+ .toList()
+
+ assertThat(exportedReceivers).isEmpty()
+ }
+
+ @Test
+ fun `this milestone requests no new permission`() {
+ val requested = Regex("
+ harness.givenAlarm(alarmAt(id = id, hour = hour))
+ }
+ val before = harness.alarmDao.stored
+
+ val outcome = harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.Unspecified))
+
+ assertThat(outcome)
+ .isEqualTo(InteropOutcome.ChooseAlarmToDismiss(listOf(1L, 2L, 3L)))
+ assertThat(harness.alarmDao.stored).isEqualTo(before)
+ }
+
+ @Test
+ fun `nothing to dismiss opens the alarms tab and writes nothing`(@TempDir tempDir: Path) =
+ runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7, enabled = false))
+ val before = harness.alarmDao.stored
+
+ val outcome = harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.Unspecified))
+
+ assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.ALARMS))
+ assertThat(harness.alarmDao.stored).isEqualTo(before)
+ }
+
+ @Test
+ fun `the next search dismisses the alarm that is actually ringing`(@TempDir tempDir: Path) =
+ runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ harness.givenAlarm(alarmAt(id = 2L, hour = 8))
+ harness.alarmEngine.reschedule()
+ harness.givenRinging(alarmId = 1L, at = offsetInstant("2026-06-10T07:00+02:00"))
+
+ harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.Next))
+
+ assertThat(listOf(harness.alarms.find(1L)!!.enabled, harness.alarms.find(2L)!!.enabled))
+ .containsExactly(false, true).inOrder()
+ }
+
+ @Test
+ fun `the all search dismisses every alarm and never asks`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ listOf(1L to 6, 2L to 7, 3L to 8).forEach { (id, hour) ->
+ harness.givenAlarm(alarmAt(id = id, hour = hour))
+ }
+
+ val outcome = harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.All))
+
+ assertThat(harness.storedAlarms().map { it.enabled }).containsExactly(false, false, false)
+ assertThat(outcome).isEqualTo(InteropOutcome.Done)
+ }
+
+ @Test
+ fun `two alarms at the named time make the app ask`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 30))
+ harness.givenAlarm(alarmAt(id = 2L, hour = 7, minute = 30))
+ val before = harness.alarmDao.stored
+
+ val outcome = harness.handler.handle(
+ AlarmClockRequest.DismissAlarm(AlarmSearch.AtTime(450, null)),
+ )
+
+ assertThat(outcome).isEqualTo(InteropOutcome.ChooseAlarmToDismiss(listOf(1L, 2L)))
+ assertThat(harness.alarmDao.stored).isEqualTo(before)
+ }
+
+ @Test
+ fun `one alarm at the named time is dismissed`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 30))
+
+ harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.AtTime(450, null)))
+
+ assertThat(harness.alarms.find(1L)!!.enabled).isFalse()
+ }
+
+ @Test
+ fun `an alarm named by its label is dismissed`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7, label = "Morning gym"))
+
+ harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.ByLabel("gym")))
+
+ assertThat(harness.alarms.find(1L)!!.enabled).isFalse()
+ }
+
+ @Test
+ fun `an alarm named by a deeplink is dismissed`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 4L, hour = 7))
+
+ harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.ById(4L)))
+
+ assertThat(harness.alarms.find(4L)!!.enabled).isFalse()
+ }
+
+ /**
+ * A URI that is present and unreadable used to fall through to the search
+ * mode — with none given, to the unspecified search's *every enabled
+ * alarm*. A caller that named one alarm and mistyped its id then disabled
+ * the user's only alarm, silently, with no dialog and no window.
+ */
+ @Test
+ fun `a mistyped alarm deeplink dismisses nothing`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ val before = harness.alarmDao.stored
+
+ val outcome = harness.handler.handle(
+ AlarmClockRequests.parse(
+ AlarmClockContract.ACTION_DISMISS_ALARM,
+ dataUri = "clockula://alarm/x",
+ extras = IntentExtras.EMPTY,
+ ),
+ )
+
+ assertThat(harness.alarmDao.stored).isEqualTo(before)
+ assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.ALARMS))
+ }
+
+ @Test
+ fun `an unusable search writes nothing and opens the alarms tab`(@TempDir tempDir: Path) =
+ runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ val before = harness.alarmDao.stored
+
+ val outcome = harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.Unusable))
+
+ assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.ALARMS))
+ assertThat(harness.alarmDao.stored).isEqualTo(before)
+ }
+
+ // --- SNOOZE_ALARM ---
+
+ @Test
+ fun `snoozing the ringing alarm honours the minutes the intent asked for`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ harness.alarmEngine.reschedule()
+ val ringingAt = offsetInstant("2026-06-10T07:00+02:00")
+ harness.givenRinging(alarmId = 1L, at = ringingAt)
+
+ val outcome = harness.handler.handle(AlarmClockRequest.SnoozeAlarm(5))
+
+ assertThat(listOf(harness.states.state(1L).snoozedUntil, outcome))
+ .containsExactly(ringingAt + 5.minutes, InteropOutcome.Done).inOrder()
+ }
+
+ @Test
+ fun `snoozing with nothing ringing writes nothing`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ harness.alarmEngine.reschedule()
+ val before = harness.alarmDao.stored to harness.stateDao.stored
+
+ val outcome = harness.handler.handle(AlarmClockRequest.SnoozeAlarm(5))
+
+ assertThat(harness.alarmDao.stored to harness.stateDao.stored).isEqualTo(before)
+ assertThat(outcome).isEqualTo(InteropOutcome.Done)
+ }
+
+ @Test
+ fun `an intent asking for a thousand-minute snooze gets an hour, end to end`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenAlarm(alarmAt(id = 1L, hour = 7))
+ harness.alarmEngine.reschedule()
+ val ringingAt = offsetInstant("2026-06-10T07:00+02:00")
+ harness.givenRinging(alarmId = 1L, at = ringingAt)
+ val request = AlarmClockRequests.parse(
+ AlarmClockContract.ACTION_SNOOZE_ALARM,
+ dataUri = null,
+ extras = IntentExtras(mapOf(AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION to 999)),
+ )
+
+ harness.handler.handle(request)
+
+ assertThat(harness.states.state(1L).snoozedUntil).isEqualTo(ringingAt + 60.minutes)
+ }
+
+ // --- DISMISS_TIMER ---
+
+ @Test
+ fun `dismissing every expired timer resets them all`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenTimer(expiredTimer(id = 1L, sortOrder = 0))
+ harness.givenTimer(expiredTimer(id = 2L, sortOrder = 1))
+
+ val outcome = harness.handler.handle(AlarmClockRequest.DismissTimer(TimerSearch.AllExpired))
+
+ assertThat(harness.storedTimers().map { it.state })
+ .containsExactly(TimerState.IDLE, TimerState.IDLE)
+ assertThat(outcome).isEqualTo(InteropOutcome.Done)
+ }
+
+ @Test
+ fun `a deeplinked timer is the only one dismissed`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenTimer(expiredTimer(id = 1L, sortOrder = 0))
+ harness.givenTimer(expiredTimer(id = 2L, sortOrder = 1))
+
+ harness.handler.handle(AlarmClockRequest.DismissTimer(TimerSearch.ById(1L)))
+
+ assertThat(harness.storedTimers().map { it.state })
+ .containsExactly(TimerState.IDLE, TimerState.EXPIRED).inOrder()
+ }
+
+ /**
+ * The same widening on the timer side, and worse: a transient timer is
+ * **deleted** by its dismissal, so one mistyped id used to destroy every
+ * expired timer the user had left standing.
+ */
+ @Test
+ fun `a mistyped timer deeplink leaves every expired timer standing`(@TempDir tempDir: Path) =
+ runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ harness.givenTimer(expiredTimer(id = 1L, sortOrder = 0))
+ harness.givenTimer(expiredTimer(id = 2L, sortOrder = 1))
+
+ val outcome = harness.handler.handle(
+ AlarmClockRequests.parse(
+ AlarmClockContract.ACTION_DISMISS_TIMER,
+ dataUri = "clockula://timer/abc",
+ extras = IntentExtras.EMPTY,
+ ),
+ )
+
+ assertThat(harness.storedTimers().map { it.state })
+ .containsExactly(TimerState.EXPIRED, TimerState.EXPIRED)
+ assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.TIMERS))
+ }
+
+ @Test
+ fun `a running timer named by a deeplink is left running`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+ val running = harness.givenTimer(
+ runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = 1_000.seconds, wallNow = T0),
+ )
+
+ harness.handler.handle(AlarmClockRequest.DismissTimer(TimerSearch.ById(1L)))
+
+ assertThat(harness.storedTimers().single()).isEqualTo(running)
+ }
+
+ // --- totality ---
+
+ @Test
+ fun `an unsupported request writes nothing and shows nothing`(@TempDir tempDir: Path) = runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+
+ val outcome = harness.handler.handle(AlarmClockRequest.Unsupported)
+
+ assertThat(harness.storedAlarms() + harness.storedTimers()).isEmpty()
+ assertThat(outcome).isEqualTo(InteropOutcome.Done)
+ }
+
+ @Test
+ fun `every bare action handles against an empty database without running away`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ for (action in AlarmClockContract.ACTIONS) {
+ val harness = InteropHarness(
+ tempDir = tempDir.resolve(action.substringAfterLast('.')).also { it.toFile().mkdirs() },
+ storeScope = backgroundScope,
+ now = fiveAm,
+ )
+
+ harness.handler.handle(AlarmClockRequests.parse(action, null, IntentExtras.EMPTY))
+
+ assertThat(harness.storedAlarms().size).isAtMost(1)
+ assertThat(harness.storedTimers().size).isAtMost(1)
+ }
+ }
+
+ @Test
+ fun `a silent ringtone survives the whole path onto the stored alarm`(@TempDir tempDir: Path) =
+ runTest {
+ val harness = interopHarness(tempDir, now = fiveAm)
+
+ harness.handler.handle(setAlarm(spec(ringtoneUri = Ringtones.SILENT_URI), skipUi = true))
+
+ assertThat(harness.storedAlarms().single().ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/interop/InteropLaunchTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/interop/InteropLaunchTest.kt
new file mode 100644
index 0000000..f16e3e1
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/interop/InteropLaunchTest.kt
@@ -0,0 +1,78 @@
+package de.jeanlucmakiola.clockula.interop
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.alarm.AlarmIntents
+import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome
+import de.jeanlucmakiola.clockula.domain.interop.InteropSurface
+import de.jeanlucmakiola.clockula.timer.TimerIntents
+import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination
+import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
+import de.jeanlucmakiola.clockula.ui.shell.ShellRequest
+import org.junit.jupiter.api.Test
+
+/**
+ * The pure half of the launch: which internal action an outcome asks for. The
+ * round trip is the point — what the door decided is what the shell replays,
+ * with no third spelling of the same answer in between.
+ */
+class InteropLaunchTest {
+
+ @Test
+ fun `showing alarms asks for the internal alarms action`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.Show(InteropSurface.ALARMS)))
+ .isEqualTo(AlarmIntents.ACTION_SHOW_ALARMS)
+ }
+
+ @Test
+ fun `showing timers asks for the internal timers action`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.Show(InteropSurface.TIMERS)))
+ .isEqualTo(TimerIntents.ACTION_SHOW_TIMERS)
+ }
+
+ @Test
+ fun `an editor outcome asks for the editor action`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.OpenAlarmEditor(7L)))
+ .isEqualTo(InteropIntents.ACTION_OPEN_ALARM_EDITOR)
+ }
+
+ @Test
+ fun `a compose-timer outcome asks for the compose action`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.ComposeTimer))
+ .isEqualTo(InteropIntents.ACTION_COMPOSE_TIMER)
+ }
+
+ @Test
+ fun `a chooser outcome asks for the chooser action`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.ChooseAlarmToDismiss(listOf(1L, 2L))))
+ .isEqualTo(InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS)
+ }
+
+ @Test
+ fun `a done outcome launches nothing at all`() {
+ assertThat(InteropLaunch.actionFor(InteropOutcome.Done)).isNull()
+ }
+
+ @Test
+ fun `every outcome that shows something round-trips into the request it meant`() {
+ val expected = listOf(
+ InteropOutcome.Show(InteropSurface.ALARMS) to
+ ShellRequest.OpenTab(ClockulaDestination.ALARMS),
+ InteropOutcome.Show(InteropSurface.TIMERS) to
+ ShellRequest.OpenTab(ClockulaDestination.TIMERS),
+ InteropOutcome.OpenAlarmEditor(7L) to ShellRequest.OpenAlarmEditor(7L),
+ InteropOutcome.ComposeTimer to ShellRequest.ComposeTimer,
+ InteropOutcome.ChooseAlarmToDismiss(listOf(3L, 1L)) to
+ ShellRequest.ChooseAlarmToDismiss(listOf(3L, 1L)),
+ )
+
+ val replayed = expected.map { (outcome, _) ->
+ ShellNavigation.requestFor(
+ action = InteropLaunch.actionFor(outcome),
+ alarmId = (outcome as? InteropOutcome.OpenAlarmEditor)?.alarmId,
+ alarmIds = (outcome as? InteropOutcome.ChooseAlarmToDismiss)?.alarmIds ?: emptyList(),
+ )
+ }
+
+ assertThat(replayed).isEqualTo(expected.map { it.second })
+ }
+}