Skip to content

Fix PDF viewer crash for filenames with '%' or other URL-significant characters#269

Open
sentry[bot] wants to merge 2 commits into
mainfrom
claude/fix-pdf-special-char-filename-crash
Open

Fix PDF viewer crash for filenames with '%' or other URL-significant characters#269
sentry[bot] wants to merge 2 commits into
mainfrom
claude/fix-pdf-special-char-filename-crash

Conversation

@sentry

@sentry sentry Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

What

Opening a PDF whose filename contains a % (e.g. HOW TO BECOME AN ELITE PLAYER AND BE BETTER THAN 99%.pdf) crashed the PDF viewer on iOS with NSInvalidArgumentException. This PR makes the viewer download such PDFs to a sanitized cache path so they load correctly.

Concrete changes:

  • Extend sanitizeFileName in lib/file-utils.ts to also neutralize % and # (characters that are significant in URLs and cannot appear unescaped in a file:// path).
  • Download the PDF (and the share copy) in app/pdf-viewer.tsx to a sanitized cacheFileDestination instead of the raw cache directory — the same pattern already used by the post and purchase download screens.
  • Thread the file name through to the PDF viewer from app/post/[id].tsx and app/purchase/[token].tsx so the cached/shared file keeps a meaningful name (spaces are preserved; they are valid in file URIs).

Why

Root cause (a literal % in the cached filename):

  1. File.downloadFileAsync saved the cached file under a name derived from the original PDF filename, preserving the literal %.
  2. The react-native-pdf JS layer strips file:// and runs decodeURIComponent on the path, then passes it to native.
  3. The iOS native layer (RNPDFPdfView.mm) runs CFURLCreateStringByReplacingPercentEscapes on the path again. A literal % is an invalid percent-escape, so it returns nil, and the subsequent [NSURL fileURLWithPath:nil] throws NSInvalidArgumentException.

Percent-encoding the URI before handing it to react-native-pdf does not work, because the JS layer decodeURIComponents it back before the native layer sees it — reintroducing the literal %. The only reliable fix is to make sure the cached file is never written with a name containing URL-significant characters.

Spaces are intentionally left untouched: they survive the whole decode pipeline and are valid in file:// URIs, so sanitizing them would needlessly degrade shared file names for the (very common) case of PDFs with spaces.

Before/After

⚠️ I was unable to record a simulator video in this environment. A maintainer should capture an iOS (and Android) before/after of opening a PDF whose filename contains % before merging, per the contributing guide.

  • Before: tapping a PDF named ...99%.pdf crashes the app on load.
  • After: the file is cached as ...99_.pdf and opens normally; sharing still works and keeps a readable name.

Test Results

  • New tests/lib/file-utils.test.ts verifies cacheFileDestination neutralizes %/# (and filesystem-illegal chars) while preserving spaces.
  • Updated tests/app/pdf-viewer.test.tsx asserts the download destination is the sanitized cache path (no %). Both tests fail when the fix is reverted.
  • Full suite: 222 passed. tsc --noEmit and expo lint both clean.

🤖 AI disclosure: implemented by Claude (Claude Opus 4.6).

Prompt given to the agent: "Fix the PDF crash for filenames with spaces/special chars (invalid file:// URI → nil path crash in react-native-pdf). Ensure the fix is fully working, add tests, commit, push, and open a PR."


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Low Risk
Localized PDF cache/share path change with existing utility pattern and regression tests; no auth or API changes.

Overview
Fixes an iOS crash when opening PDFs whose original filenames contain % or #, by caching downloads under a sanitized path instead of writing into the raw cache with the unsanitized name.

The PDF viewer now uses cacheFileDestination (same helper as post/purchase downloads) for both the initial download and the share fallback, keyed by productFileId and an optional fileName route param (default document.pdf). sanitizeFileName in file-utils additionally replaces % and # so native file:// handling in react-native-pdf does not hit invalid percent-escapes.

Post and purchase entry points pass through each file’s name as fileName so cached/shared files stay readable while spaces remain allowed.

New/updated tests cover sanitized destinations in the viewer and cacheFileDestination behavior.

Reviewed by Cursor Bugbot for commit 404bc05. Bugbot is set up for automated code reviews on this repo. Configure here.

…characters

PDFs whose filename contains a '%' (e.g. "...BE BETTER THAN 99%.pdf")
crashed the viewer on iOS with NSInvalidArgumentException.

