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