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:
makiolaj committed 2026-09-23 11:02:06 +02:00
1 parent e55f3d19d4
commit 02f3794ee8
5 files changed
+268 -23

No files matched your search

@@ -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()
}
}