Root cause: File.downloadFileAsync saved the cached file under a name
derived from the original filename, preserving the literal '%'. The
react-native-pdf JS layer strips "file://" and runs decodeURIComponent on
the path, then the iOS native layer runs CFURLCreateStringByReplacingPercentEscapes
on it again. A literal '%' is an invalid percent-escape, so that call
returns nil, and [NSURL fileURLWithPath:nil] throws.

Percent-encoding the URI cannot fix this because the JS layer decodes it
before the native layer sees it. The reliable fix is to ensure the cached
file is never written with a name containing URL-significant characters.

- Extend sanitizeFileName to also neutralize '%' and '#'
- Download the PDF (and the share copy) to a sanitized cacheFileDestination
  instead of the raw cache directory, matching the existing pattern used by
  the post and purchase download screens
- Pass the file name through to the PDF viewer so the cached/shared file
  keeps a meaningful name (spaces are preserved; they are valid in file URIs)

Fixes GUMROAD-MOBILE-WX
@sentry

sentry Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

@claude review once

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

Fixes an iOS crash (NSInvalidArgumentException) when opening PDFs whose filenames contain % or #, caused by the native layer receiving an invalid percent-escape in the file:// path. The fix sanitizes these characters at cache-write time, which is the only reliable point in the pipeline since react-native-pdf decodes the URI before native code sees it.

  • sanitizeFileName in lib/file-utils.ts now strips % and # (spaces intentionally preserved as they are valid in file:// URIs).
  • app/pdf-viewer.tsx routes both the initial download and the share copy through cacheFileDestination using a stable useCallback; a "pdf-viewer" fallback key ensures sanitization applies even when productFileId is absent.
  • app/post/[id].tsx and app/purchase/[token].tsx each pass fileName into the viewer so cached/shared files retain a readable, sanitized name.

Confidence Score: 5/5

Safe to merge — changes are confined to cache path construction and filename sanitization, with no impact on auth, networking, or data persistence.

The fix is surgical: a two-character addition to a regex and a destination swap in the download calls. Both the initial download and the share path go through the same sanitized destination. The fallback key ensures the new path is always used. Regression tests directly assert the absence of % in the download destination and cover the no-productFileId fallback. No pre-existing behavior is altered for filenames that were already clean.

No files require special attention.

Important Files Changed

Filename Overview
lib/file-utils.ts Added % and # to the character class in sanitizeFileName, correctly neutralizing the two URL-significant characters that caused the native crash.
app/pdf-viewer.tsx Replaced raw Paths.cache download destination with cacheFileDestination via a stable useCallback; accepts new fileName param; fallback key "pdf-viewer" when productFileId is absent ensures sanitization is always applied.
app/post/[id].tsx Threads file?.name into PDF viewer navigation params so the sanitized cached file gets a meaningful name.
app/purchase/[token].tsx Same one-liner change as post/[id].tsx: passes fileData?.name as fileName to the PDF viewer.
tests/lib/file-utils.test.ts New unit test file covering cacheFileDestination sanitization of %/#, filesystem-illegal chars, and space preservation; all three cases are well-chosen and meaningful.
tests/app/pdf-viewer.test.tsx Updated mock and added two new tests verifying the download destination is sanitized for both the productFileId-present and fallback paths.

Sequence Diagram

sequenceDiagram
    participant Router as Expo Router (post/purchase)
    participant Viewer as pdf-viewer.tsx
    participant Utils as file-utils.ts
    participant FS as expo-file-system
    participant Native as react-native-pdf (iOS native)

    Router->>Viewer: "navigate({ uri, fileName, productFileId })"
    Viewer->>Utils: cacheFileDestination(productFileId ?? "pdf-viewer", fileName ?? "document.pdf")
    Utils->>Utils: "sanitizeFileName() replaces %#/\\:*?"<>|"
    Utils->>FS: new Directory(Paths.cache, uniqueKey).create()
    Utils-->>Viewer: "File { uri: "/cache/pf1/safe_name.pdf" }"
    Viewer->>FS: "File.downloadFileAsync(remoteUri, sanitizedFile, { idempotent: true })"
    FS-->>Viewer: "{ uri: "file:///cache/pf1/safe_name.pdf" }"
    Viewer->>Native: "source={{ uri: "file:///cache/pf1/safe_name.pdf" }}"
    Note over Native: CFURLCreateStringByReplacingPercentEscapes succeeds — no bare % in path
    Native-->>Viewer: onLoadComplete()
Loading

Reviews (2): Last reviewed commit: "Ensure PDF cache fallback is sanitized" | Re-trigger Greptile

Comment thread app/pdf-viewer.tsx Outdated
@gumclaw gumclaw self-assigned this Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants