From 6ed65c8f70e573e3a226bc0c3d2da97c895a99e1 Mon Sep 17 00:00:00 2001
From: Adam Horodyski <ad.horodyski@gmail.com>
Date: Tue, 23 Jun 2026 17:21:24 +0200
Subject: [PATCH 1/9] Save unsent receipts to the device gallery on sign-out
 (native)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 src/languages/de.ts                           |   6 +
 src/languages/en.ts                           |   6 +
 src/languages/es.ts                           |   6 +
 src/languages/fr.ts                           |   6 +
 src/languages/it.ts                           |   6 +
 src/languages/ja.ts                           |   5 +
 src/languages/nl.ts                           |   6 +
 src/languages/pl.ts                           |   6 +
 src/languages/pt-BR.ts                        |   6 +
 src/languages/zh-hans.ts                      |   5 +
 src/libs/actions/SignInRedirect.ts            |  70 +++++++---
 .../hasGalleryWritePermission.android.ts      |  24 ++++
 .../hasGalleryWritePermission.ios.ts          |   6 +
 .../fileDownload/hasGalleryWritePermission.ts |   6 +
 src/libs/fileDownload/index.android.ts        |  53 +-------
 src/libs/fileDownload/index.ios.ts            |   4 +-
 .../saveLocalFileToGallery.android.ts         |  18 +++
 .../saveLocalFileToGallery.ios.ts             |   9 ++
 .../fileDownload/saveLocalFileToGallery.ts    |   7 +
 .../getPendingReceiptRequests.ts              |  51 +++++++
 .../savePendingReceiptsToGallery/index.ts     |   4 +
 .../saveReceiptsToGallery.native.ts           |  28 ++++
 .../saveReceiptsToGallery.ts                  |   9 ++
 .../savePendingReceiptsToGallery/types.ts     |  22 +++
 src/pages/settings/InitialSettingsPage.tsx    |  41 +++++-
 tests/unit/SignInRedirectSaveReceiptsTest.ts  |  89 ++++++++++++
 .../unit/savePendingReceiptsToGalleryTest.ts  | 127 ++++++++++++++++++
 27 files changed, 552 insertions(+), 74 deletions(-)
 create mode 100644 src/libs/fileDownload/hasGalleryWritePermission.android.ts
 create mode 100644 src/libs/fileDownload/hasGalleryWritePermission.ios.ts
 create mode 100644 src/libs/fileDownload/hasGalleryWritePermission.ts
 create mode 100644 src/libs/fileDownload/saveLocalFileToGallery.android.ts
 create mode 100644 src/libs/fileDownload/saveLocalFileToGallery.ios.ts
 create mode 100644 src/libs/fileDownload/saveLocalFileToGallery.ts
 create mode 100644 src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
 create mode 100644 src/libs/savePendingReceiptsToGallery/index.ts
 create mode 100644 src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
 create mode 100644 src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.ts
 create mode 100644 src/libs/savePendingReceiptsToGallery/types.ts
 create mode 100644 tests/unit/SignInRedirectSaveReceiptsTest.ts
 create mode 100644 tests/unit/savePendingReceiptsToGalleryTest.ts

diff --git a/src/languages/de.ts b/src/languages/de.ts
index c45021fe94fe..460de488e3b1 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2181,6 +2181,12 @@ 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',
+        },
         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 10d2081542c8..6979f2f8e0f2 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2265,6 +2265,12 @@ 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',
+        },
         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 a1d8c166ea68..f672528786b5 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2065,6 +2065,12 @@ 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',
+        },
         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 b6eded4cb850..b33c8b871770 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2187,6 +2187,12 @@ 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',
+        },
         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 efd84c1b63e8..ec8f9c26ec48 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2179,6 +2179,12 @@ 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',
+        },
         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 c28af32576a7..ebbd80a4bb61 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2161,6 +2161,11 @@ const translations: TranslationDeepObject<typeof en> = {
         signOut: 'サインアウト',
         restoreStashed: '保存済みログインを復元',
         signOutConfirmationText: 'サインアウトすると、オフライン中の変更内容はすべて失われます。',
+        saveReceiptsConfirmation: {
+            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 fad31d4c17f3..68f993f36dfe 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2174,6 +2174,12 @@ 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',
+        },
         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 8f423f10c49d..e06b01c8943f 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2174,6 +2174,12 @@ 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',
+        },
         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 165371fdccc2..248e53f49239 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2167,6 +2167,12 @@ 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',
+        },
         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 f229588b3cc8..f445d14d1e07 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2108,6 +2108,11 @@ const translations: TranslationDeepObject<typeof en> = {
         signOut: '退出登录',
         restoreStashed: '恢复暂存的登录',
         signOutConfirmationText: '如果你退出登录，所有离线更改都会丢失。',
+        saveReceiptsConfirmation: {
+            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/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts
index 74cdb07bf4fc..1f6218123903 100644
--- a/src/libs/actions/SignInRedirect.ts
+++ b/src/libs/actions/SignInRedirect.ts
@@ -3,6 +3,7 @@ import Onyx from 'react-native-onyx';
 import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils';
 import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils';
 import {getIsOffline} from '@libs/NetworkState';
+import type {getPendingReceiptRequests as GetPendingReceiptRequests, saveReceiptsToGallery as SaveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
 import CONFIG from '@src/CONFIG';
 import type {OnyxKey} from '@src/ONYXKEYS';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -43,6 +44,33 @@ Onyx.connectWithoutView({
     },
 });
 
+/**
+ * Copies queued receipts to the gallery before `Onyx.clear()` wipes the queue records. The save module and
+ * logger are required lazily, not imported: they pull in the Network layer, and a static import here would
+ * recreate the circular dependency this file is split from Session to avoid. Any throw is swallowed.
+ */
+function saveUnsentReceiptsBeforeClear(): Promise<void> {
+    try {
+        const {getPendingReceiptRequests, saveReceiptsToGallery} =
+            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- lazy require breaks the circular dependency with the Network layer documented above
+            require('@libs/savePendingReceiptsToGallery') as {getPendingReceiptRequests: typeof GetPendingReceiptRequests; saveReceiptsToGallery: typeof SaveReceiptsToGallery};
+        const pendingReceipts = getPendingReceiptRequests();
+        if (pendingReceipts.length === 0) {
+            return Promise.resolve();
+        }
+
+        return saveReceiptsToGallery(pendingReceipts)
+            .then(({savedCount, failedCount}) => {
+                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- lazy require breaks the circular dependency with the Network layer documented above
+                const {default: log} = require('@libs/Log') as {default: {info: (message: string, includeData?: boolean, parameters?: Record<string, unknown>) => void}};
+                log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount});
+            })
+            .catch(() => undefined);
+    } catch {
+        return Promise.resolve();
+    }
+}
+
 function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise<void> {
     // Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out.
     // We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting
@@ -89,26 +117,28 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?:
         Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
     }
 
