From 3e793b4d6b27eebbc1dbcc6e82c7ffabe90853d4 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 12 Sep 2026 19:29:25 +0300 Subject: [PATCH 1/3] fix: Keep the sole GPS segment when a trip is stopped after one recorded point --- src/libs/GPSDraftDetailsUtils.ts | 5 ++- tests/unit/GPSDraftDetailsUtilsTest.ts | 57 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index 277594267176..bb29ec0bf890 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -140,7 +140,10 @@ async function stopGpsTrip(isOffline: boolean, gpsPoints: GPSPoint[][], skipLast } if (isLastSegmentEmptyOrHasOnlyOnePoint(lastSegment)) { - removeLastSegment(gpsPoints); + // Dropping the sole segment would leave no points, which reads as a trip that never started + if (gpsPoints.length > 1) { + removeLastSegment(gpsPoints); + } return; } diff --git a/tests/unit/GPSDraftDetailsUtilsTest.ts b/tests/unit/GPSDraftDetailsUtilsTest.ts index 3c1c26fcc315..182964cb54c3 100644 --- a/tests/unit/GPSDraftDetailsUtilsTest.ts +++ b/tests/unit/GPSDraftDetailsUtilsTest.ts @@ -1,3 +1,4 @@ +import {removeLastSegment, setEndWaypointAddress, setIsTracking} from '@libs/actions/GPSDraftDetails'; import { calculateTrimmedEndPoint, getEffectiveDistance, @@ -7,6 +8,7 @@ import { getStringifiedGPSCoordinates, getTrimmedGpsTrip, gpsPointsToMapboxCoordinates, + stopGpsTrip, } from '@libs/GPSDraftDetailsUtils'; import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; @@ -14,6 +16,8 @@ import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Unit} from '@src/types/onyx/Policy'; import geodesicDistance from '@src/utils/geodesicDistance'; +jest.mock('@libs/actions/GPSDraftDetails'); + const point = (lat: number, long: number, address?: GPSPoint['address']): GPSPoint => ({lat, long, ...(address ? {address} : {})}); const makeDraft = (overrides: Partial = {}): GpsDraftDetails => ({ @@ -298,4 +302,57 @@ describe('GPSDraftDetailsUtils', () => { ]); }); }); + + describe('stopGpsTrip', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stops tracking the trip', async () => { + await stopGpsTrip(false, [[point(0, 0)]]); + expect(setIsTracking).toHaveBeenCalledWith(false); + }); + + it('keeps the only segment when it holds a single point, so the stopped trip stays visible', async () => { + const gpsPoints = [[point(0, 0)]]; + + await stopGpsTrip(false, gpsPoints); + + expect(removeLastSegment).not.toHaveBeenCalled(); + }); + + it('keeps the only segment when it is still empty', async () => { + const gpsPoints: GPSPoint[][] = [[]]; + + await stopGpsTrip(false, gpsPoints); + + expect(removeLastSegment).not.toHaveBeenCalled(); + expect(setEndWaypointAddress).not.toHaveBeenCalled(); + }); + + it('removes a resumed segment that holds a single point', async () => { + const gpsPoints = [[point(0, 0), point(0, 1)], [point(1, 0)]]; + + await stopGpsTrip(false, gpsPoints); + + expect(removeLastSegment).toHaveBeenCalledWith(gpsPoints); + }); + + it('removes a resumed segment that is empty', async () => { + const gpsPoints: GPSPoint[][] = [[point(0, 0), point(0, 1)], []]; + + await stopGpsTrip(false, gpsPoints); + + expect(removeLastSegment).toHaveBeenCalledWith(gpsPoints); + }); + + it('sets the end waypoint address when the last segment holds more than one point', async () => { + const gpsPoints = [[point(0, 0), point(0, 1)]]; + + await stopGpsTrip(false, gpsPoints); + + expect(removeLastSegment).not.toHaveBeenCalled(); + expect(setEndWaypointAddress).toHaveBeenCalledWith({value: '0,1', type: 'coordinates'}, gpsPoints); + }); + }); }); From 77648b25259454b719b4f014e0df96efdef05f2e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 13 Sep 2026 17:10:25 +0300 Subject: [PATCH 2/3] fix: Mark the stop and hide Edit on trips that keep a single GPS point --- src/libs/GPSDraftDetailsUtils.ts | 8 +- .../Waypoints/EditGPSTripButton.tsx | 6 +- .../useGPSWaypointMarkers.tsx | 7 +- tests/unit/GPSDraftDetailsUtilsTest.ts | 102 ++++++++++++------ tests/unit/useGPSWaypointMarkersTest.ts | 64 +++++++++++ 5 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 tests/unit/useGPSWaypointMarkersTest.ts diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index bb29ec0bf890..c1400e61b4c6 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -1,6 +1,6 @@ import type {Coordinate} from '@components/MapView/MapViewTypes'; -import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; +import {BACKGROUND_LOCATION_TRACKING_TASK_NAME, GPS_DISTANCE_INTERVAL_METERS} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; import type {GpsDraftDetails} from '@src/types/onyx'; @@ -190,6 +190,11 @@ function isTripStopped(gpsDraftDetails: GpsDraftDetails | undefined): boolean { return !gpsDraftDetails?.isTracking && getTotalGpsTripPoints(gpsDraftDetails) > 0; } +function canGpsTripBeTrimmed(gpsDraftDetails: GpsDraftDetails | undefined): boolean { + // Trimming cannot shorten a trip below one location interval, so a trip no longer than that has nothing to trim + return isTripStopped(gpsDraftDetails) && (gpsDraftDetails?.distanceInMeters ?? 0) > GPS_DISTANCE_INTERVAL_METERS; +} + function getGpsPoints(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoint[][] { return gpsDraftDetails?.gpsPoints ?? [[]]; } @@ -230,6 +235,7 @@ export { getGPSRoutes, getGPSWaypoints, stopGpsTrip, + canGpsTripBeTrimmed, getStringifiedGPSCoordinates, addressFromGpsPoint, coordinatesToString, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/EditGPSTripButton.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/EditGPSTripButton.tsx index 0068b4a0aae6..0d1166d5d057 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/EditGPSTripButton.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/EditGPSTripButton.tsx @@ -13,7 +13,7 @@ import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import {isTripStopped as isTripStoppedUtil} from '@src/libs/GPSDraftDetailsUtils'; +import {canGpsTripBeTrimmed} from '@src/libs/GPSDraftDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -30,9 +30,7 @@ function EditGPSTripButton({action, iouType, transactionID, reportID, backToRepo const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); - const isTripStopped = isTripStoppedUtil(gpsDraftDetails); - - if (!isTripStopped) { + if (!canGpsTripBeTrimmed(gpsDraftDetails)) { return null; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx index c8ff15b9a74e..203fbdfb26b6 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx @@ -2,7 +2,7 @@ import type {WayPoint} from '@components/MapView/MapViewTypes'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; -import {getGPSWaypoints, isTripStopped as isTripStoppedUtil} from '@libs/GPSDraftDetailsUtils'; +import {getGPSWaypoints, getTrimmedGpsTrip, isTripStopped as isTripStoppedUtil} from '@libs/GPSDraftDetailsUtils'; import type {GpsDraftDetails} from '@src/types/onyx'; import type {TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; @@ -20,11 +20,12 @@ function useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint: trimmedEndPoin const gpsWaypoints = getGPSWaypoints(gpsDraftDetails, trimmedEndPoint); const waypointEntries = Object.entries(gpsWaypoints); const lastIndex = waypointEntries.length - 1; + const isLastWaypointSegmentStart = getTrimmedGpsTrip(gpsDraftDetails, trimmedEndPoint).findLast((segment) => segment.length > 0)?.length === 1; return waypointEntries.flatMap(([key, waypoint], index): WayPoint[] => { const isStart = index === 0; - // End waypoint can only have odd index, as even indexes are start waypoints of trip segments - const isEnd = index === lastIndex && index % 2 === 1; + // A segment with one point contributes one waypoint, not a start and end pair + const isEnd = index === lastIndex && !isLastWaypointSegmentStart; if (isEnd && !isTripStopped) { return []; diff --git a/tests/unit/GPSDraftDetailsUtilsTest.ts b/tests/unit/GPSDraftDetailsUtilsTest.ts index 182964cb54c3..43f28d5becb2 100644 --- a/tests/unit/GPSDraftDetailsUtilsTest.ts +++ b/tests/unit/GPSDraftDetailsUtilsTest.ts @@ -1,6 +1,6 @@ -import {removeLastSegment, setEndWaypointAddress, setIsTracking} from '@libs/actions/GPSDraftDetails'; import { calculateTrimmedEndPoint, + canGpsTripBeTrimmed, getEffectiveDistance, getEffectiveEndPoint, getGPSRoutes, @@ -11,12 +11,19 @@ import { stopGpsTrip, } from '@libs/GPSDraftDetailsUtils'; +import {GPS_DISTANCE_INTERVAL_METERS} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; + +import ONYXKEYS from '@src/ONYXKEYS'; import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Unit} from '@src/types/onyx/Policy'; import geodesicDistance from '@src/utils/geodesicDistance'; -jest.mock('@libs/actions/GPSDraftDetails'); +import {reverseGeocodeAsync} from 'expo-location'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const point = (lat: number, long: number, address?: GPSPoint['address']): GPSPoint => ({lat, long, ...(address ? {address} : {})}); @@ -303,56 +310,91 @@ describe('GPSDraftDetailsUtils', () => { }); }); - describe('stopGpsTrip', () => { - beforeEach(() => { - jest.clearAllMocks(); + describe('canGpsTripBeTrimmed', () => { + it('cannot trim when there is no draft', () => { + expect(canGpsTripBeTrimmed(undefined)).toBe(false); }); - it('stops tracking the trip', async () => { - await stopGpsTrip(false, [[point(0, 0)]]); - expect(setIsTracking).toHaveBeenCalledWith(false); + it('cannot trim a trip that is still recording', () => { + expect(canGpsTripBeTrimmed(makeDraft({isTracking: true, distanceInMeters: GPS_DISTANCE_INTERVAL_METERS * 5}))).toBe(false); + }); + + it('cannot trim a stopped trip that never moved', () => { + expect(canGpsTripBeTrimmed(makeDraft({gpsPoints: [[point(0, 0)]], distanceInMeters: 0}))).toBe(false); }); - it('keeps the only segment when it holds a single point, so the stopped trip stays visible', async () => { - const gpsPoints = [[point(0, 0)]]; + it('cannot trim a trip no longer than one location interval', () => { + expect(canGpsTripBeTrimmed(makeDraft({distanceInMeters: GPS_DISTANCE_INTERVAL_METERS}))).toBe(false); + }); + + it('can trim a stopped trip longer than one location interval', () => { + expect(canGpsTripBeTrimmed(makeDraft({distanceInMeters: GPS_DISTANCE_INTERVAL_METERS + 1}))).toBe(true); + }); + }); + + describe('stopGpsTrip', () => { + const startAddress = {value: 'Amphitheatre Pkwy', type: 'address'} as const; - await stopGpsTrip(false, gpsPoints); + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); - expect(removeLastSegment).not.toHaveBeenCalled(); + beforeEach(async () => { + jest.mocked(reverseGeocodeAsync).mockReset().mockResolvedValue([]); + await Onyx.clear(); }); - it('keeps the only segment when it is still empty', async () => { - const gpsPoints: GPSPoint[][] = [[]]; + /** Stores a trip that is recording and returns the points the screen hands to stopGpsTrip */ + const recordTrip = async (gpsPoints: GPSPoint[][]): Promise => { + await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, makeDraft({gpsPoints, isTracking: true, distanceInMeters: 0})); + return gpsPoints; + }; + + const getStoppedTrip = async (): Promise => { + await waitForBatchedUpdates(); + return getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS); + }; - await stopGpsTrip(false, gpsPoints); + it('stops tracking the trip', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0, startAddress)]])); - expect(removeLastSegment).not.toHaveBeenCalled(); - expect(setEndWaypointAddress).not.toHaveBeenCalled(); + expect((await getStoppedTrip())?.isTracking).toBe(false); }); - it('removes a resumed segment that holds a single point', async () => { - const gpsPoints = [[point(0, 0), point(0, 1)], [point(1, 0)]]; + it('keeps the only point of a trip stopped without moving, so it can still be resumed or saved', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0, startAddress)]])); + + expect((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0, startAddress)]]); + }); - await stopGpsTrip(false, gpsPoints); + it('keeps the start address of that point when the stop skips the end address lookup', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0, startAddress)]]), true); - expect(removeLastSegment).toHaveBeenCalledWith(gpsPoints); + expect((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0, startAddress)]]); }); - it('removes a resumed segment that is empty', async () => { - const gpsPoints: GPSPoint[][] = [[point(0, 0), point(0, 1)], []]; + it('leaves a trip that recorded nothing as it is', async () => { + await stopGpsTrip(false, await recordTrip([[]])); - await stopGpsTrip(false, gpsPoints); + expect((await getStoppedTrip())?.gpsPoints).toEqual([[]]); + }); + + it('drops a resumed segment that holds a single point', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0), point(0, 1)], [point(1, 0)]])); - expect(removeLastSegment).toHaveBeenCalledWith(gpsPoints); + expect((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0), point(0, 1)]]); }); - it('sets the end waypoint address when the last segment holds more than one point', async () => { - const gpsPoints = [[point(0, 0), point(0, 1)]]; + it('drops a resumed segment that is empty', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0), point(0, 1)], []])); + + expect((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0), point(0, 1)]]); + }); - await stopGpsTrip(false, gpsPoints); + it('still gives a segment holding more than one point its end address', async () => { + await stopGpsTrip(false, await recordTrip([[point(0, 0), point(0, 1)]])); - expect(removeLastSegment).not.toHaveBeenCalled(); - expect(setEndWaypointAddress).toHaveBeenCalledWith({value: '0,1', type: 'coordinates'}, gpsPoints); + expect((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0), point(0, 1, {value: '0,1', type: 'coordinates'})]]); }); }); }); diff --git a/tests/unit/useGPSWaypointMarkersTest.ts b/tests/unit/useGPSWaypointMarkersTest.ts new file mode 100644 index 000000000000..bca3d312f2a3 --- /dev/null +++ b/tests/unit/useGPSWaypointMarkersTest.ts @@ -0,0 +1,64 @@ +import {renderHook} from '@testing-library/react-native'; + +import useGPSWaypointMarkers from '@pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers'; + +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import type {Unit} from '@src/types/onyx/Policy'; + +const point = (lat: number, long: number): GPSPoint => ({lat, long}); + +const makeDraft = (gpsPoints: GPSPoint[][], isTracking: boolean): GpsDraftDetails => ({ + gpsPoints, + distanceInMeters: 100, + isTracking, + reportID: '1', + unit: 'mi' as Unit, +}); + +const markerTypesFor = (gpsPoints: GPSPoint[][], isTracking: boolean, trimmedEndPoint?: TrimmedGPSPoint): Array => { + const {result} = renderHook(() => useGPSWaypointMarkers({gpsDraftDetails: makeDraft(gpsPoints, isTracking), trimmedEndPoint})); + return result.current.map(({markerType}) => markerType); +}; + +describe('useGPSWaypointMarkers', () => { + it('marks the first and last point of a stopped trip', () => { + expect(markerTypesFor([[point(0, 0), point(0, 1)]], false)).toEqual(['START_WAYPOINT', 'STOP_WAYPOINT']); + }); + + it('hides the end marker while the trip is still recording', () => { + expect(markerTypesFor([[point(0, 0), point(0, 1)]], true)).toEqual(['START_WAYPOINT']); + }); + + it('marks the stop of a stopped trip whose first segment holds a single point', () => { + expect(markerTypesFor([[point(0, 0)], [point(1, 0), point(1, 1)]], false)).toEqual(['START_WAYPOINT', 'WAYPOINT', 'STOP_WAYPOINT']); + }); + + it('hides the end marker of a resumed trip that is still recording', () => { + expect(markerTypesFor([[point(0, 0)], [point(1, 0), point(1, 1)]], true)).toEqual(['START_WAYPOINT', 'WAYPOINT']); + }); + + it('keeps the first point of a resumed segment visible while it is the only one recorded', () => { + expect(markerTypesFor([[point(0, 0), point(0, 1)], [point(1, 0)]], true)).toEqual(['START_WAYPOINT', 'WAYPOINT', 'WAYPOINT']); + }); + + it('hides the end marker when a resumed segment has not recorded anything yet', () => { + expect(markerTypesFor([[point(0, 0), point(0, 1)], []], true)).toEqual(['START_WAYPOINT']); + }); + + it('marks the trimmed end as the stop', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 0, precedingPointIndex: 1}; + + expect(markerTypesFor([[point(0, 0), point(0, 1), point(0, 2)]], false, trimmedEndPoint)).toEqual(['START_WAYPOINT', 'STOP_WAYPOINT']); + }); + + it('marks the trimmed end as the stop when the first segment holds a single point', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 1, long: 0.5, segmentIndex: 1, precedingPointIndex: 0}; + + expect(markerTypesFor([[point(0, 0)], [point(1, 0), point(1, 1), point(1, 2)]], false, trimmedEndPoint)).toEqual(['START_WAYPOINT', 'WAYPOINT', 'STOP_WAYPOINT']); + }); + + it('shows only a start marker for a trip that recorded a single point', () => { + expect(markerTypesFor([[point(0, 0)]], false)).toEqual(['START_WAYPOINT']); + }); +}); From d854560b4e4c3bbe64240924cc89884051c9ad8a Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 13 Sep 2026 17:47:26 +0300 Subject: [PATCH 3/3] fix: Keep a discarded one-point GPS trip gone and center its map --- src/components/MapView/GPSMapView.tsx | 23 +++- src/components/MapView/utils.ts | 8 ++ src/libs/actions/GPSDraftDetails.ts | 7 +- .../index.native.ts | 8 +- tests/unit/GPSDraftDetailsActionsTest.ts | 56 ++++++++++ tests/unit/MapViewUtilsTest.ts | 14 +++ .../backgroundLocationTrackingTaskTest.ts | 104 ++++++++++++++++++ 7 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 tests/unit/GPSDraftDetailsActionsTest.ts create mode 100644 tests/unit/backgroundLocationTrackingTaskTest.ts diff --git a/src/components/MapView/GPSMapView.tsx b/src/components/MapView/GPSMapView.tsx index 719027073e5b..c6c0c0e03041 100644 --- a/src/components/MapView/GPSMapView.tsx +++ b/src/components/MapView/GPSMapView.tsx @@ -44,6 +44,9 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way 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); + const {isOffline} = useNetwork(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -126,6 +129,15 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way 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, @@ -154,7 +166,7 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way }; const getWaypointBounds = () => { - if (!waypoints || userInteractedWithMap || (!waypoints.length && !directionCoordinates?.length)) { + if (!waypoints || userInteractedWithMap || !!singlePointCoordinate || (!waypoints.length && !directionCoordinates?.length)) { return undefined; } @@ -166,6 +178,8 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way }; 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; @@ -181,8 +195,8 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way const defaultSettings: Mapbox.CameraStop | undefined = { bounds: waypointsBounds, padding: waypointsBounds ? cameraPadding : undefined, - centerCoordinate: shouldFollowFallbackLocation ? centerCoordinate : undefined, - zoomLevel: shouldFollowFallbackLocation ? CONST.MAPBOX.DEFAULT_ZOOM : undefined, + centerCoordinate: shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate, + zoomLevel: shouldFollowFallbackLocation ? CONST.MAPBOX.DEFAULT_ZOOM : waypointsZoomLevel, }; const mapHeading = useSharedValue(0); @@ -227,7 +241,8 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way followZoomLevel={CONST.MAPBOX.DEFAULT_ZOOM} bounds={waypointsBounds ? {...waypointsBounds, ...cameraPadding} : undefined} defaultSettings={defaultSettings} - centerCoordinate={shouldFollowFallbackLocation ? centerCoordinate : undefined} + centerCoordinate={shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate} + zoomLevel={waypointsZoomLevel} /> {/** Show fallback location if foreground location permissions are not granted */} diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts index 72c834bf444f..486043333b7a 100644 --- a/src/components/MapView/utils.ts +++ b/src/components/MapView/utils.ts @@ -126,6 +126,13 @@ function areCoordinatesEqual(coordinate1: Coordinate | undefined, coordinate2: C return coordinate1[0] === coordinate2[0] && coordinate1[1] === coordinate2[1]; } +/** The coordinate that every waypoint and direction coordinate shares, or undefined when they span an area. Bounds around a single point have no area to fit a camera to. */ +function getSinglePointCoordinate(waypoints: Coordinate[], directionCoordinates: Coordinate[] | undefined): Coordinate | undefined { + const {southWest, northEast} = getBounds(waypoints, directionCoordinates); + + return areCoordinatesEqual(southWest, northEast) ? southWest : undefined; +} + // Simple linear interpolation of a coordinate between two points function simpleInterpolateCoordinate(start: Coordinate, end: Coordinate, progress: number): Coordinate { return [start[0] + (end[0] - start[0]) * progress, start[1] + (end[1] - start[1]) * progress]; @@ -234,6 +241,7 @@ export default { getBounds, areSameCoordinate, areCoordinatesEqual, + getSinglePointCoordinate, findClosestCoordinateOnLineFromCenter, getBoundsCenter, getDistanceSymbolCoordinates, diff --git a/src/libs/actions/GPSDraftDetails.ts b/src/libs/actions/GPSDraftDetails.ts index 66a19eaf1e4b..27b844b81f8c 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -64,11 +64,8 @@ function updateGpsPoints(gpsPoints: GPSPoint[][]) { } function removeLastSegment(gpsPoints: GPSPoint[][]) { - // Clear the last segment instead of removing it if there is only one segment - if (gpsPoints.length === 1) { - Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { - gpsPoints: [[]], - }); + // A trip's only segment is never dropped, because a trip with no points reads as one that never started + if (gpsPoints.length <= 1) { return; } diff --git a/src/setup/backgroundLocationTrackingTask/index.native.ts b/src/setup/backgroundLocationTrackingTask/index.native.ts index 1113ffc08e91..e3adb8cdc311 100644 --- a/src/setup/backgroundLocationTrackingTask/index.native.ts +++ b/src/setup/backgroundLocationTrackingTask/index.native.ts @@ -77,7 +77,13 @@ async function updateStartAddress(gpsPoints: GPSPoint[][], isOffline: boolean) { // To avoid race conditions, we need to get the latest gpsDraftDetails, because reverse geocoding may even take a few seconds const gpsDraftDetailsPromiseResult = await getGpsDraftDetails().catch(() => undefined); const updatedGpsDraftDetails = gpsDraftDetailsPromiseResult ?? undefined; - const updatedGpsPoints = updatedGpsDraftDetails ? getGpsPoints(updatedGpsDraftDetails) : gpsPoints; + + // A trip discarded during the lookup is gone, and writing its address onto the points read before would bring it back + if (!updatedGpsDraftDetails) { + return; + } + + const updatedGpsPoints = getGpsPoints(updatedGpsDraftDetails); if (address !== null) { setStartWaypointAddress({value: address, type: 'address'}, tripSegmentIndex, updatedGpsPoints); diff --git a/tests/unit/GPSDraftDetailsActionsTest.ts b/tests/unit/GPSDraftDetailsActionsTest.ts new file mode 100644 index 000000000000..d5b656af1f4f --- /dev/null +++ b/tests/unit/GPSDraftDetailsActionsTest.ts @@ -0,0 +1,56 @@ +import {removeLastSegment} from '@libs/actions/GPSDraftDetails'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import type {Unit} from '@src/types/onyx/Policy'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const point = (lat: number, long: number): GPSPoint => ({lat, long}); + +const stoppedTrip = (gpsPoints: GPSPoint[][]): GpsDraftDetails => ({ + gpsPoints, + distanceInMeters: 0, + isTracking: false, + reportID: '1', + unit: 'mi' as Unit, +}); + +const getStoredPoints = async (): Promise => { + await waitForBatchedUpdates(); + return (await getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS))?.gpsPoints; +}; + +describe('GPSDraftDetails actions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + }); + + describe('removeLastSegment', () => { + it('drops the last segment of a resumed trip', async () => { + const gpsPoints = [[point(0, 0), point(0, 1)], [point(1, 0)]]; + await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, stoppedTrip(gpsPoints)); + + removeLastSegment(gpsPoints); + + expect(await getStoredPoints()).toEqual([[point(0, 0), point(0, 1)]]); + }); + + it('keeps the only segment of a trip', async () => { + const gpsPoints = [[point(0, 0)]]; + await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, stoppedTrip(gpsPoints)); + + removeLastSegment(gpsPoints); + + expect(await getStoredPoints()).toEqual([[point(0, 0)]]); + }); + }); +}); diff --git a/tests/unit/MapViewUtilsTest.ts b/tests/unit/MapViewUtilsTest.ts index b361b61fcecf..c5d61d429390 100644 --- a/tests/unit/MapViewUtilsTest.ts +++ b/tests/unit/MapViewUtilsTest.ts @@ -173,6 +173,20 @@ describe('MapView utils', () => { }); }); + describe('getSinglePointCoordinate', () => { + it('returns the coordinate a trip with a single point sits on', () => { + expect(utils.getSinglePointCoordinate([[1, 2]], [[1, 2]])).toEqual([1, 2]); + }); + + it('returns nothing for coordinates that span an area', () => { + expect(utils.getSinglePointCoordinate([[1, 2]], SINGLE_SEGMENT)).toBeUndefined(); + }); + + it('returns nothing when there are no coordinates', () => { + expect(utils.getSinglePointCoordinate([], undefined)).toBeUndefined(); + }); + }); + describe('isSingleSegmentRoute', () => { it('detects single segment, segmented and empty routes', () => { expect(utils.isSingleSegmentRoute(SINGLE_SEGMENT)).toBe(true); diff --git a/tests/unit/backgroundLocationTrackingTaskTest.ts b/tests/unit/backgroundLocationTrackingTaskTest.ts new file mode 100644 index 000000000000..ca0daf7f722a --- /dev/null +++ b/tests/unit/backgroundLocationTrackingTaskTest.ts @@ -0,0 +1,104 @@ +import {resetGPSDraftDetails} from '@libs/actions/GPSDraftDetails'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import '@src/setup/backgroundLocationTrackingTask/index.native'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; +import type {Unit} from '@src/types/onyx/Policy'; + +import type {LocationGeocodedAddress, LocationObject} from 'expo-location'; + +import {reverseGeocodeAsync} from 'expo-location'; +import {defineTask} from 'expo-task-manager'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// The task registers itself with defineTask when its module loads +const [, runTask] = jest.mocked(defineTask).mock.calls.at(0) ?? []; + +const trackingDraft: GpsDraftDetails = { + gpsPoints: [[]], + distanceInMeters: 0, + isTracking: true, + reportID: '1', + unit: 'mi' as Unit, + accountID: 1, +}; + +const location = (latitude: number, longitude: number): LocationObject => ({ + coords: {latitude, longitude, altitude: null, accuracy: null, altitudeAccuracy: null, heading: null, speed: null}, + timestamp: 0, +}); + +const geocodedAddress = (city: string): LocationGeocodedAddress => ({ + city, + district: null, + streetNumber: null, + street: null, + region: null, + subregion: null, + country: null, + postalCode: null, + name: null, + isoCountryCode: null, + timezone: null, + formattedAddress: null, +}); + +/** Delivers locations to the task the way expo-location does while tracking */ +const receiveLocations = async (locations: LocationObject[]): Promise => { + await runTask?.({data: {locations}, error: null, executionInfo: {eventId: '1', taskName: 'location'}}); +}; + +/** Holds the start address lookup open. Await `entered` before discarding the trip, so the discard lands during the lookup */ +const holdAddressLookup = (): {entered: Promise; release: (addresses: LocationGeocodedAddress[]) => void} => { + let release: ((addresses: LocationGeocodedAddress[]) => void) | undefined; + let markEntered: (() => void) | undefined; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + jest.mocked(reverseGeocodeAsync).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + markEntered?.(); + }), + ); + return {entered, release: (addresses) => release?.(addresses)}; +}; + +describe('backgroundLocationTrackingTask', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.mocked(reverseGeocodeAsync).mockReset().mockResolvedValue([]); + await Onyx.clear(); + }); + + it('records the first location and gives it the start address', async () => { + jest.mocked(reverseGeocodeAsync).mockResolvedValue([geocodedAddress('Mountain View')]); + await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, trackingDraft); + + await receiveLocations([location(0, 0)]); + await waitForBatchedUpdates(); + + expect((await getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS))?.gpsPoints).toEqual([[{lat: 0, long: 0, address: {value: 'Mountain View', type: 'address'}}]]); + }); + + it('brings nothing back when the trip is discarded while its start address is looked up', async () => { + await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, trackingDraft); + const addressLookup = holdAddressLookup(); + + await receiveLocations([location(0, 0)]); + await addressLookup.entered; + resetGPSDraftDetails(); + await waitForBatchedUpdates(); + addressLookup.release([geocodedAddress('Mountain View')]); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS)).toBeUndefined(); + }); +});