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:
@@ -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,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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user