-    return Onyx.clear(keysToPreserve).then(() => {
-        if (CONFIG.IS_HYBRID_APP) {
-            resetSignInFlow();
-            HybridAppModule.signOutFromOldDot();
-        }
-
-        // When logging out from imported state, reset shouldForceOffline to false and clear the imported state flag
-        // so the user can log back in
-        if (currentIsUsingImportedState) {
-            Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: false});
-            Onyx.merge(ONYXKEYS.IS_USING_IMPORTED_STATE, false);
-        }
-
-        if (!errorMessage) {
-            return;
-        }
-
-        // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set`
-        Onyx.merge(ONYXKEYS.SESSION, {errors: getMicroSecondOnyxErrorWithMessage(errorMessage)});
-    });
+    return saveUnsentReceiptsBeforeClear().then(() =>
+        Onyx.clear(keysToPreserve).then(() => {
+            if (CONFIG.IS_HYBRID_APP) {
+                resetSignInFlow();
+                HybridAppModule.signOutFromOldDot();
+            }
+
+            // When logging out from imported state, reset shouldForceOffline to false and clear the imported state flag
+            // so the user can log back in
+            if (currentIsUsingImportedState) {
+                Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: false});
+                Onyx.merge(ONYXKEYS.IS_USING_IMPORTED_STATE, false);
+            }
+
+            if (!errorMessage) {
+                return;
+            }
+
+            // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set`
+            Onyx.merge(ONYXKEYS.SESSION, {errors: getMicroSecondOnyxErrorWithMessage(errorMessage)});
+        }),
+    );
 }
 
 /**
diff --git a/src/libs/fileDownload/hasGalleryWritePermission.android.ts b/src/libs/fileDownload/hasGalleryWritePermission.android.ts
new file mode 100644
index 000000000000..c10ec19d97cd
--- /dev/null
+++ b/src/libs/fileDownload/hasGalleryWritePermission.android.ts
@@ -0,0 +1,24 @@
+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['android.permission.READ_EXTERNAL_STORAGE'] === 'granted' && status['android.permission.WRITE_EXTERNAL_STORAGE'] === '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 ddd5e604ea34..e43b4087966a 100644
--- a/src/libs/fileDownload/index.android.ts
+++ b/src/libs/fileDownload/index.android.ts
@@ -1,38 +1,13 @@
-import {PermissionsAndroid, Platform} from 'react-native';
 import type {FetchBlobResponse} from 'react-native-blob-util';
 import RNFetchBlob from 'react-native-blob-util';
 import RNFS from 'react-native-fs';
 import type {LocalizedTranslate} from '@components/LocaleContextProvider';
 import CONST from '@src/CONST';
 import {appendTimeToFileName, getFileName, showGeneralErrorAlert, showPermissionErrorAlert, showSuccessAlert} from './FileUtils';
+import hasGalleryWritePermission from './hasGalleryWritePermission';
+import saveLocalFileToGallery from './saveLocalFileToGallery';
 import type {FileDownload} from './types';
 
-/**
- * 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',
-        );
-    });
-}
-
 /**
  * Handling the download
  */
@@ -75,15 +50,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) {
@@ -121,17 +88,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);
@@ -149,7 +106,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 1f2f568201d7..fdcba3c3cb35 100644
--- a/src/libs/fileDownload/index.ios.ts
+++ b/src/libs/fileDownload/index.ios.ts
@@ -6,6 +6,7 @@ import Share from 'react-native-share';
 import type {LocalizedTranslate} from '@components/LocaleContextProvider';
 import CONST from '@src/CONST';
 import {appendTimeToFileName, getFileName, getFileType, showGeneralErrorAlert, showPermissionErrorAlert, showSuccessAlert} from './FileUtils';
