From ad03646fe058129704f3e06ed18f7cd4525dd262 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 3 Nov 2026 22:13:14 +0100 Subject: [PATCH] feat(mobile): run native ground and transit navigation in the installed app Compose the navigation coordinator into the foreground app and dispatch the page's session commands to it. The foreground coordinator is registered so the background location task feeds the same instance, and the handshake now reports the navigation capabilities the shell actually has. The start flow shows the native permission screens, uses Android's background-location request to reach "Allow all the time", and asks once for notification permission. Reroute answers re-enter the coordinator as queued mutations bound to the request they answer. Transit refresh and replan are still no-ops. Fixes found on a device: expo-task-manager persists its job, so the app now holds RECEIVE_BOOT_COMPLETED (without it the first location update crashed the process); writes are serialised on the SQLite handle (separate exclusive-transaction connections hit "database is locked"); speech waits for the TTS engine to initialise (a cold launch reported every cue as failed); and the native screens clear the status bar. --- apps/mobile/app.config.ts | 9 +- apps/mobile/compliance/permissions.test.ts | 3 +- apps/mobile/compliance/permissions.ts | 7 +- .../OpenMapXNavigationAudioModule.kt | 34 ++- apps/mobile/plugins/nativeConfigTransforms.ts | 3 + apps/mobile/scripts/generatedNativeChecks.ts | 1 - apps/mobile/src/App.test.tsx | 95 ++++++- apps/mobile/src/App.tsx | 76 +++++- .../mobile/src/audio/navigationAudioModule.ts | 5 + .../src/background/defineNavigationTask.ts | 3 +- apps/mobile/src/bridge/createShellBridge.ts | 4 +- .../mobile/src/location/ExpoLocationDriver.ts | 28 +++ apps/mobile/src/location/profiles.ts | 15 ++ .../navigation/NavigationCoordinator.test.ts | 121 +++++++++ .../src/navigation/NavigationCoordinator.ts | 73 ++++++ .../src/navigation/createCoordinator.ts | 4 + .../navigation/createForegroundNavigation.ts | 237 ++++++++++++++++++ .../src/navigation/createRemoteWork.test.ts | 89 +++++++ .../mobile/src/navigation/createRemoteWork.ts | 83 ++++++ .../src/navigation/foregroundCoordinator.ts | 26 ++ .../src/navigation/transit/alightAlertCopy.ts | 38 +++ .../src/permissions/PermissionDisclosure.tsx | 16 +- .../permissions/PermissionPrompts.test.tsx | 95 +++++++ .../src/permissions/PermissionPrompts.tsx | 216 ++++++++++++++++ .../src/shell/NativeRecoveryOverlay.tsx | 6 +- apps/mobile/src/shell/systemBars.ts | 15 ++ apps/mobile/src/storage/database.ts | 41 ++- docs/docs/developer/mobile-architecture.md | 75 ++++-- docs/docs/developer/mobile-release.md | 6 +- 29 files changed, 1368 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/navigation/createForegroundNavigation.ts create mode 100644 apps/mobile/src/navigation/createRemoteWork.test.ts create mode 100644 apps/mobile/src/navigation/createRemoteWork.ts create mode 100644 apps/mobile/src/navigation/foregroundCoordinator.ts create mode 100644 apps/mobile/src/navigation/transit/alightAlertCopy.ts create mode 100644 apps/mobile/src/permissions/PermissionPrompts.test.tsx create mode 100644 apps/mobile/src/permissions/PermissionPrompts.tsx create mode 100644 apps/mobile/src/shell/systemBars.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 00d8431b2..b3460a816 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -73,13 +73,16 @@ const config: ExpoConfig = { // phone in a pocket. Declared explicitly rather than arriving silently // through expo-notifications' own manifest. "android.permission.VIBRATE", + // Every location update reaches the task through a JobScheduler job, and + // expo-task-manager always marks those persisted, which Android refuses + // without this permission. Declaring it registers no boot receiver. + "android.permission.RECEIVE_BOOT_COMPLETED", ], // Permissions some bundled Expo modules declare by default but this app - // must not ship: no remote push, no boot receiver, no exact alarms, no - // media/microphone capability. + // must not ship: no remote push, no exact alarms, no media/microphone + // capability. blockedPermissions: [ "com.google.android.c2dm.permission.RECEIVE", - "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.SCHEDULE_EXACT_ALARM", "android.permission.USE_EXACT_ALARM", "android.permission.RECORD_AUDIO", diff --git a/apps/mobile/compliance/permissions.test.ts b/apps/mobile/compliance/permissions.test.ts index 0e92b70e0..f6b6d7284 100644 --- a/apps/mobile/compliance/permissions.test.ts +++ b/apps/mobile/compliance/permissions.test.ts @@ -45,7 +45,7 @@ describe("the permitted surface is minimal", () => { expect(expectedPermissionSurface().iosBackgroundModes).toEqual(["location"]); }); - it("asks for eight Android permissions and no more", () => { + it("asks for nine Android permissions and no more", () => { expect(expectedPermissionSurface().androidPermissions).toEqual([ "android.permission.INTERNET", "android.permission.ACCESS_COARSE_LOCATION", @@ -55,6 +55,7 @@ describe("the permitted surface is minimal", () => { "android.permission.FOREGROUND_SERVICE_LOCATION", "android.permission.POST_NOTIFICATIONS", "android.permission.VIBRATE", + "android.permission.RECEIVE_BOOT_COMPLETED", ]); }); }); diff --git a/apps/mobile/compliance/permissions.ts b/apps/mobile/compliance/permissions.ts index d9493e9e6..2afe9036c 100644 --- a/apps/mobile/compliance/permissions.ts +++ b/apps/mobile/compliance/permissions.ts @@ -84,6 +84,12 @@ export const ANDROID_PERMISSIONS: PermissionEntry[] = [ reason: "The get-off alert vibrates, which is the part a rider notices with the phone in a pocket. Not a dangerous permission and not runtime-requested.", }, + { + id: "android.permission.RECEIVE_BOOT_COMPLETED", + practiceId: "background-location-active-navigation", + reason: + "expo-task-manager delivers every location update to the navigation task through a JobScheduler job it always marks persisted, and Android refuses a persisted job without this permission. The app registers no boot receiver and starts nothing at boot. Not a dangerous permission and not runtime-requested.", + }, ]; /** @@ -108,7 +114,6 @@ export const FORBIDDEN_ANDROID_PERMISSIONS: Record = { "android.permission.READ_MEDIA_IMAGES": "The app reads no photos.", "android.permission.READ_MEDIA_VIDEO": "The app reads no videos.", "android.permission.READ_MEDIA_AUDIO": "The app reads no audio files.", - "android.permission.RECEIVE_BOOT_COMPLETED": "Nothing starts at boot.", "android.permission.SYSTEM_ALERT_WINDOW": "The app draws over nothing.", "android.permission.BIND_VPN_SERVICE": "The app is not a VPN.", "android.permission.BIND_ACCESSIBILITY_SERVICE": "The app is not an accessibility service.", diff --git a/apps/mobile/modules/openmapx-navigation-audio/android/src/main/java/expo/modules/openmapxnavigationaudio/OpenMapXNavigationAudioModule.kt b/apps/mobile/modules/openmapx-navigation-audio/android/src/main/java/expo/modules/openmapxnavigationaudio/OpenMapXNavigationAudioModule.kt index 0b7c1a329..6c1118431 100644 --- a/apps/mobile/modules/openmapx-navigation-audio/android/src/main/java/expo/modules/openmapxnavigationaudio/OpenMapXNavigationAudioModule.kt +++ b/apps/mobile/modules/openmapx-navigation-audio/android/src/main/java/expo/modules/openmapxnavigationaudio/OpenMapXNavigationAudioModule.kt @@ -12,6 +12,8 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * The Android half of the navigation audio module. @@ -32,8 +34,10 @@ class OpenMapXNavigationAudioModule : Module() { private val focusPolicy = AudioFocusPolicy() private var textToSpeech: TextToSpeech? = null - private var initialized = false - private var initializationFailed = false + @Volatile private var initialized = false + @Volatile private var initializationFailed = false + /** Released by the engine's initialisation callback, whatever its status. */ + private var engineReady = CountDownLatch(1) private var speaking = false private var lastResultCode: String? = null private var focusRequest: AudioFocusRequest? = null @@ -62,6 +66,10 @@ class OpenMapXNavigationAudioModule : Module() { override fun definition() = ModuleDefinition { Name("OpenMapXNavigationAudio") + // Binding to the platform engine takes a moment; starting it here means the + // first cue of a trip rarely has to wait for it. + OnCreate { runCatching { ensureEngine() } } + OnDestroy { textToSpeech?.stop() textToSpeech?.shutdown() @@ -89,11 +97,20 @@ class OpenMapXNavigationAudioModule : Module() { } val locale = SpeechLocale.parse(request.locale) ?: return finish(SpeakOutcome.FAILED) if (!focusPolicy.acceptsSpeech) return finish(SpeakOutcome.SKIPPED) - // A replayed cue is treated as already handled, never repeated. - if (!ledger.record(request.cueId)) return finish(SpeakOutcome.SKIPPED) val engine = ensureEngine() ?: return finish(SpeakOutcome.FAILED) - if (!isLanguageAvailable(locale)) return finish(SpeakOutcome.FAILED) + // `TextToSpeech` binds to the engine asynchronously, and until it has, every + // query answers "not bound". This runs off the main thread, where the + // initialisation callback is delivered, so waiting here cannot deadlock. + if (!initialized && !engineReady.await(ENGINE_INIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + return finish(SpeakOutcome.FAILED) + } + if (!initialized || !isLanguageAvailable(locale)) { + return finish(SpeakOutcome.FAILED) + } + // A replayed cue is treated as already handled, never repeated. Recorded + // only once the engine can speak, so a cold engine does not swallow it. + if (!ledger.record(request.cueId)) return finish(SpeakOutcome.SKIPPED) if (focusPolicy.willEnqueue() && !requestFocus()) { focusPolicy.didStop() @@ -121,12 +138,16 @@ class OpenMapXNavigationAudioModule : Module() { if (focusPolicy.didStop()) abandonFocus() } + @Synchronized private fun ensureEngine(): TextToSpeech? { if (initializationFailed) return null textToSpeech?.let { return it } + val ready = CountDownLatch(1) + engineReady = ready val engine = TextToSpeech(context) { status -> initialized = status == TextToSpeech.SUCCESS initializationFailed = !initialized + ready.countDown() } engine.setAudioAttributes(audioAttributes) engine.setOnUtteranceProgressListener( @@ -196,3 +217,6 @@ class SpeakRequestRecord : Record { @Field var rate: Double? = null } + +/** How long a cue waits for a cold engine before it is reported as failed. */ +private const val ENGINE_INIT_TIMEOUT_MS = 3_000L diff --git a/apps/mobile/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index 180dc505d..472ed5c0d 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -32,6 +32,9 @@ export const REQUIRED_ANDROID_PERMISSIONS = [ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_LOCATION", "android.permission.POST_NOTIFICATIONS", + // expo-task-manager delivers every location update through a persisted + // JobScheduler job, which Android rejects without it. + "android.permission.RECEIVE_BOOT_COMPLETED", ] as const; /* ---------------------------------------------------------------- iOS ---- */ diff --git a/apps/mobile/scripts/generatedNativeChecks.ts b/apps/mobile/scripts/generatedNativeChecks.ts index 01d6250d7..018829aa3 100644 --- a/apps/mobile/scripts/generatedNativeChecks.ts +++ b/apps/mobile/scripts/generatedNativeChecks.ts @@ -64,7 +64,6 @@ export const FORBIDDEN_ANDROID_PERMISSIONS = [ "android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_VIDEO", "android.permission.READ_MEDIA_AUDIO", - "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.SCHEDULE_EXACT_ALARM", "android.permission.USE_EXACT_ALARM", "android.permission.SYSTEM_ALERT_WINDOW", diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index 71f6143fd..d51888d67 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -41,6 +41,26 @@ jest.mock("./lifecycle/createNavigationRecoveryController", () => ({ createNavigationRecoveryController: jest.fn(async () => mockRecoveryController), })); +/** A composed foreground navigation graph, standing in for the native one. */ +const mockNavigation = { + coordinator: { dispatch: jest.fn() }, + capabilities: jest.fn(), + activeSession: jest.fn(), + dispose: jest.fn(), +}; +const mockCreateForegroundNavigation = jest.fn(); +jest.mock("./navigation/createForegroundNavigation", () => ({ + createForegroundNavigation: (...args: unknown[]) => mockCreateForegroundNavigation(...args), +})); + +const NAVIGATION_CAPABILITIES = { + groundNavigation: true, + transitNavigation: true, + backgroundLocation: true, + localNotifications: true, + speech: true, +}; + const RELEASE_MANIFEST = { release: true, feasibilityMode: false, @@ -76,6 +96,15 @@ beforeEach(async () => { mockRecoveryController.reconcile.mockResolvedValue("session-ended"); mockRecoveryController.resume.mockResolvedValue("session-started"); mockRecoveryController.end.mockResolvedValue("session-ended"); + // Jest has no native location, storage or audio modules, so by default the + // graph fails to compose, as it would on a device missing one of them. + mockCreateForegroundNavigation.mockReset(); + mockCreateForegroundNavigation.mockRejectedValue(new Error("no native modules")); + mockNavigation.coordinator.dispatch.mockReset(); + mockNavigation.coordinator.dispatch.mockResolvedValue(undefined); + mockNavigation.capabilities.mockReturnValue(NAVIGATION_CAPABILITIES); + mockNavigation.activeSession.mockResolvedValue(null); + mockNavigation.dispose.mockReset(); }); describe("App WebView policy", () => { @@ -259,7 +288,7 @@ describe("App bridge channel", () => { ).resolves.toBeUndefined(); }); - it("advertises no native navigation mode until the foreground graph is composed", async () => { + it("advertises no native navigation mode when the foreground graph could not be composed", async () => { await renderApp(); const nonce = nonceIn(bootstrap()); await emitAsync(mockWebViewProps.onLoadStart); @@ -280,7 +309,7 @@ describe("App bridge channel", () => { }); }); - it("rejects an uncomposed command instead of accepting and discarding it", async () => { + it("rejects a navigation command it cannot run instead of accepting and discarding it", async () => { await renderApp(); const nonce = nonceIn(bootstrap()); await emitAsync(mockWebViewProps.onLoadStart); @@ -305,6 +334,68 @@ describe("App bridge channel", () => { forMessageId: "prepare-1", }); }); + + const hello = async () => { + await renderApp(); + const nonce = nonceIn(bootstrap()); + await emitAsync(mockWebViewProps.onLoadStart); + await post(nonce, { + type: "web.hello", + payload: { + webBuildId: "web-build-1", + minProtocolVersion: 1, + maxProtocolVersion: MOBILE_PROTOCOL_MAX, + }, + }); + return nonce; + }; + + it("advertises what the composed navigation graph reports", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + await hello(); + + const reply = decodedMessages().find((message) => message.type === "native.hello"); + expect(reply?.payload?.capabilities).toMatchObject(NAVIGATION_CAPABILITIES); + expect(mockCreateForegroundNavigation).toHaveBeenCalledTimes(1); + expect(mockCreateForegroundNavigation.mock.calls[0]?.[0]).toMatchObject({ + apiOrigin: "https://openmapx.com", + }); + }); + + it("hands every page command to the navigation coordinator", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + const nonce = await hello(); + + await post(nonce, { + messageId: "prepare-1", + type: "session.prepare", + payload: { startPackage: groundSessionFixture().payload.startPackage }, + }); + + expect(mockNavigation.coordinator.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "session.prepare", messageId: "prepare-1" }), + ); + expect(decodedMessages().some((message) => message.type === "native.error")).toBe(false); + }); + + it("disposes the navigation graph when the app unmounts", async () => { + mockCreateForegroundNavigation.mockResolvedValue(mockNavigation); + const view = await renderApp(); + await act(async () => undefined); + + await act(async () => { + await view.unmount(); + }); + + expect(mockNavigation.dispose).toHaveBeenCalledTimes(1); + }); + + it("does not compose native navigation in a feasibility build", async () => { + mockManifest = { ...RELEASE_MANIFEST, release: false, feasibilityMode: true }; + await renderApp(); + + expect(mockCreateForegroundNavigation).not.toHaveBeenCalled(); + }); }); describe("App navigation interception", () => { diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 815cdc549..753303336 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -16,6 +16,12 @@ import { getRuntimeConfig, type MobileRuntimeConfig } from "./config/runtimeConf import { useConnectivity } from "./connectivity/useConnectivity"; import { createNavigationRecoveryController } from "./lifecycle/createNavigationRecoveryController"; import { useAppVisibility } from "./lifecycle/useAppLifecycle"; +import { createLocationDriver } from "./location/createLocationDriver"; +import { + createForegroundNavigation, + type ForegroundNavigation, +} from "./navigation/createForegroundNavigation"; +import { createPermissionPrompter, PermissionPrompts } from "./permissions/PermissionPrompts"; import { FeasibilityOverlay } from "./shell/FeasibilityOverlay"; import { NativeRecoveryOverlay } from "./shell/NativeRecoveryOverlay"; import { type NativePalette, useNativePalette } from "./shell/nativeTheme"; @@ -34,6 +40,15 @@ import { useHardwareBack } from "./shell/useHardwareBack"; * outlived its process, and a fatally misconfigured build. */ +/** What a shell without native navigation reports. */ +const NO_NAVIGATION = { + groundNavigation: false, + transitNavigation: false, + backgroundLocation: false, + localNotifications: false, + speech: false, +}; + const styles = StyleSheet.create({ root: { flex: 1 }, webView: { flex: 1 }, @@ -76,21 +91,63 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement }), [config], ); - const shell = useMemo(() => { + const prompter = useMemo( + () => + createPermissionPrompter({ + locale, + requestBackgroundAccess: () => createLocationDriver().requestBackgroundPermission(), + }), + [locale], + ); + // Shell states follow guidance starting and ending; repeated reports of the + // same state are dropped so a stream of snapshots is not a stream of renders. + const navigatingRef = useRef(false); + const setNavigating = useCallback((navigating: boolean) => { + if (navigatingRef.current === navigating) return; + navigatingRef.current = navigating; + dispatchShell({ type: navigating ? "session-started" : "session-ended" }); + }, []); + const { shell, navigation } = useMemo(() => { + let navigation: Promise = Promise.resolve(null); const composed: ShellBridge = createShellBridge({ webOrigin: config.webOrigin, inject: (script) => webViewRef.current?.injectJavaScript(script), - // One-off location, settings and system-browser sign-in are answered - // here. Native navigation is not composed yet, so its commands are - // refused and every navigation capability is reported false. - dispatch: (message) => - answerCommand(message, services.auxiliary, (type, payload, options) => + // Every command goes through the navigation coordinator, which also + // answers the one-shot location, settings and sign-in requests. A build + // without it (the feasibility probe) answers only those. + dispatch: async (message) => { + const composedNavigation = await navigation; + if (composedNavigation) { + await composedNavigation.coordinator.dispatch(message); + return; + } + await answerCommand(message, services.auxiliary, (type, payload, options) => composed.bridge.send(type, payload, options), - ), + ); + }, + capabilities: async () => (await navigation)?.capabilities() ?? NO_NAVIGATION, permission: () => services.permission(), + activeSession: async () => (await navigation)?.activeSession() ?? null, }); - return composed; - }, [config.webOrigin, services]); + if (!config.feasibilityMode) { + navigation = createForegroundNavigation({ + apiOrigin: config.apiOrigin, + locale, + send: (type, payload, options) => composed.bridge.send(type, payload, options), + permissionUi: prompter.ui, + isAppActive: () => visibilityRef.current === "active", + auxiliary: services.auxiliary, + onNavigatingChanged: setNavigating, + }).catch(() => null); + } + return { shell: composed, navigation }; + }, [config, locale, prompter, services, setNavigating]); + useEffect( + () => () => { + void navigation.then((composed) => composed?.dispose()); + }, + [navigation], + ); const recovery = useMemo( () => config.feasibilityMode @@ -300,6 +357,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement state={shellState.state} onAction={handleShellAction} /> + {config.feasibilityMode ? : null} ); diff --git a/apps/mobile/src/audio/navigationAudioModule.ts b/apps/mobile/src/audio/navigationAudioModule.ts index 429445d67..4cc74e7dc 100644 --- a/apps/mobile/src/audio/navigationAudioModule.ts +++ b/apps/mobile/src/audio/navigationAudioModule.ts @@ -37,3 +37,8 @@ export function getNavigationAudio(): NavigationAudio { export function resetNavigationAudioCache(): void { cached = null; } + +/** Whether this build linked the native synthesiser, for the capability report. */ +export function isNavigationAudioAvailable(): boolean { + return requireOptionalNativeModule("OpenMapXNavigationAudio") !== null; +} diff --git a/apps/mobile/src/background/defineNavigationTask.ts b/apps/mobile/src/background/defineNavigationTask.ts index 8351676de..569afd51f 100644 --- a/apps/mobile/src/background/defineNavigationTask.ts +++ b/apps/mobile/src/background/defineNavigationTask.ts @@ -1,6 +1,7 @@ import type { LocationObject } from "expo-location"; import * as TaskManager from "expo-task-manager"; import { getRuntimeConfig } from "../config/runtimeConfig"; +import { foregroundCoordinator } from "../navigation/foregroundCoordinator"; import { getHeadlessCoordinator } from "./createHeadlessCoordinator"; import { NAVIGATION_LOCATION_TASK } from "./taskName"; @@ -44,7 +45,7 @@ TaskManager.defineTask(NAVIGATION_LOCATION_TASK, async ({ d return; } - const coordinator = await getHeadlessCoordinator(); + const coordinator = foregroundCoordinator() ?? (await getHeadlessCoordinator()); await coordinator.handleLocationBatch(batch); } catch { // Deliberately silent: logging here could not be redacted reliably, and the diff --git a/apps/mobile/src/bridge/createShellBridge.ts b/apps/mobile/src/bridge/createShellBridge.ts index 0ae2d63eb..4d3e9ba0a 100644 --- a/apps/mobile/src/bridge/createShellBridge.ts +++ b/apps/mobile/src/bridge/createShellBridge.ts @@ -25,7 +25,7 @@ export interface ShellBridgeOptions { */ dispatch?: (message: WebToNativeMessage) => Promise; /** Reports what the shell can actually run right now. */ - capabilities?: () => ShellDescription["capabilities"]; + capabilities?: () => ShellDescription["capabilities"] | Promise; permission?: () => Promise; activeSession?: () => Promise; } @@ -43,7 +43,7 @@ export interface ShellBridge { * constant. Until a processor exists the page keeps planning in the browser. */ export async function describeShell(options: ShellBridgeOptions): Promise { - const capabilities = options.capabilities?.() ?? { + const capabilities = (await options.capabilities?.()) ?? { groundNavigation: false, transitNavigation: false, backgroundLocation: false, diff --git a/apps/mobile/src/location/ExpoLocationDriver.ts b/apps/mobile/src/location/ExpoLocationDriver.ts index 626c5ad00..ea0336a21 100644 --- a/apps/mobile/src/location/ExpoLocationDriver.ts +++ b/apps/mobile/src/location/ExpoLocationDriver.ts @@ -191,3 +191,31 @@ function toFix(position: Location.LocationObject): LocationFix { ...(position.coords.altitude === null ? {} : { altitudeMeters: position.coords.altitude }), }; } + +/** + * The grant as the start-permission flow needs it: which level, whether it is + * precise, and whether the system will still show a prompt. + */ +export async function readLocationPermissionSnapshot(): Promise<{ + permission: LocationPermissionState; + accuracy?: "precise" | "approximate"; + canAskAgain: boolean; +}> { + const foreground = await Location.getForegroundPermissionsAsync(); + const background = foreground.granted ? await Location.getBackgroundPermissionsAsync() : null; + const permission = combinePermissionState(foreground, background); + const accuracy = foreground.granted ? grantedAccuracy(foreground) : undefined; + return { + permission, + ...(accuracy ? { accuracy } : {}), + canAskAgain: foreground.canAskAgain, + }; +} + +function grantedAccuracy( + response: Location.LocationPermissionResponse, +): "precise" | "approximate" | undefined { + if (response.android) return response.android.accuracy === "coarse" ? "approximate" : "precise"; + if (response.ios) return response.ios.accuracy === "reduced" ? "approximate" : "precise"; + return undefined; +} diff --git a/apps/mobile/src/location/profiles.ts b/apps/mobile/src/location/profiles.ts index 8b30f122d..7183d1b3d 100644 --- a/apps/mobile/src/location/profiles.ts +++ b/apps/mobile/src/location/profiles.ts @@ -78,3 +78,18 @@ export function profileFor(kind: LocationProfileKind): LocationProfile { if (!profile) throw new Error(`unknown location profile: ${String(kind)}`); return profile; } + +/** + * The profile a session starts with: its travel mode for ground guidance, the + * cruising cadence for transit (whose batch logic tightens it near an event). + */ +export function initialProfileFor( + session: + | { kind: "ground"; payload: { startPackage: { mode: string } } } + | { kind: "transit" } + | null, +): LocationProfileKind { + if (!session || session.kind === "transit") return session ? "transit-cruise" : "driving"; + const mode = session.payload.startPackage.mode; + return mode === "walking" || mode === "cycling" || mode === "motorcycle" ? mode : "driving"; +} diff --git a/apps/mobile/src/navigation/NavigationCoordinator.test.ts b/apps/mobile/src/navigation/NavigationCoordinator.test.ts index 8c1f448cb..b5f4b9afc 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.test.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.test.ts @@ -809,3 +809,124 @@ describe("NavigationCoordinator effects", () => { await context.close(); }); }); + +describe("NavigationCoordinator reroute answers", () => { + /** A running session whose engine has asked for reroute `requestId`. */ + async function awaitingReroute(context: Harness, requestId = "reroute-1") { + const { sessionId } = await running(context); + const current = await context.repository.loadActive(NOW); + if (current?.kind !== "ground") throw new Error("no ground session"); + await context.repository.compareAndSwap( + sessionId, + current.revision, + (session) => ({ + session: { + ...session, + revision: session.revision + 1, + payload: { + ...(session as typeof current).payload, + reroute: { status: "pending", requestId, attempts: 2 }, + }, + } as MobileNavigationSession, + effects: [], + }), + NOW, + ); + const pending = await context.repository.loadActive(NOW); + return { sessionId, revision: pending?.revision ?? 0 }; + } + + const groundOf = async (context: Harness) => { + const session = await context.repository.loadActive(NOW); + if (session?.kind !== "ground") throw new Error("no ground session"); + return session; + }; + + it("replaces the route and clears the failure history for the current request", async () => { + const context = await harness(); + const { sessionId, revision } = await awaitingReroute(context); + + await context.coordinator.applyReroute({ + ok: true, + requestId: "reroute-1", + sessionId, + baseRevision: revision, + route: START_PACKAGE, + }); + + const session = await groundOf(context); + expect(session.revision).toBe(revision + 1); + expect(session.payload.reroute).toEqual({ status: "idle", attempts: 0 }); + expect(context.effectLog).toContain("publish.snapshot"); + await context.close(); + }); + + it("drops an answer to a request the session no longer waits for", async () => { + const context = await harness(); + const { sessionId, revision } = await awaitingReroute(context, "reroute-2"); + + await context.coordinator.applyReroute({ + ok: true, + requestId: "reroute-1", + sessionId, + baseRevision: revision, + route: START_PACKAGE, + }); + + expect((await groundOf(context)).revision).toBe(revision); + await context.close(); + }); + + it("records a failure with a backoff so the engine can ask again later", async () => { + const context = await harness(); + const { sessionId, revision } = await awaitingReroute(context); + + await context.coordinator.applyReroute({ + ok: false, + requestId: "reroute-1", + sessionId, + baseRevision: revision, + code: "network", + }); + + const { reroute } = (await groundOf(context)).payload; + expect(reroute.status).toBe("pending"); + expect(reroute.attempts).toBe(3); + expect(reroute.lastFailureCode).toBe("network"); + expect(reroute.nextAttemptAtMs).toBeGreaterThan(NOW); + await context.close(); + }); + + it("ignores a request this side aborted", async () => { + const context = await harness(); + const { sessionId, revision } = await awaitingReroute(context); + + await context.coordinator.applyReroute({ + ok: false, + requestId: "reroute-1", + sessionId, + baseRevision: revision, + code: "aborted", + }); + + expect((await groundOf(context)).revision).toBe(revision); + await context.close(); + }); + + it("does nothing once the session has stopped", async () => { + const context = await harness(); + const { sessionId, revision } = await awaitingReroute(context); + await context.coordinator.dispatch(cmd("session.stop", {}, { sessionId })); + + await context.coordinator.applyReroute({ + ok: true, + requestId: "reroute-1", + sessionId, + baseRevision: revision, + route: START_PACKAGE, + }); + + expect(await context.repository.loadActive(NOW)).toBeNull(); + await context.close(); + }); +}); diff --git a/apps/mobile/src/navigation/NavigationCoordinator.ts b/apps/mobile/src/navigation/NavigationCoordinator.ts index ac7a68d41..f7f657e88 100644 --- a/apps/mobile/src/navigation/NavigationCoordinator.ts +++ b/apps/mobile/src/navigation/NavigationCoordinator.ts @@ -7,6 +7,8 @@ import { import { type RawLocation, sanitiseFixes } from "../location/sanitiseFixes"; import type { CommitResult, SessionEffect, SessionRepository } from "../storage/SessionRepository"; import type { DiagnosticSink, EffectRunner } from "./effects"; +import type { RerouteOutcome } from "./ground/GroundRerouteService"; +import { onRerouteFailed, onRerouteSucceeded, type RerouteRuntime } from "./ground/rerouteState"; import type { AnyNavigationProcessor, ProcessorMutation, ProcessorRegistry } from "./processor"; import { SerialExecutor } from "./serialExecutor"; import { groundFullSnapshot } from "./snapshots/groundSnapshot"; @@ -505,6 +507,77 @@ export class NavigationCoordinator { } } + // Answers from remote work. + + /** + * Applies a reroute answer. + * + * The request ran outside the queue, so by now the session may have moved on: + * fixes advanced the revision, the user stopped, or a newer reroute replaced + * this one. Only an answer to the session's current request is committed; a + * stale one is dropped. Fixes that arrived while it was in flight do not make + * it stale, because the new route starts from where the user actually is. + */ + async applyReroute(outcome: RerouteOutcome): Promise { + await this.queue.run(async () => { + try { + await this.commitReroute(outcome); + } catch { + this.deps.diagnostics.record("typed.error", { scope: "reroute" }); + } + }); + } + + private async commitReroute(outcome: RerouteOutcome): Promise { + const { repository, processors, clock } = this.deps; + const now = clock(); + const session = await repository.loadActive(now); + if (session?.kind !== "ground" || session.status !== "active") return; + if (session.sessionId !== outcome.sessionId) return; + const reroute = session.payload.reroute; + if (reroute.requestId !== outcome.requestId) return; + // Aborted means this side gave the request up, for a newer one or a stop. + if (!outcome.ok && outcome.code === "aborted") return; + + let mutation: ProcessorMutation | null = null; + if (outcome.ok) { + const processor = processors.get("ground"); + const replaced = processor?.replace(session as never, outcome.route, now); + if (replaced && !("ok" in replaced && replaced.ok === false)) { + const applied = replaced as ProcessorMutation; + const next = applied.session; + mutation = + next.kind === "ground" + ? { + ...applied, + session: { + ...next, + payload: { ...next.payload, reroute: onRerouteSucceeded() }, + }, + } + : applied; + } + } + if (!mutation) { + const code = outcome.ok ? "invalid-response" : outcome.code; + mutation = { + session: { + ...session, + revision: session.revision + 1, + updatedAtMs: now, + payload: { + ...session.payload, + reroute: onRerouteFailed(reroute as RerouteRuntime, code, now, Math.random()), + }, + }, + effects: [{ kind: "publish-snapshot", immediate: false }], + }; + } + + const committed = await this.commit(session, mutation, now); + if (committed.ok) await this.deps.effects.run(committed.effects); + } + // Internals. private async commit( diff --git a/apps/mobile/src/navigation/createCoordinator.ts b/apps/mobile/src/navigation/createCoordinator.ts index 825c83e39..ee6f6df1e 100644 --- a/apps/mobile/src/navigation/createCoordinator.ts +++ b/apps/mobile/src/navigation/createCoordinator.ts @@ -3,6 +3,7 @@ import { SessionRepository } from "../storage/SessionRepository"; import { type EffectPorts, EffectRunner } from "./effects"; import { GroundNavigationProcessor } from "./ground/GroundNavigationProcessor"; import { + type AuxiliaryCommandHandler, type BridgePort, type CoordinatorDeps, type DriverPort, @@ -38,6 +39,8 @@ export interface CoordinatorOverrides { ports: EffectPorts; clock?: () => number; newSessionId?: () => string; + /** Answers the page's one-shot requests; absent in the headless task. */ + auxiliary?: AuxiliaryCommandHandler; } /** Random enough to be unique, and meaningless on its own. */ @@ -92,6 +95,7 @@ export async function createCoordinator( diagnostics: overrides.ports.diagnostics, clock: overrides.clock ?? (() => Date.now()), newSessionId: overrides.newSessionId ?? randomSessionId, + ...(overrides.auxiliary ? { auxiliary: overrides.auxiliary } : {}), }; return { diff --git a/apps/mobile/src/navigation/createForegroundNavigation.ts b/apps/mobile/src/navigation/createForegroundNavigation.ts new file mode 100644 index 000000000..ba1f11ff3 --- /dev/null +++ b/apps/mobile/src/navigation/createForegroundNavigation.ts @@ -0,0 +1,237 @@ +import * as Notifications from "expo-notifications"; +import type { MobileLocale } from "../../config/nativeCopy"; +import { getNavigationAudio, isNavigationAudioAvailable } from "../audio/navigationAudioModule"; +import { currentPlatform } from "../background/createHeadlessCoordinator"; +import type { ShellDescription } from "../bridge/nativeBridge"; +import { DiagnosticRepository, type DiagnosticType } from "../diagnostics/DiagnosticRepository"; +import { createLocationDriver } from "../location/createLocationDriver"; +import { readLocationPermissionSnapshot } from "../location/ExpoLocationDriver"; +import type { LocationDriver } from "../location/LocationDriver"; +import { initialProfileFor, type LocationProfileKind, profileFor } from "../location/profiles"; +import { ExpoNotificationScheduler } from "../notifications/ExpoNotificationScheduler"; +import { PermissionController, type PermissionUi } from "../permissions/permissionController"; +import { createCoordinator } from "./createCoordinator"; +import { createRemoteWork } from "./createRemoteWork"; +import { registerForegroundCoordinator } from "./foregroundCoordinator"; +import { GroundRerouteService } from "./ground/GroundRerouteService"; +import type { + AuxiliaryCommandHandler, + BridgePort, + NavigationCoordinator, +} from "./NavigationCoordinator"; +import { createPublishPort } from "./snapshots/createPublishPort"; +import { alightAlertCopy } from "./transit/alightAlertCopy"; +import { createAlertPort } from "./transit/createAlertPort"; + +/** + * Native navigation as the visible app runs it. + * + * The same coordinator class, repository and executor the background task + * uses, with ports that reach this app's page: replies and snapshots go over + * the bridge, the start flow shows the native permission screens, and reroutes + * run against the compiled API origin. While this composition exists, the + * background task hands its location batches here, so the page sees progress + * as it happens. + */ + +export interface ForegroundNavigationOptions { + apiOrigin: string; + locale: MobileLocale; + /** Sends a message to the page; reports whether it was sent or queued. */ + send( + type: Parameters[0], + payload: unknown, + options?: { sessionId?: string; revision?: number; forMessageId?: string }, + ): "sent" | "queued" | "dropped" | "invalid"; + permissionUi: PermissionUi; + isAppActive(): boolean; + auxiliary: AuxiliaryCommandHandler; + /** Told when guidance starts or ends, for keep-awake and the shell states. */ + onNavigatingChanged(navigating: boolean): void; + driver?: LocationDriver; +} + +export interface ForegroundNavigation { + coordinator: NavigationCoordinator; + capabilities(): ShellDescription["capabilities"]; + activeSession(): Promise; + dispose(): void; +} + +export async function createForegroundNavigation( + options: ForegroundNavigationOptions, +): Promise { + const driver = options.driver ?? createLocationDriver(); + const audio = getNavigationAudio(); + const scheduler = new ExpoNotificationScheduler(); + const now = () => Date.now(); + + let notificationsGranted = false; + const refreshNotificationGrant = async () => { + try { + notificationsGranted = (await Notifications.getPermissionsAsync()).granted; + } catch { + notificationsGranted = false; + } + return notificationsGranted; + }; + void refreshNotificationGrant(); + + // The coordinator is created below, after the ports that need to reach it. + let coordinatorRef: NavigationCoordinator | null = null; + let repositoryRef: Awaited>["repository"] | null = null; + let diagnosticsRef: DiagnosticRepository | null = null; + const record = (type: string, fields: Record) => { + diagnosticsRef?.recordAsync(type as DiagnosticType, fields); + }; + + const permissions = new PermissionController({ + driver, + platform: currentPlatform(), + ui: options.permissionUi, + isAppActive: options.isAppActive, + snapshot: readLocationPermissionSnapshot, + }); + + const rerouteService = new GroundRerouteService({ apiOrigin: options.apiOrigin, now }); + let remote: ReturnType | null = null; + + const bridge: BridgePort = { + send: (type, payload, sendOptions) => { + const outcome = options.send(type, payload, sendOptions); + if (type === "session.started") options.onNavigatingChanged(true); + if (type === "session.stopped") options.onNavigatingChanged(false); + return outcome; + }, + }; + + const composition = await createCoordinator({ + bridge, + permissions: { + state: () => driver.getPermissionState(), + isAppActive: options.isAppActive, + requestForStart: async () => { + const decision = await permissions.requestForStart(); + // The tracking notification and the get-off alert both need the + // notification permission on Android 13 and later; the start is the + // moment it is asked for, once. + if (decision !== "denied" && !(await refreshNotificationGrant())) { + try { + const asked = await Notifications.getPermissionsAsync(); + if (asked.canAskAgain) { + notificationsGranted = (await Notifications.requestPermissionsAsync()).granted; + } + } catch { + // Navigation proceeds without alerts. + } + } + return decision; + }, + }, + driver: { isRunning: () => driver.isRunning() }, + // The page's one-shot requests ride the same command path. + auxiliary: options.auxiliary, + ports: { + driver: { + start: async () => { + const session = repositoryRef ? await repositoryRef.loadActive(now()) : null; + await driver.start(profileFor(initialProfileFor(session))); + }, + stop: () => driver.stop(), + updateProfile: (profile) => driver.start(profileFor(profile as LocationProfileKind)), + isRunning: () => driver.isRunning(), + }, + audio: { + speak: async (cueId, text, locale) => { + const result = await audio.speak({ cueId, text, locale }); + // Only the stable result code is recorded, never the spoken text. + record("audio.result", { result }); + }, + stop: () => audio.stop(), + }, + alerts: { + reconcile: async (sessionId) => { + if (!repositoryRef) return; + await scheduler.prepare(); + await createAlertPort({ + repository: repositoryRef, + scheduler, + copy: alightAlertCopy(options.locale), + now, + isAvailable: () => notificationsGranted, + }).reconcile(sessionId); + }, + cancelSession: async (sessionId) => { + if (!repositoryRef) return; + await createAlertPort({ + repository: repositoryRef, + scheduler, + copy: alightAlertCopy(options.locale), + now, + isAvailable: () => notificationsGranted, + }).cancelSession(sessionId); + }, + }, + publish: { + snapshot: async (immediate) => { + await publish?.port.snapshot(immediate); + // Arrival and expiry end a session from a location batch rather than + // a command, so the shell learns about them here. + const active = repositoryRef ? await repositoryRef.loadActive(now()) : null; + if (active?.status !== "active") options.onNavigatingChanged(false); + }, + event: async (eventId) => { + await publish?.port.event(eventId); + }, + }, + remote: { + reroute: async (requestId) => remote?.port.reroute(requestId), + transitRefresh: async (requestId) => remote?.port.transitRefresh(requestId), + transitReplan: async (requestId) => remote?.port.transitReplan(requestId), + }, + diagnostics: { record }, + }, + }); + + const { coordinator, repository } = composition; + coordinatorRef = coordinator; + repositoryRef = repository; + diagnosticsRef = new DiagnosticRepository(repository); + const publish = createPublishPort({ + repository, + send: (type, payload) => { + const outcome = options.send(type, payload); + return outcome === "sent" || outcome === "queued"; + }, + now, + }); + remote = createRemoteWork({ + repository, + reroute: rerouteService, + applyReroute: (outcome) => coordinatorRef?.applyReroute(outcome) ?? Promise.resolve(), + now, + diagnostics: { record }, + }); + + const unregister = registerForegroundCoordinator(coordinator); + + return { + coordinator, + capabilities: () => ({ + groundNavigation: true, + transitNavigation: true, + backgroundLocation: true, + localNotifications: notificationsGranted, + speech: isNavigationAudioAvailable(), + }), + activeSession: async () => { + const session = await repository.loadActive(now()); + if (!session) return null; + return { sessionId: session.sessionId, revision: session.revision, kind: session.kind }; + }, + dispose: () => { + remote?.abortAll(); + unregister(); + }, + }; +} diff --git a/apps/mobile/src/navigation/createRemoteWork.test.ts b/apps/mobile/src/navigation/createRemoteWork.test.ts new file mode 100644 index 000000000..82acd92b2 --- /dev/null +++ b/apps/mobile/src/navigation/createRemoteWork.test.ts @@ -0,0 +1,89 @@ +import type { MobileNavigationSession } from "@openmapx/core/navigation"; +import { groundSessionFixture } from "../storage/testing/sessionFixture"; +import { createRemoteWork, type RemoteWorkDeps } from "./createRemoteWork"; +import type { RerouteOutcome } from "./ground/GroundRerouteService"; + +const NOW = 1_700_000_100_000; + +function activeGround(requestId: string | undefined, withFix = true): MobileNavigationSession { + const base = groundSessionFixture({ status: "active" }); + return { + ...base, + ...(withFix + ? { lastAcceptedFix: { coords: [8.685, 50.115], accuracy: 5, timestampMs: NOW } } + : {}), + payload: { + ...base.payload, + reroute: { status: "pending", attempts: 0, ...(requestId ? { requestId } : {}) }, + }, + } as MobileNavigationSession; +} + +function harness(session: MobileNavigationSession | null) { + const requests: Array<{ requestId: string; origin: [number, number] }> = []; + const applied: RerouteOutcome[] = []; + let aborts = 0; + const deps: RemoteWorkDeps = { + repository: { loadActive: async () => session }, + reroute: { + request: async (request) => { + requests.push({ requestId: request.requestId, origin: request.origin }); + return { + ok: true, + requestId: request.requestId, + sessionId: request.session.sessionId, + baseRevision: request.session.revision, + route: {}, + }; + }, + abortAll: () => { + aborts += 1; + }, + }, + applyReroute: async (outcome) => { + applied.push(outcome); + }, + now: () => NOW, + diagnostics: { record: () => undefined }, + }; + return { work: createRemoteWork(deps), requests, applied, aborts: () => aborts }; +} + +describe("createRemoteWork reroute", () => { + it("requests from the raw last fix and hands the answer back", async () => { + const { work, requests, applied, aborts } = harness(activeGround("r1")); + + await work.port.reroute("r1"); + await work.idle(); + + expect(requests).toEqual([{ requestId: "r1", origin: [8.685, 50.115] }]); + expect(applied.map((outcome) => outcome.requestId)).toEqual(["r1"]); + // A newer request always supersedes whatever was still in flight. + expect(aborts()).toBe(1); + }); + + it("does not start a request the session no longer wants", async () => { + const { work, requests } = harness(activeGround("r2")); + + await work.port.reroute("r1"); + await work.idle(); + + expect(requests).toEqual([]); + }); + + it("cannot reroute without a position to route from", async () => { + const { work, requests } = harness(activeGround("r1", false)); + + await work.port.reroute("r1"); + + expect(requests).toEqual([]); + }); + + it("does nothing without an active session", async () => { + const { work, requests } = harness(null); + + await work.port.reroute("r1"); + + expect(requests).toEqual([]); + }); +}); diff --git a/apps/mobile/src/navigation/createRemoteWork.ts b/apps/mobile/src/navigation/createRemoteWork.ts new file mode 100644 index 000000000..6ef24d04b --- /dev/null +++ b/apps/mobile/src/navigation/createRemoteWork.ts @@ -0,0 +1,83 @@ +import type { SessionRepository } from "../storage/SessionRepository"; +import type { RemoteWorkPort } from "./effects"; +import type { RerouteOutcome } from "./ground/GroundRerouteService"; + +/** + * Runs the network requests a session asks for, off the coordinator's queue. + * + * An effect only starts the request. Its answer re-enters through the + * coordinator as a queued mutation bound to the request it answers, so a slow + * response never holds up location fixes and a stale one is discarded there. + */ + +export interface RerouteClient { + request(request: { + requestId: string; + session: Extract< + NonNullable>>, + { kind: "ground" } + >; + origin: [number, number]; + }): Promise; + abortAll(): void; +} + +export interface RemoteWorkDeps { + repository: Pick; + reroute: RerouteClient; + applyReroute(outcome: RerouteOutcome): Promise; + now(): number; + diagnostics: { record(type: string, fields: Record): void }; +} + +export interface RemoteWork { + port: RemoteWorkPort; + /** Settles once every request started so far has been answered. Test seam. */ + idle(): Promise; + /** Abandons everything in flight, e.g. when the composition is torn down. */ + abortAll(): void; +} + +export function createRemoteWork(deps: RemoteWorkDeps): RemoteWork { + const inFlight = new Set>(); + + const track = (work: Promise) => { + inFlight.add(work); + void work.finally(() => inFlight.delete(work)); + }; + + const port: RemoteWorkPort = { + reroute: async (requestId) => { + const session = await deps.repository.loadActive(deps.now()); + if (session?.kind !== "ground" || session.status !== "active") return; + if (session.payload.reroute.requestId !== requestId) return; + // The raw fix, not the position snapped to the route being abandoned. + const origin = session.lastAcceptedFix?.coords; + if (!origin) return; + + // One reroute at a time: a newer request supersedes the one in flight, + // whose answer would be dropped anyway. + deps.reroute.abortAll(); + track( + deps.reroute + .request({ requestId, session, origin: [origin[0], origin[1]] }) + .then((outcome) => deps.applyReroute(outcome)) + .catch(() => { + deps.diagnostics.record("typed.error", { scope: "reroute-request" }); + }), + ); + }, + // Transit refresh and replan keep their captured itinerary until their + // answers can be applied; the session still runs on the schedule. + transitRefresh: async () => undefined, + transitReplan: async () => undefined, + }; + + return { + port, + idle: async () => { + while (inFlight.size > 0) await Promise.allSettled([...inFlight]); + }, + abortAll: () => deps.reroute.abortAll(), + }; +} diff --git a/apps/mobile/src/navigation/foregroundCoordinator.ts b/apps/mobile/src/navigation/foregroundCoordinator.ts new file mode 100644 index 000000000..7919edee1 --- /dev/null +++ b/apps/mobile/src/navigation/foregroundCoordinator.ts @@ -0,0 +1,26 @@ +import type { NavigationCoordinator } from "./NavigationCoordinator"; + +/** + * The coordinator the visible app composed, if this process has one. + * + * The operating system delivers location batches to the background task in + * whichever process is alive. When that is the app the user is looking at, the + * batch belongs to the coordinator wired to the page, so progress reaches the + * WebView as it happens. Only a process started without the app (a headless + * launch) falls back to the coordinator that persists and defers. + * + * Both share one repository, processors and executor, so either path advances + * the same session in the same order. + */ +let current: NavigationCoordinator | null = null; + +export function registerForegroundCoordinator(coordinator: NavigationCoordinator): () => void { + current = coordinator; + return () => { + if (current === coordinator) current = null; + }; +} + +export function foregroundCoordinator(): NavigationCoordinator | null { + return current; +} diff --git a/apps/mobile/src/navigation/transit/alightAlertCopy.ts b/apps/mobile/src/navigation/transit/alightAlertCopy.ts new file mode 100644 index 000000000..2a7f3da88 --- /dev/null +++ b/apps/mobile/src/navigation/transit/alightAlertCopy.ts @@ -0,0 +1,38 @@ +import type { MobileLocale } from "../../../config/nativeCopy"; +import type { AlightAlertCopy } from "./AlightAlertPolicy"; + +/** + * The get-off notification's text. + * + * Composed natively because the notification is scheduled with the operating + * system and fires whether or not a page exists. The stop and platform come + * from the validated itinerary, never from the page. + */ +const COPY: Record = { + en: { + title: (stop) => `Get off at ${stop}`, + body: (stop, basis, platform) => + [ + `Your stop ${stop} is coming up.`, + platform ? `Platform ${platform}.` : null, + basis === "schedule" ? "Timed from the timetable." : null, + ] + .filter(Boolean) + .join(" "), + }, + de: { + title: (stop) => `Aussteigen: ${stop}`, + body: (stop, basis, platform) => + [ + `Gleich erreichst du ${stop}.`, + platform ? `Gleis ${platform}.` : null, + basis === "schedule" ? "Nach Fahrplan berechnet." : null, + ] + .filter(Boolean) + .join(" "), + }, +}; + +export function alightAlertCopy(locale: MobileLocale): AlightAlertCopy { + return COPY[locale]; +} diff --git a/apps/mobile/src/permissions/PermissionDisclosure.tsx b/apps/mobile/src/permissions/PermissionDisclosure.tsx index e91c089ad..3bd02b6bd 100644 --- a/apps/mobile/src/permissions/PermissionDisclosure.tsx +++ b/apps/mobile/src/permissions/PermissionDisclosure.tsx @@ -2,6 +2,7 @@ import { type ReactElement, useMemo } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import type { MobileLocale } from "../../config/nativeCopy"; import { useNativePalette } from "../shell/nativeTheme"; +import { SCROLL_SAFE_AREA, topBarInset } from "../shell/systemBars"; import { permissionCopy } from "./permissionCopy"; import type { PermissionFlowState } from "./permissionMachine"; @@ -93,7 +94,10 @@ export function PermissionDisclosure({ const text = (key: Parameters[1]) => permissionCopy(locale, key); return ( - + {text("title")} @@ -166,7 +170,10 @@ export function PermissionOutcome({ : text("settingsCannotEscalate"); return ( - + {text("settingsTitle")} @@ -199,7 +206,10 @@ export function PermissionOutcome({ if (state.state === "denied") { return ( - + {text("deniedTitle")} diff --git a/apps/mobile/src/permissions/PermissionPrompts.test.tsx b/apps/mobile/src/permissions/PermissionPrompts.test.tsx new file mode 100644 index 000000000..75f8a16c4 --- /dev/null +++ b/apps/mobile/src/permissions/PermissionPrompts.test.tsx @@ -0,0 +1,95 @@ +import { act, fireEvent, render } from "@testing-library/react-native"; +import { createPermissionPrompter, PermissionPrompts } from "./PermissionPrompts"; + +function prompter(overrides: Parameters[0] = { locale: "en" }) { + return createPermissionPrompter(overrides); +} + +describe("PermissionPrompts", () => { + it("shows the disclosure and resolves with the user's choice", async () => { + const instance = prompter(); + const view = await render(); + + let choice: unknown; + await act(async () => { + void instance.ui.showDisclosure().then((value) => { + choice = value; + }); + }); + expect(view.getByTestId("permission-disclosure")).toBeTruthy(); + + await act(async () => { + await fireEvent.press(view.getByTestId("permission-foreground-only")); + }); + + expect(choice).toBe("foreground-only"); + expect(view.queryByTestId("permission-disclosure")).toBeNull(); + }); + + it("routes Android's background step through the permission request", async () => { + const requestBackgroundAccess = jest.fn(async () => "background"); + const openSettings = jest.fn(async () => undefined); + const instance = prompter({ + locale: "en", + requestBackgroundAccess, + openSettings, + waitForReturn: async () => undefined, + }); + await render(); + + await act(async () => { + void instance.ui.showSettingsRequired({ + state: "settings-required", + platform: "android", + reason: "background-in-settings", + }); + }); + await act(async () => { + await instance.ui.openSystemSettings(); + }); + + expect(requestBackgroundAccess).toHaveBeenCalledTimes(1); + expect(openSettings).not.toHaveBeenCalled(); + }); + + it("opens settings and waits for the user to come back for other reasons", async () => { + const order: string[] = []; + const instance = prompter({ + locale: "en", + requestBackgroundAccess: async () => order.push("request"), + openSettings: async () => { + order.push("open"); + }, + // Listening starts before settings open, so the return cannot be missed. + waitForReturn: () => { + order.push("listening"); + return Promise.resolve().then(() => { + order.push("returned"); + }); + }, + }); + await render(); + + await act(async () => { + void instance.ui.showSettingsRequired({ + state: "settings-required", + platform: "android", + reason: "precise-required", + }); + }); + await act(async () => { + await instance.ui.openSystemSettings(); + }); + + expect(order).toEqual(["listening", "open", "returned"]); + }); + + it("offers foreground-only through a confirmation", async () => { + const confirm = jest.fn(async () => true); + const instance = prompter({ locale: "en", confirm }); + + await expect(instance.ui.offerForegroundOnly()).resolves.toBe("accept"); + confirm.mockResolvedValueOnce(false); + await expect(instance.ui.offerForegroundOnly()).resolves.toBe("dismiss"); + }); +}); diff --git a/apps/mobile/src/permissions/PermissionPrompts.tsx b/apps/mobile/src/permissions/PermissionPrompts.tsx new file mode 100644 index 000000000..1fb84f246 --- /dev/null +++ b/apps/mobile/src/permissions/PermissionPrompts.tsx @@ -0,0 +1,216 @@ +import { type ReactElement, useSyncExternalStore } from "react"; +import { Alert, AppState, Linking } from "react-native"; +import type { MobileLocale } from "../../config/nativeCopy"; +import { PermissionDisclosure, PermissionOutcome } from "./PermissionDisclosure"; +import type { PermissionUi } from "./permissionController"; +import { permissionCopy } from "./permissionCopy"; +import type { PermissionFlowState } from "./permissionMachine"; + +/** + * The permission flow's screens, driven by the controller. + * + * The controller awaits each step; this turns those awaits into whichever + * native screen is showing, and resolves them from the user's tap. Only one + * prompt can be pending, because the controller runs one effect at a time. + */ + +type Pending = + | { kind: "disclosure"; resolve: (choice: "accept" | "foreground-only" | "dismiss") => void } + | { + kind: "settings"; + state: Extract; + resolve: (choice: "returned" | "dismiss") => void; + } + | { + kind: "denied"; + state: Extract; + resolve: () => void; + }; + +export interface PermissionPrompter { + ui: PermissionUi; + current(): Pending | null; + subscribe(listener: () => void): () => void; +} + +export interface PermissionPrompterOptions { + locale: MobileLocale; + /** Injected for tests. */ + openSettings?: () => Promise; + waitForReturn?: () => Promise; + confirm?: (title: string, body: string, accept: string, decline: string) => Promise; + /** + * The background-location request. On Android 11 and later the system + * answers it by opening the app's location permission page, the one with + * "Allow all the time", and returns once the user leaves it, which is a + * shorter way there than the app's general settings page. + */ + requestBackgroundAccess?: () => Promise; +} + +/** Resolves once the app has left the foreground and come back. */ +export function waitForAppReturn(maxWaitMs = 10 * 60_000): Promise { + return new Promise((resolve) => { + let left = AppState.currentState !== "active"; + const done = () => { + clearTimeout(timer); + subscription.remove(); + resolve(); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "active") left = true; + else if (left) done(); + }); + // Settings that never open (or a user who never comes back) must not hold + // the start flow forever. + const timer = setTimeout(done, maxWaitMs); + }); +} + +function nativeConfirm( + title: string, + body: string, + accept: string, + decline: string, +): Promise { + return new Promise((resolve) => { + Alert.alert( + title, + body, + [ + { text: decline, style: "cancel", onPress: () => resolve(false) }, + { text: accept, onPress: () => resolve(true) }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +export function createPermissionPrompter(options: PermissionPrompterOptions): PermissionPrompter { + let pending: Pending | null = null; + let settingsReason: + | Extract["reason"] + | null = null; + const listeners = new Set<() => void>(); + const set = (next: Pending | null) => { + pending = next; + for (const listener of listeners) listener(); + }; + const text = (key: Parameters[1]) => permissionCopy(options.locale, key); + + const ui: PermissionUi = { + showDisclosure: () => + new Promise((resolve) => + set({ + kind: "disclosure", + resolve: (choice) => { + set(null); + resolve(choice); + }, + }), + ), + showSettingsRequired: (state) => { + settingsReason = state.reason; + return new Promise((resolve) => + set({ + kind: "settings", + state, + resolve: (choice) => { + set(null); + resolve(choice); + }, + }), + ); + }, + offerForegroundOnly: async () => { + const accepted = await (options.confirm ?? nativeConfirm)( + text("foregroundOnly"), + text("foregroundOnlyNote"), + text("continue"), + text("notNow"), + ); + return accepted ? "accept" : "dismiss"; + }, + showDenied: (state) => + new Promise((resolve) => + set({ + kind: "denied", + state, + resolve: () => { + set(null); + resolve(); + }, + }), + ), + openSystemSettings: async () => { + if (settingsReason === "background-in-settings" && options.requestBackgroundAccess) { + try { + await options.requestBackgroundAccess(); + return; + } catch { + // Fall back to the general settings page. + } + } + // `openSettings` resolves as soon as the settings screen opens; the + // controller reads the grant back afterwards, so wait for the user to + // return first. + const returned = (options.waitForReturn ?? waitForAppReturn)(); + try { + await (options.openSettings ?? (() => Linking.openSettings()))(); + } catch { + return; + } + await returned; + }, + }; + + return { + ui, + current: () => pending, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +export function PermissionPrompts({ + prompter, + locale, +}: { + prompter: PermissionPrompter; + locale: MobileLocale; +}): ReactElement | null { + const pending = useSyncExternalStore(prompter.subscribe, prompter.current, prompter.current); + if (!pending) return null; + + switch (pending.kind) { + case "disclosure": + return ( + pending.resolve("accept")} + onForegroundOnly={() => pending.resolve("foreground-only")} + onDismiss={() => pending.resolve("dismiss")} + /> + ); + case "settings": + return ( + pending.resolve("returned")} + onDismiss={() => pending.resolve("dismiss")} + /> + ); + case "denied": + return ( + pending.resolve()} + onDismiss={() => pending.resolve()} + /> + ); + } +} diff --git a/apps/mobile/src/shell/NativeRecoveryOverlay.tsx b/apps/mobile/src/shell/NativeRecoveryOverlay.tsx index ee0d98039..538fb4d65 100644 --- a/apps/mobile/src/shell/NativeRecoveryOverlay.tsx +++ b/apps/mobile/src/shell/NativeRecoveryOverlay.tsx @@ -4,6 +4,7 @@ import type { MobileLocale } from "../../config/nativeCopy"; import { useNativePalette } from "./nativeTheme"; import { actionsFor, type ShellAction, type ShellState } from "./ShellState"; import { type ShellCopyKey, shellCopy } from "./shellCopy"; +import { SCROLL_SAFE_AREA, topBarInset } from "./systemBars"; /** * The native explanation for the states the page cannot render itself. @@ -112,7 +113,10 @@ export function NativeRecoveryOverlay({ style={[styles.fill, { backgroundColor: palette.background }]} testID={`shell-state-${state.kind}`} > - + {shellCopy( locale, diff --git a/apps/mobile/src/shell/systemBars.ts b/apps/mobile/src/shell/systemBars.ts new file mode 100644 index 000000000..e8b1bd3d5 --- /dev/null +++ b/apps/mobile/src/shell/systemBars.ts @@ -0,0 +1,15 @@ +import { Platform, StatusBar } from "react-native"; + +/** + * Keeping native screens clear of the system bars. + * + * The app draws edge to edge. A scrolling native screen gets the safe area from + * the scroll view on iOS; on Android the status bar's height is padded in. + */ +export const SCROLL_SAFE_AREA = { + contentInsetAdjustmentBehavior: "automatic", +} as const; + +export function topBarInset(): number { + return Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; +} diff --git a/apps/mobile/src/storage/database.ts b/apps/mobile/src/storage/database.ts index 01509f371..f03a40ecf 100644 --- a/apps/mobile/src/storage/database.ts +++ b/apps/mobile/src/storage/database.ts @@ -34,13 +34,52 @@ export interface Database { closeAsync(): Promise; } +/** + * Runs every write on `database` one at a time. + * + * `expo-sqlite` executes `withExclusiveTransactionAsync` on a connection of its + * own, and a second writer that finds the file locked fails at once with + * "database is locked" rather than waiting. Writes start from independent + * places (a location batch, a fire-and-forget diagnostic, a page command), so + * without a queue two of them regularly overlap and one is lost. Everything in + * this process shares one JavaScript context, so one queue covers all of them. + * Reads stay concurrent; WAL lets them proceed beside a writer. + * + * A transaction body must use the `tx` it is given, never the outer handle, + * or it would wait on itself. + */ +export function serialiseWrites(database: Database): Database { + let tail: Promise = Promise.resolve(); + const queued = (work: () => Promise): Promise => { + const next = tail.then(work, work); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; + }; + return { + execAsync: (source) => queued(() => database.execAsync(source)), + runAsync: (source, params) => queued(() => database.runAsync(source, params)), + getFirstAsync: (source, params) => database.getFirstAsync(source, params), + getAllAsync: (source, params) => database.getAllAsync(source, params), + withExclusiveTransactionAsync: (task) => + queued(() => database.withExclusiveTransactionAsync(task)), + closeAsync: () => queued(() => database.closeAsync()), + }; +} + let opening: Promise | null = null; export function getDatabase(): Promise { opening ??= (async () => { - const database = (await SQLite.openDatabaseAsync(DATABASE_NAME)) as unknown as Database; + const opened = (await SQLite.openDatabaseAsync(DATABASE_NAME)) as unknown as Database; + const database = serialiseWrites(opened); await database.execAsync("PRAGMA journal_mode = WAL"); await database.execAsync("PRAGMA foreign_keys = ON"); + // A lock held by another process (or a connection this queue does not + // see) is waited for briefly instead of failing outright. + await database.execAsync("PRAGMA busy_timeout = 5000"); await migrateSessionSchema(database); return database; })().catch((error) => { diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index cc1b2db72..a91b5c63e 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -8,17 +8,18 @@ sidebar_position: 9 The installed app renders the web UI in a React Native WebView at a fixed origin. The shell restricts navigation and exposes a versioned bridge. It answers the -page's one-shot requests: a location fix, opening OS settings and a -system-browser sign-in. Native navigation processors, persistence, audio and -recovery components exist, but the foreground app does not yet connect them to -bridge commands. +page's one-shot requests (a location fix, opening OS settings and a +system-browser sign-in) and runs native ground and transit navigation sessions. `apps/mobile/src/App.tsx` composes `createShellServices` (`src/shell/shellServices.ts`) -into `createShellBridge`. The handshake reports the OS location grant and reports -ground navigation, transit navigation, background location, local notifications -and speech as unavailable. Navigation-session commands receive -`unsupported-capability`. Do not describe this composition as a working -background-navigation release. +and `createForegroundNavigation` (`src/navigation/createForegroundNavigation.ts`) +into `createShellBridge`. Navigation commands go to the `NavigationCoordinator`; +everything else falls back to the shell services, and a command neither handles +receives `unsupported-capability`. The handshake reports ground and transit +navigation and background location as available, local notifications once the +notification permission is granted, and speech when the native audio module is +present. Installed-device evidence covers Android only; see +[Mobile feasibility](./mobile-feasibility.md) for what has been qualified. ## Why there is a split at all @@ -28,29 +29,57 @@ session state. The web UI owns rendering, search, route planning and place detai A browser or PWA uses browser navigation facilities. Inside the installed shell, the web runtime selects native authority before the handshake and disables its -browser engine. Missing native composition does not enable a browser fallback. -This avoids competing location streams, speech and session records, but currently -leaves installed navigation unavailable. +browser engine. A shell that reports no navigation capability does not enable a +browser fallback. This avoids competing location streams, speech and session +records. ## The authority matrix -| Concern | Browser or PWA | Installed shell | -| --------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------- | -| Rendering, search and route planning | Web UI | Web UI | -| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | -| Ground and transit navigation | Browser engine | Native authority selected; capability currently false | -| Background location, local alerts and speech | Subject to browser limits | Capability currently false | -| Durable native session | Not used | SQLite components and recovery exist; session commands are not composed | -| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | -| Deep-link delivery | Browser flows | Protocol vocabulary and parser exist; OS delivery is not composed | -| Community frontend bundles | Disabled | Disabled | -| Microphone input | Subject to browser support and permission | Disabled | +| Concern | Browser or PWA | Installed shell | +| --------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------- | +| Rendering, search and route planning | Web UI | Web UI | +| Browser geolocation, speech, notifications and navigation persistence | Browser facilities | Disabled by the shell descriptor | +| Ground and transit navigation | Browser engine | Native coordinator, reported available | +| Background location, local alerts and speech | Subject to browser limits | Native; alerts need the notification grant | +| Durable native session | Not used | SQLite session, resumed after a process restart | +| One-shot location, settings and authentication commands | Browser flows | Answered by the shell services | +| Deep-link delivery | Browser flows | Protocol vocabulary and parser exist; OS delivery is not composed | +| Community frontend bundles | Disabled | Disabled | +| Microphone input | Subject to browser support and permission | Disabled | The immutable descriptor is injected before page scripts run. Shell restrictions use that descriptor, so they apply during negotiation, incompatibility and errors. `MobileRuntimeProvider.tsx` is the web authority boundary; `createShellBridge.ts` supplies the current capability report. +## Native navigation + +`createForegroundNavigation` builds one coordinator per foreground app and +registers it, so the background location task feeds the same instance instead +of opening a second, headless one beside it. The headless coordinator is used +only when the OS starts the task without the app. + +- **Start.** `session.start` runs the `PermissionController`. Its screens are + native (`PermissionPrompts.tsx`) because the page may be suspended during the + OS prompts. On Android 11 and later the background step is the system's + background-location request, which opens the app's own location page with + "Allow all the time". The first start also asks once for notification + permission, for transit alight alerts. +- **Location.** expo-location delivers batches to the task registered in + `defineNavigationTask.ts`. expo-task-manager schedules a persisted job, so the + app holds `RECEIVE_BOOT_COMPLETED`; without it the first location update + crashes the process. The app does not start anything at boot. +- **Storage.** expo-sqlite runs each exclusive transaction on its own + connection, so `getDatabase` queues writes on the one handle and sets a busy + timeout. Without the queue a diagnostic write and a batch commit collide with + "database is locked". +- **Reroute.** A `reroute` effect only starts the request + (`createRemoteWork.ts`). The answer re-enters the coordinator as a queued + `applyReroute`, which applies it only while the session and its pending + request id still match. A newer request aborts the one in flight. +- **Transit refresh and replan** are not wired yet. Their effects are no-ops, + and the session keeps running on its captured itinerary. + ## One-shot location `location.request` reads one position through the process-wide location driver diff --git a/docs/docs/developer/mobile-release.md b/docs/docs/developer/mobile-release.md index 37741bcf2..4f1dce8f5 100644 --- a/docs/docs/developer/mobile-release.md +++ b/docs/docs/developer/mobile-release.md @@ -8,9 +8,9 @@ sidebar_position: 10 Mobile artifacts are built locally with Xcode and Gradle. Store enrollment, agreements, upload, review and publication are manual account-holder operations. -The current source is not ready for a navigation store submission: the installed -foreground bridge has no command dispatcher, signing identities are placeholders, -and physical beta evidence is absent from the repository. +The current source is not ready for a navigation store submission: signing +identities are placeholders, iOS has not run on a device, and physical beta +evidence is absent from the repository. ## What the stores unavoidably require