From a87649bbf58603b5633bf8ba1d0228101b1535bc Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Mon, 13 Jul 2026 16:03:16 -0700 Subject: [PATCH 1/2] revert: back out save-receipts-on-signout (PR 94973) for blocker 95957 --- src/languages/de.ts | 12 -- src/languages/en.ts | 12 -- src/languages/es.ts | 12 -- src/languages/fr.ts | 12 -- src/languages/it.ts | 12 -- src/languages/ja.ts | 11 -- src/languages/nl.ts | 12 -- src/languages/pl.ts | 12 -- src/languages/pt-BR.ts | 12 -- src/languages/zh-hans.ts | 11 -- .../hasGalleryWritePermission.android.ts | 26 --- .../hasGalleryWritePermission.ios.ts | 6 - .../fileDownload/hasGalleryWritePermission.ts | 6 - src/libs/fileDownload/index.android.ts | 53 ++++- src/libs/fileDownload/index.ios.ts | 4 +- .../saveLocalFileToGallery.android.ts | 19 -- .../saveLocalFileToGallery.ios.ts | 9 - .../fileDownload/saveLocalFileToGallery.ts | 7 - .../getPendingReceiptRequests.native.ts | 59 ------ .../getPendingReceiptRequests.ts | 14 -- .../savePendingReceiptsToGallery/index.ts | 4 - .../saveReceiptsToGallery.native.ts | 33 ---- .../saveReceiptsToGallery.ts | 9 - .../savePendingReceiptsToGallery/types.ts | 22 --- src/pages/settings/InitialSettingsPage.tsx | 99 +--------- .../unit/savePendingReceiptsToGalleryTest.ts | 185 ------------------ 26 files changed, 59 insertions(+), 614 deletions(-) delete mode 100644 src/libs/fileDownload/hasGalleryWritePermission.android.ts delete mode 100644 src/libs/fileDownload/hasGalleryWritePermission.ios.ts delete mode 100644 src/libs/fileDownload/hasGalleryWritePermission.ts delete mode 100644 src/libs/fileDownload/saveLocalFileToGallery.android.ts delete mode 100644 src/libs/fileDownload/saveLocalFileToGallery.ios.ts delete mode 100644 src/libs/fileDownload/saveLocalFileToGallery.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/index.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts delete mode 100644 src/libs/savePendingReceiptsToGallery/types.ts delete mode 100644 tests/unit/savePendingReceiptsToGalleryTest.ts diff --git a/src/languages/de.ts b/src/languages/de.ts index 9db5d05ee8da..4781201670f5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2222,18 +2222,6 @@ const translations: TranslationDeepObject = { signOut: 'Abmelden', restoreStashed: 'Gespeicherten Login wiederherstellen', signOutConfirmationText: 'Alle Offline-Änderungen gehen verloren, wenn du dich abmeldest.', - saveReceiptsConfirmation: { - title: 'Belege speichern?', - prompt: ({count}: {count: number}) => - `Es ${count === 1 ? 'wird noch 1 Beleg' : `werden noch ${count} Belege`} hochgeladen. Wenn du dich jetzt abmeldest, ${count === 1 ? 'speichern wir ihn' : 'speichern wir sie'} in deinen Fotos, damit du ${count === 1 ? 'ihn' : 'sie'} später zu einer neuen Ausgabe hinzufügen kannst.`, - confirm: 'Speichern und abmelden', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Belege speichern?', - prompt: ({count}: {count: number}) => - `Es ${count === 1 ? 'wird noch 1 Beleg' : `werden noch ${count} Belege`} hochgeladen. Wenn du dich jetzt abmeldest, ${count === 1 ? 'speichern wir ihn' : 'speichern wir sie'} in deinen Fotos, damit du ${count === 1 ? 'ihn' : 'sie'} später zu einer neuen Ausgabe hinzufügen kannst. Alle Offline-Änderungen gehen verloren, wenn du dich abmeldest.`, - confirm: 'Speichern und abmelden', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Lesen Sie die Nutzungsbedingungen und die Datenschutzerklärung.`, help: 'Hilfe', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6c81414c7a43..ad951130d053 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2309,18 +2309,6 @@ const translations = { signOut: 'Sign out', restoreStashed: 'Restore stashed login', signOutConfirmationText: "You'll lose any offline changes if you sign out.", - saveReceiptsConfirmation: { - title: 'Save your receipts?', - prompt: ({count}: {count: number}) => - `You have ${count} ${count === 1 ? 'receipt' : 'receipts'} still uploading. Sign out now and we'll save ${count === 1 ? 'it' : 'them'} to your photos so you can add ${count === 1 ? 'it' : 'them'} to a new expense later.`, - confirm: 'Save and sign out', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Save your receipts?', - prompt: ({count}: {count: number}) => - `You have ${count} ${count === 1 ? 'receipt' : 'receipts'} still uploading. Sign out now and we'll save ${count === 1 ? 'it' : 'them'} to your photos so you can add ${count === 1 ? 'it' : 'them'} to a new expense later. You'll lose any other offline changes.`, - confirm: 'Save and sign out', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Read the Terms of Service and Privacy.`, help: 'Help', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2b7cf530a14b..1004d7cf7646 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2099,18 +2099,6 @@ const translations: TranslationDeepObject = { restoreStashed: 'Restablecer login guardado', signOut: 'Desconectar', signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado', - saveReceiptsConfirmation: { - title: '¿Guardar tus recibos?', - prompt: ({count}: {count: number}) => - `Tienes ${count} ${count === 1 ? 'recibo' : 'recibos'} subiéndose todavía. Si cierras sesión ahora, ${count === 1 ? 'lo guardaremos' : 'los guardaremos'} en tus fotos para que ${count === 1 ? 'puedas añadirlo' : 'puedas añadirlos'} a un nuevo gasto más tarde.`, - confirm: 'Guardar y cerrar sesión', - }, - saveReceiptsAndSignOutConfirmation: { - title: '¿Guardar tus recibos?', - prompt: ({count}: {count: number}) => - `Tienes ${count} ${count === 1 ? 'recibo' : 'recibos'} subiéndose todavía. Si cierras sesión ahora, ${count === 1 ? 'lo guardaremos' : 'los guardaremos'} en tus fotos para que ${count === 1 ? 'puedas añadirlo' : 'puedas añadirlos'} a un nuevo gasto más tarde. Perderás los demás cambios hechos mientras estabas desconectado.`, - confirm: 'Guardar y cerrar sesión', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Leer los Términos de Servicio y Privacidad.`, help: 'Ayuda', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 29e4137fd844..6bd23538e72a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2227,18 +2227,6 @@ const translations: TranslationDeepObject = { signOut: 'Se déconnecter', restoreStashed: 'Restaurer la connexion mise de côté', signOutConfirmationText: 'Vous perdrez toutes les modifications hors ligne si vous vous déconnectez.', - saveReceiptsConfirmation: { - title: 'Enregistrer vos reçus ?', - prompt: ({count}: {count: number}) => - `${count} ${count === 1 ? 'reçu est' : 'reçus sont'} encore en cours de téléchargement. Si vous vous déconnectez maintenant, nous ${count === 1 ? "l'enregistrerons" : 'les enregistrerons'} dans vos photos pour que vous puissiez ${count === 1 ? "l'ajouter" : 'les ajouter'} à une nouvelle dépense plus tard.`, - confirm: 'Enregistrer et se déconnecter', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Enregistrer vos reçus ?', - prompt: ({count}: {count: number}) => - `${count} ${count === 1 ? 'reçu est' : 'reçus sont'} encore en cours de téléchargement. Si vous vous déconnectez maintenant, nous ${count === 1 ? "l'enregistrerons" : 'les enregistrerons'} dans vos photos pour que vous puissiez ${count === 1 ? "l'ajouter" : 'les ajouter'} à une nouvelle dépense plus tard. Vous perdrez toutes les autres modifications hors ligne.`, - confirm: 'Enregistrer et se déconnecter', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Lisez les Conditions d'utilisation et la Politique de confidentialité.`, help: 'Aide', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7873fda1fe4b..0fca2183802a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2219,18 +2219,6 @@ const translations: TranslationDeepObject = { signOut: 'Esci', restoreStashed: 'Ripristina accesso nascosto', signOutConfirmationText: 'Perderai tutte le modifiche offline se esci.', - saveReceiptsConfirmation: { - title: 'Salvare le tue ricevute?', - prompt: ({count}: {count: number}) => - `${count === 1 ? "C'è ancora 1 ricevuta" : `Ci sono ancora ${count} ricevute`} in fase di caricamento. Se esci ora, ${count === 1 ? 'la salveremo' : 'le salveremo'} nelle tue foto così potrai ${count === 1 ? 'aggiungerla' : 'aggiungerle'} a una nuova spesa più tardi.`, - confirm: 'Salva ed esci', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Salvare le tue ricevute?', - prompt: ({count}: {count: number}) => - `${count === 1 ? "C'è ancora 1 ricevuta" : `Ci sono ancora ${count} ricevute`} in fase di caricamento. Se esci ora, ${count === 1 ? 'la salveremo' : 'le salveremo'} nelle tue foto così potrai ${count === 1 ? 'aggiungerla' : 'aggiungerle'} a una nuova spesa più tardi. Perderai tutte le altre modifiche offline.`, - confirm: 'Salva ed esci', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Leggi i Termini di servizio e l’Informativa sulla privacy.`, help: 'Aiuto', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cf45cae4aac8..afbd7d7784ee 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2197,17 +2197,6 @@ const translations: TranslationDeepObject = { signOut: 'サインアウト', restoreStashed: '保存済みログインを復元', signOutConfirmationText: 'サインアウトすると、オフライン中の変更内容はすべて失われます。', - saveReceiptsConfirmation: { - title: 'レシートを保存しますか?', - prompt: ({count}: {count: number}) => `${count}件のレシートがまだアップロード中です。今サインアウトすると、写真に保存され、後で新しい経費に追加できます。`, - confirm: '保存してサインアウト', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'レシートを保存しますか?', - prompt: ({count}: {count: number}) => - `${count}件のレシートがまだアップロード中です。今サインアウトすると、写真に保存され、後で新しい経費に追加できます。その他のオフライン中の変更内容はすべて失われます。`, - confirm: '保存してサインアウト', - }, versionLetter: 'v', readTheTermsAndPrivacy: `利用規約プライバシーポリシーをお読みください。`, help: 'ヘルプ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b53def9ce5ac..a5e91ca082a7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2214,18 +2214,6 @@ const translations: TranslationDeepObject = { signOut: 'Afmelden', restoreStashed: 'Opgeslagen login herstellen', signOutConfirmationText: 'Je verliest alle offline wijzigingen als je je afmeldt.', - saveReceiptsConfirmation: { - title: 'Bonnen opslaan?', - prompt: ({count}: {count: number}) => - `Er ${count === 1 ? 'wordt nog 1 bon' : `worden nog ${count} bonnen`} geüpload. Als je je nu afmeldt, slaan we ${count === 1 ? 'deze' : 'ze'} op in je foto's zodat je ${count === 1 ? 'deze' : 'ze'} later aan een nieuwe uitgave kunt toevoegen.`, - confirm: 'Opslaan en afmelden', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Bonnen opslaan?', - prompt: ({count}: {count: number}) => - `Er ${count === 1 ? 'wordt nog 1 bon' : `worden nog ${count} bonnen`} geüpload. Als je je nu afmeldt, slaan we ${count === 1 ? 'deze' : 'ze'} op in je foto's zodat je ${count === 1 ? 'deze' : 'ze'} later aan een nieuwe uitgave kunt toevoegen. Je verliest alle andere offline wijzigingen.`, - confirm: 'Opslaan en afmelden', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Lees de Servicevoorwaarden en het Privacybeleid.`, help: 'Help', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 31f90cb7466d..7bad6ba9dcb6 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2210,18 +2210,6 @@ const translations: TranslationDeepObject = { signOut: 'Wyloguj się', restoreStashed: 'Przywróć zapisane logowanie', signOutConfirmationText: 'Utracisz wszystkie zmiany w trybie offline, jeśli się wylogujesz.', - saveReceiptsConfirmation: { - title: 'Zapisać paragony?', - prompt: ({count}: {count: number}) => - `${count === 1 ? 'Nadal przesyłany jest 1 paragon' : `Nadal przesyłane są paragony (${count})`}. Jeśli wylogujesz się teraz, zapiszemy ${count === 1 ? 'go' : 'je'} w Twoich zdjęciach, abyś ${count === 1 ? 'mógł go dodać' : 'mógł je dodać'} później do nowego wydatku.`, - confirm: 'Zapisz i wyloguj', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Zapisać paragony?', - prompt: ({count}: {count: number}) => - `${count === 1 ? 'Nadal przesyłany jest 1 paragon' : `Nadal przesyłane są paragony (${count})`}. Jeśli wylogujesz się teraz, zapiszemy ${count === 1 ? 'go' : 'je'} w Twoich zdjęciach, abyś ${count === 1 ? 'mógł go dodać' : 'mógł je dodać'} później do nowego wydatku. Utracisz wszystkie pozostałe zmiany w trybie offline.`, - confirm: 'Zapisz i wyloguj', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Przeczytaj Warunki korzystania z usługi i Politykę prywatności.`, help: 'Pomoc', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e638ced1ec7b..759100702277 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2207,18 +2207,6 @@ const translations: TranslationDeepObject = { signOut: 'Sair', restoreStashed: 'Restaurar login armazenado', signOutConfirmationText: 'Você perderá quaisquer alterações offline se sair.', - saveReceiptsConfirmation: { - title: 'Salvar seus recibos?', - prompt: ({count}: {count: number}) => - `${count === 1 ? 'Ainda há 1 recibo' : `Ainda há ${count} recibos`} sendo enviado. Se você sair agora, ${count === 1 ? 'nós o salvaremos' : 'nós os salvaremos'} nas suas fotos para que você possa ${count === 1 ? 'adicioná-lo' : 'adicioná-los'} a uma nova despesa mais tarde.`, - confirm: 'Salvar e sair', - }, - saveReceiptsAndSignOutConfirmation: { - title: 'Salvar seus recibos?', - prompt: ({count}: {count: number}) => - `${count === 1 ? 'Ainda há 1 recibo' : `Ainda há ${count} recibos`} sendo enviado. Se você sair agora, ${count === 1 ? 'nós o salvaremos' : 'nós os salvaremos'} nas suas fotos para que você possa ${count === 1 ? 'adicioná-lo' : 'adicioná-los'} a uma nova despesa mais tarde. Você perderá quaisquer outras alterações offline.`, - confirm: 'Salvar e sair', - }, versionLetter: 'v', readTheTermsAndPrivacy: `Leia os Termos de Serviço e a Política de Privacidade.`, help: 'Ajuda', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b6032d064f89..b33368c50a5f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2144,17 +2144,6 @@ const translations: TranslationDeepObject = { signOut: '退出登录', restoreStashed: '恢复暂存的登录', signOutConfirmationText: '如果你退出登录,所有离线更改都会丢失。', - saveReceiptsConfirmation: { - title: '保存你的收据?', - prompt: ({count}: {count: number}) => `还有 ${count} 张收据正在上传。如果你现在退出登录,我们会将其保存到你的照片中,以便你稍后将其添加到新的支出中。`, - confirm: '保存并退出登录', - }, - saveReceiptsAndSignOutConfirmation: { - title: '保存你的收据?', - prompt: ({count}: {count: number}) => - `还有 ${count} 张收据正在上传。如果你现在退出登录,我们会将其保存到你的照片中,以便你稍后将其添加到新的支出中。你的所有其他离线更改都会丢失。`, - confirm: '保存并退出登录', - }, versionLetter: 'v', readTheTermsAndPrivacy: `阅读服务条款隐私政策。`, help: '帮助', diff --git a/src/libs/fileDownload/hasGalleryWritePermission.android.ts b/src/libs/fileDownload/hasGalleryWritePermission.android.ts deleted file mode 100644 index 701248dc4464..000000000000 --- a/src/libs/fileDownload/hasGalleryWritePermission.android.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {PermissionsAndroid, Platform} from 'react-native'; - -function hasGalleryWritePermission(): Promise { - // On Android API Level 33 and above, these permissions do nothing and always return 'never_ask_again' - // More info here: https://stackoverflow.com/a/74296799 - if (Number(Platform.Version) >= 33) { - return Promise.resolve(true); - } - - const writePromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); - const readPromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); - - return Promise.all([writePromise, readPromise]).then(([hasWritePermission, hasReadPermission]) => { - if (hasWritePermission && hasReadPermission) { - return true; - } - - return PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE]).then( - (status) => - status[PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE] === PermissionsAndroid.RESULTS.GRANTED && - status[PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE] === PermissionsAndroid.RESULTS.GRANTED, - ); - }); -} - -export default hasGalleryWritePermission; diff --git a/src/libs/fileDownload/hasGalleryWritePermission.ios.ts b/src/libs/fileDownload/hasGalleryWritePermission.ios.ts deleted file mode 100644 index c84624977bab..000000000000 --- a/src/libs/fileDownload/hasGalleryWritePermission.ios.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** iOS prompts for photo permission itself on the first write, so there is no upfront gate. */ -function hasGalleryWritePermission(): Promise { - return Promise.resolve(true); -} - -export default hasGalleryWritePermission; diff --git a/src/libs/fileDownload/hasGalleryWritePermission.ts b/src/libs/fileDownload/hasGalleryWritePermission.ts deleted file mode 100644 index da578484436e..000000000000 --- a/src/libs/fileDownload/hasGalleryWritePermission.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Type-resolution base; never called on web (no device gallery). */ -function hasGalleryWritePermission(): Promise { - return Promise.resolve(false); -} - -export default hasGalleryWritePermission; diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 58a40fa412e5..ee3ad7a66ad4 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -4,14 +4,39 @@ import CONST from '@src/CONST'; import type {FetchBlobResponse} from 'react-native-blob-util'; +import {PermissionsAndroid, Platform} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import RNFS from 'react-native-fs'; import type {FileDownload} from './types'; import {appendTimeToFileName, getFileName, showGeneralErrorAlert, showPermissionErrorAlert, showSuccessAlert} from './FileUtils'; -import hasGalleryWritePermission from './hasGalleryWritePermission'; -import saveLocalFileToGallery from './saveLocalFileToGallery'; + +/** + * Android permission check to store images + */ +function hasAndroidPermission(): Promise { + // On Android API Level 33 and above, these permissions do nothing and always return 'never_ask_again' + // More info here: https://stackoverflow.com/a/74296799 + if (Number(Platform.Version) >= 33) { + return Promise.resolve(true); + } + + // Read and write permission + const writePromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); + const readPromise = PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); + + return Promise.all([writePromise, readPromise]).then(([hasWritePermission, hasReadPermission]) => { + if (hasWritePermission && hasReadPermission) { + return true; // Return true if permission is already given + } + + // Ask for permission if not given + return PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE]).then( + (status) => status['android.permission.READ_EXTERNAL_STORAGE'] === 'granted' && status['android.permission.WRITE_EXTERNAL_STORAGE'] === 'granted', + ); + }); +} /** * Handling the download @@ -55,7 +80,15 @@ function handleDownload(translate: LocalizedTranslate, url: string, fileName?: s attachmentPath = (attachment as FetchBlobResponse).path(); } - return saveLocalFileToGallery(isLocalFile ? url : (attachmentPath ?? ''), attachmentName); + return RNFetchBlob.MediaCollection.copyToMediaStore( + { + name: attachmentName, + parentFolder: 'Expensify', + mimeType: null, + }, + 'Download', + attachmentPath ?? '', + ); }) .then(() => { if (attachmentPath && shouldUnlink) { @@ -93,7 +126,17 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: const downloadPath = `${RNFS.DownloadDirectoryPath}/${finalFileName}`; return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); }) - .then((downloadPath) => saveLocalFileToGallery(downloadPath, getFileName(downloadPath)).then(() => downloadPath)) + .then((downloadPath) => + RNFetchBlob.MediaCollection.copyToMediaStore( + { + name: getFileName(downloadPath), + parentFolder: 'Expensify', + mimeType: null, + }, + 'Download', + downloadPath, + ).then(() => downloadPath), + ) .then((downloadPath) => { RNFetchBlob.fs.unlink(downloadPath); showSuccessAlert(translate); @@ -111,7 +154,7 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: */ const fileDownload: FileDownload = (translate, url, fileName, successMessage, _, formData, requestType, onDownloadFailed, shouldUnlink, appendTimestamp = true) => new Promise((resolve) => { - hasGalleryWritePermission() + hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { if (requestType === CONST.NETWORK.METHOD.POST) { diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 77985ca00d2a..45577c54ba31 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -12,7 +12,6 @@ import Share from 'react-native-share'; import type {FileDownload} from './types'; import {appendTimeToFileName, getFileName, getFileType, showGeneralErrorAlert, showPermissionErrorAlert, showSuccessAlert} from './FileUtils'; -import saveLocalFileToGallery from './saveLocalFileToGallery'; const isUserCancelled = (err: unknown) => { let msg = ''; @@ -95,8 +94,7 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: * Download the image to photo lib in iOS */ function downloadImage(fileUrl: string) { - // Resolve to a truthy value so the shared success alert below still fires (the raw native result is unused). - return saveLocalFileToGallery(fileUrl).then(() => true); + return CameraRoll.saveAsset(fileUrl); } /** diff --git a/src/libs/fileDownload/saveLocalFileToGallery.android.ts b/src/libs/fileDownload/saveLocalFileToGallery.android.ts deleted file mode 100644 index b00a443b47f9..000000000000 --- a/src/libs/fileDownload/saveLocalFileToGallery.android.ts +++ /dev/null @@ -1,19 +0,0 @@ -import RNFetchBlob from 'react-native-blob-util'; - -import {getFileName} from './FileUtils'; - -/** Copies into Download/Expensify with no alert. Name is used as given; callers append a timestamp if they need collision-free names. */ -function saveLocalFileToGallery(localPath: string, fileName?: string, mimeType?: string): Promise { - const sourcePath = localPath.startsWith('file://') ? decodeURI(localPath) : localPath; - return RNFetchBlob.MediaCollection.copyToMediaStore( - { - name: fileName ?? getFileName(localPath), - parentFolder: 'Expensify', - mimeType: mimeType ?? null, - }, - 'Download', - sourcePath, - ).then(() => undefined); -} - -export default saveLocalFileToGallery; diff --git a/src/libs/fileDownload/saveLocalFileToGallery.ios.ts b/src/libs/fileDownload/saveLocalFileToGallery.ios.ts deleted file mode 100644 index 89e77035a873..000000000000 --- a/src/libs/fileDownload/saveLocalFileToGallery.ios.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {CameraRoll} from '@react-native-camera-roll/camera-roll'; - -/** Saves to the iOS camera roll with no alert (callers own user feedback). iOS prompts for permission itself on first write. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- fileName/mimeType are unused on iOS, kept for parity with the Android implementation -function saveLocalFileToGallery(localPath: string, fileName?: string, mimeType?: string): Promise { - return CameraRoll.saveAsset(localPath).then(() => undefined); -} - -export default saveLocalFileToGallery; diff --git a/src/libs/fileDownload/saveLocalFileToGallery.ts b/src/libs/fileDownload/saveLocalFileToGallery.ts deleted file mode 100644 index db2d38e06474..000000000000 --- a/src/libs/fileDownload/saveLocalFileToGallery.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Type-resolution base for the native `.ios`/`.android` files; never called on web (downloads go through the browser path). */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- base for the platform-split signature -function saveLocalFileToGallery(localPath: string, fileName?: string, mimeType?: string): Promise { - return Promise.reject(new Error('Saving to the gallery is not supported on web')); -} - -export default saveLocalFileToGallery; diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts deleted file mode 100644 index f9dca52f1d40..000000000000 --- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {isLocalFile} from '@libs/fileDownload/FileUtils'; -import {isRecord} from '@libs/ObjectUtils'; -import {RECEIPT_BEARING_COMMANDS} from '@libs/telemetry/ReceiptObservability'; - -import {getAll, getOngoingRequest} from '@userActions/PersistedRequests'; - -import type {PendingReceipt} from './types'; - -function toPendingReceipt(receipt: unknown): PendingReceipt | undefined { - if (!isRecord(receipt)) { - return undefined; - } - - const getString = (key: string): string | undefined => (typeof receipt[key] === 'string' ? receipt[key] : undefined); - - // `REPLACE_RECEIPT` queues the raw File object, which stores its local path under `uri` rather than `source`. - const localPath = getString('localSource') ?? getString('source') ?? getString('uri'); - if (localPath === undefined || !isLocalFile(localPath)) { - return undefined; - } - - return {localPath, filename: getString('filename') ?? getString('name'), type: getString('type')}; -} - -/** Pure and synchronous so the observability snapshot can share it. Skips non-receipt and already-uploaded (remote-source) requests. */ -function getPendingReceiptRequests(): PendingReceipt[] { - const ongoingRequest = getOngoingRequest(); - const requests = [...(ongoingRequest ? [ongoingRequest] : []), ...getAll()]; - - return requests.reduce((pendingReceipts, request) => { - if (!RECEIPT_BEARING_COMMANDS.has(request.command)) { - return pendingReceipts; - } - - const pendingReceipt = toPendingReceipt(request.data?.receipt); - if (pendingReceipt) { - pendingReceipts.push(pendingReceipt); - } - return pendingReceipts; - }, []); -} - -const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'tif', 'tiff', 'webp', 'bmp']); - -function isImageReceipt({type, filename, localPath}: PendingReceipt): boolean { - if (type && type.toLowerCase().startsWith('image/')) { - return true; - } - const extension = (filename ?? localPath).split('.').pop()?.toLowerCase(); - return extension !== undefined && IMAGE_EXTENSIONS.has(extension); -} - -/** Native gallery APIs (`CameraRoll.saveAsset`, Android's MediaStore image collection) accept only image/video assets. Non-image receipt formats permitted by `CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS` (PDF, DOC, HTML, ZIP, ...) would silently fail; filtering them out here keeps the sign-out modal from promising a save it can't deliver. Those receipts fall under the general offline-data-loss warning. */ -function getSaveablePendingReceiptRequests(): PendingReceipt[] { - return getPendingReceiptRequests().filter(isImageReceipt); -} - -export default getPendingReceiptRequests; -export {getSaveablePendingReceiptRequests}; diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts deleted file mode 100644 index c8fec43b6616..000000000000 --- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {PendingReceipt} from './types'; - -/** Web has no device gallery target, so the caller (sign-out modal gate) sees an empty list and skips the save flow entirely. */ -function getPendingReceiptRequests(): PendingReceipt[] { - return []; -} - -/** Web no-op counterpart to the native saveable filter. See the native file for rationale. */ -function getSaveablePendingReceiptRequests(): PendingReceipt[] { - return []; -} - -export default getPendingReceiptRequests; -export {getSaveablePendingReceiptRequests}; diff --git a/src/libs/savePendingReceiptsToGallery/index.ts b/src/libs/savePendingReceiptsToGallery/index.ts deleted file mode 100644 index c2af533530ac..000000000000 --- a/src/libs/savePendingReceiptsToGallery/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import {getSaveablePendingReceiptRequests} from './getPendingReceiptRequests'; -import saveReceiptsToGallery from './saveReceiptsToGallery'; - -export {getSaveablePendingReceiptRequests, saveReceiptsToGallery}; diff --git a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts deleted file mode 100644 index 9b9ba925baff..000000000000 --- a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {appendTimeToFileName, getFileName} from '@libs/fileDownload/FileUtils'; -import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermission'; -import saveLocalFileToGallery from '@libs/fileDownload/saveLocalFileToGallery'; -import Log from '@libs/Log'; - -import type {PendingReceipt, SaveReceiptsResult} from './types'; - -/** Writes each file with `Promise.allSettled` so one failure does not sink the rest, and swallows every throw so it can never block sign-out. */ -function saveReceiptsToGallery(receipts: PendingReceipt[]): Promise { - if (receipts.length === 0) { - return Promise.resolve({savedCount: 0, failedCount: 0}); - } - - return hasGalleryWritePermission() - .then((hasPermission) => { - if (!hasPermission) { - return {savedCount: 0, failedCount: receipts.length}; - } - - return Promise.allSettled( - receipts.map((receipt) => saveLocalFileToGallery(receipt.localPath, appendTimeToFileName(receipt.filename ?? getFileName(receipt.localPath)), receipt.type)), - ).then((results) => { - const savedCount = results.filter((result) => result.status === 'fulfilled').length; - return {savedCount, failedCount: results.length - savedCount}; - }); - }) - .catch((error: unknown) => { - Log.hmmm('[Receipt] Gallery save batch failed', {error, receiptCount: receipts.length}); - return {savedCount: 0, failedCount: receipts.length}; - }); -} - -export default saveReceiptsToGallery; diff --git a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts deleted file mode 100644 index ad6d47cd4aa7..000000000000 --- a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {PendingReceipt, SaveReceiptsResult} from './types'; - -/** Web has no device gallery, so the sign-out redirect calls this no-op and web behavior is unchanged. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for interface parity with the native implementation -function saveReceiptsToGallery(receipts: PendingReceipt[]): Promise { - return Promise.resolve({savedCount: 0, failedCount: 0}); -} - -export default saveReceiptsToGallery; diff --git a/src/libs/savePendingReceiptsToGallery/types.ts b/src/libs/savePendingReceiptsToGallery/types.ts deleted file mode 100644 index a442287f8ac6..000000000000 --- a/src/libs/savePendingReceiptsToGallery/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** A pending, locally-stored receipt awaiting upload, reduced to what the gallery save needs. */ -type PendingReceipt = { - /** Local file path, from `receipt.localSource ?? receipt.source`, only when it passes `isLocalFile()` */ - localPath: string; - - /** Original receipt file name, when available */ - filename?: string; - - /** Receipt MIME type, when available */ - type?: string; -}; - -/** Outcome of a batch gallery save. */ -type SaveReceiptsResult = { - /** Number of receipts written to the gallery */ - savedCount: number; - - /** Number of receipts that failed to write */ - failedCount: number; -}; - -export type {PendingReceipt, SaveReceiptsResult}; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index a42e147a13ba..2880085ddeba 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -35,11 +35,9 @@ import {resetExitSurveyForm} from '@libs/actions/ExitSurvey'; import {closeReactNativeApp} from '@libs/actions/HybridApp'; import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import {hasPendingExpensifyCardAction, hasVirtualExpensifyCardMissingPersonalDetails} from '@libs/CardUtils'; -import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive'; import Navigation from '@libs/Navigation/Navigation'; -import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery'; import {useIsAgentAccount} from '@libs/SessionUtils'; import {getFreeTrialText, hasSubscriptionRedDotError} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -112,11 +110,7 @@ type MenuData = WithSentryLabel & { isBadgeCondensed?: boolean; }; -type Menu = { - sectionStyle: StyleProp; - sectionTranslationKey: TranslationPaths; - items: MenuData[]; -}; +type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]}; export type {MenuData}; @@ -145,12 +139,8 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); - const [loginList] = useOnyx(ONYXKEYS.LOGINS, { - selector: expensifyLoginsSelector, - }); - const [hasDeviceManagementErrorValue] = useOnyx(ONYXKEYS.LOGINS, { - selector: hasDeviceManagementError, - }); + const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); + const [hasDeviceManagementErrorValue] = useOnyx(ONYXKEYS.LOGINS, {selector: hasDeviceManagementError}); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const allCards = useNonPersonalCardList(); @@ -177,9 +167,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const hasActivatedWallet = ([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM] as string[]).includes(userWallet?.tierName ?? ''); const hasLockedBankAccount = bankAccountList ? Object.values(bankAccountList).some((bankAccount) => bankAccount.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED) : false; const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); - const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, { - selector: isTrackingSelector, - }); + const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {selector: isTrackingSelector}); const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); const [unsharedBankAccount] = useOnyx(ONYXKEYS.UNSHARE_BANK_ACCOUNT); const [stashedCredentials] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS); @@ -201,9 +189,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr personalCard: {shouldShowRBR: shouldShowRBRForPersonalCard}, } = useCardFeedErrors(); const hasPendingCardAction = hasPendingExpensifyCardAction(allCards, privatePersonalDetails); - const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: isActingAsDelegateSelector, - }); + const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector}); const hasVirtualCardMissingDetails = hasVirtualExpensifyCardMissingPersonalDetails(allCards, privatePersonalDetails, isActingAsDelegate); let walletBrickRoadIndicator; if ( @@ -249,81 +235,16 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }); }; - const showSaveReceiptsModal = (pendingReceiptCount: number) => { - return showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsConfirmation.prompt', { - count: pendingReceiptCount, - }), - confirmText: translate('initialSettingsPage.saveReceiptsConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - }); - }; - - // Combined modal for the offline case: warns about losing offline changes and offers to save the pending receipts, so the user is not shown two back-to-back prompts. - const showSaveReceiptsAndSignOutModal = (pendingReceiptCount: number) => { - return showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.prompt', { - count: pendingReceiptCount, - }), - confirmText: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - danger: true, - }); - }; - - // Save must complete before the forced-signout branch dispatches `Onyx.clear`, which wipes the persisted queue that holds these local file paths. - const saveReceipts = async (saveableReceipts: ReturnType) => { - try { - const {savedCount, failedCount} = await saveReceiptsToGallery(saveableReceipts); - Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount}); - } catch (error) { - Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error}); - } - }; - const signOut = async (shouldForceSignout = false) => { - // Forced sign-out (expired session, SAML re-auth) must be non-interactive: it must not touch the gallery flow, which can trigger OS permission prompts and delay the redirect. - if (shouldForceSignout) { + if ((!network.isOffline && !isTrackingGPS) || shouldForceSignout) { return signOutAndRedirectToSignIn(); } - // `getSaveablePendingReceiptRequests` is platform-split (web returns `[]`) and image-filtered so we do not promise a save the native gallery API can not deliver. - const saveableReceipts = getSaveablePendingReceiptRequests(); - const shouldWarnBeforeSignOut = network.isOffline || isTrackingGPS; - // Offline + receipts is the common case; merge the offline warning and the save-receipts prompt into a single modal. GPS keeps its own warning, so it falls through to the two-step path below. - const isOfflineReceiptsCase = network.isOffline && !isTrackingGPS && saveableReceipts.length > 0; - - if (!shouldWarnBeforeSignOut && saveableReceipts.length === 0) { - return signOutAndRedirectToSignIn(); - } - - if (isOfflineReceiptsCase) { - const result = await showSaveReceiptsAndSignOutModal(saveableReceipts.length); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(saveableReceipts); - } else { - if (shouldWarnBeforeSignOut) { - const result = await showSignOutModal(); - if (result.action !== ModalActions.CONFIRM) { - return; - } - } - - if (saveableReceipts.length > 0) { - const result = await showSaveReceiptsModal(saveableReceipts.length); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(saveableReceipts); - } + // When offline, warn the user that any actions they took while offline will be lost if they sign out + const result = await showSignOutModal(); + if (result.action !== ModalActions.CONFIRM) { + return; } - if (isTrackingGPS) { stopGpsTripNotification(); stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); diff --git a/tests/unit/savePendingReceiptsToGalleryTest.ts b/tests/unit/savePendingReceiptsToGalleryTest.ts deleted file mode 100644 index 737359556e55..000000000000 --- a/tests/unit/savePendingReceiptsToGalleryTest.ts +++ /dev/null @@ -1,185 +0,0 @@ -import {WRITE_COMMANDS} from '@libs/API/types'; -import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermission'; -import saveLocalFileToGallery from '@libs/fileDownload/saveLocalFileToGallery'; -import getPendingReceiptRequests, {getSaveablePendingReceiptRequests} from '@libs/savePendingReceiptsToGallery/getPendingReceiptRequests'; -import saveReceiptsToGallery from '@libs/savePendingReceiptsToGallery/saveReceiptsToGallery'; - -import * as PersistedRequests from '@userActions/PersistedRequests'; - -import type {AnyRequest} from '@src/types/onyx/Request'; - -jest.mock('@userActions/PersistedRequests', () => ({ - getAll: jest.fn(() => []), - getOngoingRequest: jest.fn(() => null), - onInitialization: jest.fn(), - onCrossTabRequestsMerged: jest.fn(), -})); - -jest.mock('@libs/fileDownload/hasGalleryWritePermission', () => ({ - __esModule: true, - default: jest.fn(() => Promise.resolve(true)), -})); - -jest.mock('@libs/fileDownload/saveLocalFileToGallery', () => ({ - __esModule: true, - default: jest.fn(() => Promise.resolve()), -})); - -const mockedHasPermission = jest.mocked(hasGalleryWritePermission); -const mockedWrite = jest.mocked(saveLocalFileToGallery); - -const mockedGetAll = jest.mocked(PersistedRequests.getAll); -const mockedGetOngoing = jest.mocked(PersistedRequests.getOngoingRequest); - -function buildRequest(command: string, receipt?: Record): AnyRequest { - return {command, data: receipt ? {receipt} : {}}; -} - -describe('getPendingReceiptRequests', () => { - beforeEach(() => { - mockedGetAll.mockReset(); - mockedGetOngoing.mockReset(); - mockedGetOngoing.mockReturnValue(null); - }); - - it('returns only local-file, receipt-bearing requests and skips remote and non-receipt requests', () => { - mockedGetAll.mockReturnValue([ - buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///receipt-a.jpg', filename: 'a.jpg', type: 'image/jpeg'}), - buildRequest(WRITE_COMMANDS.TRACK_EXPENSE, {localSource: 'file:///receipt-b.jpg', source: 'https://remote.example/b.jpg', filename: 'b.jpg', type: 'image/png'}), - buildRequest(WRITE_COMMANDS.REPLACE_RECEIPT, {source: 'file:///receipt-c.jpg'}), - buildRequest(WRITE_COMMANDS.TRACK_EXPENSE, {source: 'https://remote.example/d.jpg'}), - buildRequest(WRITE_COMMANDS.REQUEST_MONEY), - buildRequest('OpenReport', {source: 'file:///not-a-receipt.jpg'}), - ]); - - const result = getPendingReceiptRequests(); - - expect(result).toEqual([ - {localPath: 'file:///receipt-a.jpg', filename: 'a.jpg', type: 'image/jpeg'}, - {localPath: 'file:///receipt-b.jpg', filename: 'b.jpg', type: 'image/png'}, - {localPath: 'file:///receipt-c.jpg', filename: undefined, type: undefined}, - ]); - }); - - it('includes the in-flight request', () => { - mockedGetOngoing.mockReturnValue(buildRequest(WRITE_COMMANDS.START_SPLIT_BILL, {source: 'file:///ongoing.jpg', filename: 'ongoing.jpg'})); - mockedGetAll.mockReturnValue([]); - - const result = getPendingReceiptRequests(); - - expect(result).toEqual([{localPath: 'file:///ongoing.jpg', filename: 'ongoing.jpg', type: undefined}]); - }); - - it('returns nothing when no receipt requests are pending', () => { - mockedGetAll.mockReturnValue([buildRequest('OpenReport'), buildRequest(WRITE_COMMANDS.TRACK_EXPENSE, {source: 'https://remote.example/x.jpg'})]); - - expect(getPendingReceiptRequests()).toEqual([]); - }); - - it('reads the local path from a REPLACE_RECEIPT File shape that uses `uri` and `name` instead of `source`', () => { - mockedGetAll.mockReturnValue([buildRequest(WRITE_COMMANDS.REPLACE_RECEIPT, {uri: 'file:///replaced.jpg', name: 'replaced.jpg', type: 'image/jpeg'})]); - - expect(getPendingReceiptRequests()).toEqual([{localPath: 'file:///replaced.jpg', filename: 'replaced.jpg', type: 'image/jpeg'}]); - }); - - it('picks up receipts on the other receipt-bearing commands (share, categorize, add-to-policy, send money)', () => { - mockedGetAll.mockReturnValue([ - buildRequest(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, {source: 'file:///share.jpg', filename: 'share.jpg', type: 'image/jpeg'}), - buildRequest(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, {source: 'file:///categorize.jpg', filename: 'categorize.jpg', type: 'image/jpeg'}), - buildRequest(WRITE_COMMANDS.ADD_TRACKED_EXPENSE_TO_POLICY, {source: 'file:///policy.jpg', filename: 'policy.jpg', type: 'image/jpeg'}), - buildRequest(WRITE_COMMANDS.SEND_MONEY_ELSEWHERE, {source: 'file:///send.jpg', filename: 'send.jpg', type: 'image/jpeg'}), - ]); - - expect(getPendingReceiptRequests()).toEqual([ - {localPath: 'file:///share.jpg', filename: 'share.jpg', type: 'image/jpeg'}, - {localPath: 'file:///categorize.jpg', filename: 'categorize.jpg', type: 'image/jpeg'}, - {localPath: 'file:///policy.jpg', filename: 'policy.jpg', type: 'image/jpeg'}, - {localPath: 'file:///send.jpg', filename: 'send.jpg', type: 'image/jpeg'}, - ]); - }); -}); - -describe('getSaveablePendingReceiptRequests', () => { - beforeEach(() => { - mockedGetAll.mockReset(); - mockedGetOngoing.mockReset(); - mockedGetOngoing.mockReturnValue(null); - }); - - it('drops non-image receipts that the native gallery API cannot accept (PDF, DOC, HTML, ZIP)', () => { - mockedGetAll.mockReturnValue([ - buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///image.jpg', filename: 'image.jpg', type: 'image/jpeg'}), - buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///doc.pdf', filename: 'doc.pdf', type: 'application/pdf'}), - buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///page.html', filename: 'page.html', type: 'text/html'}), - buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///bundle.zip', filename: 'bundle.zip', type: 'application/zip'}), - ]); - - expect(getSaveablePendingReceiptRequests()).toEqual([{localPath: 'file:///image.jpg', filename: 'image.jpg', type: 'image/jpeg'}]); - }); - - it('falls back to the filename extension when the mime type is missing', () => { - mockedGetAll.mockReturnValue([ - buildRequest(WRITE_COMMANDS.REPLACE_RECEIPT, {uri: 'file:///IMG_0001.HEIC', name: 'IMG_0001.HEIC'}), - buildRequest(WRITE_COMMANDS.REPLACE_RECEIPT, {uri: 'file:///doc.pdf', name: 'doc.pdf'}), - ]); - - expect(getSaveablePendingReceiptRequests()).toEqual([{localPath: 'file:///IMG_0001.HEIC', filename: 'IMG_0001.HEIC', type: undefined}]); - }); - - it('returns an empty list when the queue holds only non-image receipts (offline-warning modal covers the loss)', () => { - mockedGetAll.mockReturnValue([buildRequest(WRITE_COMMANDS.REQUEST_MONEY, {source: 'file:///only.pdf', filename: 'only.pdf', type: 'application/pdf'})]); - - expect(getSaveablePendingReceiptRequests()).toEqual([]); - }); -}); - -describe('saveReceiptsToGallery', () => { - const receipts = [{localPath: 'file:///a.jpg'}, {localPath: 'file:///b.jpg'}, {localPath: 'file:///c.jpg'}]; - - beforeEach(() => { - mockedHasPermission.mockReset(); - mockedHasPermission.mockResolvedValue(true); - mockedWrite.mockReset(); - mockedWrite.mockResolvedValue(); - }); - - it('writes once per receipt and reports the saved count', async () => { - const result = await saveReceiptsToGallery(receipts); - - expect(mockedWrite).toHaveBeenCalledTimes(3); - expect(mockedWrite.mock.calls.at(0)?.at(0)).toBe('file:///a.jpg'); - expect(result).toEqual({savedCount: 3, failedCount: 0}); - }); - - it('lets the batch survive a single rejection and counts it as failed', async () => { - mockedWrite.mockResolvedValueOnce().mockRejectedValueOnce(new Error('write failed')).mockResolvedValueOnce(); - - const result = await saveReceiptsToGallery(receipts); - - expect(mockedWrite).toHaveBeenCalledTimes(3); - expect(result).toEqual({savedCount: 2, failedCount: 1}); - }); - - it('skips the save and never writes when gallery permission is denied', async () => { - mockedHasPermission.mockResolvedValue(false); - - const result = await saveReceiptsToGallery(receipts); - - expect(mockedWrite).not.toHaveBeenCalled(); - expect(result).toEqual({savedCount: 0, failedCount: 3}); - }); - - it('swallows a thrown permission error rather than propagating it', async () => { - mockedHasPermission.mockRejectedValue(new Error('permission blew up')); - - await expect(saveReceiptsToGallery(receipts)).resolves.toEqual({savedCount: 0, failedCount: 3}); - }); - - it('does no work for an empty list', async () => { - const result = await saveReceiptsToGallery([]); - - expect(mockedHasPermission).not.toHaveBeenCalled(); - expect(mockedWrite).not.toHaveBeenCalled(); - expect(result).toEqual({savedCount: 0, failedCount: 0}); - }); -}); From 62d13baed8779f9c229be4a2752d754968bcdbe0 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Mon, 13 Jul 2026 16:25:04 -0700 Subject: [PATCH 2/2] fix: bucket areAgentsLoaded and arePolicyRoomsLoaded onyx keys --- src/libs/ExportOnyxState/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index e7e07ff1e34b..cf4ad366f553 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -129,6 +129,8 @@ const ONYX_KEY_EXPORT_RULES: Record = { const safeOnyxKeys = new Set([ ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, ONYXKEYS.ACTIVE_CLIENTS, + ONYXKEYS.ARE_AGENTS_LOADED, + ONYXKEYS.ARE_POLICY_ROOMS_LOADED, ONYXKEYS.BETAS, ONYXKEYS.BETA_CONFIGURATION, ONYXKEYS.CACHED_PDF_PATHS,