+import saveLocalFileToGallery from './saveLocalFileToGallery';
 import type {FileDownload} from './types';
 
 const isUserCancelled = (err: unknown) => {
@@ -89,7 +90,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..12474b1573db
--- /dev/null
+++ b/src/libs/fileDownload/saveLocalFileToGallery.android.ts
@@ -0,0 +1,18 @@
+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.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
new file mode 100644
index 000000000000..f0d79330f6af
--- /dev/null
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
@@ -0,0 +1,51 @@
+import {WRITE_COMMANDS} from '@libs/API/types';
+import {isLocalFile} from '@libs/fileDownload/FileUtils';
+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,
+]);
+
+function toPendingReceipt(receipt: unknown): PendingReceipt | undefined {
+    if (typeof receipt !== 'object' || receipt === null) {
+        return undefined;
+    }
+
+    const localSource = 'localSource' in receipt && typeof receipt.localSource === 'string' ? receipt.localSource : undefined;
+    const source = 'source' in receipt && typeof receipt.source === 'string' ? receipt.source : undefined;
+    const localPath = localSource ?? source;
+    if (localPath === undefined || !isLocalFile(localPath)) {
+        return undefined;
+    }
+
+    const filename = 'filename' in receipt && typeof receipt.filename === 'string' ? receipt.filename : undefined;
+    const type = 'type' in receipt && typeof receipt.type === 'string' ? receipt.type : undefined;
+    return {localPath, filename, 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;
+    }, []);
+}
+
+export default getPendingReceiptRequests;
+export {RECEIPT_BEARING_COMMANDS};
diff --git a/src/libs/savePendingReceiptsToGallery/index.ts b/src/libs/savePendingReceiptsToGallery/index.ts
new file mode 100644
index 000000000000..452901cb1b4e
--- /dev/null
+++ b/src/libs/savePendingReceiptsToGallery/index.ts
@@ -0,0 +1,4 @@
+import getPendingReceiptRequests from './getPendingReceiptRequests';
+import saveReceiptsToGallery from './saveReceiptsToGallery';
+
+export {getPendingReceiptRequests, saveReceiptsToGallery};
diff --git a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
new file mode 100644
index 000000000000..9f4bfe1696e5
--- /dev/null
+++ b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
@@ -0,0 +1,28 @@
+import {appendTimeToFileName, getFileName} from '@libs/fileDownload/FileUtils';
+import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermission';
+import saveLocalFileToGallery from '@libs/fileDownload/saveLocalFileToGallery';
+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(() => ({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 dd07c321d59c..3fd5827f8166 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -41,9 +41,11 @@ import {resetExitSurveyForm} from '@libs/actions/ExitSurvey';
 import {closeReactNativeApp} from '@libs/actions/HybridApp';
 import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils';
 import {hasPendingExpensifyCardAction} from '@libs/CardUtils';
+import getPlatform from '@libs/getPlatform';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive';
 import Navigation from '@libs/Navigation/Navigation';
+import {getPendingReceiptRequests} from '@libs/savePendingReceiptsToGallery';
 import {useIsAgentAccount} from '@libs/SessionUtils';
 import {getFreeTrialText, hasSubscriptionRedDotError} from '@libs/SubscriptionUtils';
 import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -222,16 +224,45 @@ 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,
+        });
+    };
+
     const signOut = async (shouldForceSignout = false) => {
-        if ((!network.isOffline && !isTrackingGPS) || shouldForceSignout) {
+        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;
+        // Saving pending receipts to the gallery on sign-out is native-only, so only warn about them there.
+        const platform = getPlatform();
+        const isNativePlatform = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID;
+        const pendingReceiptCount = isNativePlatform ? getPendingReceiptRequests().length : 0;
+        const shouldWarnBeforeSignOut = network.isOffline || isTrackingGPS;
+
+        if (!shouldWarnBeforeSignOut && pendingReceiptCount === 0) {
+            return signOutAndRedirectToSignIn();
+        }
+
+        if (shouldWarnBeforeSignOut) {
+            const result = await showSignOutModal();
+            if (result.action !== ModalActions.CONFIRM) {
+                return;
+            }
         }
+
+        if (pendingReceiptCount > 0) {
+            const result = await showSaveReceiptsModal(pendingReceiptCount);
+            if (result.action !== ModalActions.CONFIRM) {
+                return;
+            }
+        }
+
         if (isTrackingGPS) {
             stopGpsTripNotification();
             stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error));
diff --git a/tests/unit/SignInRedirectSaveReceiptsTest.ts b/tests/unit/SignInRedirectSaveReceiptsTest.ts
new file mode 100644
index 000000000000..1a8f500d00ba
--- /dev/null
+++ b/tests/unit/SignInRedirectSaveReceiptsTest.ts
@@ -0,0 +1,89 @@
+import Onyx from 'react-native-onyx';
+import Log from '@libs/Log';
+import {getPendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
+import redirectToSignIn from '@userActions/SignInRedirect';
+import ONYXKEYS from '@src/ONYXKEYS';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+jest.mock('@libs/savePendingReceiptsToGallery', () => ({
+    getPendingReceiptRequests: jest.fn(() => []),
+    saveReceiptsToGallery: jest.fn(() => Promise.resolve({savedCount: 0, failedCount: 0})),
+}));
+
+jest.mock('@libs/Log', () => ({
+    __esModule: true,
+    default: {info: jest.fn(), warn: jest.fn(), alert: jest.fn(), hmmm: jest.fn()},
+}));
+
+const mockedGetPending = jest.mocked(getPendingReceiptRequests);
+const mockedSave = jest.mocked(saveReceiptsToGallery);
+// eslint-disable-next-line @typescript-eslint/unbound-method -- Log is a jest mock, so there is no `this` to bind
+const mockedLogInfo = jest.mocked(Log.info);
+
+const SAMPLE_RECEIPTS = [{localPath: 'file:///pending.jpg'}];
+
+describe('clearStorageAndRedirect saves pending receipts before clearing storage', () => {
+    let order: string[];
+    let clearSpy: jest.SpyInstance;
+
+    beforeAll(() => {
+        Onyx.init({keys: ONYXKEYS});
+    });
+
+    beforeEach(() => {
+        order = [];
+        mockedGetPending.mockReset();
+        mockedSave.mockReset();
+        mockedLogInfo.mockReset();
+
+        mockedGetPending.mockReturnValue(SAMPLE_RECEIPTS);
+        mockedSave.mockImplementation(() =>
+            Promise.resolve().then(() => {
+                order.push('save');
+                return {savedCount: 1, failedCount: 0};
+            }),
+        );
+        clearSpy = jest.spyOn(Onyx, 'clear').mockImplementation(() => {
+            order.push('clear');
+            return Promise.resolve();
+        });
+    });
+
+    afterEach(() => {
+        clearSpy.mockRestore();
+    });
+
+    it('resolves the save before Onyx.clear is invoked', async () => {
+        await redirectToSignIn();
+        await waitForBatchedUpdates();
+
+        expect(mockedSave).toHaveBeenCalledWith(SAMPLE_RECEIPTS);
+        expect(order).toEqual(['save', 'clear']);
+    });
+
+    it('logs the saved and failed counts with the [Receipt] prefix', async () => {
+        await redirectToSignIn();
+        await waitForBatchedUpdates();
+
+        expect(mockedLogInfo).toHaveBeenCalledWith(expect.stringContaining('[Receipt]'), false, {savedCount: 1, failedCount: 0});
+    });
+
+    it('still clears storage when the save rejects', async () => {
+        mockedSave.mockRejectedValue(new Error('save failed'));
+
+        await redirectToSignIn();
+        await waitForBatchedUpdates();
+
+        expect(clearSpy).toHaveBeenCalled();
+    });
+
+    it('does not attempt a save when no receipts are pending, but still clears storage', async () => {
+        mockedGetPending.mockReturnValue([]);
+
+        await redirectToSignIn();
+        await waitForBatchedUpdates();
+
+        expect(mockedSave).not.toHaveBeenCalled();
+        expect(clearSpy).toHaveBeenCalled();
+    });
+});
diff --git a/tests/unit/savePendingReceiptsToGalleryTest.ts b/tests/unit/savePendingReceiptsToGalleryTest.ts
new file mode 100644
index 000000000000..a637762eb3c4
--- /dev/null
+++ b/tests/unit/savePendingReceiptsToGalleryTest.ts
@@ -0,0 +1,127 @@
+import {WRITE_COMMANDS} from '@libs/API/types';
+import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermission';
+import saveLocalFileToGallery from '@libs/fileDownload/saveLocalFileToGallery';
+import getPendingReceiptRequests 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([]);
+    });
+});
+
+describe('saveReceiptsToGallery', () => {
+    const receipts = [{localPath: 'file:///a.jpg'}, {localPath: 'file:///b.jpg'}, {localPath: 'file:///c.jpg'}];
+
+    beforeEach(() => {
+        mockedHasPermission.mockReset();
+        mockedHasPermission.mockResolvedValue(true);
+        mockedWrite.mockReset();
+        mockedWrite.mockResolvedValue();
+    });
+
+    it('writes once per receipt and reports the saved count', async () => {
+        const result = await saveReceiptsToGallery(receipts);
+
+        expect(mockedWrite).toHaveBeenCalledTimes(3);
+        expect(mockedWrite.mock.calls.at(0)?.at(0)).toBe('file:///a.jpg');
+        expect(result).toEqual({savedCount: 3, failedCount: 0});
+    });
+
+    it('lets the batch survive a single rejection and counts it as failed', async () => {
+        mockedWrite.mockResolvedValueOnce().mockRejectedValueOnce(new Error('write failed')).mockResolvedValueOnce();
+
+        const result = await saveReceiptsToGallery(receipts);
+
+        expect(mockedWrite).toHaveBeenCalledTimes(3);
+        expect(result).toEqual({savedCount: 2, failedCount: 1});
+    });
+
+    it('skips the save and never writes when gallery permission is denied', async () => {
+        mockedHasPermission.mockResolvedValue(false);
+
+        const result = await saveReceiptsToGallery(receipts);
+
+        expect(mockedWrite).not.toHaveBeenCalled();
+        expect(result).toEqual({savedCount: 0, failedCount: 3});
+    });
+
+    it('swallows a thrown permission error rather than propagating it', async () => {
+        mockedHasPermission.mockRejectedValue(new Error('permission blew up'));
+
+        await expect(saveReceiptsToGallery(receipts)).resolves.toEqual({savedCount: 0, failedCount: 3});
+    });
+
+    it('does no work for an empty list', async () => {
+        const result = await saveReceiptsToGallery([]);
+
+        expect(mockedHasPermission).not.toHaveBeenCalled();
+        expect(mockedWrite).not.toHaveBeenCalled();
+        expect(result).toEqual({savedCount: 0, failedCount: 0});
+    });
+});

