feat(shell): navigation the system can ask for, read once per launch

An incoming request is more than a tab now — it can open an editor, compose a
timer or ask which alarm to dismiss. So it is read once, when the activity is
created rather than on every configuration change: re-deriving it on a rotation
would drag the user back into an editor they had just left.

The extras are unparcelled behind a guard here too. `MainActivity` is exported,
and before this it only ever read the action, which never unparcels.
This commit is contained in:
2026-09-23 11:02:06 +02:00
parent e55f3d19d4
commit 02f3794ee8
5 changed files with 268 additions and 23 deletions
@@ -17,10 +17,11 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs 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.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation 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.shell.RequestNotificationPermissionOnce
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -52,12 +53,17 @@ class MainActivity : ComponentActivity() {
private var pendingCrashReport by mutableStateOf<String?>(null) private var pendingCrashReport by mutableStateOf<String?>(null)
/** /**
* A tab an incoming intent asked for, consumed once by the shell (M6 D24). * What an incoming intent asked the shell for, consumed once by it
* Read in both `onCreate` and `onNewIntent`, because the activity is * (M6 D24, M9 D23). Read in both `onCreate` and `onNewIntent`, because the
* `singleTop`: tapping a timer notification with the app already open * activity is `singleTop`: tapping a timer notification with the app already
* delivers a new intent to the running instance rather than creating one. * 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<ClockulaDestination?>(null) private var openRequest by mutableStateOf<ShellRequest?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -74,7 +80,12 @@ class MainActivity : ComponentActivity() {
// Surface a single captured crash as a dialog on the next launch. // Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) 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 { setContent {
// Until the first snapshot arrives, the family's defaults stand in — // Until the first snapshot arrives, the family's defaults stand in —
@@ -96,10 +107,10 @@ class MainActivity : ComponentActivity() {
) { ) {
ClockulaShell( ClockulaShell(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
openTab = openTab, openRequest = openRequest,
// Consumed once: a request left standing would re-select the // Consumed once: a request left standing would re-select the
// tab on every recomposition and fight the user's taps. // 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: // First launch asks for notifications once, and only here:
// it belongs to starting the app, not to any one tab. // it belongs to starting the app, not to any one tab.
@@ -132,7 +143,29 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(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() { override fun onResume() {
@@ -14,6 +14,9 @@ object AlarmIntents {
const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE" const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE"
const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS" 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_ALARM_ID: String = PREFIX + "extra.ALARM_ID"
const val EXTRA_FIRE_AT: String = PREFIX + "extra.FIRE_AT" const val EXTRA_FIRE_AT: String = PREFIX + "extra.FIRE_AT"
@@ -16,6 +16,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -55,8 +58,8 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable @Composable
fun ClockulaShell( fun ClockulaShell(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openTab: ClockulaDestination? = null, openRequest: ShellRequest? = null,
onTabOpened: () -> Unit = {}, onRequestConsumed: () -> Unit = {},
viewModel: ShellViewModel = hiltViewModel(), viewModel: ShellViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
) { ) {
@@ -70,13 +73,44 @@ fun ClockulaShell(
// rather than sliding: a slide would claim a hierarchy that is not there. // rather than sliding: a slide would claim a hierarchy that is not there.
val fade = fadeThrough() val fade = fadeThrough()
// A notification's deep link, replayed as exactly the command a tab tap // The `AlarmClock` contract's chooser, held across a rotation but not a
// produces — which is what the user expects, and it leaves M4's asserted // process death: the intent that asked has been consumed, and re-asking
// back policy alone: the start destination is still Alarms (M6 D24). // would be a dialog with no provenance (M9 D13).
LaunchedEffect(openTab) { var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) }
val target = openTab ?: return@LaunchedEffect // Whether an incoming intent asked for the timer setup panel (M9 D7).
navController.replay(ShellNavigation.onTabSelected(route, target)) var timerSetupRequested by rememberSaveable { mutableStateOf(false) }
onTabOpened()
// 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( NavigationSuiteScaffold(
@@ -119,7 +153,11 @@ fun ClockulaShell(
popExitTransition = { fade.initialContentExit }, popExitTransition = { fade.initialContentExit },
) { ) {
composable(ClockulaDestination.ALARMS.route) { 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: // A sibling route in the same flat host, not a nested one:
// it is no tab, so the Alarms tab stays selected and the // it is no tab, so the Alarms tab stays selected and the
@@ -133,7 +171,11 @@ fun ClockulaShell(
AlarmEditorScreen(onBack = { navController.popBackStack() }) AlarmEditorScreen(onBack = { navController.popBackStack() })
} }
composable(ClockulaDestination.TIMERS.route) { 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 // A sibling route in the same flat host, exactly as the
// alarm editor is: it is no tab, so the Timers tab stays // alarm editor is: it is no tab, so the Timers tab stays
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.ui.shell 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.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.TimerIntents
@@ -13,6 +15,17 @@ data class TabNavCommand(
val launchSingleTop: Boolean, 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<Long>) : ShellRequest
}
sealed interface ShellBackAction { sealed interface ShellBackAction {
data class GoTo(val destination: ClockulaDestination) : 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 * The tab an incoming intent action asks for. Null for null and for
* anything unknown, so an unrecognised action leaves the start destination * anything unknown, so an unrecognised action leaves the start destination
* alone (M6 D24). M9 extends this with the rest of the `AlarmClock` * alone (M6 D24). M9 finished it with one more action: the platform's own
* contract; M6 wires exactly one action to it. * `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) { fun tabForAction(action: String?): ClockulaDestination? = when (action) {
AlarmIntents.ACTION_SHOW_ALARMS -> ClockulaDestination.ALARMS
TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS
StopwatchIntents.ACTION_SHOW_STOPWATCH -> ClockulaDestination.STOPWATCH StopwatchIntents.ACTION_SHOW_STOPWATCH -> ClockulaDestination.STOPWATCH
else -> null 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<Long>): 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 * 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 * back from Alarms leaves the app. An unrecognised route is a nested
@@ -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()
}
}