Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/__tests__/components/DonateQRModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<DonateQRModal visible={true} onClose={onClose} />);

// 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();
});
});
25 changes: 16 additions & 9 deletions src/components/modals/DonateQRModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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:
Expand All @@ -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);
Expand All @@ -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);
}
}, []);

Expand Down