From e0ab8a849e84fad7b3e7110b8fecdf22397d3860 Mon Sep 17 00:00:00 2001
From: Adam Horodyski <ad.horodyski@gmail.com>
Date: Wed, 24 Jun 2026 14:23:29 +0200
Subject: [PATCH 2/9] Cover all receipt-bearing commands and the File `uri`
 shape on sign-out save

getPendingReceiptRequests() missed pending receipts in two ways, both of which
let Onyx.clear() delete a local receipt on sign-out without saving it:

- REPLACE_RECEIPT queues the raw File object, whose local path lives under `uri`
  (and name under `name`), not `source`/`localSource`. It was allow-listed but
  could never match, so every offline receipt-replace was silently dropped.
  Fall back to `uri` for the path and `name` for the filename.
- Other receipt-bearing commands (SEND_MONEY_ELSEWHERE, SEND_MONEY_WITH_WALLET,
  SHARE_TRACKED_EXPENSE, CATEGORIZE_TRACKED_EXPENSE, ADD_TRACKED_EXPENSE_TO_POLICY)
  were absent from the allow-list. Add them.

Also collapse the repeated string-property guards into a single isRecord-based
getString helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../getPendingReceiptRequests.ts              | 19 ++++++++++------
 .../unit/savePendingReceiptsToGalleryTest.ts  | 22 +++++++++++++++++++
 2 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
index f0d79330f6af..1a8d7b9c14b2 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
@@ -1,5 +1,6 @@
 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';
 
