diff --git a/MIGRATING.md b/MIGRATING.md index da6debad..abb65083 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -4,6 +4,7 @@ This document covers breaking changes and migration steps between major versions ## Table of Contents +- [Migrating from 0.19.0 to 0.19.1](#migrating-from-0190-to-0191) - [Migrating from 0.18.x to 0.19.x](#migrating-from-018x-to-019x) - [Migrating from 0.17.x to 0.18.x](#migrating-from-017x-to-018x) - [Migrating from 0.16.x to 0.17.x](#migrating-from-016x-to-017x) @@ -11,6 +12,51 @@ This document covers breaking changes and migration steps between major versions --- +## Migrating from 0.19.0 to 0.19.1 + +Version 0.19.1 upgrades the Android Navigation SDK from `8.0.0` to +`8.1.0`. The iOS Navigation SDK remains at `11.3.0`. + +### Migrate route tokens + +`SetDestinationsOptions.routeTokenOptions` and `RouteTokenOptions` are +deprecated and still work. Move token routing to `setDestinationsWithToken`. + +Before: + +```tsx +await navigationController.setDestinations(waypoints, { + routeTokenOptions: { routeToken, travelMode: TravelMode.DRIVING }, + displayOptions: { showDestinationMarkers: false }, +}); +``` + +After: + +```tsx +await navigationController.setDestinationsWithToken({ + routeToken, + travelMode: TravelMode.DRIVING, + displayOptions: { showDestinationMarkers: false }, +}); +``` + +- Drop the waypoints and `routingOptions`: the token supplies the destinations + and routing preferences. +- Pass the travel mode the token was generated with. Modes other than + `TravelMode.DRIVING` and `TravelMode.TWO_WHEELER` reject with error code + `travelModeUnsupported`. +- Destination markers are shown when `displayOptions` is omitted. + +See [Using Route Tokens](README.md#using-route-tokens) for the full behavior, +including the platform difference when `travelMode` is omitted. + +### Rebuild both platforms + +Rebuild Android, then run `pod install` in your iOS directory and rebuild iOS. + +--- + ## Migrating from 0.18.x to 0.19.x Version 0.19.0 upgrades the Android Navigation SDK from `7.9.0` to `8.0.0` and diff --git a/README.md b/README.md index 0885cfdd..40ce97b9 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ package's config plugin instead of the Android and iOS steps below. See the ### Android -This package uses Navigation SDK for Android **8.0.0**. See the +This package uses Navigation SDK for Android **8.1.0**. See the [0.19.x migration guide](./MIGRATING.md#migrating-from-018x-to-019x) for build requirements and breaking API changes. @@ -337,30 +337,30 @@ try { #### Using Route Tokens -You can use a pre-computed route from the [Routes API](https://developers.google.com/maps/documentation/routes) by providing a route token. This is useful when you want to ensure the navigation follows a specific route that was calculated server-side. +Use a route token from the [Routes API](https://developers.google.com/maps/documentation/routes/route_token) or [Route Optimization API](https://developers.google.com/maps/documentation/route-optimization/polylines-and-route-tokens) to navigate a route planned by your backend. -To use a route token: - -1. Pass the token using `routeTokenOptions` instead of `routingOptions` -2. **Important:** The waypoints passed to `setDestinations` must match the waypoints used when generating the route token +After navigation initialization and a valid location update, pass a `SetDestinationsWithTokenOptions` object: ```tsx -const waypoint = { - title: 'Destination', - position: { lat: 37.7749, lng: -122.4194 }, -}; +const routeStatus = await navigationController.setDestinationsWithToken({ + routeToken, + travelMode: TravelMode.DRIVING, // Use the mode the token was generated with. + displayOptions: { showDestinationMarkers: true }, // Optional; defaults to true. +}); +if (routeStatus === RouteStatus.OK) { + await navigationController.startGuidance(); +} +``` -const routeTokenOptions = { - routeToken: 'your-route-token-from-routes-api', - travelMode: TravelMode.DRIVING, // Must match the travel mode used to generate the token -}; +The required `routeToken` supplies the destinations and routing preferences; do not pass waypoints or `routingOptions`. The optional nested `displayOptions` defaults to showing destination markers. Guidance, simulation, arrival events, and `continueToNextDestination` work with token routes as they do with waypoint routes. Arrival waypoints are reported by the native SDK, not matched against caller-provided labels. -await navigationController.setDestinations([waypoint], { routeTokenOptions }); -await navigationController.startGuidance(); -``` +The optional `travelMode` accepts only `TravelMode.DRIVING` or `TravelMode.TWO_WHEELER`; pass the mode the token was generated with. Explicit unsupported modes (including walking, cycling, and taxi) reject with native error code `travelModeUnsupported` before changing the existing route. On iOS, omitting `travelMode` preserves the current client mode and rejects with the same code if that mode is unsupported; pass an explicit supported mode to switch after walking or cycling. On Android, omission follows the native token-routing behavior and uses the token's mode. A supported client mode never overrides the travel mode encoded in the token. -> [!IMPORTANT] -> `routingOptions` and `routeTokenOptions` are mutually exclusive. Providing both will throw an error. +For Routes API tokens, use `DRIVE` or `TWO_WHEELER`, a `TRAFFIC_AWARE` or `TRAFFIC_AWARE_OPTIMAL` routing preference, and no `via` waypoints. + +`RouteStatus.OK` means the SDK found a route, not that it reproduced the planned route exactly. Starting location, traffic, or road changes can affect the route. This API returns `RouteStatus`, not token execution details. + +`SetDestinationsOptions.routeTokenOptions` and `RouteTokenOptions` are deprecated. Existing `setDestination` and `setDestinations` callers can migrate using the [token-routing migration guide](./MIGRATING.md#migrating-from-0190-to-0191). In the deprecated API, `routingOptions` and `routeTokenOptions` remain mutually exclusive. #### Adding navigation listeners diff --git a/android/build.gradle b/android/build.gradle index 979b7dbe..1f079389 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -58,6 +58,6 @@ dependencies { implementation "androidx.car.app:app:1.4.0" implementation "androidx.car.app:app-projected:1.4.0" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation "com.google.android.libraries.navigation:navigation:8.0.0" + implementation "com.google.android.libraries.navigation:navigation:8.1.0" api 'com.google.guava:guava:31.0.1-android' } diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro index 636f054c..5eb39577 100644 --- a/android/consumer-rules.pro +++ b/android/consumer-rules.pro @@ -17,6 +17,7 @@ public static *; } -# SDK 8's protobuf registry is package-private. Its reflective loader must stay -# in the same package; R8 repackaging otherwise causes IllegalAccessException. --keep class com.google.android.libraries.navigation.internal.amv.av { *; } +# SDK 8.1's protobuf registry (internal.anr.k) is package-private. Its reflective +# loader must stay in the same package; R8 repackaging otherwise causes +# IllegalAccessException even when optimizations are disabled. +-keep class com.google.android.libraries.navigation.internal.anr.av { *; } diff --git a/android/src/main/java/com/google/android/react/navsdk/NavModule.java b/android/src/main/java/com/google/android/react/navsdk/NavModule.java index 582e1ac7..201eb0de 100644 --- a/android/src/main/java/com/google/android/react/navsdk/NavModule.java +++ b/android/src/main/java/com/google/android/react/navsdk/NavModule.java @@ -672,6 +672,49 @@ public void setDestinations( } } + @Override + public void setDestinationsWithToken(ReadableMap options, final Promise promise) { + if (!ensureNavigatorAvailable(promise)) { + return; + } + + try { + if (options.hasKey("travelMode") && !options.isNull("travelMode")) { + @RoutingOptions.TravelMode + int travelMode = EnumTranslationUtil.getTravelModeFromJsValue(options.getInt("travelMode")); + if (travelMode != RoutingOptions.TravelMode.DRIVING + && travelMode != RoutingOptions.TravelMode.TWO_WHEELER) { + promise.reject( + "travelModeUnsupported", + "Route tokens support only DRIVING and TWO_WHEELER travel modes."); + return; + } + } + + String routeToken = options.getString("routeToken"); + DisplayOptions parsedDisplayOptions = + ObjectTranslationUtil.getDisplayOptionsFromMap( + options.getMap("displayOptions").toHashMap()); + + mWaypoints.clear(); + + // The SDK uses the mode encoded in the token, not the validated client mode. + mNavigator.setDestinationsWithToken( + routeToken, + parsedDisplayOptions, + routeDescription -> { + if (routeDescription == null) { + promise.reject("noRouteStatus", "The Navigation SDK returned no route status."); + return; + } + promise.resolve( + EnumTranslationUtil.getRouteStatusStringValue(routeDescription.getRouteStatus())); + }); + } catch (RuntimeException e) { + promise.reject("setDestinationsWithTokenFailed", e.getMessage(), e); + } + } + @Override public void clearDestinations(final Promise promise) { if (!ensureNavigatorAvailable(promise)) { @@ -702,7 +745,7 @@ public void startGuidance(final Promise promise) { if (!ensureNavigatorAvailable(promise)) { return; } - if (mWaypoints.isEmpty()) { + if (mNavigator.getCurrentRouteSegment() == null) { promise.reject(JsErrors.NO_DESTINATIONS_ERROR_CODE, JsErrors.NO_DESTINATIONS_ERROR_MESSAGE); return; } @@ -723,15 +766,17 @@ public void stopGuidance(final Promise promise) { @Override public void simulateLocationsAlongExistingRoute(ReadableMap options, final Promise promise) { - float speedMultiplier = - options.hasKey("speedMultiplier") ? (float) options.getDouble("speedMultiplier") : 1.0f; - if (mNavigator == null) { + if (!ensureNavigatorAvailable(promise)) { return; } - if (mWaypoints.isEmpty()) { + if (mNavigator.getCurrentRouteSegment() == null) { + promise.reject(JsErrors.NO_DESTINATIONS_ERROR_CODE, JsErrors.NO_DESTINATIONS_ERROR_MESSAGE); return; } + float speedMultiplier = + options.hasKey("speedMultiplier") ? (float) options.getDouble("speedMultiplier") : 1.0f; + mNavigator .getSimulator() .simulateLocationsAlongExistingRoute( diff --git a/example/README.md b/example/README.md index 620fedd4..90fea11b 100644 --- a/example/README.md +++ b/example/README.md @@ -51,3 +51,37 @@ First, make sure you go through the setup from the main [README](../README.md). 1. On your Emulator, go to App Info for the installed app, then Permissions > Location and allow location for the app. 2. Restart the app, now the Navigation view should be displayed instead of the map. + +## Route-token integration tests + +The focused route-token suite requests fresh single- and multi-stop tokens from +the Routes API, then exercises native routing, omitted and supported client modes, +nested display options, guidance, simulation, arrival, route replacement, and the +deprecated API on both platforms. Configure the native Navigation SDK keys above +and follow the [Detox setup](../CONTRIBUTING.md#integration-tests). + +Enable Routes API for a test key that permits requests from the test machine. +An Android- or iOS-app-restricted key may not permit these Node-side requests. +The key stays in the test process; only the resulting token is entered into +the app. Do not commit keys or route tokens. + +From the repository root: + +```sh +yarn example detox:build:android-release +yarn example detox:build:ios-release + +# Paste the Routes API key, then press Enter; input is not echoed. +read -r -s ROUTES_API_KEY +export ROUTES_API_KEY + +yarn example detox:test:android-release --testMatch "$PWD/example/e2e/route_token.e2e.js" +yarn example detox:test:ios-release --testMatch "$PWD/example/e2e/route_token.e2e.js" --headless + +unset ROUTES_API_KEY +``` + +The default `*.test.js` suite does not require this additional key. Its token +validation coverage checks unsupported mode rejection and prior-route preservation +using an invalid token. The focused suite fails explicitly if `ROUTES_API_KEY` is +missing or cannot obtain a token. diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 0ead5c95..9f35e866 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -157,7 +157,7 @@ dependencies { implementation "androidx.car.app:app-projected:1.4.0" // Include the Google Navigation SDK. - implementation 'com.google.android.libraries.navigation:navigation:8.0.0' + implementation 'com.google.android.libraries.navigation:navigation:8.1.0' // 1.2.1 pads libdatastore_shared_counter.so's GNU_RELRO segment for 16 KB pages. implementation 'androidx.datastore:datastore-core:1.3.0' diff --git a/example/e2e/navigation.test.js b/example/e2e/navigation.test.js index cd91de75..2aefc459 100644 --- a/example/e2e/navigation.test.js +++ b/example/e2e/navigation.test.js @@ -83,7 +83,7 @@ describe('Navigation tests', () => { await expectSuccess(); }); - it('NT08 - setDestinations with both routingOptions and routeTokenOptions should throw error', async () => { + it('NT08 - rejects deprecated routeTokenOptions combined with routingOptions and unsupported token modes without changing route state', async () => { await selectTestByName('testRouteTokenOptionsValidation'); await agreeToTermsAndConditions(); await waitForTestToFinish(); diff --git a/example/e2e/route_token.e2e.js b/example/e2e/route_token.e2e.js new file mode 100644 index 00000000..53f2a195 --- /dev/null +++ b/example/e2e/route_token.e2e.js @@ -0,0 +1,120 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { device, element, waitFor, by } from 'detox'; +import { + initializeIntegrationTestsPage, + agreeToTermsAndConditions, + selectTestByName, + waitForTestToFinish, + expectSuccess, + expectNoErrors, +} from './shared.js'; + +// Opt in with --testMatch and this file's absolute path; see example/README.md. +// The default suite needs no Routes API key; targeted runs require one. +// Keep these fixtures in sync with the in-app token integration scenarios. +const ORIGIN = { latitude: 37.791366, longitude: -122.415659 }; +const DESTINATION = { latitude: 37.791957, longitude: -122.412529 }; +const INTERMEDIATE = { latitude: 37.79165, longitude: -122.4141 }; + +const fetchFreshRouteToken = async (multiStop = false) => { + const apiKey = process.env.ROUTES_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + 'ROUTES_API_KEY is required for the targeted token-routing tests; enable Routes API for this credential.' + ); + } + + let response; + try { + response = await fetch( + 'https://routes.googleapis.com/directions/v2:computeRoutes', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'routes.routeToken', + }, + body: JSON.stringify({ + origin: { location: { latLng: ORIGIN } }, + destination: { location: { latLng: DESTINATION } }, + intermediates: multiStop + ? [{ location: { latLng: INTERMEDIATE }, via: false }] + : [], + travelMode: 'DRIVE', + routingPreference: 'TRAFFIC_AWARE', + }), + signal: AbortSignal.timeout(30000), + } + ); + } catch (error) { + // Do not include request headers, credentials, or an unsanitized HTTP error. + const code = error?.cause?.code ?? error?.code ?? error?.name; + throw new Error( + `Routes API request failed before an HTTP response (${code}).` + ); + } + if (!response.ok) { + throw new Error(`Routes API request failed (HTTP ${response.status}).`); + } + + let data; + try { + data = await response.json(); + } catch { + throw new Error( + `Routes API returned invalid JSON (HTTP ${response.status}).` + ); + } + const token = data.routes?.[0]?.routeToken; + if (typeof token !== 'string' || !token.trim()) { + throw new Error('Routes API returned no route token.'); + } + return token; +}; + +const runTokenTest = async (testName, multiStop = false) => { + // Fetch immediately before each run; do not hardcode or cache route tokens. + const routeToken = await fetchFreshRouteToken(multiStop); + await initializeIntegrationTestsPage(); + await device.disableSynchronization(); + const tokenInput = element(by.id('route_token_input')); + await waitFor(tokenInput).toBeVisible().withTimeout(10000); + await tokenInput.tap(); + await tokenInput.replaceText(routeToken); + if (device.getPlatform() === 'android') { + await device.pressBack(); + } else { + await tokenInput.tapReturnKey(); + } + await selectTestByName(testName); + await agreeToTermsAndConditions(); + await waitForTestToFinish(180000); + await expectNoErrors(); + await expectSuccess(); +}; + +describe('Fresh route-token navigation tests', () => { + it('NT12 - handles omitted and supported client modes, replaces routes, routes with deprecated routeTokenOptions, clears, guides and arrives', async () => { + await runTokenTest('testTokenRouting'); + }, 240000); + + it('NT13 - follows a multi-stop token, continues between SDK legs and clears the final route', async () => { + await runTokenTest('testMultiStopTokenRouting', true); + }, 240000); +}); diff --git a/example/src/helpers/routesApi.ts b/example/src/helpers/routesApi.ts index 422c173a..1a15520f 100644 --- a/example/src/helpers/routesApi.ts +++ b/example/src/helpers/routesApi.ts @@ -15,7 +15,7 @@ */ // Minimal Routes API implementation for fetching route tokens. -// Route tokens are currently only supported for DRIVE travel mode. +// This example requests DRIVE; route tokens support DRIVE or TWO_WHEELER. // See: https://developers.google.com/maps/documentation/routes/route_token import type { LatLng } from '@googlemaps/react-native-navigation-sdk'; @@ -25,7 +25,7 @@ const COMPUTE_ROUTES_URL = /** * Fetches a route token from the Google Maps Routes API. - * Route tokens are only supported for DRIVE travel mode. + * This example requests DRIVE; route tokens support DRIVE or TWO_WHEELER. * * @param apiKey - Google Maps API key with Routes API enabled. * @param origin - Starting location. diff --git a/example/src/screens/IntegrationTestsScreen.tsx b/example/src/screens/IntegrationTestsScreen.tsx index 84b1dc4e..1e7f1536 100644 --- a/example/src/screens/IntegrationTestsScreen.tsx +++ b/example/src/screens/IntegrationTestsScreen.tsx @@ -16,7 +16,7 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { Text, View, useWindowDimensions } from 'react-native'; +import { Text, TextInput, View, useWindowDimensions } from 'react-native'; import { ExampleAppButton } from '../controls/ExampleAppButton'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -54,6 +54,8 @@ import { testNavigationStateGuards, testStartGuidanceWithoutDestinations, testRouteTokenOptionsValidation, + testTokenRouting, + testMultiStopTokenRouting, testMapStyle, testMinMaxZoomLevels, testSetFollowingPerspective, @@ -84,6 +86,7 @@ const IntegrationTestsScreen = () => { TestRunStatus.NotRunning ); const [testResult, setTestResult] = useState(TestResult.None); + const [routeToken, setRouteToken] = useState(''); // Get navigationController and listener setters from useNavigation hook const { @@ -227,6 +230,7 @@ const IntegrationTestsScreen = () => { navigationController, mapViewController, navigationViewController, + routeToken, setOnNavigationReady, setOnArrival, setOnRemainingTimeOrDistanceChanged, @@ -316,6 +320,12 @@ const IntegrationTestsScreen = () => { case 'testRouteTokenOptionsValidation': await testRouteTokenOptionsValidation(getTestTools()); break; + case 'testTokenRouting': + await testTokenRouting(getTestTools()); + break; + case 'testMultiStopTokenRouting': + await testMultiStopTokenRouting(getTestTools()); + break; case 'testMapStyle': await testMapStyle(getTestTools()); break; @@ -382,6 +392,17 @@ const IntegrationTestsScreen = () => { Test status: {testStatusString} Test result: {testResult} {failureMessage} + { @@ -539,6 +560,20 @@ const IntegrationTestsScreen = () => { }} testID="testRouteTokenOptionsValidation" /> + { + runTest('testTokenRouting'); + }} + testID="testTokenRouting" + /> + { + runTest('testMultiStopTokenRouting'); + }} + testID="testMultiStopTokenRouting" + /> { diff --git a/example/src/screens/RouteTokenScreen.tsx b/example/src/screens/RouteTokenScreen.tsx index 74b1ad8d..7c1eb91d 100644 --- a/example/src/screens/RouteTokenScreen.tsx +++ b/example/src/screens/RouteTokenScreen.tsx @@ -28,11 +28,8 @@ import { ExampleAppButton } from '../controls/ExampleAppButton'; import { CommonStyles, MapStyles } from '../styles/components'; import { NavigationView, - TravelMode, NavigationSessionStatus, type NavigationViewController, - type Waypoint, - type RouteTokenOptions, type LatLng, RouteStatus, useNavigation, @@ -119,25 +116,12 @@ const RouteTokenScreen = () => { // Simulate user location at origin before setting destination navigationController.simulator.simulateLocation(ORIGIN_LOCATION); - // Set destination with route token using fixed destination - const waypoint: Waypoint = { - title: 'San Francisco', - position: DESTINATION_LOCATION, - }; - - const routeTokenOptions: RouteTokenOptions = { - routeToken: confirmedRouteToken, - travelMode: TravelMode.DRIVING, // Route tokens only support driving mode. - }; - try { - const routeStatus = await navigationController.setDestination( - waypoint, - { + const routeStatus = + await navigationController.setDestinationsWithToken({ + routeToken: confirmedRouteToken, displayOptions: { showDestinationMarkers: true }, - routeTokenOptions, - } - ); + }); // Handle route status result switch (routeStatus) { @@ -325,7 +309,8 @@ const RouteTokenScreen = () => { Note: - Route tokens only support driving mode. + This example requests DRIVE routes. Route tokens support DRIVE or + TWO_WHEELER travel modes. {'\n\n'} The user location will be simulated at the origin when navigation starts. diff --git a/example/src/screens/integration_tests/integration_test.ts b/example/src/screens/integration_tests/integration_test.ts index 67b06b1a..6e3c8bf7 100644 --- a/example/src/screens/integration_tests/integration_test.ts +++ b/example/src/screens/integration_tests/integration_test.ts @@ -43,6 +43,7 @@ interface TestTools { navigationController: NavigationController; mapViewController: MapViewController | null; navigationViewController: NavigationViewController | null; + routeToken: string; setOnNavigationReady: (listener: (() => void) | null | undefined) => void; setOnArrival: ( listener: ((arrivalEvent: ArrivalEvent) => void) | null | undefined @@ -184,6 +185,25 @@ const waitForTimeAndDistance = async ( delayMs ); +const waitForNoRoute = (navigationController: NavigationController) => + waitForCondition( + async () => { + try { + return (await navigationController.getRouteSegments()).length === 0; + } catch (error) { + // iOS reports absent route legs as an error; Android returns an empty list. + if ( + Platform.OS === 'ios' && + extractNativeErrorCode(error) === 'route_not_available' + ) { + return true; + } + throw error; + } + }, + absent => absent + ); + const disableVoiceGuidanceForTests = ( navigationController: NavigationController ) => @@ -239,6 +259,31 @@ const simulateAndWaitForLocation = ( }); }; +const simulateUntilArrival = ({ + navigationController, + setOnArrival, +}: TestTools): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + setOnArrival(null); + resolve(null); + }, 60000); + setOnArrival(arrival => { + clearTimeout(timer); + setOnArrival(null); + resolve(arrival); + }); + try { + navigationController.simulator.simulateLocationsAlongExistingRoute({ + speedMultiplier: Platform.OS === 'ios' ? 5 : 10, + }); + } catch (error) { + clearTimeout(timer); + setOnArrival(null); + reject(error); + } + }); + export const testNavigationSessionInitialization = async ( testTools: TestTools ) => { @@ -1559,6 +1604,33 @@ export const testNavigationStateGuards = async (testTools: TestTools) => { return; } + if ( + !(await expectNavigatorUnavailableError('setDestinationsWithToken', () => + navigationController.setDestinationsWithToken({ + routeToken: 'invalid-route-token', + }) + )) + ) { + return; + } + + for (const travelMode of [ + TravelMode.WALKING, + TravelMode.CYCLING, + TravelMode.TAXI, + ]) { + if ( + !(await expectNavigatorUnavailableError('setDestinationsWithToken', () => + navigationController.setDestinationsWithToken({ + routeToken: 'invalid-route-token', + travelMode, + }) + )) + ) { + return; + } + } + if ( !(await expectNavigatorUnavailableError('startGuidance', () => navigationController.startGuidance() @@ -1656,8 +1728,13 @@ export const testStartGuidanceWithoutDestinations = async ( * These options are mutually exclusive and should not be used together. */ export const testRouteTokenOptionsValidation = async (testTools: TestTools) => { - const { navigationController, setOnNavigationReady, passTest, failTest } = - testTools; + const { + navigationController, + setOnNavigationReady, + setOnLocationChanged, + passTest, + failTest, + } = testTools; // Accept ToS first if (!(await acceptToS(navigationController, failTest))) { @@ -1665,40 +1742,535 @@ export const testRouteTokenOptionsValidation = async (testTools: TestTools) => { } setOnNavigationReady(async () => { - disableVoiceGuidanceForTests(navigationController); - + setOnNavigationReady(null); + let failure: string | undefined; try { - // Attempt to provide both routingOptions and routeTokenOptions - await navigationController.setDestinations([DEFAULT_TEST_WAYPOINT], { + disableVoiceGuidanceForTests(navigationController); + const located = await simulateAndWaitForLocation( + navigationController, + setOnLocationChanged, + { lat: 37.791366, lng: -122.415659 } + ); + if (!located) { + throw new Error('Timed out waiting for simulated location'); + } + const status = await navigationController.setDestination( + DEFAULT_TEST_WAYPOINT + ); + if (status !== RouteStatus.OK) { + throw new Error(`Baseline route returned ${status}`); + } + const before = await navigationController.getRouteSegments(); + if (before.length !== 1) { + throw new Error('Expected one baseline route segment'); + } + + const options: SetDestinationsOptions = { routingOptions: { travelMode: TravelMode.DRIVING }, routeTokenOptions: { - routeToken: 'some-token', + routeToken: 'invalid-route-token', travelMode: TravelMode.DRIVING, }, - }); - failTest( - 'Expected error when both routingOptions and routeTokenOptions provided' + }; + const conflictingCalls = [ + () => + navigationController.setDestination(DEFAULT_TEST_WAYPOINT, options), + () => + navigationController.setDestinations( + [DEFAULT_TEST_WAYPOINT], + options + ), + ]; + for (const call of conflictingCalls) { + let rejected = false; + try { + await call(); + } catch (error) { + // Conflicting options must be rejected before a native route request, + // independent of the human-readable error message. + rejected = + error instanceof Error && + extractNativeErrorCode(error) === undefined; + } + if (!rejected) { + throw new Error( + 'Conflicting routing/token options were not rejected' + ); + } + const after = await navigationController.getRouteSegments(); + if ( + after.length !== before.length || + distanceBetween( + after[0]!.destinationLatLng, + before[0]!.destinationLatLng + ) > 1 + ) { + throw new Error('Conflicting options changed the existing route'); + } + } + + // No Routes API key is needed: unsupported modes must reject before + // the native SDK parses this deliberately invalid token. + for (const travelMode of [ + TravelMode.WALKING, + TravelMode.CYCLING, + TravelMode.TAXI, + ]) { + let code: string | undefined; + try { + await navigationController.setDestinationsWithToken({ + routeToken: 'invalid-route-token', + travelMode, + displayOptions: { showDestinationMarkers: false }, + }); + } catch (error) { + code = extractNativeErrorCode(error); + } + if (code !== 'travelModeUnsupported') { + throw new Error( + `Token mode ${travelMode} rejected with ${code ?? 'no error'}` + ); + } + const after = await navigationController.getRouteSegments(); + if ( + after.length !== before.length || + distanceBetween( + after[0]!.destinationLatLng, + before[0]!.destinationLatLng + ) > 1 + ) { + throw new Error('Unsupported token mode changed the existing route'); + } + } + } catch (error) { + failure = `Route token validation failed: ${error}`; + } finally { + setOnLocationChanged(null); + try { + await navigationController.simulator.stopLocationSimulation(); + await navigationController.cleanup(); + } catch { + failure = failure ?? 'Route token exclusivity cleanup failed'; + } + } + if (failure) { + failTest(failure); + } else { + passTest(); + } + }); + await initializeNavigation(navigationController, failTest); +}; + +/** + * Uses a fresh Routes API token supplied by Detox, never an API key. + * Endpoint/state checks demonstrate a usable route, not exact token replay. + */ +export const testTokenRouting = async (testTools: TestTools) => { + const { + navigationController, + routeToken, + setOnNavigationReady, + setOnLocationChanged, + setOnArrival, + passTest, + failTest, + } = testTools; + if (!routeToken.trim()) { + return failTest( + 'A fresh Routes API token is required for testTokenRouting' + ); + } + if (!(await acceptToS(navigationController, failTest))) { + return; + } + + // Keep these coordinates in sync with e2e/route_token.e2e.js. + const origin = { lat: 37.791366, lng: -122.415659 }; + const destination = DEFAULT_TEST_WAYPOINT; + const replacement = { + position: { lat: 37.7894, lng: -122.4175 }, + }; + const replacementStop = { + position: { lat: 37.7906, lng: -122.4195 }, + }; + + const expectRoute = async ( + calculation: Promise, + endpoints: LatLng[], + label: string + ) => { + const status = await calculation.catch(error => { + throw new Error( + `${label} rejected (${extractNativeErrorCode(error) ?? 'unknown'})` ); + }); + if (status !== RouteStatus.OK) { + throw new Error(`${label} returned ${status}`); + } + const segments = await waitForCondition( + () => navigationController.getRouteSegments(), + route => + route.length === endpoints.length && + route.every( + (segment, index) => + distanceBetween(segment.destinationLatLng, endpoints[index]!) <= + LOCATION_THRESHOLD_METERS + ) + ); + if (!segments) { + throw new Error(`${label} did not replace the SDK route endpoints/count`); + } + const current = await navigationController.getCurrentRouteSegment(); + if ( + !current || + distanceBetween(current.destinationLatLng, endpoints[0]!) > + LOCATION_THRESHOLD_METERS + ) { + throw new Error(`${label} has no matching current SDK route segment`); + } + }; + + const expectNoRoute = async () => { + const noRoute = await waitForNoRoute(navigationController); + if (!noRoute) { + throw new Error('Expected no remaining SDK route segments'); + } + let code: string | undefined; + try { + await navigationController.startGuidance(); } catch (error) { - // Should throw JS error about mutual exclusivity + code = extractNativeErrorCode(error); + } + if (code !== NO_DESTINATIONS_ERROR_CODE) { + throw new Error( + 'Guidance without a route did not reject NO_DESTINATIONS' + ); + } + }; + + setOnNavigationReady(async () => { + setOnNavigationReady(null); + let failure: string | undefined; + try { + disableVoiceGuidanceForTests(navigationController); + const located = await simulateAndWaitForLocation( + navigationController, + setOnLocationChanged, + origin + ); + if (!located) { + throw new Error('Timed out waiting for the token fixture origin'); + } + + await navigationController.clearDestinations(); + try { + const status = await navigationController.setDestinationsWithToken({ + routeToken: 'invalid-route-token', + }); + if ( + status === RouteStatus.OK || + !Object.values(RouteStatus).includes(status) + ) { + throw new Error('Malformed token returned an invalid success/status'); + } + } catch (error) { + const code = extractNativeErrorCode(error); + if ( + code !== 'setDestinationsWithTokenFailed' && + code !== 'noRouteStatus' + ) { + throw error; + } + } + await expectNoRoute(); + + // iOS preserves the client mode when omitted; Android's token-only SDK + // request uses the token mode regardless of the preceding normal route. + for (const mode of [TravelMode.WALKING, TravelMode.CYCLING]) { + await expectRoute( + navigationController.setDestination(replacement, { + routingOptions: { travelMode: mode }, + }), + [replacement.position], + `Prior travel mode ${mode}` + ); + if (Platform.OS === 'ios') { + let code: string | undefined; + try { + await navigationController.setDestinationsWithToken({ routeToken }); + } catch (error) { + code = extractNativeErrorCode(error); + } + if (code !== 'travelModeUnsupported') { + throw new Error( + `Omitted token mode after ${mode} rejected with ${code ?? 'no error'}` + ); + } + const unchanged = await navigationController.getRouteSegments(); + if ( + unchanged.length !== 1 || + distanceBetween( + unchanged[0]!.destinationLatLng, + replacement.position + ) > LOCATION_THRESHOLD_METERS + ) { + throw new Error('Rejected token request changed the prior route'); + } + } else { + await expectRoute( + navigationController.setDestinationsWithToken({ routeToken }), + [destination.position], + `Token with omitted mode after travel mode ${mode}` + ); + } + await expectRoute( + navigationController.setDestinationsWithToken({ + routeToken, + travelMode: TravelMode.DRIVING, + }), + [destination.position], + `Token with explicit DRIVING after travel mode ${mode}` + ); + } + + // A supported client mode must not override this DRIVE token's mode. + await expectRoute( + navigationController.setDestinationsWithToken({ + routeToken, + travelMode: TravelMode.TWO_WHEELER, + displayOptions: { showDestinationMarkers: false }, + }), + [destination.position], + 'Driving token with TWO_WHEELER client mode and hidden markers' + ); + + await expectRoute( + navigationController.setDestinations([replacementStop, replacement], { + routingOptions: { travelMode: TravelMode.DRIVING }, + }), + [replacementStop.position, replacement.position], + 'Normal multi-stop replacement' + ); + await expectRoute( + navigationController.setDestinationsWithToken({ routeToken }), + [destination.position], + 'Token replacing normal multi-stop route' + ); + await expectRoute( + navigationController.setDestination(replacement), + [replacement.position], + 'Normal route replacing token' + ); + + // Deprecated singular/plural callers must still accept the same token. + const legacyOptions: SetDestinationsOptions = { + routeTokenOptions: { + routeToken, + travelMode: TravelMode.DRIVING, + }, + }; + await expectRoute( + navigationController.setDestination(destination, legacyOptions), + [destination.position], + 'Legacy singular token' + ); + await expectRoute( + navigationController.setDestinations([destination], legacyOptions), + [destination.position], + 'Legacy plural token' + ); + + // Clear must invalidate guidance; a new token must restore it without + // caller-maintained waypoints or a subsequent normal route request. + await navigationController.clearDestinations(); + await expectNoRoute(); + await expectRoute( + navigationController.setDestinationsWithToken({ + routeToken, + displayOptions: { showDestinationMarkers: true }, + }), + [destination.position], + 'Token after clear' + ); + await navigationController.startGuidance(); + const timeAndDistance = + await waitForTimeAndDistance(navigationController); + if (!timeAndDistance || timeAndDistance.meters <= 0) { + throw new Error('Token guidance has no positive remaining distance'); + } + const arrival = await simulateUntilArrival(testTools); + if (!arrival) { + throw new Error('Token route simulation did not emit arrival'); + } if ( - error instanceof Error && - error.message.includes( - 'Only one of routingOptions or routeTokenOptions' - ) + arrival.isFinalDestination === false || + !arrival.waypoint.position || + distanceBetween(arrival.waypoint.position, destination.position) > + LOCATION_THRESHOLD_METERS ) { - try { - await navigationController.cleanup(); - } catch (cleanupError) { - console.error('Cleanup failed', cleanupError); + throw new Error('Token arrival did not identify its final destination'); + } + } catch (error) { + failure = `Token routing failed: ${error}`; + } finally { + setOnArrival(null); + setOnLocationChanged(null); + try { + await navigationController.simulator.stopLocationSimulation(); + await navigationController.cleanup(); + } catch { + failure = failure ?? 'Token routing cleanup failed'; + } + } + if (failure) { + failTest(failure); + } else { + passTest(); + } + }); + if (!(await initializeNavigation(navigationController, failTest))) { + setOnNavigationReady(null); + } +}; + +export const testMultiStopTokenRouting = async (testTools: TestTools) => { + const { + navigationController, + routeToken, + setOnNavigationReady, + setOnLocationChanged, + setOnArrival, + passTest, + failTest, + } = testTools; + if (!routeToken.trim()) { + return failTest( + 'A fresh multi-stop Routes API token is required for testMultiStopTokenRouting' + ); + } + if (!(await acceptToS(navigationController, failTest))) { + return; + } + + // Keep these coordinates in sync with e2e/route_token.e2e.js. + const origin = { lat: 37.791366, lng: -122.415659 }; + const endpoints = [ + { lat: 37.79165, lng: -122.4141 }, + DEFAULT_TEST_WAYPOINT.position, + ]; + setOnNavigationReady(async () => { + setOnNavigationReady(null); + let failure: string | undefined; + try { + disableVoiceGuidanceForTests(navigationController); + const located = await simulateAndWaitForLocation( + navigationController, + setOnLocationChanged, + origin + ); + if (!located) { + throw new Error('Timed out waiting for the multi-stop token origin'); + } + const status = await navigationController.setDestinationsWithToken({ + routeToken, + displayOptions: { showDestinationMarkers: true }, + }); + if (status !== RouteStatus.OK) { + throw new Error(`Multi-stop token returned ${status}`); + } + + for (let index = 0; index < endpoints.length; index++) { + const remaining = endpoints.slice(index); + const segments = await waitForCondition( + () => navigationController.getRouteSegments(), + route => + route.length === remaining.length && + route.every( + (segment, segmentIndex) => + distanceBetween( + segment.destinationLatLng, + remaining[segmentIndex]! + ) <= LOCATION_THRESHOLD_METERS + ) + ); + if (!segments) { + throw new Error(`Token leg ${index} has incorrect endpoints/count`); + } + const current = await navigationController.getCurrentRouteSegment(); + if ( + !current || + distanceBetween(current.destinationLatLng, endpoints[index]!) > + LOCATION_THRESHOLD_METERS + ) { + throw new Error(`Token leg ${index} is not the current SDK route`); + } + + await navigationController.startGuidance(); + const arrival = await simulateUntilArrival(testTools); + const isFinal = index === endpoints.length - 1; + if ( + !arrival?.waypoint.position || + distanceBetween(arrival.waypoint.position, endpoints[index]!) > + LOCATION_THRESHOLD_METERS || + (arrival.isFinalDestination !== undefined && + arrival.isFinalDestination !== isFinal) + ) { + throw new Error( + `Token leg ${index} did not emit its expected arrival` + ); + } + navigationController.simulator.pauseLocationSimulation(); + const continued = + await navigationController.continueToNextDestination(); + if (isFinal) { + if (continued.waypoint !== null) { + throw new Error('Final token continuation retained a destination'); + } + } else if ( + !continued.waypoint?.position || + distanceBetween(continued.waypoint.position, endpoints[index + 1]!) > + LOCATION_THRESHOLD_METERS || + (Platform.OS === 'ios' && continued.routeStatus !== RouteStatus.OK) + ) { + throw new Error('Token continuation did not select the next SDK leg'); } - passTest(); - } else { - failTest(`Unexpected error: ${error}`); } + + const cleared = await waitForNoRoute(navigationController); + if (!cleared) { + throw new Error('Final continuation left an SDK route'); + } + let code: string | undefined; + try { + await navigationController.startGuidance(); + } catch (error) { + code = extractNativeErrorCode(error); + } + if (code !== NO_DESTINATIONS_ERROR_CODE) { + throw new Error('Final continuation did not invalidate guidance'); + } + } catch (error) { + failure = `Multi-stop token routing failed: ${error}`; + } finally { + setOnArrival(null); + setOnLocationChanged(null); + try { + await navigationController.simulator.stopLocationSimulation(); + await navigationController.cleanup(); + } catch { + failure = failure ?? 'Multi-stop token routing cleanup failed'; + } + } + if (failure) { + failTest(failure); + } else { + passTest(); } }); - await initializeNavigation(navigationController, failTest); + if (!(await initializeNavigation(navigationController, failTest))) { + setOnNavigationReady(null); + } }; /** diff --git a/ios/react-native-navigation-sdk/NavModule.h b/ios/react-native-navigation-sdk/NavModule.h index 78f0ebc6..0fb466dd 100644 --- a/ios/react-native-navigation-sdk/NavModule.h +++ b/ios/react-native-navigation-sdk/NavModule.h @@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN +@class NavViewController; + @interface NavModule : NativeNavModuleSpecBase *_destinations; + NSNumber *_tokenRouteShowDestinationMarkers; RCTPromiseResolveBlock _pendingInitResolve; RCTPromiseRejectBlock _pendingInitReject; } @@ -303,6 +303,7 @@ - (void)cleanup:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)re self.enableUpdateInfo = NO; self->_session.started = NO; self->_session = nil; + self->_tokenRouteShowDestinationMarkers = nil; NavViewModule *navViewModule = [NavViewModule sharedInstance]; [navViewModule navigationSessionDestroyed]; @@ -398,7 +399,7 @@ - (void)startGuidance:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBl if (![self checkNavigatorWithError:reject navigator:&navigator]) { return; } - if (self->_destinations != NULL) { + if (navigator.routeLegs.count > 0) { navigator.guidanceActive = YES; [self onStartGuidance]; navigator.sendsBackgroundNotifications = YES; @@ -426,8 +427,10 @@ - (void)simulateLocationsAlongExistingRoute:(LocationSimulationOptionsSpec &)opt reject:(RCTPromiseRejectBlock)reject { LocationSimulationOptionsSpec optionsCopy(options); dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_destinations != nil && self->_session != nil) { + if (self->_session.navigator.routeLegs.count > 0) { [self->_session.locationSimulator setSpeedMultiplier:optionsCopy.speedMultiplier()]; + // Arrival at a stop pauses the SDK simulator; starting the next leg must resume it. + self->_session.locationSimulator.paused = NO; [self->_session.locationSimulator simulateLocationsAlongExistingRoute]; resolve(@(YES)); } else { @@ -456,7 +459,7 @@ - (void)clearDestinations:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseReje } [navigator clearDestinations]; - self->_destinations = NULL; + self->_tokenRouteShowDestinationMarkers = nil; resolve(@(YES)); }); } @@ -510,6 +513,8 @@ - (void)setDestinations:(NSArray *)waypoints return; } + strongSelf->_tokenRouteShowDestinationMarkers = nil; + // Apply display options to views (only if valid flag is set) if (displayOptionsCopy.valid().value_or(false)) { std::optional showDestinationMarkers = displayOptionsCopy.showDestinationMarkers(); @@ -518,7 +523,7 @@ - (void)setDestinations:(NSArray *)waypoints } } - strongSelf->_destinations = [[NSMutableArray alloc] init]; + NSMutableArray *destinations = [[NSMutableArray alloc] init]; for (NSDictionary *wp in waypointsCopy) { GMSNavigationMutableWaypoint *w; @@ -547,7 +552,7 @@ - (void)setDestinations:(NSArray *)waypoints w.preferredHeading = [wp[@"preferredHeading"] intValue]; } - [strongSelf->_destinations addObject:w]; + [destinations addObject:w]; } GMSNavigationRouteCompletionHandler routeCompletionHandler = @@ -587,17 +592,92 @@ - (void)setDestinations:(NSArray *)waypoints GMSNavigationRoutingOptions *gmRoutingOptions = [NavModule routingOptionsWithStrategy:routingStrategy alternateRoutesStrategy:alternateRoutesStrategy]; - [navigator setDestinations:strongSelf->_destinations + [navigator setDestinations:destinations routingOptions:gmRoutingOptions completionHandler:routeCompletionHandler]; } else { // No valid options provided, use defaults - [navigator setDestinations:strongSelf->_destinations - completionHandler:routeCompletionHandler]; + [navigator setDestinations:destinations completionHandler:routeCompletionHandler]; } }); } +- (void)setDestinationsWithToken:(SetDestinationsWithTokenOptionsSpec &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + NSString *routeTokenCopy = [options.routeToken() copy]; + std::optional travelMode = options.travelMode(); + DisplayOptionsSpec displayOptionsCopy(options.displayOptions()); + + __weak __typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + reject(@"internal_error", @"An internal error occurred", nil); + return; + } + + GMSNavigator *navigator = nil; + if (![strongSelf checkNavigatorWithError:reject navigator:&navigator]) { + return; + } + + double clientTravelMode = travelMode.value_or(strongSelf->_session.travelMode); + if (clientTravelMode != GMSNavigationTravelModeDriving && + clientTravelMode != GMSNavigationTravelModeTwoWheeler) { + reject(@"travelModeUnsupported", + @"Route tokens support only DRIVING and TWO_WHEELER travel modes.", nil); + return; + } + + // Preserve the current client mode unless the caller explicitly requests another. + [strongSelf configureNavigatorWithTravelMode:navigator travelMode:travelMode]; + NavViewController *carViewController = [NavAutoModule sharedInstance].viewController; + if (travelMode.has_value()) { + [carViewController setTravelMode:(GMSNavigationTravelMode)travelMode.value()]; + } + + // Unlike the legacy API, every token-only call applies the default when omitted. + strongSelf->_tokenRouteShowDestinationMarkers = + @(displayOptionsCopy.showDestinationMarkers().value_or(true)); + for (NavViewController *viewController in + [[NavViewModule viewControllersRegistry] objectEnumerator]) { + [strongSelf applyTokenRouteDisplayOptionsToViewController:viewController]; + } + if (carViewController != nil) { + [strongSelf applyTokenRouteDisplayOptionsToViewController:carViewController]; + } + + [navigator + setDestinationsWithToken:routeTokenCopy + completionHandler:^(id _Nullable routeDescription, + NSError *_Nullable error) { + __strong __typeof(weakSelf) innerStrongSelf = weakSelf; + if (!innerStrongSelf) return; + if (routeDescription != nil && + routeDescription.routeStatus == GMSRouteStatusTravelModeUnsupported) { + reject(@"travelModeUnsupported", + error.localizedDescription + ?: @"Route tokens support only DRIVING and TWO_WHEELER travel modes.", + error); + return; + } + if (routeDescription == nil) { + reject(kNoRouteStatusErrorCode, + error.localizedDescription ?: kNoRouteStatusErrorMessage, error); + return; + } + resolve([NavModule routeStatusToString:routeDescription.routeStatus]); + }]; + }); +} + +- (void)applyTokenRouteDisplayOptionsToViewController:(NavViewController *)viewController { + if (_tokenRouteShowDestinationMarkers != nil) { + [viewController setShowDestinationMarkersEnabled:[_tokenRouteShowDestinationMarkers boolValue]]; + } +} + - (void)applyNavigationUISettingsShowDestinationMarkers: (std::optional)showDestinationMarkers { for (NavViewController *viewController in diff --git a/ios/react-native-navigation-sdk/NavViewController.mm b/ios/react-native-navigation-sdk/NavViewController.mm index c3bd4caf..25a538e1 100644 --- a/ios/react-native-navigation-sdk/NavViewController.mm +++ b/ios/react-native-navigation-sdk/NavViewController.mm @@ -148,6 +148,7 @@ - (BOOL)attachToNavigationSessionIfNeeded { [self applyStylingOptions]; [self restoreNavigationUIState]; [self applyTrafficPromptsSetting]; + [navModule applyTokenRouteDisplayOptionsToViewController:self]; [self forceInvalidateView]; // Notify state delegate that session is attached diff --git a/src/native/NativeNavModule.ts b/src/native/NativeNavModule.ts index 2535faaa..c0fdaeff 100644 --- a/src/native/NativeNavModule.ts +++ b/src/native/NativeNavModule.ts @@ -76,6 +76,12 @@ type RouteTokenOptionsSpec = Readonly<{ travelMode?: CodegenTypes.Double; }>; +type SetDestinationsWithTokenOptionsSpec = Readonly<{ + routeToken: string; + travelMode?: CodegenTypes.Double; + displayOptions: DisplayOptionsSpec; +}>; + type SpeedAlertOptionsSpec = Readonly<{ valid?: CodegenTypes.WithDefault; majorSpeedAlertPercentThreshold: CodegenTypes.Float; @@ -185,6 +191,9 @@ export interface Spec extends TurboModule { displayOptions: DisplayOptionsSpec, routeTokenOptions: RouteTokenOptionsSpec ): Promise; + setDestinationsWithToken( + options: SetDestinationsWithTokenOptionsSpec + ): Promise; continueToNextDestination(): Promise; clearDestinations(): Promise; startGuidance(): Promise; diff --git a/src/navigation/navigation/types.ts b/src/navigation/navigation/types.ts index a1cb74e7..79ecbeb5 100644 --- a/src/navigation/navigation/types.ts +++ b/src/navigation/navigation/types.ts @@ -44,6 +44,8 @@ import { NavigationSessionStatus } from '../types'; * Pass the same waypoints that were used to request the route token. On iOS the * route token alone defines the destinations, and the waypoints are not sent to * the Navigation SDK. + * + * @deprecated Use {@link NavigationController.setDestinationsWithToken} instead. */ export interface RouteTokenOptions { /** @@ -80,10 +82,46 @@ export interface SetDestinationsOptions { /** * Options for using a pre-computed route token from the Routes API. * Cannot be used with routingOptions. + * + * @deprecated Use {@link NavigationController.setDestinationsWithToken} instead. */ routeTokenOptions?: RouteTokenOptions; } +/** + * Options for replacing destinations with a route encoded in a route token. + */ +export interface SetDestinationsWithTokenOptions { + /** + * A route token from the Routes API or Route Optimization API. + * Tokens support DRIVE or TWO_WHEELER routes. Destinations and routing + * preferences, including travel mode, come from the token and take precedence + * over supported client-side travel mode settings. + */ + routeToken: string; + + /** + * Optional client-side travel mode. Only TravelMode.DRIVING and + * TravelMode.TWO_WHEELER are supported; other values reject with error code + * `travelModeUnsupported` before changing the current route. + * + * On iOS, omitting this preserves the current client travel mode. If that mode + * is unsupported (for example, after walking or cycling), the call rejects + * with `travelModeUnsupported`; pass an explicit supported mode to switch. + * On Android, omitting this uses the token's routing mode. + * + * A supported client travel mode does not override the mode encoded in the + * route token. + */ + travelMode?: TravelMode; + + /** + * Route display options. Destination markers are shown by default, including + * when displayOptions or showDestinationMarkers is omitted. + */ + displayOptions?: DisplayOptions; +} + /** * Defines the options used by the Navigator for calculating a route to a destination. */ @@ -186,7 +224,8 @@ export interface ContinueToNextDestinationResponse { */ export interface ArrivalEvent { /** - * Waypoint the device has arrived to. This waypoint was used when setDestinations was called. + * The destination reported by the Navigation SDK. For token routes, the + * destination comes from the token rather than caller-provided waypoints. */ waypoint: Waypoint; @@ -279,7 +318,8 @@ export interface NavigationCallbacks { */ export interface Simulator { /** - * Initiates a simulation of the navigation with a specified speed multiplier. + * Starts or resumes simulation along the current route with a specified speed multiplier. + * Requires a successfully calculated route, including after continueToNextDestination. * @param speedMultiplier - Multiplier to speed up the simulation. */ simulateLocationsAlongExistingRoute(options: LocationSimulationOptions): void; @@ -453,6 +493,29 @@ export interface NavigationController { options?: SetDestinationsOptions ): Promise; + /** + * Replaces the current destinations with a route encoded in a route token. + * Call after navigation initialization. Destinations and routing preferences, + * including travel mode, come from the token; a supported client travel mode + * does not override the token's mode. + * + * @param options - Route token, optional client travel mode, and optional display + * options. Only TravelMode.DRIVING and TravelMode.TWO_WHEELER + * are supported. On iOS, an omitted travelMode preserves the + * current client mode; pass a supported mode explicitly after + * walking or cycling. On Android, omission uses token routing. + * Destination markers are shown by default. + * @returns The SDK's route calculation status. A successful status does not + * guarantee exact replay when the starting location or road conditions change. + * Rejects if navigation is unavailable, with `travelModeUnsupported` + * before changing routes if an explicit travelMode is unsupported + * (or the inherited iOS client mode is unsupported), with `noRouteStatus` + * if the SDK returns no route status, or when the native call fails. + */ + setDestinationsWithToken( + options: SetDestinationsWithTokenOptions + ): Promise; + /** * Proceeds to the next destination or waypoint within a predefined route. * Assumes that there is an ongoing route with multiple waypoints. diff --git a/src/navigation/navigation/useNavigationController.ts b/src/navigation/navigation/useNavigationController.ts index fdde40d1..d91d33be 100644 --- a/src/navigation/navigation/useNavigationController.ts +++ b/src/navigation/navigation/useNavigationController.ts @@ -35,6 +35,7 @@ import { TaskRemovedBehavior, type TurnByTurnEvent, type SetDestinationsOptions, + type SetDestinationsWithTokenOptions, type TermsAndConditionsDialogOptions, type NavigationController, type SpeedAlertOptions, @@ -446,6 +447,22 @@ export const useNavigationController = ( return await setDestinationsImpl(waypoints, options); }, + setDestinationsWithToken: async ( + options: SetDestinationsWithTokenOptions + ) => { + const result = await NavModule.setDestinationsWithToken({ + routeToken: options.routeToken, + travelMode: options.travelMode, + displayOptions: { + ...options.displayOptions, + valid: true, + showDestinationMarkers: + options.displayOptions?.showDestinationMarkers ?? true, + }, + }); + return result as RouteStatus; + }, + continueToNextDestination: async (): Promise => { const result = await NavModule.continueToNextDestination();