Skip to content
Open
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
18 changes: 13 additions & 5 deletions app/pdf-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
import { Text } from "@/components/ui/text";
import { useRefToLatest } from "@/components/use-ref-to-latest";
import { useAuth } from "@/lib/auth-context";
import { cacheFileDestination } from "@/lib/file-utils";
import { updateMediaLocation } from "@/lib/media-location";
import { File, Paths } from "expo-file-system";
import { File } from "expo-file-system";
import * as Sharing from "expo-sharing";
import * as Sentry from "@sentry/react-native";
import { useQueryClient } from "@tanstack/react-query";
Expand All @@ -21,11 +22,13 @@ import { safeOpenURL } from "@/lib/open-url";
import { Button } from "@/components/ui/button";

const STALE_BLOB_ERROR = "Unable to resolve data for blob:";
const DEFAULT_PDF_FILE_NAME = "document.pdf";

export default function PdfViewerScreen() {
const { uri, title, urlRedirectId, productFileId, purchaseId, initialPage } = useLocalSearchParams<{
const { uri, title, fileName, urlRedirectId, productFileId, purchaseId, initialPage } = useLocalSearchParams<{
uri: string;
title?: string;
fileName?: string;
urlRedirectId?: string;
productFileId?: string;
purchaseId?: string;
Expand All @@ -50,12 +53,17 @@ export default function PdfViewerScreen() {
const [downloadError, setDownloadError] = useState(false);
const [isDownloading, setIsDownloading] = useState(true);

const downloadDestination = useCallback(
() => cacheFileDestination(productFileId ?? "pdf-viewer", fileName ?? DEFAULT_PDF_FILE_NAME),
[productFileId, fileName],
);

const downloadPdf = useCallback(() => {
let cancelled = false;
setDownloadError(false);
setCachedUri(null);
setIsDownloading(true);
File.downloadFileAsync(uri, Paths.cache, { idempotent: true })
File.downloadFileAsync(uri, downloadDestination(), { idempotent: true })
.then((result) => {
if (!cancelled) setCachedUri(result.uri);
})
Expand All @@ -72,7 +80,7 @@ export default function PdfViewerScreen() {
return () => {
cancelled = true;
};
}, [uri]);
}, [uri, downloadDestination]);

useEffect(() => {
cancelDownloadRef.current = downloadPdf();
Expand Down Expand Up @@ -143,7 +151,7 @@ export default function PdfViewerScreen() {
const isAvailable = await Sharing.isAvailableAsync();
if (!isAvailable) return;
const sharedUri =
cachedUri ?? (await File.downloadFileAsync(uri, Paths.cache, { idempotent: true })).uri;
cachedUri ?? (await File.downloadFileAsync(uri, downloadDestination(), { idempotent: true })).uri;
await Sharing.shareAsync(sharedUri);
} finally {
setIsSharing(false);
Expand Down
1 change: 1 addition & 0 deletions app/post/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export default function PostScreen() {
params: {
uri: downloadUrl(purchase.url_redirect_token, fileId),
title: post.name,
fileName: file?.name,
urlRedirectId: post.url_redirect_external_id,
productFileId: fileId,
purchaseId: purchase.purchase_id,
Expand Down
1 change: 1 addition & 0 deletions app/purchase/[token].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export default function DownloadScreen() {
params: {
uri: downloadUrl(token, message.payload.resourceId),
title: purchase?.name,
fileName: fileData?.name,
urlRedirectId: purchase?.url_redirect_external_id,
productFileId: message.payload.resourceId,
purchaseId: purchase?.purchase_id,
Expand Down
2 changes: 1 addition & 1 deletion lib/file-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Directory, File, Paths } from "expo-file-system";

const sanitizeFileName = (name: string) => name.replace(/[/\\:*?"<>|]/g, "_").trim();
const sanitizeFileName = (name: string) => name.replace(/[/\\:*?"<>|%#]/g, "_").trim();

export const cacheFileDestination = (uniqueKey: string, fileName: string) => {
const dir = new Directory(Paths.cache, uniqueKey);
Expand Down
75 changes: 70 additions & 5 deletions tests/app/pdf-viewer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { fireEvent, screen, waitFor } from "@testing-library/react-native";
import { renderWithQueryClient } from "../render-with-query-client";

type MockSearchParams = {
uri: string;
title?: string;
fileName?: string;
productFileId?: string;
};

const defaultSearchParams: MockSearchParams = {
uri: "https://example.com/test.pdf",
title: "Test PDF",
fileName: "HOW TO BECOME AN ELITE PLAYER AND BE BETTER THAN 99%.pdf",
productFileId: "pf1",
};
let mockSearchParams: MockSearchParams = { ...defaultSearchParams };

jest.mock("expo-router", () => ({
useLocalSearchParams: () => ({ uri: "https://example.com/test.pdf", title: "Test PDF" }),
useLocalSearchParams: () => mockSearchParams,
Stack: {
Screen: ({ options }: { options?: { headerRight?: () => React.ReactNode } }) => {
const React = require("react");
Expand All @@ -16,10 +31,29 @@ jest.mock("expo-sharing", () => ({
shareAsync: jest.fn(),
}));

jest.mock("expo-file-system", () => ({
File: { downloadFileAsync: jest.fn().mockResolvedValue({ uri: "file:///cache/test.pdf" }) },
Paths: { cache: "/cache" },
}));
jest.mock("expo-file-system", () => {
const Paths = { cache: "/cache" };
class Directory {
uri: string;
exists = true;
constructor(parent: string | { uri: string }, name: string) {
const base = typeof parent === "string" ? parent : parent.uri;
this.uri = `${base}/${name}`;
}
create() {}
}
class File {
name: string;
uri: string;
static downloadFileAsync = jest.fn().mockResolvedValue({ uri: "file:///cache/test.pdf" });
constructor(parent: string | { uri: string }, name?: string) {
const base = typeof parent === "string" ? parent : parent.uri;
this.name = name ?? "";
this.uri = name === undefined ? base : `${base}/${name}`;
}
}
return { Directory, File, Paths };
});

jest.mock("@/lib/auth-context", () => ({
useAuth: () => ({ accessToken: "test-token" }),
Expand Down Expand Up @@ -77,6 +111,7 @@ describe("PdfViewerScreen", () => {
beforeEach(() => {
const { File } = require("expo-file-system");
const Sharing = require("expo-sharing");
mockSearchParams = { ...defaultSearchParams };
mockOnError = null;
File.downloadFileAsync.mockReset();
File.downloadFileAsync.mockResolvedValue({ uri: "file:///cache/test.pdf" });
Expand Down Expand Up @@ -171,6 +206,36 @@ describe("PdfViewerScreen", () => {
expect(screen.getByTestId("pdf-component")).toBeTruthy();
});

it("downloads to a sanitized cache destination so PDFs with special characters in the name load", async () => {
const { File } = require("expo-file-system");

renderWithProviders();

await waitFor(() => expect(screen.getByTestId("pdf-component")).toBeTruthy());

const destination = File.downloadFileAsync.mock.calls[0][1];
expect(destination.uri).toBe("/cache/pf1/HOW TO BECOME AN ELITE PLAYER AND BE BETTER THAN 99_.pdf");
expect(destination.uri).not.toContain("%");
});

it("downloads to a sanitized fallback cache destination when product file id is missing", async () => {
const { File } = require("expo-file-system");
mockSearchParams = {
uri: "https://example.com/test.pdf",
title: "Test PDF",
fileName: "100% bonus #1.pdf",
};

renderWithProviders();

await waitFor(() => expect(screen.getByTestId("pdf-component")).toBeTruthy());

const destination = File.downloadFileAsync.mock.calls[0][1];
expect(destination.uri).toBe("/cache/pdf-viewer/100_ bonus _1.pdf");
expect(destination.uri).not.toContain("%");
expect(destination.uri).not.toContain("#");
});

it("shares the cached PDF file when available", async () => {
const { File } = require("expo-file-system");
const Sharing = require("expo-sharing");
Expand Down
47 changes: 47 additions & 0 deletions tests/lib/file-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
jest.mock("expo-file-system", () => {
const Paths = { cache: "/cache" };
class Directory {
uri: string;
exists = false;
constructor(parent: string | { uri: string }, name: string) {
const base = typeof parent === "string" ? parent : parent.uri;
this.uri = `${base}/${name}`;
}
create() {
this.exists = true;
}
}
class File {
name: string;
uri: string;
constructor(parent: string | { uri: string }, name: string) {
const base = typeof parent === "string" ? parent : parent.uri;
this.name = name;
this.uri = `${base}/${name}`;
}
}
return { Directory, File, Paths };
});

import { cacheFileDestination } from "@/lib/file-utils";

describe("cacheFileDestination", () => {
it("neutralizes URL-significant characters that break native file URI parsing", () => {
const destination = cacheFileDestination("file-id", "HOW TO BECOME AN ELITE PLAYER 99%#.pdf");

expect(destination.name).toBe("HOW TO BECOME AN ELITE PLAYER 99__.pdf");
expect(destination.name).not.toMatch(/[%#]/);
});

it("neutralizes characters that are illegal in file names", () => {
const destination = cacheFileDestination("file-id", 'a/b\\c:d*e?f"g<h>i|j.pdf');

expect(destination.name).not.toMatch(/[/\\:*?"<>|]/);
});

it("preserves spaces, which are valid in file URIs", () => {
const destination = cacheFileDestination("file-id", "my great file.pdf");

expect(destination.name).toBe("my great file.pdf");
});
});
Loading