diff --git a/src/components/MapView/GPSMapView.tsx b/src/components/MapView/GPSMapView.tsx index c6c0c0e03041..e582489ee6d8 100644 --- a/src/components/MapView/GPSMapView.tsx +++ b/src/components/MapView/GPSMapView.tsx @@ -1,61 +1,26 @@ -import Button from '@components/Button'; -import ImageSVG from '@components/ImageSVG'; - import useAppFocusEvent from '@hooks/useAppFocusEvent'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useOnyx from '@hooks/useOnyx'; -import useTheme from '@hooks/useTheme'; +import useLocationServicesRemountKey from '@hooks/useLocationServicesRemountKey'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; import useLocalize from '@src/hooks/useLocalize'; import useNetwork from '@src/hooks/useNetwork'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import type {MapState} from '@rnmapbox/maps'; import {useFocusEffect} from '@react-navigation/native'; -import Mapbox, {MarkerView} from '@rnmapbox/maps'; import {getForegroundPermissionsAsync} from 'expo-location'; -import {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; -import {useSharedValue} from 'react-native-reanimated'; +import {useState} from 'react'; import type {GPSMapViewProps} from './MapViewTypes'; -import Compass from './Compass'; -import GPSDirection from './GPSDirection'; -import GPSWaypointLayer from './GPSWaypointLayer'; -import LayerOrderAnchors from './LayerOrderAnchors'; +import GPSMapViewContent from './GPSMapViewContent'; import PendingMapView from './PendingMapView'; -import responder from './responder'; import useAccessToken from './useAccessToken'; -import utils from './utils'; - -const LOCATION_PUCK_PULSING = { - isEnabled: true, - color: CONST.MAP_CURRENT_LOCATION_FILL_COLOR, - radius: 40.0, -}; - -const CURRENT_LOCATION_PUCK_IMAGE = 'current-location-puck-image'; - -function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, waypoints, directionCoordinates: directionCoordinatesProp, isTrackingGPS}: GPSMapViewProps) { - const directionCoordinates = utils.convertSegmentedRouteToSingleSegmentRoute(directionCoordinatesProp); - const noWaypoints = !waypoints || waypoints.length === 0; - - // Fitting the camera to bounds around a single point zooms it in as far as it goes, so such a trip is centered at a fixed zoom instead - const singlePointCoordinate = utils.getSinglePointCoordinate(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], directionCoordinates); +function GPSMapView({accessToken, ...contentProps}: GPSMapViewProps) { const {isOffline} = useNetwork(); const {translate} = useLocalize(); const styles = useThemeStyles(); - const theme = useTheme(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'MapCurrentLocationPuck', 'MapCurrentLocation']); const isAccessTokenReady = useAccessToken({accessToken}); - const cameraRef = useRef(null); - const [foregroundLocationPermissionsGranted, setForegroundLocationPermissionsGranted] = useState(null); // Check (never request) foreground location permissions to determine if we can use the followUserLocation prop on the map camera. @@ -89,234 +54,15 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way }); }); - const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); - const centerCoordinate = userLocation ? [userLocation.longitude, userLocation.latitude] : CONST.MAPBOX.DEFAULT_COORDINATE; - - const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); - const [shouldUseImmediateFollowTransition, setShouldUseImmediateFollowTransition] = useState(noWaypoints || isTrackingGPS); - const [lastLocation, setLastLocation] = useState<{longitude: number; latitude: number} | undefined>(); - - // Determines if map can be panned to user's detected location without bothering the user. It will return - // false if user has already started dragging the map or if there are one or more waypoints present - // and the GPS trip is not active or the foreground location permissions are not granted. - const shouldFollowUserLocation = !userInteractedWithMap && (noWaypoints || isTrackingGPS) && foregroundLocationPermissionsGranted !== false; - - // When the route/waypoints are cleared (e.g. discarding a GPS trip), - // resume following the user's current location. - const prevWaypointsLength = useRef(waypoints?.length ?? 0); - useEffect(() => { - const currentLength = waypoints?.length ?? 0; - if (prevWaypointsLength.current > 0 && currentLength === 0) { - // Reset the user interaction state to allow the map to follow the user's location - setUserInteractedWithMap(false); - - // If foreground location permissions are not granted, center the map on the fallback location - if (!foregroundLocationPermissionsGranted) { - cameraRef.current?.setCamera({ - zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, - centerCoordinate, - }); - } - } - prevWaypointsLength.current = currentLength; - // only run when waypoints length changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [waypoints?.length]); - - useFocusEffect(() => { - if (noWaypoints || shouldFollowUserLocation || !!lastLocation || userInteractedWithMap) { - return; - } - - if (singlePointCoordinate) { - cameraRef.current?.setCamera({ - zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, - centerCoordinate: singlePointCoordinate, - }); - return; - } - - const {southWest, northEast} = utils.getBounds( - waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, - ); - cameraRef.current?.fitBounds(northEast, southWest, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); - }); - - const centerMap = () => { - const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; - if (!isTrackingGPS && (waypointCoordinates.length > 1 || directionCoordinates?.length > 1)) { - const {southWest, northEast} = utils.getBounds(waypointCoordinates, directionCoordinates); - cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); - return; - } - // Reset the user interaction state to allow the map to follow the user's location - setUserInteractedWithMap(false); - - // If foreground location permissions are not granted, center the map on the fallback location - if (!foregroundLocationPermissionsGranted) { - cameraRef.current?.setCamera({ - zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, - centerCoordinate, - }); - } - }; - - const getWaypointBounds = () => { - if (!waypoints || userInteractedWithMap || !!singlePointCoordinate || (!waypoints.length && !directionCoordinates?.length)) { - return undefined; - } - - const {northEast, southWest} = utils.getBounds( - waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, - ); - return {ne: northEast, sw: southWest}; - }; - - const waypointsBounds = getWaypointBounds(); - const waypointsCenterCoordinate = userInteractedWithMap ? undefined : singlePointCoordinate; - const waypointsZoomLevel = waypointsCenterCoordinate ? CONST.MAPBOX.SINGLE_MARKER_ZOOM : undefined; - - const onUserLocationUpdate = (update: Mapbox.Location) => { - const coords = update.coords; - setLastLocation({longitude: coords.longitude, latitude: coords.latitude}); - }; - - const shouldFollowFallbackLocation = noWaypoints && foregroundLocationPermissionsGranted === false; - - const cameraPadding: Mapbox.CameraPadding | undefined = - mapPadding !== undefined ? {paddingLeft: mapPadding, paddingRight: mapPadding, paddingTop: mapPadding, paddingBottom: mapPadding} : undefined; - - // defaultSettings with bounds ensures there is immediate snap to GPS trip on map load - const defaultSettings: Mapbox.CameraStop | undefined = { - bounds: waypointsBounds, - padding: waypointsBounds ? cameraPadding : undefined, - centerCoordinate: shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate, - zoomLevel: shouldFollowFallbackLocation ? CONST.MAPBOX.DEFAULT_ZOOM : waypointsZoomLevel, - }; - - const mapHeading = useSharedValue(0); - - const onCameraChanged = (e: MapState) => { - mapHeading.set(e.properties.heading ?? 0); - }; + // A map created while Location services were off never gets a location fix again, so it is recreated once they come back + const mapInstanceKey = useLocationServicesRemountKey(); return !isOffline && isAccessTokenReady && foregroundLocationPermissionsGranted !== null ? ( - - setUserInteractedWithMap(true)} - pitchEnabled={pitchEnabled} - attributionPosition={{...styles.r2, ...styles.b2}} - scaleBarEnabled={false} - // We use scaleBarPosition with top: -32 to hide the scale bar on iOS because scaleBarEnabled={false} not work on iOS - scaleBarPosition={{...styles.tn8, left: 0}} - compassEnabled={false} - onCameraChanged={onCameraChanged} - logoPosition={{...styles.l2, ...styles.b2}} - {...responder.panHandlers} - > - - - { - if (!shouldUseImmediateFollowTransition) { - return; - } - - if (event.from.kind === 'transition' && event.from.toState.kind === 'followPuck') { - setShouldUseImmediateFollowTransition(false); - } - }} - /> - - - {/** Show fallback location if foreground location permissions are not granted */} - {foregroundLocationPermissionsGranted === false && ( - - - - )} - - - - - - - - {/** We want to use our custom current location marker instead of the default one */} - {!!foregroundLocationPermissionsGranted && ( - <> - - - - )} - - - - {!noWaypoints && ( - - )} - - - - - - + ) : ( waypoint.coordinate) ?? [], directionCoordinates); + + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'MapCurrentLocationPuck', 'MapCurrentLocation']); + + const cameraRef = useRef(null); + + const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); + const centerCoordinate = userLocation ? [userLocation.longitude, userLocation.latitude] : CONST.MAPBOX.DEFAULT_COORDINATE; + + const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); + const [shouldUseImmediateFollowTransition, setShouldUseImmediateFollowTransition] = useState(noWaypoints || isTrackingGPS); + const [lastLocation, setLastLocation] = useState<{longitude: number; latitude: number} | undefined>(); + + // Determines if map can be panned to user's detected location without bothering the user. It will return + // false if user has already started dragging the map or if there are one or more waypoints present + // and the GPS trip is not active or the foreground location permissions are not granted. + const shouldFollowUserLocation = !userInteractedWithMap && (noWaypoints || isTrackingGPS) && foregroundLocationPermissionsGranted; + + // When the route/waypoints are cleared (e.g. discarding a GPS trip), + // resume following the user's current location. + const prevWaypointsLength = useRef(waypoints?.length ?? 0); + useEffect(() => { + const currentLength = waypoints?.length ?? 0; + if (prevWaypointsLength.current > 0 && currentLength === 0) { + // Reset the user interaction state to allow the map to follow the user's location + setUserInteractedWithMap(false); + + // If foreground location permissions are not granted, center the map on the fallback location + if (!foregroundLocationPermissionsGranted) { + cameraRef.current?.setCamera({ + zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + centerCoordinate, + }); + } + } + prevWaypointsLength.current = currentLength; + // only run when waypoints length changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [waypoints?.length]); + + useFocusEffect(() => { + if (noWaypoints || shouldFollowUserLocation || !!lastLocation || userInteractedWithMap) { + return; + } + + if (singlePointCoordinate) { + cameraRef.current?.setCamera({ + zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + centerCoordinate: singlePointCoordinate, + }); + return; + } + + const {southWest, northEast} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + directionCoordinates, + ); + cameraRef.current?.fitBounds(northEast, southWest, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); + }); + + const centerMap = () => { + const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; + if (!isTrackingGPS && (waypointCoordinates.length > 1 || directionCoordinates?.length > 1)) { + const {southWest, northEast} = utils.getBounds(waypointCoordinates, directionCoordinates); + cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); + return; + } + // Reset the user interaction state to allow the map to follow the user's location + setUserInteractedWithMap(false); + + // If foreground location permissions are not granted, center the map on the fallback location + if (!foregroundLocationPermissionsGranted) { + cameraRef.current?.setCamera({ + zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + centerCoordinate, + }); + } + }; + + const getWaypointBounds = () => { + if (!waypoints || userInteractedWithMap || !!singlePointCoordinate || (!waypoints.length && !directionCoordinates?.length)) { + return undefined; + } + + const {northEast, southWest} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + directionCoordinates, + ); + return {ne: northEast, sw: southWest}; + }; + + const waypointsBounds = getWaypointBounds(); + const waypointsCenterCoordinate = userInteractedWithMap ? undefined : singlePointCoordinate; + const waypointsZoomLevel = waypointsCenterCoordinate ? CONST.MAPBOX.SINGLE_MARKER_ZOOM : undefined; + + const onUserLocationUpdate = (update: Mapbox.Location) => { + const coords = update.coords; + setLastLocation({longitude: coords.longitude, latitude: coords.latitude}); + }; + + const shouldFollowFallbackLocation = noWaypoints && !foregroundLocationPermissionsGranted; + + const cameraPadding: Mapbox.CameraPadding | undefined = + mapPadding !== undefined ? {paddingLeft: mapPadding, paddingRight: mapPadding, paddingTop: mapPadding, paddingBottom: mapPadding} : undefined; + + // defaultSettings with bounds ensures there is immediate snap to GPS trip on map load + const defaultSettings: Mapbox.CameraStop | undefined = { + bounds: waypointsBounds, + padding: waypointsBounds ? cameraPadding : undefined, + centerCoordinate: shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate, + zoomLevel: shouldFollowFallbackLocation ? CONST.MAPBOX.DEFAULT_ZOOM : waypointsZoomLevel, + }; + + const mapHeading = useSharedValue(0); + + const onCameraChanged = (e: MapState) => { + mapHeading.set(e.properties.heading ?? 0); + }; + + return ( + + setUserInteractedWithMap(true)} + pitchEnabled={pitchEnabled} + attributionPosition={{...styles.r2, ...styles.b2}} + scaleBarEnabled={false} + // We use scaleBarPosition with top: -32 to hide the scale bar on iOS because scaleBarEnabled={false} not work on iOS + scaleBarPosition={{...styles.tn8, left: 0}} + compassEnabled={false} + onCameraChanged={onCameraChanged} + logoPosition={{...styles.l2, ...styles.b2}} + {...responder.panHandlers} + > + + + { + if (!shouldUseImmediateFollowTransition) { + return; + } + + // Clear the flag only once following actually succeeds. A failed transition (e.g. no location fix yet) + // would otherwise leave the retry with Mapbox's default transition, which can time out and leave the camera stuck. + if (event.from.kind === 'transition' && event.from.toState.kind === 'followPuck' && event.reason === 'TransitionSucceeded') { + setShouldUseImmediateFollowTransition(false); + } + }} + /> + + + {/** Show fallback location if foreground location permissions are not granted */} + {!foregroundLocationPermissionsGranted && ( + + + + )} + + + + + + + + {/** We want to use our custom current location marker instead of the default one */} + {!!foregroundLocationPermissionsGranted && ( + <> + + + + )} + + + + {!noWaypoints && ( + + )} + + + + + + + ); +} + +export default GPSMapViewContent; diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index 30aaf33db68f..d3d5520728b4 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -80,6 +80,11 @@ type GPSMapViewProps = Omit & { + /** Whether the foreground location permissions are granted */ + foregroundLocationPermissionsGranted: boolean; +}; + type GPSDirectionProps = { /** Whether the GPS trip is active */ isTrackingGPS: boolean; @@ -200,6 +205,7 @@ export type { WayPoint, MapViewProps, GPSMapViewProps, + GPSMapViewContentProps, DirectionProps, PendingMapViewProps, Coordinate, diff --git a/src/hooks/useLocationServicesRemountKey/index.android.ts b/src/hooks/useLocationServicesRemountKey/index.android.ts new file mode 100644 index 000000000000..595303b5ef7c --- /dev/null +++ b/src/hooks/useLocationServicesRemountKey/index.android.ts @@ -0,0 +1,49 @@ +import usePolling from '@hooks/usePolling'; + +import CONST from '@src/CONST'; + +import {useFocusEffect} from '@react-navigation/native'; +import {hasServicesEnabledAsync} from 'expo-location'; +import {useRef, useState} from 'react'; + +/** + * The Mapbox SDK creates the map's location provider once per map instance and never recovers if Location services were + * off at that moment (see https://github.com/rnmapbox/maps/issues/4106), so the map has to be recreated once they come back. + * Android only: iOS resumes on its own. + */ +function useLocationServicesRemountKey(): number { + const areLocationServicesEnabledRef = useRef(null); + const [remountKey, setRemountKey] = useState(0); + + useFocusEffect(() => { + let ignore = false; + hasServicesEnabledAsync().then((areLocationServicesEnabled) => { + if (ignore) { + return; + } + areLocationServicesEnabledRef.current = areLocationServicesEnabled; + }); + + return () => { + ignore = true; + }; + }); + + const checkLocationServices = async () => { + const areLocationServicesEnabled = await hasServicesEnabledAsync(); + const wereLocationServicesEnabled = areLocationServicesEnabledRef.current; + areLocationServicesEnabledRef.current = areLocationServicesEnabled; + + // Only an off -> on transition needs a fresh map; services being on from the start does not + if (wereLocationServicesEnabled !== false || !areLocationServicesEnabled) { + return; + } + setRemountKey((key) => key + 1); + }; + + usePolling(checkLocationServices, CONST.TIMING.LOCATION_UPDATE_INTERVAL, true, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + + return remountKey; +} + +export default useLocationServicesRemountKey; diff --git a/src/hooks/useLocationServicesRemountKey/index.ts b/src/hooks/useLocationServicesRemountKey/index.ts new file mode 100644 index 000000000000..7bd9b98b980d --- /dev/null +++ b/src/hooks/useLocationServicesRemountKey/index.ts @@ -0,0 +1,6 @@ +// Only Android needs to recreate the map after Location services come back, see index.android.ts +function useLocationServicesRemountKey(): number { + return 0; +} + +export default useLocationServicesRemountKey; diff --git a/tests/unit/GPSMapViewTest.tsx b/tests/unit/GPSMapViewTest.tsx new file mode 100644 index 000000000000..50270acabba6 --- /dev/null +++ b/tests/unit/GPSMapViewTest.tsx @@ -0,0 +1,102 @@ +import {act, render} from '@testing-library/react-native'; + +import GPSMapView from '@components/MapView/GPSMapView'; + +import type * as UseLocationServicesRemountKeyAndroid from '@hooks/useLocationServicesRemountKey/index.android'; + +import CONST from '@src/CONST'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {hasServicesEnabledAsync} from 'expo-location'; +import React from 'react'; + +const mockContentMount = jest.fn(); + +jest.mock('@components/MapView/GPSMapViewContent', () => { + const {useEffect} = jest.requireActual('react'); + function GPSMapViewContentStub() { + useEffect(() => { + mockContentMount(); + }, []); + return null; + } + return GPSMapViewContentStub; +}); +jest.mock('@components/MapView/PendingMapView', () => () => null); +jest.mock('@components/MapView/useAccessToken', () => jest.fn(() => true)); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +// Jest resolves the platform-agnostic stub, which never remounts; the Android implementation is what these tests cover +jest.mock('@hooks/useLocationServicesRemountKey', () => jest.requireActual('@hooks/useLocationServicesRemountKey/index.android')); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + // The screen counts as focused for the whole test, so the effect simply runs on every render like the real hook does + useFocusEffect: (callback: () => void | (() => void)) => { + jest.requireActual('react').useEffect(callback); + }, +})); + +// What the OS currently reports for Location services; the hook may read it any number of times per tick +let areLocationServicesEnabled = true; + +// Resolves the pending expo-location promises so state updates from them are applied +const flushPromises = () => + act(async () => { + await Promise.resolve(); + }); + +// Fires one polling tick (interval plus debounce) and resolves what it triggered +const advanceOnePollingTick = () => + act(async () => { + jest.advanceTimersByTime(CONST.TIMING.LOCATION_UPDATE_INTERVAL + CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + }); + +function renderGPSMapView() { + return render( + , + ); +} + +describe('GPSMapView', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockContentMount.mockClear(); + jest.mocked(hasServicesEnabledAsync).mockImplementation(() => Promise.resolve(areLocationServicesEnabled)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each([ + ['services stay on', [true, true], 1], + ['services come back on', [false, true], 2], + ['services go off and come back on', [true, false, true], 2], + ['services stay off', [false, false], 1], + ])('mounts the map %s: %j -> %i mount(s)', async (_description, servicesEnabledSequence, expectedMounts) => { + // Given Location services in the first state when the map screen opens + const [initialState, ...laterStates] = servicesEnabledSequence; + areLocationServicesEnabled = initialState; + renderGPSMapView(); + await flushPromises(); + + // When Location services move through the remaining states, one polling tick apart + for (const state of laterStates) { + areLocationServicesEnabled = state; + await advanceOnePollingTick(); + } + + // Then the map is recreated only after an off -> on transition, because the Mapbox SDK's location provider + // does not recover from Location services being off when the map was created + expect(mockContentMount).toHaveBeenCalledTimes(expectedMounts); + }); +}); diff --git a/tests/unit/useLocationServicesRemountKeyTest.ts b/tests/unit/useLocationServicesRemountKeyTest.ts new file mode 100644 index 000000000000..f2e92835bb40 --- /dev/null +++ b/tests/unit/useLocationServicesRemountKeyTest.ts @@ -0,0 +1,68 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useLocationServicesRemountKey from '@hooks/useLocationServicesRemountKey/index.android'; + +import CONST from '@src/CONST'; + +import type * as ReactNavigation from '@react-navigation/native'; +import type React from 'react'; + +import {hasServicesEnabledAsync} from 'expo-location'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + // The screen counts as focused for the whole test, so the effect simply runs on every render like the real hook does + useFocusEffect: (callback: () => void | (() => void)) => { + jest.requireActual('react').useEffect(callback); + }, +})); + +// What the OS currently reports for Location services; the hook may read it any number of times per tick +let areLocationServicesEnabled = true; + +// Resolves the pending expo-location promises so state updates from them are applied +const flushPromises = () => + act(async () => { + await Promise.resolve(); + }); + +// Fires one polling tick (interval plus debounce) and resolves what it triggered +const advanceOnePollingTick = () => + act(async () => { + jest.advanceTimersByTime(CONST.TIMING.LOCATION_UPDATE_INTERVAL + CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + }); + +describe('useLocationServicesRemountKey (Android)', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.mocked(hasServicesEnabledAsync).mockImplementation(() => Promise.resolve(areLocationServicesEnabled)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each([ + ['stay on', [true, true], 0], + ['come back on', [false, true], 1], + ['go off and come back on', [true, false, true], 1], + ['stay off', [false, false], 0], + ['come back on twice', [false, true, false, true], 2], + ])('when Location services %s (%j) the key changes %i time(s)', async (_description, servicesEnabledSequence, expectedKey) => { + // Given Location services in the first state when the hook mounts + const [initialState, ...laterStates] = servicesEnabledSequence; + areLocationServicesEnabled = initialState; + const {result} = renderHook(() => useLocationServicesRemountKey()); + await flushPromises(); + + // When Location services move through the remaining states, one polling tick apart + for (const state of laterStates) { + areLocationServicesEnabled = state; + await advanceOnePollingTick(); + } + + // Then the key changes once per off -> on transition and never for services being on from the start, + // so the consumer recreates its map only when the Mapbox location provider had no chance to start + expect(result.current).toBe(expectedKey); + }); +});