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