diff --git a/src/languages/de.ts b/src/languages/de.ts index a62f082fe36b..f926d256bef9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2203,6 +2203,18 @@ 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 50f48f2d444e..0884f77ee6cf 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2290,6 +2290,18 @@ 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 9b8219bd0b36..1782e5e2f526 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2081,6 +2081,18 @@ 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 194741bd75ce..d94cca6c71b0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2209,6 +2209,18 @@ 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 ced18bde4860..3d7a6dd26b3a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2201,6 +2201,18 @@ 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 00ac27dbd9a7..24cd09cd14b4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2179,6 +2179,17 @@ 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 cd605d22e0d2..9359666f7bcc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2196,6 +2196,18 @@ 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 2127c9808542..9f46de496b2d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2192,6 +2192,18 @@ 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 0b7f5c5bb7c9..00b2f288fa6b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2189,6 +2189,18 @@ 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 24cdbbf2cbeb..c705fd8429de 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2126,6 +2126,17 @@ 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 new file mode 100644 index 000000000000..701248dc4464 --- /dev/null +++ b/src/libs/fileDownload/hasGalleryWritePermission.android.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000000..c84624977bab --- /dev/null +++ b/src/libs/fileDownload/hasGalleryWritePermission.ios.ts @@ -0,0 +1,6 @@ +/** 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 new file mode 100644 index 000000000000..da578484436e --- /dev/null +++ b/src/libs/fileDownload/hasGalleryWritePermission.ts @@ -0,0 +1,6 @@ +/** 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 ee3ad7a66ad4..58a40fa412e5 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -4,39 +4,14 @@ 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'; - -/** - * 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', - ); - }); -} +import hasGalleryWritePermission from './hasGalleryWritePermission'; +import saveLocalFileToGallery from './saveLocalFileToGallery'; /** * Handling the download @@ -80,15 +55,7 @@ function handleDownload(translate: LocalizedTranslate, url: string, fileName?: s attachmentPath = (attachment as FetchBlobResponse).path(); } - return RNFetchBlob.MediaCollection.copyToMediaStore( - { - name: attachmentName, - parentFolder: 'Expensify', - mimeType: null, - }, - 'Download', - attachmentPath ?? '', - ); + return saveLocalFileToGallery(isLocalFile ? url : (attachmentPath ?? ''), attachmentName); }) .then(() => { if (attachmentPath && shouldUnlink) { @@ -126,17 +93,7 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: const downloadPath = `${RNFS.DownloadDirectoryPath}/${finalFileName}`; return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); }) - .then((downloadPath) => - RNFetchBlob.MediaCollection.copyToMediaStore( - { - name: getFileName(downloadPath), - parentFolder: 'Expensify', - mimeType: null, - }, - 'Download', - downloadPath, - ).then(() => downloadPath), - ) + .then((downloadPath) => saveLocalFileToGallery(downloadPath, getFileName(downloadPath)).then(() => downloadPath)) .then((downloadPath) => { RNFetchBlob.fs.unlink(downloadPath); showSuccessAlert(translate); @@ -154,7 +111,7 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: */ const fileDownload: FileDownload = (translate, url, fileName, successMessage, _, formData, requestType, onDownloadFailed, shouldUnlink, appendTimestamp = true) => new Promise((resolve) => { - hasAndroidPermission() + hasGalleryWritePermission() .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 45577c54ba31..77985ca00d2a 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -12,6 +12,7 @@ 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 = ''; @@ -94,7 +95,8 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: * Download the image to photo lib in iOS */ function downloadImage(fileUrl: string) { - return CameraRoll.saveAsset(fileUrl); + // Resolve to a truthy value so the shared success alert below still fires (the raw native result is unused). + return saveLocalFileToGallery(fileUrl).then(() => true); } /** diff --git a/src/libs/fileDownload/saveLocalFileToGallery.android.ts b/src/libs/fileDownload/saveLocalFileToGallery.android.ts new file mode 100644 index 000000000000..b00a443b47f9 --- /dev/null +++ b/src/libs/fileDownload/saveLocalFileToGallery.android.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000000..89e77035a873 --- /dev/null +++ b/src/libs/fileDownload/saveLocalFileToGallery.ios.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000000..db2d38e06474 --- /dev/null +++ b/src/libs/fileDownload/saveLocalFileToGallery.ts @@ -0,0 +1,7 @@ +/** 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 new file mode 100644 index 000000000000..c7c083a7d037 --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts @@ -0,0 +1,73 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; +import {isLocalFile} from '@libs/fileDownload/FileUtils'; +import {isRecord} from '@libs/ObjectUtils'; + +import {getAll, getOngoingRequest} from '@userActions/PersistedRequests'; + +import type {PendingReceipt} from './types'; + +const RECEIPT_BEARING_COMMANDS = new Set([ + WRITE_COMMANDS.REQUEST_MONEY, + WRITE_COMMANDS.TRACK_EXPENSE, + WRITE_COMMANDS.SPLIT_BILL, + WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, + WRITE_COMMANDS.START_SPLIT_BILL, + WRITE_COMMANDS.REPLACE_RECEIPT, + WRITE_COMMANDS.SEND_MONEY_ELSEWHERE, + WRITE_COMMANDS.SEND_MONEY_WITH_WALLET, + WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, + WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, + WRITE_COMMANDS.ADD_TRACKED_EXPENSE_TO_POLICY, +]); + +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 new file mode 100644 index 000000000000..c8fec43b6616 --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000000..c2af533530ac --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000000..9b9ba925baff --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts @@ -0,0 +1,33 @@ +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 new file mode 100644 index 000000000000..ad6d47cd4aa7 --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000000..a442287f8ac6 --- /dev/null +++ b/src/libs/savePendingReceiptsToGallery/types.ts @@ -0,0 +1,22 @@ +/** 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 402ae80506ee..3ed1809d57e2 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -35,9 +35,11 @@ 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'; @@ -110,7 +112,11 @@ type MenuData = WithSentryLabel & { isBadgeCondensed?: boolean; }; -type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]}; +type Menu = { + sectionStyle: StyleProp; + sectionTranslationKey: TranslationPaths; + items: MenuData[]; +}; export type {MenuData}; @@ -139,8 +145,12 @@ 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(); @@ -167,7 +177,9 @@ 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); @@ -189,7 +201,9 @@ 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 ( @@ -235,16 +249,81 @@ 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) => { - if ((!network.isOffline && !isTrackingGPS) || shouldForceSignout) { + // 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) { return signOutAndRedirectToSignIn(); } - // 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; + // `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); + } + } + 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 new file mode 100644 index 000000000000..2a2cef0bfb92 --- /dev/null +++ b/tests/unit/savePendingReceiptsToGalleryTest.ts @@ -0,0 +1,185 @@ +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.SPLIT_BILL, {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.SPLIT_BILL, {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}); + }); +});