diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index 683ff9aa..71cb797a 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage { add(BulwarkClientCertModule(reactContext)) add(BulwarkDeviceSyncModule(reactContext)) add(BulwarkWidgetsModule(reactContext)) + add(BulwarkPdfModule(reactContext)) // The bridge registers React Native's own module under this name. if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { add(BulwarkHeadlessJsTaskSupportModule(reactContext)) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt new file mode 100644 index 00000000..c2461c1c --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt @@ -0,0 +1,141 @@ +package com.anonymous.bulwarkmobile + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.ParcelFileDescriptor +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.Executors + +/** + * Renders PDF pages to PNG files for the in-app file preview, with the + * platform's own PdfRenderer: Android has no PDF view, and its WebView + * doesn't show PDFs either. The preview draws the pages in a WebView of + * its own so they zoom, asking for each page as it scrolls into view. + * + * PdfRenderer is not thread-safe and allows one open page at a time, so + * every call runs on one background thread, and the open document is kept + * between calls for the file the preview shows. + */ +class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } } + private var openPath: String? = null + private var descriptor: ParcelFileDescriptor? = null + private var renderer: PdfRenderer? = null + + override fun getName(): String = "BulwarkPdf" + + override fun invalidate() { + worker.execute { closeDocument() } + worker.shutdown() + super.invalidate() + } + + /** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */ + @ReactMethod + fun open(fileUri: String, promise: Promise) { + worker.execute { + try { + val doc = document(fileUri) + val pages = Arguments.createArray() + for (i in 0 until doc.pageCount) { + doc.openPage(i).use { page -> + pages.pushMap(Arguments.createMap().apply { + putInt("width", page.width) + putInt("height", page.height) + }) + } + } + promise.resolve(Arguments.createMap().apply { putArray("pages", pages) }) + } catch (e: SecurityException) { + // Password-protected; PdfRenderer can't open those. + closeDocument() + promise.reject("E_PDF_PROTECTED", e.message, e) + } catch (e: Throwable) { + closeDocument() + promise.reject("E_PDF_OPEN", e.message, e) + } + } + } + + /** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */ + @ReactMethod + fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) { + worker.execute { + try { + val doc = document(fileUri) + if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index") + doc.openPage(index).use { page -> + val w = width.coerceIn(1, MAX_PAGE_PX) + val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + try { + // Pages are transparent where nothing is drawn. + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + val out = fileOf(outUri) + out.parentFile?.mkdirs() + FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } + } finally { + bitmap.recycle() + } + } + promise.resolve(outUri) + } catch (e: Throwable) { + promise.reject("E_PDF_RENDER", e.message, e) + } + } + } + + /** Close the document the preview showed. */ + @ReactMethod + fun close(fileUri: String, promise: Promise) { + worker.execute { + if (openPath == fileOf(fileUri).absolutePath) closeDocument() + promise.resolve(null) + } + } + + private fun document(fileUri: String): PdfRenderer { + val path = fileOf(fileUri).absolutePath + renderer?.let { if (openPath == path) return it } + closeDocument() + val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY) + try { + val doc = PdfRenderer(fd) + descriptor = fd + renderer = doc + openPath = path + return doc + } catch (e: Throwable) { + fd.close() + throw e + } + } + + private fun closeDocument() { + try { renderer?.close() } catch (_: Throwable) {} + try { descriptor?.close() } catch (_: Throwable) {} + renderer = null + descriptor = null + openPath = null + } + + private fun fileOf(uri: String): File { + val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri + return File(path ?: throw IllegalArgumentException("not a file: $uri")) + } + + companion object { + // A 4096 px page bitmap is 64 MB; keep well below. + private const val MAX_PAGE_PX = 2480 + } +} diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 549d87d4..31da86be 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -408,7 +408,9 @@ "open_external": "فتح في تطبيق", "pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.", "unsupported": "لا توجد معاينة لهذا النوع من الملفات.", - "media_unsupported": "لا يمكن تشغيل هذا الملف هنا." + "media_unsupported": "لا يمكن تشغيل هذا الملف هنا.", + "pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.", + "pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا." }, "received_via": "إلى {name}", "remove_color": "إزالة الوسم", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 3168ddc8..0572c33e 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -408,7 +408,9 @@ "open_external": "Obre en una aplicació", "pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.", "unsupported": "No hi ha previsualització per a aquest tipus de fitxer.", - "media_unsupported": "Aquest fitxer no es pot reproduir aquí." + "media_unsupported": "Aquest fitxer no es pot reproduir aquí.", + "pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.", + "pdf_unreadable": "Aquest PDF no es pot mostrar aquí." }, "received_via": "per a {name}", "remove_color": "Elimina l'etiqueta", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 06ae3816..4bb3f3d0 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -408,7 +408,9 @@ "open_external": "Otevřít v aplikaci", "pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.", "unsupported": "Pro tento typ souboru není náhled k dispozici.", - "media_unsupported": "Tento soubor zde nelze přehrát." + "media_unsupported": "Tento soubor zde nelze přehrát.", + "pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.", + "pdf_unreadable": "Toto PDF zde nelze zobrazit." }, "received_via": "pro {name}", "remove_color": "Odebrat štítek", diff --git a/locales/rn/da.json b/locales/rn/da.json index 2e4d1b37..078ebb72 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -408,7 +408,9 @@ "open_external": "Åbn i app", "pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.", "unsupported": "Ingen forhåndsvisning for denne filtype.", - "media_unsupported": "Denne fil kan ikke afspilles her." + "media_unsupported": "Denne fil kan ikke afspilles her.", + "pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.", + "pdf_unreadable": "Denne PDF kan ikke vises her." }, "received_via": "til {name}", "remove_color": "Fjern tag", diff --git a/locales/rn/de.json b/locales/rn/de.json index 813cf886..84ca7657 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -414,7 +414,9 @@ "open_external": "In App öffnen", "pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.", "unsupported": "Keine Vorschau für diesen Dateityp.", - "media_unsupported": "Diese Datei kann hier nicht abgespielt werden." + "media_unsupported": "Diese Datei kann hier nicht abgespielt werden.", + "pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.", + "pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden." }, "received_via": "an {name}", "remove_color": "Label entfernen", diff --git a/locales/rn/en.json b/locales/rn/en.json index a02d31a2..45cb669a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -425,7 +425,9 @@ "open_external": "Open in app", "pdf_external": "PDFs open in your PDF viewer on this device.", "unsupported": "No preview for this file type.", - "media_unsupported": "This file can't be played here." + "media_unsupported": "This file can't be played here.", + "pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.", + "pdf_unreadable": "This PDF can't be shown here." }, "received_via": "to {name}", "remove_color": "Remove tag", diff --git a/locales/rn/es.json b/locales/rn/es.json index 86836702..14e0df37 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -408,7 +408,9 @@ "open_external": "Abrir en una app", "pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.", "unsupported": "No hay vista previa para este tipo de archivo.", - "media_unsupported": "Este archivo no se puede reproducir aquí." + "media_unsupported": "Este archivo no se puede reproducir aquí.", + "pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.", + "pdf_unreadable": "Este PDF no se puede mostrar aquí." }, "received_via": "para {name}", "remove_color": "Eliminar etiqueta", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index ded4c31c..393c9e25 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -408,7 +408,9 @@ "open_external": "باز کردن در برنامه", "pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.", "unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.", - "media_unsupported": "این فایل اینجا پخش نمی‌شود." + "media_unsupported": "این فایل اینجا پخش نمی‌شود.", + "pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.", + "pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود." }, "received_via": "به {name}", "remove_color": "حذف برچسب", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index ce9ed8e3..8675882f 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -408,7 +408,9 @@ "open_external": "Ouvrir dans une app", "pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.", "unsupported": "Aucun aperçu pour ce type de fichier.", - "media_unsupported": "Ce fichier ne peut pas être lu ici." + "media_unsupported": "Ce fichier ne peut pas être lu ici.", + "pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.", + "pdf_unreadable": "Ce PDF ne peut pas être affiché ici." }, "received_via": "à {name}", "remove_color": "Retirer l'étiquette", diff --git a/locales/rn/he.json b/locales/rn/he.json index 0e37ee86..b3331776 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -408,7 +408,9 @@ "open_external": "פתח באפליקציה", "pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.", "unsupported": "אין תצוגה מקדימה לסוג קובץ זה.", - "media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן." + "media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.", + "pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.", + "pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן." }, "received_via": "אל {name}", "remove_color": "הסר תג", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 0f7f75c6..1f9b1370 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -408,7 +408,9 @@ "open_external": "Megnyitás alkalmazásban", "pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.", "unsupported": "Ehhez a fájltípushoz nincs előnézet.", - "media_unsupported": "Ez a fájl itt nem játszható le." + "media_unsupported": "Ez a fájl itt nem játszható le.", + "pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.", + "pdf_unreadable": "Ez a PDF itt nem jeleníthető meg." }, "received_via": "címzett: {name}", "remove_color": "Címke eltávolítása", diff --git a/locales/rn/it.json b/locales/rn/it.json index 6479ef03..512a386b 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -408,7 +408,9 @@ "open_external": "Apri in un'app", "pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.", "unsupported": "Nessuna anteprima per questo tipo di file.", - "media_unsupported": "Questo file non può essere riprodotto qui." + "media_unsupported": "Questo file non può essere riprodotto qui.", + "pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.", + "pdf_unreadable": "Questo PDF non può essere visualizzato qui." }, "received_via": "a {name}", "remove_color": "Rimuovi etichetta", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 8828cba6..96a00d49 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -408,7 +408,9 @@ "open_external": "アプリで開く", "pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。", "unsupported": "このファイル形式はプレビューできません。", - "media_unsupported": "このファイルはここでは再生できません。" + "media_unsupported": "このファイルはここでは再生できません。", + "pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。", + "pdf_unreadable": "このPDFはここでは表示できません。" }, "received_via": "{name}宛", "remove_color": "ラベルを削除", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index cff3ef27..d9afa773 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -408,7 +408,9 @@ "open_external": "앱에서 열기", "pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.", "unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.", - "media_unsupported": "이 파일은 여기서 재생할 수 없습니다." + "media_unsupported": "이 파일은 여기서 재생할 수 없습니다.", + "pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.", + "pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다." }, "received_via": "받는 사람: {name}", "remove_color": "태그 제거", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 09342da2..3d413883 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -408,7 +408,9 @@ "open_external": "Atvērt lietotnē", "pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.", "unsupported": "Šim faila tipam nav priekšskatījuma.", - "media_unsupported": "Šo failu šeit nevar atskaņot." + "media_unsupported": "Šo failu šeit nevar atskaņot.", + "pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.", + "pdf_unreadable": "Šo PDF šeit nevar parādīt." }, "received_via": "kam: {name}", "remove_color": "Noņemt tagu", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 6a3f0bfd..6d63704b 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -408,7 +408,9 @@ "open_external": "Апп-д нээх", "pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.", "unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.", - "media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй." + "media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.", + "pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.", + "pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй." }, "received_via": "Хэнд: {name}", "remove_color": "Тагийг хасах", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 0f5cf1dd..a6612ee1 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -408,7 +408,9 @@ "open_external": "Åpne i app", "pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.", "unsupported": "Ingen forhåndsvisning for denne filtypen.", - "media_unsupported": "Denne filen kan ikke spilles av her." + "media_unsupported": "Denne filen kan ikke spilles av her.", + "pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.", + "pdf_unreadable": "Denne PDF-en kan ikke vises her." }, "received_via": "til {name}", "remove_color": "Fjern etiketten", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index c7f279a1..e5006356 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -408,7 +408,9 @@ "open_external": "Openen in app", "pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.", "unsupported": "Geen voorbeeld voor dit bestandstype.", - "media_unsupported": "Dit bestand kan hier niet worden afgespeeld." + "media_unsupported": "Dit bestand kan hier niet worden afgespeeld.", + "pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.", + "pdf_unreadable": "Deze pdf kan hier niet worden weergegeven." }, "received_via": "aan {name}", "remove_color": "Label verwijderen", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 43613007..249f3294 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -408,7 +408,9 @@ "open_external": "Otwórz w aplikacji", "pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.", "unsupported": "Brak podglądu dla tego typu pliku.", - "media_unsupported": "Tego pliku nie można tutaj odtworzyć." + "media_unsupported": "Tego pliku nie można tutaj odtworzyć.", + "pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.", + "pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić." }, "received_via": "do {name}", "remove_color": "Usuń etykietę", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 06e4ff49..17be6f02 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -408,7 +408,9 @@ "open_external": "Abrir em outro app", "pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.", "unsupported": "Sem pré-visualização para este tipo de arquivo.", - "media_unsupported": "Este ficheiro não pode ser reproduzido aqui." + "media_unsupported": "Este ficheiro não pode ser reproduzido aqui.", + "pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.", + "pdf_unreadable": "Este PDF não pode ser mostrado aqui." }, "received_via": "para {name}", "remove_color": "Remover etiqueta", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 782dc7ed..a86f5a0d 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -408,7 +408,9 @@ "open_external": "Deschideți într-o aplicație", "pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.", "unsupported": "Nu există previzualizare pentru acest tip de fișier.", - "media_unsupported": "Acest fișier nu poate fi redat aici." + "media_unsupported": "Acest fișier nu poate fi redat aici.", + "pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.", + "pdf_unreadable": "Acest PDF nu poate fi afișat aici." }, "received_via": "către {name}", "remove_color": "Eliminați eticheta", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 483fe7f2..f12ec606 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -408,7 +408,9 @@ "open_external": "Открыть в приложении", "pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.", "unsupported": "Для этого типа файлов нет предпросмотра.", - "media_unsupported": "Этот файл нельзя воспроизвести здесь." + "media_unsupported": "Этот файл нельзя воспроизвести здесь.", + "pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.", + "pdf_unreadable": "Этот PDF нельзя показать здесь." }, "received_via": "на {name}", "remove_color": "Убрать тег", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index a561cf0d..26494ae8 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -408,7 +408,9 @@ "open_external": "Otvoriť v aplikácii", "pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.", "unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.", - "media_unsupported": "Tento súbor tu nie je možné prehrať." + "media_unsupported": "Tento súbor tu nie je možné prehrať.", + "pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.", + "pdf_unreadable": "Toto PDF tu nie je možné zobraziť." }, "received_via": "pre {name}", "remove_color": "Odstrániť štítok", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index af1e539e..be06fdab 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -408,7 +408,9 @@ "open_external": "Uygulamada aç", "pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.", "unsupported": "Bu dosya türü için önizleme yok.", - "media_unsupported": "Bu dosya burada oynatılamıyor." + "media_unsupported": "Bu dosya burada oynatılamıyor.", + "pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.", + "pdf_unreadable": "Bu PDF burada gösterilemiyor." }, "received_via": "alıcı: {name}", "remove_color": "Etiketi kaldır", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 666e4504..1be0e304 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -408,7 +408,9 @@ "open_external": "Відкрити в застосунку", "pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.", "unsupported": "Для цього типу файлів немає попереднього перегляду.", - "media_unsupported": "Цей файл не можна відтворити тут." + "media_unsupported": "Цей файл не можна відтворити тут.", + "pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.", + "pdf_unreadable": "Цей PDF не можна показати тут." }, "received_via": "на {name}", "remove_color": "Прибрати тег", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index c577ec2a..14e41e90 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -408,7 +408,9 @@ "open_external": "在應用程式中開啟", "pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。", "unsupported": "無法預覽此檔案類型。", - "media_unsupported": "無法在此播放這個檔案。" + "media_unsupported": "無法在此播放這個檔案。", + "pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。", + "pdf_unreadable": "無法在此顯示這個 PDF。" }, "received_via": "寄給 {name}", "remove_color": "移除標籤", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 0a27a7fc..d0ab9384 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -408,7 +408,9 @@ "open_external": "在应用中打开", "pdf_external": "PDF 将在此设备的 PDF 查看器中打开。", "unsupported": "无法预览此类文件。", - "media_unsupported": "无法在此播放该文件。" + "media_unsupported": "无法在此播放该文件。", + "pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。", + "pdf_unreadable": "无法在此显示该 PDF。" }, "received_via": "发给 {name}", "remove_color": "删除标签", diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 0b94b90e..da81ec10 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { - View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, useWindowDimensions, + View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, + useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { WebView } from 'react-native-webview'; +import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { X, ExternalLink, Share2, Download } from '../icons'; import type { Email } from '../../api/types'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; @@ -11,6 +12,7 @@ import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import EmailBodyView from '../EmailBodyView'; import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview'; +import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages'; import type { EmlPreview } from './use-body-override'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -20,7 +22,7 @@ export interface PreviewItem { mimeType: string; /** Local file:// URI of the downloaded copy (images, PDFs, media). */ fileUri?: string; - /** The viewer page next to the file (images, video, audio); see preview-viewer-html. */ + /** The viewer page next to the file (images, video, audio, Android's PDFs); see preview-viewer-html. */ viewerUri?: string; /** Decoded text for text-like parts. */ text?: string; @@ -47,14 +49,28 @@ interface Props { docked?: boolean; } +interface ViewerWebViewProps { + uri: string; + background: string; + onFailed: () => void; + /** Messages other than 'error'. */ + onMessage?: (data: string) => void; + webRef?: React.Ref; +} + /** - * A local page in a WebView that may read only its own folder: the image - * and media viewers. It posts 'error' when the file doesn't decode. + * A local page in a WebView that may read only its own folder: the image, + * media and PDF viewers. It posts 'error' when the file doesn't decode. */ -function ViewerWebView({ uri, background, onFailed }: { uri: string; background: string; onFailed: () => void }) { +function ViewerWebView({ uri, background, onFailed, onMessage, webRef }: ViewerWebViewProps) { const folder = uri.slice(0, uri.lastIndexOf('/') + 1); + const handleMessage = (e: WebViewMessageEvent) => { + if (e.nativeEvent.data === 'error') onFailed(); + else onMessage?.(e.nativeEvent.data); + }; return ( req.url === uri} - onMessage={(e) => { if (e.nativeEvent.data === 'error') onFailed(); }} + onMessage={handleMessage} onError={onFailed} style={[viewerStyles.fill, { backgroundColor: background }]} containerStyle={{ backgroundColor: background }} @@ -82,8 +98,39 @@ function ViewerWebView({ uri, background, onFailed }: { uri: string; background: ); } +// Pages are rendered half again as wide as the screen's pixels, so text stays +// sharp at the first zoom step; PdfRenderer's bitmap is capped natively. +const PDF_PAGE_OVERSAMPLE = 1.5; + /** - * In-app preview for images, PDFs (iOS renders them in the WebView), video + * Android's PDF view: the viewer page asks for each page as it comes near + * the screen, and the page is rendered next to it by the platform's + * PdfRenderer (pdf-pages.ts). + */ +function PdfPagesView({ fileUri, viewerUri, background, onFailed }: { + fileUri: string; + viewerUri: string; + background: string; + onFailed: () => void; +}) { + const ref = React.useRef(null); + const { width } = useWindowDimensions(); + const pagePx = PixelRatio.getPixelSizeForLayoutSize(Math.min(width, 1000)) * PDF_PAGE_OVERSAMPLE; + const folder = viewerUri.slice(0, viewerUri.lastIndexOf('/') + 1); + const onMessage = (data: string) => { + const m = /^page:(\d+)$/.exec(data); + if (!m) return; + const index = Number(m[1]); + const name = pdfPageFileName(index); + renderPdfPage(fileUri, index, pagePx, folder + name) + .then(() => ref.current?.injectJavaScript(`window.__pdfPage(${index}, ${JSON.stringify(name)});true;`)) + .catch(() => undefined); + }; + return ; +} + +/** + * In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video * and audio (the WebView's player), text, Markdown and HTML documents and * embedded messages. Files that don't decode, and types without a preview, * offer another app instead. Used by every place that opens a file. @@ -133,7 +180,7 @@ export function AttachmentPreviewModal({ || (!!failure?.native && !item.viewerUri) ); const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web; - const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri; + const pdfInline = item?.kind === 'pdf' && !!item.fileUri && (Platform.OS === 'ios' || !!item.viewerUri) && !failure?.web; // The fallback screen has its own Open button; the header adds one elsewhere. const showsFallback = !!item && ( item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed || mediaFailed @@ -216,6 +263,17 @@ export function AttachmentPreviewModal({ /> ); case 'pdf': + if (pdfInline && item.fileUri && item.viewerUri) { + return ( + markFailed(item.fileUri, 'web')} + /> + ); + } if (pdfInline && item.fileUri) { return ( ); } - return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.')); + return renderFallback(t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here.")); case 'text': return ( diff --git a/src/components/email/use-file-preview.ts b/src/components/email/use-file-preview.ts index d18888d4..6cc83a8c 100644 --- a/src/components/email/use-file-preview.ts +++ b/src/components/email/use-file-preview.ts @@ -11,7 +11,8 @@ import { } from '../../lib/file-preview'; import type { PreviewKind } from '../../lib/file-preview'; import { markdownToHtml } from '../../lib/markdown-html'; -import { imageViewerHtml, mediaViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html'; +import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html'; +import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages'; import { useLocaleStore } from '../../stores/locale-store'; import { useColors, useResolvedTheme } from '../../theme/colors'; import type { PreviewItem } from './AttachmentPreviewModal'; @@ -74,16 +75,22 @@ function writeViewer(local: File, html: string): string { return page.uri; } +interface Notices { + tooLarge: string; + pdfProtected: string; + pdfUnreadable: string; +} + async function buildItem( kind: Exclude, source: PreviewSource, local: File, - tooLarge: string, + notices: Notices, colors: ViewerColors, ): Promise { const base = { name: source.name, mimeType: source.type }; // The source may not know its size; the copy does. - if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: tooLarge }; + if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge }; switch (kind) { case 'text': return { ...base, kind, text: await local.text() }; @@ -98,6 +105,17 @@ async function buildItem( case 'video': case 'audio': return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) }; + case 'pdf': { + // iOS's WebView shows the PDF itself; Android renders its pages. + if (Platform.OS !== 'android') return { ...base, kind, fileUri: local.uri }; + try { + const pages = await openPdfPages(local.uri); + return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, pdfViewerHtml(local.name, pages, colors)) }; + } catch (e) { + const protectedPdf = e instanceof PdfOpenError && e.reason === 'protected'; + return { ...base, kind: 'none', notice: protectedPdf ? notices.pdfProtected : notices.pdfUnreadable }; + } + } default: return { ...base, kind, fileUri: local.uri }; } @@ -141,7 +159,9 @@ export function useFilePreview(opts: { seq.current += 1; const open = session.current; session.current = null; - if (open && !open.handedOff) discardPreviewFile(open.file); + if (!open) return; + closePdfPages(open.file.uri); + if (!open.handedOff) discardPreviewFile(open.file); }, []); React.useEffect(() => end, [end]); @@ -181,9 +201,14 @@ export function useFilePreview(opts: { return; } session.current = { file: local, handedOff: false }; - const next = await buildItem( - kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'), colors.current, - ); + const next = await buildItem(kind, src, local, { + tooLarge: t('files.preview_too_large', 'This file is too large to preview.'), + pdfProtected: t( + 'email_viewer.preview.pdf_protected', + 'This PDF is password-protected. Open it in another app to enter the password.', + ), + pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."), + }, colors.current); if (current()) setItem(next); } catch (e) { if (!current()) return; diff --git a/src/lib/__tests__/file-preview.test.ts b/src/lib/__tests__/file-preview.test.ts index 870d2b95..f4c6823a 100644 --- a/src/lib/__tests__/file-preview.test.ts +++ b/src/lib/__tests__/file-preview.test.ts @@ -37,10 +37,11 @@ describe('filePreviewKind', () => { expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown'); }); - it('shows PDFs in-app only where the platform renders them', () => { + it('shows PDFs in-app on both platforms', () => { expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf'); expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf'); - expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none'); + expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('pdf'); + expect(filePreviewKind({ name: 'REPORT.PDF', type: '' }, ANDROID)).toBe('pdf'); }); it('plays the video formats each platform plays', () => { diff --git a/src/lib/file-preview.ts b/src/lib/file-preview.ts index d0c1922f..8b707074 100644 --- a/src/lib/file-preview.ts +++ b/src/lib/file-preview.ts @@ -11,7 +11,7 @@ export interface FilePreviewOptions { /** * The platform's viewers decide what plays: video and audio play in the * system WebView (Chromium on Android, WebKit on iOS), whose formats - * differ, and only iOS renders PDFs in-app. + * differ. */ platform: 'ios' | 'android'; } @@ -139,9 +139,8 @@ export function filePreviewKind(file: Pick, op || (ios && (IOS_IMAGE_MIME.has(mime) || IOS_IMAGE_EXT.has(ext))); return shown ? 'image' : 'none'; } - if (mime === 'application/pdf' || (!media && ext === 'pdf')) { - return opts.platform === 'ios' ? 'pdf' : 'none'; - } + // iOS's WebView shows PDFs; Android renders their pages (pdf-pages.ts). + if (mime === 'application/pdf' || (!media && ext === 'pdf')) return 'pdf'; for (const kind of ['video', 'audio'] as const) { const formats = MEDIA[kind][opts.platform]; if (mime.startsWith(`${kind}/`)) return formats.mime.has(mime) || formats.ext.has(ext) ? kind : 'none'; diff --git a/src/lib/pdf-pages.ts b/src/lib/pdf-pages.ts new file mode 100644 index 00000000..16247471 --- /dev/null +++ b/src/lib/pdf-pages.ts @@ -0,0 +1,67 @@ +// Android's PDF pages for the file preview, rendered by BulwarkPdfModule +// (the platform's PdfRenderer) into PNGs next to the PDF. iOS needs none of +// this: its WebView shows PDFs itself. + +import { NativeModules, Platform } from 'react-native'; + +export interface PdfPageSize { + /** In points. */ + width: number; + height: number; +} + +interface NativePdf { + open(fileUri: string): Promise<{ pages: PdfPageSize[] }>; + renderPage(fileUri: string, index: number, width: number, outUri: string): Promise; + close(fileUri: string): Promise; +} + +function native(): NativePdf | null { + if (Platform.OS !== 'android') return null; + return (NativeModules.BulwarkPdf as NativePdf | undefined) ?? null; +} + +/** Whether this build renders PDFs in-app. */ +export function canRenderPdfPages(): boolean { + return native() !== null; +} + +export type PdfOpenFailure = 'protected' | 'unreadable'; + +export class PdfOpenError extends Error { + constructor(readonly reason: PdfOpenFailure, message: string) { + super(message); + } +} + +/** The pages of a PDF, or a {@link PdfOpenError} saying why it can't be shown. */ +export async function openPdfPages(fileUri: string): Promise { + const pdf = native(); + if (!pdf) throw new PdfOpenError('unreadable', 'PDF rendering is not available'); + try { + const { pages } = await pdf.open(fileUri); + if (!pages.length) throw new PdfOpenError('unreadable', 'The PDF has no pages'); + return pages; + } catch (e) { + if (e instanceof PdfOpenError) throw e; + const code = (e as { code?: string } | null)?.code; + throw new PdfOpenError(code === 'E_PDF_PROTECTED' ? 'protected' : 'unreadable', e instanceof Error ? e.message : String(e)); + } +} + +/** Render page `index` (0-based) `width` pixels wide into `outUri`. */ +export function renderPdfPage(fileUri: string, index: number, width: number, outUri: string): Promise { + const pdf = native(); + if (!pdf) return Promise.reject(new Error('PDF rendering is not available')); + return pdf.renderPage(fileUri, index, Math.round(width), outUri); +} + +/** Let go of the open document. */ +export function closePdfPages(fileUri: string): void { + void native()?.close(fileUri).catch(() => undefined); +} + +/** Name of the image a page is rendered into, next to the viewer page. */ +export function pdfPageFileName(index: number): string { + return `__page-${index + 1}.png`; +} diff --git a/src/lib/preview-viewer-html.ts b/src/lib/preview-viewer-html.ts index b5f4460e..c98b6134 100644 --- a/src/lib/preview-viewer-html.ts +++ b/src/lib/preview-viewer-html.ts @@ -102,3 +102,41 @@ export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, color + `${script}`, ); } + +/** + * Android's PDF viewer: one placeholder per page at the page's proportions, + * filled with an image the app renders when the page comes near the screen + * (it posts `page:` and the app answers by calling + * `window.__pdfPage(index, src)`). The WebView zooms the whole document, and a + * badge shows the page at the middle of the screen. + */ +export function pdfViewerHtml( + fileName: string, + pages: ReadonlyArray<{ width: number; height: number }>, + colors: ViewerColors, +): string { + const sizes = JSON.stringify(pages.map((p) => [Math.max(1, Math.round(p.width)), Math.max(1, Math.round(p.height))])); + return page( + fileName, + { ...colors, background: colors.surface }, + 'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=6,user-scalable=yes', + 'body{height:auto;min-height:100%}' + + '#doc{padding:8px 8px 56px;display:flex;flex-direction:column;align-items:center;gap:8px}' + + '.page{width:100%;max-width:1000px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);position:relative}' + + '.page img{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .15s}' + + '.page img.on{opacity:1}' + + `#badge{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:4px 12px;border-radius:999px;` + + `background:rgba(0,0,0,.65);color:#fff;font-size:13px;font-variant-numeric:tabular-nums;transition:opacity .3s}` + + '#badge.hide{opacity:0}', + '
' + + ``, + ); +}