feat(interop): the AlarmClock vocabulary, and a validator with no Android in it

The platform's contract as constants, and everything that decides what an
incoming intent *means* — parsed, validated and range-checked as pure Kotlin,
so hostile input can be tested without a device.

`IntentExtras` reads a map rather than a `Bundle`, because the platform's typed
getters cannot tell "absent" from "wrong type" and that distinction **is** the
validation. The range rule: clamp what the user is doing now, drop what would
define a future ring — hour 25 clamped to 23 rings at an hour nobody chose, so
it is dropped and the editor opens instead.

A link we cannot read names nothing, rather than falling back to "all of them":
a caller who mistypes one timer's id must not dismiss every expired timer the
user left standing. A repeat list sent non-empty from which nothing survives
leaves the alarm incomplete, because a repeating alarm silently becoming a
one-shot is a missed alarm next week.
This commit is contained in:
2026-09-23 11:01:42 +02:00
parent b43d9117ab
commit 05fc6e437f
17 changed files with 1929 additions and 0 deletions
@@ -30,6 +30,21 @@ sealed interface FireOutcome {
data object Ignored : FireOutcome
}
/** What [de.jeanlucmakiola.clockula.alarm.AlarmEngine.dismissUpcoming] did (M9 D14). */
sealed interface DismissOutcome {
/** It was ringing; the cycle closed. */
data class Dismissed(val alarmId: Long) : DismissOutcome
/** Repeating: the upcoming instance is skipped, the alarm stays enabled. */
data class SkippedNext(val alarmId: Long) : DismissOutcome
/** One-shot: disabled — or deleted, when it was transient. */
data class Disabled(val alarmId: Long) : DismissOutcome
/** Unknown id, or an alarm with no upcoming instance. Nothing was written. */
data object NotFound : DismissOutcome
}
sealed interface SnoozeOutcome {
data class Snoozed(val alarmId: Long, val until: Instant, val remainingSnoozes: Int) : SnoozeOutcome
@@ -0,0 +1,65 @@
package de.jeanlucmakiola.clockula.domain.interop
/**
* The platform's `android.provider.AlarmClock` vocabulary, spelled as literals
* so pure code can name it without an Android import. This file is the **only**
* place these strings may appear in `src/main` (M9 D27, D28); an instrumentation
* test asserts each equals the real platform constant.
*/
object AlarmClockContract {
const val ACTION_SET_ALARM: String = "android.intent.action.SET_ALARM"
const val ACTION_SET_TIMER: String = "android.intent.action.SET_TIMER"
const val ACTION_SHOW_ALARMS: String = "android.intent.action.SHOW_ALARMS"
const val ACTION_SHOW_TIMERS: String = "android.intent.action.SHOW_TIMERS"
const val ACTION_DISMISS_ALARM: String = "android.intent.action.DISMISS_ALARM"
const val ACTION_DISMISS_TIMER: String = "android.intent.action.DISMISS_TIMER"
const val ACTION_SNOOZE_ALARM: String = "android.intent.action.SNOOZE_ALARM"
const val EXTRA_HOUR: String = "android.intent.extra.alarm.HOUR"
const val EXTRA_MINUTES: String = "android.intent.extra.alarm.MINUTES"
const val EXTRA_MESSAGE: String = "android.intent.extra.alarm.MESSAGE"
const val EXTRA_DAYS: String = "android.intent.extra.alarm.DAYS"
const val EXTRA_RINGTONE: String = "android.intent.extra.alarm.RINGTONE"
const val EXTRA_VIBRATE: String = "android.intent.extra.alarm.VIBRATE"
const val EXTRA_SKIP_UI: String = "android.intent.extra.alarm.SKIP_UI"
const val EXTRA_LENGTH: String = "android.intent.extra.alarm.LENGTH"
const val EXTRA_IS_PM: String = "android.intent.extra.alarm.IS_PM"
const val EXTRA_ALARM_SEARCH_MODE: String = "android.intent.extra.alarm.SEARCH_MODE"
const val EXTRA_ALARM_SNOOZE_DURATION: String = "android.intent.extra.alarm.SNOOZE_DURATION"
const val SEARCH_MODE_TIME: String = "android.time"
const val SEARCH_MODE_NEXT: String = "android.next"
const val SEARCH_MODE_ALL: String = "android.all"
const val SEARCH_MODE_LABEL: String = "android.label"
const val VALUE_RINGTONE_SILENT: String = "silent"
/** The permission a *caller* must hold; declared on the door, never requested. */
const val PERMISSION_SET_ALARM: String = "com.android.alarm.permission.SET_ALARM"
/** Every action the app answers, in the order the manifest lists them. */
val ACTIONS: List<String> = listOf(
ACTION_SET_ALARM,
ACTION_SET_TIMER,
ACTION_SHOW_ALARMS,
ACTION_SHOW_TIMERS,
ACTION_DISMISS_ALARM,
ACTION_DISMISS_TIMER,
ACTION_SNOOZE_ALARM,
)
/** Every extra key the reader lifts out of a Bundle. */
val EXTRA_KEYS: List<String> = listOf(
EXTRA_HOUR,
EXTRA_MINUTES,
EXTRA_MESSAGE,
EXTRA_DAYS,
EXTRA_RINGTONE,
EXTRA_VIBRATE,
EXTRA_SKIP_UI,
EXTRA_LENGTH,
EXTRA_IS_PM,
EXTRA_ALARM_SEARCH_MODE,
EXTRA_ALARM_SNOOZE_DURATION,
)
}
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import kotlin.time.Duration
enum class InteropSurface { ALARMS, TIMERS }
/** The sanitised SET_ALARM fields. A null [time] means the caller did not (validly) give one. */
data class AlarmSpec(
val time: TimeOfDay?,
val repeatDays: RepeatDays,
val label: String,
val ringtoneUri: String?,
/** Null = inherit `ClockDefaults`; an explicit false is a choice. */
val vibrate: Boolean?,
) {
val isComplete: Boolean get() = time != null
}
sealed interface AlarmSearch {
data class ById(val alarmId: Long) : AlarmSearch
data object Next : AlarmSearch
data object All : AlarmSearch
/** Minutes of day. [alsoMinutesOfDay] carries the AM/PM twin when IS_PM was absent. */
data class AtTime(val minutesOfDay: Int, val alsoMinutesOfDay: Int?) : AlarmSearch
data class ByLabel(val phrase: String) : AlarmSearch
/** No URI and no search mode. */
data object Unspecified : AlarmSearch
/**
* A search mode was given but its parameters are unusable, **or** a data
* URI was given that names no alarm. "Absent" and "present but unusable"
* are not the same question: widening a caller's mistyped deeplink to
* "every enabled alarm" dismisses alarms nobody named.
*/
data object Unusable : AlarmSearch
}
/** Which timer `DISMISS_TIMER` names. The URI's three states, kept apart. */
sealed interface TimerSearch {
data class ById(val timerId: Long) : TimerSearch
/** No data URI at all: the contract's "dismiss the expired timers". */
data object AllExpired : TimerSearch
/** A data URI was given and it names no timer of ours: nothing is dismissed. */
data object Unusable : TimerSearch
}
sealed interface AlarmClockRequest {
/** [skipUi] is already false when [spec] is incomplete — the contract ignores it there. */
data class SetAlarm(val spec: AlarmSpec, val skipUi: Boolean) : AlarmClockRequest
data class SetTimer(val length: Duration?, val label: String, val skipUi: Boolean) : AlarmClockRequest
data class Show(val surface: InteropSurface) : AlarmClockRequest
data class DismissAlarm(val search: AlarmSearch) : AlarmClockRequest
/** Null [minutes] = use the alarm's own resolved snooze length. Already clamped. */
data class SnoozeAlarm(val minutes: Int?) : AlarmClockRequest
data class DismissTimer(val search: TimerSearch) : AlarmClockRequest
data object Unsupported : AlarmClockRequest
}
sealed interface InteropOutcome {
/** Finish the door; show nothing. */
data object Done : InteropOutcome
data class Show(val surface: InteropSurface) : InteropOutcome
data class OpenAlarmEditor(val alarmId: Long) : InteropOutcome
/** The Timers tab with the setup panel open. */
data object ComposeTimer : InteropOutcome
data class ChooseAlarmToDismiss(val alarmIds: List<Long>) : InteropOutcome
}
@@ -0,0 +1,177 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.seconds
/** The validator: every range, type and sentinel rule of the contract (M9 D5–D9, D12). */
object AlarmClockRequests {
/** Pure and **total**: never throws, whatever [extras] holds. */
fun parse(action: String?, dataUri: String?, extras: IntentExtras): AlarmClockRequest =
when (action) {
AlarmClockContract.ACTION_SET_ALARM -> setAlarm(extras)
AlarmClockContract.ACTION_SET_TIMER -> setTimer(extras)
AlarmClockContract.ACTION_SHOW_ALARMS -> AlarmClockRequest.Show(InteropSurface.ALARMS)
AlarmClockContract.ACTION_SHOW_TIMERS -> AlarmClockRequest.Show(InteropSurface.TIMERS)
AlarmClockContract.ACTION_DISMISS_ALARM ->
AlarmClockRequest.DismissAlarm(search(dataUri, extras))
AlarmClockContract.ACTION_DISMISS_TIMER ->
AlarmClockRequest.DismissTimer(timerSearch(dataUri))
AlarmClockContract.ACTION_SNOOZE_ALARM -> AlarmClockRequest.SnoozeAlarm(
// Clamped, not dropped: the alarm is sounding *now*, and
// refusing a 1 000-minute snooze by leaving it ringing is
// strictly worse than granting a 60-minute one (D5).
extras.int(AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION)
?.coerceIn(InteropLimits.SNOOZE_MINUTES),
)
// An action we do not answer opens nothing and writes nothing (D24).
else -> AlarmClockRequest.Unsupported
}
private fun setAlarm(extras: IntentExtras): AlarmClockRequest.SetAlarm {
val days = repeatDays(extras)
val spec = AlarmSpec(
// A value that decides when something will ring in the future is
// **dropped** when it is out of range, never clamped: clamping hour
// 25 to 23 sets an enabled alarm for an hour nobody chose (D5). A
// repeat none of which survived drops the time too, so the user
// sees the alarm rather than a one-shot they did not ask for (D8).
time = if (days.usable) timeOfDay(extras) else null,
repeatDays = days.value,
label = InteropText.label(extras.string(AlarmClockContract.EXTRA_MESSAGE)),
ringtoneUri = InteropText.ringtoneUri(extras.string(AlarmClockContract.EXTRA_RINGTONE)),
// Absent **inherits** rather than assuming true: the contract's
// "default is true" describes the effective behaviour, and
// `ClockDefaults.vibrate` is already true.
vibrate = extras.boolean(AlarmClockContract.EXTRA_VIBRATE),
)
return AlarmClockRequest.SetAlarm(
spec = spec,
// The contract ignores SKIP_UI when the time is missing, and so do we.
skipUi = spec.isComplete && extras.boolean(AlarmClockContract.EXTRA_SKIP_UI) == true,
)
}
private fun timeOfDay(extras: IntentExtras): TimeOfDay? {
val hour = extras.int(AlarmClockContract.EXTRA_HOUR)
?.takeIf { it in InteropLimits.HOURS }
?: return null
val minute = minute(extras) ?: return null
return TimeOfDay(hour, minute)
}
/** Absent ⇒ 0, the contract's own default. Present and unusable ⇒ null, which drops the time. */
private fun minute(extras: IntentExtras): Int? =
if (!extras.has(AlarmClockContract.EXTRA_MINUTES)) {
0
} else {
extras.int(AlarmClockContract.EXTRA_MINUTES)?.takeIf { it in InteropLimits.MINUTES }
}
/**
* [usable] is false only when the caller sent a **non-empty** repeat list
* and none of it survived: a repeating alarm silently becoming a one-shot is
* a missed alarm next week. An absent list, an empty one, or one of the
* wrong type is a one-shot the caller asked for (D8).
*/
private data class Repeat(val value: RepeatDays, val usable: Boolean)
private fun repeatDays(extras: IntentExtras): Repeat {
val raw = extras.intList(AlarmClockContract.EXTRA_DAYS) ?: return Repeat(RepeatDays.NONE, true)
val days = raw.mapNotNull(::dayOfWeek).toSet()
if (days.isNotEmpty()) return Repeat(RepeatDays.of(days), true)
// The count of what was **sent**, not of what survived the type
// filter: a list of `["mon", "tue"]` survives as nothing, and reading
// that as "the caller asked for a one-shot" is the very substitution
// D8 forbids. Only an explicitly empty list is a one-shot.
val sent = extras.listSize(AlarmClockContract.EXTRA_DAYS) ?: 0
return Repeat(RepeatDays.NONE, sent == 0)
}
/**
* `java.util.Calendar`'s constants, translated at the intent boundary and
* nowhere else (`ARCHITECTURE.md` §4): Sunday = 1 … Saturday = 7.
*/
private fun dayOfWeek(calendarDay: Int): DayOfWeek? = when (calendarDay) {
1 -> DayOfWeek.SUNDAY
in 2..7 -> DayOfWeek.of(calendarDay - 1)
else -> null
}
private fun setTimer(extras: IntentExtras): AlarmClockRequest.SetTimer {
val length = extras.int(AlarmClockContract.EXTRA_LENGTH)
?.takeIf { it in InteropLimits.TIMER_SECONDS }
?.seconds
return AlarmClockRequest.SetTimer(
length = length,
label = InteropText.label(extras.string(AlarmClockContract.EXTRA_MESSAGE)),
skipUi = length != null && extras.boolean(AlarmClockContract.EXTRA_SKIP_UI) == true,
)
}
/**
* No URI ⇒ every expired timer, which is what the contract asks for. A URI
* that names no timer of ours is **not** the same thing: widening it to
* "all of them" would let one mistyped id dismiss — and, for a transient
* timer, delete — every expired timer the user had left standing.
*/
private fun timerSearch(dataUri: String?): TimerSearch = when {
dataUri.isNullOrBlank() -> TimerSearch.AllExpired
else -> InteropDeepLinks.timerId(dataUri)?.let(TimerSearch::ById) ?: TimerSearch.Unusable
}
/**
* The data URI wins over the search mode, as the contract says (D12) — and
* it wins even when we cannot read it. "No URI" and "a URI naming no alarm
* of ours" are different questions, and answering the second with the
* unspecified search's "every enabled alarm" would dismiss alarms the
* caller never named.
*/
private fun search(dataUri: String?, extras: IntentExtras): AlarmSearch {
if (!dataUri.isNullOrBlank()) {
return InteropDeepLinks.alarmId(dataUri)?.let(AlarmSearch::ById) ?: AlarmSearch.Unusable
}
val mode = extras.string(AlarmClockContract.EXTRA_ALARM_SEARCH_MODE)
?: return AlarmSearch.Unspecified
return when (mode) {
AlarmClockContract.SEARCH_MODE_NEXT -> AlarmSearch.Next
AlarmClockContract.SEARCH_MODE_ALL -> AlarmSearch.All
AlarmClockContract.SEARCH_MODE_LABEL -> label(extras)
AlarmClockContract.SEARCH_MODE_TIME -> atTime(extras)
else -> AlarmSearch.Unusable
}
}
private fun label(extras: IntentExtras): AlarmSearch {
val phrase = InteropText.label(extras.string(AlarmClockContract.EXTRA_MESSAGE))
return if (phrase.isEmpty()) AlarmSearch.Unusable else AlarmSearch.ByLabel(phrase)
}
/**
* `EXTRA_IS_PM` is consulted only when the hour is **ambiguous**: 13..23 is
* a 24-hour reading and wins over a contradictory flag. A 0..12 hour with no
* flag is the case the contract calls ambiguous and says to "ask for
* clarification" — both readings become candidates, so the question only
* ever reaches the user when the data cannot settle it (D12).
*/
private fun atTime(extras: IntentExtras): AlarmSearch {
val hour = extras.int(AlarmClockContract.EXTRA_HOUR)
?.takeIf { it in InteropLimits.HOURS }
?: return AlarmSearch.Unusable
// We do not scan every hour of the day for a matching minute.
val minute = minute(extras) ?: return AlarmSearch.Unusable
val isPm = extras.boolean(AlarmClockContract.EXTRA_IS_PM)
if (hour > 12) return AlarmSearch.AtTime(hour * 60 + minute, null)
if (isPm != null) {
val hour24 = when {
hour == 12 -> if (isPm) 12 else 0
isPm -> hour + 12
else -> hour
}
return AlarmSearch.AtTime(hour24 * 60 + minute, null)
}
return AlarmSearch.AtTime(hour * 60 + minute, ((hour + 12) % 24) * 60 + minute)
}
}
@@ -0,0 +1,48 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.text.TextFolding
/** Which alarms a search names, and whether a multi-match has to ask (M9 D12). */
object AlarmMatching {
/**
* The ids [search] names, in [alarms]' own order, without duplicates.
* [alarms] is every alarm, enabled or not; [ringingId] and [nextId] come
* from the engine.
*/
fun matches(
search: AlarmSearch,
alarms: List<Alarm>,
ringingId: Long?,
nextId: Long?,
): List<Long> = when (search) {
// A deeplink names one alarm by id, enabled or not: the caller has said
// exactly which one it means.
is AlarmSearch.ById -> alarms.filter { it.id == search.alarmId }.map { it.id }
// A ringing alarm wins: "the next alarm" to someone whose alarm is
// sounding is the one in front of them.
AlarmSearch.Next -> alarms.filter { it.id == (ringingId ?: nextId) }.map { it.id }
AlarmSearch.All, AlarmSearch.Unspecified -> alarms.filter { it.enabled }.map { it.id }
// Exactly, never "nearest": dismissing an alarm the caller did not name
// is a missed alarm, and no distance makes that a good trade.
is AlarmSearch.AtTime -> alarms
.filter { it.enabled && it.time.minutesOfDay in setOfNotNull(search.minutesOfDay, search.alsoMinutesOfDay) }
.map { it.id }
// Substring after folding, not the zone picker's word prefix: the
// contract says "contain the word or phrase", and "gym" must find
// "Morning gym" (D18).
is AlarmSearch.ByLabel -> alarms
.filter { it.enabled && TextFolding.containsFolded(it.label, search.phrase) }
.map { it.id }
AlarmSearch.Unusable -> emptyList()
}
/** False for ALL, which means "all of them"; true where ≥2 matches must ask the user. */
fun asksWhenAmbiguous(search: AlarmSearch): Boolean = when (search) {
// ALL's whole meaning is "selects all alarms"; asking would make it useless.
AlarmSearch.All -> false
// These name at most one alarm each, so they can never be ambiguous.
AlarmSearch.Next, is AlarmSearch.ById -> false
else -> true
}
}
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.clockula.domain.interop
/**
* A Bundle's contents as plain data. Every accessor is **total**: a key that is
* absent, holds null, or holds the wrong type all read back as null (M9 D4).
*/
@JvmInline
value class IntentExtras(val values: Map<String, Any?>) {
fun has(key: String): Boolean = values.containsKey(key)
/** An `Int` and nothing else — not a Long, not a numeric String. */
fun int(key: String): Int? = values[key] as? Int
/** Any `CharSequence`, as its `toString()`. */
fun string(key: String): String? = (values[key] as? CharSequence)?.toString()
fun boolean(key: String): Boolean? = values[key] as? Boolean
/**
* Null when absent, or when the value is neither a `List<*>` nor an
* `IntArray`; otherwise the `Int` entries, in order.
*
* An `IntArray` counts because `putExtra(EXTRA_DAYS, intArrayOf(2, 3, 4))`
* is what a caller writes when it does not reach for
* `putIntegerArrayListExtra`. Reading that back as *absent* rather than as
* a list would turn a repeating alarm into an enabled one-shot without
* telling anyone — the exact harm the "non-empty but nothing survived"
* branch exists to prevent (D8).
*/
fun intList(key: String): List<Int>? =
// A list of nothing but rubbish reads back **empty**, not absent: the
// caller did send a list, and the difference decides whether a repeat
// the user asked for silently became a one-shot (D8).
when (val value = values[key]) {
is List<*> -> value.filterIsInstance<Int>()
is IntArray -> value.toList()
else -> null
}
/**
* How many entries the caller actually **sent** under [key], before any
* type filtering — null when absent or when the value is neither a
* `List<*>` nor an `IntArray`.
*
* [intList] deliberately cannot answer this: it filters, so a list of
* nothing but rubbish and an explicitly empty list both read back empty.
* The difference is load-bearing exactly once — `EXTRA_DAYS` (D8) — where
* "the caller asked for a one-shot" and "the caller asked for a repeat we
* could not read" must not share an answer, because the second silently
* becomes an enabled one-shot and a missed alarm next week.
*/
fun listSize(key: String): Int? = when (val value = values[key]) {
is List<*> -> value.size
is IntArray -> value.size
else -> null
}
companion object {
val EMPTY: IntentExtras = IntentExtras(emptyMap())
}
}
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.clockula.domain.interop
/** `clockula://alarm/{id}` and `clockula://timer/{id}`, built and parsed (M9 D20). */
object InteropDeepLinks {
const val SCHEME: String = "clockula"
const val HOST_ALARM: String = "alarm"
const val HOST_TIMER: String = "timer"
fun alarm(alarmId: Long): String = "$SCHEME://$HOST_ALARM/$alarmId"
fun timer(timerId: Long): String = "$SCHEME://$HOST_TIMER/$timerId"
/** A positive id from `clockula://alarm/{id}`, else null. Never throws. */
fun alarmId(uri: String?): Long? = idFrom(uri, HOST_ALARM)
fun timerId(uri: String?): Long? = idFrom(uri, HOST_TIMER)
/**
* Parsed by hand and strictly: exact lowercase scheme and host, one path
* segment of nothing but digits, no query and no fragment. Anything else
* falls through to the search mode rather than being guessed at, and an id
* that overflows a `Long` reads as nothing at all.
*
* The URI is trimmed first, because an `Intent`'s data may arrive from a
* shell command line.
*/
private fun idFrom(uri: String?, host: String): Long? {
val text = uri?.trim() ?: return null
val prefix = "$SCHEME://$host/"
if (!text.startsWith(prefix)) return null
val segment = text.removePrefix(prefix)
if (segment.isEmpty() || !segment.all { it in '0'..'9' }) return null
return segment.toLongOrNull()?.takeIf { it > 0L }
}
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.clockula.domain.interop
/** The bounds an extra from another process is read against (M9 D5, D19). */
object InteropLimits {
const val MAX_LABEL_LENGTH: Int = 256
const val MAX_URI_LENGTH: Int = 2_048
val HOURS: IntRange = 0..23
val MINUTES: IntRange = 0..59
/** The contract's own range for `EXTRA_LENGTH`, in seconds. */
val TIMER_SECONDS: IntRange = 1..86_400
/** `ClockPrefs`' own clamp: an intent cannot ask for a snooze the user could not choose. */
val SNOOZE_MINUTES: IntRange = 1..60
val RINGTONE_SCHEMES: List<String> = listOf("content://", "android.resource://")
}
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.clockula.domain.interop
import de.jeanlucmakiola.clockula.domain.Ringtones
/** The two sanitisers for the strings another process chose (M9 D9, D19). */
object InteropText {
/**
* Trimmed, control characters removed, truncated. Never null; blank stays blank.
*
* Truncated rather than rejected, because a label is cosmetic: half a label
* still names the alarm, while half an hour does not (D19). A newline in a
* one-line row is a layout bug and a `\u0000` in a `TEXT` column is worse,
* so everything below a space goes.
*/
fun label(raw: String?): String = raw
?.filter { it >= ' ' }
?.trim()
?.take(InteropLimits.MAX_LABEL_LENGTH)
.orEmpty()
/**
* "silent" ⇒ `Ringtones.SILENT_URI`. Otherwise an accepted, bounded URI, else null.
*
* A **drop, not a rejection**: null means "inherit the app default", which
* is exactly the contract's own fallback, and an unusable sound must never
* stop an alarm from being set. The URI is never *verified* here — M5 locked
* "an unreadable sound is reported, never rewritten" (D9).
*/
fun ringtoneUri(raw: String?): String? {
val text = raw?.trim().orEmpty()
if (text == AlarmClockContract.VALUE_RINGTONE_SILENT) return Ringtones.SILENT_URI
if (text.isEmpty() || text.length > InteropLimits.MAX_URI_LENGTH) return null
if (text.any { it < ' ' }) return null
// `file://` is an exposure the platform itself refuses to hand across
// processes; everything but our two schemes inherits instead.
if (InteropLimits.RINGTONE_SCHEMES.none { text.startsWith(it) }) return null
return text
}
}
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import org.junit.jupiter.api.Test
/**
* The platform's vocabulary, frozen. These strings are the whole contract with
* every other app on the device: one typo and an Assistant's alarm silently
* goes to Google Clock instead. An instrumentation test proves each equals the
* real `android.provider.AlarmClock` constant; this one proves they never drift.
*/
class AlarmClockContractTest {
@Test
fun `the seven action strings are the platform's own`() {
val actions = listOf(
AlarmClockContract.ACTION_SET_ALARM,
AlarmClockContract.ACTION_SET_TIMER,
AlarmClockContract.ACTION_SHOW_ALARMS,
AlarmClockContract.ACTION_SHOW_TIMERS,
AlarmClockContract.ACTION_DISMISS_ALARM,
AlarmClockContract.ACTION_DISMISS_TIMER,
AlarmClockContract.ACTION_SNOOZE_ALARM,
)
assertThat(actions).containsExactly(
"android.intent.action.SET_ALARM",
"android.intent.action.SET_TIMER",
"android.intent.action.SHOW_ALARMS",
"android.intent.action.SHOW_TIMERS",
"android.intent.action.DISMISS_ALARM",
"android.intent.action.DISMISS_TIMER",
"android.intent.action.SNOOZE_ALARM",
).inOrder()
}
@Test
fun `the eleven extra keys are the platform's own`() {
val extras = listOf(
AlarmClockContract.EXTRA_HOUR,
AlarmClockContract.EXTRA_MINUTES,
AlarmClockContract.EXTRA_MESSAGE,
AlarmClockContract.EXTRA_DAYS,
AlarmClockContract.EXTRA_RINGTONE,
AlarmClockContract.EXTRA_VIBRATE,
AlarmClockContract.EXTRA_SKIP_UI,
AlarmClockContract.EXTRA_LENGTH,
AlarmClockContract.EXTRA_IS_PM,
AlarmClockContract.EXTRA_ALARM_SEARCH_MODE,
AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION,
)
assertThat(extras).containsExactly(
"android.intent.extra.alarm.HOUR",
"android.intent.extra.alarm.MINUTES",
"android.intent.extra.alarm.MESSAGE",
"android.intent.extra.alarm.DAYS",
"android.intent.extra.alarm.RINGTONE",
"android.intent.extra.alarm.VIBRATE",
"android.intent.extra.alarm.SKIP_UI",
"android.intent.extra.alarm.LENGTH",
"android.intent.extra.alarm.IS_PM",
"android.intent.extra.alarm.SEARCH_MODE",
"android.intent.extra.alarm.SNOOZE_DURATION",
).inOrder()
}
@Test
fun `the four search modes are the platform's own`() {
val modes = listOf(
AlarmClockContract.SEARCH_MODE_TIME,
AlarmClockContract.SEARCH_MODE_NEXT,
AlarmClockContract.SEARCH_MODE_ALL,
AlarmClockContract.SEARCH_MODE_LABEL,
)
assertThat(modes).containsExactly("android.time", "android.next", "android.all", "android.label")
.inOrder()
}
@Test
fun `the silent sentinel and the caller's permission are spelled exactly`() {
val values = listOf(
AlarmClockContract.VALUE_RINGTONE_SILENT,
AlarmClockContract.PERMISSION_SET_ALARM,
)
assertThat(values).containsExactly("silent", "com.android.alarm.permission.SET_ALARM").inOrder()
}
@Test
fun `the two published lists are complete and hold no duplicate`() {
val actions = AlarmClockContract.ACTIONS
val extras = AlarmClockContract.EXTRA_KEYS
assertThat(actions).hasSize(7)
assertThat(actions).containsNoDuplicates()
assertThat(extras).hasSize(11)
assertThat(extras).containsNoDuplicates()
}
@Test
fun `the platform's vocabulary never collides with one of ours`() {
val ours = listOf(
AlarmIntents.ACTION_FIRE,
AlarmIntents.ACTION_AUTO_SILENCE,
AlarmIntents.ACTION_SNOOZE,
AlarmIntents.ACTION_DISMISS,
AlarmIntents.ACTION_SHOW_ALARMS,
TimerIntents.ACTION_EXPIRY,
TimerIntents.ACTION_PAUSE,
TimerIntents.ACTION_RESUME,
TimerIntents.ACTION_RESET,
TimerIntents.ACTION_ADD_TIME,
TimerIntents.ACTION_SHOW_TIMERS,
TimerIntents.ACTION_SERVICE_STOP,
StopwatchIntents.ACTION_LAP,
StopwatchIntents.ACTION_PAUSE,
StopwatchIntents.ACTION_RESUME,
StopwatchIntents.ACTION_RESET,
StopwatchIntents.ACTION_SHOW_STOPWATCH,
StopwatchIntents.ACTION_SERVICE_STOP,
) + InteropIntents.ACTIONS
assertThat(AlarmClockContract.ACTIONS.intersect(ours.toSet())).isEmpty()
}
}
@@ -0,0 +1,186 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.testing.alarmAt
import org.junit.jupiter.api.Test
/**
* Which alarms a search names. Two deliberate narrowings, each because the
* alternative is a **missed alarm**: a time search matches exactly rather than
* "most closely", and a disabled alarm is invisible to every search but an
* explicit deeplink (M9 D12).
*/
class AlarmMatchingTest {
private fun matches(
search: AlarmSearch,
alarms: List<Alarm>,
ringingId: Long? = null,
nextId: Long? = null,
): List<Long> = AlarmMatching.matches(search, alarms, ringingId, nextId)
private val three = listOf(
alarmAt(id = 1L, hour = 6, label = "Morning gym"),
alarmAt(id = 2L, hour = 7, label = "Café run"),
alarmAt(id = 3L, hour = 8, label = "My morning gym"),
)
@Test
fun `the next search prefers a ringing alarm over the next to fire`() {
assertThat(matches(AlarmSearch.Next, three, ringingId = 3L, nextId = 1L)).containsExactly(3L)
}
@Test
fun `the next search falls back to the next to fire`() {
assertThat(matches(AlarmSearch.Next, three, ringingId = null, nextId = 1L)).containsExactly(1L)
}
@Test
fun `the next search names nothing when nothing is ringing or scheduled`() {
assertThat(matches(AlarmSearch.Next, three)).isEmpty()
}
@Test
fun `the all search names every enabled alarm in list order`() {
assertThat(matches(AlarmSearch.All, three)).containsExactly(1L, 2L, 3L).inOrder()
}
@Test
fun `the all search skips a disabled alarm`() {
val alarms = three.map { if (it.id == 2L) it.copy(enabled = false) else it }
assertThat(matches(AlarmSearch.All, alarms)).containsExactly(1L, 3L).inOrder()
}
@Test
fun `the all search over no alarms names nothing`() {
assertThat(matches(AlarmSearch.All, emptyList())).isEmpty()
}
@Test
fun `a time search names the alarm at exactly that time`() {
val alarms = listOf(alarmAt(id = 4L, hour = 7, minute = 30))
assertThat(matches(AlarmSearch.AtTime(450, null), alarms)).containsExactly(4L)
}
@Test
fun `an ambiguous time search names both readings in list order`() {
val alarms = listOf(
alarmAt(id = 4L, hour = 7, minute = 30),
alarmAt(id = 5L, hour = 19, minute = 30),
)
assertThat(matches(AlarmSearch.AtTime(450, 1_170), alarms)).containsExactly(4L, 5L).inOrder()
}
@Test
fun `a time search is never nearest`() {
val alarms = listOf(
alarmAt(id = 4L, hour = 7, minute = 29),
alarmAt(id = 5L, hour = 7, minute = 31),
)
assertThat(matches(AlarmSearch.AtTime(450, null), alarms)).isEmpty()
}
@Test
fun `a time search ignores a disabled alarm at that time`() {
val alarms = listOf(alarmAt(id = 4L, hour = 7, minute = 30, enabled = false))
assertThat(matches(AlarmSearch.AtTime(450, null), alarms)).isEmpty()
}
@Test
fun `a label search matches a substring, whatever the case`() {
assertThat(matches(AlarmSearch.ByLabel("gym"), three)).containsExactly(1L, 3L).inOrder()
}
@Test
fun `a label search folds diacritics away`() {
assertThat(matches(AlarmSearch.ByLabel("cafe"), three)).containsExactly(2L)
}
@Test
fun `a label search matches a phrase with a space in it`() {
assertThat(matches(AlarmSearch.ByLabel("morning gym"), three)).containsExactly(1L, 3L).inOrder()
}
@Test
fun `a label search that matches nothing names nothing`() {
assertThat(matches(AlarmSearch.ByLabel("zzz"), three)).isEmpty()
}
@Test
fun `a label search ignores disabled alarms`() {
val alarms = three.map { it.copy(enabled = false) }
assertThat(matches(AlarmSearch.ByLabel("gym"), alarms)).isEmpty()
}
@Test
fun `an id search names a disabled alarm too`() {
val alarms = three + alarmAt(id = 4L, hour = 9, enabled = false)
assertThat(matches(AlarmSearch.ById(4L), alarms)).containsExactly(4L)
}
@Test
fun `an id search for an alarm that is not there names nothing`() {
assertThat(matches(AlarmSearch.ById(99L), three)).isEmpty()
}
@Test
fun `an unspecified search over one enabled alarm names it`() {
assertThat(matches(AlarmSearch.Unspecified, listOf(three.first()))).containsExactly(1L)
}
@Test
fun `an unspecified search names every enabled alarm`() {
val alarms = three + listOf(
alarmAt(id = 4L, hour = 9, enabled = false),
alarmAt(id = 5L, hour = 10, enabled = false),
)
assertThat(matches(AlarmSearch.Unspecified, alarms)).containsExactly(1L, 2L, 3L).inOrder()
}
@Test
fun `an unusable search names nothing, whatever is stored`() {
assertThat(matches(AlarmSearch.Unusable, three, ringingId = 1L, nextId = 2L)).isEmpty()
}
@Test
fun `only the all search acts on more than one match without asking`() {
val asks = listOf(
AlarmSearch.All,
AlarmSearch.AtTime(450, null),
AlarmSearch.ByLabel("gym"),
AlarmSearch.Unspecified,
AlarmSearch.Next,
AlarmSearch.ById(1L),
).map(AlarmMatching::asksWhenAmbiguous)
assertThat(asks).containsExactly(false, true, true, true, false, false).inOrder()
}
@Test
fun `every search names each alarm at most once, in the list's own order`() {
val searches = listOf(
AlarmSearch.ById(1L),
AlarmSearch.Next,
AlarmSearch.All,
AlarmSearch.AtTime(360, 1_080),
AlarmSearch.ByLabel("gym"),
AlarmSearch.Unspecified,
AlarmSearch.Unusable,
)
val results = searches.map { matches(it, three, ringingId = 2L, nextId = 1L) }
val ids = three.map { it.id }
assertThat(results.filter { it != it.distinct() }).isEmpty()
assertThat(results.filter { result -> result != ids.filter { it in result } }).isEmpty()
}
}
@@ -0,0 +1,282 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* `DISMISS_ALARM`, `SNOOZE_ALARM`, `DISMISS_TIMER` and the two `SHOW_*`
* actions. The ambiguity the contract says to "ask for clarification" about is
* carried as **data** — both readings of a 0..12 hour with no `IS_PM` — so it
* only ever reaches the user when the data cannot settle it (M9 D12).
*/
class DismissParsingTest {
private fun dismiss(
dataUri: String? = null,
vararg pairs: Pair<String, Any?>,
): AlarmSearch =
(
AlarmClockRequests.parse(
AlarmClockContract.ACTION_DISMISS_ALARM,
dataUri,
IntentExtras(mapOf(*pairs)),
) as AlarmClockRequest.DismissAlarm
).search
private fun snooze(vararg pairs: Pair<String, Any?>): AlarmClockRequest.SnoozeAlarm =
AlarmClockRequests.parse(
AlarmClockContract.ACTION_SNOOZE_ALARM,
dataUri = null,
extras = IntentExtras(mapOf(*pairs)),
) as AlarmClockRequest.SnoozeAlarm
private fun dismissTimer(dataUri: String?): TimerSearch =
(
AlarmClockRequests.parse(
AlarmClockContract.ACTION_DISMISS_TIMER,
dataUri,
IntentExtras.EMPTY,
) as AlarmClockRequest.DismissTimer
).search
private val mode = AlarmClockContract.EXTRA_ALARM_SEARCH_MODE
private val hour = AlarmClockContract.EXTRA_HOUR
private val minutes = AlarmClockContract.EXTRA_MINUTES
private val isPm = AlarmClockContract.EXTRA_IS_PM
private val message = AlarmClockContract.EXTRA_MESSAGE
private val duration = AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION
@Test
fun `no URI and no search mode is an unspecified search`() {
assertThat(dismiss()).isEqualTo(AlarmSearch.Unspecified)
}
@Test
fun `the next mode asks for the next alarm`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_NEXT)).isEqualTo(AlarmSearch.Next)
}
@Test
fun `the all mode asks for every alarm`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_ALL)).isEqualTo(AlarmSearch.All)
}
@Test
fun `the label mode carries the phrase`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_LABEL, message to "gym"))
.isEqualTo(AlarmSearch.ByLabel("gym"))
}
@Test
fun `the label mode with no phrase is unusable`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_LABEL))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `the label mode with a blank phrase is unusable`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_LABEL, message to " "))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `an ambiguous morning hour carries both readings`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 7, minutes to 30))
.isEqualTo(AlarmSearch.AtTime(450, 1_170))
}
@Test
fun `an explicit AM settles the hour`() {
assertThat(
dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 7, minutes to 0, isPm to false),
).isEqualTo(AlarmSearch.AtTime(420, null))
}
@Test
fun `an explicit PM settles the hour`() {
assertThat(
dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 7, minutes to 0, isPm to true),
).isEqualTo(AlarmSearch.AtTime(1_140, null))
}
@Test
fun `twelve AM is midnight`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 12, isPm to false))
.isEqualTo(AlarmSearch.AtTime(0, null))
}
@Test
fun `twelve PM is noon`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 12, isPm to true))
.isEqualTo(AlarmSearch.AtTime(720, null))
}
@Test
fun `an afternoon hour is unambiguous and has no twin`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 19))
.isEqualTo(AlarmSearch.AtTime(1_140, null))
}
@Test
fun `a twenty-four hour reading wins over a contradictory AM flag`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 19, isPm to false))
.isEqualTo(AlarmSearch.AtTime(1_140, null))
}
@Test
fun `hour zero carries midnight and noon`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 0))
.isEqualTo(AlarmSearch.AtTime(0, 720))
}
@Test
fun `a minute with no hour is unusable, because we do not scan every hour`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, minutes to 30))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `an out-of-range hour is unusable`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 25))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `an out-of-range minute is unusable`() {
assertThat(dismiss(null, mode to AlarmClockContract.SEARCH_MODE_TIME, hour to 7, minutes to 99))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `an unknown search mode is unusable`() {
assertThat(dismiss(null, mode to "android.wibble")).isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `a search mode of the wrong type reads as absent`() {
assertThat(dismiss(null, mode to 3)).isEqualTo(AlarmSearch.Unspecified)
}
@Test
fun `a deeplink beats the search mode`() {
assertThat(dismiss("clockula://alarm/7", mode to AlarmClockContract.SEARCH_MODE_ALL))
.isEqualTo(AlarmSearch.ById(7L))
}
/**
* A URI that is present and unreadable is **not** "no URI". Falling through
* to the search mode — or, with no mode, to the unspecified search's "every
* enabled alarm" — lets a caller that named one alarm and got the id wrong
* dismiss alarms it never named; with exactly one enabled alarm that
* happens with no dialog and no UI at all.
*/
@Test
fun `a malformed deeplink names no alarm rather than falling through to the mode`() {
assertThat(dismiss("clockula://alarm/x", mode to AlarmClockContract.SEARCH_MODE_ALL))
.isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `a mistyped id names no alarm rather than every enabled one`() {
assertThat(dismiss("clockula://alarm/0")).isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `a foreign URI names no alarm`() {
assertThat(dismiss("http://evil.example/alarm/7")).isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `a timer deeplink names no alarm`() {
assertThat(dismiss("clockula://timer/7")).isEqualTo(AlarmSearch.Unusable)
}
@Test
fun `a blank URI is no URI at all`() {
assertThat(dismiss(" ", mode to AlarmClockContract.SEARCH_MODE_ALL))
.isEqualTo(AlarmSearch.All)
}
@Test
fun `a snooze with no duration uses the alarm's own`() {
assertThat(snooze().minutes).isNull()
}
@Test
fun `a snooze duration in range is carried`() {
assertThat(snooze(duration to 5).minutes).isEqualTo(5)
}
@Test
fun `a zero snooze is clamped up, because the alarm is sounding now`() {
assertThat(snooze(duration to 0).minutes).isEqualTo(1)
}
@Test
fun `an enormous snooze is clamped down to an hour`() {
assertThat(snooze(duration to 999).minutes).isEqualTo(60)
}
@Test
fun `a negative snooze is clamped up`() {
assertThat(snooze(duration to -20).minutes).isEqualTo(1)
}
@Test
fun `a snooze duration sent as a String is no duration`() {
assertThat(snooze(duration to "5").minutes).isNull()
}
@Test
fun `dismissing timers with no URI means every expired one`() {
assertThat(dismissTimer(null)).isEqualTo(TimerSearch.AllExpired)
}
@Test
fun `a timer deeplink names that timer`() {
assertThat(dismissTimer("clockula://timer/3")).isEqualTo(TimerSearch.ById(3L))
}
/**
* "No URI" and "a URI we cannot read" are different: treating the second as
* the first dismisses — and for a transient timer **deletes** — every
* expired timer the user had left standing, because one caller mistyped
* one id.
*/
@Test
fun `an alarm deeplink names no timer, and does not mean all of them`() {
assertThat(dismissTimer("clockula://alarm/3")).isEqualTo(TimerSearch.Unusable)
}
@Test
fun `a mistyped timer id names no timer, and does not mean all of them`() {
assertThat(dismissTimer("clockula://timer/abc")).isEqualTo(TimerSearch.Unusable)
}
@Test
fun `the two show actions name a surface and read no extra`() {
val hostile = IntentExtras(mapOf(hour to 7, mode to AlarmClockContract.SEARCH_MODE_ALL))
val shown = listOf(
AlarmClockRequests.parse(AlarmClockContract.ACTION_SHOW_ALARMS, null, hostile),
AlarmClockRequests.parse(AlarmClockContract.ACTION_SHOW_TIMERS, null, hostile),
)
assertThat(shown).containsExactly(
AlarmClockRequest.Show(InteropSurface.ALARMS),
AlarmClockRequest.Show(InteropSurface.TIMERS),
).inOrder()
}
@Test
fun `every action parses to something, and only ours parse to something supported`() {
val inputs = AlarmClockContract.ACTIONS + listOf(null, "com.example.NONSENSE")
val parsed = inputs.map { AlarmClockRequests.parse(it, null, IntentExtras.EMPTY) }
assertThat(parsed.filter { it == AlarmClockRequest.Unsupported })
.hasSize(2)
assertThat(parsed.take(AlarmClockContract.ACTIONS.size).filter { it == AlarmClockRequest.Unsupported })
.isEmpty()
}
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* A `Bundle` from another process is not a typed record: hostile input is an
* `Int` where a `String` was documented, a `CharSequence` that is not a
* `String`, a list with a null in it. Every accessor here is **total** — absent
* and wrong-type both read back as null — because the platform's own typed
* getters cannot tell those two apart, which is the distinction M9 is about.
*/
class IntentExtrasTest {
private fun extras(vararg pairs: Pair<String, Any?>) = IntentExtras(mapOf(*pairs))
@Test
fun `an empty bundle reads back as nothing at all`() {
val extras = IntentExtras.EMPTY
assertThat(
listOf(
extras.has("k"),
extras.int("k"),
extras.string("k"),
extras.boolean("k"),
extras.intList("k"),
),
).containsExactly(false, null, null, null, null).inOrder()
}
@Test
fun `an Int reads back as an Int`() {
assertThat(extras("k" to 7).int("k")).isEqualTo(7)
}
@Test
fun `a numeric String is not an Int`() {
assertThat(extras("k" to "7").int("k")).isNull()
}
@Test
fun `a Long is not an Int`() {
assertThat(extras("k" to 7L).int("k")).isNull()
}
@Test
fun `a key that is present but null reads as null and still counts as present`() {
val extras = extras("k" to null)
assertThat(listOf(extras.int("k"), extras.has("k"))).containsExactly(null, true).inOrder()
}
@Test
fun `a String reads back as itself`() {
assertThat(extras("k" to "hi").string("k")).isEqualTo("hi")
}
@Test
fun `any CharSequence reads back as its toString`() {
assertThat(extras("k" to StringBuilder("hi")).string("k")).isEqualTo("hi")
}
@Test
fun `an Int is not a String`() {
assertThat(extras("k" to 7).string("k")).isNull()
}
@Test
fun `only a real Boolean is a Boolean`() {
val read = listOf(extras("k" to true).boolean("k"), extras("k" to "true").boolean("k"))
assertThat(read).containsExactly(true, null).inOrder()
}
@Test
fun `a list of Ints reads back in order`() {
assertThat(extras("k" to listOf(1, 2, 3)).intList("k")).containsExactly(1, 2, 3).inOrder()
}
@Test
fun `a mixed list keeps its Int entries in order`() {
assertThat(extras("k" to listOf(1, "x", 3)).intList("k")).containsExactly(1, 3).inOrder()
}
@Test
fun `a list of rubbish is an empty list, not an absent one`() {
assertThat(extras("k" to listOf("x", "y")).intList("k")).isEmpty()
}
@Test
fun `a value that is not a list at all is absent`() {
assertThat(extras("k" to 7).intList("k")).isNull()
}
@Test
fun `a null inside a list is dropped`() {
assertThat(extras("k" to listOf(1, null, 2)).intList("k")).containsExactly(1, 2).inOrder()
}
@Test
fun `an array is not a list`() {
assertThat(extras("k" to arrayOf(1, 2)).intList("k")).isNull()
}
/**
* `putExtra(key, intArrayOf(...))` is what a caller writes when it does not
* reach for `putIntegerArrayListExtra`, and it arrives as an `IntArray`.
* Reading it back as *absent* would turn a repeating alarm into an enabled
* one-shot and tell nobody.
*/
@Test
fun `an int array reads back as its entries, in order`() {
assertThat(extras("k" to intArrayOf(2, 3, 4)).intList("k")).containsExactly(2, 3, 4).inOrder()
}
@Test
fun `an empty int array is an empty list, not an absent one`() {
assertThat(extras("k" to intArrayOf()).intList("k")).isEmpty()
}
/**
* `intList` filters, so it cannot tell "an empty list" from "a list of
* nothing but rubbish". `listSize` reports what was **sent**, which is the
* only thing that separates "the caller asked for a one-shot" from "the
* caller asked for a repeat we could not read" (D8).
*/
@Test
fun `the sent size counts entries the type filter dropped`() {
assertThat(extras("k" to listOf("mon", "tue")).listSize("k")).isEqualTo(2)
}
@Test
fun `an explicitly empty list has sent nothing`() {
assertThat(extras("k" to emptyList<Int>()).listSize("k")).isEqualTo(0)
}
@Test
fun `an int array reports its own size`() {
assertThat(extras("k" to intArrayOf(2, 3, 4)).listSize("k")).isEqualTo(3)
}
@Test
fun `a value that is not a list at all has no sent size`() {
val read = listOf(extras("k" to 7).listSize("k"), IntentExtras.EMPTY.listSize("k"))
assertThat(read).containsExactly(null, null).inOrder()
}
@Test
fun `a huge hostile bundle answers a missing key without throwing`() {
val hostile = IntentExtras(
(1..10_000).associate { "key$it" to (if (it % 2 == 0) it.toString() else listOf(null)) },
)
assertThat(
listOf(
hostile.has("absent"),
hostile.int("absent"),
hostile.string("absent"),
hostile.boolean("absent"),
hostile.intList("absent"),
),
).containsExactly(false, null, null, null, null).inOrder()
}
}
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The contract lets `DISMISS_ALARM` carry "a deeplink to the alarm". Clockula
* accepts its own scheme and parses it strictly: anything else falls through to
* the search mode rather than being guessed at (M9 D20).
*/
class InteropDeepLinksTest {
@Test
fun `an alarm deeplink round-trips`() {
val uri = InteropDeepLinks.alarm(7L)
assertThat(listOf(uri, InteropDeepLinks.alarmId(uri)))
.containsExactly("clockula://alarm/7", 7L).inOrder()
}
@Test
fun `a timer deeplink round-trips`() {
assertThat(InteropDeepLinks.timerId(InteropDeepLinks.timer(7L))).isEqualTo(7L)
}
@Test
fun `a null or empty URI names nothing`() {
assertThat(listOf(InteropDeepLinks.alarmId(null), InteropDeepLinks.alarmId("")))
.containsExactly(null, null)
}
@Test
fun `a URI with no id names nothing`() {
assertThat(InteropDeepLinks.alarmId("clockula://alarm/")).isNull()
}
@Test
fun `an id must be positive`() {
assertThat(
listOf(
InteropDeepLinks.alarmId("clockula://alarm/0"),
InteropDeepLinks.alarmId("clockula://alarm/-1"),
),
).containsExactly(null, null)
}
@Test
fun `an id that overflows a Long names nothing and throws nothing`() {
assertThat(InteropDeepLinks.alarmId("clockula://alarm/99999999999999999999")).isNull()
}
@Test
fun `a second path segment names nothing`() {
assertThat(InteropDeepLinks.alarmId("clockula://alarm/7/extra")).isNull()
}
@Test
fun `the scheme and host are matched exactly, never case-insensitively`() {
assertThat(InteropDeepLinks.alarmId("CLOCKULA://ALARM/7")).isNull()
}
@Test
fun `a query or a fragment names nothing`() {
assertThat(
listOf(
InteropDeepLinks.alarmId("clockula://alarm/7?x=1"),
InteropDeepLinks.alarmId("clockula://alarm/7#frag"),
),
).containsExactly(null, null)
}
@Test
fun `the two hosts never answer for each other`() {
assertThat(
listOf(
InteropDeepLinks.timerId("clockula://alarm/7"),
InteropDeepLinks.alarmId("clockula://timer/7"),
),
).containsExactly(null, null)
}
@Test
fun `a single slash is not a deeplink`() {
assertThat(InteropDeepLinks.alarmId("clockula:/alarm/7")).isNull()
}
@Test
fun `surrounding whitespace is trimmed, because data can arrive from a shell`() {
assertThat(InteropDeepLinks.alarmId(" clockula://alarm/7 ")).isEqualTo(7L)
}
}
@@ -0,0 +1,106 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Ringtones
import org.junit.jupiter.api.Test
/**
* Both sanitisers stand between a string another process chose and a row in
* this app's database. A label is cosmetic, so it is truncated; a URI names a
* thing, so half of one is dropped rather than kept (M9 D9, D19).
*/
class InteropTextTest {
@Test
fun `a label is trimmed`() {
assertThat(InteropText.label(" Gym ")).isEqualTo("Gym")
}
@Test
fun `a label loses every control character`() {
assertThat(InteropText.label("a\nb\tc")).isEqualTo("abc")
}
@Test
fun `a label loses an embedded NUL`() {
assertThat(InteropText.label("a\u0000b")).isEqualTo("ab")
}
@Test
fun `an overlong label is truncated rather than rejected`() {
assertThat(InteropText.label("x".repeat(1_000))).hasLength(InteropLimits.MAX_LABEL_LENGTH)
}
@Test
fun `an absent or blank label is the empty string`() {
assertThat(listOf(InteropText.label(null), InteropText.label(" "))).containsExactly("", "")
}
@Test
fun `the silent sentinel becomes the app's own silent URI`() {
assertThat(InteropText.ringtoneUri("silent")).isEqualTo(Ringtones.SILENT_URI)
}
@Test
fun `the silent sentinel is case-sensitive`() {
assertThat(InteropText.ringtoneUri("SILENT")).isNull()
}
@Test
fun `a content URI passes through unchanged`() {
assertThat(InteropText.ringtoneUri("content://media/internal/audio/media/42"))
.isEqualTo("content://media/internal/audio/media/42")
}
@Test
fun `an android resource URI passes through unchanged`() {
assertThat(InteropText.ringtoneUri("android.resource://de.jeanlucmakiola.clockula/2131"))
.isEqualTo("android.resource://de.jeanlucmakiola.clockula/2131")
}
@Test
fun `a URI is trimmed`() {
assertThat(InteropText.ringtoneUri(" content://media/42 ")).isEqualTo("content://media/42")
}
@Test
fun `a file URI is dropped`() {
assertThat(InteropText.ringtoneUri("file:///sdcard/a.mp3")).isNull()
}
@Test
fun `an http URI is dropped`() {
assertThat(InteropText.ringtoneUri("http://example.com/a.mp3")).isNull()
}
@Test
fun `a javascript URI is dropped`() {
assertThat(InteropText.ringtoneUri("javascript:alert(1)")).isNull()
}
@Test
fun `a bare filesystem path is dropped`() {
assertThat(InteropText.ringtoneUri("/sdcard/a.mp3")).isNull()
}
@Test
fun `an absent, empty or blank URI is dropped`() {
val read = listOf(
InteropText.ringtoneUri(null),
InteropText.ringtoneUri(""),
InteropText.ringtoneUri(" "),
)
assertThat(read).containsExactly(null, null, null)
}
@Test
fun `an overlong URI is dropped rather than truncated`() {
assertThat(InteropText.ringtoneUri("content://" + "x".repeat(3_000))).isNull()
}
@Test
fun `a URI carrying a control character is dropped`() {
assertThat(InteropText.ringtoneUri("content://media/4\n2")).isNull()
}
}
@@ -0,0 +1,324 @@
package de.jeanlucmakiola.clockula.domain.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 org.junit.jupiter.api.Test
import java.time.DayOfWeek
/**
* `SET_ALARM`, extra by extra. The rule the whole milestone turns on: a value
* that decides when something will ring in the future is **dropped** when it is
* out of range, never clamped — clamping hour 25 to 23 sets an enabled alarm
* for an hour nobody chose, which is the failure mode this app exists not to
* have (M9 D5).
*/
class SetAlarmParsingTest {
private fun parse(vararg pairs: Pair<String, Any?>): AlarmClockRequest.SetAlarm =
AlarmClockRequests.parse(
AlarmClockContract.ACTION_SET_ALARM,
dataUri = null,
extras = IntentExtras(mapOf(*pairs)),
) as AlarmClockRequest.SetAlarm
private val hour = AlarmClockContract.EXTRA_HOUR
private val minutes = AlarmClockContract.EXTRA_MINUTES
private val message = AlarmClockContract.EXTRA_MESSAGE
private val days = AlarmClockContract.EXTRA_DAYS
private val ringtone = AlarmClockContract.EXTRA_RINGTONE
private val vibrate = AlarmClockContract.EXTRA_VIBRATE
private val skipUi = AlarmClockContract.EXTRA_SKIP_UI
@Test
fun `an hour and a minute make a complete spec`() {
val request = parse(hour to 7, minutes to 30)
assertThat(listOf(request.spec.time, request.spec.isComplete, request.skipUi))
.containsExactly(TimeOfDay(7, 30), true, false).inOrder()
}
@Test
fun `an absent minute is the contract's documented zero`() {
assertThat(parse(hour to 7).spec.time).isEqualTo(TimeOfDay(7, 0))
}
@Test
fun `an absent hour leaves the spec incomplete`() {
val request = parse(minutes to 30)
assertThat(listOf(request.spec.time, request.spec.isComplete))
.containsExactly(null, false).inOrder()
}
@Test
fun `an incomplete spec forces skipUi off, because the user must see the alarm`() {
assertThat(parse(skipUi to true).skipUi).isFalse()
}
@Test
fun `hour 24 is dropped, never clamped to 23`() {
assertThat(parse(hour to 24).spec.time).isNull()
}
@Test
fun `a negative hour is dropped`() {
assertThat(parse(hour to -1).spec.time).isNull()
}
@Test
fun `the largest possible hour is dropped`() {
assertThat(parse(hour to Int.MAX_VALUE).spec.time).isNull()
}
@Test
fun `minute 60 drops the whole time, rather than defaulting to zero`() {
assertThat(parse(hour to 7, minutes to 60).spec.time).isNull()
}
@Test
fun `a negative minute drops the whole time`() {
assertThat(parse(hour to 7, minutes to -1).spec.time).isNull()
}
@Test
fun `an hour sent as a String is not an hour`() {
assertThat(parse(hour to "7").spec.time).isNull()
}
@Test
fun `midnight is a complete spec`() {
val request = parse(hour to 0, minutes to 0)
assertThat(listOf(request.spec.time, request.spec.isComplete))
.containsExactly(TimeOfDay(0, 0), true).inOrder()
}
@Test
fun `the last minute of the day is a complete spec`() {
val request = parse(hour to 23, minutes to 59)
assertThat(listOf(request.spec.time, request.spec.isComplete))
.containsExactly(TimeOfDay(23, 59), true).inOrder()
}
@Test
fun `a complete spec keeps the skipUi the caller asked for`() {
assertThat(parse(hour to 7, skipUi to true).skipUi).isTrue()
}
@Test
fun `skipUi sent as a String is not skipUi`() {
assertThat(parse(hour to 7, skipUi to "true").skipUi).isFalse()
}
@Test
fun `a message becomes a sanitised label`() {
assertThat(parse(hour to 7, message to " Gym\n ").spec.label).isEqualTo("Gym")
}
@Test
fun `an absent message is an empty label`() {
assertThat(parse(hour to 7).spec.label).isEmpty()
}
@Test
fun `an overlong message is truncated`() {
assertThat(parse(hour to 7, message to "x".repeat(1_000)).spec.label)
.hasLength(InteropLimits.MAX_LABEL_LENGTH)
}
@Test
fun `Calendar Monday through Friday is the weekdays mask`() {
assertThat(parse(hour to 7, days to listOf(2, 3, 4, 5, 6)).spec.repeatDays)
.isEqualTo(RepeatDays.WEEKDAYS)
}
@Test
fun `Calendar one is Sunday`() {
assertThat(parse(hour to 7, days to listOf(1)).spec.repeatDays)
.isEqualTo(RepeatDays.of(DayOfWeek.SUNDAY))
}
@Test
fun `Calendar seven is Saturday`() {
assertThat(parse(hour to 7, days to listOf(7)).spec.repeatDays)
.isEqualTo(RepeatDays.of(DayOfWeek.SATURDAY))
}
@Test
fun `all seven Calendar days are every day`() {
assertThat(parse(hour to 7, days to listOf(1, 2, 3, 4, 5, 6, 7)).spec.repeatDays)
.isEqualTo(RepeatDays.EVERY_DAY)
}
@Test
fun `Calendar Sunday and Saturday are the weekend`() {
assertThat(parse(hour to 7, days to listOf(1, 7)).spec.repeatDays).isEqualTo(RepeatDays.WEEKENDS)
}
@Test
fun `a repeated day cannot be double-counted`() {
assertThat(parse(hour to 7, days to listOf(2, 2, 2)).spec.repeatDays)
.isEqualTo(RepeatDays.of(DayOfWeek.MONDAY))
}
@Test
fun `a repeat list none of which survives leaves the spec incomplete`() {
val complete = listOf(0, 8, -3, 99).map { parse(hour to 7, days to listOf(it)).spec.isComplete }
assertThat(complete).containsExactly(false, false, false, false)
}
@Test
fun `a partly valid repeat list keeps what survived and stays complete`() {
val request = parse(hour to 7, days to listOf(2, 9))
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.of(DayOfWeek.MONDAY), true).inOrder()
}
/**
* `putStringArrayListExtra(EXTRA_DAYS, listOf("mon", "tue"))` sends a
* non-empty list none of whose entries is a `Calendar` constant. Reading it
* as "no repeat asked for" would set an enabled one-shot and the user would
* miss every weekday after the first — the harm D8 exists to prevent, so
* the spec is **incomplete** and the editor opens instead.
*/
@Test
fun `a repeat list of strings leaves the spec incomplete`() {
val request = parse(hour to 7, days to listOf("mon", "tue"))
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.NONE, false).inOrder()
}
@Test
fun `a repeat list of nulls leaves the spec incomplete`() {
assertThat(parse(hour to 7, days to listOf(null, null)).spec.isComplete).isFalse()
}
@Test
fun `a repeat list of mixed types still repeats on the entries that survived`() {
val request = parse(hour to 7, days to listOf("mon", 3, "sat"))
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.of(DayOfWeek.TUESDAY), true).inOrder()
}
@Test
fun `an explicitly empty repeat list is a one-shot alarm`() {
val request = parse(hour to 7, days to emptyList<Int>())
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.NONE, true).inOrder()
}
@Test
fun `an absent repeat list is a one-shot alarm`() {
val request = parse(hour to 7)
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.NONE, true).inOrder()
}
@Test
fun `a repeat extra of the wrong type reads as absent, not as malformed`() {
val request = parse(hour to 7, days to 2)
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(RepeatDays.NONE, true).inOrder()
}
@Test
fun `a repeat list sent as an int array still repeats`() {
val request = parse(hour to 7, days to intArrayOf(2, 3, 4))
assertThat(listOf(request.spec.repeatDays, request.spec.isComplete))
.containsExactly(
RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY),
true,
).inOrder()
}
@Test
fun `an int array none of which survives leaves the spec incomplete`() {
assertThat(parse(hour to 7, days to intArrayOf(0, 99)).spec.isComplete).isFalse()
}
@Test
fun `a repeat entry of the wrong type is dropped on its own`() {
assertThat(parse(hour to 7, days to listOf(2, "3")).spec.repeatDays)
.isEqualTo(RepeatDays.of(DayOfWeek.MONDAY))
}
@Test
fun `the silent ringtone sentinel survives parsing`() {
assertThat(parse(hour to 7, ringtone to "silent").spec.ringtoneUri)
.isEqualTo(Ringtones.SILENT_URI)
}
@Test
fun `a content ringtone URI survives parsing`() {
assertThat(parse(hour to 7, ringtone to "content://media/42").spec.ringtoneUri)
.isEqualTo("content://media/42")
}
@Test
fun `an unusable sound is dropped and never blocks the alarm`() {
val request = parse(hour to 7, ringtone to "file:///a.mp3")
assertThat(listOf(request.spec.ringtoneUri, request.spec.isComplete))
.containsExactly(null, true).inOrder()
}
@Test
fun `an absent ringtone inherits the app default`() {
assertThat(parse(hour to 7).spec.ringtoneUri).isNull()
}
@Test
fun `vibrate true is carried`() {
assertThat(parse(hour to 7, vibrate to true).spec.vibrate).isTrue()
}
@Test
fun `an explicit vibrate false is a choice, and is kept`() {
assertThat(parse(hour to 7, vibrate to false).spec.vibrate).isFalse()
}
@Test
fun `an absent vibrate inherits rather than assuming true`() {
assertThat(parse(hour to 7).spec.vibrate).isNull()
}
@Test
fun `vibrate sent as an Int inherits`() {
assertThat(parse(hour to 7, vibrate to 1).spec.vibrate).isNull()
}
@Test
fun `an intent hostile in every extra at once parses to an empty, incomplete spec`() {
val request = parse(
hour to "x",
minutes to 99,
days to "mon",
ringtone to "javascript:0",
vibrate to "yes",
message to "\u0001".repeat(5_000),
skipUi to 1,
)
assertThat(
listOf(
request.spec.time,
request.spec.repeatDays,
request.spec.ringtoneUri,
request.spec.vibrate,
request.spec.label,
request.skipUi,
),
).containsExactly(null, RepeatDays.NONE, null, null, "", false).inOrder()
}
}
@@ -0,0 +1,108 @@
package de.jeanlucmakiola.clockula.domain.interop
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* `SET_TIMER`. A length is a future ring, so an out-of-range one is dropped and
* the setup panel opens rather than a timer of some other length starting
* (M9 D5, D7).
*/
class SetTimerParsingTest {
private fun parse(vararg pairs: Pair<String, Any?>): AlarmClockRequest.SetTimer =
AlarmClockRequests.parse(
AlarmClockContract.ACTION_SET_TIMER,
dataUri = null,
extras = IntentExtras(mapOf(*pairs)),
) as AlarmClockRequest.SetTimer
private val length = AlarmClockContract.EXTRA_LENGTH
private val message = AlarmClockContract.EXTRA_MESSAGE
private val skipUi = AlarmClockContract.EXTRA_SKIP_UI
@Test
fun `a length in seconds becomes a duration`() {
assertThat(parse(length to 300).length).isEqualTo(5.minutes)
}
@Test
fun `one second is accepted`() {
assertThat(parse(length to 1).length).isEqualTo(1.seconds)
}
@Test
fun `twenty-four hours is accepted`() {
assertThat(parse(length to 86_400).length).isEqualTo(24.hours)
}
@Test
fun `a zero length is dropped`() {
assertThat(parse(length to 0).length).isNull()
}
@Test
fun `a negative length is dropped`() {
assertThat(parse(length to -1).length).isNull()
}
@Test
fun `a length past twenty-four hours is dropped, never clamped`() {
assertThat(parse(length to 86_401).length).isNull()
}
@Test
fun `the largest possible length is dropped`() {
assertThat(parse(length to Int.MAX_VALUE).length).isNull()
}
@Test
fun `a length sent as a String is not a length`() {
assertThat(parse(length to "300").length).isNull()
}
@Test
fun `an absent length is no length`() {
assertThat(parse().length).isNull()
}
@Test
fun `no length forces skipUi off`() {
assertThat(parse(skipUi to true).skipUi).isFalse()
}
@Test
fun `a valid length keeps the skipUi the caller asked for`() {
assertThat(parse(length to 300, skipUi to true).skipUi).isTrue()
}
@Test
fun `a message becomes a sanitised label, and an absent one an empty label`() {
val labels = listOf(
parse(length to 300, message to " Pasta\n").label,
parse(length to 300).label,
)
assertThat(labels).containsExactly("Pasta", "").inOrder()
}
@Test
fun `an overlong message is truncated`() {
assertThat(parse(length to 300, message to "x".repeat(1_000)).label)
.hasLength(InteropLimits.MAX_LABEL_LENGTH)
}
@Test
fun `the contract's length range is the app's own timer clamp`() {
val ends = listOf(
InteropLimits.TIMER_SECONDS.first.seconds,
InteropLimits.TIMER_SECONDS.last.seconds,
)
assertThat(ends).containsExactly(TimerPresets.MIN, TimerPresets.MAX).inOrder()
}
}