From fed5bbbf578b85c239054ea1b34893ceedb73060 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:30:04 +0000 Subject: [PATCH 01/10] feat(mobile): startup check and native PDF handoff stores and plugins (MOB-P3b) The native export renders nothing until a startup check finds a secure context, Web Crypto, Web Locks and IndexedDB, and shows an update screen otherwise. SuiteFiles (Android ACTION_VIEW through FileProvider, iOS QuickLook) receives a PDF in 1 MiB base64 chunks with the 25 MiB cap, the %PDF- signature, one handle at a time and a 60 s idle expiry enforced natively; suite-native's openPdf drives it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUerjkrSzzPz54mUmf1u --- .../android/app/src/main/AndroidManifest.xml | 8 + .../java/org/jeswr/suite/MainActivity.java | 1 + .../java/org/jeswr/suite/PdfPreviewStore.java | 138 ++++++++++++++++++ .../org/jeswr/suite/SuiteFilesPlugin.java | 112 ++++++++++++++ .../app/src/main/res/xml/file_paths.xml | 2 + .../org/jeswr/suite/PdfPreviewStoreTest.java | 129 ++++++++++++++++ .../ios/App/App.xcodeproj/project.pbxproj | 12 ++ .../ios/App/App/MainViewController.swift | 1 + apps/mobile/ios/App/App/PdfPreviewStore.swift | 136 +++++++++++++++++ .../mobile/ios/App/App/SuiteFilesPlugin.swift | 121 +++++++++++++++ .../App/AppTests/PdfPreviewStoreTests.swift | 104 +++++++++++++ apps/mobile/test/native.test.ts | 47 +++++- apps/suite/src/app/globals.css | 8 + apps/suite/src/app/layout.tsx | 8 +- .../src/components/native-startup-gate.tsx | 33 +++++ apps/suite/src/lib/native/startup-check.ts | 25 ++++ apps/suite/test/native-startup-check.test.ts | 74 ++++++++++ packages/suite-native/src/capacitor.test.ts | 1 + packages/suite-native/src/capacitor.ts | 3 + packages/suite-native/src/index.test.ts | 4 + packages/suite-native/src/index.ts | 8 + packages/suite-native/src/pdf-preview.test.ts | 111 ++++++++++++++ packages/suite-native/src/pdf-preview.ts | 71 +++++++++ 23 files changed, 1155 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/android/app/src/main/java/org/jeswr/suite/PdfPreviewStore.java create mode 100644 apps/mobile/android/app/src/main/java/org/jeswr/suite/SuiteFilesPlugin.java create mode 100644 apps/mobile/android/app/src/test/java/org/jeswr/suite/PdfPreviewStoreTest.java create mode 100644 apps/mobile/ios/App/App/PdfPreviewStore.swift create mode 100644 apps/mobile/ios/App/App/SuiteFilesPlugin.swift create mode 100644 apps/mobile/ios/App/AppTests/PdfPreviewStoreTests.swift create mode 100644 apps/suite/src/components/native-startup-gate.tsx create mode 100644 apps/suite/src/lib/native/startup-check.ts create mode 100644 apps/suite/test/native-startup-check.test.ts create mode 100644 packages/suite-native/src/pdf-preview.test.ts create mode 100644 packages/suite-native/src/pdf-preview.ts diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index d0ed0063e..cdb962e13 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,14 @@ + + + + + + + + MAX_BYTES) throw new Refused("too-large"); + if (name == null || name.length() > 4096 || size < SIGNATURE.length) throw new Refused("invalid"); + String id = UUID.randomUUID().toString(); + File dir = new File(root, id); + File created = new File(dir, fileName(name)); + if (!dir.mkdir() || !created.createNewFile()) throw new IOException("Cannot create preview file"); + this.handle = id; + this.file = created; + this.size = size; + this.written = 0; + this.touched = now; + this.finished = false; + return id; + } + + /** Appends one decoded chunk. Any refusal discards the preview. */ + public synchronized void append(String id, byte[] bytes, long now) throws Refused, IOException { + current(id, now); + try { + if (bytes == null || bytes.length == 0 || bytes.length > MAX_CHUNK_BYTES) throw new Refused("invalid"); + if (written + bytes.length > size) throw new Refused("too-large"); + if (written == 0 && !startsWithSignature(bytes)) throw new Refused("not-pdf"); + try (FileOutputStream out = new FileOutputStream(file, true)) { + out.write(bytes); + } + written += bytes.length; + touched = now; + } catch (Refused | IOException e) { + clear(); + throw e; + } + } + + /** The complete file, ready for the viewer. Later appends to this handle are refused. */ + public synchronized File finish(String id, long now) throws Refused, IOException { + current(id, now); + if (written != size) { + clear(); + throw new Refused("invalid"); + } + finished = true; + touched = now; + return file; + } + + /** Drops the handle and empties the directory. */ + public synchronized void clear() throws IOException { + handle = null; + file = null; + finished = false; + File[] entries = root.listFiles(); + if (entries == null) throw new IOException("Cannot list pdf-preview cache"); + for (File entry : entries) remove(entry); + } + + private void current(String id, long now) throws Refused, IOException { + if (handle == null || finished || !handle.equals(id)) throw new Refused("invalid"); + if (now - touched > IDLE_MS) { + clear(); + throw new Refused("invalid"); + } + } + + private static boolean startsWithSignature(byte[] bytes) { + if (bytes.length < SIGNATURE.length) return false; + for (int i = 0; i < SIGNATURE.length; i++) if (bytes[i] != SIGNATURE[i]) return false; + return true; + } + + /** A filesystem-safe display name that always ends in .pdf. */ + static String fileName(String name) { + String safe = ShareOutStore.sanitiseName(name); + if (safe.equals("shared-file")) safe = "document"; + if (!safe.toLowerCase(Locale.ROOT).endsWith(".pdf")) { + while (safe.getBytes(StandardCharsets.UTF_8).length > 251) { + safe = safe.substring(0, safe.offsetByCodePoints(safe.length(), -1)); + } + safe = safe + ".pdf"; + } + return safe; + } + + private static void remove(File entry) throws IOException { + File[] children = entry.isDirectory() ? entry.listFiles() : null; + if (children != null) for (File child : children) remove(child); + if (!entry.delete() && entry.exists()) throw new IOException("Cannot remove " + entry.getName()); + } +} diff --git a/apps/mobile/android/app/src/main/java/org/jeswr/suite/SuiteFilesPlugin.java b/apps/mobile/android/app/src/main/java/org/jeswr/suite/SuiteFilesPlugin.java new file mode 100644 index 000000000..31ba06a5e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/org/jeswr/suite/SuiteFilesPlugin.java @@ -0,0 +1,112 @@ +// AUTHORED-BY Claude Opus 5.5 +package org.jeswr.suite; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.util.Base64; +import androidx.core.content.FileProvider; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.File; +import java.io.IOException; + +/** + * The Drive "Open PDF" handoff (MOB-P3 E5): base64 chunks into {@link PdfPreviewStore}, then an + * ACTION_VIEW intent with a read-only FileProvider URI. Only bytes and a display name cross. + */ +@CapacitorPlugin(name = "SuiteFiles") +public class SuiteFilesPlugin extends Plugin { + private PdfPreviewStore store; + + @Override + public void load() { + try { + store(); + } catch (IOException ignored) { + // Each call retries and fails closed. + } + } + + private synchronized PdfPreviewStore store() throws IOException { + if (store == null) store = new PdfPreviewStore(new File(getContext().getCacheDir(), "pdf-preview")); + return store; + } + + @PluginMethod + public void pdfBegin(PluginCall call) { + try { + Object raw = call.getData().opt("size"); + if (!(raw instanceof Number)) throw new PdfPreviewStore.Refused("invalid"); + double size = ((Number) raw).doubleValue(); + if (!Double.isFinite(size) || size != Math.floor(size) || size < 0) throw new PdfPreviewStore.Refused("invalid"); + String handle = store().begin(call.getString("name"), size > Long.MAX_VALUE ? Long.MAX_VALUE : (long) size, + System.currentTimeMillis()); + JSObject result = new JSObject(); + result.put("handle", handle); + call.resolve(result); + } catch (PdfPreviewStore.Refused e) { + call.reject("Cannot preview this PDF", e.code); + } catch (IOException | RuntimeException e) { + call.reject("Cannot preview this PDF", "failed"); + } + } + + @PluginMethod + public void pdfChunk(PluginCall call) { + try { + String data = call.getString("data"); + if (data == null || data.length() > 4 * ((PdfPreviewStore.MAX_CHUNK_BYTES + 2) / 3) || + data.length() % 4 != 0 || !data.matches("[A-Za-z0-9+/]*={0,2}")) { + throw new PdfPreviewStore.Refused("invalid"); + } + byte[] bytes = Base64.decode(data, Base64.NO_WRAP); + store().append(call.getString("handle"), bytes, System.currentTimeMillis()); + call.resolve(); + } catch (PdfPreviewStore.Refused e) { + discard(); + call.reject("Cannot preview this PDF", e.code); + } catch (IOException | RuntimeException e) { + discard(); + call.reject("Cannot preview this PDF", "failed"); + } + } + + @PluginMethod + public void pdfOpen(PluginCall call) { + try { + File file = store().finish(call.getString("handle"), System.currentTimeMillis()); + Uri uri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file); + Intent view = new Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/pdf") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (view.resolveActivity(getContext().getPackageManager()) == null) { + discard(); + call.reject("No PDF viewer is installed", "no-viewer"); + return; + } + getActivity().startActivity(view); + call.resolve(); + } catch (ActivityNotFoundException e) { + discard(); + call.reject("No PDF viewer is installed", "no-viewer"); + } catch (PdfPreviewStore.Refused e) { + discard(); + call.reject("Cannot preview this PDF", e.code); + } catch (IOException | RuntimeException e) { + discard(); + call.reject("Cannot preview this PDF", "failed"); + } + } + + private void discard() { + try { + store().clear(); + } catch (IOException ignored) { + // The next launch empties the directory. + } + } +} diff --git a/apps/mobile/android/app/src/main/res/xml/file_paths.xml b/apps/mobile/android/app/src/main/res/xml/file_paths.xml index cf54667cb..c102d2e40 100644 --- a/apps/mobile/android/app/src/main/res/xml/file_paths.xml +++ b/apps/mobile/android/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,6 @@ + + diff --git a/apps/mobile/android/app/src/test/java/org/jeswr/suite/PdfPreviewStoreTest.java b/apps/mobile/android/app/src/test/java/org/jeswr/suite/PdfPreviewStoreTest.java new file mode 100644 index 000000000..e72331dd6 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/org/jeswr/suite/PdfPreviewStoreTest.java @@ -0,0 +1,129 @@ +// AUTHORED-BY Claude Opus 5.5 +package org.jeswr.suite; + +import static org.junit.Assert.*; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class PdfPreviewStoreTest { + @Rule public TemporaryFolder temp = new TemporaryFolder(); + private File root; + private PdfPreviewStore store; + private final long now = 1_800_000_000_000L; + private static final byte[] PDF = "%PDF-1.7\n%%EOF\n".getBytes(StandardCharsets.US_ASCII); + + @Before public void setup() throws Exception { + root = new File(temp.getRoot(), "pdf-preview"); + store = new PdfPreviewStore(root); + } + + private String code(ThrowingRunnable action) { + try { + action.run(); + } catch (PdfPreviewStore.Refused e) { + return e.code; + } catch (Exception e) { + throw new AssertionError(e); + } + throw new AssertionError("expected a refusal"); + } + + private interface ThrowingRunnable { void run() throws Exception; } + + private int files() { + int count = 0; + for (File dir : root.listFiles()) count += dir.isDirectory() ? dir.listFiles().length : 1; + return count; + } + + @Test public void writesAndFinishesOnePdf() throws Exception { + String handle = store.begin("Report", PDF.length, now); + store.append(handle, PDF, now + 1); + File file = store.finish(handle, now + 2); + assertEquals("Report.pdf", file.getName()); + assertArrayEquals(PDF, Files.readAllBytes(file.toPath())); + assertEquals(root.getCanonicalFile(), file.getParentFile().getParentFile()); + assertEquals("invalid", code(() -> store.append(handle, PDF, now + 3))); + assertEquals("invalid", code(() -> store.finish(handle, now + 3))); + } + + @Test public void capsTheDeclaredSizeAndEveryAppend() throws Exception { + assertEquals("too-large", code(() -> store.begin("a.pdf", PdfPreviewStore.MAX_BYTES + 1, now))); + assertEquals(0, files()); + String handle = store.begin("a.pdf", PDF.length, now); + byte[] longer = new byte[PDF.length + 1]; + System.arraycopy(PDF, 0, longer, 0, PDF.length); + assertEquals("too-large", code(() -> store.append(handle, longer, now))); + assertEquals(0, files()); + assertEquals("invalid", code(() -> store.append(handle, PDF, now))); + String big = store.begin("a.pdf", PdfPreviewStore.MAX_BYTES, now); + assertEquals("invalid", code(() -> store.append(big, new byte[PdfPreviewStore.MAX_CHUNK_BYTES + 1], now))); + } + + @Test public void requiresTheSignatureOnTheFirstBytes() throws Exception { + String handle = store.begin("a.pdf", 16, now); + assertEquals("not-pdf", code(() -> store.append(handle, "evil store.append(shortFirst, "%PD".getBytes(StandardCharsets.US_ASCII), now))); + String split = store.begin("a.pdf", 10, now); + store.append(split, "%PDF-".getBytes(StandardCharsets.US_ASCII), now); + store.append(split, " store.begin(null, 10, now))); + assertEquals("invalid", code(() -> store.begin("a.pdf", 4, now))); + String handle = store.begin("a.pdf", PDF.length, now); + assertEquals("invalid", code(() -> store.append(handle, new byte[0], now))); + String next = store.begin("a.pdf", PDF.length, now); + assertEquals("invalid", code(() -> store.append("not-the-handle", PDF, now))); + assertEquals("invalid", code(() -> store.finish(next, now))); + assertEquals(0, files()); + } + + @Test public void aNewBeginReplacesThePreviousHandleAndFile() throws Exception { + String first = store.begin("first.pdf", PDF.length, now); + store.append(first, PDF, now); + store.finish(first, now); + String second = store.begin("second.pdf", PDF.length, now); + assertEquals("invalid", code(() -> store.append(first, PDF, now))); + assertEquals(1, files()); + store.append(second, PDF, now); + assertEquals("second.pdf", store.finish(second, now).getName()); + assertEquals(1, root.listFiles().length); + } + + @Test public void dropsAHandleIdleForSixtySeconds() throws Exception { + String handle = store.begin("a.pdf", PDF.length * 2L, now); + store.append(handle, PDF, now + PdfPreviewStore.IDLE_MS); + assertEquals("invalid", code(() -> store.append(handle, PDF, now + 2 * PdfPreviewStore.IDLE_MS + 1))); + assertEquals(0, files()); + } + + @Test public void emptiesTheDirectoryOnLaunch() throws Exception { + File stale = new File(new File(root, "left-by-a-killed-process"), "x.pdf"); + assertTrue(stale.getParentFile().mkdirs()); + assertTrue(stale.createNewFile()); + assertTrue(new File(root, "loose").createNewFile()); + new PdfPreviewStore(root); + assertEquals(0, root.listFiles().length); + } + + @Test public void namesEndInPdfAndStayInTheirDirectory() { + assertEquals("a.PDF", PdfPreviewStore.fileName("a.PDF")); + assertEquals("notes.txt.pdf", PdfPreviewStore.fileName("notes.txt")); + assertEquals("_.._x.pdf", PdfPreviewStore.fileName("/../x")); + assertEquals("document.pdf", PdfPreviewStore.fileName("")); + assertEquals("document.pdf", PdfPreviewStore.fileName("...")); + String longName = PdfPreviewStore.fileName("é".repeat(200)); + assertTrue(longName.endsWith(".pdf")); + assertTrue(longName.getBytes(StandardCharsets.UTF_8).length <= 255); + } +} diff --git a/apps/mobile/ios/App/App.xcodeproj/project.pbxproj b/apps/mobile/ios/App/App.xcodeproj/project.pbxproj index 837558c4b..5f310c374 100644 --- a/apps/mobile/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 13BFB63FC7789FC67E2029A8 /* SuiteOriginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 972ED7D72093E17922DEAB5B /* SuiteOriginTests.swift */; }; 39109055553BED83ECA4B1C6 /* SuiteSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76DAAC37EC9C6F21D26A402 /* SuiteSchemeHandlerTests.swift */; }; 6B24F5AA202B17FE20F62ABA /* RouteResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29859CBC34FA94A45846F22C /* RouteResolverTests.swift */; }; + 0E61567A807E42F44B52291B /* PdfPreviewStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2500473CBE3B686FF8692105 /* PdfPreviewStoreTests.swift */; }; 14E03E00A3139E5EE2B85FD8 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8EFB09D532889909E021C3 /* CapApp-SPM */; }; 186DFAEB4660B57133868B0A /* SuiteOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDC852D88EB015D4D001F2A /* SuiteOrigin.swift */; }; 1DDC1CD42450C91A74B05BE7 /* SuiteNavigationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E7C6F804CE98F2E210DFB9 /* SuiteNavigationPlugin.swift */; }; @@ -42,6 +43,8 @@ 8ADAA7BF2211267F5587DEDD /* SecureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87F89D3293E5108720B588A /* SecureStore.swift */; }; 6DD32503E71E53A35A225317 /* SuiteSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F145B7FE8FE09B27FF1F7ED /* SuiteSchemeHandler.swift */; }; 73B034EA5ED1FE9905C24236 /* RouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A25C102554B76E91A6D8F2B /* RouteResolver.swift */; }; + 789C6341DF25810BAAFAAE50 /* SuiteFilesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A6A9CD870E55A5B0A308EC /* SuiteFilesPlugin.swift */; }; + 866E0F0058935106025CD248 /* PdfPreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F7CC92D40A42574487F5DF /* PdfPreviewStore.swift */; }; 7F247B81197F10306C06213A /* SecureStoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0665BEBD95AD1E7DFF1C51D3 /* SecureStoreState.swift */; }; 0C95C5B31EC3F8AEBFAE0994 /* SecureStoreKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353758E7722CC7B5E93F6827 /* SecureStoreKeys.swift */; }; CB20AD68EF1E7F678BF04177 /* SecureStorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D3649DD161E6A9FD6AC6407 /* SecureStorePersistence.swift */; }; @@ -139,6 +142,7 @@ 972ED7D72093E17922DEAB5B /* SuiteOriginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuiteOriginTests.swift; sourceTree = ""; }; F76DAAC37EC9C6F21D26A402 /* SuiteSchemeHandlerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuiteSchemeHandlerTests.swift; sourceTree = ""; }; 29859CBC34FA94A45846F22C /* RouteResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteResolverTests.swift; sourceTree = ""; }; + 2500473CBE3B686FF8692105 /* PdfPreviewStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PdfPreviewStoreTests.swift; sourceTree = ""; }; AF4BF16769184CC8BBB912E8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B281A69F43BED62127E362EA /* SuiteAuthSessionPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuiteAuthSessionPluginTests.swift; sourceTree = ""; }; BD909227DF8A840931F93E70 /* BridgeOriginGuardTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BridgeOriginGuardTests.swift; sourceTree = ""; }; @@ -159,6 +163,8 @@ F87F89D3293E5108720B588A /* SecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStore.swift; sourceTree = ""; }; 4F145B7FE8FE09B27FF1F7ED /* SuiteSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuiteSchemeHandler.swift; sourceTree = ""; }; 7A25C102554B76E91A6D8F2B /* RouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteResolver.swift; sourceTree = ""; }; + D5A6A9CD870E55A5B0A308EC /* SuiteFilesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuiteFilesPlugin.swift; sourceTree = ""; }; + 89F7CC92D40A42574487F5DF /* PdfPreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PdfPreviewStore.swift; sourceTree = ""; }; 0665BEBD95AD1E7DFF1C51D3 /* SecureStoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStoreState.swift; sourceTree = ""; }; 353758E7722CC7B5E93F6827 /* SecureStoreKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStoreKeys.swift; sourceTree = ""; }; 6D3649DD161E6A9FD6AC6407 /* SecureStorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorePersistence.swift; sourceTree = ""; }; @@ -241,6 +247,7 @@ 972ED7D72093E17922DEAB5B /* SuiteOriginTests.swift */, F76DAAC37EC9C6F21D26A402 /* SuiteSchemeHandlerTests.swift */, 29859CBC34FA94A45846F22C /* RouteResolverTests.swift */, + 2500473CBE3B686FF8692105 /* PdfPreviewStoreTests.swift */, ); name = AppTests; path = AppTests; @@ -283,6 +290,8 @@ F87F89D3293E5108720B588A /* SecureStore.swift */, 4F145B7FE8FE09B27FF1F7ED /* SuiteSchemeHandler.swift */, 7A25C102554B76E91A6D8F2B /* RouteResolver.swift */, + D5A6A9CD870E55A5B0A308EC /* SuiteFilesPlugin.swift */, + 89F7CC92D40A42574487F5DF /* PdfPreviewStore.swift */, 621B21C8DE22655BA133A66B /* SuiteSecureStorePlugin.swift */, 0BF2AA18A77CA5F18284C231 /* SuitePushPlugin.swift */, E1EEB1B35639161CCCB530E8 /* SuitePushCoordinator.swift */, @@ -577,6 +586,8 @@ 8ADAA7BF2211267F5587DEDD /* SecureStore.swift in Sources */, 6DD32503E71E53A35A225317 /* SuiteSchemeHandler.swift in Sources */, 73B034EA5ED1FE9905C24236 /* RouteResolver.swift in Sources */, + 789C6341DF25810BAAFAAE50 /* SuiteFilesPlugin.swift in Sources */, + 866E0F0058935106025CD248 /* PdfPreviewStore.swift in Sources */, F4B9B4E14754357C61BAA2A1 /* SuiteSecureStorePlugin.swift in Sources */, 48FBFA41AC02A3879AB98E86 /* PushCompletion.swift in Sources */, E3003BA39D9DD7C031CE9B91 /* PushCrypto.swift in Sources */, @@ -628,6 +639,7 @@ 13BFB63FC7789FC67E2029A8 /* SuiteOriginTests.swift in Sources */, 39109055553BED83ECA4B1C6 /* SuiteSchemeHandlerTests.swift in Sources */, 6B24F5AA202B17FE20F62ABA /* RouteResolverTests.swift in Sources */, + 0E61567A807E42F44B52291B /* PdfPreviewStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/mobile/ios/App/App/MainViewController.swift b/apps/mobile/ios/App/App/MainViewController.swift index 4a6f50d6f..b627ce3c1 100644 --- a/apps/mobile/ios/App/App/MainViewController.swift +++ b/apps/mobile/ios/App/App/MainViewController.swift @@ -44,6 +44,7 @@ class MainViewController: CAPBridgeViewController { bridge.registerPluginInstance(SuitePushPlugin()) bridge.registerPluginInstance(SuiteShareIntakePlugin()) bridge.registerPluginInstance(SuiteShareStagingPlugin()) + bridge.registerPluginInstance(SuiteFilesPlugin()) let secureStore = SuiteSecureStorePlugin() bridge.registerPluginInstance(secureStore) // Takes over the "Share" name from @capacitor/share so only staged files can be shared. diff --git a/apps/mobile/ios/App/App/PdfPreviewStore.swift b/apps/mobile/ios/App/App/PdfPreviewStore.swift new file mode 100644 index 000000000..f67ff814c --- /dev/null +++ b/apps/mobile/ios/App/App/PdfPreviewStore.swift @@ -0,0 +1,136 @@ +// AUTHORED-BY Claude Opus 5.5 +import Foundation + +/// The PDF preview handoff's cache (MOB-P3 E5): Caches/pdf-preview//.pdf, at most one +/// file. The 25 MiB cap applies to the declared size and to every append, the first bytes must be +/// `%PDF-`, one handle is live at a time, and a handle idle for 60 seconds is dropped. Not +/// thread-safe: SuiteFilesPlugin calls it on one serial queue. +final class PdfPreviewStore { + static let maxBytes: Int64 = 25 * 1024 * 1024 + static let maxChunkBytes = 1024 * 1024 + static let idle: TimeInterval = 60 + private static let signature = Data("%PDF-".utf8) + + /// A refusal; `code` is what the plugin rejects with. + struct Refused: Error, Equatable { + let code: String + } + + private let root: URL + private var handle: String? + private var file: URL? + private var size: Int64 = 0 + private var written: Int64 = 0 + private var touched = Date.distantPast + private var finished = false + + static func defaultRoot() throws -> URL { + try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("pdf-preview", isDirectory: true) + } + + /// Creates the store and empties its directory, which also clears files a killed process left. + init(root: URL) throws { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + self.root = root.resolvingSymlinksInPath().standardizedFileURL + try clear() + } + + /// Starts a preview, discarding the previous handle and its file. + func begin(name: String, size: Int64, now: Date = Date()) throws -> String { + try clear() + guard size <= PdfPreviewStore.maxBytes else { throw Refused(code: "too-large") } + guard name.utf8.count <= 16384, size >= Int64(PdfPreviewStore.signature.count) else { + throw Refused(code: "invalid") + } + let id = UUID().uuidString.lowercased() + let dir = root.appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: false) + let created = dir.appendingPathComponent(PdfPreviewStore.fileName(name), isDirectory: false) + guard FileManager.default.createFile(atPath: created.path, contents: nil) else { + throw Refused(code: "failed") + } + handle = id + file = created + self.size = size + written = 0 + touched = now + finished = false + return id + } + + /// Appends one decoded chunk. Any refusal discards the preview. + func append(handle id: String, data: Data, now: Date = Date()) throws { + let target = try current(id, now: now) + do { + guard !data.isEmpty, data.count <= PdfPreviewStore.maxChunkBytes else { throw Refused(code: "invalid") } + guard written + Int64(data.count) <= size else { throw Refused(code: "too-large") } + if written == 0, !data.starts(with: PdfPreviewStore.signature) { throw Refused(code: "not-pdf") } + let out = try FileHandle(forWritingTo: target) + defer { try? out.close() } + try out.seekToEnd() + try out.write(contentsOf: data) + written += Int64(data.count) + touched = now + } catch { + try? clear() + throw error + } + } + + /// The complete file, ready for the viewer. Later appends to this handle are refused. + func finish(handle id: String, now: Date = Date()) throws -> URL { + let target = try current(id, now: now) + guard written == size else { + try? clear() + throw Refused(code: "invalid") + } + finished = true + touched = now + return target + } + + /// Deletes `url` when it is the finished preview (the viewer closed); anything else is ignored. + func release(_ url: URL) { + guard finished, let file, file == url else { return } + try? clear() + } + + /// Drops the handle and empties the directory. + func clear() throws { + handle = nil + file = nil + finished = false + for entry in try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) { + try FileManager.default.removeItem(at: entry) + } + } + + private func current(_ id: String, now: Date) throws -> URL { + guard let handle, let file, !finished, handle == id else { throw Refused(code: "invalid") } + guard now.timeIntervalSince(touched) <= PdfPreviewStore.idle else { + try? clear() + throw Refused(code: "invalid") + } + return file + } + + /// A filesystem-safe display name that always ends in .pdf. + static func fileName(_ raw: String) -> String { + let unsafe = CharacterSet(charactersIn: "/\\").union(.controlCharacters) + var name = String(String.UnicodeScalarView(raw.unicodeScalars.map { unsafe.contains($0) ? "_" : $0 })) + while let first = name.unicodeScalars.first, + first == "." || CharacterSet.whitespacesAndNewlines.contains(first) { + name.removeFirst() + } + name = name.trimmingCharacters(in: .whitespacesAndNewlines) + var suffix = ".pdf" + if name.lowercased().hasSuffix(".pdf") { + suffix = String(name.suffix(4)) + name.removeLast(4) + } + if name.isEmpty { name = "document" } + while name.utf8.count > 255 - suffix.utf8.count { name.removeLast() } + return name + suffix + } +} diff --git a/apps/mobile/ios/App/App/SuiteFilesPlugin.swift b/apps/mobile/ios/App/App/SuiteFilesPlugin.swift new file mode 100644 index 000000000..4241424ad --- /dev/null +++ b/apps/mobile/ios/App/App/SuiteFilesPlugin.swift @@ -0,0 +1,121 @@ +// AUTHORED-BY Claude Opus 5.5 +import Capacitor +import CoreFoundation +import Foundation +import QuickLook +import UIKit + +/// The Drive "Open PDF" handoff (MOB-P3 E5): base64 chunks into PdfPreviewStore, then +/// QLPreviewController. The file is deleted when the viewer closes. Only bytes and a display name +/// cross the bridge. +@objc(SuiteFilesPlugin) +public class SuiteFilesPlugin: CAPPlugin, CAPBridgedPlugin, QLPreviewControllerDataSource, QLPreviewControllerDelegate { + public let identifier = "SuiteFilesPlugin" + public let jsName = "SuiteFiles" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "pdfBegin", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "pdfChunk", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "pdfOpen", returnType: CAPPluginReturnPromise) + ] + private let queue = DispatchQueue(label: "org.jeswr.suite.pdf-preview") + private var cachedStore: PdfPreviewStore? + /// The file the open viewer shows (main thread only). + private var showing: URL? + + private func store() throws -> PdfPreviewStore { + if let store = cachedStore { return store } + let store = try PdfPreviewStore(root: PdfPreviewStore.defaultRoot()) + cachedStore = store + return store + } + + override public func load() { + queue.async { _ = try? self.store() } + } + + private func reject(_ call: CAPPluginCall, _ error: Error) { + try? store().clear() + let code = (error as? PdfPreviewStore.Refused)?.code ?? "failed" + call.reject(code == "no-viewer" ? "No PDF viewer is available" : "Cannot preview this PDF", code) + } + + @objc func pdfBegin(_ call: CAPPluginCall) { + queue.async { + do { + // Inspect the original value: Capacitor's numeric accessors coerce booleans and fractions. + guard let name = call.getString("name"), let number = call.options["size"] as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + throw PdfPreviewStore.Refused(code: "invalid") + } + let value = number.doubleValue + guard value.isFinite, value >= 0, value.rounded() == value else { + throw PdfPreviewStore.Refused(code: "invalid") + } + let size = value > Double(PdfPreviewStore.maxBytes) ? PdfPreviewStore.maxBytes + 1 : Int64(value) + call.resolve(["handle": try self.store().begin(name: name, size: size)]) + } catch { self.reject(call, error) } + } + } + + @objc func pdfChunk(_ call: CAPPluginCall) { + queue.async { + do { + guard let handle = call.getString("handle"), let encoded = call.getString("data"), + encoded.utf8.count <= 4 * ((PdfPreviewStore.maxChunkBytes + 2) / 3), + let data = Data(base64Encoded: encoded), data.base64EncodedString() == encoded else { + throw PdfPreviewStore.Refused(code: "invalid") + } + try self.store().append(handle: handle, data: data) + call.resolve() + } catch { self.reject(call, error) } + } + } + + @objc func pdfOpen(_ call: CAPPluginCall) { + queue.async { + do { + let file = try self.store().finish(handle: call.getString("handle") ?? "") + DispatchQueue.main.async { self.present(file, call) } + } catch { self.reject(call, error) } + } + } + + private func present(_ file: URL, _ call: CAPPluginCall) { + guard QLPreviewController.canPreview(file as NSURL), let presenter = topViewController() else { + queue.async { self.reject(call, PdfPreviewStore.Refused(code: "no-viewer")) } + return + } + let viewer = QLPreviewController() + viewer.dataSource = self + viewer.delegate = self + showing = file + presenter.present(viewer, animated: true) + call.resolve() + } + + private func topViewController() -> UIViewController? { + var presenter = bridge?.viewController + while let presented = presenter?.presentedViewController { presenter = presented } + return presenter + } + + public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + showing == nil ? 0 : 1 + } + + public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + (showing ?? URL(fileURLWithPath: "/dev/null")) as NSURL + } + + /// Read-only: no markup or editing writes back to the cache. + public func previewController(_ controller: QLPreviewController, + editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode { + .disabled + } + + public func previewControllerDidDismiss(_ controller: QLPreviewController) { + guard let file = showing else { return } + showing = nil + queue.async { try? self.store().release(file) } + } +} diff --git a/apps/mobile/ios/App/AppTests/PdfPreviewStoreTests.swift b/apps/mobile/ios/App/AppTests/PdfPreviewStoreTests.swift new file mode 100644 index 000000000..615b189c3 --- /dev/null +++ b/apps/mobile/ios/App/AppTests/PdfPreviewStoreTests.swift @@ -0,0 +1,104 @@ +// AUTHORED-BY Claude Opus 5.5 +import Foundation +import XCTest +@testable import App + +final class PdfPreviewStoreTests: XCTestCase { + private var root: URL! + private var store: PdfPreviewStore! + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private let pdf = Data("%PDF-1.7\n%%EOF\n".utf8) + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("pdf-preview-\(UUID().uuidString)", isDirectory: true) + store = try PdfPreviewStore(root: root) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + private func refusal(_ action: () throws -> Void) -> String? { + do { try action() } catch let refused as PdfPreviewStore.Refused { return refused.code } catch { return "other" } + return nil + } + + private func files() throws -> Int { + try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil).reduce(0) { count, dir in + count + ((try? FileManager.default.contentsOfDirectory(atPath: dir.path).count) ?? 1) + } + } + + func testWritesAndFinishesOnePdf() throws { + let handle = try store.begin(name: "Report", size: Int64(pdf.count), now: now) + try store.append(handle: handle, data: pdf, now: now) + let file = try store.finish(handle: handle, now: now) + XCTAssertEqual(file.lastPathComponent, "Report.pdf") + XCTAssertEqual(try Data(contentsOf: file), pdf) + XCTAssertEqual(refusal { try store.append(handle: handle, data: pdf, now: now) }, "invalid") + XCTAssertEqual(refusal { _ = try store.finish(handle: handle, now: now) }, "invalid") + store.release(file) + XCTAssertEqual(try files(), 0) + } + + func testCapsTheDeclaredSizeAndEveryAppend() throws { + XCTAssertEqual(refusal { _ = try store.begin(name: "a.pdf", size: PdfPreviewStore.maxBytes + 1, now: now) }, "too-large") + let handle = try store.begin(name: "a.pdf", size: Int64(pdf.count), now: now) + XCTAssertEqual(refusal { try store.append(handle: handle, data: pdf + Data([0]), now: now) }, "too-large") + XCTAssertEqual(try files(), 0) + let big = try store.begin(name: "a.pdf", size: PdfPreviewStore.maxBytes, now: now) + let oversized = Data(count: PdfPreviewStore.maxChunkBytes + 1) + XCTAssertEqual(refusal { try store.append(handle: big, data: oversized, now: now) }, "invalid") + } + + func testRequiresTheSignatureOnTheFirstBytes() throws { + let handle = try store.begin(name: "a.pdf", size: 16, now: now) + XCTAssertEqual(refusal { try store.append(handle: handle, data: Data("evil { expect(read("ios/App/Shared/ShareOutStore.swift")).not.toMatch(/import (Capacitor|UIKit)/); }); + // AUTHORED-BY Claude Opus 5.5 + test("registers the iOS PDF handoff, which previews read-only and deletes the file on close", () => { + expect(read("ios/App/App/MainViewController.swift")).toContain( + "bridge.registerPluginInstance(SuiteFilesPlugin())", + ); + const source = read("ios/App/App/SuiteFilesPlugin.swift"); + expect(source).toContain('jsName = "SuiteFiles"'); + for (const method of ["pdfBegin", "pdfChunk", "pdfOpen"]) { + expect(source).toContain(`CAPPluginMethod(name: "${method}"`); + } + expect(source).toContain("QLPreviewController.canPreview(file as NSURL)"); + expect(source).toMatch(/editingModeFor previewItem[^}]*\.disabled/); + expect(source).toMatch(/previewControllerDidDismiss[\s\S]*release\(file\)/); + const pbxproj = read("ios/App/App.xcodeproj/project.pbxproj"); + for (const file of [ + "PdfPreviewStore.swift", + "SuiteFilesPlugin.swift", + "PdfPreviewStoreTests.swift", + ]) + expect(pbxproj).toContain(`/* ${file} in Sources */`); + }); + test("replaces @capacitor/share on iOS so only committed staged files can be shared", () => { const main = read("ios/App/App/MainViewController.swift"); // Registered after the staging plugin, and after Capacitor's own plugins have loaded. @@ -450,7 +472,10 @@ describe("Android project", () => { const entries = [...paths.matchAll(/<([a-z-]+)\s+name="([^"]+)"\s+path="([^"]+)"\s*\/>/g)].map( (match) => match.slice(1), ); - expect(entries).toEqual([["cache-path", "share_out", "share-out/"]]); + expect(entries).toEqual([ + ["cache-path", "share_out", "share-out/"], + ["cache-path", "pdf_preview", "pdf-preview/"], + ]); expect(paths).not.toMatch(/<(?:external|root|files)-path/); expect(manifest).toContain('android:resource="@xml/file_paths"'); // biome-ignore lint/suspicious/noTemplateCurlyInString: Android manifest placeholder is literal. @@ -463,6 +488,26 @@ describe("Android project", () => { expect(source).toContain('result.put("uri", Uri.fromFile(file).toString())'); }); + // AUTHORED-BY Claude Opus 5.5 + test("registers the PDF handoff, which opens a read-only FileProvider URI after a viewer check", () => { + expect(read("android/app/src/main/java/org/jeswr/suite/MainActivity.java")).toContain( + "registerPlugin(SuiteFilesPlugin.class)", + ); + const source = read("android/app/src/main/java/org/jeswr/suite/SuiteFilesPlugin.java"); + expect(source).toContain('@CapacitorPlugin(name = "SuiteFiles")'); + for (const method of ["pdfBegin", "pdfChunk", "pdfOpen"]) { + expect(source).toContain(`public void ${method}(PluginCall call)`); + } + expect(source).toContain('setDataAndType(uri, "application/pdf")'); + expect(source).toContain("addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)"); + expect(source).not.toContain("FLAG_GRANT_WRITE_URI_PERMISSION"); + expect(source.indexOf("resolveActivity")).toBeLessThan(source.indexOf("startActivity")); + expect(source).toContain('"pdf-preview"'); + expect(manifest).toMatch( + /\s*\s*\s*\s*<\/intent>\s*<\/queries>/, + ); + }); + test("disables backup and cleartext in the main manifest", () => { expect(manifest).toContain('android:allowBackup="false"'); expect(manifest).toContain('android:usesCleartextTraffic="false"'); diff --git a/apps/suite/src/app/globals.css b/apps/suite/src/app/globals.css index e62be11c3..e0eaa29be 100644 --- a/apps/suite/src/app/globals.css +++ b/apps/suite/src/app/globals.css @@ -49,6 +49,14 @@ a { .skip-link:focus { clip-path: none; } +/* AUTHORED-BY Claude Opus 5.5: the native startup check's update screen (MOB-P3 E6). */ +.native-update { + display: grid; + gap: var(--space-3); + max-width: 32rem; + margin: 0 auto; + padding: var(--space-6) var(--space-4); +} h1, h2, h3, diff --git a/apps/suite/src/app/layout.tsx b/apps/suite/src/app/layout.tsx index a56f6e3a6..6356cca35 100644 --- a/apps/suite/src/app/layout.tsx +++ b/apps/suite/src/app/layout.tsx @@ -1,6 +1,8 @@ // AUTHORED-BY Codex GPT-6.1-Sol +// AUTHORED-BY Claude Opus 5.5 import { onest, sourceCode, sourceSerif } from "@jeswr/suite-ui/fonts"; import type { Metadata, Viewport } from "next"; +import { NativeStartupGate } from "@/components/native-startup-gate"; import { Providers } from "./providers"; import "./globals.css"; export const viewport: Viewport = { colorScheme: "light dark" }; @@ -19,7 +21,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) Skip to main content - {children} + {process.env.NEXT_PUBLIC_SUITE_TARGET === "native" ? ( + {children} + ) : ( + children + )} diff --git a/apps/suite/src/components/native-startup-gate.tsx b/apps/suite/src/components/native-startup-gate.tsx new file mode 100644 index 000000000..5951a3833 --- /dev/null +++ b/apps/suite/src/components/native-startup-gate.tsx @@ -0,0 +1,33 @@ +// AUTHORED-BY Claude Opus 5.5 +"use client"; +import { type ReactNode, useSyncExternalStore } from "react"; +import { missingPlatformFeatures, type StartupScope } from "@/lib/native/startup-check"; + +const noSubscription = () => () => {}; +let checked: string | undefined; +// One answer per page load; the platform's features do not change while it runs. +const clientSnapshot = () => { + checked ??= missingPlatformFeatures(globalThis as StartupScope).join(" "); + return checked; +}; +const serverSnapshot = () => null; + +/** + * Renders the app only once the startup check passes (MOB-P3 E6). The exported HTML holds + * nothing below the gate, so no page code, and no pod-auth, runs before the check. + */ +export function NativeStartupGate({ children }: { children: ReactNode }) { + const missing = useSyncExternalStore(noSubscription, clientSnapshot, serverSnapshot); + if (missing === null) return null; + if (missing === "") return children; + return ( +
+

