diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index ff6226b..e5c7816 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -17,10 +17,11 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.interop.InteropIntents import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity -import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation +import de.jeanlucmakiola.clockula.ui.shell.ShellRequest import de.jeanlucmakiola.clockula.ui.shell.RequestNotificationPermissionOnce import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.floret.crash.CrashReportDialog @@ -52,12 +53,17 @@ class MainActivity : ComponentActivity() { private var pendingCrashReport by mutableStateOf(null) /** - * A tab an incoming intent asked for, consumed once by the shell (M6 D24). - * Read in both `onCreate` and `onNewIntent`, because the activity is - * `singleTop`: tapping a timer notification with the app already open - * delivers a new intent to the running instance rather than creating one. + * What an incoming intent asked the shell for, consumed once by it + * (M6 D24, M9 D23). Read in both `onCreate` and `onNewIntent`, because the + * activity is `singleTop`: tapping a timer notification with the app already + * open delivers a new intent to the running instance rather than creating + * one. + * + * Only ever *navigation*: the exported `AlarmClockActivity` has already done + * the writing, so a process death that recreates this activity with its + * original intent replays a tab selection and never a second alarm. */ - private var openTab by mutableStateOf(null) + private var openRequest by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,7 +80,12 @@ class MainActivity : ComponentActivity() { // Surface a single captured crash as a dialog on the next launch. if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) - openTab = ShellNavigation.tabForAction(intent?.action) + // Handled **once**. `setIntent` keeps the launching intent, so + // re-deriving it on every configuration change would replay stateful + // navigation: a rotation would force the user back into the editor, + // re-open the timer setup sheet, or re-ask the dismiss question they + // just answered. A new intent arrives through `onNewIntent` instead. + if (savedInstanceState == null) openRequest = requestFrom(intent) setContent { // Until the first snapshot arrives, the family's defaults stand in — @@ -96,10 +107,10 @@ class MainActivity : ComponentActivity() { ) { ClockulaShell( modifier = Modifier.fillMaxSize(), - openTab = openTab, + openRequest = openRequest, // Consumed once: a request left standing would re-select the // tab on every recomposition and fight the user's taps. - onTabOpened = { openTab = null }, + onRequestConsumed = { openRequest = null }, ) // First launch asks for notifications once, and only here: // it belongs to starting the app, not to any one tab. @@ -132,7 +143,29 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - openTab = ShellNavigation.tabForAction(intent.action) + openRequest = requestFrom(intent) + } + + /** + * Degrades rather than throws: a malformed intent still lands somewhere + * sensible (M9 D23). + * + * Each extra read is guarded, because reading *any* extra unparcels the + * whole `Bundle`. This activity is exported, so another app can hand it a + * Parcelable whose class is not in our classloader, and the + * `BadParcelableException` that follows would be thrown inside `onCreate` — + * a launch crash that also feeds `CrashReporter.isCrashLoop`. Reading the + * action never unparcels, so an intent we cannot unpack still selects its + * tab. `IntentExtrasReader.read` carries the same guard. + */ + private fun requestFrom(intent: Intent?): ShellRequest? { + if (intent == null) return null + val alarmId = runCatching { intent.getLongExtra(InteropIntents.EXTRA_ALARM_ID, 0L) }.getOrNull() + val alarmIds = runCatching { intent.getLongArrayExtra(InteropIntents.EXTRA_ALARM_IDS) } + .getOrNull() + ?.toList() + .orEmpty() + return ShellNavigation.requestFor(intent.action, alarmId, alarmIds) } override fun onResume() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt index 423b0f4..af65f07 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmIntents.kt @@ -14,6 +14,9 @@ object AlarmIntents { const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE" const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS" + /** Internal: "show me the alarms tab", the transport for `SHOW_ALARMS` and the next-alarm show intent. */ + const val ACTION_SHOW_ALARMS: String = PREFIX + "action.SHOW_ALARMS" + const val EXTRA_ALARM_ID: String = PREFIX + "extra.ALARM_ID" const val EXTRA_FIRE_AT: String = PREFIX + "extra.FIRE_AT" diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index 986a7f4..083c56b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -16,6 +16,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -55,8 +58,8 @@ import de.jeanlucmakiola.floret.identity.predictiveBack @Composable fun ClockulaShell( modifier: Modifier = Modifier, - openTab: ClockulaDestination? = null, - onTabOpened: () -> Unit = {}, + openRequest: ShellRequest? = null, + onRequestConsumed: () -> Unit = {}, viewModel: ShellViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), ) { @@ -70,13 +73,44 @@ fun ClockulaShell( // rather than sliding: a slide would claim a hierarchy that is not there. val fade = fadeThrough() - // A notification's deep link, replayed as exactly the command a tab tap - // produces — which is what the user expects, and it leaves M4's asserted - // back policy alone: the start destination is still Alarms (M6 D24). - LaunchedEffect(openTab) { - val target = openTab ?: return@LaunchedEffect - navController.replay(ShellNavigation.onTabSelected(route, target)) - onTabOpened() + // The `AlarmClock` contract's chooser, held across a rotation but not a + // process death: the intent that asked has been consumed, and re-asking + // would be a dialog with no provenance (M9 D13). + var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) } + // Whether an incoming intent asked for the timer setup panel (M9 D7). + var timerSetupRequested by rememberSaveable { mutableStateOf(false) } + + // A notification's deep link — or the exported door's outcome — replayed as + // exactly the command a tab tap produces, which is what the user expects, + // and it leaves M4's asserted back policy alone: the start destination is + // still Alarms (M6 D24, M9 D23). + LaunchedEffect(openRequest) { + when (val request = openRequest) { + null -> return@LaunchedEffect + is ShellRequest.OpenTab -> + navController.replay(ShellNavigation.onTabSelected(route, request.destination)) + is ShellRequest.OpenAlarmEditor -> { + // The same two steps the FAB takes, so the back stack is the one + // M5 asserted: the tab, then the editor on top of it. + navController.replay( + ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS), + ) + navController.navigate(AlarmRoutes.editor(request.alarmId)) + } + ShellRequest.ComposeTimer -> { + navController.replay( + ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS), + ) + timerSetupRequested = true + } + is ShellRequest.ChooseAlarmToDismiss -> { + navController.replay( + ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS), + ) + dismissCandidates = request.alarmIds.toLongArray() + } + } + onRequestConsumed() } NavigationSuiteScaffold( @@ -119,7 +153,11 @@ fun ClockulaShell( popExitTransition = { fade.initialContentExit }, ) { composable(ClockulaDestination.ALARMS.route) { - AlarmsScreen(onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) }) + AlarmsScreen( + onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) }, + dismissCandidates = dismissCandidates, + onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) }, + ) } // A sibling route in the same flat host, not a nested one: // it is no tab, so the Alarms tab stays selected and the @@ -133,7 +171,11 @@ fun ClockulaShell( AlarmEditorScreen(onBack = { navController.popBackStack() }) } composable(ClockulaDestination.TIMERS.route) { - TimersScreen(onEditTimer = { navController.navigate(TimerRoutes.editor(it)) }) + TimersScreen( + onEditTimer = { navController.navigate(TimerRoutes.editor(it)) }, + requestSetup = timerSetupRequested, + onSetupRequestConsumed = { timerSetupRequested = false }, + ) } // A sibling route in the same flat host, exactly as the // alarm editor is: it is no tab, so the Timers tab stays diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index 34c94bc..5f3f426 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.clockula.ui.shell +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.interop.InteropIntents import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.timer.TimerIntents @@ -13,6 +15,17 @@ data class TabNavCommand( val launchSingleTop: Boolean, ) +/** + * What an incoming intent asks the shell to do. Three of the `AlarmClock` + * contract's outcomes are not "select a tab" (M9 D23). + */ +sealed interface ShellRequest { + data class OpenTab(val destination: ClockulaDestination) : ShellRequest + data class OpenAlarmEditor(val alarmId: Long) : ShellRequest + data object ComposeTimer : ShellRequest + data class ChooseAlarmToDismiss(val alarmIds: List) : ShellRequest +} + sealed interface ShellBackAction { data class GoTo(val destination: ClockulaDestination) : ShellBackAction @@ -51,15 +64,44 @@ object ShellNavigation { /** * The tab an incoming intent action asks for. Null for null and for * anything unknown, so an unrecognised action leaves the start destination - * alone (M6 D24). M9 extends this with the rest of the `AlarmClock` - * contract; M6 wires exactly one action to it. + * alone (M6 D24). M9 finished it with one more action: the platform's own + * `AlarmClock` strings never reach `MainActivity` — the exported door + * translates them into these internal ones — so mapping them here would be + * dead code that looks like a contract (M9 D22). */ fun tabForAction(action: String?): ClockulaDestination? = when (action) { + AlarmIntents.ACTION_SHOW_ALARMS -> ClockulaDestination.ALARMS TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS StopwatchIntents.ACTION_SHOW_STOPWATCH -> ClockulaDestination.STOPWATCH else -> null } + /** + * The request an incoming *internal* intent makes of the shell. Null for an + * action the shell does not answer; **degrades rather than throws** — a + * missing or non-positive alarm id, or an empty id list, falls back to the + * Alarms tab (M9 D23). + */ + fun requestFor(action: String?, alarmId: Long?, alarmIds: List): ShellRequest? = + when (action) { + null -> null + InteropIntents.ACTION_OPEN_ALARM_EDITOR -> + if (alarmId != null && alarmId > 0L) { + ShellRequest.OpenAlarmEditor(alarmId) + } else { + ShellRequest.OpenTab(ClockulaDestination.ALARMS) + } + InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer + InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS -> + if (alarmIds.isEmpty()) { + ShellRequest.OpenTab(ClockulaDestination.ALARMS) + } else { + ShellRequest.ChooseAlarmToDismiss(alarmIds) + } + // An unknown action is never rescued by its extras. + else -> tabForAction(action)?.let(ShellRequest::OpenTab) + } + /** * Alarms is the shell's root: back from any other tab returns to it, and * back from Alarms leaves the app. An unrecognised route is a nested diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt new file mode 100644 index 0000000..09190be --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -0,0 +1,125 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract +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 shell's intent surface, now that three of the contract's outcomes are not + * "select a tab". `requestFor` **degrades rather than throws**: an intent that + * reaches `MainActivity` malformed must still put the user somewhere sensible + * (M9 D22, D23). + */ +class ShellNavigationInteropTest { + + @Test + fun `the internal show-alarms action selects the alarms tab`() { + assertThat(ShellNavigation.tabForAction(AlarmIntents.ACTION_SHOW_ALARMS)) + .isEqualTo(ClockulaDestination.ALARMS) + } + + @Test + fun `the tabs the earlier milestones wired still answer`() { + val tabs = listOf( + ShellNavigation.tabForAction(TimerIntents.ACTION_SHOW_TIMERS), + ShellNavigation.tabForAction(StopwatchIntents.ACTION_SHOW_STOPWATCH), + ShellNavigation.tabForAction(null), + ShellNavigation.tabForAction("com.example.X"), + ) + + assertThat(tabs).containsExactly( + ClockulaDestination.TIMERS, + ClockulaDestination.STOPWATCH, + null, + null, + ).inOrder() + } + + @Test + fun `an editor request carries the alarm id`() { + assertThat(ShellNavigation.requestFor(InteropIntents.ACTION_OPEN_ALARM_EDITOR, 7L, emptyList())) + .isEqualTo(ShellRequest.OpenAlarmEditor(7L)) + } + + @Test + fun `an editor request with no id lands on the alarms tab`() { + assertThat(ShellNavigation.requestFor(InteropIntents.ACTION_OPEN_ALARM_EDITOR, null, emptyList())) + .isEqualTo(ShellRequest.OpenTab(ClockulaDestination.ALARMS)) + } + + @Test + fun `an editor request with a non-positive id lands on the alarms tab`() { + val requests = listOf( + ShellNavigation.requestFor(InteropIntents.ACTION_OPEN_ALARM_EDITOR, 0L, emptyList()), + ShellNavigation.requestFor(InteropIntents.ACTION_OPEN_ALARM_EDITOR, -4L, emptyList()), + ) + + assertThat(requests).containsExactly( + ShellRequest.OpenTab(ClockulaDestination.ALARMS), + ShellRequest.OpenTab(ClockulaDestination.ALARMS), + ) + } + + @Test + fun `a compose-timer request needs no extra`() { + assertThat(ShellNavigation.requestFor(InteropIntents.ACTION_COMPOSE_TIMER, null, emptyList())) + .isEqualTo(ShellRequest.ComposeTimer) + } + + @Test + fun `a chooser request keeps its ids in order`() { + assertThat( + ShellNavigation.requestFor( + InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS, + null, + listOf(3L, 1L), + ), + ).isEqualTo(ShellRequest.ChooseAlarmToDismiss(listOf(3L, 1L))) + } + + @Test + fun `a chooser request with nothing to choose from lands on the alarms tab`() { + assertThat( + ShellNavigation.requestFor(InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS, null, emptyList()), + ).isEqualTo(ShellRequest.OpenTab(ClockulaDestination.ALARMS)) + } + + @Test + fun `a tab action becomes a tab request`() { + assertThat(ShellNavigation.requestFor(TimerIntents.ACTION_SHOW_TIMERS, null, emptyList())) + .isEqualTo(ShellRequest.OpenTab(ClockulaDestination.TIMERS)) + } + + @Test + fun `a null action asks for nothing`() { + assertThat(ShellNavigation.requestFor(null, 7L, listOf(1L))).isNull() + } + + @Test + fun `an unknown action is never rescued by its extras`() { + assertThat(ShellNavigation.requestFor("com.example.WHATEVER", 7L, listOf(1L))).isNull() + } + + @Test + fun `the internal transport never collides with another vocabulary`() { + val others = AlarmClockContract.ACTIONS + listOf( + AlarmIntents.ACTION_FIRE, + AlarmIntents.ACTION_AUTO_SILENCE, + AlarmIntents.ACTION_SNOOZE, + AlarmIntents.ACTION_DISMISS, + AlarmIntents.ACTION_SHOW_ALARMS, + TimerIntents.ACTION_EXPIRY, + TimerIntents.ACTION_SHOW_TIMERS, + TimerIntents.ACTION_SERVICE_STOP, + StopwatchIntents.ACTION_SHOW_STOPWATCH, + StopwatchIntents.ACTION_SERVICE_STOP, + ) + + assertThat(InteropIntents.ACTIONS.intersect(others.toSet())).isEmpty() + assertThat(InteropIntents.ACTIONS).containsNoDuplicates() + } +}