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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Nutzungsbedingungen</a> und die <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Datenschutzerklärung</a>.`,
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Terms of Service</a> and <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Privacy</a>.`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Términos de Servicio</a> y <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Privacidad</a>.`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Conditions d'utilisation</a> et la <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Politique de confidentialité</a>.`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Termini di servizio</a> e l’<a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Informativa sulla privacy</a>.`,
         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<typeof en> = {
         signOut: 'サインアウト',
         restoreStashed: '保存済みログインを復元',
         signOutConfirmationText: 'サインアウトすると、オフライン中の変更内容はすべて失われます。',
+        saveReceiptsConfirmation: {
+            title: 'レシートを保存しますか？',
+            prompt: ({count}: {count: number}) => `${count}件のレシートがまだアップロード中です。今サインアウトすると、写真に保存され、後で新しい経費に追加できます。`,
+            confirm: '保存してサインアウト',
+        },
+        saveReceiptsAndSignOutConfirmation: {
+            title: 'レシートを保存しますか？',
+            prompt: ({count}: {count: number}) =>
+                `${count}件のレシートがまだアップロード中です。今サインアウトすると、写真に保存され、後で新しい経費に追加できます。その他のオフライン中の変更内容はすべて失われます。`,
+            confirm: '保存してサインアウト',
+        },
         versionLetter: 'v',
         readTheTermsAndPrivacy: `<a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">利用規約</a>と<a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">プライバシーポリシー</a>をお読みください。`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Servicevoorwaarden</a> en het <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Privacybeleid</a>.`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Warunki korzystania z usługi</a> i <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Politykę prywatności</a>.`,
         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<typeof en> = {
         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 <a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">Termos de Serviço</a> e a <a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">Política de Privacidade</a>.`,
         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<typeof en> = {
         signOut: '退出登录',
         restoreStashed: '恢复暂存的登录',
         signOutConfirmationText: '如果你退出登录，所有离线更改都会丢失。',
+        saveReceiptsConfirmation: {
+            title: '保存你的收据？',
+            prompt: ({count}: {count: number}) => `还有 ${count} 张收据正在上传。如果你现在退出登录，我们会将其保存到你的照片中，以便你稍后将其添加到新的支出中。`,
+            confirm: '保存并退出登录',
+        },
+        saveReceiptsAndSignOutConfirmation: {
+            title: '保存你的收据？',
+            prompt: ({count}: {count: number}) =>
+                `还有 ${count} 张收据正在上传。如果你现在退出登录，我们会将其保存到你的照片中，以便你稍后将其添加到新的支出中。你的所有其他离线更改都会丢失。`,
+            confirm: '保存并退出登录',
+        },
         versionLetter: 'v',
         readTheTermsAndPrivacy: `阅读<a href="${CONST.OLD_DOT_PUBLIC_URLS.TERMS_URL}">服务条款</a>和<a href="${CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL}">隐私政策</a>。`,
         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<boolean> {
+    // 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<boolean> {
+    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<boolean> {
+    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<boolean> {
-    // 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<void> {
+    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<void> {
+    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<void> {
+    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<string>([
+    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<PendingReceipt[]>((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<SaveReceiptsResult> {
+    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<SaveReceiptsResult> {
+    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<ViewStyle>; sectionTranslationKey: TranslationPaths; items: MenuData[]};
+type Menu = {
+    sectionStyle: StyleProp<ViewStyle>;
+    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<typeof getSaveablePendingReceiptRequests>) => {
+        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<string, unknown>): 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});
+    });
+});