Update needed

+

+ This device's web view is missing features Solid Suite needs to keep your sign-in safe. + Update the system (and, on Android, Android System WebView), then open the app again. +

+
+ ); +} + diff --git a/apps/suite/src/lib/native/startup-check.ts b/apps/suite/src/lib/native/startup-check.ts new file mode 100644 index 000000000..7fe6aa5f0 --- /dev/null +++ b/apps/suite/src/lib/native/startup-check.ts @@ -0,0 +1,25 @@ +// AUTHORED-BY Claude Opus 5.5 +/** + * The bundled app's startup check (MOB-P3 E6). pod-auth needs a secure context, Web Crypto, + * Web Locks and IndexedDB; a WebView without one of them shows the update screen instead. + */ +export type PlatformFeature = "secure-context" | "web-crypto" | "web-locks" | "indexeddb"; + +/** The globals the check reads; `globalThis` in the app, a plain object in tests. */ +export interface StartupScope { + isSecureContext?: boolean; + crypto?: { subtle?: unknown }; + navigator?: { locks?: { request?: unknown } }; + indexedDB?: { open?: unknown }; +} + +/** The features this WebView lacks, in a fixed order; empty when the app can start. */ +export function missingPlatformFeatures(scope: StartupScope): PlatformFeature[] { + const missing: PlatformFeature[] = []; + if (scope.isSecureContext !== true) missing.push("secure-context"); + if (typeof scope.crypto?.subtle !== "object" || scope.crypto.subtle === null) + missing.push("web-crypto"); + if (typeof scope.navigator?.locks?.request !== "function") missing.push("web-locks"); + if (typeof scope.indexedDB?.open !== "function") missing.push("indexeddb"); + return missing; +} diff --git a/apps/suite/test/native-startup-check.test.ts b/apps/suite/test/native-startup-check.test.ts new file mode 100644 index 000000000..f50c36d24 --- /dev/null +++ b/apps/suite/test/native-startup-check.test.ts @@ -0,0 +1,74 @@ +// AUTHORED-BY Claude Opus 5.5 +import { afterEach, expect, it, vi } from "vitest"; +import { missingPlatformFeatures } from "../src/lib/native/startup-check"; +import { h, mount } from "./drive-ui-dom"; + +const complete = () => ({ + isSecureContext: true, + crypto: { subtle: {} }, + navigator: { locks: { request: () => undefined } }, + indexedDB: { open: () => undefined }, +}); + +it("passes a WebView with all four features", () => { + expect(missingPlatformFeatures(complete())).toEqual([]); +}); + +it.each([ + ["secure-context", { isSecureContext: false }], + ["secure-context", { isSecureContext: undefined }], + ["web-crypto", { crypto: {} }], + ["web-crypto", { crypto: { subtle: null } }], + ["web-crypto", { crypto: undefined }], + ["web-locks", { navigator: {} }], + ["web-locks", { navigator: { locks: {} } }], + ["indexeddb", { indexedDB: undefined }], + ["indexeddb", { indexedDB: {} }], +] as const)("reports a missing %s", (feature, change) => { + expect(missingPlatformFeatures({ ...complete(), ...change })).toEqual([feature]); +}); + +it("reports every missing feature in a fixed order", () => { + expect(missingPlatformFeatures({})).toEqual([ + "secure-context", + "web-crypto", + "web-locks", + "indexeddb", + ]); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +async function gate() { + vi.resetModules(); + return (await import("../src/components/native-startup-gate")).NativeStartupGate; +} + +it("renders the app once the check passes", async () => { + vi.stubGlobal("isSecureContext", true); + vi.stubGlobal("navigator", { ...navigator, locks: { request: () => undefined } }); + vi.stubGlobal("indexedDB", { open: () => undefined }); + const Gate = await gate(); + const view = await mount(h(Gate, null, h("span", null, "Pod content"))); + expect(view.host.textContent).toBe("Pod content"); +}); + +it("shows the update screen instead of the app when a feature is missing", async () => { + vi.stubGlobal("isSecureContext", true); + vi.stubGlobal("navigator", { ...navigator, locks: undefined }); + vi.stubGlobal("indexedDB", { open: () => undefined }); + const Gate = await gate(); + const view = await mount(h(Gate, null, h("span", null, "Pod content"))); + expect(view.host.textContent).toContain("Update needed"); + expect(view.host.textContent).not.toContain("Pod content"); + expect(view.host.querySelector("main")?.dataset.missing).toBe("web-locks"); +}); + +it("renders nothing below the gate in the exported HTML", async () => { + const { renderToString } = await import("react-dom/server"); + const Gate = await gate(); + expect(renderToString(h(Gate, null, h("span", null, "Pod content")))).toBe(""); +}); diff --git a/packages/suite-native/src/capacitor.test.ts b/packages/suite-native/src/capacitor.test.ts index a103f060b..cb227793a 100644 --- a/packages/suite-native/src/capacitor.test.ts +++ b/packages/suite-native/src/capacitor.test.ts @@ -9,5 +9,6 @@ test("registers the native plugins by their native names", async () => { expect(bindings.shareIntakePlugin).toEqual({ pluginName: "SuiteShareIntake" }); expect(bindings.shareStagingPlugin).toEqual({ pluginName: "SuiteShareStaging" }); expect(bindings.secureStorePlugin).toEqual({ pluginName: "SuiteSecureStore" }); + expect(bindings.filesPlugin).toEqual({ pluginName: "SuiteFiles" }); expect(bindings.sharePlugin).toEqual({ pluginName: "Share" }); }); diff --git a/packages/suite-native/src/capacitor.ts b/packages/suite-native/src/capacitor.ts index 0af83f2d9..25529bf1d 100644 --- a/packages/suite-native/src/capacitor.ts +++ b/packages/suite-native/src/capacitor.ts @@ -1,6 +1,7 @@ // AUTHORED-BY Claude Opus 5.5 import { registerPlugin } from "@capacitor/core"; import type { AuthSessionPlugin } from "./auth-session.js"; +import type { PdfPreviewPlugin } from "./pdf-preview.js"; import type { SecureStorePlugin } from "./secure-store.js"; import type { SharePlugin } from "./share.js"; import type { ShareIntakePlugin } from "./share-intake.js"; @@ -10,6 +11,8 @@ import type { ShareStagingPlugin } from "./share-staging.js"; export const authSessionPlugin = registerPlugin("SuiteAuthSession"); export const shareIntakePlugin = registerPlugin("SuiteShareIntake"); export const shareStagingPlugin = registerPlugin("SuiteShareStaging"); +/** The Drive "Open PDF" handoff (MOB-P3 E5). */ +export const filesPlugin = registerPlugin("SuiteFiles"); export const secureStorePlugin = registerPlugin("SuiteSecureStore"); /** The `@capacitor/share` native plugin, registered by name so the suite needs no extra package. */ export const sharePlugin = registerPlugin("Share"); diff --git a/packages/suite-native/src/index.test.ts b/packages/suite-native/src/index.test.ts index a099daf22..5305f6dba 100644 --- a/packages/suite-native/src/index.test.ts +++ b/packages/suite-native/src/index.test.ts @@ -17,6 +17,9 @@ test("the root entry point exposes the runtime API without importing Capacitor", "MOBILE_CLIENT_ID_PATH", "MOBILE_REDIRECT_URI", "NativeAuthError", + "PDF_PREVIEW_CHUNK_BYTES", + "PDF_PREVIEW_MAX_BYTES", + "PdfPreviewError", "SecureStoreError", "ShareUnavailableError", "StateMismatchError", @@ -28,6 +31,7 @@ test("the root entry point exposes the runtime API without importing Capacitor", "createShareInbox", "isNativeApp", "mobileClientIdDocument", + "openPdf", "platform", "refreshTokenKey", "sanitiseFileName", diff --git a/packages/suite-native/src/index.ts b/packages/suite-native/src/index.ts index 80439c00d..2dc1c3fa3 100644 --- a/packages/suite-native/src/index.ts +++ b/packages/suite-native/src/index.ts @@ -26,6 +26,14 @@ export { SuiteNativeError, UserCancelledError, } from "./errors.js"; +export { + openPdf, + PDF_PREVIEW_CHUNK_BYTES, + PDF_PREVIEW_MAX_BYTES, + PdfPreviewError, + type PdfPreviewErrorCode, + type PdfPreviewPlugin, +} from "./pdf-preview.js"; export { type CapacitorRuntime, isNativeApp, type Platform, platform } from "./platform.js"; export { createSecureStore, diff --git a/packages/suite-native/src/pdf-preview.test.ts b/packages/suite-native/src/pdf-preview.test.ts new file mode 100644 index 000000000..2c2f21c56 --- /dev/null +++ b/packages/suite-native/src/pdf-preview.test.ts @@ -0,0 +1,111 @@ +// AUTHORED-BY Claude Opus 5.5 +import { describe, expect, it, vi } from "vitest"; +import { SuiteNativeError } from "./errors.js"; +import { + openPdf, + PDF_PREVIEW_CHUNK_BYTES, + PDF_PREVIEW_MAX_BYTES, + PdfPreviewError, + type PdfPreviewPlugin, +} from "./pdf-preview.js"; + +const pdf = (size: number) => { + const bytes = new Uint8Array(size).fill(0x41); + bytes.set([0x25, 0x50, 0x44, 0x46, 0x2d]); + return bytes; +}; + +function plugin(overrides: Partial = {}) { + return { + pdfBegin: vi.fn(async () => ({ handle: "h1" })), + pdfChunk: vi.fn(async () => undefined), + pdfOpen: vi.fn(async () => undefined), + ...overrides, + } satisfies PdfPreviewPlugin; +} + +const code = (promise: Promise) => + promise.then( + () => "resolved", + (error: unknown) => (error instanceof PdfPreviewError ? error.code : "other"), + ); + +describe("openPdf", () => { + it("sends the declared size, 1 MiB base64 chunks in order, then opens the handle", async () => { + const native = plugin(); + const bytes = pdf(PDF_PREVIEW_CHUNK_BYTES * 2 + 3); + await openPdf(native, "Report.pdf", bytes); + expect(native.pdfBegin).toHaveBeenCalledWith({ name: "Report.pdf", size: bytes.byteLength }); + const chunks = native.pdfChunk.mock.calls.map(([options]) => options); + expect(chunks.map((chunk) => chunk.handle)).toEqual(["h1", "h1", "h1"]); + const decoded = chunks.map((chunk) => Buffer.from(chunk.data, "base64")); + expect(decoded.map((chunk) => chunk.byteLength)).toEqual([ + PDF_PREVIEW_CHUNK_BYTES, + PDF_PREVIEW_CHUNK_BYTES, + 3, + ]); + expect(Buffer.concat(decoded).equals(Buffer.from(bytes))).toBe(true); + expect(native.pdfOpen).toHaveBeenCalledWith({ handle: "h1" }); + }); + + it("refuses an oversized file before anything crosses the bridge", async () => { + const native = plugin(); + const oversized = { byteLength: PDF_PREVIEW_MAX_BYTES + 1 } as Uint8Array; + expect(await code(openPdf(native, "a.pdf", oversized))).toBe("too-large"); + expect(native.pdfBegin).not.toHaveBeenCalled(); + }); + + it("accepts a file of exactly the cap", async () => { + const native = plugin(); + await openPdf(native, "a.pdf", pdf(PDF_PREVIEW_MAX_BYTES)); + expect(native.pdfChunk).toHaveBeenCalledTimes(PDF_PREVIEW_MAX_BYTES / PDF_PREVIEW_CHUNK_BYTES); + }); + + it.each([ + ["HTML", new TextEncoder().encode("