From e10a2ed06e266732079cd40f12920f69dbbc42ba Mon Sep 17 00:00:00 2001 From: Jakub Magiera Date: Mon, 19 Oct 2026 11:47:05 +0200 Subject: [PATCH] Pre-warm user location in ShareRootPage --- src/pages/Share/ShareRootPage.tsx | 2 + src/pages/Share/SubmitDetailsPage.tsx | 5 - tests/ui/ShareRootPageLocationTest.tsx | 180 +++++++++++++++++++++++++ tests/ui/SubmitDetailsPageTest.tsx | 38 +++--- 4 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 tests/ui/ShareRootPageLocationTest.tsx diff --git a/src/pages/Share/ShareRootPage.tsx b/src/pages/Share/ShareRootPage.tsx index 6ec0c6e448d9..6cb61df3fe9b 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'; @@ -144,6 +145,7 @@ 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); + snapshotUserLocation(); } else { setIsFileScannable(false); } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 5690db0d0822..bde127091926 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -35,7 +35,6 @@ 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'; @@ -175,10 +174,6 @@ function SubmitDetailsPage({ [], ); - useEffect(() => { - snapshotUserLocation(); - }, []); - useEffect(() => { if (!errorTitle || !errorMessage) { return; diff --git a/tests/ui/ShareRootPageLocationTest.tsx b/tests/ui/ShareRootPageLocationTest.tsx new file mode 100644 index 000000000000..0e9bac90744c --- /dev/null +++ b/tests/ui/ShareRootPageLocationTest.tsx @@ -0,0 +1,180 @@ +import {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 {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +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()}))); + +// Isolate the page from the tab contents and the screen chrome: these tests only cover what processing a shared file triggers. +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}; + +function createSharedFile(content: string, mimeType: string): SharedFile { + return {id: 'shared-file-1', content, mimeType, processedAt: '0', aspectRatio: 1}; +} + +function mockPositionAnswer() { + jest.mocked(getCurrentPosition).mockImplementation(async (success) => { + success({ + coords: { + latitude: DEVICE_POSITION.latitude, + longitude: DEVICE_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) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +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(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 position is cached before the user even picks a recipient, so the Submit details page never has to + // read the device on open, which would land inside the ShareExtensionOpenSubmitFlow span + 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 the device location is never read, because reading it would request the permission and show the system prompt + 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(); + }); +}); diff --git a/tests/ui/SubmitDetailsPageTest.tsx b/tests/ui/SubmitDetailsPageTest.tsx index 0fd2ef93b14e..f4dbf7c22857 100644 --- a/tests/ui/SubmitDetailsPageTest.tsx +++ b/tests/ui/SubmitDetailsPageTest.tsx @@ -191,6 +191,8 @@ jest.mock('@components/MoneyRequestConfirmationList', () => { const SHARED_REPORT_ID = 'report-share-1'; const ACCOUNT_ID = 1; const PARTICIPANT_ACCOUNT_ID = 2; +// Deliberately differs from mockPositionAnswer, so a device read on open would be visible in USER_LOCATION. +const CACHED_USER_LOCATION = {latitude: 52.2297, longitude: 21.0122}; function createTestReport(): Report { return { @@ -607,41 +609,43 @@ 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 confirming read the device no further + // 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: 40.7128, long: -74.006}); - expect(getCurrentPosition).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('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 it neither checks the permission nor reads the device, and the cached position survives, because a mount-time + // snapshot would put the main-thread location work and the USER_LOCATION re-renders inside the span + 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.