diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1d0acd7..7a5cc62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,6 +71,57 @@ + + + + + + + + + + + + + + + + + + + + + = applicationScope.async { handler.handle(request) } + + lifecycleScope.launch { + // A caller's broken intent must not take the app down mid-alarm, and + // the write is already independent of this activity. + val outcome = runCatching { work.await() }.getOrDefault(InteropOutcome.Done) + InteropLaunch.intentFor(this@AlarmClockActivity, outcome)?.let(::startActivity) + finish() + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt new file mode 100644 index 0000000..c0d789d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt @@ -0,0 +1,192 @@ +package de.jeanlucmakiola.clockula.interop + +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.AlarmDefaults +import de.jeanlucmakiola.clockula.domain.AlarmDraft +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.TimerDraft +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.alarm.FireSource +import de.jeanlucmakiola.clockula.domain.interop.AlarmClockRequest +import de.jeanlucmakiola.clockula.domain.interop.AlarmMatching +import de.jeanlucmakiola.clockula.domain.interop.AlarmSearch +import de.jeanlucmakiola.clockula.domain.interop.AlarmSpec +import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome +import de.jeanlucmakiola.clockula.domain.interop.InteropSurface +import de.jeanlucmakiola.clockula.domain.interop.TimerSearch +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.timer.TimerEngine +import kotlinx.coroutines.flow.first +import kotlin.time.Duration +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The orchestrator for the `AlarmClock` contract: a parsed request in, every + * write through a repository or an engine, an [InteropOutcome] out. Plain + * Kotlin — no `android.*` import may ever appear in this file (M9 D1, D28). + */ +@Singleton +class AlarmClockHandler @Inject constructor( + private val alarms: AlarmRepository, + private val timers: TimerRepository, + private val alarmEngine: AlarmEngine, + private val timerEngine: TimerEngine, + private val wallClock: WallClock, + private val zones: ZoneProvider, +) { + suspend fun handle(request: AlarmClockRequest): InteropOutcome = when (request) { + is AlarmClockRequest.SetAlarm -> setAlarm(request.spec, request.skipUi) + is AlarmClockRequest.SetTimer -> setTimer(request.length, request.label, request.skipUi) + is AlarmClockRequest.Show -> InteropOutcome.Show(request.surface) + is AlarmClockRequest.DismissAlarm -> dismissAlarm(request.search) + is AlarmClockRequest.SnoozeAlarm -> snooze(request.minutes) + is AlarmClockRequest.DismissTimer -> dismissTimer(request.search) + // Garbage aimed at an exported activity opens nothing (D24). + AlarmClockRequest.Unsupported -> InteropOutcome.Done + } + + /** + * Complete ⇒ write; incomplete ⇒ create the alarm it *can* and open its + * editor. `SKIP_UI` never means "skip validation": nothing is written and + * hidden, and an incomplete spec always puts the user in front of the alarm + * (D6). The editor edits a persisted row (M5 D2), so the row is created + * first — the same create-then-navigate the FAB does. + */ + private suspend fun setAlarm(spec: AlarmSpec, skipUi: Boolean): InteropOutcome { + val time = spec.time ?: run { + val id = alarms.create( + AlarmDraft( + time = AlarmDefaults.nextWholeHour(wallClock.now(), zones.current()), + label = spec.label, + repeatDays = spec.repeatDays, + ringtoneUri = spec.ringtoneUri, + vibrate = spec.vibrate, + ), + ) + alarmEngine.reschedule() + // Never transient: the user is about to take ownership of this row. + return InteropOutcome.OpenAlarmEditor(id) + } + + val existing = alarms.alarms().first().filter { it.matches(spec) }.minByOrNull { it.id } + if (existing != null) { + // "The alarm is always enabled", and a pending skip would silently + // eat the very occurrence the caller just asked for (D11). The + // contract is explicit that a re-used alarm is not removed after + // use, so `deleteAfterUse` is left exactly as it was. + alarms.setEnabled(existing.id, true) + alarms.setSkipNextOccurrence(existing.id, false) + } else { + alarms.create( + AlarmDraft( + time = time, + label = spec.label, + repeatDays = spec.repeatDays, + ringtoneUri = spec.ringtoneUri, + vibrate = spec.vibrate, + // "If a time of day is specified, and EXTRA_SKIP_UI is true, + // and the alarm is not repeating, the implementation should + // remove this alarm after it has been dismissed" (D10). + deleteAfterUse = skipUi && !spec.repeatDays.isRepeating, + ), + ) + } + alarmEngine.reschedule() + return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.ALARMS) + } + + /** The five fields the contract's extras can set, and nothing else (D11). */ + private fun Alarm.matches(spec: AlarmSpec): Boolean = + time == spec.time && + repeatDays == spec.repeatDays && + label == spec.label && + ringtoneUri == spec.ringtoneUri && + vibrate == spec.vibrate + + /** + * "This action always starts the timer", so `SKIP_UI` changes only whether a + * window opens. With no usable length nothing is written: a timer has a + * natural commit point and the keypad is a real "activity capable of setting + * a timer" (D7). + */ + private suspend fun setTimer(length: Duration?, label: String, skipUi: Boolean): InteropOutcome { + if (length == null) return InteropOutcome.ComposeTimer + + // "An identical, *unused* timer may be re-used": restarting somebody's + // running countdown is destructive, so only an IDLE one qualifies. + val existing = timers.timers().first() + .filter { it.state == TimerState.IDLE && it.matches(length, label) } + .minByOrNull { it.id } + val id = existing?.id + ?: timers.create(TimerDraft(duration = length, label = label, deleteAfterUse = skipUi)) + timerEngine.start(id) + return if (skipUi) InteropOutcome.Done else InteropOutcome.Show(InteropSurface.TIMERS) + } + + private fun Timer.matches(length: Duration, label: String): Boolean = + duration == length && this.label == label + + /** + * Which alarm, and when to ask instead of acting. `ALL` means "all of them", + * so it never asks; every other search with two or more matches hands the + * question to the user rather than guessing (D12). + */ + private suspend fun dismissAlarm(search: AlarmSearch): InteropOutcome { + val stored = alarms.alarms().first() + val ringingId = if (search == AlarmSearch.Next) alarmEngine.ringSession()?.alarm?.id else null + val nextId = if (search == AlarmSearch.Next) nextToFire() else null + val ids = AlarmMatching.matches(search, stored, ringingId, nextId) + + return when { + // Nothing matched: nothing is written, and the user is shown the + // list so they can see why. + ids.isEmpty() -> InteropOutcome.Show(InteropSurface.ALARMS) + ids.size > 1 && AlarmMatching.asksWhenAmbiguous(search) -> + InteropOutcome.ChooseAlarmToDismiss(ids) + else -> { + for (id in ids) alarmEngine.dismissUpcoming(id) + InteropOutcome.Done + } + } + } + + /** Read-only: `upcoming()` never writes, so asking who is next writes nothing. */ + private suspend fun nextToFire(): Long? = alarmEngine.upcoming().first() + .firstOrNull { it.nextFire != null && it.source != FireSource.RESUMED_RING } + ?.alarm + ?.id + + /** + * The duration applies to the currently ringing alarm only, and nothing is + * written when nothing is ringing (D15). + */ + private suspend fun snooze(minutes: Int?): InteropOutcome { + val ringingId = alarmEngine.ringSession()?.alarm?.id ?: return InteropOutcome.Done + alarmEngine.snooze(ringingId, minutes) + return InteropOutcome.Done + } + + /** + * A URI we cannot read names no timer, and "all of them" is not a safe + * guess: it would dismiss — and for a transient timer delete — every + * expired timer the user had left standing. Nothing is written and the + * Timers tab opens, the same answer a `DISMISS_ALARM` that matched nothing + * gives. + */ + private suspend fun dismissTimer(search: TimerSearch): InteropOutcome = when (search) { + TimerSearch.AllExpired -> { + timerEngine.dismissAllExpired() + InteropOutcome.Done + } + is TimerSearch.ById -> { + timerEngine.dismissExpired(search.timerId) + InteropOutcome.Done + } + TimerSearch.Unusable -> InteropOutcome.Show(InteropSurface.TIMERS) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt new file mode 100644 index 0000000..45ada59 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/IntentExtrasReader.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.clockula.interop + +import android.content.Intent +import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract +import de.jeanlucmakiola.clockula.domain.interop.IntentExtras + +/** `Intent` → [IntentExtras]: the contract's keys only, types kept intact (M9 D4). */ +object IntentExtrasReader { + /** Only `AlarmClockContract.EXTRA_KEYS` are lifted. Never throws. */ + @Suppress("DEPRECATION") + fun read(intent: Intent): IntentExtras { + // `Bundle.get` is the only API that returns a heterogeneous value with + // its type intact. The typed getters hide "wrong type" behind a default + // and cannot tell it from "absent" — which is precisely the distinction + // this boundary is about (D4). The unbundling itself is guarded: a + // Parcelable an unrelated app sent can fail to unmarshal. + val extras = runCatching { intent.extras }.getOrNull() ?: return IntentExtras.EMPTY + val values = AlarmClockContract.EXTRA_KEYS + .filter { runCatching { extras.containsKey(it) }.getOrDefault(false) } + .associateWith { runCatching { extras.get(it) }.getOrNull() } + return IntentExtras(values) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt new file mode 100644 index 0000000..90396da --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropIntents.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.clockula.interop + +/** + * The **internal** transport from the exported door to `MainActivity`. The + * platform's own action strings never reach the shell — the door translates + * them — so a process death that recreates `MainActivity` with its original + * intent replays navigation and never a write (M9 D1). + */ +object InteropIntents { + private const val PREFIX = "de.jeanlucmakiola.clockula." + + const val ACTION_OPEN_ALARM_EDITOR: String = PREFIX + "action.OPEN_ALARM_EDITOR" + const val ACTION_COMPOSE_TIMER: String = PREFIX + "action.COMPOSE_TIMER" + const val ACTION_CHOOSE_ALARM_TO_DISMISS: String = PREFIX + "action.CHOOSE_ALARM_TO_DISMISS" + + const val EXTRA_ALARM_ID: String = PREFIX + "extra.INTEROP_ALARM_ID" + const val EXTRA_ALARM_IDS: String = PREFIX + "extra.INTEROP_ALARM_IDS" + + /** All three, for the distinctness assertions. */ + val ACTIONS: List = listOf( + ACTION_OPEN_ALARM_EDITOR, + ACTION_COMPOSE_TIMER, + ACTION_CHOOSE_ALARM_TO_DISMISS, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt new file mode 100644 index 0000000..eb89add --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/interop/InteropLaunch.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.clockula.interop + +import android.content.Context +import android.content.Intent +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.domain.interop.InteropOutcome +import de.jeanlucmakiola.clockula.domain.interop.InteropSurface +import de.jeanlucmakiola.clockula.timer.TimerIntents + +/** What an outcome asks `MainActivity` to show, if anything (M9 D1). */ +object InteropLaunch { + /** The internal action an outcome asks MainActivity for. Null for [InteropOutcome.Done]. */ + fun actionFor(outcome: InteropOutcome): String? = when (outcome) { + // `SKIP_UI` means "bypass any intermediate UI", and opening the app + // because somebody aimed garbage at an exported activity would be a + // free way to take over the user's screen (D24, D25). + InteropOutcome.Done -> null + is InteropOutcome.Show -> when (outcome.surface) { + InteropSurface.ALARMS -> AlarmIntents.ACTION_SHOW_ALARMS + InteropSurface.TIMERS -> TimerIntents.ACTION_SHOW_TIMERS + } + is InteropOutcome.OpenAlarmEditor -> InteropIntents.ACTION_OPEN_ALARM_EDITOR + InteropOutcome.ComposeTimer -> InteropIntents.ACTION_COMPOSE_TIMER + is InteropOutcome.ChooseAlarmToDismiss -> InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS + } + + /** + * Android-side: the whole Intent, or null when nothing should be shown. + * + * `MainActivity` is `singleTop`, so `NEW_TASK` brings an existing task + * forward and `onNewIntent` replays the request — the write has already + * happened, and what travels here is navigation and nothing else (D1). + */ + fun intentFor(context: Context, outcome: InteropOutcome): Intent? { + val action = actionFor(outcome) ?: return null + return Intent(context, MainActivity::class.java).apply { + setAction(action) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (outcome is InteropOutcome.OpenAlarmEditor) { + putExtra(InteropIntents.EXTRA_ALARM_ID, outcome.alarmId) + } + if (outcome is InteropOutcome.ChooseAlarmToDismiss) { + putExtra(InteropIntents.EXTRA_ALARM_IDS, outcome.alarmIds.toLongArray()) + } + } + } +} diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 939bd76..5aa70ef 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,4 +4,8 @@ @android:color/transparent true + + +