@@ -10,23 +11,27 @@ const RECEIPT_BEARING_COMMANDS = new Set<string>([
     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 (typeof receipt !== 'object' || receipt === null) {
+    if (!isRecord(receipt)) {
         return undefined;
     }
 
-    const localSource = 'localSource' in receipt && typeof receipt.localSource === 'string' ? receipt.localSource : undefined;
-    const source = 'source' in receipt && typeof receipt.source === 'string' ? receipt.source : undefined;
-    const localPath = localSource ?? source;
+    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;
     }
 
-    const filename = 'filename' in receipt && typeof receipt.filename === 'string' ? receipt.filename : undefined;
-    const type = 'type' in receipt && typeof receipt.type === 'string' ? receipt.type : undefined;
-    return {localPath, filename, type};
+    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. */
diff --git a/tests/unit/savePendingReceiptsToGalleryTest.ts b/tests/unit/savePendingReceiptsToGalleryTest.ts
index a637762eb3c4..656aa7dc1f98 100644
--- a/tests/unit/savePendingReceiptsToGalleryTest.ts
+++ b/tests/unit/savePendingReceiptsToGalleryTest.ts
@@ -73,6 +73,28 @@ describe('getPendingReceiptRequests', () => {
 
         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('saveReceiptsToGallery', () => {

From 29c33e2cf18f0862ea66b25deffd8d90282cbfe0 Mon Sep 17 00:00:00 2001
From: Adam Horodyski <ad.horodyski@gmail.com>
Date: Wed, 24 Jun 2026 16:43:18 +0200
Subject: [PATCH 3/9] Drop unused RECEIPT_BEARING_COMMANDS export to fix knip

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../savePendingReceiptsToGallery/getPendingReceiptRequests.ts    | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
index 1a8d7b9c14b2..54df6ebcea8b 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
@@ -53,4 +53,3 @@ function getPendingReceiptRequests(): PendingReceipt[] {
 }
 
 export default getPendingReceiptRequests;
-export {RECEIPT_BEARING_COMMANDS};

From 37c834e476aa52ed9885d3f800274055cb756b17 Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Wed, 1 Jul 2026 18:24:26 +0200
Subject: [PATCH 4/9] refactor - address coding-standards review findings

---
 src/libs/actions/SignInRedirect.ts            | 36 +++++-------
 .../getPendingReceiptRequests.native.ts       | 55 +++++++++++++++++++
 .../getPendingReceiptRequests.ts              | 51 +----------------
 .../saveReceiptsToGallery.native.ts           |  6 +-
 src/pages/settings/InitialSettingsPage.tsx    |  7 +--
 5 files changed, 77 insertions(+), 78 deletions(-)
 create mode 100644 src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts

diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts
index 1f6218123903..e78835c3f4dc 100644
--- a/src/libs/actions/SignInRedirect.ts
+++ b/src/libs/actions/SignInRedirect.ts
@@ -1,9 +1,10 @@
 import HybridAppModule from '@expensify/react-native-hybrid-app';
 import Onyx from 'react-native-onyx';
 import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils';
+import Log from '@libs/Log';
 import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils';
 import {getIsOffline} from '@libs/NetworkState';
-import type {getPendingReceiptRequests as GetPendingReceiptRequests, saveReceiptsToGallery as SaveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
+import {getPendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
 import CONFIG from '@src/CONFIG';
 import type {OnyxKey} from '@src/ONYXKEYS';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -44,31 +45,20 @@ Onyx.connectWithoutView({
     },
 });
 
-/**
- * Copies queued receipts to the gallery before `Onyx.clear()` wipes the queue records. The save module and
- * logger are required lazily, not imported: they pull in the Network layer, and a static import here would
- * recreate the circular dependency this file is split from Session to avoid. Any throw is swallowed.
- */
+/** Copies queued receipts to the gallery before `Onyx.clear()` wipes the queue records. The `saveReceiptsToGallery` implementation resolves on every failure path itself, so the outer `.catch` only fires on a contract violation (e.g. future refactor rejects, tests stub a reject) — we log and swallow so sign-out is never blocked. */
 function saveUnsentReceiptsBeforeClear(): Promise<void> {
-    try {
-        const {getPendingReceiptRequests, saveReceiptsToGallery} =
-            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- lazy require breaks the circular dependency with the Network layer documented above
-            require('@libs/savePendingReceiptsToGallery') as {getPendingReceiptRequests: typeof GetPendingReceiptRequests; saveReceiptsToGallery: typeof SaveReceiptsToGallery};
-        const pendingReceipts = getPendingReceiptRequests();
-        if (pendingReceipts.length === 0) {
-            return Promise.resolve();
-        }
-
-        return saveReceiptsToGallery(pendingReceipts)
-            .then(({savedCount, failedCount}) => {
-                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- lazy require breaks the circular dependency with the Network layer documented above
-                const {default: log} = require('@libs/Log') as {default: {info: (message: string, includeData?: boolean, parameters?: Record<string, unknown>) => void}};
-                log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount});
-            })
-            .catch(() => undefined);
-    } catch {
+    const pendingReceipts = getPendingReceiptRequests();
+    if (pendingReceipts.length === 0) {
         return Promise.resolve();
     }
+
+    return saveReceiptsToGallery(pendingReceipts)
+        .then(({savedCount, failedCount}) => {
+            Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount});
+        })
+        .catch((error: unknown) => {
+            Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error});
+        });
 }
 
 function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise<void> {
diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
new file mode 100644
index 000000000000..54df6ebcea8b
--- /dev/null
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
@@ -0,0 +1,55 @@
+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;
+    }, []);
+}
+
+export default getPendingReceiptRequests;
diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
index 54df6ebcea8b..248b657bf7b1 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
@@ -1,55 +1,8 @@
-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. */
+/** Web has no device gallery target, so the caller (sign-out redirect + settings modal gate) sees an empty list and skips the save flow entirely. */
 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;
-    }, []);
+    return [];
 }
 
 export default getPendingReceiptRequests;
diff --git a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
index 9f4bfe1696e5..d6c0abe9afdd 100644
--- a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
+++ b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
@@ -1,6 +1,7 @@
 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. */
@@ -22,7 +23,10 @@ function saveReceiptsToGallery(receipts: PendingReceipt[]): Promise<SaveReceipts
                 return {savedCount, failedCount: results.length - savedCount};
             });
         })
-        .catch(() => ({savedCount: 0, failedCount: receipts.length}));
+        .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/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index 3fd5827f8166..139b8f6cd3ed 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -41,7 +41,6 @@ import {resetExitSurveyForm} from '@libs/actions/ExitSurvey';
 import {closeReactNativeApp} from '@libs/actions/HybridApp';
 import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils';
 import {hasPendingExpensifyCardAction} from '@libs/CardUtils';
-import getPlatform from '@libs/getPlatform';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive';
 import Navigation from '@libs/Navigation/Navigation';
@@ -239,10 +238,8 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
             return signOutAndRedirectToSignIn();
         }
 
