From 2f03554a60d3a21dd59fe38c24aea8e60d61a307 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 10 Nov 2026 00:21:28 +0100 Subject: [PATCH] feat(native): files open in the system viewer instead of the share sheet A PDF, image or document opened in the app went straight to the share sheet. It now opens in Quick Look on iOS, to page through and zoom with its own share button, and in the app that views its type on Android. An app without the viewer (feature filePreview), or a type nothing on the device shows, gets the share sheet as before. --- .../com/liketrek/trek/TrekShellPlugin.java | 40 +++++++++++++ client/ios/App/App/TrekShellPlugin.swift | 57 ++++++++++++++++++- client/src/native/nativeFiles.test.ts | 39 ++++++++++++- client/src/native/nativeFiles.ts | 34 +++++++++++ client/src/native/trekShell.ts | 10 ++++ client/src/utils/fileDownload.ts | 8 +-- .../unit/utils/fileDownload.native.test.ts | 10 ++-- 7 files changed, 188 insertions(+), 10 deletions(-) diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index 67d77f140..fa24e4c2d 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -1,6 +1,7 @@ package com.liketrek.trek; import android.Manifest; +import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; import android.os.Build; @@ -70,6 +71,7 @@ public class TrekShellPlugin extends Plugin { "keyCommands", "windowLayout", "clientCertificate", + "filePreview", }; /** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */ @@ -267,6 +269,44 @@ public class TrekShellPlugin extends Plugin { call.resolve(); } + /** + * Opens a file the UI wrote to the cache in the app that views it, a PDF in + * the system's PDF viewer, instead of the share sheet. "unsupported" where no + * app on the device views the type; the UI then hands it to the share sheet. + */ + @PluginMethod + public void previewFile(PluginCall call) { + String path = call.getString("path"); + Uri parsed = path == null ? null : Uri.parse(path); + java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null; + java.io.File cache = getContext().getCacheDir(); + boolean inCache; + try { + inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator); + } catch (java.io.IOException e) { + inCache = false; + } + if (!inCache || !file.isFile()) { + call.reject("a file in the app's cache is required", "INVALID"); + return; + } + String type = call.getString("mimeType", "application/octet-stream"); + Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file); + Intent view = new Intent(Intent.ACTION_VIEW) + .setDataAndType(content, type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); + getActivity().runOnUiThread(() -> { + JSObject result = new JSObject(); + try { + getActivity().startActivity(view); + result.put("result", "shown"); + } catch (ActivityNotFoundException e) { + result.put("result", "unsupported"); + } + call.resolve(result); + }); + } + @PluginMethod public void getServer(PluginCall call) { String url = TrekServerStore.getUrl(getContext()); diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index aaa87517d..d32b71d09 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -11,6 +11,7 @@ import ActivityKit #endif import LocalAuthentication import PassKit +import QuickLook import UIKit import WebKit @@ -55,6 +56,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -66,7 +68,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. static var features: [String] { - var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"] + var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"] if canAddWalletPasses { names.append("wallet") } if TrekWatchLink.isAvailable { names.append("watch") } return names @@ -562,6 +564,36 @@ extension TrekShellPlugin { /// already there opens in Wallet instead of being offered twice. Resolves with /// `result` added, opened or cancelled; rejects INVALID for bytes that are no /// pass and UNAVAILABLE where Wallet cannot take one. + /// Kept while Quick Look shows a file: the controller holds its data source weakly. + private var previewSource: TrekFilePreviewSource? + + /// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through, + /// an image, a document, with the system's own share button. "unsupported" for + /// a file Quick Look cannot show, which the UI then hands to the share sheet. + @objc func previewFile(_ call: CAPPluginCall) { + guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL, + FileManager.default.fileExists(atPath: url.path) else { + call.reject("a file in the app's cache is required", "INVALID") + return + } + DispatchQueue.main.async { [weak self] in + guard let self, let presenter = self.bridge?.viewController else { + call.reject("nothing to show the preview from", "UNAVAILABLE") + return + } + guard QLPreviewController.canPreview(url as QLPreviewItem) else { + call.resolve(["result": "unsupported"]) + return + } + let source = TrekFilePreviewSource(url: url, title: call.getString("title")) + self.previewSource = source + let preview = QLPreviewController() + preview.dataSource = source + presenter.present(preview, animated: true) + call.resolve(["result": "shown"]) + } + } + @objc func addToWallet(_ call: CAPPluginCall) { guard let base64 = call.getString("data"), let data = Data(base64Encoded: base64), let pass = try? PKPass(data: data) else { @@ -957,3 +989,26 @@ enum TrekSharedKeychain { return SecItemAdd(q as CFDictionary, nil) == errSecSuccess } } + +/// The one file Quick Look shows, under the name the user knows it by. +final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource { + private let item: Item + + init(url: URL, title: String?) { + item = Item(url: url, title: title) + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item } + + final class Item: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String?) { + previewItemURL = url + previewItemTitle = title + } + } +} diff --git a/client/src/native/nativeFiles.test.ts b/client/src/native/nativeFiles.test.ts index ecf7df911..08f5b147e 100644 --- a/client/src/native/nativeFiles.test.ts +++ b/client/src/native/nativeFiles.test.ts @@ -1,10 +1,14 @@ import { Directory, Filesystem } from '@capacitor/filesystem'; import { Share } from '@capacitor/share'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { cacheFileName, fileNameFromUrl, shareFile } from './nativeFiles'; +import { hasNativeFeature } from './capabilities'; +import { cacheFileName, fileNameFromUrl, openNativeFile, shareFile } from './nativeFiles'; +import { TrekShell } from './trekShell'; vi.mock('@capacitor/filesystem', () => ({ Directory: { Cache: 'CACHE' }, Filesystem: { writeFile: vi.fn() } })); vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } })); +vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() })); +vi.mock('./trekShell', () => ({ TrekShell: { previewFile: vi.fn() } })); beforeEach(() => { vi.mocked(Filesystem.writeFile).mockReset().mockResolvedValue({ uri: 'file:///cache/trip.pdf' }); @@ -51,3 +55,36 @@ describe('shareFile', () => { await expect(shareFile(new Blob(['x']), 'x.txt')).rejects.toThrow('No activity found'); }); }); + +describe('openNativeFile', () => { + const pdf = () => new Blob(['%PDF'], { type: 'application/pdf' }); + + it('FE-NATIVE-FILES-OPEN-001: shows the file in the system viewer where the app has one, not in the share sheet', async () => { + vi.mocked(hasNativeFeature).mockReturnValue(true); + vi.mocked(TrekShell.previewFile).mockResolvedValue({ result: 'shown' }); + + await openNativeFile(pdf(), 'trip.pdf'); + + expect(TrekShell.previewFile).toHaveBeenCalledWith({ + path: 'file:///cache/trip.pdf', + mimeType: 'application/pdf', + title: 'trip.pdf', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); + + it('FE-NATIVE-FILES-OPEN-002: falls back to the share sheet for an older app, a type nothing shows, or a viewer that fails', async () => { + vi.mocked(hasNativeFeature).mockReturnValue(false); + await openNativeFile(pdf(), 'trip.pdf'); + expect(TrekShell.previewFile).not.toHaveBeenCalled(); + + vi.mocked(hasNativeFeature).mockReturnValue(true); + vi.mocked(TrekShell.previewFile).mockResolvedValueOnce({ result: 'unsupported' }); + await openNativeFile(pdf(), 'trip.pdf'); + vi.mocked(TrekShell.previewFile).mockRejectedValueOnce(new Error('no window')); + await openNativeFile(pdf(), 'trip.pdf'); + + expect(Share.share).toHaveBeenCalledTimes(3); + expect(Share.share).toHaveBeenCalledWith({ files: ['file:///cache/trip.pdf'] }); + }); +}); diff --git a/client/src/native/nativeFiles.ts b/client/src/native/nativeFiles.ts index 98639cfd3..7cddbe65a 100644 --- a/client/src/native/nativeFiles.ts +++ b/client/src/native/nativeFiles.ts @@ -1,6 +1,8 @@ import { Directory, Filesystem } from '@capacitor/filesystem'; import { Share } from '@capacitor/share'; import { toBase64 } from './blobBase64'; +import { hasNativeFeature } from './capabilities'; +import { TrekShell } from './trekShell'; import { addToWallet, isWalletPassName } from './wallet'; /** Readable, but safe as a single path segment inside the cache directory. */ @@ -29,11 +31,43 @@ export function fileNameFromUrl(url: string): string { export async function shareFile(blob: Blob, filename: string): Promise { // A Wallet pass goes to Apple Wallet's own sheet, not to the generic share sheet. if (isWalletPassName(filename) && (await addToWallet(blob))) return; + await shareCached(await writeToCache(blob, filename)); +} + +/** + * Opens a file to look at it: a PDF to page through, an image, a document, in + * the system's viewer (Quick Look on iOS, the viewing app on Android). An app + * without the viewer, or a type nothing on the device shows, gets the share + * sheet as before, which still previews and saves it. + */ +export async function openNativeFile(blob: Blob, filename: string): Promise { + if (isWalletPassName(filename) && (await addToWallet(blob))) return; + const uri = await writeToCache(blob, filename); + if (hasNativeFeature('filePreview')) { + try { + const { result } = await TrekShell.previewFile({ + path: uri, + mimeType: blob.type || 'application/octet-stream', + title: filename, + }); + if (result === 'shown') return; + } catch { + /* the viewer could not open it: the share sheet still can */ + } + } + await shareCached(uri); +} + +async function writeToCache(blob: Blob, filename: string): Promise { const { uri } = await Filesystem.writeFile({ path: cacheFileName(filename), data: await toBase64(blob), directory: Directory.Cache, }); + return uri; +} + +async function shareCached(uri: string): Promise { try { await Share.share({ files: [uri] }); } catch (err) { diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index be4616ad9..78869c9bc 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -103,6 +103,16 @@ export interface TrekShellPlugin { storeFinish(options: { transactionId: string }): Promise; /** iOS, feature wallet: adds a .pkpass (base64) to Apple Wallet, or opens it there when it is already in. */ addToWallet(options: { data: string }): Promise<{ result: 'added' | 'opened' | 'cancelled' }>; + /** + * Shows a file written to the app's cache (a file:// path) in the system's + * viewer: Quick Look on iOS, the app that views the type on Android. Feature + * "filePreview"; "unsupported" where nothing on the device shows the type. + */ + previewFile(options: { + path: string; + mimeType: string; + title?: string; + }): Promise<{ result: 'shown' | 'unsupported' }>; /** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */ storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>; /** Feature watch: what the Apple Watch app shows (native/watch.ts), as JSON, sent on through WatchConnectivity. */ diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index cf41f732d..b9ca0bb6e 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -2,7 +2,7 @@ import { getCachedBlob } from '../db/offlineDb' import { isEffectivelyOffline } from '../sync/networkMode' import { isIosStandalone } from './iosDevice' import { isNativeApp } from '../native/platform' -import { fileNameFromUrl, shareFile } from '../native/nativeFiles' +import { fileNameFromUrl, openNativeFile, shareFile } from '../native/nativeFiles' import { reportNativeFailure } from '../native/reportFailure' // MIME types safe to open inline (will not execute script in any browser). @@ -118,9 +118,9 @@ export async function downloadFile(url: string, filename?: string): Promise { const blob = await getFileBlob(url) - // The share sheet previews PDFs and images too, and a blob tab has nowhere - // to open inside the app. - if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url)) + // A blob tab has nowhere to open inside the app: the system's viewer shows it, + // or the share sheet where the app has none. + if (isNativeApp()) return openNativeFile(blob, filename ?? fileNameFromUrl(url)) const blobUrl = URL.createObjectURL(blob) // Force download for MIME types that can execute script when rendered inline diff --git a/client/tests/unit/utils/fileDownload.native.test.ts b/client/tests/unit/utils/fileDownload.native.test.ts index 203f0b771..dd995d778 100644 --- a/client/tests/unit/utils/fileDownload.native.test.ts +++ b/client/tests/unit/utils/fileDownload.native.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { shareFile } from '../../../src/native/nativeFiles'; +import { openNativeFile, shareFile } from '../../../src/native/nativeFiles'; import { downloadBlob, downloadFile, openFile } from '../../../src/utils/fileDownload'; vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() })); @@ -8,13 +8,14 @@ vi.mock('../../../src/native/nativeFiles', async () => { const actual = await vi.importActual( '../../../src/native/nativeFiles' ); - return { ...actual, shareFile: vi.fn() }; + return { ...actual, shareFile: vi.fn(), openNativeFile: vi.fn() }; }); const pdf = new Blob(['%PDF'], { type: 'application/pdf' }); beforeEach(() => { vi.mocked(shareFile).mockReset().mockResolvedValue(); + vi.mocked(openNativeFile).mockReset().mockResolvedValue(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, blob: () => Promise.resolve(pdf) })); }); @@ -42,8 +43,9 @@ describe('fileDownload inside the native app', () => { expect(shareFile).toHaveBeenLastCalledWith(pdf, 'ticket A.pdf'); }); - it('opens files through the share sheet too, since there is no tab to open them in', async () => { + it('opens files in the system viewer, since there is no tab to open them in', async () => { await openFile('/api/trips/1/files/2/download'); - expect(shareFile).toHaveBeenLastCalledWith(pdf, 'download'); + expect(openNativeFile).toHaveBeenLastCalledWith(pdf, 'download'); + expect(shareFile).not.toHaveBeenCalled(); }); });