Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/compliance/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
]);
});
});
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/compliance/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
];

/**
Expand All @@ -108,7 +114,6 @@ export const FORBIDDEN_ANDROID_PERMISSIONS: Record<string, string> = {
"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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions apps/mobile/plugins/nativeConfigTransforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---- */
Expand Down
1 change: 0 additions & 1 deletion apps/mobile/scripts/generatedNativeChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 93 additions & 2 deletions apps/mobile/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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", () => {
Expand Down
76 changes: 67 additions & 9 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 },
Expand Down Expand Up @@ -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<ForegroundNavigation | null> = 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
Expand Down Expand Up @@ -300,6 +357,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement
state={shellState.state}
onAction={handleShellAction}
/>
<PermissionPrompts prompter={prompter} locale={locale} />
{config.feasibilityMode ? <FeasibilityOverlay /> : null}
</View>
);
Expand Down
Loading
Loading