Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/components/MapView/GPSMapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth verifying on device

This adds zoomLevel={waypointsZoomLevel} to the Camera - a prop it never had.
During tracking with one point recorded, singlePointCoordinate is truthy, so the Camera now gets centerCoordinate + zoomLevel: 15 while followUserLocation is also true.
rnmapbox should let follow-mode win, but controlled camera props fighting follow-mode has been flaky historically.
Confirm the map still tracks the user normally between the first and second recorded point.

/>

{/** Show fallback location if foreground location permissions are not granted */}
Expand Down
8 changes: 8 additions & 0 deletions src/components/MapView/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -234,6 +241,7 @@ export default {
getBounds,
areSameCoordinate,
areCoordinatesEqual,
getSinglePointCoordinate,
findClosestCoordinateOnLineFromCenter,
getBoundsCenter,
getDistanceSymbolCoordinates,
Expand Down
13 changes: 11 additions & 2 deletions src/libs/GPSDraftDetailsUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}

@mkhutornyi mkhutornyi Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this part is the core fix - restores exactly the guard that #90237 added and #91418 deleted.

The other 6 files

A stopped trip with exactly one point was previously an unreachable state, so everything downstream of isTripStopped now has to handle it. 3 of 4 are genuine consequences; one is scope creep.

Change Verdict
useGPSWaypointMarkers changes replaces the index % 2 === 1 end-marker parity check with "last non-empty segment has >1 point" Needed. Parity assumed every segment yields 2 waypoints. A surviving 1-point segment yields 1, so [[p1],[p2,p3]] (start → stop → resume → move → stop) produced an odd count and lost its end marker. Newly reachable because of the fix.
GPSMapView + MapView/utils changes getSinglePointCoordinate, center at fixed zoom instead of fitBounds Needed. While tracking, shouldFollowUserLocation is true so bounds are ignored. After Stop with 1 waypoint it flips false, and getBounds returns southWest === northEastfitBounds on a zero-area box max-zooms the camera. Only reachable post-fix.
canGpsTripBeTrimmed + EditGPSTripButton changes hides Edit when distanceInMeters <= 100 Needed, but indirect. Edit was gated on isTripStopped, so it now appears on a 0-distance trip. The distance threshold is a proxy; getTotalGpsTripPoints(...) > 1 says what's actually meant and avoids hiding Edit on a real 2-point trip measuring exactly 100 m.
backgroundLocationTrackingTask.updateStartAddress changes bail out if the draft vanished during reverse geocoding Scope creep, borderline. Pre-existing discard-resurrection race. Arguably newly reachable (DiscardGPSTripButton is also gated on isTripStopped, so "start → stop → discard while the start-point geocode is in flight" only exists after the fix), but it's a distinct bug and belongs in its own PR.

return;
}

Expand Down Expand Up @@ -187,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 ?? [[]];
}
Expand Down Expand Up @@ -227,6 +235,7 @@ export {
getGPSRoutes,
getGPSWaypoints,
stopGpsTrip,
canGpsTripBeTrimmed,
getStringifiedGPSCoordinates,
addressFromGpsPoint,
coordinatesToString,
Expand Down
7 changes: 2 additions & 5 deletions src/libs/actions/GPSDraftDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 [];
Expand Down
8 changes: 7 additions & 1 deletion src/setup/backgroundLocationTrackingTask/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/GPSDraftDetailsActionsTest.ts
Original file line number Diff line number Diff line change
@@ -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<GPSPoint[][] | undefined> => {
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)]]);
});
});
});
99 changes: 99 additions & 0 deletions tests/unit/GPSDraftDetailsUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import {
calculateTrimmedEndPoint,
canGpsTripBeTrimmed,
getEffectiveDistance,
getEffectiveEndPoint,
getGPSRoutes,
getGPSWaypoints,
getStringifiedGPSCoordinates,
getTrimmedGpsTrip,
gpsPointsToMapboxCoordinates,
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';

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} : {})});

const makeDraft = (overrides: Partial<GpsDraftDetails> = {}): GpsDraftDetails => ({
Expand Down Expand Up @@ -298,4 +309,92 @@ describe('GPSDraftDetailsUtils', () => {
]);
});
});

describe('canGpsTripBeTrimmed', () => {
it('cannot trim when there is no draft', () => {
expect(canGpsTripBeTrimmed(undefined)).toBe(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('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;

beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

beforeEach(async () => {
jest.mocked(reverseGeocodeAsync).mockReset().mockResolvedValue([]);
await Onyx.clear();
});

/** Stores a trip that is recording and returns the points the screen hands to stopGpsTrip */
const recordTrip = async (gpsPoints: GPSPoint[][]): Promise<GPSPoint[][]> => {
await Onyx.set(ONYXKEYS.GPS_DRAFT_DETAILS, makeDraft({gpsPoints, isTracking: true, distanceInMeters: 0}));
return gpsPoints;
};

const getStoppedTrip = async (): Promise<GpsDraftDetails | undefined> => {
await waitForBatchedUpdates();
return getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS);
};

it('stops tracking the trip', async () => {
await stopGpsTrip(false, await recordTrip([[point(0, 0, startAddress)]]));

expect((await getStoppedTrip())?.isTracking).toBe(false);
});

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)]]);
});

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((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0, startAddress)]]);
});

it('leaves a trip that recorded nothing as it is', async () => {
await stopGpsTrip(false, await recordTrip([[]]));

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((await getStoppedTrip())?.gpsPoints).toEqual([[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)]]);
});

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((await getStoppedTrip())?.gpsPoints).toEqual([[point(0, 0), point(0, 1, {value: '0,1', type: 'coordinates'})]]);
});
});
});
Loading
Loading