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(); }); });