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 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<String?>(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<ClockulaDestination?>(null)
private var openRequest by mutableStateOf<ShellRequest?>(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() {
@@ -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"
@@ -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
@@ -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<Long>) : 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<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
* 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()
}
}