-        // Saving pending receipts to the gallery on sign-out is native-only, so only warn about them there.
-        const platform = getPlatform();
-        const isNativePlatform = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID;
-        const pendingReceiptCount = isNativePlatform ? getPendingReceiptRequests().length : 0;
+        // `getPendingReceiptRequests` is platform-split: it returns `[]` on web via its default implementation, so no runtime platform gate is needed here.
+        const pendingReceiptCount = getPendingReceiptRequests().length;
         const shouldWarnBeforeSignOut = network.isOffline || isTrackingGPS;
 
         if (!shouldWarnBeforeSignOut && pendingReceiptCount === 0) {

From 0e1e1cfea0f42166bfde2c39a3119675a6bfbd72 Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Thu, 2 Jul 2026 15:19:41 +0200
Subject: [PATCH 5/9] fix: move the gallery save call out of SignInRedirect

---
 src/libs/actions/SignInRedirect.ts            | 60 +++++--------
 .../getPendingReceiptRequests.native.ts       | 16 ++++
 .../getPendingReceiptRequests.ts              |  8 +-
 .../savePendingReceiptsToGallery/index.ts     |  4 +-
 src/pages/settings/InitialSettingsPage.tsx    | 21 +++--
 tests/unit/SignInRedirectSaveReceiptsTest.ts  | 89 -------------------
 .../unit/savePendingReceiptsToGalleryTest.ts  | 36 +++++++-
 7 files changed, 95 insertions(+), 139 deletions(-)
 delete mode 100644 tests/unit/SignInRedirectSaveReceiptsTest.ts

diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts
index e78835c3f4dc..74cdb07bf4fc 100644
--- a/src/libs/actions/SignInRedirect.ts
+++ b/src/libs/actions/SignInRedirect.ts
@@ -1,10 +1,8 @@
 import HybridAppModule from '@expensify/react-native-hybrid-app';
 import Onyx from 'react-native-onyx';
 import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils';
-import Log from '@libs/Log';
 import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils';
 import {getIsOffline} from '@libs/NetworkState';
-import {getPendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
 import CONFIG from '@src/CONFIG';
 import type {OnyxKey} from '@src/ONYXKEYS';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -45,22 +43,6 @@ Onyx.connectWithoutView({
     },
 });
 
-/** Copies queued receipts to the gallery before `Onyx.clear()` wipes the queue records. The `saveReceiptsToGallery` implementation resolves on every failure path itself, so the outer `.catch` only fires on a contract violation (e.g. future refactor rejects, tests stub a reject) — we log and swallow so sign-out is never blocked. */
-function saveUnsentReceiptsBeforeClear(): Promise<void> {
-    const pendingReceipts = getPendingReceiptRequests();
-    if (pendingReceipts.length === 0) {
-        return Promise.resolve();
-    }
-
-    return saveReceiptsToGallery(pendingReceipts)
-        .then(({savedCount, failedCount}) => {
-            Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount});
-        })
-        .catch((error: unknown) => {
-            Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error});
-        });
-}
-
 function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise<void> {
     // Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out.
     // We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting
@@ -107,28 +89,26 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?:
         Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
     }
 
