Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
204c2ae
Add accountID to the GPS draft so it can be owner-checked
allgandalf Aug 4, 2026
0096067
Stamp the starting user on the GPS draft
allgandalf Aug 4, 2026
4161915
Keep the GPS trip across a forced SAML re-auth
allgandalf Aug 4, 2026
2cab489
Only keep a live trip, and stamp its owner on the way out
allgandalf Aug 4, 2026
b148569
Never offer a resumed trip to a different user
allgandalf Aug 4, 2026
226ad5f
Rename the owner flag to satisfy no-negated-variables
allgandalf Aug 4, 2026
e369d33
Cover the GPS trip surviving a forced re-auth
allgandalf Aug 4, 2026
cfdb42a
Do not let an image reauth sign the user out when it cannot refresh s…
allgandalf Aug 4, 2026
637feab
Keep a valid attachment token instead of blanking on a session-age guess
allgandalf Aug 4, 2026
08fac85
Merge branch 'Expensify:main' into gps-trip-survives-forced-reauth
allgandalf Aug 7, 2026
d9bddf3
Move canReauthenticateSilently next to the branches it predicts
allgandalf Aug 12, 2026
20485c7
Say why the GPS draft read has to be a subscription
allgandalf Aug 12, 2026
6b0d9e5
Do not suppress reauth before Onyx has loaded credentials
allgandalf Aug 12, 2026
86eb524
Never keep a GPS trip we cannot record an owner for
allgandalf Aug 12, 2026
c854287
Simplify the canReauthenticateSilently doc
allgandalf Aug 12, 2026
5a0a1ec
Merge remote-tracking branch 'upstream/main' into HEAD
allgandalf Aug 13, 2026
88ccfa3
Read the GPS draft on demand instead of subscribing to it
allgandalf Aug 13, 2026
a67e9df
Merge remote-tracking branch 'upstream/main' into HEAD
allgandalf Aug 16, 2026
6d92bb9
Preserve the trip without reading Onyx during sign out
allgandalf Aug 16, 2026
63052b7
Check trip ownership with useOnyx once the session has loaded
allgandalf Aug 16, 2026
ff2210a
Seed the trip owner the way a real trip records it
allgandalf Aug 16, 2026
95222c0
Merge remote-tracking branch 'upstream/main' into HEAD
allgandalf Aug 18, 2026
6f039fb
Use the shared accountID selector
allgandalf Aug 18, 2026
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
27 changes: 26 additions & 1 deletion src/components/GPSTripStateChecker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';

import {resetGPSDraftDetails} from '@libs/actions/GPSDraftDetails';
import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils';
import Navigation from '@libs/Navigation/Navigation';
import {generateReportID} from '@libs/ReportUtils';
Expand All @@ -16,6 +17,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import {useSplashScreenState} from '@src/SplashScreenStateContext';

import {accountIDSelector} from '@selectors/Session';
import {hasStartedLocationUpdatesAsync, startLocationUpdatesAsync, stopLocationUpdatesAsync} from 'expo-location';
import React, {useEffect, useState} from 'react';
import OnyxUtils from 'react-native-onyx/dist/OnyxUtils';
Expand All @@ -27,6 +29,8 @@ function GPSTripStateChecker() {
const {translate} = useLocalize();
const [showContinueTripModal, setShowContinueTripModal] = useState(false);
const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS);
const [currentAccountID, currentAccountIDResult] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const isSessionLoaded = currentAccountIDResult.status === 'loaded';
const {isOffline} = useNetwork();

