diff --git a/README.md b/README.md index 41dd46b..20b381e 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ try { showTrafficLights: true, }; - await navigationController.setDestinations([waypoint], routingOptions, displayOptions); + await navigationController.setDestinations([waypoint], { routingOptions, displayOptions }); await navigationController.startGuidance(); } catch (error) { console.error('Error starting navigation', error); @@ -240,6 +240,33 @@ try { > > To avoid this, ensure that the SDK has provided a valid user location before calling the setDestinations function. You can do this by subscribing to the onLocationChanged navigation callback and waiting for the first valid location update. +#### 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. + +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 + +```tsx +const waypoint = { + title: 'Destination', + position: { lat: 37.7749, lng: -122.4194 }, +}; + +const routeTokenOptions = { + routeToken: 'your-route-token-from-routes-api', + travelMode: TravelMode.DRIVING, // Must match the travel mode used to generate the token +}; + +await navigationController.setDestinations([waypoint], { routeTokenOptions }); +await navigationController.startGuidance(); +``` + +> [!IMPORTANT] +> `routingOptions` and `routeTokenOptions` are mutually exclusive. Providing both will throw an error. + #### Adding navigation listeners diff --git a/android/src/main/java/com/google/android/react/navsdk/EnumTranslationUtil.java b/android/src/main/java/com/google/android/react/navsdk/EnumTranslationUtil.java index 4dc7e98..160cd58 100644 --- a/android/src/main/java/com/google/android/react/navsdk/EnumTranslationUtil.java +++ b/android/src/main/java/com/google/android/react/navsdk/EnumTranslationUtil.java @@ -19,6 +19,7 @@ import com.google.android.libraries.navigation.AlternateRoutesStrategy; import com.google.android.libraries.navigation.ForceNightMode; import com.google.android.libraries.navigation.Navigator; +import com.google.android.libraries.navigation.RoutingOptions; public class EnumTranslationUtil { public static AlternateRoutesStrategy getAlternateRoutesStrategyFromJsValue(int jsValue) { @@ -108,4 +109,19 @@ public static CustomTypes.MapViewType getMapViewTypeFromJsValue(int jsValue) { return MapColorScheme.FOLLOW_SYSTEM; } } + + public static @RoutingOptions.TravelMode int getTravelModeFromJsValue(int jsValue) { + switch (jsValue) { + case 1: + return RoutingOptions.TravelMode.CYCLING; + case 2: + return RoutingOptions.TravelMode.WALKING; + case 3: + return RoutingOptions.TravelMode.TWO_WHEELER; + case 4: + return RoutingOptions.TravelMode.TAXI; + default: + return RoutingOptions.TravelMode.DRIVING; + } + } } 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 0a1b4b0..3080c95 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 @@ -36,6 +36,8 @@ import com.google.android.libraries.mapsplatform.turnbyturn.model.NavInfo; import com.google.android.libraries.mapsplatform.turnbyturn.model.StepInfo; import com.google.android.libraries.navigation.ArrivalEvent; +import com.google.android.libraries.navigation.CustomRoutesOptions; +import com.google.android.libraries.navigation.DisplayOptions; import com.google.android.libraries.navigation.ListenableResultFuture; import com.google.android.libraries.navigation.NavigationApi; import com.google.android.libraries.navigation.NavigationApi.OnTermsResponseListener; @@ -43,6 +45,7 @@ import com.google.android.libraries.navigation.RoadSnappedLocationProvider; import com.google.android.libraries.navigation.RoadSnappedLocationProvider.LocationListener; import com.google.android.libraries.navigation.RouteSegment; +import com.google.android.libraries.navigation.RoutingOptions; import com.google.android.libraries.navigation.SimulationOptions; import com.google.android.libraries.navigation.SpeedAlertOptions; import com.google.android.libraries.navigation.SpeedAlertSeverity; @@ -429,22 +432,12 @@ private void createWaypoint(Map map) { } } - @ReactMethod - public void setDestination( - ReadableMap waypoint, - @Nullable ReadableMap routingOptions, - @Nullable ReadableMap displayOptions, - final Promise promise) { - WritableArray array = new WritableNativeArray(); - array.pushMap(waypoint); - setDestinations(array, routingOptions, displayOptions, promise); - } - @ReactMethod public void setDestinations( ReadableArray waypoints, @Nullable ReadableMap routingOptions, @Nullable ReadableMap displayOptions, + @Nullable ReadableMap routeTokenOptions, final Promise promise) { if (!ensureNavigatorAvailable(promise)) { return; @@ -459,19 +452,44 @@ public void setDestinations( createWaypoint(map); } - if (routingOptions != null) { - if (displayOptions != null) { + // Get display options if provided + DisplayOptions parsedDisplayOptions = + displayOptions != null + ? ObjectTranslationUtil.getDisplayOptionsFromMap(displayOptions.toHashMap()) + : null; + + // If route token options are provided, use CustomRoutesOptions + if (routeTokenOptions != null) { + CustomRoutesOptions customRoutesOptions; + try { + customRoutesOptions = + ObjectTranslationUtil.getCustomRoutesOptionsFromMap(routeTokenOptions.toHashMap()); + } catch (IllegalStateException e) { + promise.reject("routeTokenMalformed", "The route token passed is malformed", e); + return; + } + + if (parsedDisplayOptions != null) { pendingRoute = - mNavigator.setDestinations( - mWaypoints, - ObjectTranslationUtil.getRoutingOptionsFromMap(routingOptions.toHashMap()), - ObjectTranslationUtil.getDisplayOptionsFromMap(displayOptions.toHashMap())); + mNavigator.setDestinations(mWaypoints, customRoutesOptions, parsedDisplayOptions); } else { + pendingRoute = mNavigator.setDestinations(mWaypoints, customRoutesOptions); + } + } else if (routingOptions != null) { + RoutingOptions parsedRoutingOptions = + ObjectTranslationUtil.getRoutingOptionsFromMap(routingOptions.toHashMap()); + + if (parsedDisplayOptions != null) { pendingRoute = - mNavigator.setDestinations( - mWaypoints, - ObjectTranslationUtil.getRoutingOptionsFromMap(routingOptions.toHashMap())); + mNavigator.setDestinations(mWaypoints, parsedRoutingOptions, parsedDisplayOptions); + } else { + pendingRoute = mNavigator.setDestinations(mWaypoints, parsedRoutingOptions); } + } else if (parsedDisplayOptions != null) { + // No routing options provided: use defaults, but still honor display options if + // supplied. + pendingRoute = + mNavigator.setDestinations(mWaypoints, new RoutingOptions(), parsedDisplayOptions); } else { pendingRoute = mNavigator.setDestinations(mWaypoints); } diff --git a/android/src/main/java/com/google/android/react/navsdk/ObjectTranslationUtil.java b/android/src/main/java/com/google/android/react/navsdk/ObjectTranslationUtil.java index c44cdca..0b18fdd 100644 --- a/android/src/main/java/com/google/android/react/navsdk/ObjectTranslationUtil.java +++ b/android/src/main/java/com/google/android/react/navsdk/ObjectTranslationUtil.java @@ -26,6 +26,7 @@ import com.google.android.gms.maps.model.Polyline; import com.google.android.libraries.mapsplatform.turnbyturn.model.StepInfo; import com.google.android.libraries.navigation.AlternateRoutesStrategy; +import com.google.android.libraries.navigation.CustomRoutesOptions; import com.google.android.libraries.navigation.DisplayOptions; import com.google.android.libraries.navigation.NavigationRoadStretchRenderingData; import com.google.android.libraries.navigation.RouteSegment; @@ -146,8 +147,9 @@ public static RoutingOptions getRoutingOptionsFromMap(Map map) { } if (map.containsKey("travelMode")) { - options.travelMode( - CollectionUtil.getInt("travelMode", map, RoutingOptions.TravelMode.DRIVING)); + int travelModeJsValue = + CollectionUtil.getInt("travelMode", map, RoutingOptions.TravelMode.DRIVING); + options.travelMode(EnumTranslationUtil.getTravelModeFromJsValue(travelModeJsValue)); } if (map.containsKey("routingStrategy")) { @@ -168,6 +170,21 @@ public static RoutingOptions getRoutingOptionsFromMap(Map map) { return options; } + public static CustomRoutesOptions getCustomRoutesOptionsFromMap(Map map) + throws IllegalStateException { + String routeToken = CollectionUtil.getString("routeToken", map); + + CustomRoutesOptions.Builder builder = CustomRoutesOptions.builder().setRouteToken(routeToken); + + if (map.containsKey("travelMode")) { + int travelModeJsValue = + CollectionUtil.getInt("travelMode", map, RoutingOptions.TravelMode.DRIVING); + builder.setTravelMode(EnumTranslationUtil.getTravelModeFromJsValue(travelModeJsValue)); + } + + return builder.build(); + } + public static LatLng getLatLngFromMap(Map map) { if (map.get(Constants.LAT_FIELD_KEY) == null || map.get(Constants.LNG_FIELD_KEY) == null) { return null; diff --git a/example/e2e/navigation.test.js b/example/e2e/navigation.test.js index efd52ae..04fff4f 100644 --- a/example/e2e/navigation.test.js +++ b/example/e2e/navigation.test.js @@ -82,4 +82,12 @@ describe('Navigation tests', () => { await expectNoErrors(); await expectSuccess(); }); + + it('T08 - setDestinations with both routingOptions and routeTokenOptions should throw error', async () => { + await selectTestByName('testRouteTokenOptionsValidation'); + await agreeToTermsAndConditions(); + await waitForTestToFinish(); + await expectNoErrors(); + await expectSuccess(); + }); }); diff --git a/example/e2e/shared.js b/example/e2e/shared.js index 0903302..35a24eb 100644 --- a/example/e2e/shared.js +++ b/example/e2e/shared.js @@ -71,6 +71,14 @@ export const initializeIntegrationTestsPage = async () => { }; export const selectTestByName = async name => { + await waitFor(element(by.id('tests_menu_button'))) + .toBeVisible() + .withTimeout(10000); await element(by.id('tests_menu_button')).tap(); + // Scroll to make the test button visible before tapping + await waitFor(element(by.id(name))) + .toBeVisible() + .whileElement(by.id('overlay_scroll_view')) + .scroll(100, 'down'); await element(by.id(name)).tap(); }; diff --git a/example/ios/SampleApp.xcodeproj/project.pbxproj b/example/ios/SampleApp.xcodeproj/project.pbxproj index bee13b1..5754433 100644 --- a/example/ios/SampleApp.xcodeproj/project.pbxproj +++ b/example/ios/SampleApp.xcodeproj/project.pbxproj @@ -641,12 +641,12 @@ CODE_SIGN_ENTITLEMENTS = SampleApp/SampleApp.entitlements; CURRENT_PROJECT_VERSION = 1; ENABLE_BITCODE = NO; - INFOPLIST_FILE = "SampleApp/Info-CarPlay.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "CARPLAY=1", ); + INFOPLIST_FILE = "SampleApp/Info-CarPlay.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -674,12 +674,12 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = SampleApp/SampleApp.entitlements; CURRENT_PROJECT_VERSION = 1; - INFOPLIST_FILE = "SampleApp/Info-CarPlay.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "CARPLAY=1", ); + INFOPLIST_FILE = "SampleApp/Info-CarPlay.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/package.json b/example/package.json index f37bd4e..e9af6b8 100644 --- a/example/package.json +++ b/example/package.json @@ -47,7 +47,7 @@ "@react-native/typescript-config": "0.81.1", "@types/jest": "^29.5.14", "@types/node": "^22.9.0", - "detox": "^20.27.6", + "detox": "^20.46.3", "jest": "^29.7.0", "react-native-builder-bob": "^0.40.13", "react-native-monorepo-config": "^0.1.9", diff --git a/example/src/App.tsx b/example/src/App.tsx index ce2eaff..0722cf6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -29,6 +29,7 @@ import { ExampleAppButton } from './controls/ExampleAppButton'; import NavigationScreen from './screens/NavigationScreen'; import MultipleMapsScreen from './screens/MultipleMapsScreen'; import MapIdScreen from './screens/MapIdScreen'; +import RouteTokenScreen from './screens/RouteTokenScreen'; import { NavigationProvider, TaskRemovedBehavior, @@ -41,6 +42,7 @@ export type ScreenNames = [ 'Navigation', 'Multiple maps', 'Map ID', + 'Route Token', 'Integration tests', ]; @@ -70,7 +72,9 @@ const HomeScreen = () => { }, [navigationController]); return ( - + {/* SDK Version Display */} @@ -95,6 +99,12 @@ const HomeScreen = () => { onPress={() => isFocused && navigate('Map ID')} /> + + isFocused && navigate('Route Token')} + /> + + { @@ -166,11 +165,10 @@ const NavigationControls = ({ showTrafficLights: true, }; - navigationController.setDestinations( - waypoints, + navigationController.setDestinations(waypoints, { routingOptions, - displayOptions - ); + displayOptions, + }); }; const setFollowingPerspective = (index: CameraPerspective) => { diff --git a/example/src/helpers/overlayModal.tsx b/example/src/helpers/overlayModal.tsx index 26855da..a77846e 100644 --- a/example/src/helpers/overlayModal.tsx +++ b/example/src/helpers/overlayModal.tsx @@ -47,6 +47,7 @@ const OverlayModal: React.FC = ({ { + const output: Record = { via }; + + // Check if it's a Waypoint with placeId + if ('placeId' in waypoint && waypoint.placeId) { + output.placeId = waypoint.placeId; + } else { + // Handle LatLng or Waypoint with position + let lat: number; + let lng: number; + + if ('position' in waypoint && waypoint.position) { + lat = waypoint.position.lat; + lng = waypoint.position.lng; + } else if ('lat' in waypoint && 'lng' in waypoint) { + lat = waypoint.lat; + lng = waypoint.lng; + } else { + throw new Error( + 'Invalid waypoint: Either position or placeId must be provided.' + ); + } + + const location: Record = { + latLng: { + latitude: lat, + longitude: lng, + }, + }; + + // Add preferred heading if available + if ('preferredHeading' in waypoint && waypoint.preferredHeading != null) { + location.heading = waypoint.preferredHeading; + } + + output.location = location; + } + + return output; +} + +/** + * Queries the Google Maps Routes API and returns a list of route tokens. + * + * @param apiKey - The Google Maps API key with Routes API enabled. + * @param waypoints - A list of waypoints representing the route (minimum 2: origin and destination). + * @param options - Optional configuration for the route request. + * @returns A promise that resolves to a list of route tokens. + * @throws Error if the request fails or returns no route tokens. + * + * @example + * ```typescript + * const tokens = await getRouteToken( + * 'YOUR_API_KEY', + * [ + * { lat: 37.7749, lng: -122.4194 }, // Origin + * { lat: 37.3382, lng: -121.8863 }, // Destination + * ], + * { travelMode: 'DRIVE' } + * ); + * ``` + */ +export async function getRouteToken( + apiKey: string, + waypoints: (Waypoint | LatLng)[], + options: RoutesApiOptions = {} +): Promise { + if (!apiKey || apiKey.trim() === '') { + throw new Error( + 'API key is required. Please provide a valid Google Maps API key.' + ); + } + + if (waypoints.length < 2) { + throw new Error( + 'At least two waypoints (origin and destination) are required.' + ); + } + + const { travelMode = 'DRIVE', routingPreference = 'TRAFFIC_AWARE' } = options; + + const origin = waypoints[0]!; + const destination = waypoints[waypoints.length - 1]!; + const intermediates = waypoints.slice(1, -1); + + const requestBody: Record = { + origin: toRoutesApiWaypoint(origin), + destination: toRoutesApiWaypoint(destination), + intermediates: intermediates.map(wp => toRoutesApiWaypoint(wp, true)), + travelMode, + routingPreference, + }; + + const headers: Record = { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'routes.routeToken', + 'Content-Type': 'application/json', + }; + + const response = await fetch(COMPUTE_ROUTES_URL, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to get route tokens: ${response.statusText}\n${errorText}` + ); + } + + const responseData = (await response.json()) as { + routes?: { routeToken: string }[]; + }; + const routes = responseData.routes; + + if (!routes || routes.length === 0) { + throw new Error('No routes returned from the Routes API.'); + } + + return routes.map(route => route.routeToken); +} diff --git a/example/src/screens/IntegrationTestsScreen.tsx b/example/src/screens/IntegrationTestsScreen.tsx index ed56fe4..eccbb79 100644 --- a/example/src/screens/IntegrationTestsScreen.tsx +++ b/example/src/screens/IntegrationTestsScreen.tsx @@ -49,6 +49,7 @@ import { testOnRouteChanged, testNavigationStateGuards, testStartGuidanceWithoutDestinations, + testRouteTokenOptionsValidation, NO_ERRORS_DETECTED_LABEL, } from './integration_tests/integration_test'; @@ -221,6 +222,9 @@ const IntegrationTestsScreen = () => { case 'testStartGuidanceWithoutDestinations': await testStartGuidanceWithoutDestinations(getTestTools()); break; + case 'testRouteTokenOptionsValidation': + await testRouteTokenOptionsValidation(getTestTools()); + break; default: resetTestState(); break; @@ -239,7 +243,9 @@ const IntegrationTestsScreen = () => { }, [windowDimensions.height, insets.top, insets.bottom]); return ( - + See CONTRIBUTING.md to see how to run integration tests. { }} testID="testStartGuidanceWithoutDestinations" /> + { + runTest('testRouteTokenOptionsValidation'); + }} + testID="testRouteTokenOptionsValidation" + /> ); diff --git a/example/src/screens/RouteTokenScreen.tsx b/example/src/screens/RouteTokenScreen.tsx new file mode 100644 index 0000000..63c3931 --- /dev/null +++ b/example/src/screens/RouteTokenScreen.tsx @@ -0,0 +1,532 @@ +/** + * 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 React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { + View, + Text, + TextInput, + StyleSheet, + Alert, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { ExampleAppButton } from '../controls/ExampleAppButton'; +import { CommonStyles, MapStyles } from '../styles/components'; +import { + NavigationView, + TravelMode, + type NavigationViewController, + type NavigationCallbacks, + type MapViewCallbacks, + type NavigationViewCallbacks, + type Waypoint, + type RouteTokenOptions, + type LatLng, + RouteStatus, + useNavigation, +} from '@googlemaps/react-native-navigation-sdk'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import usePermissions from '../checkPermissions'; +import SelectDropdown from 'react-native-select-dropdown'; +import Snackbar from 'react-native-snackbar'; +import { getRouteToken, type RoutesApiTravelMode } from '../helpers/routesApi'; + +// Fixed locations for the route token example +const ORIGIN_LOCATION: LatLng = { + lat: 37.422, + lng: -122.084, +}; // Googleplex, Mountain View + +const DESTINATION_LOCATION: LatLng = { + lat: 37.7749, + lng: -122.4194, +}; // San Francisco + +const showSnackbar = (text: string, duration = Snackbar.LENGTH_SHORT) => { + Snackbar.show({ text, duration }); +}; + +const RouteTokenScreen = () => { + const [routeTokenInput, setRouteTokenInput] = useState(''); + const [confirmedRouteToken, setConfirmedRouteToken] = useState( + null + ); + const [navigationViewController, setNavigationViewController] = + useState(null); + const [travelMode, setTravelMode] = useState(TravelMode.DRIVING); + // API key for Routes API + const [apiKey, setApiKey] = useState(''); + const [isFetchingToken, setIsFetchingToken] = useState(false); + const insets = useSafeAreaInsets(); + const { arePermissionsApproved } = usePermissions(); + const { navigationController, addListeners, removeListeners } = + useNavigation(); + + const travelModeOptions = [ + { label: 'Driving', value: TravelMode.DRIVING }, + { label: 'Cycling', value: TravelMode.CYCLING }, + { label: 'Walking', value: TravelMode.WALKING }, + { label: 'Two Wheeler', value: TravelMode.TWO_WHEELER }, + { label: 'Taxi', value: TravelMode.TAXI }, + ]; + + // Map TravelMode to Routes API travel mode + const getRoutesApiTravelMode = (mode: TravelMode): RoutesApiTravelMode => { + switch (mode) { + case TravelMode.CYCLING: + return 'BICYCLE'; + case TravelMode.WALKING: + return 'WALK'; + case TravelMode.TWO_WHEELER: + return 'TWO_WHEELER'; + case TravelMode.DRIVING: + case TravelMode.TAXI: + default: + return 'DRIVE'; + } + }; + + const handleFetchRouteToken = useCallback(async () => { + if (apiKey.trim() === '') { + Alert.alert('Missing API Key', 'Please enter your Google Maps API key.'); + return; + } + + setIsFetchingToken(true); + try { + const tokens = await getRouteToken( + apiKey.trim(), + [ORIGIN_LOCATION, DESTINATION_LOCATION], + { travelMode: getRoutesApiTravelMode(travelMode) } + ); + + if (tokens.length > 0) { + setRouteTokenInput(tokens[0]!); + showSnackbar('Route token fetched successfully'); + } else { + Alert.alert('No Route Found', 'The Routes API returned no routes.'); + } + } catch (error) { + console.error('Error fetching route token:', error); + Alert.alert( + 'Error', + `Failed to fetch route token: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsFetchingToken(false); + } + }, [apiKey, travelMode]); + + const handleSetRouteToken = useCallback(() => { + if (routeTokenInput.trim() === '') { + Alert.alert('Empty Route Token', 'Please enter a valid route token.'); + return; + } + setConfirmedRouteToken(routeTokenInput.trim()); + }, [routeTokenInput]); + + const handleReset = useCallback(() => { + setConfirmedRouteToken(null); + }, []); + + const onNavigationReady = useCallback(async () => { + if (navigationViewController != null && confirmedRouteToken != null) { + await navigationViewController.setNavigationUIEnabled(true); + console.log('Navigation ready, setting route with token'); + + // 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, + }; + + try { + await navigationController.setDestination(waypoint, { + displayOptions: { showDestinationMarkers: true }, + routeTokenOptions, + }); + } catch (error) { + console.error('Error setting destination with route token:', error); + Alert.alert( + 'Route Token Error', + 'Failed to set destination with route token. The token may be malformed or expired.' + ); + } + } + }, [ + navigationViewController, + confirmedRouteToken, + travelMode, + navigationController, + ]); + + const onNavigationMapReady = useCallback(async () => { + console.log( + 'NavigationView map is ready, initializing navigation session...' + ); + try { + await navigationController.init(); + console.log('Navigation session initialized successfully'); + } catch (error) { + console.error('Error initializing navigation session', error); + Alert.alert( + 'Navigation Error', + 'Failed to initialize navigation session' + ); + } + }, [navigationController]); + + const onRouteStatusResult = useCallback((routeStatus: RouteStatus) => { + switch (routeStatus) { + case RouteStatus.OK: + showSnackbar('Route created from token'); + break; + case RouteStatus.ROUTE_CANCELED: + showSnackbar('Error: Route Cancelled'); + break; + case RouteStatus.NO_ROUTE_FOUND: + showSnackbar('Error: No Route Found'); + break; + case RouteStatus.NETWORK_ERROR: + showSnackbar('Error: Network Error'); + break; + default: + console.log('routeStatus: ' + routeStatus); + showSnackbar('Error: Route creation failed'); + } + }, []); + + const navigationCallbacks: NavigationCallbacks = useMemo( + () => ({ + onNavigationReady, + onRouteStatusResult, + }), + [onNavigationReady, onRouteStatusResult] + ); + + const navigationMapViewCallbacks: MapViewCallbacks = useMemo( + () => ({ + onMapReady: onNavigationMapReady, + }), + [onNavigationMapReady] + ); + + const navigationViewCallbacks: NavigationViewCallbacks = useMemo( + () => ({ + onRecenterButtonClick: () => console.log('Recenter button clicked'), + }), + [] + ); + + useEffect(() => { + addListeners(navigationCallbacks); + return () => { + removeListeners(navigationCallbacks); + }; + }, [navigationCallbacks, addListeners, removeListeners]); + + const startGuidance = useCallback(async () => { + try { + await navigationController.startGuidance(); + // Start simulating location along the route + navigationController.simulator.simulateLocationsAlongExistingRoute({ + speedMultiplier: 5, + }); + showSnackbar('Guidance and simulation started'); + } catch (error) { + console.error('Error starting guidance:', error); + Alert.alert('Error', 'Failed to start guidance'); + } + }, [navigationController]); + + const stopGuidance = useCallback(async () => { + try { + // Stop simulation and reset to start location + navigationController.simulator.stopLocationSimulation(); + navigationController.simulator.simulateLocation(ORIGIN_LOCATION); + await navigationController.stopGuidance(); + showSnackbar('Guidance stopped, location reset to origin'); + } catch (error) { + console.error('Error stopping guidance:', error); + } + }, [navigationController]); + + if (!arePermissionsApproved) { + return ( + + + Location permissions are required to use this feature. + + + ); + } + + return ( + + {confirmedRouteToken === null ? ( + // Configuration screen + + Route Token Example + + Fetch a route token from the Google Maps Routes API or paste an + existing token to navigate using a pre-computed route. + + + {/* Routes API Section */} + + Fetch Route Token + + + Maps API Key: + + + + {/* Fixed Route Info */} + + Fixed Route: + + Origin: Googleplex, Mountain View ({ORIGIN_LOCATION.lat},{' '} + {ORIGIN_LOCATION.lng}) + + + Destination: San Francisco ({DESTINATION_LOCATION.lat},{' '} + {DESTINATION_LOCATION.lng}) + + + + + Travel Mode: + setTravelMode(selectedItem.value)} + defaultValue={travelModeOptions[0]} + renderButton={selectedItem => ( + + + {selectedItem?.label || 'Select travel mode'} + + + )} + renderItem={item => ( + + {item.label} + + )} + /> + + + + {isFetchingToken ? ( + + ) : ( + + )} + + + + {/* Divider */} + + + OR + + + + {/* Manual Token Entry Section */} + + Paste Route Token + + + Route Token: + + + + + + + + + + Note: + + The travel mode must match what was used to generate the token. + {'\n\n'} + The user location will be simulated at the origin when navigation + starts. + + + + ) : ( + // Navigation display screen + + + Route Token Navigation + + + + {/* NavigationView */} + + {}} + /> + + + {/* Control buttons */} + + + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + content: { + flexGrow: 1, + }, + configContainer: { + flex: 1, + padding: 20, + }, + mapsContainer: { + flex: 1, + }, + routeTokenInput: { + height: 100, + textAlignVertical: 'top', + }, + controlsContainer: { + padding: 12, + backgroundColor: '#fff', + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-evenly', + marginBottom: 10, + }, + dropdownButton: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + dropdownButtonText: { + fontSize: 16, + color: '#333', + }, + dropdownItem: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + dropdownItemText: { + fontSize: 16, + color: '#333', + }, + sectionContainer: { + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginBottom: 12, + }, + routeInfoContainer: { + backgroundColor: '#f5f5f5', + padding: 12, + borderRadius: 8, + marginBottom: 12, + }, + routeInfoTitle: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginBottom: 4, + }, + routeInfoText: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, + dividerContainer: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 20, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: '#ccc', + }, + dividerText: { + marginHorizontal: 16, + fontSize: 14, + color: '#666', + fontWeight: '500', + }, +}); + +export default RouteTokenScreen; diff --git a/example/src/screens/integration_tests/integration_test.ts b/example/src/screens/integration_tests/integration_test.ts index 3b563c4..32064eb 100644 --- a/example/src/screens/integration_tests/integration_test.ts +++ b/example/src/screens/integration_tests/integration_test.ts @@ -310,9 +310,11 @@ export const testNavigationToSingleDestination = async ( }, ], { - travelMode: TravelMode.DRIVING, - avoidFerries: true, - avoidTolls: false, + routingOptions: { + travelMode: TravelMode.DRIVING, + avoidFerries: true, + avoidTolls: false, + }, } ); await navigationController.startGuidance(); @@ -375,9 +377,11 @@ export const testNavigationToMultipleDestination = async ( }, ], { - travelMode: TravelMode.DRIVING, - avoidFerries: true, - avoidTolls: false, + routingOptions: { + travelMode: TravelMode.DRIVING, + avoidFerries: true, + avoidTolls: false, + }, } ); await navigationController.startGuidance(); @@ -914,3 +918,59 @@ export const testStartGuidanceWithoutDestinations = async ( failTest('navigationController.init() exception'); } }; + +/** + * Tests that providing both routingOptions and routeTokenOptions throws an error. + * These options are mutually exclusive and should not be used together. + */ +export const testRouteTokenOptionsValidation = async (testTools: TestTools) => { + const { navigationController, addListeners, passTest, failTest } = testTools; + + addListeners({ + onNavigationReady: async () => { + disableVoiceGuidanceForTests(navigationController); + + try { + // Attempt to provide both routingOptions and routeTokenOptions + await navigationController.setDestinations([DEFAULT_TEST_WAYPOINT], { + routingOptions: { travelMode: TravelMode.DRIVING }, + routeTokenOptions: { + routeToken: 'some-token', + travelMode: TravelMode.DRIVING, + }, + }); + failTest( + 'Expected error when both routingOptions and routeTokenOptions provided' + ); + } catch (error) { + // Should throw JS error about mutual exclusivity + if ( + error instanceof Error && + error.message.includes( + 'Only one of routingOptions or routeTokenOptions' + ) + ) { + try { + await navigationController.cleanup(); + } catch (cleanupError) { + console.error('cleanup failed', cleanupError); + } + passTest(); + } else { + failTest(`Unexpected error: ${error}`); + } + } + }, + onNavigationInitError: (errorCode: NavigationInitErrorCode) => { + console.log(errorCode); + failTest(`onNavigationInitError: ${errorCode}`); + }, + }); + + try { + await navigationController.init(); + } catch (error) { + console.error('Error initializing navigator', error); + failTest('navigationController.init() exception'); + } +}; diff --git a/ios/react-native-navigation-sdk/NavModule.m b/ios/react-native-navigation-sdk/NavModule.m index 4953169..632d885 100644 --- a/ios/react-native-navigation-sdk/NavModule.m +++ b/ios/react-native-navigation-sdk/NavModule.m @@ -324,24 +324,11 @@ - (void)showTermsAndConditionsDialog { }); } -RCT_EXPORT_METHOD(setDestination - : (nonnull NSDictionary *)waypoint routingOptions - : (NSDictionary *)routingOptions displayOptions - : (NSDictionary *)displayOptions resolve - : (RCTPromiseResolveBlock)resolve rejecter - : (RCTPromiseRejectBlock)reject) { - NSArray *waypoints = @[ waypoint ]; - [self setDestinations:waypoints - routingOptions:routingOptions - displayOptions:displayOptions - resolve:resolve - rejecter:reject]; -} - RCT_EXPORT_METHOD(setDestinations : (nonnull NSArray *)waypoints routingOptions : (NSDictionary *)routingOptions displayOptions - : (NSDictionary *)displayOptions resolve + : (NSDictionary *)displayOptions routeTokenOptions + : (NSDictionary *)routeTokenOptions resolve : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { __weak typeof(self) weakSelf = self; @@ -400,7 +387,13 @@ - (void)showTermsAndConditionsDialog { resolve(@(YES)); }; - if (routingOptions != NULL) { + // If route token options are provided, use route token for navigation + if (routeTokenOptions != NULL && routeTokenOptions[@"routeToken"] != nil) { + [strongSelf configureNavigator:navigator withRouteTokenOptions:routeTokenOptions]; + [navigator setDestinations:strongSelf->_destinations + routeToken:routeTokenOptions[@"routeToken"] + callback:routeStatusCallback]; + } else if (routingOptions != NULL) { [strongSelf configureNavigator:navigator withRoutingOptions:routingOptions]; [navigator setDestinations:strongSelf->_destinations routingOptions:[NavModule getRoutingOptions:routingOptions] @@ -437,6 +430,15 @@ + (GMSNavigationRoutingOptions *)getRoutingOptions:(NSDictionary *)options { return routingOptions; } +- (void)configureNavigator:(GMSNavigator *)navigator + withRouteTokenOptions:(NSDictionary *)routeTokenOptions { + if (routeTokenOptions[@"travelMode"] != nil) { + NavViewModule *navViewModule = [NavViewModule sharedInstance]; + [navViewModule + setTravelMode:(GMSNavigationTravelMode)[routeTokenOptions[@"travelMode"] intValue]]; + } +} + - (void)configureNavigator:(GMSNavigator *)navigator withRoutingOptions:(NSDictionary *)routingOptions { if (routingOptions[@"travelMode"] != nil) { diff --git a/src/navigation/navigation/types.ts b/src/navigation/navigation/types.ts index 89c8b11..1f54bf9 100644 --- a/src/navigation/navigation/types.ts +++ b/src/navigation/navigation/types.ts @@ -27,6 +27,54 @@ import type { Waypoint, } from '../types'; +/** + * Provides options for routing using a route token from the Routes API. + * + * Route tokens can be obtained from the Google Maps Routes API by setting + * `X-Goog-FieldMask` to include `routes.routeToken` in the request. + * + * When using route token options, you should not provide routing options + * as they are mutually exclusive. + */ +export interface RouteTokenOptions { + /** + * The route token string obtained from the Routes API. + */ + routeToken: string; + + /** + * The travel mode used when generating the route token. + * + * This must match the travel mode used to generate the route token. + * If there is a mismatch, this travel mode will override the one used + * to generate the route token. + */ + travelMode?: TravelMode; +} + +/** + * Options for setting destinations on the navigator. + * + * Either routingOptions or routeTokenOptions can be provided, but not both. + */ +export interface SetDestinationsOptions { + /** + * Options for calculating the route. Cannot be used with routeTokenOptions. + */ + routingOptions?: RoutingOptions; + + /** + * Options for displaying navigation elements like destination markers. + */ + displayOptions?: DisplayOptions; + + /** + * Options for using a pre-computed route token from the Routes API. + * Cannot be used with routingOptions. + */ + routeTokenOptions?: RouteTokenOptions; +} + /** * Defines the options used by the Navigator for calculating a route to a destination. */ @@ -322,11 +370,12 @@ export interface NavigationController { * @param waypoint - A Waypoint object, defining a destination or stopover point * with specific attributes, such as place ID, title, and * coordinates (latitude and longitude). + * @param options - Optional destination options including routing, display, or route token settings. + * Note: routingOptions and routeTokenOptions are mutually exclusive. */ setDestination( waypoint: Waypoint, - routingOptions?: RoutingOptions, - displayOptions?: DisplayOptions + options?: SetDestinationsOptions ): Promise; /** @@ -334,11 +383,12 @@ export interface NavigationController { * * @param waypoints - A list of Waypoint objects, each defining a destination * or stopover point with specific attributes. + * @param options - Optional destination options including routing, display, or route token settings. + * Note: routingOptions and routeTokenOptions are mutually exclusive. */ setDestinations( waypoints: Waypoint[], - routingOptions?: RoutingOptions, - displayOptions?: DisplayOptions + options?: SetDestinationsOptions ): Promise; /** diff --git a/src/navigation/navigation/useNavigationController.ts b/src/navigation/navigation/useNavigationController.ts index e1221e3..90ed838 100644 --- a/src/navigation/navigation/useNavigationController.ts +++ b/src/navigation/navigation/useNavigationController.ts @@ -27,11 +27,10 @@ import { type NavigationCallbacks, type TermsAndConditionsDialogOptions, type NavigationController, - type RoutingOptions, type SpeedAlertOptions, type LocationSimulationOptions, TaskRemovedBehavior, - type DisplayOptions, + type SetDestinationsOptions, } from './types'; import { getRouteStatusFromStringValue } from '../navigationView'; @@ -92,25 +91,39 @@ export const useNavigationController = ( setDestination: async ( waypoint: Waypoint, - routingOptions?: RoutingOptions, - displayOptions?: DisplayOptions + options?: SetDestinationsOptions ) => { - return await NavModule.setDestination( - waypoint, + const { routingOptions, displayOptions, routeTokenOptions } = + options ?? {}; + if (routingOptions && routeTokenOptions) { + throw new Error( + 'Only one of routingOptions or routeTokenOptions can be provided, not both.' + ); + } + return await NavModule.setDestinations( + [waypoint], routingOptions, - displayOptions + displayOptions, + routeTokenOptions ); }, setDestinations: async ( waypoints: Waypoint[], - routingOptions?: RoutingOptions, - displayOptions?: DisplayOptions + options?: SetDestinationsOptions ) => { + const { routingOptions, displayOptions, routeTokenOptions } = + options ?? {}; + if (routingOptions && routeTokenOptions) { + throw new Error( + 'Only one of routingOptions or routeTokenOptions can be provided, not both.' + ); + } return await NavModule.setDestinations( waypoints, routingOptions, - displayOptions + displayOptions, + routeTokenOptions ); }, diff --git a/yarn.lock b/yarn.lock index ce894b2..969b3aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4093,7 +4093,7 @@ __metadata: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 + qs: 6.14.1 raw-body: 2.5.2 type-is: ~1.6.18 unpipe: 1.0.0 @@ -5086,9 +5086,9 @@ __metadata: languageName: node linkType: hard -"detox@npm:^20.27.6": - version: 20.46.0 - resolution: "detox@npm:20.46.0" +"detox@npm:^20.46.3": + version: 20.46.3 + resolution: "detox@npm:20.46.3" dependencies: "@wix-pilot/core": ^3.4.2 "@wix-pilot/detox": ^1.0.13 @@ -5134,7 +5134,7 @@ __metadata: optional: true bin: detox: local-cli/cli.js - checksum: 94ad8295b4f06f2fb7ef29db345ea6b4a5124530e144e84026fa1924373f05f6f33935724e5bee035dcdbbfd48e49a2c8b29b2f3d050f07d3959147c0533cbcc + checksum: afe7a0e2f9c005fe66fd630acdf7520287d6b260cac21c00ea49d22c6609496cae59aaa043dcfbe3186350d2a296362be89bf6ca0cf4df7e4cc307cd0af8c74d languageName: node linkType: hard @@ -9761,12 +9761,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0": - version: 6.13.0 - resolution: "qs@npm:6.13.0" +"qs@npm:6.14.1": + version: 6.14.1 + resolution: "qs@npm:6.14.1" dependencies: - side-channel: ^1.0.6 - checksum: e9404dc0fc2849245107108ce9ec2766cde3be1b271de0bf1021d049dc5b98d1a2901e67b431ac5509f865420a7ed80b7acb3980099fe1c118a1c5d2e1432ad8 + side-channel: ^1.1.0 + checksum: 7fffab0344fd75bfb6b8c94b8ba17f3d3e823d25b615900f68b473c3a078e497de8eaa08f709eaaa170eedfcee50638a7159b98abef7d8c89c2ede79291522f2 languageName: node linkType: hard @@ -9947,7 +9947,7 @@ __metadata: "@react-navigation/stack": ^6.4.1 "@types/jest": ^29.5.14 "@types/node": ^22.9.0 - detox: ^20.27.6 + detox: ^20.46.3 jest: ^29.7.0 react: 19.1.0 react-native: 0.81.1 @@ -10685,7 +10685,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: