diff --git a/app.config.ts b/app.config.ts index 7b5f9e7..b264724 100644 --- a/app.config.ts +++ b/app.config.ts @@ -55,8 +55,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ UIViewControllerBasedStatusBarAppearance: false, NSBluetoothAlwaysUsageDescription: 'Resgrid IC uses Bluetooth to connect to wireless headsets and speaker-microphone accessories for Push-to-Talk audio. For example, when you pair a Bluetooth speaker-mic, pressing its talk button transmits your voice to your department audio channel.', - // Allow the app to open its own custom-scheme deep links (needed for SSO callbacks) - LSApplicationQueriesSchemes: ['resgridic'], + // Allow the app to open its own custom-scheme deep links (needed for SSO callbacks). + // The navigation apps the Navigation App setting can hand directions to are detected with + // canOpenURL, which only answers for declared schemes (src/lib/navigation.ts). + LSApplicationQueriesSchemes: ['resgridic', 'waze', 'comgooglemaps'], }, entitlements: { // Required for APNs registration. Previously added by the withForegroundNotifications diff --git a/customManifest.plugin.js b/customManifest.plugin.js index 25e63ec..01deba4 100644 --- a/customManifest.plugin.js +++ b/customManifest.plugin.js @@ -2,6 +2,33 @@ const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins'); const SERVICE_NAME = 'app.notifee.core.ForegroundService'; +// Schemes src/lib/navigation.ts probes with Linking.canOpenURL before handing directions to a navigation +// app. Android 11+ answers false for any scheme the manifest does not declare in , even when the +// app is installed. (iOS declares the same schemes in LSApplicationQueriesSchemes in app.config.ts.) +const NAVIGATION_QUERY_SCHEMES = ['waze']; + +/** Adds a VIEW-intent entry for each navigation scheme the manifest does not declare yet. */ +const addNavigationAppQueries = (manifest) => { + manifest.manifest.queries = manifest.manifest.queries || []; + if (manifest.manifest.queries.length === 0) { + manifest.manifest.queries.push({}); + } + + const declaresScheme = (scheme) => manifest.manifest.queries.some((query) => (query.intent || []).some((intent) => (intent.data || []).some((data) => data?.$?.['android:scheme'] === scheme))); + + const queries = manifest.manifest.queries[0]; + queries.intent = queries.intent || []; + for (const scheme of NAVIGATION_QUERY_SCHEMES) { + if (!declaresScheme(scheme)) { + queries.intent.push({ + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + data: [{ $: { 'android:scheme': scheme } }], + }); + } + } + return manifest; +}; + const withForegroundService = (config) => { return withAndroidManifest(config, async (config) => { const manifest = config.modResults; @@ -10,6 +37,8 @@ const withForegroundService = (config) => { manifest.manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; } + addNavigationAppQueries(manifest); + const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); mainApplication['service'] = mainApplication['service'] || []; @@ -35,3 +64,4 @@ const withForegroundService = (config) => { }; module.exports = withForegroundService; +module.exports.addNavigationAppQueries = addNavigationAppQueries; diff --git a/src/app/(app)/poi/[id].tsx b/src/app/(app)/poi/[id].tsx index 7eacc36..a33f232 100644 --- a/src/app/(app)/poi/[id].tsx +++ b/src/app/(app)/poi/[id].tsx @@ -55,7 +55,7 @@ export default function PoiDetailScreen() { return; } - const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiSelectionLabel(poi, poiTypesById), userLatitude || undefined, userLongitude || undefined); + const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiSelectionLabel(poi, poiTypesById), userLatitude || undefined, userLongitude || undefined, poi.Address); if (!success) { showToast('error', t('routes.failed_to_open_poi_maps')); } diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index f9c9e51..56b4c58 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -10,6 +10,7 @@ import { Item } from '@/components/settings/item'; import { KeepAliveItem } from '@/components/settings/keep-alive-item'; import { LanguageItem } from '@/components/settings/language-item'; import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sheet'; +import { NavigationAppItem } from '@/components/settings/navigation-app-item'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { ThemeItem } from '@/components/settings/theme-item'; import { ToggleItem } from '@/components/settings/toggle-item'; @@ -145,6 +146,7 @@ export default function Settings() { + diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 18f56b4..a1a30b9 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -271,14 +271,18 @@ export default function CallDetail() { * Opens the device's native maps application with directions to the call location */ const handleRoute = async () => { - if (coordinates.latitude === null || coordinates.longitude === null) { + // A withheld address must not leave the app in a navigation URL; coordinates still route. + const routableAddress = call?.Address && !isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callAddress, call.Address) ? call.Address.trim() : ''; + + if ((coordinates.latitude === null || coordinates.longitude === null) && !routableAddress) { showToast('error', t('call_detail.no_location_for_routing')); return; } try { - const destinationName = call?.Address || t('call_detail.call_location'); - const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLatitude || undefined, userLongitude || undefined); + const destinationName = routableAddress || t('call_detail.call_location'); + // Without usable coordinates the helper navigates to the address instead. + const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLatitude || undefined, userLongitude || undefined, routableAddress); if (!success) { showToast('error', t('call_detail.failed_to_open_maps')); diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 62ae2f1..27e7409 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -328,6 +328,17 @@ jest.mock('react-i18next', () => ({ }), })); +// Route hands the destination to the navigation helper, which src/lib/__tests__/navigation.test.ts covers. +const mockOpenMapsWithDirections = jest.fn(); +jest.mock('@/lib/navigation', () => ({ + openMapsWithDirections: (...args: unknown[]) => mockOpenMapsWithDirections(...args), +})); + +// How a withheld value is drawn is ProtectedText's concern; a plain stand-in suits this suite's light UI mocks. +jest.mock('@/components/data-protection/protected-text', () => ({ + ProtectedText: ({ value }: { value?: string | null }) => value ?? null, +})); + // React Native mocks jest.mock('react-native', () => ({ View: jest.fn().mockImplementation(({ children, ...props }) => children), @@ -779,4 +790,97 @@ describe('CallDetail', () => { }); }); }); + + describe('Route', () => { + const routableCall = { + CallId: 'test-call-id', + Name: 'Test Call', + Number: 'C2024001', + Priority: 2, + Type: 'Emergency', + Address: ' 123 Main St ', + Latitude: '40.7128', + Longitude: '-74.0060', + NotesCount: 0, + ImgagesCount: 0, + FileCount: 0, + }; + + const mockShowToast = jest.fn(); + + const setCall = (call: Record) => { + mockUseCallDetailStore.mockImplementation((selector: any) => { + const store = { call, callExtraData: null, callPriority: null, isLoading: false, error: null, fetchCallDetail: jest.fn(), reset: jest.fn() }; + return selector ? selector(store) : store; + }); + }; + + beforeEach(() => { + mockOpenMapsWithDirections.mockReset(); + mockOpenMapsWithDirections.mockResolvedValue(true); + mockShowToast.mockReset(); + mockUseToastStore.mockImplementation((selector: any) => { + const store = { showToast: mockShowToast }; + return selector ? selector(store) : store; + }); + }); + + it('routes to the call coordinates, named by the trimmed address, with the address as the fallback', async () => { + setCall(routableCall); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('button--common.route')); + + await waitFor(() => { + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(40.7128, -74.006, '123 Main St', 40.7128, -74.006, '123 Main St'); + }); + }); + + it('routes to the address when the call has no usable coordinates', async () => { + setCall({ ...routableCall, Latitude: '0', Longitude: '0' }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('button--common.route')); + + await waitFor(() => { + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(null, null, '123 Main St', 40.7128, -74.006, '123 Main St'); + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('keeps a withheld address out of the navigation app', async () => { + setCall({ ...routableCall, RedactedFields: ['calls.address'] }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('button--common.route')); + + await waitFor(() => { + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(40.7128, -74.006, 'call_detail.call_location', 40.7128, -74.006, ''); + }); + }); + + it('opens nothing and explains why without coordinates or a usable address', async () => { + setCall({ ...routableCall, Latitude: '0', Longitude: '0', RedactedFields: ['calls.address'] }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('button--common.route')); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.no_location_for_routing'); + }); + expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); + }); + + it('tells the user when no navigation app opened', async () => { + mockOpenMapsWithDirections.mockResolvedValue(false); + setCall(routableCall); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('button--common.route')); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.failed_to_open_maps'); + }); + }); + }); }); diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx index d6a0118..2bbe915 100644 --- a/src/components/contacts/contact-details-extra.tsx +++ b/src/components/contacts/contact-details-extra.tsx @@ -1,7 +1,7 @@ import { LockIcon } from 'lucide-react-native'; import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Linking, View } from 'react-native'; +import { View } from 'react-native'; import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; import { HStack } from '@/components/ui/hstack'; @@ -9,8 +9,8 @@ import { HomeIcon, MapPinIcon, SettingsIcon } from '@/components/ui/lucide-icons import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { mapsUrls } from '@/lib/contacts/format'; -import { logger } from '@/lib/logging'; +import { parseCoordinates } from '@/lib/contacts/format'; +import { openMapsWithAddress, openMapsWithDirections } from '@/lib/navigation'; import { type ContactAddressData, type ContactResultData } from '@/models/v4/contacts/contactResultData'; import { useContactsStore } from '@/stores/contacts/store'; @@ -20,17 +20,14 @@ interface ContactDetailsExtraProps { showCustomFields?: boolean; } +// Opens the navigation app chosen in Settings, which also logs a failure. A "lat,lng" value routes to the point. const openMaps = async (query: string) => { - const { app, web } = mapsUrls(query); - try { - if (app && (await Linking.canOpenURL(app))) { - await Linking.openURL(app); - return; - } - await Linking.openURL(web); - } catch (error) { - logger.warn({ message: 'Could not open maps for contact address', context: { error } }); + const coordinates = parseCoordinates(query); + if (coordinates) { + await openMapsWithDirections(coordinates.latitude, coordinates.longitude, undefined, undefined, undefined, query); + return; } + await openMapsWithAddress(query); }; const AddressRow = ({ label, address }: { label: string; address: ContactAddressData }) => { diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx index f3a9832..6d67e10 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -1,7 +1,7 @@ import { router } from 'expo-router'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Linking, Platform, ScrollView, useWindowDimensions, View } from 'react-native'; +import { Linking, ScrollView, useWindowDimensions, View } from 'react-native'; import { LocationHistoryPanel } from '@/components/calls/location-history-panel'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; @@ -26,6 +26,7 @@ import { import { useAnalytics } from '@/hooks/use-analytics'; import { htmlToText, withoutRedacted } from '@/lib/contacts/format'; import { logger } from '@/lib/logging'; +import { openMapsWithAddress } from '@/lib/navigation'; import { ContactType } from '@/models/v4/contacts/contactResultData'; import { useContactsStore } from '@/stores/contacts/store'; import { TermKeys, useTerm } from '@/stores/terminology/store'; @@ -84,8 +85,13 @@ const ContactField: React.FC = ({ label, value, icon, isLink, const handlePress = useCallback(async () => { if (action === 'none' || !value) return; + if (action === 'address') { + // The navigation app chosen in Settings; the helper owns the per-platform URLs, the fallbacks and the failure log. + await openMapsWithAddress(fullAddress || value.toString()); + return; + } + let url: string | null = null; - let webFallbackUrl: string | null = null; switch (action) { case 'email': @@ -94,17 +100,6 @@ const ContactField: React.FC = ({ label, value, icon, isLink, case 'phone': url = `tel:${value.toString().replace(/[^0-9+]/g, '')}`; break; - case 'address': { - const addressToOpen = fullAddress || value.toString(); - const encodedAddress = encodeURIComponent(addressToOpen); - webFallbackUrl = `https://maps.google.com/?q=${encodedAddress}`; - url = Platform.select({ - ios: `maps:0,0?q=${encodedAddress}`, - android: `geo:0,0?q=${encodedAddress}`, - default: webFallbackUrl, - }); - break; - } case 'website': { const websiteUrl = value.toString(); url = websiteUrl.startsWith('http') ? websiteUrl : `https://${websiteUrl}`; @@ -117,9 +112,6 @@ const ContactField: React.FC = ({ label, value, icon, isLink, const canOpen = await Linking.canOpenURL(url); if (canOpen) { await Linking.openURL(url); - } else if (action === 'address' && webFallbackUrl) { - // Fallback to Google Maps web URL for address action - await Linking.openURL(webFallbackUrl); } else { logger.warn({ message: 'Cannot open URL', @@ -131,18 +123,6 @@ const ContactField: React.FC = ({ label, value, icon, isLink, message: 'Failed to open URL from contact details', context: { error, url, action }, }); - - // For address action, attempt web fallback on error - if (action === 'address' && webFallbackUrl) { - try { - await Linking.openURL(webFallbackUrl); - } catch (fallbackError) { - logger.error({ - message: 'Failed to open fallback Google Maps URL', - context: { error: fallbackError, webFallbackUrl }, - }); - } - } } } }, [action, value, fullAddress]); diff --git a/src/components/settings/__tests__/navigation-app-item.test.tsx b/src/components/settings/__tests__/navigation-app-item.test.tsx new file mode 100644 index 0000000..1c572b1 --- /dev/null +++ b/src/components/settings/__tests__/navigation-app-item.test.tsx @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { NavigationAppItem } from '@/components/settings/navigation-app-item'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'settings.navigation_app.title': 'Navigation App', + 'settings.navigation_app.description': 'The app this device opens when you start navigation to a call, destination or map pin.', + 'settings.navigation_app.default': 'Device default', + 'settings.navigation_app.google_maps': 'Google Maps', + 'settings.navigation_app.apple_maps': 'Apple Maps', + 'settings.navigation_app.waze': 'Waze', + 'settings.navigation_app.gaia_gps': 'Gaia GPS', + 'settings.navigation_app.here_wego': 'HERE WeGo', + 'settings.navigation_app.mapquest': 'MapQuest', + 'settings.navigation_app.onx_offroad': 'onX Offroad', + 'settings.navigation_app.organic_maps': 'Organic Maps', + 'settings.navigation_app.osmand': 'OsmAnd', + }; + return translations[key] || key; + }, + }), +})); + +// Offer the options of the platform under test, through the real filtering. +let mockPlatform = 'android'; +jest.mock('@/lib/storage/navigation-app', () => { + const actual = jest.requireActual('@/lib/storage/navigation-app'); + return { + ...actual, + getNavigationAppOptions: () => actual.getNavigationAppOptions(mockPlatform), + }; +}); + +const mockSetNavigationApp = jest.fn(); +let mockNavigationApp = 'default'; +jest.mock('@/lib/hooks/use-navigation-app', () => ({ + useNavigationApp: () => ({ + navigationApp: mockNavigationApp, + setNavigationApp: mockSetNavigationApp, + }), +})); + +// A flat stand-in for the gluestack Select: every option is a pressable row, and the change handler is +// captured so a test can send a value no option offers. +let mockOnValueChange: ((value: string) => void) | undefined; +jest.mock('@/components/ui/select', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + const SelectContext = React.createContext({ onValueChange: (_value: string) => {} }); + const Passthrough = ({ children }: { children?: React.ReactNode }) => {children}; + return { + Select: ({ + children, + onValueChange, + selectedValue, + initialLabel, + testID, + }: { + children: React.ReactNode; + onValueChange: (value: string) => void; + selectedValue: string | null; + initialLabel?: string; + testID?: string; + }) => { + mockOnValueChange = onValueChange; + return ( + + + {initialLabel} + {children} + + + ); + }, + SelectTrigger: Passthrough, + SelectInput: () => null, + SelectIcon: () => null, + SelectPortal: Passthrough, + SelectBackdrop: () => null, + SelectContent: Passthrough, + SelectDragIndicatorWrapper: Passthrough, + SelectDragIndicator: () => null, + SelectItem: ({ label, value }: { label: string; value: string }) => { + const { onValueChange } = React.useContext(SelectContext); + return ( + onValueChange(value)}> + {label} + + ); + }, + }; +}); + +describe('NavigationAppItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPlatform = 'android'; + mockNavigationApp = 'default'; + }); + + it('renders the label and description', () => { + render(); + + expect(screen.getByText('Navigation App')).toBeTruthy(); + expect(screen.getByText('The app this device opens when you start navigation to a call, destination or map pin.')).toBeTruthy(); + }); + + it('offers device default, Google Maps and Waze on Android, without Apple Maps', () => { + render(); + + expect(screen.getByTestId('navigation-app-option-default')).toBeTruthy(); + expect(screen.getByTestId('navigation-app-option-google_maps')).toBeTruthy(); + expect(screen.getByTestId('navigation-app-option-waze')).toBeTruthy(); + expect(screen.queryByTestId('navigation-app-option-apple_maps')).toBeNull(); + }); + + it('also offers Apple Maps on iOS', () => { + mockPlatform = 'ios'; + + render(); + + expect(screen.getByTestId('navigation-app-option-apple_maps')).toBeTruthy(); + expect(screen.getByText('Apple Maps')).toBeTruthy(); + }); + + it.each(['android', 'ios'])('offers the mobile-only navigation apps on %s, by name', (platform) => { + mockPlatform = platform; + + render(); + + for (const [app, name] of [ + ['gaia_gps', 'Gaia GPS'], + ['here_wego', 'HERE WeGo'], + ['mapquest', 'MapQuest'], + ['onx_offroad', 'onX Offroad'], + ['organic_maps', 'Organic Maps'], + ['osmand', 'OsmAnd'], + ]) { + expect(screen.getByTestId(`navigation-app-option-${app}`)).toBeTruthy(); + expect(screen.getByText(name)).toBeTruthy(); + } + }); + + it('does not offer the mobile-only navigation apps on the web', () => { + mockPlatform = 'web'; + + render(); + + expect(screen.getByTestId('navigation-app-option-waze')).toBeTruthy(); + expect(screen.queryByTestId('navigation-app-option-osmand')).toBeNull(); + expect(screen.queryByTestId('navigation-app-option-here_wego')).toBeNull(); + }); + + it('saves a mobile-only navigation app', () => { + render(); + + fireEvent.press(screen.getByTestId('navigation-app-option-organic_maps')); + + expect(mockSetNavigationApp).toHaveBeenCalledWith('organic_maps'); + }); + + it('shows the saved choice', () => { + mockNavigationApp = 'waze'; + + render(); + + expect(screen.getByTestId('navigation-app-select').props.accessibilityValue).toEqual({ text: 'waze' }); + expect(screen.getByTestId('navigation-app-selected-label').props.children).toBe('Waze'); + }); + + it('saves the chosen app', () => { + render(); + + fireEvent.press(screen.getByTestId('navigation-app-option-waze')); + + expect(mockSetNavigationApp).toHaveBeenCalledWith('waze'); + }); + + it('ignores a value that is not an option on this platform', () => { + render(); + + // Apple Maps is iOS-only, so on Android the change is dropped rather than saved. + act(() => { + mockOnValueChange?.('apple_maps'); + }); + + expect(mockSetNavigationApp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/navigation-app-item.tsx b/src/components/settings/navigation-app-item.tsx new file mode 100644 index 0000000..a9827d8 --- /dev/null +++ b/src/components/settings/navigation-app-item.tsx @@ -0,0 +1,77 @@ +import { ChevronDownIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import { useNavigationApp } from '@/lib/hooks/use-navigation-app'; +import { getNavigationAppOptions, type NavigationApp } from '@/lib/storage/navigation-app'; + +const OPTION_LABEL_KEYS: Record = { + default: 'settings.navigation_app.default', + google_maps: 'settings.navigation_app.google_maps', + apple_maps: 'settings.navigation_app.apple_maps', + waze: 'settings.navigation_app.waze', + gaia_gps: 'settings.navigation_app.gaia_gps', + here_wego: 'settings.navigation_app.here_wego', + mapquest: 'settings.navigation_app.mapquest', + onx_offroad: 'settings.navigation_app.onx_offroad', + organic_maps: 'settings.navigation_app.organic_maps', + osmand: 'settings.navigation_app.osmand', +}; + +/** Settings row for the per-device navigation app used by every "Route" / "Directions" action. */ +export const NavigationAppItem = () => { + const { navigationApp, setNavigationApp } = useNavigationApp(); + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + + const options = React.useMemo(() => getNavigationAppOptions().map((app) => ({ label: t(OPTION_LABEL_KEYS[app]), value: app })), [t]); + + const selectedOption = React.useMemo(() => options.find((option) => option.value === navigationApp), [options, navigationApp]); + + const onSelect = React.useCallback( + (value: string) => { + const option = options.find((candidate) => candidate.value === value); + if (option) { + setNavigationApp(option.value); + } + }, + [options, setNavigationApp] + ); + + return ( + + + + {t('settings.navigation_app.title')} + + + + + + + {t('settings.navigation_app.description')} + + + ); +}; diff --git a/src/lib/__tests__/navigation.test.ts b/src/lib/__tests__/navigation.test.ts index 29b1465..8347c61 100644 --- a/src/lib/__tests__/navigation.test.ts +++ b/src/lib/__tests__/navigation.test.ts @@ -1,6 +1,8 @@ -import { Platform, Linking } from 'react-native'; -import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; -import { openMapsWithDirections, openMapsWithAddress } from '../navigation'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { Linking, Platform } from 'react-native'; + +import { logger } from '../logging'; +import { openMapsWithAddress, openMapsWithDirections } from '../navigation'; // Mock expo-router — the real module pulls in its vendored react-navigation tree, which // needs far more of react-native than the minimal stub below provides. @@ -27,368 +29,544 @@ jest.mock('react-native', () => ({ jest.mock('../logging', () => ({ logger: { error: jest.fn(), + warn: jest.fn(), }, })); -// Get the mocked Linking module +// The per-device "Navigation app" setting, controlled per test. +let mockNavigationApp = 'default'; +jest.mock('@/lib/storage/navigation-app', () => ({ + getNavigationApp: () => mockNavigationApp, +})); + const MockedLinking = Linking as jest.Mocked; +const mockedLogger = logger as jest.Mocked; + +const setPlatform = (os: string) => { + (Platform as { OS: string }).OS = os; +}; + +// Brussels Grand-Place, the kind of call location in the Belgian report. +const LAT = 50.8467; +const LNG = 4.3525; +const POINT = '50.8467,4.3525'; +const ADDRESS = 'Grand-Place 1, 1000 Bruxelles'; +const ENCODED_ADDRESS = 'Grand-Place%201%2C%201000%20Bruxelles'; + +const GOOGLE_WEB_POINT = `https://www.google.com/maps/dir/?api=1&destination=${POINT}&travelmode=driving`; +const GOOGLE_WEB_ADDRESS = `https://www.google.com/maps/dir/?api=1&destination=${ENCODED_ADDRESS}&travelmode=driving`; describe('Navigation Functions', () => { beforeEach(() => { jest.clearAllMocks(); + mockNavigationApp = 'default'; + setPlatform('ios'); + // Every app is "installed" unless a test says otherwise. MockedLinking.canOpenURL.mockResolvedValue(true); MockedLinking.openURL.mockResolvedValue(true); }); afterEach(() => { - // Reset Platform.OS to default - (Platform as any).OS = 'ios'; + setPlatform('ios'); }); - describe('openMapsWithDirections', () => { - describe('iOS Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + describe('openMapsWithDirections — URL per navigation app and platform', () => { + it.each([ + // [platform, navigation app, URL opened, URL probed with canOpenURL (or null)] + ['ios', 'default', `maps://maps.apple.com/?daddr=${POINT}&dirflg=d`, null], + ['ios', 'apple_maps', `maps://maps.apple.com/?daddr=${POINT}&dirflg=d`, null], + ['ios', 'google_maps', `comgooglemaps://?daddr=${POINT}&directionsmode=driving`, `comgooglemaps://?daddr=${POINT}&directionsmode=driving`], + ['ios', 'waze', `waze://?ll=${POINT}&navigate=yes`, `waze://?ll=${POINT}&navigate=yes`], + ['android', 'default', `geo:${POINT}?q=${POINT}(Grand-Place)`, null], + ['android', 'google_maps', `google.navigation:q=${POINT}`, null], + // Apple Maps cannot be chosen on Android; a stale value behaves as the device default. + ['android', 'apple_maps', `geo:${POINT}?q=${POINT}(Grand-Place)`, null], + ['android', 'waze', `waze://?ll=${POINT}&navigate=yes`, `waze://?ll=${POINT}&navigate=yes`], + ['web', 'default', GOOGLE_WEB_POINT, null], + ['web', 'google_maps', GOOGLE_WEB_POINT, null], + ['web', 'apple_maps', GOOGLE_WEB_POINT, null], + ['web', 'waze', `https://waze.com/ul?ll=${POINT}&navigate=yes`, null], + ['windows', 'default', GOOGLE_WEB_POINT, null], + ['macos', 'waze', `https://waze.com/ul?ll=${POINT}&navigate=yes`, null], + ])('%s + %s opens %s', async (platform, app, expectedUrl, probedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(expectedUrl); + if (probedUrl) { + expect(MockedLinking.canOpenURL).toHaveBeenCalledWith(probedUrl); + } else { + // geo:, google.navigation:, maps:// and https links are opened directly. On Android 11+ + // canOpenURL answers false for undeclared schemes, which used to divert every tap. + expect(MockedLinking.canOpenURL).not.toHaveBeenCalled(); + } + }); - it('should open Apple Maps with current location as origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + it.each(['ios', 'android'])('opens the Waze universal link on %s when Waze is not installed', async (platform) => { + setPlatform(platform); + mockNavigationApp = 'waze'; + MockedLinking.canOpenURL.mockResolvedValue(false); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(result).toBe(true); - }); + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); - it('should open Apple Maps with specific origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`https://waze.com/ul?ll=${POINT}&navigate=yes`); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?saddr=40.7589,-73.9851&daddr=40.7128,-74.006&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?saddr=40.7589,-73.9851&daddr=40.7128,-74.006&dirflg=d'); - expect(result).toBe(true); - }); + it('opens Google Maps on the web on iOS when the Google Maps app is not installed', async () => { + mockNavigationApp = 'google_maps'; + MockedLinking.canOpenURL.mockResolvedValue(false); - it('should handle string coordinates', async () => { - const result = await openMapsWithDirections('40.7128', '-74.006', 'New York'); + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(result).toBe(true); - }); + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(GOOGLE_WEB_POINT); }); - describe('Android Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'android'; - }); - - it('should open Google Maps with current location as origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + it('treats a canOpenURL failure as "not installed" and moves on', async () => { + mockNavigationApp = 'waze'; + MockedLinking.canOpenURL.mockRejectedValue(new Error('scheme not declared')); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006'); - expect(result).toBe(true); - }); + const result = await openMapsWithDirections(LAT, LNG); - it('should open Google Maps with specific origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`https://waze.com/ul?ll=${POINT}&navigate=yes`); + }); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006&origin=40.7589,-73.9851'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=40.7128,-74.006&origin=40.7589,-73.9851'); - expect(result).toBe(true); - }); + describe('openMapsWithDirections — mobile-only navigation apps', () => { + const ORIGIN = '50.8503,4.3517'; + + it.each([ + // [platform, navigation app, URL opened] + ['ios', 'gaia_gps', `gaiagps://map?lat=${LAT}&lng=${LNG}&z=14`], + // Gaia GPS has no Android scheme; its universal link opens the app when it is installed. + ['android', 'gaia_gps', `https://www.gaiagps.com/map/?lat=${LAT}&lon=${LNG}&zoom=14`], + ['ios', 'here_wego', `https://share.here.com/r/mylocation/${POINT},Grand-Place?m=d`], + ['android', 'here_wego', `https://share.here.com/r/mylocation/${POINT},Grand-Place?m=d`], + ['ios', 'mapquest', `quest://search?q=${POINT}`], + ['android', 'mapquest', `quest://search?q=${POINT}`], + ['ios', 'onx_offroad', `https://offroad.onxmaps.com/offroad/map/query/${POINT}`], + ['android', 'onx_offroad', `https://offroad.onxmaps.com/offroad/map/query/${POINT}`], + // Without an origin Organic Maps can only drop a pin: its route link needs a start point. + ['ios', 'organic_maps', `om://map?v=1&ll=${POINT}&n=Grand-Place`], + ['android', 'organic_maps', `om://map?v=1&ll=${POINT}&n=Grand-Place`], + ['ios', 'osmand', `osmandmaps://navigate?lat=${LAT}&lon=${LNG}&title=Grand-Place`], + ['android', 'osmand', `osmand.api://navigate?dest_lat=${LAT}&dest_lon=${LNG}&dest_name=Grand-Place&profile=car`], + ])('%s + %s opens %s', async (platform, app, expectedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(expectedUrl); + // Their schemes are opened directly, so none of them needs declaring for canOpenURL. + expect(MockedLinking.canOpenURL).not.toHaveBeenCalled(); }); - describe('Web Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'web'; - }); + it.each(['ios', 'android'])('routes in Organic Maps from the origin on %s', async (platform) => { + setPlatform(platform); + mockNavigationApp = 'organic_maps'; - it('should open Google Maps web with current location as origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + await openMapsWithDirections(LAT, LNG, 'Grand-Place', 50.8503, 4.3517); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`om://route?sll=${ORIGIN}&saddr=&dll=${POINT}&daddr=Grand-Place&type=vehicle`); + }); - it('should open Google Maps web with specific origin', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York', 40.7589, -73.9851); + it('names an unlabelled Organic Maps destination by its coordinates', async () => { + mockNavigationApp = 'organic_maps'; - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&origin=40.7589,-73.9851&destination=40.7128,-74.006&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&origin=40.7589,-73.9851&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); - }); + await openMapsWithDirections(LAT, LNG, undefined, 50.8503, 4.3517); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(`om://route?sll=${ORIGIN}&saddr=&dll=${POINT}&daddr=50.8467%2C4.3525&type=vehicle`); }); - describe('Windows Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'windows'; - }); + it('starts HERE WeGo from the live location even when an origin is given', async () => { + mockNavigationApp = 'here_wego'; - it('should open Google Maps web for Windows', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + await openMapsWithDirections(LAT, LNG, undefined, 50.8503, 4.3517); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`https://share.here.com/r/mylocation/${POINT}?m=d`); }); - describe('macOS Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'macos'; - }); + it('encodes the separators of a HERE WeGo name, which sits in the link path', async () => { + mockNavigationApp = 'here_wego'; - it('should open Google Maps web for macOS', async () => { - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + await openMapsWithDirections(LAT, LNG, 'Station 1, North/East'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`https://share.here.com/r/mylocation/${POINT},Station%201%2C%20North%2FEast?m=d`); }); - describe('Fallback Behavior', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + it.each([ + // [platform, navigation app, fallback opened when the app is not installed] + ['ios', 'gaia_gps', `https://www.gaiagps.com/map/?lat=${LAT}&lon=${LNG}&zoom=14`], + ['ios', 'organic_maps', `https://omaps.app/map?v=1&ll=${POINT}&n=Grand-Place`], + ['android', 'organic_maps', `https://omaps.app/map?v=1&ll=${POINT}&n=Grand-Place`], + ['ios', 'osmand', `https://osmand.net/map/navigate/?end=${POINT}&profile=car`], + ['android', 'osmand', `https://osmand.net/map/navigate/?end=${POINT}&profile=car`], + ['ios', 'mapquest', GOOGLE_WEB_POINT], + ['android', 'mapquest', GOOGLE_WEB_POINT], + ])('%s + %s falls back to %s when the app is not installed', async (platform, app, expectedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + MockedLinking.openURL.mockRejectedValueOnce(new Error('No Activity found to handle Intent')); + + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(2); + expect(MockedLinking.openURL).toHaveBeenLastCalledWith(expectedUrl); + }); - it('should use web fallback when canOpenURL returns false', async () => { - MockedLinking.canOpenURL.mockResolvedValue(false); + it.each(['gaia_gps', 'here_wego', 'mapquest', 'onx_offroad', 'organic_maps', 'osmand'])('opens Google Maps on the web for a stale %s choice on the web', async (app) => { + setPlatform('web'); + mockNavigationApp = app; - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + await openMapsWithDirections(LAT, LNG, 'Grand-Place'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(GOOGLE_WEB_POINT); + }); - it('should handle unknown platform', async () => { - (Platform as any).OS = 'unknown'; + it('logs only the URL schemes when nothing opens', async () => { + setPlatform('android'); + mockNavigationApp = 'osmand'; + MockedLinking.openURL.mockRejectedValue(new Error('Failed')); - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=40.7128,-74.006&travelmode=driving'); - expect(result).toBe(true); + expect(result).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith({ + message: 'Failed to open a navigation app', + context: expect.objectContaining({ navigationApp: 'osmand', platform: 'android', schemes: ['osmand.api', 'https', 'https'] }), }); }); + }); - describe('Error Handling', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + describe('openMapsWithDirections — origin', () => { + const ORIGIN = '50.8503,4.3517'; + + it.each([ + ['ios', 'default', `maps://maps.apple.com/?saddr=${ORIGIN}&daddr=${POINT}&dirflg=d`], + ['ios', 'google_maps', `comgooglemaps://?saddr=${ORIGIN}&daddr=${POINT}&directionsmode=driving`], + ['web', 'default', `https://www.google.com/maps/dir/?api=1&origin=${ORIGIN}&destination=${POINT}&travelmode=driving`], + // geo:, google.navigation: and Waze always start from the device's current location. + ['android', 'default', `geo:${POINT}?q=${POINT}`], + ['android', 'google_maps', `google.navigation:q=${POINT}`], + ['android', 'waze', `waze://?ll=${POINT}&navigate=yes`], + ['ios', 'waze', `waze://?ll=${POINT}&navigate=yes`], + ])('%s + %s with an origin opens %s', async (platform, app, expectedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + + await openMapsWithDirections(LAT, LNG, undefined, 50.8503, 4.3517); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(expectedUrl); + }); + + it.each([ + ['(0,0) sentinel', 0, 0], + ['non-numeric', 'abc', 'def'], + ['out of range', 95, 4.35], + ])('drops a %s origin', async (_label, originLat, originLng) => { + await openMapsWithDirections(LAT, LNG, undefined, originLat, originLng); - it('should handle Linking.openURL errors', async () => { - const mockError = new Error('Unable to open URL'); - MockedLinking.openURL.mockRejectedValue(mockError); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`maps://maps.apple.com/?daddr=${POINT}&dirflg=d`); + }); + }); - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + describe('openMapsWithDirections — coordinate formatting', () => { + beforeEach(() => { + setPlatform('android'); + }); - expect(result).toBe(false); - }); + it('accepts numeric strings', async () => { + await openMapsWithDirections('50.8467', '4.3525'); - it('should handle Linking.canOpenURL errors', async () => { - const mockError = new Error('Unable to check URL'); - MockedLinking.canOpenURL.mockRejectedValue(mockError); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:${POINT}?q=${POINT}`); + }); - const result = await openMapsWithDirections(40.7128, -74.006, 'New York'); + it('accepts a decimal comma from a locale-formatted source and always emits a dot', async () => { + await openMapsWithDirections('50,8467', ' 4,3525 '); - expect(result).toBe(false); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:${POINT}?q=${POINT}`); }); - describe('Parameter Validation', () => { - it('should handle undefined destination name', async () => { - const result = await openMapsWithDirections(40.7128, -74.006); + it('rounds to 6 decimals without exponent notation or trailing zeros', async () => { + await openMapsWithDirections(50.846712345, -0.0000001); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=40.7128,-74.006&dirflg=d'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith('geo:50.846712,0?q=50.846712,0'); + }); - it('should handle zero coordinates', async () => { - const result = await openMapsWithDirections(0, 0, 'Equator'); + it('keeps negative coordinates', async () => { + await openMapsWithDirections(-33.8688, -151.2093); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=0,0&dirflg=d'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith('geo:-33.8688,-151.2093?q=-33.8688,-151.2093'); + }); - it('should handle negative coordinates', async () => { - const result = await openMapsWithDirections(-40.7128, -74.006, 'South America'); + it('encodes the label, including the parentheses that delimit it in a geo: query', async () => { + await openMapsWithDirections(LAT, LNG, 'Station 1 (North) & Co'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=-40.7128,-74.006&dirflg=d'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:${POINT}?q=${POINT}(Station%201%20%28North%29%20%26%20Co)`); + }); + + it('omits a blank label', async () => { + await openMapsWithDirections(LAT, LNG, ' '); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:${POINT}?q=${POINT}`); }); }); - describe('openMapsWithAddress', () => { - describe('iOS Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + describe('openMapsWithDirections — unusable coordinates', () => { + it.each([ + ['the (0,0) sentinel', 0, 0], + ['string zeros', '0', '0'], + ['NaN', Number.NaN, Number.NaN], + ['non-numeric strings', 'abc', 'def'], + ['empty strings', '', ''], + ['null', null, null], + ['undefined', undefined, undefined], + ['an out-of-range latitude', 95, 4.35], + ['an out-of-range longitude', 50.85, 185], + ])('navigates to the fallback address for %s', async (_label, latitude, longitude) => { + setPlatform('android'); + + const result = await openMapsWithDirections(latitude, longitude, 'Grand-Place', undefined, undefined, ADDRESS); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:0,0?q=${ENCODED_ADDRESS}`); + }); - it('should open Apple Maps with address', async () => { - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + it('hands the fallback address to Waze', async () => { + mockNavigationApp = 'waze'; - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); - expect(result).toBe(true); - }); + await openMapsWithDirections(0, 0, undefined, undefined, undefined, ADDRESS); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(`waze://?q=${ENCODED_ADDRESS}&navigate=yes`); + }); - it('should handle special characters in address', async () => { - const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + it('prefers usable coordinates over the fallback address', async () => { + setPlatform('android'); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&dirflg=d'); - expect(result).toBe(true); - }); + await openMapsWithDirections(LAT, LNG, undefined, undefined, undefined, ADDRESS); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:${POINT}?q=${POINT}`); }); - describe('Android Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'android'; - }); + it('opens nothing and returns false without coordinates or an address', async () => { + const result = await openMapsWithDirections(0, 0, 'Equator'); - it('should open Google Maps with address', async () => { - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + expect(result).toBe(false); + expect(MockedLinking.openURL).not.toHaveBeenCalled(); + expect(mockedLogger.warn).toHaveBeenCalled(); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20Street%2C%20New%20York%2C%20NY'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20Street%2C%20New%20York%2C%20NY'); - expect(result).toBe(true); - }); + it('opens nothing for a blank fallback address', async () => { + const result = await openMapsWithDirections(Number.NaN, Number.NaN, undefined, undefined, undefined, ' '); - it('should handle special characters in address', async () => { - const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + expect(result).toBe(false); + expect(MockedLinking.openURL).not.toHaveBeenCalled(); + }); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('google.navigation:q=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY'); - expect(result).toBe(true); - }); + describe('openMapsWithDirections — failures', () => { + it('falls back to Google Maps on the web when the geo: intent has no handler', async () => { + setPlatform('android'); + MockedLinking.openURL.mockRejectedValueOnce(new Error('No Activity found to handle Intent')); + + const result = await openMapsWithDirections(LAT, LNG, 'Grand-Place'); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenNthCalledWith(1, `geo:${POINT}?q=${POINT}(Grand-Place)`); + expect(MockedLinking.openURL).toHaveBeenNthCalledWith(2, GOOGLE_WEB_POINT); }); - describe('Web Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'web'; - }); + it('falls back to Google Maps on the web when Google Maps is not installed on Android', async () => { + setPlatform('android'); + mockNavigationApp = 'google_maps'; + MockedLinking.openURL.mockRejectedValueOnce(new Error('No Activity found to handle Intent')); - it('should open Google Maps web with address', async () => { - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + const result = await openMapsWithDirections(LAT, LNG); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); - }); + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenLastCalledWith(GOOGLE_WEB_POINT); + }); - it('should handle special characters in address', async () => { - const result = await openMapsWithAddress('123 Main St. & 1st Ave, New York, NY'); + it('falls back to Google Maps on the web when Apple Maps fails to open', async () => { + MockedLinking.openURL.mockRejectedValueOnce(new Error('Unable to open URL')); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20St.%20%26%201st%20Ave%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); - }); + const result = await openMapsWithDirections(LAT, LNG); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenLastCalledWith(GOOGLE_WEB_POINT); }); - describe('Windows Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'windows'; - }); + it('returns false and logs only the URL schemes when nothing opens', async () => { + setPlatform('android'); + mockNavigationApp = 'waze'; + MockedLinking.canOpenURL.mockResolvedValue(false); + MockedLinking.openURL.mockRejectedValue(new Error('Failed')); - it('should open Google Maps web for Windows', async () => { - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + const result = await openMapsWithDirections(0, 0, undefined, undefined, undefined, ADDRESS); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); + expect(result).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith({ + message: 'Failed to open a navigation app', + context: expect.objectContaining({ navigationApp: 'waze', platform: 'android', schemes: ['waze', 'https', 'https'] }), }); + // The incident address stays out of the logs (and so out of Sentry). + expect(JSON.stringify(mockedLogger.error.mock.calls)).not.toContain('Bruxelles'); }); - describe('macOS Platform', () => { - beforeEach(() => { - (Platform as any).OS = 'macos'; - }); + it('logs only the error name when the native rejection carries the URL', async () => { + setPlatform('android'); + MockedLinking.openURL.mockImplementation((url: string) => Promise.reject(new Error(`Could not open URL '${url}': No Activity found to handle Intent`))); - it('should open Google Maps web for macOS', async () => { - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + const result = await openMapsWithAddress(ADDRESS); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); + expect(result).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith({ + message: 'Failed to open a navigation app', + context: { errorName: 'Error', navigationApp: 'default', platform: 'android', schemes: ['geo', 'https'] }, }); + const logged = JSON.stringify(mockedLogger.error.mock.calls); + expect(logged).not.toContain(ENCODED_ADDRESS); + expect(logged).not.toContain('Could not open URL'); }); - describe('Fallback Behavior', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + it('resolves false instead of rejecting when the destination cannot be encoded into a URL', async () => { + // A lone surrogate makes encodeURIComponent throw a URIError. + const result = await openMapsWithAddress('\uD800 Main St'); - it('should use web fallback when canOpenURL returns false', async () => { - MockedLinking.canOpenURL.mockResolvedValue(false); + expect(result).toBe(false); + expect(MockedLinking.openURL).not.toHaveBeenCalled(); + expect(mockedLogger.error).toHaveBeenCalledWith({ + message: 'Failed to build navigation URLs', + context: { errorName: 'URIError', navigationApp: 'default', platform: 'ios' }, + }); + }); + }); - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + describe('openMapsWithAddress', () => { + it.each([ + ['ios', 'default', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + ['ios', 'apple_maps', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + ['ios', 'google_maps', `comgooglemaps://?daddr=${ENCODED_ADDRESS}&directionsmode=driving`], + ['ios', 'waze', `waze://?q=${ENCODED_ADDRESS}&navigate=yes`], + ['android', 'default', `geo:0,0?q=${ENCODED_ADDRESS}`], + ['android', 'google_maps', `google.navigation:q=${ENCODED_ADDRESS}`], + ['android', 'waze', `waze://?q=${ENCODED_ADDRESS}&navigate=yes`], + ['web', 'default', GOOGLE_WEB_ADDRESS], + ['web', 'google_maps', GOOGLE_WEB_ADDRESS], + ['web', 'waze', `https://waze.com/ul?q=${ENCODED_ADDRESS}&navigate=yes`], + ['windows', 'default', GOOGLE_WEB_ADDRESS], + ])('%s + %s opens %s', async (platform, app, expectedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + + const result = await openMapsWithAddress(ADDRESS); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(expectedUrl); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=123%20Main%20Street%2C%20New%20York%2C%20NY&dirflg=d'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); - }); + it.each([ + // [platform, navigation app, URL opened] + ['ios', 'mapquest', `quest://search?q=${ENCODED_ADDRESS}`], + ['android', 'mapquest', `quest://search?q=${ENCODED_ADDRESS}`], + ['ios', 'organic_maps', `om://search?query=${ENCODED_ADDRESS}`], + ['android', 'organic_maps', `om://search?query=${ENCODED_ADDRESS}`], + // These apps cannot take an address, so it goes to the device default. + ['ios', 'gaia_gps', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + ['android', 'gaia_gps', `geo:0,0?q=${ENCODED_ADDRESS}`], + ['ios', 'here_wego', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + ['android', 'here_wego', `geo:0,0?q=${ENCODED_ADDRESS}`], + ['ios', 'onx_offroad', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + ['android', 'onx_offroad', `geo:0,0?q=${ENCODED_ADDRESS}`], + ['ios', 'osmand', `maps://maps.apple.com/?daddr=${ENCODED_ADDRESS}&dirflg=d`], + // OsmAnd searches an address around a point, and openMapsWithAddress has none to give it. + ['android', 'osmand', `geo:0,0?q=${ENCODED_ADDRESS}`], + ])('%s + %s opens %s for an address', async (platform, app, expectedUrl) => { + setPlatform(platform); + mockNavigationApp = app; + + const result = await openMapsWithAddress(ADDRESS); + + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenCalledTimes(1); + expect(MockedLinking.openURL).toHaveBeenCalledWith(expectedUrl); + }); - it('should handle unknown platform', async () => { - (Platform as any).OS = 'unknown'; + it('searches the fallback address around the origin in OsmAnd on Android', async () => { + setPlatform('android'); + mockNavigationApp = 'osmand'; - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + await openMapsWithDirections(0, 0, undefined, 50.8503, 4.3517, ADDRESS); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(MockedLinking.openURL).toHaveBeenCalledWith('https://www.google.com/maps/dir/?api=1&destination=123%20Main%20Street%2C%20New%20York%2C%20NY&travelmode=driving'); - expect(result).toBe(true); - }); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`osmand.api://navigate_search?dest_search_query=${ENCODED_ADDRESS}&search_lat=50.8503&search_lon=4.3517&profile=car`); }); - describe('Error Handling', () => { - beforeEach(() => { - (Platform as any).OS = 'ios'; - }); + it.each(['ios', 'android'])('opens the Waze universal link for an address on %s when Waze is not installed', async (platform) => { + setPlatform(platform); + mockNavigationApp = 'waze'; + MockedLinking.canOpenURL.mockResolvedValue(false); - it('should handle Linking.openURL errors', async () => { - const mockError = new Error('Unable to open URL'); - MockedLinking.openURL.mockRejectedValue(mockError); + await openMapsWithAddress(ADDRESS); - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`https://waze.com/ul?q=${ENCODED_ADDRESS}&navigate=yes`); + }); - expect(result).toBe(false); - }); + it('encodes special characters', async () => { + setPlatform('android'); + const address = 'Rue de l’Étuve 46 & Rue du Chêne, Bruxelles'; - it('should handle Linking.canOpenURL errors', async () => { - const mockError = new Error('Unable to check URL'); - MockedLinking.canOpenURL.mockRejectedValue(mockError); + await openMapsWithAddress(address); - const result = await openMapsWithAddress('123 Main Street, New York, NY'); + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:0,0?q=${encodeURIComponent(address)}`); + }); - expect(result).toBe(false); - }); + it('trims the address', async () => { + setPlatform('android'); + + await openMapsWithAddress(` ${ADDRESS} `); + + expect(MockedLinking.openURL).toHaveBeenCalledWith(`geo:0,0?q=${ENCODED_ADDRESS}`); }); - describe('Parameter Validation', () => { - it('should handle empty address', async () => { - const result = await openMapsWithAddress(''); + it('falls back to Google Maps on the web when the geo: intent has no handler', async () => { + setPlatform('android'); + MockedLinking.openURL.mockRejectedValueOnce(new Error('No Activity found to handle Intent')); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=&dirflg=d'); - expect(result).toBe(true); - }); + const result = await openMapsWithAddress(ADDRESS); - it('should handle address with only spaces', async () => { - const result = await openMapsWithAddress(' '); + expect(result).toBe(true); + expect(MockedLinking.openURL).toHaveBeenLastCalledWith(GOOGLE_WEB_ADDRESS); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith('maps://maps.apple.com/?daddr=%20%20%20&dirflg=d'); - expect(result).toBe(true); - }); + it.each(['', ' '])('opens nothing and returns false for a blank address (%p)', async (address) => { + const result = await openMapsWithAddress(address); - it('should handle very long address', async () => { - const longAddress = 'A'.repeat(1000); - const result = await openMapsWithAddress(longAddress); + expect(result).toBe(false); + expect(MockedLinking.openURL).not.toHaveBeenCalled(); + }); - expect(MockedLinking.canOpenURL).toHaveBeenCalledWith(`maps://maps.apple.com/?daddr=${encodeURIComponent(longAddress)}&dirflg=d`); - expect(result).toBe(true); - }); + it('returns false when nothing opens', async () => { + MockedLinking.openURL.mockRejectedValue(new Error('Failed')); + + const result = await openMapsWithAddress(ADDRESS); + + expect(result).toBe(false); + expect(mockedLogger.error).toHaveBeenCalled(); }); }); }); diff --git a/src/lib/contacts/__tests__/format.test.ts b/src/lib/contacts/__tests__/format.test.ts index 2e3cd00..6725412 100644 --- a/src/lib/contacts/__tests__/format.test.ts +++ b/src/lib/contacts/__tests__/format.test.ts @@ -1,4 +1,4 @@ -import { htmlToText, mapsUrls, parseCoordinates, withoutRedacted } from '@/lib/contacts/format'; +import { htmlToText, parseCoordinates, withoutRedacted } from '@/lib/contacts/format'; import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; it('turns web-editor rich text into readable plain text', () => { @@ -6,14 +6,10 @@ it('turns web-editor rich text into readable plain text', () => { expect(htmlToText(null)).toBe(''); }); -it('parses coordinates and builds a maps link for an address or a point', () => { +it('parses coordinates so a point routes as a point and anything else as an address', () => { expect(parseCoordinates('39.7392, -104.9903')).toEqual({ latitude: 39.7392, longitude: -104.9903 }); expect(parseCoordinates('not a place')).toBeNull(); expect(parseCoordinates('95, 10')).toBeNull(); - // The platform app link differs (maps: on iOS, geo: on Android); both carry the point or the encoded address. - expect(mapsUrls('39.7392,-104.9903').app).toMatch(/39\.7392,-104\.9903.*q=39\.7392%2C-104\.9903$/); - expect(mapsUrls('1 Main St, Denver').app).toMatch(/0,0\?q=1%20Main%20St%2C%20Denver$/); - expect(mapsUrls('1 Main St').web).toBe('https://maps.google.com/?q=1%20Main%20St'); }); it('removes withheld values and names the fields so the sheet never shows or dials the sentinel', () => { diff --git a/src/lib/contacts/format.ts b/src/lib/contacts/format.ts index 08a4951..6762dd4 100644 --- a/src/lib/contacts/format.ts +++ b/src/lib/contacts/format.ts @@ -1,5 +1,3 @@ -import { Platform } from 'react-native'; - import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; // Pure presentation helpers for a contact (the shared detail sheet in every field app). Nothing here decides @@ -31,19 +29,6 @@ export const parseCoordinates = (value: string | null | undefined): { latitude: return Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 ? { latitude, longitude } : null; }; -/** The platform maps app for an address or a coordinate pair, with a web map as the fallback. */ -export const mapsUrls = (query: string) => { - const coordinates = parseCoordinates(query); - const encoded = encodeURIComponent(coordinates ? `${coordinates.latitude},${coordinates.longitude}` : query); - const web = `https://maps.google.com/?q=${encoded}`; - const app = Platform.select({ - ios: coordinates ? `maps:?ll=${coordinates.latitude},${coordinates.longitude}&q=${encoded}` : `maps:0,0?q=${encoded}`, - android: coordinates ? `geo:${coordinates.latitude},${coordinates.longitude}?q=${encoded}` : `geo:0,0?q=${encoded}`, - default: web, - }); - return { app, web }; -}; - const REDACTED = 'REDACTED'; /** diff --git a/src/lib/hooks/__tests__/use-navigation-app.test.ts b/src/lib/hooks/__tests__/use-navigation-app.test.ts new file mode 100644 index 0000000..81f7fd4 --- /dev/null +++ b/src/lib/hooks/__tests__/use-navigation-app.test.ts @@ -0,0 +1,60 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { useNavigationApp } from '@/lib/hooks/use-navigation-app'; + +const mockSetStoredApp = jest.fn(); +let mockStoredApp: string | undefined; +jest.mock('react-native-mmkv', () => ({ + useMMKVString: jest.fn(() => [mockStoredApp, mockSetStoredApp]), +})); + +jest.mock('@/lib/storage', () => ({ + storage: {}, +})); + +describe('useNavigationApp', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStoredApp = undefined; + }); + + it('reads the MMKV navigation app key', () => { + const { useMMKVString } = jest.requireMock<{ useMMKVString: jest.Mock }>('react-native-mmkv'); + + renderHook(() => useNavigationApp()); + + expect(useMMKVString).toHaveBeenCalledWith('NAVIGATION_APP', expect.anything()); + }); + + it('defaults to the device default when nothing was saved', () => { + const { result } = renderHook(() => useNavigationApp()); + + expect(result.current.navigationApp).toBe('default'); + }); + + it('returns the saved navigation app', () => { + mockStoredApp = 'waze'; + + const { result } = renderHook(() => useNavigationApp()); + + expect(result.current.navigationApp).toBe('waze'); + }); + + it('treats an unknown saved value as the device default', () => { + mockStoredApp = 'sygic'; + + const { result } = renderHook(() => useNavigationApp()); + + expect(result.current.navigationApp).toBe('default'); + }); + + it('saves a new choice', () => { + const { result } = renderHook(() => useNavigationApp()); + + act(() => { + result.current.setNavigationApp('google_maps'); + }); + + expect(mockSetStoredApp).toHaveBeenCalledWith('google_maps'); + }); +}); diff --git a/src/lib/hooks/use-navigation-app.ts b/src/lib/hooks/use-navigation-app.ts new file mode 100644 index 0000000..31fe123 --- /dev/null +++ b/src/lib/hooks/use-navigation-app.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import { useMMKVString } from 'react-native-mmkv'; + +import { storage } from '@/lib/storage'; +import { NAVIGATION_APP_STORAGE_KEY, type NavigationApp, normalizeNavigationApp } from '@/lib/storage/navigation-app'; + +/** + * The per-device "Navigation app" setting, persisted in MMKV alongside the other app settings. + * `openMapsWithDirections` / `openMapsWithAddress` read the same key when they open. + */ +export const useNavigationApp = () => { + const [storedApp, _setStoredApp] = useMMKVString(NAVIGATION_APP_STORAGE_KEY, storage); + + const setNavigationApp = React.useCallback( + (app: NavigationApp) => { + _setStoredApp(app); + }, + [_setStoredApp] + ); + + const navigationApp = normalizeNavigationApp(storedApp); + return { navigationApp, setNavigationApp } as const; +}; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index fab0045..fcb467a 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -1,6 +1,8 @@ import { type Href, router } from 'expo-router'; import { Linking, Platform } from 'react-native'; +import { getNavigationApp, type NavigationApp } from '@/lib/storage/navigation-app'; + import { logger } from './logging'; export interface RouterPushRetryOptions { @@ -75,150 +77,345 @@ export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryO } }; +// --------------------------------------------------------------------------- +// Turn-by-turn navigation hand-off +// --------------------------------------------------------------------------- +// +// Which app opens is the per-device "Navigation app" setting (`@/lib/storage/navigation-app`): +// +// - default: Android geo: intent (the default navigation app, or the system chooser); +// iOS Apple Maps; web / desktop Google Maps in the browser. +// - google_maps: Android google.navigation: intent; iOS comgooglemaps:// when installed; +// web / desktop Google Maps in the browser. +// - apple_maps: iOS Apple Maps. It is offered on iOS only; elsewhere it behaves as default. +// - waze: waze:// when installed, else the https://waze.com/ul universal link (which +// opens Waze when installed, else the Waze web / store page). +// +// The apps below are offered on iOS and Android only. Several cannot start a route from a link, so +// they open at the destination instead; a destination one of them cannot take (an address, mostly) +// goes to the device default chain above. +// +// - gaia_gps: iOS gaiagps:// map; then the gaiagps.com/map universal link. Shows the point. +// - here_wego: the share.here.com route universal link, from the live location. Coordinates only. +// - mapquest: quest://search — MapQuest has no directions link, so it searches the destination. +// - onx_offroad: the offroad.onxmaps.com query universal link. Shows the point. Coordinates only. +// - organic_maps: om:// route when there is an origin (its route link needs a start point), else a +// pin; an address is searched. Then the same link on omaps.app. +// - osmand: Android osmand.api://navigate (navigate_search for an address, which needs an +// origin); iOS osmandmaps://navigate. Then osmand.net route planning. +// +// Every chain ends with the Google Maps https URL, used only if everything before it failed to open. +// +// `waze` and `comgooglemaps` are only probed with `Linking.canOpenURL`, which needs the scheme +// declared for querying (LSApplicationQueriesSchemes in app.config.ts; the Android +// entry added by customManifest.plugin.js). Everything else is opened directly and the chain +// moves on only when `openURL` rejects — `canOpenURL('geo:…')` is false on Android 11+ without +// a declaration, which is what used to divert every Android tap to Google Maps. + +interface NavigationCoordinates { + latitude: string; + longitude: string; +} + +interface NavigationDestination { + /** Validated, invariant-formatted destination coordinates, or null to navigate by address. */ + coordinates: NavigationCoordinates | null; + /** Trimmed destination address, used only when there are no usable coordinates. */ + address: string | null; + /** Display name for the pin, where the target app supports one. */ + label: string | null; + /** Explicit starting point; omitted means the navigation app uses the device's current location. */ + origin: NavigationCoordinates | null; +} + +interface NavigationAttempt { + url: string; + /** Open only when `Linking.canOpenURL` confirms the app is installed (the scheme is declared for querying). */ + requiresInstalledApp?: boolean; +} + /** - * Opens the device's native maps application with directions using an address. - * - * @param address - The destination address - * @returns Promise - True if the maps app was successfully opened + * One coordinate component as a number, or null when it is missing, not numeric or out of range. + * Accepts numbers and numeric strings, including a decimal comma ("50,8503") from a locale-formatted source. */ -export const openMapsWithAddress = async (address: string): Promise => { - const encodedAddress = encodeURIComponent(address); - let url = ''; +const parseCoordinate = (value: number | string | null | undefined, limit: number): number | null => { + if (value === null || value === undefined) { + return null; + } - // Platform-specific URL schemes - if (Platform.OS === 'ios') { - // Apple Maps (iOS) - url = `maps://maps.apple.com/?daddr=${encodedAddress}&dirflg=d`; - } else if (Platform.OS === 'android') { - // Google Maps (Android) - url = `google.navigation:q=${encodedAddress}`; - } else if (Platform.OS === 'web') { - // Google Maps (Web) - url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; - } else if (Platform.OS === 'windows' || Platform.OS === 'macos') { - // For desktop platforms, use web URL that will open in browser - url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + let numeric: number; + if (typeof value === 'number') { + numeric = value; + } else { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + // A lone comma between digits is a decimal separator, not a lat/lng separator. + numeric = Number(/^-?\d+,\d+$/.test(trimmed) ? trimmed.replace(',', '.') : trimmed); } - // Fallback to web URL if platform-specific URL is empty - if (!url) { - url = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; + return Number.isFinite(numeric) && Math.abs(numeric) <= limit ? numeric : null; +}; + +/** Locale-invariant decimal text: a dot separator, no exponent notation, no trailing zeros. */ +const formatCoordinate = (value: number): string => { + const formatted = value.toFixed(6).replace(/\.?0+$/, ''); + return formatted === '-0' ? '0' : formatted; +}; + +/** Both components as URL-ready text, or null when either is unusable. (0,0) is the server's "no location" sentinel. */ +const toNavigationCoordinates = (latitude: number | string | null | undefined, longitude: number | string | null | undefined): NavigationCoordinates | null => { + const lat = parseCoordinate(latitude, 90); + const lng = parseCoordinate(longitude, 180); + if (lat === null || lng === null || (lat === 0 && lng === 0)) { + return null; } + return { latitude: formatCoordinate(lat), longitude: formatCoordinate(lng) }; +}; - try { - const canOpen = await Linking.canOpenURL(url); - if (canOpen) { - await Linking.openURL(url); - return true; - } else { - // If the specific map app can't be opened, try a web fallback - const webUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}&travelmode=driving`; - await Linking.openURL(webUrl); +const toNonEmpty = (value: string | null | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +}; + +const toPair = (coordinates: NavigationCoordinates): string => `${coordinates.latitude},${coordinates.longitude}`; + +/** The destination as a URL parameter value: the coordinate pair, or the encoded address. */ +const toDestinationParam = ({ coordinates, address }: NavigationDestination): string => (coordinates ? toPair(coordinates) : encodeURIComponent(address ?? '')); + +/** Parentheses delimit the label inside a geo: query, so they must not appear unencoded in it. */ +const encodeGeoLabel = (label: string): string => encodeURIComponent(label).replace(/\(/g, '%28').replace(/\)/g, '%29'); + +const googleMapsWebUrl = (destination: NavigationDestination): string => { + const origin = destination.origin ? `&origin=${toPair(destination.origin)}` : ''; + return `https://www.google.com/maps/dir/?api=1${origin}&destination=${toDestinationParam(destination)}&travelmode=driving`; +}; + +const appleMapsUrl = (destination: NavigationDestination): string => { + const origin = destination.origin ? `saddr=${toPair(destination.origin)}&` : ''; + return `maps://maps.apple.com/?${origin}daddr=${toDestinationParam(destination)}&dirflg=d`; +}; + +const googleMapsIosUrl = (destination: NavigationDestination): string => { + const origin = destination.origin ? `saddr=${toPair(destination.origin)}&` : ''; + return `comgooglemaps://?${origin}daddr=${toDestinationParam(destination)}&directionsmode=driving`; +}; + +/** Google Maps' own turn-by-turn intent; it always starts from the current location. */ +const googleNavigationUrl = (destination: NavigationDestination): string => `google.navigation:q=${toDestinationParam(destination)}`; + +/** The generic Android location intent, which every navigation app (Waze, Google Maps, ...) handles. */ +const geoUrl = ({ coordinates, address, label }: NavigationDestination): string => { + if (coordinates) { + const pair = toPair(coordinates); + return `geo:${pair}?q=${pair}${label ? `(${encodeGeoLabel(label)})` : ''}`; + } + return `geo:0,0?q=${encodeURIComponent(address ?? '')}`; +}; + +/** Waze always navigates from the current location, so the origin is not passed. */ +const wazeQuery = ({ coordinates, address }: NavigationDestination): string => (coordinates ? `ll=${toPair(coordinates)}` : `q=${encodeURIComponent(address ?? '')}`); + +/** The zoom Gaia GPS opens at: close enough to read the trails around the point. */ +const GAIA_GPS_ZOOM = 14; + +/** + * Organic Maps' link path and query. Its route link needs an explicit start point, with all five keys + * in this order, so without an origin the destination is shown as a pin instead. + */ +const organicMapsPath = ({ coordinates, address, label, origin }: NavigationDestination): string => { + if (coordinates && origin) { + return `route?sll=${toPair(origin)}&saddr=&dll=${toPair(coordinates)}&daddr=${encodeURIComponent(label ?? toPair(coordinates))}&type=vehicle`; + } + if (coordinates) { + return `map?v=1&ll=${toPair(coordinates)}${label ? `&n=${encodeURIComponent(label)}` : ''}`; + } + return `search?query=${encodeURIComponent(address ?? '')}`; +}; + +/** + * OsmAnd's own links, which start guidance: its Android URL API and its iOS scheme. Null when OsmAnd + * cannot take the destination: an address search needs a point to search around (the origin), and + * iOS has no address link. + */ +const osmandAttempts = ({ coordinates, address, label, origin }: NavigationDestination, platform: string): NavigationAttempt[] | null => { + if (coordinates) { + // osmand.net's route planner opens OsmAnd on iOS (a universal link) and the web planner otherwise. + const routePlanner: NavigationAttempt = { url: `https://osmand.net/map/navigate/?end=${toPair(coordinates)}&profile=car` }; + const name = label ? encodeURIComponent(label) : null; + return platform === 'android' + ? [{ url: `osmand.api://navigate?dest_lat=${coordinates.latitude}&dest_lon=${coordinates.longitude}${name ? `&dest_name=${name}` : ''}&profile=car` }, routePlanner] + : [{ url: `osmandmaps://navigate?lat=${coordinates.latitude}&lon=${coordinates.longitude}${name ? `&title=${name}` : ''}` }, routePlanner]; + } + + return platform === 'android' && origin + ? [{ url: `osmand.api://navigate_search?dest_search_query=${encodeURIComponent(address ?? '')}&search_lat=${origin.latitude}&search_lon=${origin.longitude}&profile=car` }] + : null; +}; + +/** + * The app's own URLs for the apps offered on iOS and Android only, or null when the app is not one of + * them or cannot take this destination (the caller then uses the device default). Each custom scheme + * is opened directly: `openURL` rejects when the app is not installed, and the chain moves on. + */ +const buildMobileAppAttempts = (destination: NavigationDestination, app: NavigationApp, platform: string): NavigationAttempt[] | null => { + const { coordinates } = destination; + + switch (app) { + case 'gaia_gps': { + if (!coordinates) { + return null; + } + // Gaia GPS centers its map on the point; it has no route or address link. Its Android app has no custom scheme. + const universalLink: NavigationAttempt = { url: `https://www.gaiagps.com/map/?lat=${coordinates.latitude}&lon=${coordinates.longitude}&zoom=${GAIA_GPS_ZOOM}` }; + return platform === 'ios' ? [{ url: `gaiagps://map?lat=${coordinates.latitude}&lng=${coordinates.longitude}&z=${GAIA_GPS_ZOOM}` }, universalLink] : [universalLink]; + } + case 'here_wego': { + if (!coordinates) { + return null; + } + // `mylocation` starts the route from the live position. The link opens HERE WeGo when installed, else WeGo on the web. + const name = destination.label ? `,${encodeURIComponent(destination.label)}` : ''; + return [{ url: `https://share.here.com/r/mylocation/${toPair(coordinates)}${name}?m=d` }]; + } + case 'mapquest': + // MapQuest's only deep link is a search, so it opens the destination as a search result. + return [{ url: `quest://search?q=${toDestinationParam(destination)}` }]; + case 'onx_offroad': + // onX Offroad has no custom scheme; its universal link opens a point card, not a route. + return coordinates ? [{ url: `https://offroad.onxmaps.com/offroad/map/query/${toPair(coordinates)}` }] : null; + case 'organic_maps': { + const path = organicMapsPath(destination); + return [{ url: `om://${path}` }, { url: `https://omaps.app/${path}` }]; + } + case 'osmand': + return osmandAttempts(destination, platform); + default: + return null; + } +}; + +/** The URLs to try, in order, for a navigation app on a platform. */ +const buildNavigationAttempts = (destination: NavigationDestination, app: NavigationApp, platform: string): NavigationAttempt[] => { + const webFallback: NavigationAttempt = { url: googleMapsWebUrl(destination) }; + const isNativePlatform = platform === 'ios' || platform === 'android'; + + if (app === 'waze') { + // The universal link opens Waze when it is installed and the Waze web / store page otherwise. + const universalLink: NavigationAttempt = { url: `https://waze.com/ul?${wazeQuery(destination)}&navigate=yes` }; + return isNativePlatform ? [{ url: `waze://?${wazeQuery(destination)}&navigate=yes`, requiresInstalledApp: true }, universalLink, webFallback] : [universalLink, webFallback]; + } + + const mobileAppAttempts = isNativePlatform ? buildMobileAppAttempts(destination, app, platform) : null; + if (mobileAppAttempts) { + return [...mobileAppAttempts, webFallback]; + } + + if (platform === 'ios') { + return app === 'google_maps' ? [{ url: googleMapsIosUrl(destination), requiresInstalledApp: true }, webFallback] : [{ url: appleMapsUrl(destination) }, webFallback]; + } + + if (platform === 'android') { + return app === 'google_maps' ? [{ url: googleNavigationUrl(destination) }, webFallback] : [{ url: geoUrl(destination) }, webFallback]; + } + + return [webFallback]; +}; + +/** + * The error's name only. `Linking` rejections carry the URL in their message ("Unable to open URL: …" on iOS, + * "Could not open URL '…'" on Android), and the logger forwards an error's message to Sentry. + */ +const toErrorName = (error: unknown): string => (error instanceof Error ? error.name : typeof error); + +/** Opens the first attempt that works. True when one opened. */ +const openFirstAvailable = async (attempts: NavigationAttempt[], navigationApp: NavigationApp): Promise => { + let lastError: unknown; + + for (const attempt of attempts) { + try { + if (attempt.requiresInstalledApp && !(await Linking.canOpenURL(attempt.url))) { + continue; + } + await Linking.openURL(attempt.url); return true; + } catch (error) { + lastError = error; } + } + + // Only the schemes are logged: the URLs carry the incident address and location. + logger.error({ + message: 'Failed to open a navigation app', + context: { errorName: toErrorName(lastError), navigationApp, platform: Platform.OS, schemes: attempts.map((attempt) => attempt.url.split(':')[0]) }, + }); + return false; +}; + +/** Never rejects: every failure is logged and resolves false, so callers only need to check the result. */ +const openNavigation = async (destination: NavigationDestination): Promise => { + if (!destination.coordinates && !destination.address) { + logger.warn({ message: 'Navigation requested without usable coordinates or an address' }); + return false; + } + + const navigationApp = getNavigationApp(); + + let attempts: NavigationAttempt[]; + try { + attempts = buildNavigationAttempts(destination, navigationApp, Platform.OS); } catch (error) { - logger.error({ - message: 'Failed to open maps application with address', - context: { error, url, address }, - }); + // encodeURIComponent throws a URIError on a lone surrogate in the address or label. + logger.error({ message: 'Failed to build navigation URLs', context: { errorName: toErrorName(error), navigationApp, platform: Platform.OS } }); return false; } + + return openFirstAvailable(attempts, navigationApp); +}; + +/** + * Starts navigation to an address in the navigation app chosen in Settings. + * + * @param address - The destination address + * @returns Promise - True if a navigation or maps app was opened; false for a blank address or when nothing opened. Never rejects. + */ +export const openMapsWithAddress = async (address: string): Promise => { + const destinationAddress = toNonEmpty(address); + return openNavigation({ coordinates: null, address: destinationAddress, label: destinationAddress, origin: null }); }; /** - * Opens the device's native maps application with directions from the user's current location - * to the specified destination coordinates. + * Starts navigation to coordinates in the navigation app chosen in Settings, on iOS, Android, + * web and desktop. * - * This function works across all platforms: iOS, Android, Web, Windows, and macOS. + * Coordinates may be numbers or numeric strings. Missing, non-numeric, out-of-range and (0,0) + * coordinates are not navigable: the `fallbackAddress` is used instead when one is given, + * otherwise nothing opens and the result is false. * * @param destinationLatitude - The latitude of the destination * @param destinationLongitude - The longitude of the destination * @param destinationName - Optional name/label for the destination * @param originLatitude - Optional latitude of the starting point (if not provided, current location is used) * @param originLongitude - Optional longitude of the starting point (if not provided, current location is used) - * @returns Promise - True if the maps app was successfully opened + * @param fallbackAddress - Optional destination address, used when the coordinates are not navigable + * @returns Promise - True if a navigation or maps app was opened; false when nothing opened. Never rejects. */ export const openMapsWithDirections = async ( - destinationLatitude: number | string, - destinationLongitude: number | string, + destinationLatitude: number | string | null | undefined, + destinationLongitude: number | string | null | undefined, destinationName?: string, originLatitude?: number | string, - originLongitude?: number | string + originLongitude?: number | string, + fallbackAddress?: string | null ): Promise => { - // Convert coordinates to strings if they're numbers - const destLat = typeof destinationLatitude === 'number' ? destinationLatitude.toString() : destinationLatitude; - const destLng = typeof destinationLongitude === 'number' ? destinationLongitude.toString() : destinationLongitude; - - let url = ''; - - // Platform-specific URL schemes - if (Platform.OS === 'ios') { - // Apple Maps (iOS) - if (originLatitude && originLongitude) { - // With specific origin - const originLat = typeof originLatitude === 'number' ? originLatitude.toString() : originLatitude; - const originLng = typeof originLongitude === 'number' ? originLongitude.toString() : originLongitude; - url = `maps://maps.apple.com/?saddr=${originLat},${originLng}&daddr=${destLat},${destLng}&dirflg=d`; - } else { - // Using current location as origin - url = `maps://maps.apple.com/?daddr=${destLat},${destLng}&dirflg=d`; - } - } else if (Platform.OS === 'android') { - // Google Maps (Android) - if (originLatitude && originLongitude) { - // With specific origin - const originLat = typeof originLatitude === 'number' ? originLatitude.toString() : originLatitude; - const originLng = typeof originLongitude === 'number' ? originLongitude.toString() : originLongitude; - url = `google.navigation:q=${destLat},${destLng}&origin=${originLat},${originLng}`; - } else { - // Using current location as origin - url = `google.navigation:q=${destLat},${destLng}`; - } - } else if (Platform.OS === 'web') { - // Google Maps (Web) - if (originLatitude && originLongitude) { - // With specific origin - const originLat = typeof originLatitude === 'number' ? originLatitude.toString() : originLatitude; - const originLng = typeof originLongitude === 'number' ? originLongitude.toString() : originLongitude; - url = `https://www.google.com/maps/dir/?api=1&origin=${originLat},${originLng}&destination=${destLat},${destLng}&travelmode=driving`; - } else { - // Using current location as origin (Google Maps will ask for permission) - url = `https://www.google.com/maps/dir/?api=1&destination=${destLat},${destLng}&travelmode=driving`; - } - } else if (Platform.OS === 'windows' || Platform.OS === 'macos') { - // For desktop platforms, use web URL that will open in browser - if (originLatitude && originLongitude) { - // With specific origin - const originLat = typeof originLatitude === 'number' ? originLatitude.toString() : originLatitude; - const originLng = typeof originLongitude === 'number' ? originLongitude.toString() : originLongitude; - url = `https://www.google.com/maps/dir/?api=1&origin=${originLat},${originLng}&destination=${destLat},${destLng}&travelmode=driving`; - } else { - // Using current location as origin - url = `https://www.google.com/maps/dir/?api=1&destination=${destLat},${destLng}&travelmode=driving`; - } - } + const coordinates = toNavigationCoordinates(destinationLatitude, destinationLongitude); + const address = coordinates ? null : toNonEmpty(fallbackAddress); - // Fallback to web URL if platform-specific URL is empty - if (!url) { - url = `https://www.google.com/maps/dir/?api=1&destination=${destLat},${destLng}&travelmode=driving`; - } - - try { - const canOpen = await Linking.canOpenURL(url); - if (canOpen) { - await Linking.openURL(url); - return true; - } else { - // If the specific map app can't be opened, try a web fallback - const webUrl = `https://www.google.com/maps/dir/?api=1&destination=${destLat},${destLng}&travelmode=driving`; - await Linking.openURL(webUrl); - return true; - } - } catch (error) { - logger.error({ - message: 'Failed to open maps application', - context: { error, url }, - }); - return false; - } + return openNavigation({ + coordinates, + address, + label: toNonEmpty(destinationName) ?? address, + origin: toNavigationCoordinates(originLatitude, originLongitude), + }); }; diff --git a/src/lib/storage/__tests__/navigation-app.test.ts b/src/lib/storage/__tests__/navigation-app.test.ts new file mode 100644 index 0000000..6049892 --- /dev/null +++ b/src/lib/storage/__tests__/navigation-app.test.ts @@ -0,0 +1,63 @@ +const mockStore = new Map(); +jest.mock('@/lib/storage', () => ({ + storage: { + getString: jest.fn((key: string) => mockStore.get(key)), + set: jest.fn((key: string, value: string) => { + mockStore.set(key, value); + }), + }, +})); + +import { storage } from '@/lib/storage'; +import { getNavigationApp, getNavigationAppOptions, NAVIGATION_APP_STORAGE_KEY, normalizeNavigationApp, setNavigationApp } from '@/lib/storage/navigation-app'; + +describe('navigation app preference', () => { + beforeEach(() => { + mockStore.clear(); + jest.clearAllMocks(); + }); + + it('offers Apple Maps on iOS only and the mobile-only apps on iOS and Android only', () => { + const mobileOnly = ['gaia_gps', 'here_wego', 'mapquest', 'onx_offroad', 'organic_maps', 'osmand']; + expect(getNavigationAppOptions('ios')).toEqual(['default', 'google_maps', 'apple_maps', 'waze', ...mobileOnly]); + expect(getNavigationAppOptions('android')).toEqual(['default', 'google_maps', 'waze', ...mobileOnly]); + expect(getNavigationAppOptions('web')).toEqual(['default', 'google_maps', 'waze']); + expect(getNavigationAppOptions('windows')).toEqual(['default', 'google_maps', 'waze']); + }); + + it('keeps a valid choice for the platform', () => { + expect(normalizeNavigationApp('waze', 'android')).toBe('waze'); + expect(normalizeNavigationApp('google_maps', 'ios')).toBe('google_maps'); + expect(normalizeNavigationApp('apple_maps', 'ios')).toBe('apple_maps'); + expect(normalizeNavigationApp('osmand', 'android')).toBe('osmand'); + expect(normalizeNavigationApp('onx_offroad', 'ios')).toBe('onx_offroad'); + }); + + it('falls back to the device default for missing, unknown or unavailable values', () => { + expect(normalizeNavigationApp(undefined, 'ios')).toBe('default'); + expect(normalizeNavigationApp(null, 'android')).toBe('default'); + expect(normalizeNavigationApp('tomtom_go', 'android')).toBe('default'); + expect(normalizeNavigationApp('apple_maps', 'android')).toBe('default'); + expect(normalizeNavigationApp('organic_maps', 'web')).toBe('default'); + }); + + it('defaults to the device default when nothing was saved', () => { + expect(getNavigationApp()).toBe('default'); + }); + + it('persists the choice under its MMKV key', () => { + setNavigationApp('waze'); + + expect(storage.set).toHaveBeenCalledWith(NAVIGATION_APP_STORAGE_KEY, 'waze'); + expect(NAVIGATION_APP_STORAGE_KEY).toBe('NAVIGATION_APP'); + expect(getNavigationApp()).toBe('waze'); + }); + + it('reads as the device default when storage throws', () => { + (storage.getString as jest.Mock).mockImplementationOnce(() => { + throw new Error('MMKV unavailable'); + }); + + expect(getNavigationApp()).toBe('default'); + }); +}); diff --git a/src/lib/storage/navigation-app.ts b/src/lib/storage/navigation-app.ts new file mode 100644 index 0000000..fb39417 --- /dev/null +++ b/src/lib/storage/navigation-app.ts @@ -0,0 +1,58 @@ +import { Platform } from 'react-native'; + +import { storage } from '@/lib/storage'; + +/** + * MMKV key for the per-device "Navigation app" preference: which app opens when the user + * asks for directions to a call, destination, POI, map pin, route stop or contact. + * + * It describes the apps installed on this device rather than anything about the signed-in + * user, so it is kept across logout (see the preserved-keys list in the app reset service). + */ +export const NAVIGATION_APP_STORAGE_KEY = 'NAVIGATION_APP'; + +/** + * - `default`: the device decides. Android sends a `geo:` intent, so the user's default + * navigation app opens, or the system asks which one (Waze, Google Maps, ...). iOS opens + * Apple Maps. Web and desktop open Google Maps in the browser. + * - `google_maps` / `apple_maps` / `waze`: always that app (Apple Maps exists on iOS only). + * - `gaia_gps` / `here_wego` / `mapquest` / `onx_offroad` / `organic_maps` / `osmand`: that app, on + * iOS and Android only. Some of them only show the destination rather than starting a route + * (see src/lib/navigation.ts). + */ +export type NavigationApp = 'default' | 'google_maps' | 'apple_maps' | 'waze' | 'gaia_gps' | 'here_wego' | 'mapquest' | 'onx_offroad' | 'organic_maps' | 'osmand'; + +const ALL_NAVIGATION_APPS: readonly NavigationApp[] = ['default', 'google_maps', 'apple_maps', 'waze', 'gaia_gps', 'here_wego', 'mapquest', 'onx_offroad', 'organic_maps', 'osmand']; + +/** Apps that only exist on phones and tablets, so web and desktop have nothing of theirs to open. */ +const MOBILE_ONLY_NAVIGATION_APPS: readonly NavigationApp[] = ['gaia_gps', 'here_wego', 'mapquest', 'onx_offroad', 'organic_maps', 'osmand']; + +const isOfferedOn = (app: NavigationApp, platform: string): boolean => { + if (app === 'apple_maps') { + return platform === 'ios'; + } + return !MOBILE_ONLY_NAVIGATION_APPS.includes(app) || platform === 'ios' || platform === 'android'; +}; + +/** The choices that make sense on a platform, in display order. Apple Maps is iOS-only; the less common apps are mobile-only. */ +export const getNavigationAppOptions = (platform: string = Platform.OS): NavigationApp[] => ALL_NAVIGATION_APPS.filter((app) => isOfferedOn(app, platform)); + +/** A stored value as a valid choice for the platform; anything unknown or unavailable is `default`. */ +export const normalizeNavigationApp = (value: string | null | undefined, platform: string = Platform.OS): NavigationApp => { + const match = getNavigationAppOptions(platform).find((app) => app === value); + return match ?? 'default'; +}; + +/** The saved navigation app for this device, `default` when none was chosen. */ +export const getNavigationApp = (): NavigationApp => { + try { + return normalizeNavigationApp(storage.getString(NAVIGATION_APP_STORAGE_KEY)); + } catch { + return 'default'; + } +}; + +/** Saves the navigation app for this device. */ +export const setNavigationApp = (app: NavigationApp): void => { + storage.set(NAVIGATION_APP_STORAGE_KEY, app); +}; diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index ebad17d..a7c6e45 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -13,6 +13,7 @@ import { clearMapboxToken } from '@/lib/mapbox-token'; import { SHARED_INSTALLATION_STORAGE_KEY } from '@/lib/mfa/shared-installation'; import { storage } from '@/lib/storage'; import { BASE_API_URL_STORAGE_KEY, removeActiveCallId, removeDeviceUuid } from '@/lib/storage/app'; +import { NAVIGATION_APP_STORAGE_KEY } from '@/lib/storage/navigation-app'; import { locationService } from '@/services/location'; import { pushNotificationService } from '@/services/push-notification'; import { signalRService } from '@/services/signalr.service'; @@ -189,7 +190,8 @@ export const INITIAL_PUSH_NOTIFICATION_MODAL_STATE = { // Keys to preserve during storage clear. The selected server URL is an install-level // preference: wiping it on logout would silently send the next sign-in to the default server. // A shared command device stays shared across operators: signing out is exactly when the next one signs in. -const STORAGE_KEYS_TO_PRESERVE = ['IS_FIRST_TIME', BASE_API_URL_STORAGE_KEY, SHARED_INSTALLATION_STORAGE_KEY]; +// The navigation app names an app installed on this device, so the next operator needs it too. +const STORAGE_KEYS_TO_PRESERVE = ['IS_FIRST_TIME', BASE_API_URL_STORAGE_KEY, SHARED_INSTALLATION_STORAGE_KEY, NAVIGATION_APP_STORAGE_KEY]; /** * Clears all persisted storage items except those in the preserve list diff --git a/src/translations/ar.json b/src/translations/ar.json index 17ce7fc..34b49b3 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "هل أنت متأكد من رغبتك في تسجيل الخروج؟ سيؤدي هذا إلى مسح جميع بيانات التطبيق والقيم المخزنة مؤقتاً والإعدادات.", "logout_confirm_title": "تأكيد تسجيل الخروج", "more": "المزيد", + "navigation_app": { + "apple_maps": "خرائط Apple", + "default": "الافتراضي للجهاز", + "description": "التطبيق الذي يفتحه هذا الجهاز عند بدء الملاحة إلى موقع مكالمة أو وجهة أو دبوس على الخريطة. بعض التطبيقات تعرض الموقع فقط بدلاً من بدء مسار.", + "gaia_gps": "Gaia GPS", + "google_maps": "خرائط Google", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "تطبيق الملاحة", + "waze": "Waze" + }, "no_units_available": "لا توجد وحدات متاحة", "notifications": "الإشعارات", "notifications_description": "تفعيل الإشعارات لتلقي التنبيهات والتحديثات", diff --git a/src/translations/de.json b/src/translations/de.json index f10e7ee..0f31031 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Sind Sie sicher, dass Sie sich abmelden möchten? Dadurch werden alle App-Daten, zwischengespeicherten Werte und Einstellungen gelöscht.", "logout_confirm_title": "Abmeldung bestätigen", "more": "Mehr", + "navigation_app": { + "apple_maps": "Apple Karten", + "default": "Gerätestandard", + "description": "Die App, die dieses Gerät öffnet, wenn Sie die Navigation zu einem Einsatz, Ziel oder Kartenpunkt starten. Einige Apps zeigen nur den Standort an, statt eine Route zu starten.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Navigations-App", + "waze": "Waze" + }, "no_units_available": "Keine Einheiten verfügbar", "notifications": "Push-Benachrichtigungen", "notifications_description": "Benachrichtigungen aktivieren, um Warnungen und Updates zu erhalten", diff --git a/src/translations/el.json b/src/translations/el.json index 80b2624..08157b4 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε; Αυτό θα διαγράψει όλα τα δεδομένα της εφαρμογής, τις προσωρινά αποθηκευμένες τιμές και τις ρυθμίσεις.", "logout_confirm_title": "Επιβεβαίωση Αποσύνδεσης", "more": "Περισσότερα", + "navigation_app": { + "apple_maps": "Χάρτες Apple", + "default": "Προεπιλογή συσκευής", + "description": "Η εφαρμογή που ανοίγει αυτή η συσκευή όταν ξεκινάτε πλοήγηση προς μια κλήση, έναν προορισμό ή μια καρφίτσα στον χάρτη. Ορισμένες εφαρμογές εμφανίζουν μόνο την τοποθεσία αντί να ξεκινούν διαδρομή.", + "gaia_gps": "Gaia GPS", + "google_maps": "Χάρτες Google", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Εφαρμογή πλοήγησης", + "waze": "Waze" + }, "no_units_available": "Δεν υπάρχουν διαθέσιμες μονάδες", "notifications": "Ειδοποιήσεις Push", "notifications_description": "Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε ειδοποιήσεις και ενημερώσεις", diff --git a/src/translations/en.json b/src/translations/en.json index 80f27ce..ac9d372 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Are you sure you want to logout? This will clear all app data, cached values, and settings.", "logout_confirm_title": "Confirm Logout", "more": "More", + "navigation_app": { + "apple_maps": "Apple Maps", + "default": "Device default", + "description": "The app this device opens when you start navigation to a call, destination or map pin. Some apps only show the location instead of starting a route.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Navigation App", + "waze": "Waze" + }, "no_units_available": "No units available", "notifications": "Push Notifications", "notifications_description": "Enable notifications to receive alerts and updates", diff --git a/src/translations/es.json b/src/translations/es.json index 1d92cc2..528eb6b 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "¿Está seguro de que desea cerrar sesión? Esto borrará todos los datos de la aplicación, valores en caché y configuraciones.", "logout_confirm_title": "Confirmar cierre de sesión", "more": "Más", + "navigation_app": { + "apple_maps": "Mapas de Apple", + "default": "Predeterminada del dispositivo", + "description": "La aplicación que abre este dispositivo cuando inicia la navegación hacia una llamada, un destino o un marcador del mapa. Algunas aplicaciones solo muestran la ubicación en lugar de iniciar una ruta.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Aplicación de navegación", + "waze": "Waze" + }, "no_units_available": "No hay unidades disponibles", "notifications": "Notificaciones push", "notifications_description": "Activa las notificaciones para recibir alertas y actualizaciones", diff --git a/src/translations/fr.json b/src/translations/fr.json index 7288a9a..6ce0c00 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Êtes-vous sûr de vouloir vous déconnecter ? Cela effacera toutes les données de l'application, les valeurs mises en cache et les paramètres.", "logout_confirm_title": "Confirmer la déconnexion", "more": "Plus", + "navigation_app": { + "apple_maps": "Plans d'Apple", + "default": "Par défaut de l'appareil", + "description": "L'application que cet appareil ouvre lorsque vous lancez la navigation vers un appel, une destination ou un repère de la carte. Certaines applications affichent seulement l'emplacement au lieu de lancer un itinéraire.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Application de navigation", + "waze": "Waze" + }, "no_units_available": "Aucune unité disponible", "notifications": "Notifications push", "notifications_description": "Activez les notifications pour recevoir des alertes et des mises à jour", diff --git a/src/translations/it.json b/src/translations/it.json index 5487b28..be65dd5 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Sei sicuro di voler uscire? Questo cancellerà tutti i dati dell'app, i valori memorizzati nella cache e le impostazioni.", "logout_confirm_title": "Conferma uscita", "more": "Altro", + "navigation_app": { + "apple_maps": "Mappe di Apple", + "default": "Predefinita del dispositivo", + "description": "L'app che questo dispositivo apre quando avvii la navigazione verso una chiamata, una destinazione o un segnaposto sulla mappa. Alcune app mostrano solo la posizione invece di avviare un percorso.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "App di navigazione", + "waze": "Waze" + }, "no_units_available": "Nessuna unità disponibile", "notifications": "Notifiche push", "notifications_description": "Abilita le notifiche per ricevere avvisi e aggiornamenti", diff --git a/src/translations/pl.json b/src/translations/pl.json index 75c6ff1..c3c3350 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Czy na pewno chcesz się wylogować? Spowoduje to wyczyszczenie wszystkich danych aplikacji, wartości w pamięci podręcznej i ustawień.", "logout_confirm_title": "Potwierdź wylogowanie", "more": "Więcej", + "navigation_app": { + "apple_maps": "Mapy Apple", + "default": "Domyślna urządzenia", + "description": "Aplikacja, którą to urządzenie otwiera po rozpoczęciu nawigacji do zgłoszenia, celu lub pinezki na mapie. Niektóre aplikacje pokazują tylko lokalizację zamiast wyznaczać trasę.", + "gaia_gps": "Gaia GPS", + "google_maps": "Mapy Google", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Aplikacja nawigacyjna", + "waze": "Waze" + }, "no_units_available": "Brak dostępnych jednostek", "notifications": "Powiadomienia push", "notifications_description": "Włącz powiadomienia, aby otrzymywać alerty i aktualizacje", diff --git a/src/translations/sv.json b/src/translations/sv.json index c2181aa..f897061 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Är du säker på att du vill logga ut? Detta rensar all appdata, cachade värden och inställningar.", "logout_confirm_title": "Bekräfta utloggning", "more": "Mer", + "navigation_app": { + "apple_maps": "Apple Kartor", + "default": "Enhetens standard", + "description": "Appen som den här enheten öppnar när du startar navigering till ett larm, en destination eller en kartnål. Vissa appar visar bara platsen i stället för att starta en rutt.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Maps", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Navigeringsapp", + "waze": "Waze" + }, "no_units_available": "Inga enheter tillgängliga", "notifications": "Push-aviseringar", "notifications_description": "Aktivera aviseringar för att ta emot varningar och uppdateringar", diff --git a/src/translations/uk.json b/src/translations/uk.json index 020bfa2..b1ce3f9 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -2288,6 +2288,20 @@ "logout_confirm_message": "Ви впевнені, що хочете вийти? Це очистить усі дані додатку, кешовані значення та налаштування.", "logout_confirm_title": "Підтвердити вихід", "more": "Більше", + "navigation_app": { + "apple_maps": "Apple Карти", + "default": "Типовий для пристрою", + "description": "Застосунок, який цей пристрій відкриває, коли ви починаєте навігацію до виклику, пункту призначення або мітки на карті. Деякі застосунки лише показують місце замість того, щоб прокласти маршрут.", + "gaia_gps": "Gaia GPS", + "google_maps": "Google Карти", + "here_wego": "HERE WeGo", + "mapquest": "MapQuest", + "onx_offroad": "onX Offroad", + "organic_maps": "Organic Maps", + "osmand": "OsmAnd", + "title": "Навігаційний застосунок", + "waze": "Waze" + }, "no_units_available": "Немає доступних підрозділів", "notifications": "Push-сповіщення", "notifications_description": "Увімкніть сповіщення для отримання попереджень та оновлень",