diff --git a/src/__tests__/components/DonateQRModal.test.tsx b/src/__tests__/components/DonateQRModal.test.tsx index 8398dc5..f1da190 100644 --- a/src/__tests__/components/DonateQRModal.test.tsx +++ b/src/__tests__/components/DonateQRModal.test.tsx @@ -66,18 +66,30 @@ jest.mock("expo-image", () => ({ Image: "ExpoImage", })); +// Mock expo-asset +jest.mock("expo-asset", () => ({ + Asset: { + fromModule: jest.fn(() => ({ + downloadAsync: jest.fn().mockResolvedValue({ + localUri: "file:///source.jpg", + }), + })), + }, +})); + // Mock expo-file-system jest.mock("expo-file-system", () => ({ File: jest.fn().mockImplementation(() => ({ + uri: "/cache/sloth-donation-qr.jpg", copy: jest.fn().mockResolvedValue(undefined), })), Paths: { cache: "/cache" }, })); -// Mock expo-media-library +// Mock expo-media-library (new class-based API) jest.mock("expo-media-library", () => ({ + Asset: { create: jest.fn().mockResolvedValue({ id: "asset-1" }) }, requestPermissionsAsync: jest.fn().mockResolvedValue({ status: "granted" }), - saveToLibraryAsync: jest.fn().mockResolvedValue(undefined), })); // Mock sonner-native toast @@ -157,4 +169,44 @@ describe("DonateQRModal", () => { const pressables = root.findAllByProps({ onPress: onClose }); expect(pressables.length).toBeGreaterThanOrEqual(1); }); + + it("copies the resolved file:// asset and saves it to the photo library", async () => { + jest.useFakeTimers(); + const onClose = jest.fn(); + const { root } = render(); + + // Find the "Save to Photos" pressable (the one whose onPress is not onClose) + const savePressable = root.findAll( + (node) => + typeof node.props.onPress === "function" && + node.props.onPress !== onClose, + )[0]; + expect(savePressable).toBeDefined(); + + await act(async () => { + await savePressable.props.onPress(); + }); + + // Source must be resolved to a file:// path before copying (regression: + // resolveAssetSource URIs use non-file schemes that File.copy() rejects) + const FileMock = jest.requireMock("expo-file-system").File; + expect(FileMock).toHaveBeenNthCalledWith(1, "file:///source.jpg"); + expect(FileMock).toHaveBeenNthCalledWith( + 2, + "/cache", + "sloth-donation-qr.jpg", + ); + + // Dest file copy then gallery save + const mediaLibrary = jest.requireMock("expo-media-library"); + expect(mediaLibrary.Asset.create).toHaveBeenCalledWith( + "/cache/sloth-donation-qr.jpg", + ); + + // Flush the success-toast reset timer + act(() => { + jest.runAllTimers(); + }); + jest.useRealTimers(); + }); }); diff --git a/src/components/modals/DonateQRModal.tsx b/src/components/modals/DonateQRModal.tsx index 52d2a09..1535c60 100644 --- a/src/components/modals/DonateQRModal.tsx +++ b/src/components/modals/DonateQRModal.tsx @@ -10,11 +10,15 @@ */ import { useCallback, useRef, useState } from "react"; -import { Image as RNImage, Modal, Pressable, Text, View } from "react-native"; +import { Modal, Pressable, Text, View } from "react-native"; import { Lucide } from "@react-native-vector-icons/lucide"; +import { Asset } from "expo-asset"; import { Image } from "expo-image"; import { File, Paths } from "expo-file-system"; -import * as MediaLibrary from "expo-media-library"; +import { + Asset as MediaAsset, + requestPermissionsAsync, +} from "expo-media-library"; import { useColors } from "@/theme/ThemeContext"; import { toast } from "@/hooks/useToast"; import Color from "color"; @@ -37,8 +41,8 @@ export function DonateQRModal({ visible, onClose }: DonateQRModalProps) { const handleSave = useCallback(async () => { try { - // Request photo library permission - const { status } = await MediaLibrary.requestPermissionsAsync(); + // Request photo library permission (write-only is enough to save) + const { status } = await requestPermissionsAsync(true); if (status !== "granted") { toast.error("Permission Required", { description: @@ -47,14 +51,16 @@ export function DonateQRModal({ visible, onClose }: DonateQRModalProps) { return; } - // Resolve the bundled asset URI and copy to a writable location - const assetSource = RNImage.resolveAssetSource(QR_ASSET); - const sourceFile = new File(assetSource.uri); + // Resolve the bundled asset to a real file:// path, then copy to a + // writable location (resolveAssetSource URIs use non-file schemes that + // File.copy() rejects). + const asset = await Asset.fromModule(QR_ASSET).downloadAsync(); + const sourceFile = new File(asset.localUri ?? asset.uri); const destFile = new File(Paths.cache, "sloth-donation-qr.jpg"); await sourceFile.copy(destFile, { overwrite: true }); - // Save to device gallery - await MediaLibrary.saveToLibraryAsync(destFile.uri); + // Save to device gallery via the class-based API + await MediaAsset.create(destFile.uri); // Show success toast setSaved(true); @@ -64,6 +70,7 @@ export function DonateQRModal({ visible, onClose }: DonateQRModalProps) { const message = err instanceof Error ? err.message : "An unexpected error occurred."; toast.error("Save Failed", { description: message }); + console.error("Error saving QR code:", err); } }, []);