const {splashScreenState} = useSplashScreenState();
Expand All @@ -36,6 +40,27 @@ function GPSTripStateChecker() {
useUpdateGpsTripOnReconnect({gpsPoints: getGpsPoints(gpsDraftDetails)});
useUpdateGpsNotification();

// A trip kept across a forced re-auth belongs to whoever started it. Wait for the session to load before
// judging that, otherwise a trip would be discarded just because the accountID had not arrived yet.
const isTripFromDifferentUser = isSessionLoaded && !!gpsDraftDetails && gpsDraftDetails.accountID !== currentAccountID;

useEffect(() => {
if (!isTripFromDifferentUser) {
return;
}

resetGPSDraftDetails();
hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).then((isRunning) => {
if (!isRunning) {
return;
}

stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) =>
console.error('[GPS distance request] Failed to stop tracking for a trip from another user', error),
);
});
}, [isTripFromDifferentUser]);

useEffect(() => {
async function handleGpsTripInProgressOnAppRestart() {
await checkAndCleanGpsNotification();
Expand Down Expand Up @@ -111,7 +136,7 @@ function GPSTripStateChecker() {

return (
<ConfirmModal
isVisible={showContinueTripModal && splashScreenState === CONST.BOOT_SPLASH_STATE.HIDDEN}
isVisible={showContinueTripModal && !!gpsDraftDetails?.isTracking && !isTripFromDifferentUser && splashScreenState === CONST.BOOT_SPLASH_STATE.HIDDEN}
title={translate('gps.continueGpsTripModal.title')}
prompt={translate('gps.continueGpsTripModal.prompt')}
shouldReverseStackedButtons
Expand Down
9 changes: 7 additions & 2 deletions src/components/Image/getImageSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,27 @@ type GetImageSourceParams = {
session: Session | undefined;
isAuthTokenRequired: boolean;
isOffline: boolean;

/** Whether the auth token can still be refreshed in the background */
canReauthenticateSilently: boolean;
};

type GetImageSourceReturn = {
source: ImageProps['source'];
shouldReauthenticate: boolean;
};

export default function getImageSource({propsSource, session, isAuthTokenRequired, isOffline}: GetImageSourceParams): GetImageSourceReturn {
export default function getImageSource({propsSource, session, isAuthTokenRequired, isOffline, canReauthenticateSilently}: GetImageSourceParams): GetImageSourceReturn {
if (typeof propsSource === 'object' && propsSource !== null && 'uri' in propsSource) {
if (typeof propsSource.uri === 'number') {
return {source: propsSource.uri, shouldReauthenticate: false};
}

const authToken = session?.encryptedAuthToken ?? null;
if (isAuthTokenRequired && authToken) {
if (isOffline || (!!session?.creationDate && !isExpiredSession(session.creationDate))) {
// The age check is a client-side guess, not proof the token is dead. Without a way to refresh it,
// blanking the image only loses a working attachment, so keep serving the token we already have.
if (isOffline || !canReauthenticateSilently || (!!session?.creationDate && !isExpiredSession(session.creationDate))) {
return {
source: {
...propsSource,
Expand Down
2 changes: 2 additions & 0 deletions src/components/Image/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useNetwork from '@hooks/useNetwork';

import {isExpiredSession} from '@libs/actions/Session';
import activateReauthenticator from '@libs/actions/Session/AttachmentImageReauthenticator';
import {canReauthenticateSilently} from '@libs/Reauthentication';

import CONST from '@src/CONST';

Expand Down Expand Up @@ -128,6 +129,7 @@ function Image({
session,
isAuthTokenRequired,
isOffline,
canReauthenticateSilently: canReauthenticateSilently(),
});

if (resolvedImageSource.shouldReauthenticate && session) {
Expand Down
22 changes: 22 additions & 0 deletions src/libs/Reauthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,27 @@ function getAuthenticationErrorResponse(error: HttpsError): Response<OnyxKey> {
};
}

/**
* Whether `reauthenticate()` could refresh the token in the background. When it cannot it can only end in
* `redirectToSignIn`, so a caller that is only guessing the session went stale must not trigger it.
* Mirrors the two branches below that redirect to sign in, and lives here so they cannot drift apart.
*/
function canReauthenticateSilently(): boolean {
if (account?.isSAMLRequired && !isSupportSession && !isSupportAuthTokenUsed) {
return false;
}

const credentials = isConnectedAsDelegate({delegatedAccess: account?.delegatedAccess}) ? stashedCredentials : getCredentials();

// `undefined` means Onyx has not loaded credentials yet, unlike `null` which means there are none.
// Assume a refresh is possible until we know, so a render during startup is not treated as a dead session.
if (credentials === undefined) {
return true;
}

return !!credentials?.autoGeneratedLogin && !!credentials?.autoGeneratedPassword;
}

/**
* Reauthenticate using the stored credentials and redirect to the sign in page if unable to do so.
* @param [command] command name for logging purposes
Expand Down Expand Up @@ -368,3 +389,4 @@ function reauthenticate(command = ''): Promise<boolean> {
}

export default reauthenticate;
export {canReauthenticateSilently};
3 changes: 2 additions & 1 deletion src/libs/actions/GPSDraftDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,14 @@ function removeLastSegment(gpsPoints: GPSPoint[][]) {
});
}

function initGpsDraft(reportID: string, unit: Unit) {
function initGpsDraft(reportID: string, unit: Unit, accountID?: number) {
Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, {
gpsPoints: [[]],
isTracking: true,
distanceInMeters: 0,
reportID,
unit,
accountID,
});
}

Expand Down
4 changes: 2 additions & 2 deletions src/libs/actions/Session/AttachmentImageReauthenticator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Log from '@libs/Log';
import {getIsOffline} from '@libs/NetworkState';
import reauthenticate from '@libs/Reauthentication';
import reauthenticate, {canReauthenticateSilently} from '@libs/Reauthentication';

import ONYXKEYS from '@src/ONYXKEYS';
import type Session from '@src/types/onyx/Session';
Expand Down Expand Up @@ -54,7 +54,7 @@ function activate(session: Session) {
}

function tryReauthenticate() {
if (getIsOffline() || !active) {
if (getIsOffline() || !active || !canReauthenticateSilently()) {
return;
}
reauthenticate().catch((error) => {
Expand Down
4 changes: 4 additions & 0 deletions src/libs/actions/SignInRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?:
keysToPreserve.push(ONYXKEYS.ACCOUNT);
Onyx.merge(ONYXKEYS.CREDENTIALS, {login: currentSessionEmail, autoGeneratedLogin: null, autoGeneratedPassword: null});
Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});

// A forced re-auth is involuntary, so an in-progress trip is kept and offered back on return.
// The trip records who started it, and GPSTripStateChecker drops it unless that user signs back in.
keysToPreserve.push(ONYXKEYS.GPS_DRAFT_DETAILS);
}

return Onyx.clear(keysToPreserve).then(async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Button from '@components/ButtonComposed';
import ConfirmModal from '@components/ConfirmModal';
import {loadIllustration} from '@components/Icon/IllustrationLoader';
import {useSession} from '@components/OnyxListItemProvider';

import {useMemoizedLazyAsset} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
Expand Down Expand Up @@ -53,6 +54,7 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP
const [showZeroDistanceModal, setShowZeroDistanceModal] = useState(false);
const [showDisabledServicesModal, setShowDisabledServicesModal] = useState(false);
const {isOffline} = useNetwork();
const session = useSession();

const {asset: ReceiptLocationMarker} = useMemoizedLazyAsset(() => loadIllustration('ReceiptLocationMarker'));
const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS);
Expand Down Expand Up @@ -93,7 +95,7 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP
return;
}

initGpsDraft(reportID, unit);
initGpsDraft(reportID, unit, session?.accountID);
startGpsTripNotification(translate, reportID, unit);
};

Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/GpsDraftDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ type GpsDraftDetails = {
/** Distance unit of the ongoing GPS trip */
unit: Unit;

/** accountID that started the trip, so a draft kept across a re-auth is never resumed by a different user */
accountID?: number;

/**
* Distance the user trimmed to in the Edit Stop screen.
* When set, this is the distance shown to the user and used when creating the expense.
Expand Down
40 changes: 40 additions & 0 deletions tests/actions/SessionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {openAuthSessionAsync} from 'expo-web-browser';
import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch';
import Onyx from 'react-native-onyx';

import getOnyxValue from '../utils/getOnyxValue';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

Expand Down Expand Up @@ -1050,6 +1051,45 @@ describe('Session', () => {
});
});

describe('GPS trip on the sign in redirect', () => {
const gpsTrip = {
gpsPoints: [[{lat: 1, long: 2}]],
distanceInMeters: 100,
isTracking: true,
reportID: '1',
unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
};

beforeEach(() => {
jest.restoreAllMocks();
});

test('keeps the in-progress trip when a SAML re-auth forces the redirect', async () => {
await TestHelper.signInWithTestUser();
const accountID = (await getOnyxValue(ONYXKEYS.SESSION))?.accountID;
await Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, {...gpsTrip, accountID});
await waitForBatchedUpdates();

await SignInRedirect.default(undefined, true);
await waitForBatchedUpdates();

const draft = await getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS);
expect(draft?.isTracking).toBe(true);
expect(draft?.accountID).toBe(accountID);
});

test('discards the in-progress trip on a sign out redirect', async () => {
await TestHelper.signInWithTestUser();
await Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, gpsTrip);
await waitForBatchedUpdates();

await SignInRedirect.default();
await waitForBatchedUpdates();

expect(await getOnyxValue(ONYXKEYS.GPS_DRAFT_DETAILS)).toBeUndefined();
});
});

describe('signIn', () => {
test('sends the login and validate code arguments to the API, independent of the CREDENTIALS Onyx cache', async () => {
const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined);
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/components/Image/getImageSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('getImageSource', () => {
session: undefined,
isAuthTokenRequired: false,
isOffline: false,
canReauthenticateSilently: true,
}),
).toEqual({source, shouldReauthenticate: false});
});
Expand All @@ -43,6 +44,7 @@ describe('getImageSource', () => {
session,
isAuthTokenRequired: true,
isOffline: false,
canReauthenticateSilently: true,
}),
).toEqual({
source: {
Expand All @@ -69,6 +71,7 @@ describe('getImageSource', () => {
session,
isAuthTokenRequired: true,
isOffline: true,
canReauthenticateSilently: true,
}),
).toEqual({
source: {
Expand All @@ -94,10 +97,38 @@ describe('getImageSource', () => {
session,
isAuthTokenRequired: true,
isOffline: false,
canReauthenticateSilently: true,
}),
).toEqual({source: undefined, shouldReauthenticate: true});
});

it('keeps serving the token on an expired-looking session when a background refresh is not possible', () => {
const propsSource = {uri: MOCK_URI};
const session: Session = {
encryptedAuthToken: MOCK_TOKEN,
creationDate: NOW.getTime() - CONST.SESSION_EXPIRATION_TIME_MS - 1,
};

expect(
getImageSource({
propsSource,
session,
isAuthTokenRequired: true,
isOffline: false,
canReauthenticateSilently: false,
}),
).toEqual({
source: {
...propsSource,
cacheKey: MOCK_URI,
headers: {
[CONST.CHAT_ATTACHMENT_TOKEN_KEY]: MOCK_TOKEN,
},
},
shouldReauthenticate: false,
});
});

it('preserves numeric image sources', () => {
// @ts-expect-error -- Numeric object URIs intentionally exercise the runtime compatibility branch not represented by the public image source model.
const propsSource: Parameters<typeof getImageSource>[0]['propsSource'] = {uri: 42};
Expand All @@ -108,6 +139,7 @@ describe('getImageSource', () => {
session: undefined,
isAuthTokenRequired: true,
isOffline: false,
canReauthenticateSilently: true,
}),
).toEqual({source: 42, shouldReauthenticate: false});
});
Expand Down
Loading