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