diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt index 73f8bc7..581ee36 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContract.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContract.kt new file mode 100644 index 0000000..6fde28f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContract.kt @@ -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 = 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 = 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequest.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequest.kt new file mode 100644 index 0000000..1452f56 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequest.kt @@ -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) : InteropOutcome +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequests.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequests.kt new file mode 100644 index 0000000..e5b37e9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockRequests.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatching.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatching.kt new file mode 100644 index 0000000..246600c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatching.kt @@ -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, + ringingId: Long?, + nextId: Long?, + ): List = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtras.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtras.kt new file mode 100644 index 0000000..54358f1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtras.kt @@ -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) { + + 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? = + // 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() + 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()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinks.kt new file mode 100644 index 0000000..0e79b93 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinks.kt @@ -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 } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropLimits.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropLimits.kt new file mode 100644 index 0000000..907bc9a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropLimits.kt @@ -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 = listOf("content://", "android.resource://") +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropText.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropText.kt new file mode 100644 index 0000000..25ec093 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/interop/InteropText.kt @@ -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 + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContractTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContractTest.kt new file mode 100644 index 0000000..b5228bc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmClockContractTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatchingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatchingTest.kt new file mode 100644 index 0000000..de48cbb --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/AlarmMatchingTest.kt @@ -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, + ringingId: Long? = null, + nextId: Long? = null, + ): List = 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/DismissParsingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/DismissParsingTest.kt new file mode 100644 index 0000000..1f343a2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/DismissParsingTest.kt @@ -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, + ): AlarmSearch = + ( + AlarmClockRequests.parse( + AlarmClockContract.ACTION_DISMISS_ALARM, + dataUri, + IntentExtras(mapOf(*pairs)), + ) as AlarmClockRequest.DismissAlarm + ).search + + private fun snooze(vararg pairs: Pair): 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtrasTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtrasTest.kt new file mode 100644 index 0000000..eae6a93 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/IntentExtrasTest.kt @@ -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) = 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()).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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinksTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinksTest.kt new file mode 100644 index 0000000..568b1c1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropDeepLinksTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropTextTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropTextTest.kt new file mode 100644 index 0000000..1af9aef --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/InteropTextTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetAlarmParsingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetAlarmParsingTest.kt new file mode 100644 index 0000000..0467c92 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetAlarmParsingTest.kt @@ -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): 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()) + + 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetTimerParsingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetTimerParsingTest.kt new file mode 100644 index 0000000..e6c69b3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/interop/SetTimerParsingTest.kt @@ -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): 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() + } +}