diff --git a/android/app/build.gradle b/android/app/build.gradle
index 082dc3b..d780c82 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -92,8 +92,8 @@ android {
applicationId 'com.builderpro.opennotes'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 15
- versionName "1.3.2"
+ versionCode 16
+ versionName "1.3.3"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
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.json b/app.json
index 884f762..d26bb2e 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "OpenNotes",
"slug": "open-notes",
- "version": "1.3.2",
+ "version": "1.3.3",
"scheme": "opennotes",
"orientation": "default",
"icon": "./assets/icon.png",
@@ -16,7 +16,7 @@
"ios": {
"bundleIdentifier": "com.builderpro.opennotes",
"icon": "./assets/icon.png",
- "buildNumber": "15",
+ "buildNumber": "16",
"supportsTablet": true,
"usesIcloudStorage": true,
"entitlements": {
@@ -65,7 +65,7 @@
},
"android": {
"package": "com.builderpro.opennotes",
- "versionCode": 15,
+ "versionCode": 16,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#F7F7F4"
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/fastlane/Deliverfile b/fastlane/Deliverfile
index 758f04e..6e37f38 100644
--- a/fastlane/Deliverfile
+++ b/fastlane/Deliverfile
@@ -15,7 +15,7 @@ skip_screenshots true
# draft version if it does not exist yet is required - without it deliver
# loops on "Cannot find edit app store version". Keep this in sync with the
# version being prepared for release.
-app_version "1.3.2"
+app_version "1.3.3"
# Metadata is reviewed locally in git; precheck mostly duplicates that.
precheck_include_in_app_purchases false
diff --git a/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/de-DE/release_notes.txt
index 0850be2..1e8edc6 100644
--- a/fastlane/metadata/de-DE/release_notes.txt
+++ b/fastlane/metadata/de-DE/release_notes.txt
@@ -1 +1 @@
-Text erscheint jetzt an der angetippten Stelle. PDF-Exporte enthalten nun eingefügte Bilder und behalten die Seitenproportionen bei.
+Importierte PDFs verschwinden nach einem App-Update nicht mehr aus Notizen. Abstürze beim Löschen einer Auswahl nach dem Widerrufen und beim PDF-Export langer Notizbücher wurden behoben. Falls OpenNotes unerwartet beendet wird, kannst du jetzt die Fehlerdetails teilen, damit wir das Problem beheben können.
diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt
index 6ee01c2..71a6250 100644
--- a/fastlane/metadata/en-US/release_notes.txt
+++ b/fastlane/metadata/en-US/release_notes.txt
@@ -1 +1 @@
-Fixed text appearing away from where you tap. PDF exports now include inserted images and preserve page proportions.
+Fixed imported PDFs disappearing from notes after an app update. Fixed crashes when deleting a selection after Undo and when exporting long notebooks to PDF. If OpenNotes ever closes unexpectedly, you can now share the error details to help us fix it.
diff --git a/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/es-ES/release_notes.txt
index bea0afd..5ae3282 100644
--- a/fastlane/metadata/es-ES/release_notes.txt
+++ b/fastlane/metadata/es-ES/release_notes.txt
@@ -1 +1 @@
-Corregido el texto que aparecía lejos del punto donde tocabas. Las exportaciones a PDF ahora incluyen las imágenes insertadas y conservan las proporciones de las páginas.
+Los PDF importados ya no desaparecen de las notas tras actualizar la app. Se han corregido los cierres al eliminar una selección después de Deshacer y al exportar cuadernos largos a PDF. Si OpenNotes se cierra inesperadamente, ahora puedes compartir los detalles del error para ayudarnos a solucionarlo.
diff --git a/fastlane/metadata/es-MX/release_notes.txt b/fastlane/metadata/es-MX/release_notes.txt
index bea0afd..f1e871e 100644
--- a/fastlane/metadata/es-MX/release_notes.txt
+++ b/fastlane/metadata/es-MX/release_notes.txt
@@ -1 +1 @@
-Corregido el texto que aparecía lejos del punto donde tocabas. Las exportaciones a PDF ahora incluyen las imágenes insertadas y conservan las proporciones de las páginas.
+Los PDF importados ya no desaparecen de las notas después de actualizar la app. Se corrigieron los cierres al eliminar una selección después de Deshacer y al exportar cuadernos largos a PDF. Si OpenNotes se cierra inesperadamente, ahora puedes compartir los detalles del error para ayudarnos a solucionarlo.
diff --git a/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/fr-FR/release_notes.txt
index ec67c29..f8eaec7 100644
--- a/fastlane/metadata/fr-FR/release_notes.txt
+++ b/fastlane/metadata/fr-FR/release_notes.txt
@@ -1 +1 @@
-Le texte apparaît désormais à l’endroit où vous touchez la page. Les exports PDF incluent les images insérées et conservent les proportions des pages.
+Les PDF importés ne disparaissent plus des notes après une mise à jour de l'app. Correction des plantages lors de la suppression d'une sélection après une annulation et de l'export PDF de longs carnets. Si OpenNotes se ferme de façon inattendue, vous pouvez désormais partager les détails de l'erreur pour nous aider à la corriger.
diff --git a/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/it/release_notes.txt
index ba38798..e94548d 100644
--- a/fastlane/metadata/it/release_notes.txt
+++ b/fastlane/metadata/it/release_notes.txt
@@ -1 +1 @@
-Corretto il testo che appariva lontano dal punto toccato. Le esportazioni PDF ora includono le immagini inserite e mantengono le proporzioni delle pagine.
+I PDF importati non scompaiono più dalle note dopo un aggiornamento dell'app. Corretti i crash durante l'eliminazione di una selezione dopo Annulla e l'esportazione in PDF di quaderni lunghi. Se OpenNotes si chiude inaspettatamente, ora puoi condividere i dettagli dell'errore per aiutarci a risolverlo.
diff --git a/fastlane/metadata/ja/release_notes.txt b/fastlane/metadata/ja/release_notes.txt
index 5c2ecfd..f10a09a 100644
--- a/fastlane/metadata/ja/release_notes.txt
+++ b/fastlane/metadata/ja/release_notes.txt
@@ -1 +1 @@
-タップした位置から離れた場所にテキストが挿入される問題を修正しました。PDF書き出しに挿入画像が含まれるようになり、ページの縦横比も維持されます。
+アプリのアップデート後に、読み込んだ PDF がノートから消える問題を修正しました。取り消し後に選択範囲を削除したときや、長いノートを PDF に書き出したときのクラッシュを修正しました。OpenNotes が予期せず終了した場合、エラーの詳細を共有して問題の解決に協力できるようになりました。
diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt
index 556893d..99b8f23 100644
--- a/fastlane/metadata/ko/release_notes.txt
+++ b/fastlane/metadata/ko/release_notes.txt
@@ -1 +1 @@
-탭한 위치와 다른 곳에 텍스트가 삽입되는 문제를 수정했습니다. 이제 PDF 내보내기에 삽입한 이미지가 포함되며 페이지 비율이 유지됩니다.
+앱 업데이트 후 가져온 PDF가 노트에서 사라지는 문제를 수정했습니다. 실행 취소 후 선택 항목을 삭제할 때와 긴 노트를 PDF로 내보낼 때 발생하던 충돌을 수정했습니다. OpenNotes가 예기치 않게 종료되면 이제 오류 세부 정보를 공유해 문제 해결에 도움을 줄 수 있습니다.
diff --git a/fastlane/metadata/nl-NL/release_notes.txt b/fastlane/metadata/nl-NL/release_notes.txt
index 0be14bd..1af4cbb 100644
--- a/fastlane/metadata/nl-NL/release_notes.txt
+++ b/fastlane/metadata/nl-NL/release_notes.txt
@@ -1 +1 @@
-Tekst verschijnt nu op de plek waar je tikt. PDF-exports bevatten nu ingevoegde afbeeldingen en behouden de paginaverhoudingen.
+Geïmporteerde pdf's verdwijnen na een app-update niet meer uit notities. Crashes bij het verwijderen van een selectie na Ongedaan maken en bij het exporteren van lange notitieboeken naar pdf zijn opgelost. Als OpenNotes onverwacht sluit, kun je nu de foutdetails delen om ons te helpen het op te lossen.
diff --git a/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/pt-BR/release_notes.txt
index f28e0ad..4394237 100644
--- a/fastlane/metadata/pt-BR/release_notes.txt
+++ b/fastlane/metadata/pt-BR/release_notes.txt
@@ -1 +1 @@
-Corrigido o texto que aparecia longe do ponto tocado. As exportações em PDF agora incluem as imagens inseridas e preservam as proporções das páginas.
+PDFs importados não desaparecem mais das notas após uma atualização do app. Corrigimos falhas ao apagar uma seleção depois de Desfazer e ao exportar cadernos longos para PDF. Se o OpenNotes fechar inesperadamente, agora você pode compartilhar os detalhes do erro para nos ajudar a corrigi-lo.
diff --git a/fastlane/metadata/ru/release_notes.txt b/fastlane/metadata/ru/release_notes.txt
index 56a006e..80cabfb 100644
--- a/fastlane/metadata/ru/release_notes.txt
+++ b/fastlane/metadata/ru/release_notes.txt
@@ -1 +1 @@
-Исправлена вставка текста вдали от места касания. При экспорте в PDF теперь сохраняются вставленные изображения и пропорции страниц.
+Импортированные PDF больше не исчезают из заметок после обновления приложения. Исправлены сбои при удалении выделения после отмены и при экспорте длинных тетрадей в PDF. Если OpenNotes неожиданно закроется, теперь можно поделиться сведениями об ошибке, чтобы помочь нам её исправить.
diff --git a/fastlane/metadata/zh-Hans/release_notes.txt b/fastlane/metadata/zh-Hans/release_notes.txt
index 5c964f3..a453f42 100644
--- a/fastlane/metadata/zh-Hans/release_notes.txt
+++ b/fastlane/metadata/zh-Hans/release_notes.txt
@@ -1 +1 @@
-修复了文字出现在远离点击位置的问题。导出的 PDF 现在包含插入的图片,并保持页面比例。
+修复了应用更新后导入的 PDF 从笔记中消失的问题。修复了撤销后删除所选内容以及将长笔记本导出为 PDF 时的崩溃。如果 OpenNotes 意外关闭,现在可以分享错误详情,帮助我们修复问题。
diff --git a/fastlane/metadata/zh-Hant/release_notes.txt b/fastlane/metadata/zh-Hant/release_notes.txt
index 43f1c82..d6689bf 100644
--- a/fastlane/metadata/zh-Hant/release_notes.txt
+++ b/fastlane/metadata/zh-Hant/release_notes.txt
@@ -1 +1 @@
-修正了文字出現在遠離點按位置的問題。匯出的 PDF 現在包含插入的圖片,並保持頁面比例。
+修正了 App 更新後匯入的 PDF 從筆記中消失的問題。修正了復原後刪除所選內容,以及將長筆記本匯出為 PDF 時的當機。如果 OpenNotes 意外關閉,現在可以分享錯誤詳細資料,協助我們修正問題。
diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj
index 9551e1a..7ffe5e5 100644
--- a/ios/OpenNotes.xcodeproj/project.pbxproj
+++ b/ios/OpenNotes.xcodeproj/project.pbxproj
@@ -364,7 +364,7 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -377,7 +377,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.2;
+ MARKETING_VERSION = 1.3.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -403,7 +403,7 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
INFOPLIST_FILE = OpenNotes/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -411,7 +411,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.2;
+ MARKETING_VERSION = 1.3.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist
index de17a0f..c3a1472 100644
--- a/ios/OpenNotes/Info.plist
+++ b/ios/OpenNotes/Info.plist
@@ -34,7 +34,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.3.2
+ 1.3.3
CFBundleSignature
????
CFBundleURLTypes
@@ -54,7 +54,7 @@
CFBundleVersion
- 15
+ 16
ITSAppUsesNonExemptEncryption
LSMinimumSystemVersion
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/ios/Podfile.lock b/ios/Podfile.lock
index 14e4818..03d4228 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -259,7 +259,7 @@ PODS:
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
- hermes-engine/Pre-built (0.81.5)
- - MathNotesMobileInk (0.3.2):
+ - MathNotesMobileInk (0.3.6):
- React-Core
- react-native-skia
- RCTDeprecation (0.81.5)
@@ -2552,7 +2552,7 @@ SPEC CHECKSUMS:
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
- MathNotesMobileInk: 397dd2c1d132a84b594547f911a625cd667f77e9
+ MathNotesMobileInk: affc434b8ea8baee9686500bcc89fa5c40ac861c
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
diff --git a/package-lock.json b/package-lock.json
index 53dc7c5..a46c8b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
- "@mathnotes/mobile-ink": "0.3.4",
+ "@mathnotes/mobile-ink": "0.3.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@shopify/react-native-skia": "2.2.12",
"expo": "54.0.34",
@@ -2565,9 +2565,9 @@
}
},
"node_modules/@mathnotes/mobile-ink": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.4.tgz",
- "integrity": "sha512-3PqHlAekzEwcytOJ/59wJcCDenb9HCkeNANS3sK4/jkamlt7DQrskxmqVKOGZJnhPklaKlBqekjv18K2F8cgpg==",
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.6.tgz",
+ "integrity": "sha512-XrRL8JVEoG1YuyiRCiP+jvVpD6fP/LhAQKDddQc2tTf3ln6Wuz/w3/T437IgrOhrEk6+uWz9IcC164PqtMqHEA==",
"license": "Apache-2.0",
"peerDependencies": {
"@shopify/react-native-skia": ">=2.0.0",
diff --git a/package.json b/package.json
index 0131cdb..883fe2a 100644
--- a/package.json
+++ b/package.json
@@ -19,13 +19,13 @@
"android": "expo run:android",
"test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs",
"test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs",
- "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs",
+ "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs",
"test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
- "@mathnotes/mobile-ink": "0.3.4",
+ "@mathnotes/mobile-ink": "0.3.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@shopify/react-native-skia": "2.2.12",
"expo": "54.0.34",
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, /