-    return saveUnsentReceiptsBeforeClear().then(() =>
-        Onyx.clear(keysToPreserve).then(() => {
-            if (CONFIG.IS_HYBRID_APP) {
-                resetSignInFlow();
-                HybridAppModule.signOutFromOldDot();
-            }
-
-            // When logging out from imported state, reset shouldForceOffline to false and clear the imported state flag
-            // so the user can log back in
-            if (currentIsUsingImportedState) {
-                Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: false});
-                Onyx.merge(ONYXKEYS.IS_USING_IMPORTED_STATE, false);
-            }
-
-            if (!errorMessage) {
-                return;
-            }
-
-            // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set`
-            Onyx.merge(ONYXKEYS.SESSION, {errors: getMicroSecondOnyxErrorWithMessage(errorMessage)});
-        }),
-    );
+    return Onyx.clear(keysToPreserve).then(() => {
+        if (CONFIG.IS_HYBRID_APP) {
+            resetSignInFlow();
+            HybridAppModule.signOutFromOldDot();
+        }
+
+        // When logging out from imported state, reset shouldForceOffline to false and clear the imported state flag
+        // so the user can log back in
+        if (currentIsUsingImportedState) {
+            Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: false});
+            Onyx.merge(ONYXKEYS.IS_USING_IMPORTED_STATE, false);
+        }
+
+        if (!errorMessage) {
+            return;
+        }
+
+        // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set`
+        Onyx.merge(ONYXKEYS.SESSION, {errors: getMicroSecondOnyxErrorWithMessage(errorMessage)});
+    });
 }
 
 /**
diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
index 54df6ebcea8b..e634bf669ea4 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
@@ -52,4 +52,20 @@ function getPendingReceiptRequests(): PendingReceipt[] {
     }, []);
 }
 
+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
index 248b657bf7b1..c8fec43b6616 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.ts
@@ -1,8 +1,14 @@
 import type {PendingReceipt} from './types';
 
-/** Web has no device gallery target, so the caller (sign-out redirect + settings modal gate) sees an empty list and skips the save flow entirely. */
+/** 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
index 452901cb1b4e..a0772c66fb72 100644
--- a/src/libs/savePendingReceiptsToGallery/index.ts
+++ b/src/libs/savePendingReceiptsToGallery/index.ts
@@ -1,4 +1,4 @@
-import getPendingReceiptRequests from './getPendingReceiptRequests';
+import getPendingReceiptRequests, {getSaveablePendingReceiptRequests} from './getPendingReceiptRequests';
 import saveReceiptsToGallery from './saveReceiptsToGallery';
 
-export {getPendingReceiptRequests, saveReceiptsToGallery};
+export {getPendingReceiptRequests, getSaveablePendingReceiptRequests, saveReceiptsToGallery};
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index 139b8f6cd3ed..4f202e31a579 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -41,10 +41,11 @@ import {resetExitSurveyForm} from '@libs/actions/ExitSurvey';
 import {closeReactNativeApp} from '@libs/actions/HybridApp';
 import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils';
 import {hasPendingExpensifyCardAction} 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 {getPendingReceiptRequests} from '@libs/savePendingReceiptsToGallery';
+import {getSaveablePendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
 import {useIsAgentAccount} from '@libs/SessionUtils';
 import {getFreeTrialText, hasSubscriptionRedDotError} from '@libs/SubscriptionUtils';
 import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -234,15 +235,16 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
     };
 
     const signOut = async (shouldForceSignout = false) => {
+        // Forced sign-out (expired session, SAML re-auth) must be non-interactive: it must not touch the gallery flow, which can trigger OS permission prompts and delay the redirect.
         if (shouldForceSignout) {
             return signOutAndRedirectToSignIn();
         }
 
-        // `getPendingReceiptRequests` is platform-split: it returns `[]` on web via its default implementation, so no runtime platform gate is needed here.
-        const pendingReceiptCount = getPendingReceiptRequests().length;
+        // `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;
 
-        if (!shouldWarnBeforeSignOut && pendingReceiptCount === 0) {
+        if (!shouldWarnBeforeSignOut && saveableReceipts.length === 0) {
             return signOutAndRedirectToSignIn();
         }
 
@@ -253,11 +255,18 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
             }
         }
 
-        if (pendingReceiptCount > 0) {
-            const result = await showSaveReceiptsModal(pendingReceiptCount);
+        if (saveableReceipts.length > 0) {
+            const result = await showSaveReceiptsModal(saveableReceipts.length);
             if (result.action !== ModalActions.CONFIRM) {
                 return;
             }
+            // Save must complete before the forced-signout branch below dispatches `Onyx.clear`, which wipes the persisted queue that holds these local file paths.
+            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});
+            }
         }
 
         if (isTrackingGPS) {
diff --git a/tests/unit/SignInRedirectSaveReceiptsTest.ts b/tests/unit/SignInRedirectSaveReceiptsTest.ts
deleted file mode 100644
index 1a8f500d00ba..000000000000
--- a/tests/unit/SignInRedirectSaveReceiptsTest.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import Onyx from 'react-native-onyx';
-import Log from '@libs/Log';
-import {getPendingReceiptRequests, saveReceiptsToGallery} from '@libs/savePendingReceiptsToGallery';
-import redirectToSignIn from '@userActions/SignInRedirect';
-import ONYXKEYS from '@src/ONYXKEYS';
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
-
-jest.mock('@libs/savePendingReceiptsToGallery', () => ({
-    getPendingReceiptRequests: jest.fn(() => []),
-    saveReceiptsToGallery: jest.fn(() => Promise.resolve({savedCount: 0, failedCount: 0})),
-}));
-
-jest.mock('@libs/Log', () => ({
-    __esModule: true,
-    default: {info: jest.fn(), warn: jest.fn(), alert: jest.fn(), hmmm: jest.fn()},
-}));
-
-const mockedGetPending = jest.mocked(getPendingReceiptRequests);
-const mockedSave = jest.mocked(saveReceiptsToGallery);
-// eslint-disable-next-line @typescript-eslint/unbound-method -- Log is a jest mock, so there is no `this` to bind
-const mockedLogInfo = jest.mocked(Log.info);
-
-const SAMPLE_RECEIPTS = [{localPath: 'file:///pending.jpg'}];
-
-describe('clearStorageAndRedirect saves pending receipts before clearing storage', () => {
-    let order: string[];
-    let clearSpy: jest.SpyInstance;
-
-    beforeAll(() => {
-        Onyx.init({keys: ONYXKEYS});
-    });
-
-    beforeEach(() => {
-        order = [];
-        mockedGetPending.mockReset();
-        mockedSave.mockReset();
-        mockedLogInfo.mockReset();
-
-        mockedGetPending.mockReturnValue(SAMPLE_RECEIPTS);
-        mockedSave.mockImplementation(() =>
-            Promise.resolve().then(() => {
-                order.push('save');
-                return {savedCount: 1, failedCount: 0};
-            }),
-        );
-        clearSpy = jest.spyOn(Onyx, 'clear').mockImplementation(() => {
-            order.push('clear');
-            return Promise.resolve();
-        });
-    });
-
-    afterEach(() => {
-        clearSpy.mockRestore();
-    });
-
-    it('resolves the save before Onyx.clear is invoked', async () => {
-        await redirectToSignIn();
-        await waitForBatchedUpdates();
-
-        expect(mockedSave).toHaveBeenCalledWith(SAMPLE_RECEIPTS);
-        expect(order).toEqual(['save', 'clear']);
-    });
-
-    it('logs the saved and failed counts with the [Receipt] prefix', async () => {
-        await redirectToSignIn();
-        await waitForBatchedUpdates();
-
-        expect(mockedLogInfo).toHaveBeenCalledWith(expect.stringContaining('[Receipt]'), false, {savedCount: 1, failedCount: 0});
-    });
-
-    it('still clears storage when the save rejects', async () => {
-        mockedSave.mockRejectedValue(new Error('save failed'));
-
-        await redirectToSignIn();
-        await waitForBatchedUpdates();
-
-        expect(clearSpy).toHaveBeenCalled();
-    });
-
-    it('does not attempt a save when no receipts are pending, but still clears storage', async () => {
-        mockedGetPending.mockReturnValue([]);
-
-        await redirectToSignIn();
-        await waitForBatchedUpdates();
-
-        expect(mockedSave).not.toHaveBeenCalled();
-        expect(clearSpy).toHaveBeenCalled();
-    });
-});
diff --git a/tests/unit/savePendingReceiptsToGalleryTest.ts b/tests/unit/savePendingReceiptsToGalleryTest.ts
index 656aa7dc1f98..28eb4c29ab8f 100644
--- a/tests/unit/savePendingReceiptsToGalleryTest.ts
+++ b/tests/unit/savePendingReceiptsToGalleryTest.ts
@@ -1,7 +1,7 @@
 import {WRITE_COMMANDS} from '@libs/API/types';
 import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermission';
 import saveLocalFileToGallery from '@libs/fileDownload/saveLocalFileToGallery';
-import getPendingReceiptRequests from '@libs/savePendingReceiptsToGallery/getPendingReceiptRequests';
+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';
@@ -97,6 +97,40 @@ describe('getPendingReceiptRequests', () => {
     });
 });
 
+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'}];
 

From 0d306125a6662e271d9bad483e4efabf0c4cd968 Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Thu, 2 Jul 2026 15:25:29 +0200
Subject: [PATCH 6/9] fix: drop unused reexport

---
 src/libs/savePendingReceiptsToGallery/index.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/libs/savePendingReceiptsToGallery/index.ts b/src/libs/savePendingReceiptsToGallery/index.ts
index a0772c66fb72..c2af533530ac 100644
--- a/src/libs/savePendingReceiptsToGallery/index.ts
+++ b/src/libs/savePendingReceiptsToGallery/index.ts
@@ -1,4 +1,4 @@
-import getPendingReceiptRequests, {getSaveablePendingReceiptRequests} from './getPendingReceiptRequests';
+import {getSaveablePendingReceiptRequests} from './getPendingReceiptRequests';
 import saveReceiptsToGallery from './saveReceiptsToGallery';
 
-export {getPendingReceiptRequests, getSaveablePendingReceiptRequests, saveReceiptsToGallery};
+export {getSaveablePendingReceiptRequests, saveReceiptsToGallery};

From b76a37db262595143d4fa10d1d90d565efbeeade Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Fri, 3 Jul 2026 12:19:16 +0200
Subject: [PATCH 7/9] chore: fmt changes

---
 src/libs/fileDownload/saveLocalFileToGallery.android.ts         | 1 +
 .../getPendingReceiptRequests.native.ts                         | 2 ++
 .../saveReceiptsToGallery.native.ts                             | 1 +
 tests/unit/savePendingReceiptsToGalleryTest.ts                  | 2 ++
 4 files changed, 6 insertions(+)

diff --git a/src/libs/fileDownload/saveLocalFileToGallery.android.ts b/src/libs/fileDownload/saveLocalFileToGallery.android.ts
index 12474b1573db..b00a443b47f9 100644
--- a/src/libs/fileDownload/saveLocalFileToGallery.android.ts
+++ b/src/libs/fileDownload/saveLocalFileToGallery.android.ts
@@ -1,4 +1,5 @@
 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. */
