From 04a8b85dc72a6917505e1d3e731ba32b6996247c Mon Sep 17 00:00:00 2001 From: markm39 Date: Fri, 2 Oct 2026 16:30:24 -0500 Subject: [PATCH 1/2] fix: stop PDFs vanishing, bound PDF export memory, capture crash details Vanishing PDFs (App Store review, 1.3.2): notes stored absolute file:// URIs that embed the app container path, which iOS changes on updates, reinstalls and iCloud restores. The live canvas then loaded a missing PDF: annotations stayed, the PDF disappeared. The catalog now derives each note's pdfUri from its id (and persists the healed value once), and note bodies re-root inserted-image paths under the current documents folder. PDF export out of memory (Hermes OOM crash group, 1.3.1/1.3.2): every page raster was held as base64 in JS, regex-escaped and concatenated into one HTML string. Export now renders and prints 8 pages per pass and merges the pass PDFs natively (Core Graphics on iOS, PdfRenderer on Android). Base64 data URIs are no longer escaped (they cannot contain HTML-special characters). Crash capture (JS fatal crash group, 1.0-1.3.2; App Store logs drop the JS message): a global JS error hook records the error synchronously before the app terminates, a root ErrorBoundary turns render errors into a recovery screen, and the next launch offers to share the report. Nothing is sent automatically. Strings are localized in all 12 locales. Unknown deep links and Open-In file URLs now open the library instead of expo-router's Unmatched Route page. --- .../builderpro/opennotes/PDFUtilsModule.kt | 81 +++++++++++- app/+native-intent.tsx | 9 ++ app/+not-found.tsx | 6 + app/_layout.tsx | 90 ++++++++++++- ios/OpenNotes/PDFUtilsModule.m | 4 + ios/OpenNotes/PDFUtilsModule.swift | 86 ++++++++++-- scripts/appDocumentUri.test.mjs | 66 ++++++++++ scripts/catalogStore.test.mjs | 25 ++++ scripts/crashReport.test.mjs | 70 ++++++++++ scripts/exportService.test.mjs | 96 +++++++++++++- src/components/CrashReportBanner.tsx | 76 +++++++++++ src/i18n/locales/de.ts | 8 ++ src/i18n/locales/en.ts | 8 ++ src/i18n/locales/es.ts | 8 ++ src/i18n/locales/fr.ts | 8 ++ src/i18n/locales/it.ts | 8 ++ src/i18n/locales/ja.ts | 8 ++ src/i18n/locales/ko.ts | 8 ++ src/i18n/locales/nl.ts | 8 ++ src/i18n/locales/pt.ts | 8 ++ src/i18n/locales/ru.ts | 8 ++ src/i18n/locales/zhHans.ts | 8 ++ src/i18n/locales/zhHant.ts | 8 ++ src/services/catalogStore.ts | 22 +++- src/services/crashCapture.ts | 88 +++++++++++++ src/services/crashReport.ts | 81 ++++++++++++ src/services/exportService.ts | 122 +++++++++++------- src/services/imageInsertStorage.ts | 3 +- src/services/noteBodyStorage.ts | 4 +- src/services/pdfExportHtml.ts | 13 +- src/services/pdfStorage.ts | 9 +- src/services/pdfUtilsModule.ts | 10 ++ src/utils/appDocumentUri.ts | 48 +++++++ 33 files changed, 1026 insertions(+), 79 deletions(-) create mode 100644 app/+native-intent.tsx create mode 100644 app/+not-found.tsx create mode 100644 scripts/appDocumentUri.test.mjs create mode 100644 scripts/crashReport.test.mjs create mode 100644 src/components/CrashReportBanner.tsx create mode 100644 src/services/crashCapture.ts create mode 100644 src/services/crashReport.ts create mode 100644 src/services/pdfUtilsModule.ts create mode 100644 src/utils/appDocumentUri.ts diff --git a/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt index fb90d9e..bd7d834 100644 --- a/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt +++ b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt @@ -1,10 +1,15 @@ package com.builderpro.opennotes +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Rect +import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfRenderer import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Base64 import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -84,6 +89,66 @@ class PDFUtilsModule(private val reactContext: ReactApplicationContext) : } } + /** + * Concatenates PDFs into one new cache file and resolves its file:// URI. + * Android has no vector PDF merge API, so each page is rendered at 2x and + * written into a new document one page at a time to keep memory bounded. + */ + @ReactMethod + fun mergePdfFiles(fileUris: ReadableArray, promise: Promise) { + if (fileUris.size() == 0) { + promise.reject("E_NO_INPUT", "No PDF files to merge") + return + } + val exportDir = File(reactContext.cacheDir, "pdf-export") + val destination = File(exportDir, "${UUID.randomUUID()}.pdf") + val output = PdfDocument() + try { + // Earlier exports have been shared already; don't let them pile up. + exportDir.listFiles()?.forEach { it.delete() } + exportDir.mkdirs() + var outputPageNumber = 1 + for (index in 0 until fileUris.size()) { + val uri = fileUris.getString(index) + ?: throw IOException("Missing PDF file URL at position $index") + val source = localFileFor(uri) ?: throw IOException("Invalid PDF file URL: $uri") + if (!source.exists()) throw IOException("Could not read ${source.name}") + ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + for (pageIndex in 0 until renderer.pageCount) { + renderer.openPage(pageIndex).use { page -> + copyPage(page, output, outputPageNumber) + } + outputPageNumber += 1 + } + } + } + } + FileOutputStream(destination).use { output.writeTo(it) } + promise.resolve("file://${destination.absolutePath}") + } catch (e: Exception) { + destination.delete() + promise.reject("E_MERGE_FAILED", e.message ?: "Could not merge PDF files", e) + } finally { + output.close() + } + } + + private fun copyPage(page: PdfRenderer.Page, output: PdfDocument, pageNumber: Int) { + val scale = 2 + val bitmap = Bitmap.createBitmap(page.width * scale, page.height * scale, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT) + val info = PdfDocument.PageInfo.Builder(page.width, page.height, pageNumber).create() + val outputPage = output.startPage(info) + outputPage.canvas.drawBitmap(bitmap, null, Rect(0, 0, page.width, page.height), null) + output.finishPage(outputPage) + } finally { + bitmap.recycle() + } + } + private fun getPageCountFromFile(file: File): Int? { if (!file.exists()) return null val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) @@ -114,6 +179,13 @@ class PDFUtilsModule(private val reactContext: ReactApplicationContext) : } } + /** A local file for a file:// URI (percent-decoded) or a plain absolute path. */ + private fun localFileFor(uri: String): File? = when { + uri.startsWith("file://") -> File(Uri.parse(uri).path ?: uri.removePrefix("file://")) + uri.startsWith("/") -> File(uri) + else -> null + } + private fun copySourceToTempFile(sourceUrl: String): File { val importDir = File(reactContext.cacheDir, "pdf-import") if (!importDir.exists() && !importDir.mkdirs()) { @@ -128,12 +200,9 @@ class PDFUtilsModule(private val reactContext: ReactApplicationContext) : reactContext.contentResolver.openInputStream(Uri.parse(sourceUrl)) ?: throw IOException("Could not open content URI") } - sourceUrl.startsWith("file://") -> { - val path = Uri.parse(sourceUrl).path ?: sourceUrl.removePrefix("file://") - FileInputStream(File(path)) - } - sourceUrl.startsWith("/") -> FileInputStream(File(sourceUrl)) - else -> throw IOException("Unsupported file path format: $sourceUrl") + else -> FileInputStream( + localFileFor(sourceUrl) ?: throw IOException("Unsupported file path format: $sourceUrl"), + ) } inputStream.use { input -> diff --git a/app/+native-intent.tsx b/app/+native-intent.tsx new file mode 100644 index 0000000..5026ba5 --- /dev/null +++ b/app/+native-intent.tsx @@ -0,0 +1,9 @@ +// "Open In" launches the app with the PDF's file:// (or Android content://) +// URL. That is not a screen route: map it to the library before routing so +// expo-router never shows its "Unmatched Route" page. RootLayout's Open-In +// handler still receives the original URL through expo-linking and imports +// the PDF. +export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { + const lower = path.toLowerCase(); + return lower.startsWith('file:') || lower.startsWith('content:') ? '/' : path; +} diff --git a/app/+not-found.tsx b/app/+not-found.tsx new file mode 100644 index 0000000..6ebd8bb --- /dev/null +++ b/app/+not-found.tsx @@ -0,0 +1,6 @@ +import { Redirect } from 'expo-router'; + +/** Unknown deep links open the library instead of a developer error page. */ +export default function NotFound() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx index ed30268..eae87e7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,13 +1,28 @@ import 'react-native-gesture-handler'; -import React, { useEffect, useRef } from 'react'; -import { Stack, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Stack, useRouter, type ErrorBoundaryProps } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { Alert, useColorScheme, View } from 'react-native'; +import { Alert, Pressable, Share, StyleSheet, Text, useColorScheme, View } from 'react-native'; +import { CrashReportBanner } from '../src/components/CrashReportBanner'; import * as Linking from 'expo-linking'; import { darkColors, lightColors } from '../src/theme/colors'; +import { radius, spacing } from '../src/theme/spacing'; +import { typography } from '../src/theme/typography'; +import { useTheme } from '../src/hooks/useTheme'; import { createPdfNoteFromUri } from '../src/services/pdfImportService'; +import { + installCrashCapture, + recordCrash, + takePendingCrashReport, +} from '../src/services/crashCapture'; +import { formatCrashReport, type CrashReport } from '../src/services/crashReport'; +import { t } from '../src/i18n'; + +installCrashCapture(); +// Read once per launch; reading also removes the stored report. +const reportFromPreviousRun = takePendingCrashReport(); const handledPdfUrls = new Set(); @@ -16,6 +31,12 @@ export default function RootLayout() { const isDark = scheme === 'dark'; const colors = isDark ? darkColors : lightColors; useOpenInPdfImport(); + const [pendingReport, setPendingReport] = useState(reportFromPreviousRun); + const dismissReport = useCallback(() => setPendingReport(null), []); + const shareReport = useCallback(() => { + if (pendingReport) shareCrashReport(pendingReport); + setPendingReport(null); + }, [pendingReport]); return ( @@ -38,12 +59,59 @@ export default function RootLayout() { }} /> + {pendingReport ? ( + + ) : null} ); } +function shareCrashReport(report: CrashReport) { + Share.share({ message: formatCrashReport(report) }).catch((error: unknown) => { + if (__DEV__) console.warn('[RootLayout] sharing error report failed', error); + }); +} + +/** Render errors anywhere in the app land here instead of closing it. */ +export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { + const theme = useTheme(); + useEffect(() => { + recordCrash(error, 'render'); + }, [error]); + const tryAgain = () => { + // Recovered in place: the app did not close, so don't offer the report + // again on next launch. "Share error details" remains available here. + takePendingCrashReport(); + void retry(); + }; + const shareDetails = () => { + const report = takePendingCrashReport(); + if (report) shareCrashReport(report); + }; + return ( + + + {t.crash.screenTitle} + + + {t.crash.screenBody} + + + {t.editor.tryAgain} + + + {t.crash.shareDetails} + + + ); +} + function useOpenInPdfImport() { const router = useRouter(); const importingRef = useRef(false); @@ -90,3 +158,19 @@ function looksLikePdfUrl(url: string): boolean { (lower.endsWith('.pdf') || lower.includes('.pdf/')) ); } + +const styles = StyleSheet.create({ + errorScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl }, + centered: { textAlign: 'center' }, + errorBody: { marginTop: spacing.xs, maxWidth: 320, textAlign: 'center' }, + errorButton: { + marginTop: spacing.lg, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xxl, + borderRadius: radius.md, + minWidth: 220, + alignItems: 'center', + }, + errorButtonLabel: { color: '#FFFFFF' }, + errorLink: { marginTop: spacing.xl, paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, +}); diff --git a/ios/OpenNotes/PDFUtilsModule.m b/ios/OpenNotes/PDFUtilsModule.m index fb63042..857f284 100644 --- a/ios/OpenNotes/PDFUtilsModule.m +++ b/ios/OpenNotes/PDFUtilsModule.m @@ -14,4 +14,8 @@ @interface RCT_EXTERN_MODULE(PDFUtilsModule, NSObject) resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(mergePdfFiles:(NSArray *)fileUris + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end diff --git a/ios/OpenNotes/PDFUtilsModule.swift b/ios/OpenNotes/PDFUtilsModule.swift index f8db7f2..0d6b4dd 100644 --- a/ios/OpenNotes/PDFUtilsModule.swift +++ b/ios/OpenNotes/PDFUtilsModule.swift @@ -8,20 +8,23 @@ class PDFUtilsModule: NSObject { return false } + /// A file URL for a file:// URI or a plain absolute path. + private static func localFileURL(_ path: String) -> URL? { + if path.hasPrefix("file://") { + guard let url = URL(string: path), url.isFileURL else { return nil } + return url + } + return URL(fileURLWithPath: path) + } + @objc func getPageCount(_ filePath: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { DispatchQueue.global(qos: .userInitiated).async { - let url: URL - if filePath.hasPrefix("file://") { - guard let parsedUrl = URL(string: filePath) else { - rejecter("E_INVALID_PATH", "Invalid file URL: \(filePath)", nil) - return - } - url = parsedUrl - } else { - url = URL(fileURLWithPath: filePath) + guard let url = PDFUtilsModule.localFileURL(filePath) else { + rejecter("E_INVALID_PATH", "Invalid file URL: \(filePath)", nil) + return } guard FileManager.default.fileExists(atPath: url.path) else { @@ -120,4 +123,69 @@ class PDFUtilsModule: NSObject { resolver(document.numberOfPages) } } + + /// Concatenates PDFs into one new file in tmp/pdf-export and resolves its + /// file:// URI. Pages are streamed one at a time so memory stays flat no + /// matter how many pages the inputs hold; page content is copied as-is. + @objc + func mergePdfFiles(_ fileUris: [String], + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .userInitiated).async { + guard !fileUris.isEmpty else { + rejecter("E_NO_INPUT", "No PDF files to merge", nil) + return + } + + var sourceUrls: [URL] = [] + for uri in fileUris { + guard let url = PDFUtilsModule.localFileURL(uri) else { + rejecter("E_INVALID_URL", "Invalid PDF file URL: \(uri)", nil) + return + } + sourceUrls.append(url) + } + + let exportDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("pdf-export") + do { + // Earlier exports have been shared already; don't let them pile up. + try? FileManager.default.removeItem(at: exportDir) + try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) + } catch { + rejecter("E_WRITE_FAILED", "Could not create export directory: \(error.localizedDescription)", error) + return + } + let destination = exportDir.appendingPathComponent("\(UUID().uuidString).pdf") + + guard let context = CGContext(destination as CFURL, mediaBox: nil, nil) else { + rejecter("E_WRITE_FAILED", "Could not create merged PDF", nil) + return + } + + var failure: String? + for url in sourceUrls { + guard let document = CGPDFDocument(url as CFURL), document.numberOfPages > 0 else { + failure = "Could not read \(url.lastPathComponent)" + break + } + for pageNumber in 1...document.numberOfPages { + autoreleasepool { + guard let page = document.page(at: pageNumber) else { return } + var mediaBox = page.getBoxRect(.mediaBox) + context.beginPage(mediaBox: &mediaBox) + context.drawPDFPage(page) + context.endPage() + } + } + } + context.closePDF() + + if let failure { + try? FileManager.default.removeItem(at: destination) + rejecter("E_READ_FAILED", failure, nil) + return + } + resolver(destination.absoluteString) + } + } } diff --git a/scripts/appDocumentUri.test.mjs b/scripts/appDocumentUri.test.mjs new file mode 100644 index 0000000..415d13f --- /dev/null +++ b/scripts/appDocumentUri.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { rebaseAppDocumentUri, withCurrentImagePaths } from '../src/utils/appDocumentUri.ts'; + +const IOS_DOCS = 'file:///var/mobile/Containers/Data/Application/NEW-UUID/Documents/'; + +test('re-roots an inserted image saved under a previous iOS container', () => { + assert.equal( + rebaseAppDocumentUri( + 'file:///private/var/mobile/Containers/Data/Application/OLD/Documents/images/note%20x/img_1.jpg', + IOS_DOCS, + ), + `${IOS_DOCS}images/note%20x/img_1.jpg`, + ); +}); + +test('re-roots Android files/ image paths', () => { + assert.equal( + rebaseAppDocumentUri( + 'file:///data/user/0/com.old/files/images/n/a.png', + 'file:///data/user/0/com.builderpro.opennotes/files/', + ), + 'file:///data/user/0/com.builderpro.opennotes/files/images/n/a.png', + ); +}); + +test('leaves current, foreign and non-file URIs untouched', () => { + const current = `${IOS_DOCS}images/n/a.png`; + assert.equal(rebaseAppDocumentUri(current, IOS_DOCS), current); + const tmp = 'file:///var/mobile/Containers/Data/Application/OLD/tmp/ImagePicker/a.jpg'; + assert.equal(rebaseAppDocumentUri(tmp, IOS_DOCS), tmp); + const dataUri = 'data:image/png;base64,AAAA'; + assert.equal(rebaseAppDocumentUri(dataUri, IOS_DOCS), dataUri); + assert.equal(rebaseAppDocumentUri('file:///x/Documents/images/a.png', ''), 'file:///x/Documents/images/a.png'); +}); + +test('a note body with stale image paths is re-rooted, everything else kept', () => { + const stale = 'file:///var/mobile/Containers/Data/Application/OLD/Documents/images/n/a.jpg'; + const data = { + version: '1.0', + pages: [ + { id: 'p1', data: '', insertedElements: [{ id: 'e1', type: 'image', x: 1, y: 2, sourceUri: stale }] }, + { id: 'p2', data: '' }, + ], + }; + + const result = withCurrentImagePaths(data, IOS_DOCS); + + assert.equal(result.pages[0].insertedElements[0].sourceUri, `${IOS_DOCS}images/n/a.jpg`); + assert.equal(result.pages[0].insertedElements[0].x, 1); + assert.equal(result.pages[1], data.pages[1]); +}); + +test('a note body that needs no changes is returned as the same object', () => { + const data = { + version: '1.0', + pages: [{ id: 'p1', data: '', insertedElements: [{ id: 'e1', sourceUri: `${IOS_DOCS}images/n/a.jpg` }] }], + }; + + assert.equal(withCurrentImagePaths(data, IOS_DOCS), data); +}); + +test('a stored path that climbs out of the images folder is never re-rooted', () => { + const crafted = 'file:///var/mobile/Containers/Data/Application/OLD/Documents/images/../../Library/x.db'; + assert.equal(rebaseAppDocumentUri(crafted, IOS_DOCS), crafted); +}); diff --git a/scripts/catalogStore.test.mjs b/scripts/catalogStore.test.mjs index b00b167..aba65d0 100644 --- a/scripts/catalogStore.test.mjs +++ b/scripts/catalogStore.test.mjs @@ -346,3 +346,28 @@ test('empty folders index with surviving folder records still lists folders', as const catalog = await store.getCatalog(); assert.deepEqual(catalog.folders.map((f) => f.id), ['folder-a']); }); + +test('REPRO: a PDF note saved before the app container moved is re-pointed at its PDF and healed on disk', async () => { + // iOS moves the app container on updates and iCloud restores. The stored + // pdfUri still named the old container, so the native PDF background could + // not load it: annotations stayed visible but the PDF vanished. + const stale = 'file:///var/mobile/Containers/Data/Application/OLD-UUID/Documents/pdfs/note-pdf.pdf'; + const catalogFile = JSON.stringify({ + version: 1, + notes: [ + { id: 'note-pdf', title: 'Lecture', folderId: null, createdAt: 'x', updatedAt: 'x', backgroundType: 'pdf', pdfUri: stale, thumbnailUri: null }, + { id: 'note-plain', title: 'Plain', folderId: null, createdAt: 'x', updatedAt: 'x', backgroundType: 'plain', pdfUri: null, thumbnailUri: null }, + ], + folders: [], + deletedNoteIds: {}, + }); + const env = makeEnv({ catalogFile, bodyFiles: ['note-pdf', 'note-plain'] }); + + const catalog = await createCatalogStore(env).getCatalog(); + + const pdfNote = catalog.notes.find((n) => n.id === 'note-pdf'); + assert.equal(pdfNote.pdfUri, 'file:///documents/pdfs/note-pdf.pdf'); + assert.equal(catalog.notes.find((n) => n.id === 'note-plain').pdfUri, null); + const persisted = JSON.parse(env.catalogFile).notes.find((n) => n.id === 'note-pdf'); + assert.equal(persisted.pdfUri, 'file:///documents/pdfs/note-pdf.pdf'); +}); diff --git a/scripts/crashReport.test.mjs b/scripts/crashReport.test.mjs new file mode 100644 index 0000000..4222997 --- /dev/null +++ b/scripts/crashReport.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildCrashReport, + formatCrashReport, + parseCrashReport, +} from '../src/services/crashReport.ts'; + +const env = { + appVersion: '1.3.3 (16)', + platform: 'ios', + osVersion: '26.6', + deviceIdiom: 'pad', + now: new Date('2026-10-02T12:00:00.000Z'), +}; + +test('a thrown TypeError is recorded with its name, message and stack', () => { + const error = new TypeError("Cannot read property 'id' of undefined"); + + const report = buildCrashReport(error, 'global', env); + + assert.equal(report.message, "TypeError: Cannot read property 'id' of undefined"); + assert.match(report.stack, /TypeError/); + assert.equal(report.occurredAt, '2026-10-02T12:00:00.000Z'); + assert.equal(report.appVersion, '1.3.3 (16)'); +}); + +test('a thrown non-Error value is still recorded', () => { + const report = buildCrashReport('plain string', 'render', env); + + assert.equal(report.message, 'string: plain string'); + assert.equal(report.stack, null); + assert.equal(report.source, 'render'); +}); + +test('very long stacks are truncated so the report stays small', () => { + const error = new Error('deep'); + error.stack = 'x'.repeat(10000); + + const report = buildCrashReport(error, 'global', env); + + assert.equal(report.stack.length, 4000); +}); + +test('a stored report round-trips, and corrupt files are ignored', () => { + const report = buildCrashReport(new RangeError('Invalid array length'), 'global', env); + + assert.deepEqual(parseCrashReport(JSON.stringify(report)), report); + assert.equal(parseCrashReport('{not json'), null); + assert.equal(parseCrashReport('{"stack":"no message"}'), null); + assert.equal(parseCrashReport('null'), null); +}); + +test('the shared text names the app version, system and error', () => { + const report = buildCrashReport(new Error('boom'), 'render', env); + + const text = formatCrashReport(report); + + assert.match(text, /^OpenNotes error report/); + assert.match(text, /App: 1\.3\.3 \(16\)/); + assert.match(text, /System: ios 26\.6 \(pad\)/); + assert.match(text, /Where: screen rendering/); + assert.match(text, /Error: boom/); +}); + +test('an error message carrying a large payload is capped in the shared report', () => { + const report = buildCrashReport(new Error('x'.repeat(50000)), 'global', env); + + assert.equal(report.message.length, 1000); +}); diff --git a/scripts/exportService.test.mjs b/scripts/exportService.test.mjs index 265d4fd..fbfab44 100644 --- a/scripts/exportService.test.mjs +++ b/scripts/exportService.test.mjs @@ -24,10 +24,12 @@ function notebook(pages) { }; } -function harness(files = {}) { +function harness(files = {}, { mergePdfFiles } = {}) { const printCalls = []; const batchCalls = []; const fileReads = []; + const deletedFiles = []; + const mergeCalls = []; const exports = {}; const fileSystem = { EncodingType: { Base64: 'base64' }, @@ -36,11 +38,14 @@ function harness(files = {}) { if (!(uri in files)) throw new Error('File not found'); return files[uri]; }, + async deleteAsync(uri) { + deletedFiles.push(uri); + }, }; const print = { async printToFileAsync(options) { printCalls.push(options); - return { uri: 'file:///export.pdf', numberOfPages: 3 }; + return { uri: 'file:///export-' + printCalls.length + '.pdf' }; }, }; const mobileInk = { @@ -59,6 +64,18 @@ function harness(files = {}) { return { isAvailableAsync: async () => false }; } if (name === 'react-native') return { PixelRatio: { get: () => 2 } }; + if (name === './pdfUtilsModule') { + return { + PDFUtilsModule: mergePdfFiles === null + ? {} + : { + mergePdfFiles: async (uris) => { + mergeCalls.push(uris); + return mergePdfFiles ? mergePdfFiles(uris) : 'file:///merged.pdf'; + }, + }, + }; + } if (name === '@mathnotes/mobile-ink') return mobileInk; if (name === './pdfExportHtml') { return { @@ -72,7 +89,14 @@ function harness(files = {}) { throw new Error('Unexpected import: ' + name); }, }); - return { exportNotebookAsPdf: exports.exportNotebookAsPdf, printCalls, batchCalls, fileReads }; + return { + exportNotebookAsPdf: exports.exportNotebookAsPdf, + printCalls, + batchCalls, + fileReads, + deletedFiles, + mergeCalls, + }; } test('a three-page PDF includes a photo and typed text on their original page', async () => { @@ -166,3 +190,69 @@ test('page HTML preserves a uniform scale and escapes typed text', () => { assert.doesNotMatch(html, /