diff --git a/src/libs/actions/UserLocation.ts b/src/libs/actions/UserLocation.ts index 7fb59f9cce7c..9a22fbbf2007 100644 --- a/src/libs/actions/UserLocation.ts +++ b/src/libs/actions/UserLocation.ts @@ -19,12 +19,14 @@ function clearUserLocation() { } function snapshotUserLocation() { + // Clear USER_LOCATION before checking permission. It may contain coordinates from another session or AddressSearch, + // and submission could otherwise use those coordinates if permission is granted later. + clearUserLocation(); hasLocationPermission().then((isGranted) => { if (!isGranted) { return; } - clearUserLocation(); // Best effort: a submit that finds no cached position falls back to a capped read getCurrentPosition( (successData) => { diff --git a/src/pages/Share/ShareRootPage.tsx b/src/pages/Share/ShareRootPage.tsx index fe4ac356b6df..73b46132f9c7 100644 --- a/src/pages/Share/ShareRootPage.tsx +++ b/src/pages/Share/ShareRootPage.tsx @@ -9,6 +9,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {addTempShareFile, addValidatedShareFile, clearShareData} from '@libs/actions/Share'; +import {snapshotUserLocation} from '@libs/actions/UserLocation'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {splitExtensionFromFileName, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import Log from '@libs/Log'; @@ -141,6 +142,9 @@ function ShareRootPage() { // Reset the persisted tab to SUBMIT so that defaultSelectedTab takes effect // even when a previous Share session left the tab on SHARE. Tab.setSelectedTab(CONST.TAB.SHARE.NAVIGATOR_ID, CONST.TAB.SHARE.SUBMIT); + // Pre-warm the position while the user picks a recipient so SubmitDetailsPage + // avoids reading the device inside the ShareExtensionOpenSubmitFlow span. + snapshotUserLocation(); } else { setIsFileScannable(false); } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 47166ded8b14..ad592655403e 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -35,13 +35,13 @@ import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt'; import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams'; -import {snapshotUserLocation} from '@libs/actions/UserLocation'; import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPositionWithinCap from '@libs/getCurrentPosition/getCurrentPositionWithinCap'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getDefaultReimbursableForPolicy, getExistingTransactionID, isLookingAroundSearchRoutingActive, resolveReportForMoneyRequest} from '@libs/IOUUtils'; +import Log from '@libs/Log'; import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import type {ShareNavigatorParamList} from '@libs/Navigation/types'; @@ -74,6 +74,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; import {showErrorAlert} from './ShareRootPage'; import useShareFileSizeValidation from './useShareFileSizeValidation'; @@ -143,7 +144,6 @@ function SubmitDetailsPage({ const hasStartedPendingNavigation = useRef(false); const formHasBeenSubmitted = useRef(false); const hasCalledReveal = useRef(false); - const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); const [errorTitle, setErrorTitle] = useState(undefined); const [errorMessage, setErrorMessage] = useState(undefined); @@ -175,10 +175,6 @@ function SubmitDetailsPage({ [], ); - useEffect(() => { - snapshotUserLocation(); - }, []); - useEffect(() => { if (!errorTitle || !errorMessage) { return; @@ -587,14 +583,32 @@ function SubmitDetailsPage({ return; } // Use cached userLocation when available — avoids an extra getCurrentPosition round-trip. - if (userLocation) { - finishRequestAndNavigate(receipt, { - lat: userLocation.latitude, - long: userLocation.longitude, + Onyx.get(ONYXKEYS.USER_LOCATION) + .catch((error: unknown) => { + // Location is optional. If the cache read fails, use the capped device read so submission can continue. + Log.warn('[SubmitDetailsPage] Failed to read the cached user location', {error}); + return null; + }) + .then((userLocation): GpsPoint | Promise => { + if (userLocation) { + return { + lat: userLocation.latitude, + long: userLocation.longitude, + }; + } + // Resolve the position first, then submit in the promise chain so the catch below handles submission errors. + return new Promise((resolve) => { + getCurrentPositionWithinCap((gpsCoords) => resolve(gpsCoords)); + }); + }) + .then((gpsPoint) => finishRequestAndNavigate(receipt, gpsPoint)) + .catch((error: unknown) => { + // This chain runs after readFileAsync has already resolved, so its onFailure reset no longer covers a throw here. + // Reset the same way so the confirm button does not stay loading and the user can retry. + Log.warn('[SubmitDetailsPage] Failed to submit the shared expense', {error}); + formHasBeenSubmitted.current = false; + setIsConfirming(false); }); - return; - } - getCurrentPositionWithinCap((gpsCoords) => finishRequestAndNavigate(receipt, gpsCoords)); }; // Separate helper so the permission-modal callbacks don't re-enter onConfirm (deadlocked when OS permission was pre-granted). diff --git a/tests/ui/ScanScreenLocationTest.tsx b/tests/ui/ScanScreenLocationTest.tsx index df070bd6a80d..3387ea8b93f7 100644 --- a/tests/ui/ScanScreenLocationTest.tsx +++ b/tests/ui/ScanScreenLocationTest.tsx @@ -200,6 +200,23 @@ describe('scan screen location permission prompt', () => { expect(jest.mocked(getCurrentPosition)).not.toHaveBeenCalled(); }); + it('clears a previously cached position when permission is denied inside the prompt window', async () => { + // Given a cached USER_LOCATION, denied location permission, and a prompt timestamp within the seven-day cool-down + mockLocationPermissionResult = 'denied'; + await act(async () => { + await Onyx.merge(ONYXKEYS.USER_LOCATION, {latitude: 10, longitude: 20}); + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, new Date().toISOString()); + }); + + // When the scan screen opens and snapshots the user location + await renderScanScreen(); + await waitForBatchedUpdates(); + + // Then the stale position is wiped rather than reused, and no location read that permission would refuse is spent + expect(jest.mocked(getCurrentPosition)).not.toHaveBeenCalled(); + expect(await getUserLocationFromOnyx()).toBeUndefined(); + }); + it('caches a position without prompting when permission was granted from the OS settings inside the prompt window', async () => { // Given a user who tapped "Not now" recently and later granted location from the OS settings, so the prompt flow will not run mockLocationPermissionResult = 'granted'; diff --git a/tests/ui/ShareRootPageLocationTest.tsx b/tests/ui/ShareRootPageLocationTest.tsx new file mode 100644 index 000000000000..1aa5e0b78915 --- /dev/null +++ b/tests/ui/ShareRootPageLocationTest.tsx @@ -0,0 +1,238 @@ +import {act, render} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; + +import getCurrentPosition from '@libs/getCurrentPosition'; + +import ShareRootPage from '@pages/Share/ShareRootPage'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {UserLocation} from '@src/types/onyx'; + +import type {AppStateStatus} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {AppState} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {check} from 'react-native-permissions'; + +import type * as FileUtilsModule from '../../src/libs/fileDownload/FileUtils'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type SharedFile = {id: string; content: string; mimeType: string; processedAt: string; aspectRatio: number}; + +// The file the mocked native share handler hands back. The `mock` prefix lets the hoisted jest.mock factory read it. +let mockSharedFiles: SharedFile[] = []; + +jest.mock('@libs/ShareActionHandlerModule', () => ({ + __esModule: true, + default: { + processFiles: jest.fn((callback: (files: SharedFile[]) => void) => callback(mockSharedFiles)), + }, +})); + +jest.mock('@libs/getCurrentPosition', () => jest.fn()); + +jest.mock('@libs/fileDownload/FileUtils', () => { + const actual = jest.requireActual('@libs/fileDownload/FileUtils'); + return { + ...actual, + validateImageForCorruption: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('@pages/Share/getFileSize', () => jest.fn(() => Promise.resolve(10000))); + +jest.mock('@hooks/useFilesValidation', () => jest.fn(() => ({validateFiles: jest.fn()}))); + +// Mock the tabs, header, and screen wrapper to test what happens when the page processes a shared file. +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/HeaderWithBackButton', () => () => null); +jest.mock('@components/Skeletons/TabNavigatorSkeleton', () => () => null); +jest.mock('@libs/Navigation/OnyxTabNavigator', () => ({ + __esModule: true, + default: () => null, + TopTab: {Screen: () => null}, +})); +jest.mock('@pages/Share/ShareTab', () => () => null); +jest.mock('@pages/Share/SubmitTab', () => () => null); + +const DEVICE_POSITION = {latitude: 52.2297, longitude: 21.0122}; +// Where the device is when a later share arrives, distinct from DEVICE_POSITION. +const NEW_DEVICE_POSITION = {latitude: 50.0647, longitude: 19.945}; + +function createSharedFile(content: string, mimeType: string): SharedFile { + return {id: 'shared-file-1', content, mimeType, processedAt: '0', aspectRatio: 1}; +} + +function mockPositionAnswer(position: UserLocation = DEVICE_POSITION) { + jest.mocked(getCurrentPosition).mockImplementation(async (success) => { + success({ + coords: { + latitude: position.latitude, + longitude: position.longitude, + altitude: null, + accuracy: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: 0, + }); + }); +} + +function getUserLocationFromOnyx(): Promise> { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.USER_LOCATION, + callback: (value) => { + resolve(value); + connection.unsubscribe(); + }, + }); + }); +} + +// Captures the AppState listener the page registers, so a test can replay the app leaving and returning to the foreground. +function captureAppStateListener(): (state: AppStateStatus) => void { + let listener: ((state: AppStateStatus) => void) | undefined; + jest.spyOn(AppState, 'addEventListener').mockImplementation((type, handler) => { + listener = handler; + return {remove: jest.fn()}; + }); + return (state) => listener?.(state); +} + +async function returnToForeground(emitAppState: (state: AppStateStatus) => void) { + await act(async () => { + emitAppState('background'); + emitAppState('active'); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function renderShareRootPage() { + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); +} + +describe('ShareRootPage location snapshot', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockPositionAnswer(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('caches the device position as soon as a receipt file is shared', async () => { + // Given a shared image that can become a receipt, and location permission already granted + mockSharedFiles = [createSharedFile('file:///shared/receipt.jpg', CONST.RECEIPT_ALLOWED_FILE_TYPES.JPEG)]; + + // When the share root processes the file + await renderShareRootPage(); + + // Then the device position is cached before a recipient is selected, ready for submission + expect(getCurrentPosition).toHaveBeenCalledTimes(1); + expect(await getUserLocationFromOnyx()).toEqual(DEVICE_POSITION); + }); + + it('does not read the location for a file that cannot become a receipt', async () => { + // Given a shared video, which only offers the Share tab, so the Submit flow is unreachable + mockSharedFiles = [createSharedFile('file:///shared/clip.mp4', CONST.SHARE_FILE_MIMETYPE.MP4)]; + + // When the share root processes the file + await renderShareRootPage(); + + // Then the device location is never read, since no expense can use it + expect(check).not.toHaveBeenCalled(); + expect(getCurrentPosition).not.toHaveBeenCalled(); + }); + + it('does not read the location when the permission is not granted, so sharing never prompts for it', async () => { + // Given a shared receipt image and a location permission the user has not granted + mockSharedFiles = [createSharedFile('file:///shared/receipt.jpg', CONST.RECEIPT_ALLOWED_FILE_TYPES.JPEG)]; + jest.mocked(check).mockResolvedValueOnce('denied'); + + // When the share root processes the file + await renderShareRootPage(); + + // Then getCurrentPosition is skipped so processing the share does not request location permission + expect(getCurrentPosition).not.toHaveBeenCalled(); + expect(await getUserLocationFromOnyx()).toBeUndefined(); + }); + + it('clears stale cached location when the permission is not granted', async () => { + // Given a stale cached location and a location permission the user has not granted + await Onyx.set(ONYXKEYS.USER_LOCATION, DEVICE_POSITION); + mockSharedFiles = [createSharedFile('file:///shared/receipt.jpg', CONST.RECEIPT_ALLOWED_FILE_TYPES.JPEG)]; + jest.mocked(check).mockResolvedValueOnce('denied'); + + // When the share root processes the file + await renderShareRootPage(); + + // Then the device location is never read and the stale position is cleared from cache + expect(getCurrentPosition).not.toHaveBeenCalled(); + expect(await getUserLocationFromOnyx()).toBeUndefined(); + }); + + it('caches nothing and raises nothing when the permission check fails', async () => { + // Given a shared receipt image and a device whose location permission check fails outright + mockSharedFiles = [createSharedFile('file:///shared/receipt.jpg', CONST.RECEIPT_ALLOWED_FILE_TYPES.JPEG)]; + jest.mocked(check).mockRejectedValueOnce(new Error('permission check failed')); + const unhandledRejection = jest.fn(); + process.on('unhandledRejection', unhandledRejection); + + // When the share root processes the file + await renderShareRootPage(); + process.off('unhandledRejection', unhandledRejection); + + // Then no position is cached, and the failed check never surfaces as an unhandled rejection + expect(getCurrentPosition).not.toHaveBeenCalled(); + expect(await getUserLocationFromOnyx()).toBeUndefined(); + expect(unhandledRejection).not.toHaveBeenCalled(); + }); + + it('reads a fresh position when another share reuses the same file path while the page stays mounted', async () => { + // Given a shared receipt whose position the share root already cached + const emitAppState = captureAppStateListener(); + mockSharedFiles = [createSharedFile('file:///shared/shared_image.png', CONST.RECEIPT_ALLOWED_FILE_TYPES.PNG)]; + await renderShareRootPage(); + expect(await getUserLocationFromOnyx()).toEqual(DEVICE_POSITION); + + // When the user moves and shares another image at the same path, then returns to the app + // (iOS saves image data as shared_image.png; Android uses the display name) + mockPositionAnswer(NEW_DEVICE_POSITION); + mockSharedFiles = [createSharedFile('file:///shared/shared_image.png', CONST.RECEIPT_ALLOWED_FILE_TYPES.PNG)]; + await returnToForeground(emitAppState); + + // Then the cache contains the new position for this share. Reused file paths must still refresh the + // location so a new expense does not use coordinates from an earlier share. + expect(getCurrentPosition).toHaveBeenCalledTimes(2); + expect(await getUserLocationFromOnyx()).toEqual(NEW_DEVICE_POSITION); + }); +}); diff --git a/tests/ui/SubmitDetailsPageTest.tsx b/tests/ui/SubmitDetailsPageTest.tsx index 827434ab96b5..8d0a8d2b6a1f 100644 --- a/tests/ui/SubmitDetailsPageTest.tsx +++ b/tests/ui/SubmitDetailsPageTest.tsx @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import {snapshotUserLocation} from '@libs/actions/UserLocation'; import {readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -192,6 +193,11 @@ jest.mock('@components/MoneyRequestConfirmationList', () => { const SHARED_REPORT_ID = 'report-share-1'; const ACCOUNT_ID = 1; const PARTICIPANT_ACCOUNT_ID = 2; +// Differs from DEVICE_POSITION so an unexpected location snapshot on open would change USER_LOCATION. +const CACHED_USER_LOCATION = {latitude: 52.2297, longitude: 21.0122}; +// A position left over from an earlier share, distinct from both CACHED_USER_LOCATION and the device position. +const STALE_USER_LOCATION = {latitude: 51.5074, longitude: -0.1278}; +const DEVICE_POSITION = {latitude: 40.7128, longitude: -74.006}; function createTestReport(): Report { return { @@ -251,8 +257,8 @@ function mockPositionAnswer() { jest.mocked(getCurrentPosition).mockImplementation(async (success) => { success({ coords: { - latitude: 40.7128, - longitude: -74.006, + latitude: DEVICE_POSITION.latitude, + longitude: DEVICE_POSITION.longitude, altitude: null, accuracy: null, altitudeAccuracy: null, @@ -451,6 +457,89 @@ describe('SubmitDetailsPage', () => { expect(TrackExpense.requestMoney).toHaveBeenCalled(); }); + it('falls back to device position and continues submission when reading cached location fails', async () => { + // Given granted location permission and a cached position, with Onyx.get set to fail when the user confirms + mockPositionAnswer(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, CACHED_USER_LOCATION); + }); + renderSubmitDetailsPage(); + await waitForBatchedUpdatesWithAct(); + + const onyxGetSpy = jest.spyOn(Onyx, 'get').mockRejectedValueOnce(new Error('Onyx read error')); + + // When the user confirms the share and Onyx.get fails + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + + // Then the expense is created with the device position after the cache read fails + expect(getCurrentPosition).toHaveBeenCalledTimes(1); + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); + expect(jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].gpsPoint).toEqual({lat: DEVICE_POSITION.latitude, long: DEVICE_POSITION.longitude}); + onyxGetSpy.mockRestore(); + }); + + it('lets the user retry when submitting with the cached position throws', async () => { + // Given location permission granted, a cached position, and an expense write that throws on the first attempt + mockPositionAnswer(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, CACHED_USER_LOCATION); + }); + const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + jest.mocked(TrackExpense.requestMoney).mockImplementationOnce(() => { + throw new Error('requestMoney failed'); + }); + const unhandledRejection = jest.fn(); + process.on('unhandledRejection', unhandledRejection); + renderSubmitDetailsPage(); + await waitForBatchedUpdatesWithAct(); + + // When the user confirms, the write throws after the cached position was read, and the user confirms again + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + process.off('unhandledRejection', unhandledRejection); + + // Then the submission error is caught and formHasBeenSubmitted is reset, allowing a second requestMoney call + // without an unhandled rejection + expect(unhandledRejection).not.toHaveBeenCalled(); + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(2); + logWarnSpy.mockRestore(); + }); + + it('lets the user retry when submitting with the device position throws (no cached position)', async () => { + // Given location permission granted, no cached position, and an expense write that throws on the first attempt + mockPositionAnswer(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, null); + }); + const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + jest.mocked(TrackExpense.requestMoney).mockImplementationOnce(() => { + throw new Error('requestMoney failed'); + }); + const unhandledRejection = jest.fn(); + process.on('unhandledRejection', unhandledRejection); + renderSubmitDetailsPage(); + await waitForBatchedUpdatesWithAct(); + + // When the user confirms, the write throws after the device position was read, and the user confirms again + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + process.off('unhandledRejection', unhandledRejection); + + // Then the promise chain catches the submission error and resets formHasBeenSubmitted, so confirming + // again calls requestMoney a second time without an unhandled rejection + expect(unhandledRejection).not.toHaveBeenCalled(); + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(2); + logWarnSpy.mockRestore(); + }); + // Error #5 — wide layout fallback: when destination is not topmost, reveal it via revealRouteBeforeDismissingModal // and defer navigation to cleanup (shouldNavigate: false) so we do not double-navigate after dismiss. it('wide layout: reveals destination via revealRouteBeforeDismissingModal when another report is topmost', async () => { @@ -614,41 +703,88 @@ describe('SubmitDetailsPage', () => { } }); - it('creates the shared expense with the position the share screen cached, without reading the device at submit', async () => { - // Given a shared receipt, location permission granted when the user confirms, and a position the share screen cached when it opened + it('creates the shared expense with the position the share root cached, without reading the device at submit', async () => { + // Given a shared receipt, location permission granted when the user confirms, and a position ShareRootPage cached + // while the user picked a recipient mockPositionAnswer(); await act(async () => { await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, CACHED_USER_LOCATION); }); renderSubmitDetailsPage(); await waitForBatchedUpdatesWithAct(); - expect(await getUserLocationFromOnyx()).toEqual({latitude: 40.7128, longitude: -74.006}); + // When the user confirms the share + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + + // Then the expense carries the cached position, and neither opening nor confirming read the device + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); + expect(jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].gpsPoint).toEqual({lat: CACHED_USER_LOCATION.latitude, long: CACHED_USER_LOCATION.longitude}); + expect(getCurrentPosition).not.toHaveBeenCalled(); + }); + + it('falls back to a capped device read when the share root cached no position and permission is granted at confirm', async () => { + // Given a shared receipt and no position cached by ShareRootPage, with location permission granted at confirmation + mockPositionAnswer(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, null); + }); + renderSubmitDetailsPage(); + await waitForBatchedUpdatesWithAct(); // When the user confirms the share fireEvent.press(screen.getByTestId('mock-confirm-button')); await waitForBatchedUpdatesWithAct(); - // Then the expense carries the cached position and confirming read the device no further + // Then one device read supplies gpsPoint, and the expense is created with those coordinates + expect(getCurrentPosition).toHaveBeenCalledTimes(1); expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); - expect(jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].gpsPoint).toEqual({lat: 40.7128, long: -74.006}); + expect(jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].gpsPoint).toEqual({lat: DEVICE_POSITION.latitude, long: DEVICE_POSITION.longitude}); + }); + + it('does not reuse a stale cached position when permission was denied at pre-warm and granted in the submit modal', async () => { + // Given a position cached by an earlier share that is still in Onyx, and a pre-warm that finds the permission denied + mockPositionAnswer(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, null); + await Onyx.set(ONYXKEYS.USER_LOCATION, STALE_USER_LOCATION); + }); + jest.mocked(check).mockResolvedValueOnce('denied'); + snapshotUserLocation(); + await waitForBatchedUpdatesWithAct(); + + // When the user confirms the share with location permission now granted + renderSubmitDetailsPage(); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('mock-confirm-button')); + await waitForBatchedUpdatesWithAct(); + + // Then snapshotUserLocation has cleared the stale cache, so submission reads the device and attaches its current position expect(getCurrentPosition).toHaveBeenCalledTimes(1); + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); + const gpsPoint = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].gpsPoint; + expect(gpsPoint).toEqual({lat: DEVICE_POSITION.latitude, long: DEVICE_POSITION.longitude}); + expect(gpsPoint).not.toEqual({lat: STALE_USER_LOCATION.latitude, long: STALE_USER_LOCATION.longitude}); }); - it('caches nothing and raises nothing when the permission check fails on open', async () => { - // Given a device whose location permission check fails outright - jest.mocked(check).mockRejectedValueOnce(new Error('permission check failed')); - const unhandledRejection = jest.fn(); - process.on('unhandledRejection', unhandledRejection); + it('leaves the cached position alone when it opens, so the location snapshot stays off the ShareExtensionOpenSubmitFlow span', async () => { + // Given location permission granted and a position ShareRootPage cached before the user picked a recipient + mockPositionAnswer(); + await act(async () => { + await Onyx.set(ONYXKEYS.USER_LOCATION, CACHED_USER_LOCATION); + }); - // When the share screen opens + // When the details page opens renderSubmitDetailsPage(); await waitForBatchedUpdatesWithAct(); - process.off('unhandledRejection', unhandledRejection); - // Then the screen cached no position, and the failed check never surfaced as an unhandled rejection - expect(await getUserLocationFromOnyx()).toBeUndefined(); - expect(unhandledRejection).not.toHaveBeenCalled(); + // Then opening the page leaves USER_LOCATION unchanged and neither checks permission nor reads the device. + // The cached position is kept for submission. + expect(check).not.toHaveBeenCalled(); + expect(getCurrentPosition).not.toHaveBeenCalled(); + expect(await getUserLocationFromOnyx()).toEqual(CACHED_USER_LOCATION); }); // Error #11 — narrow layout race: confirm fires before scheduleWhenIdle runs pre-insert setup.