diff --git a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
index e634bf669ea4..c7c083a7d037 100644
--- a/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
+++ b/src/libs/savePendingReceiptsToGallery/getPendingReceiptRequests.native.ts
@@ -1,7 +1,9 @@
 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>([
diff --git a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
index d6c0abe9afdd..9b9ba925baff 100644
--- a/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
+++ b/src/libs/savePendingReceiptsToGallery/saveReceiptsToGallery.native.ts
@@ -2,6 +2,7 @@ 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. */
diff --git a/tests/unit/savePendingReceiptsToGalleryTest.ts b/tests/unit/savePendingReceiptsToGalleryTest.ts
index 28eb4c29ab8f..2a2cef0bfb92 100644
--- a/tests/unit/savePendingReceiptsToGalleryTest.ts
+++ b/tests/unit/savePendingReceiptsToGalleryTest.ts
@@ -3,7 +3,9 @@ import hasGalleryWritePermission from '@libs/fileDownload/hasGalleryWritePermiss
 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', () => ({

From 5891d81bdaa1220cd64fe5f85968b599cc1d8b38 Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Mon, 6 Jul 2026 17:08:31 +0200
Subject: [PATCH 8/9] fix: merge offline sign-out + save-receipts prompts into
 one modal

---
 src/languages/de.ts                        |  6 +++
 src/languages/en.ts                        |  6 +++
 src/languages/es.ts                        |  6 +++
 src/languages/fr.ts                        |  6 +++
 src/languages/it.ts                        |  6 +++
 src/languages/ja.ts                        |  6 +++
 src/languages/nl.ts                        |  6 +++
 src/languages/pl.ts                        |  6 +++
 src/languages/pt-BR.ts                     |  6 +++
 src/languages/zh-hans.ts                   |  6 +++
 src/pages/settings/InitialSettingsPage.tsx | 56 ++++++++++++++++------
 11 files changed, 102 insertions(+), 14 deletions(-)

diff --git a/src/languages/de.ts b/src/languages/de.ts
index e61c30a5f5f4..6b899e56c5a4 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2207,6 +2207,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `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 ced712587a0c..991a23ccfb39 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2294,6 +2294,12 @@ const translations = {
                 `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 7d2c01e1c4ec..7d94e99817b7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2085,6 +2085,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `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 5e3f6d8f7cce..2fe537321f5b 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2213,6 +2213,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `${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 2f31eaabc43d..f165c1cc43d2 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2205,6 +2205,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `${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 8ad001cef4e5..b2f2e6ee27a9 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2182,6 +2182,12 @@ const translations: TranslationDeepObject<typeof en> = {
             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 b82e59c699b3..a36601320a62 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2200,6 +2200,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `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 0300057010ca..614c399ea0c7 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2196,6 +2196,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `${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 2a846778481d..9c171b978460 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2193,6 +2193,12 @@ const translations: TranslationDeepObject<typeof en> = {
                 `${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 691d698b201c..1e68c0390825 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2129,6 +2129,12 @@ const translations: TranslationDeepObject<typeof en> = {
             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/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index d8b74e248e63..3ed1809d57e2 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -261,6 +261,30 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
         });
     };
 
+    // 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) => {
         // 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) {
@@ -270,29 +294,33 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
         // `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 (shouldWarnBeforeSignOut) {
-            const result = await showSignOutModal();
+        if (isOfflineReceiptsCase) {
+            const result = await showSaveReceiptsAndSignOutModal(saveableReceipts.length);
             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);
+        } else {
+            if (shouldWarnBeforeSignOut) {
+                const result = await showSignOutModal();
+                if (result.action !== ModalActions.CONFIRM) {
+                    return;
+                }
             }
-            // Save must complete before the forced-signout branch below dispatches `Onyx.clear`, which wipes the persisted queue that holds these local file paths.
-            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});
+
+            if (saveableReceipts.length > 0) {
+                const result = await showSaveReceiptsModal(saveableReceipts.length);
+                if (result.action !== ModalActions.CONFIRM) {
+                    return;
+                }
+                await saveReceipts(saveableReceipts);
             }
         }
 

From 3d67a62e16d7a96b23045d5bf9f12da4b368b7ed Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
Date: Tue, 7 Jul 2026 12:48:12 +0200
Subject: [PATCH 9/9] chore: magic string refactor

---
 src/libs/fileDownload/hasGalleryWritePermission.android.ts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/libs/fileDownload/hasGalleryWritePermission.android.ts b/src/libs/fileDownload/hasGalleryWritePermission.android.ts
index c10ec19d97cd..701248dc4464 100644
--- a/src/libs/fileDownload/hasGalleryWritePermission.android.ts
+++ b/src/libs/fileDownload/hasGalleryWritePermission.android.ts
@@ -16,7 +16,9 @@ function hasGalleryWritePermission(): Promise<boolean> {
         }
 
         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',
+            (status) =>
+                status[PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE] === PermissionsAndroid.RESULTS.GRANTED &&
+                status[PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE] === PermissionsAndroid.RESULTS.GRANTED,
         );
     });
 }
