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