feat(interop): one exported door for the whole AlarmClock contract

`AlarmClockActivity` is the only exported surface that takes a platform intent,
guarded by the caller-side `SET_ALARM` permission — which cannot go on
`MainActivity`, because the system checks it against the Launcher too. It is
invisible, does its work, and finishes.

Two intent-filters, not one: a filter carrying a `<data>` element never matches
an intent without data, so the deeplink actions need a filter of their own.
`IntentExtrasReader` turns the `Bundle` into a plain map and guards the
unparcel, because an exported door is reachable by anything on the device and a
hostile Parcelable must not crash the launch.
This commit is contained in:
2026-09-23 11:02:06 +02:00
parent 15dfaf300b
commit e55f3d19d4
10 changed files with 1258 additions and 0 deletions
+51
View File
@@ -71,6 +71,57 @@
</intent-filter>
</activity>
<!-- The one exported door for android.provider.AlarmClock: every other
app on the device drives Clockula through it. Guarded by the
caller's own SET_ALARM permission, which AlarmClock's documentation
asks for and which is protection-level `normal`, so a legitimate
caller is granted it at install and a drive-by startActivity from an
app that never declared an interest in alarms is kept out. The
permission is on *this* activity and not on MainActivity, where it
would also be checked against the Launcher (M9 D2, D3).
Two intent-filters, deliberately: a filter with a <data> element
never matches an intent without data, and one without never matches
an intent with it — and DISMISS_ALARM/DISMISS_TIMER may arrive
either way. android.intent.category.VOICE is *not* declared: it
advertises the VoiceInteractor follow-on flows, which v1 does not
implement.
No window: translucent, out of recents, no history, no task of its
own. The children carry explicit closing tags rather than being
self-closed, so the whole declaration reads as one block — which is
how ManifestRulesTest reads it too.
M9 requests no permission of its own: the one named below is a
requirement on the *caller*, never something Clockula asks for. -->
<activity
android:name=".interop.AlarmClockActivity"
android:exported="true"
android:permission="com.android.alarm.permission.SET_ALARM"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@style/Theme.Clockula.Invisible">
<intent-filter>
<action android:name="android.intent.action.SET_ALARM"></action>
<action android:name="android.intent.action.SET_TIMER"></action>
<action android:name="android.intent.action.SHOW_ALARMS"></action>
<action android:name="android.intent.action.SHOW_TIMERS"></action>
<action android:name="android.intent.action.DISMISS_ALARM"></action>
<action android:name="android.intent.action.DISMISS_TIMER"></action>
<action android:name="android.intent.action.SNOOZE_ALARM"></action>
<category android:name="android.intent.category.DEFAULT"></category>
</intent-filter>
<!-- The deeplink form: clockula://alarm/{id} and clockula://timer/{id},
which the contract lets a caller carry as the intent's data. -->
<intent-filter>
<action android:name="android.intent.action.DISMISS_ALARM"></action>
<action android:name="android.intent.action.DISMISS_TIMER"></action>
<category android:name="android.intent.category.DEFAULT"></category>
<data android:scheme="clockula"></data>
</intent-filter>
</activity>
<!-- Standalone crash-report surface; MainActivity routes here on a
startup crash-loop. Not exported, kept out of recents. -->
<activity
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.clockula.interop
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequests
import de.jeanlucmakiola.clockula.domain.interop.IntentExtras
import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The one exported door for `android.provider.AlarmClock`: permission-guarded,
* window-less, no content view and no Compose. It parses, hands the request to
* [AlarmClockHandler] on the application scope, then finishes — launching
* `MainActivity` only when the outcome asks for a window (M9 D3, D26).
*/
@AndroidEntryPoint
class AlarmClockActivity : ComponentActivity() {
@Inject
lateinit var handler: AlarmClockHandler
// The **write** outlives this window: the handler runs on the application
// scope, so swiping the task away mid-flight cannot lose the alarm the user
// just asked for. Only the `startActivity` that follows depends on this
// activity still being alive — which it is, because it is the foreground
// activity and the work is milliseconds (D26).
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The activity finishes in flight and never recreates, but the guard is
// free and makes "handled exactly once" a property of the code rather
// than of a timing argument.
if (savedInstanceState != null) {
finish()
return
}
val request = AlarmClockRequests.parse(
action = intent?.action,
dataUri = intent?.data?.toString(),
extras = intent?.let(IntentExtrasReader::read) ?: IntentExtras.EMPTY,
)
val work: Deferred<InteropOutcome> = 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()
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<String> = listOf(
ACTION_OPEN_ALARM_EDITOR,
ACTION_COMPOSE_TIMER,
ACTION_CHOOSE_ALARM_TO_DISMISS,
)
}
@@ -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())
}
}
}
}
+4
View File
@@ -4,4 +4,8 @@
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<!-- The exported AlarmClock door has no content view: translucent and
title-less so nothing flashes on the way through (M9 D3). -->
<style name="Theme.Clockula.Invisible" parent="android:Theme.Translucent.NoTitleBar" />
</resources>
@@ -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
* `<data>` 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 `<activity>` … `</activity>` 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("<activity\\b[\\s\\S]*?(?:/>|</activity>)").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("<intent-filter[\\s\\S]*?</intent-filter>")
.findAll(activityBlock(".interop.AlarmClockActivity"))
.map { it.value }
.toList()
assertThat(filters).hasSize(2)
assertThat(filters.filterNot { it.contains("<data") }).hasSize(1)
val scheme = filters.single { it.contains("<data") }
assertThat(scheme).contains("android:scheme=\"clockula\"")
assertThat(scheme).contains(AlarmClockContract.ACTION_DISMISS_ALARM)
assertThat(scheme).contains(AlarmClockContract.ACTION_DISMISS_TIMER)
}
@Test
fun `the door leaves no trace in recents and no task of its own`() {
val block = activityBlock(".interop.AlarmClockActivity")
assertThat(block).contains("android:excludeFromRecents=\"true\"")
assertThat(block).contains("android:noHistory=\"true\"")
assertThat(block).contains("android:taskAffinity=\"\"")
}
@Test
fun `every receiver is still unexported`() {
val exportedReceivers = Regex("<receiver\\b[\\s\\S]*?(?:/>|</receiver>)")
.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("<uses-permission[\\s\\S]*?android:name=\"([^\"]+)\"")
.findAll(text)
.map { it.groupValues[1] }
.toList()
assertThat(requested).containsExactly(
"android.permission.USE_EXACT_ALARM",
"android.permission.SCHEDULE_EXACT_ALARM",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.USE_FULL_SCREEN_INTENT",
"android.permission.POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED",
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
"android.permission.WAKE_LOCK",
"android.permission.VIBRATE",
)
}
}
@@ -0,0 +1,655 @@
package de.jeanlucmakiola.clockula.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequest
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequests
import de.jeanlucmakiola.clockula.domain.interop.AlarmSearch
import de.jeanlucmakiola.clockula.domain.interop.AlarmSpec
import de.jeanlucmakiola.clockula.domain.interop.IntentExtras
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.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.InteropHarness
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.interopHarness
import de.jeanlucmakiola.clockula.testing.offsetInstant
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The orchestrator, over both real engines and one DataStore. Every write in
* this milestone goes through a repository or an engine, so this is where the
* contract's promises are proved: an identical alarm is re-used rather than
* duplicated, a `SKIP_UI` alarm is transient, an incomplete spec still lands
* the user in an editor on a real row, and a search that matches two alarms
* asks rather than guessing (M9 D6–D12).
*/
class AlarmClockHandlerTest {
private val fiveAm = instantAt(BERLIN, "2026-06-10T05:00")
private val sevenThirtyToday = offsetInstant("2026-06-10T07:30+02:00")
private fun spec(
time: TimeOfDay? = TimeOfDay(7, 30),
repeatDays: RepeatDays = RepeatDays.NONE,
label: String = "Gym",
ringtoneUri: String? = "content://media/42",
vibrate: Boolean? = true,
) = AlarmSpec(time, repeatDays, label, ringtoneUri, vibrate)
private fun setAlarm(spec: AlarmSpec = spec(), skipUi: Boolean = false) =
AlarmClockRequest.SetAlarm(spec, skipUi)
// --- SET_ALARM ---
@Test
fun `a complete spec writes exactly one enabled alarm and shows nothing`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(setAlarm(skipUi = true))
val alarm = harness.storedAlarms().single()
assertThat(
listOf(
alarm.time,
alarm.enabled,
alarm.label,
alarm.repeatDays,
alarm.ringtoneUri,
alarm.vibrate,
outcome,
),
).containsExactly(
TimeOfDay(7, 30),
true,
"Gym",
RepeatDays.NONE,
"content://media/42",
true,
InteropOutcome.Done,
).inOrder()
}
@Test
fun `an alarm set behind the user's back is transient`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.handler.handle(setAlarm(skipUi = true))
assertThat(harness.storedAlarms().single().deleteAfterUse).isTrue()
}
@Test
fun `a repeating alarm is never transient, however it was set`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.handler.handle(setAlarm(spec(repeatDays = RepeatDays.WEEKDAYS), skipUi = true))
assertThat(harness.storedAlarms().single().deleteAfterUse).isFalse()
}
@Test
fun `an alarm the user was shown is theirs to keep`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(setAlarm(skipUi = false))
assertThat(listOf(harness.storedAlarms().single().deleteAfterUse, outcome))
.containsExactly(false, InteropOutcome.Show(InteropSurface.ALARMS)).inOrder()
}
@Test
fun `setting an alarm registers it with the system`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.handler.handle(setAlarm())
assertThat(harness.alarmScheduler.next?.fireAt).isEqualTo(sevenThirtyToday)
}
@Test
fun `an identical alarm is re-used rather than duplicated`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L))
harness.handler.handle(setAlarm())
val alarm = harness.storedAlarms().single()
assertThat(listOf(alarm.id, alarm.enabled)).containsExactly(2L, true).inOrder()
}
@Test
fun `re-using an alarm clears a pending skip, which would eat the occurrence just asked for`(
@TempDir tempDir: Path,
) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L, skipNextOccurrence = true))
harness.handler.handle(setAlarm())
assertThat(harness.storedAlarms().single().skipNextOccurrence).isFalse()
}
@Test
fun `a re-used alarm never becomes transient`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L))
harness.handler.handle(setAlarm(skipUi = true))
assertThat(harness.storedAlarms().single().deleteAfterUse).isFalse()
}
@Test
fun `an alarm differing only in its label is a different alarm`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L, label = "Yoga"))
harness.handler.handle(setAlarm())
assertThat(harness.storedAlarms()).hasSize(2)
}
@Test
fun `two identical alarms hand the tie to the lower id`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(harness.alarmFor(id = 2L))
harness.givenAlarm(harness.alarmFor(id = 5L))
harness.handler.handle(setAlarm())
assertThat(harness.storedAlarms().filter { it.enabled }.map { it.id }).containsExactly(2L)
}
@Test
fun `an incomplete spec creates the alarm it can and opens its editor`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T05:12"))
val outcome = harness.handler.handle(
setAlarm(spec(time = null, repeatDays = RepeatDays.of(DayOfWeek.MONDAY))),
)
val alarm = harness.storedAlarms().single()
assertThat(
listOf(
alarm.time,
alarm.label,
alarm.repeatDays,
alarm.ringtoneUri,
alarm.vibrate,
alarm.enabled,
outcome,
),
).containsExactly(
TimeOfDay(6, 0),
"Gym",
RepeatDays.of(DayOfWeek.MONDAY),
"content://media/42",
true,
true,
InteropOutcome.OpenAlarmEditor(alarm.id),
).inOrder()
}
@Test
fun `an alarm the user is about to take ownership of is never transient`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.handler.handle(setAlarm(spec(time = null)))
assertThat(harness.storedAlarms().single().deleteAfterUse).isFalse()
}
@Test
fun `an intent hostile in every extra still leaves exactly one alarm and an editor`(
@TempDir tempDir: Path,
) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val request = AlarmClockRequests.parse(
AlarmClockContract.ACTION_SET_ALARM,
dataUri = null,
extras = IntentExtras(
mapOf(
AlarmClockContract.EXTRA_HOUR to "x",
AlarmClockContract.EXTRA_MINUTES to 99,
AlarmClockContract.EXTRA_DAYS to "mon",
AlarmClockContract.EXTRA_RINGTONE to "javascript:0",
AlarmClockContract.EXTRA_VIBRATE to "yes",
AlarmClockContract.EXTRA_MESSAGE to "\u0001".repeat(5_000),
AlarmClockContract.EXTRA_SKIP_UI to 1,
),
),
)
val outcome = harness.handler.handle(request)
assertThat(harness.storedAlarms()).hasSize(1)
assertThat(outcome).isInstanceOf(InteropOutcome.OpenAlarmEditor::class.java)
}
// --- SET_TIMER ---
@Test
fun `a timer set behind the user's back is running and transient`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(
AlarmClockRequest.SetTimer(5.minutes, "Pasta", skipUi = true),
)
val timer = harness.storedTimers().single()
assertThat(listOf(timer.state, timer.label, timer.deleteAfterUse, outcome))
.containsExactly(TimerState.RUNNING, "Pasta", true, InteropOutcome.Done).inOrder()
}
@Test
fun `the contract always starts the timer, window or no window`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(
AlarmClockRequest.SetTimer(5.minutes, "Pasta", skipUi = false),
)
val timer = harness.storedTimers().single()
assertThat(listOf(timer.state, timer.deleteAfterUse, outcome))
.containsExactly(
TimerState.RUNNING,
false,
InteropOutcome.Show(InteropSurface.TIMERS),
).inOrder()
}
@Test
fun `an identical unused timer is re-used and started`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenTimer(idleTimer(id = 3L, duration = 5.minutes, label = "Pasta"))
harness.handler.handle(AlarmClockRequest.SetTimer(5.minutes, "Pasta", skipUi = true))
val timer = harness.storedTimers().single()
assertThat(listOf(timer.id, timer.state, timer.deleteAfterUse))
.containsExactly(3L, TimerState.RUNNING, false).inOrder()
}
@Test
fun `a running countdown is never restarted under somebody's feet`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenTimer(
runningTimerWith(
id = 3L,
left = 2.minutes,
elapsedNow = 1_000.seconds,
wallNow = T0,
duration = 5.minutes,
label = "Pasta",
),
)
harness.handler.handle(AlarmClockRequest.SetTimer(5.minutes, "Pasta", skipUi = true))
assertThat(harness.storedTimers()).hasSize(2)
}
@Test
fun `a timer with no length writes nothing and opens the keypad`(@TempDir tempDir: Path) =
runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(
AlarmClockRequest.SetTimer(length = null, label = "Pasta", skipUi = false),
)
assertThat(harness.storedTimers()).isEmpty()
assertThat(outcome).isEqualTo(InteropOutcome.ComposeTimer)
}
// --- SHOW_* ---
@Test
fun `showing the alarms writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(AlarmClockRequest.Show(InteropSurface.ALARMS))
assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.ALARMS))
assertThat(harness.storedAlarms() + harness.storedTimers()).isEmpty()
}
@Test
fun `showing the timers writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
val outcome = harness.handler.handle(AlarmClockRequest.Show(InteropSurface.TIMERS))
assertThat(outcome).isEqualTo(InteropOutcome.Show(InteropSurface.TIMERS))
assertThat(harness.storedAlarms() + harness.storedTimers()).isEmpty()
}
// --- DISMISS_ALARM ---
@Test
fun `the only enabled alarm is dismissed without asking`(@TempDir tempDir: Path) = runTest {
val harness = interopHarness(tempDir, now = fiveAm)
harness.givenAlarm(alarmAt(id = 1L, hour = 7))
val outcome = harness.handler.handle(AlarmClockRequest.DismissAlarm(AlarmSearch.Unspecified))
assertThat(listOf(harness.alarms.find(1L)!!.enabled, outcome))
.containsExactly(false, InteropOutcome.Done).inOrder()
}
@Test
fun `three enabled alarms make the app ask, and write nothing`(@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 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)
}
}
@@ -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 })
}
}