Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/pages/Share/ShareRootPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 0 additions & 5 deletions src/pages/Share/SubmitDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -175,10 +174,6 @@ function SubmitDetailsPage({
[],
);

useEffect(() => {
snapshotUserLocation();
}, []);

useEffect(() => {
if (!errorTitle || !errorMessage) {
return;
Expand Down
180 changes: 180 additions & 0 deletions tests/ui/ShareRootPageLocationTest.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FileUtilsModule>('@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<OnyxEntry<UserLocation>> {
return new Promise((resolve) => {
const connection = Onyx.connect({
key: ONYXKEYS.USER_LOCATION,
callback: (value) => {
Onyx.disconnect(connection);
resolve(value);
},
});
});
}

async function renderShareRootPage() {
render(
<LocaleContextProvider>
<ShareRootPage />
</LocaleContextProvider>,
);
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();
});
});
38 changes: 21 additions & 17 deletions tests/ui/SubmitDetailsPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading