diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index 2666a7df0..4dfa23c5b 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -17,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { for context in connectionOptions.urlContexts where context.url.isFileURL { TrekSharedFiles.receive(context.url) } + // Launched by a tap on a widget or a Live Activity: held like a quick action. + // Capacitor still hears of the link, the UI ignores it there (feature appLinks). + for context in connectionOptions.urlContexts { + if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) } + } SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) } @@ -40,7 +45,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // com.liketrek.trek://share: the share sheet left an item for the import. let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" } if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() } - let others = URLContexts.subtracting(files).subtracting(handoffs) + // com.liketrek.trek://navigate/: a widget or Live Activity tap. Through + // Capacitor it was an event a page reloading after the system ended its process + // never heard, and that page then took the same link for one already followed. + let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil } + for context in appLinks { + if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) } + } + let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks) if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } } @@ -104,10 +116,25 @@ enum TrekQuickActions { static func open(_ item: UIApplicationShortcutItem) -> Bool { guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false } - if let onOpen { onOpen(path) } else { pending = path } + open(path: path) return true } + /// A path to open: at once when the UI listens, else held for it to take. + /// Widget and Live Activity taps come this way too, so each is followed once. + static func open(path: String) { + if let onOpen { onOpen(path) } else { pending = path } + } + + /// The path in com.liketrek.trek://navigate/, the link widgets and Live Activities open. + static func appLinkPath(_ url: URL) -> String? { + let prefix = "com.liketrek.trek://navigate" + guard url.absoluteString.hasPrefix(prefix) else { return nil } + let path = String(url.absoluteString.dropFirst(prefix.count)) + if path.isEmpty { return "/" } + return path.hasPrefix("/") ? path : nil + } + /// The action the app was launched with, once. static func take() -> String? { defer { pending = nil } diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index e022ee418..b7681e96b 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -69,7 +69,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", "filePreview"] + var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"] if canAddWalletPasses { names.append("wallet") } if TrekWatchLink.isAvailable { names.append("watch") } return names @@ -94,8 +94,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr TrekKeyCommands.onPress = { [weak self] id in self?.notifyListeners("keyCommand", data: ["id": id]) } + // Kept until a listener takes it: a tap while the page reloads reaches the new page. TrekQuickActions.onOpen = { [weak self] path in - DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) } + DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) } } } diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index 219c53a63..5cdd8c307 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeTopOverlay } from '../mobile/overlayStack'; import { isEffectivelyOffline } from '../sync/networkMode'; -import { checkAppUpdate } from './capabilities'; +import { checkAppUpdate, hasNativeFeature } from './capabilities'; import { installNativeGeolocation } from './geolocation'; import { prepareKeyboardFields } from './keyboardFields'; import { scrollToTop, startNativeApp } from './nativeApp'; @@ -28,6 +28,7 @@ vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() })); vi.mock('./capabilities', () => ({ checkAppUpdate: vi.fn(async () => false), loadNativeCapabilities: vi.fn(async () => ({})), + hasNativeFeature: vi.fn(() => false), })); vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() })); vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); @@ -51,6 +52,7 @@ beforeEach(() => { vi.mocked(isNativeIos).mockReturnValue(false); vi.mocked(isEffectivelyOffline).mockReturnValue(false); vi.mocked(keepOfflineShellFresh).mockResolvedValue(); + vi.mocked(hasNativeFeature).mockReturnValue(false); document.documentElement.classList.remove('dark'); document.documentElement.removeAttribute('data-native-app'); document.documentElement.removeAttribute('data-native-ios'); @@ -210,6 +212,18 @@ describe('startNativeApp', () => { expect(openPushTarget).not.toHaveBeenCalled(); }); + it('leaves widget links to the binary when it hands them over as quick actions', async () => { + sessionStorage.clear(); + vi.mocked(hasNativeFeature).mockImplementation((feature) => feature === 'appLinks'); + vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'com.liketrek.trek://navigate/trips/5?tab=listen' }); + startNativeApp(); + await vi.waitFor(() => expect(hasNativeFeature).toHaveBeenCalledWith('appLinks')); + await Promise.resolve(); + expect(App.getLaunchUrl).not.toHaveBeenCalled(); + expect(vi.mocked(App.addListener).mock.calls.some(([name]) => name === 'appUrlOpen')).toBe(false); + expect(openPushTarget).not.toHaveBeenCalled(); + }); + it('leaves the start page alone for a launch without a TREK link', async () => { sessionStorage.clear(); vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' }); diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index adec0cf89..a1d96762a 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import { closeTopOverlay } from '../mobile/overlayStack'; import { useSettingsStore } from '../store/settingsStore'; import { isEffectivelyOffline } from '../sync/networkMode'; -import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; +import { checkAppUpdate, hasNativeFeature, loadNativeCapabilities } from './capabilities'; import { installNativeGeolocation } from './geolocation'; import { fadeInImages } from './imageFade'; import { keepFieldInView } from './keepFieldInView'; @@ -54,13 +54,18 @@ export function startNativeApp(): void { // A quick action on the app icon opens its page, also the one the app was launched with. void loadNativeCapabilities().then(() => startQuickActions()); - // A widget or Live Activity tap opens com.liketrek.trek://navigate/. - void App.addListener('appUrlOpen', ({ url }) => { - const prefix = `${APP_URL_SCHEME}://navigate`; - if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/'); + // A widget or Live Activity tap opens com.liketrek.trek://navigate/. A + // binary with the feature appLinks hands it over as a quick action instead, + // held until taken, so it is only followed here for older binaries. + void loadNativeCapabilities().then(() => { + if (hasNativeFeature('appLinks')) return; + void App.addListener('appUrlOpen', ({ url }) => { + const prefix = `${APP_URL_SCHEME}://navigate`; + if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/'); + }); + // A tap that launched the app comes as the launch URL, not as appUrlOpen. + void openLaunchLink(); }); - // A tap that launched the app comes as the launch URL, not as appUrlOpen. - void openLaunchLink(); // A tap on the status bar scrolls the list under it back to the top, as in // every iOS app (the status-bar plugin fires statusTap on iOS). diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 23319ddc2..dac4c60b7 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -211,9 +211,12 @@ export interface TrekShellPlugin { ): Promise<{ remove: () => Promise }>; /** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */ setQuickActions(options: { items: NativeQuickAction[] }): Promise; - /** Feature quickActions: the action the app was launched with, before the UI ran. */ + /** + * Feature quickActions: the action the app was launched with, before the UI ran. + * With the feature appLinks also a widget or Live Activity tap, as its path. + */ takeQuickAction(): Promise<{ path: string | null }>; - /** Feature quickActions: an action chosen while the app was running. */ + /** Feature quickActions: an action chosen (with appLinks: a widget tapped) while the app was running. */ addListener( event: 'quickAction', listener: (payload: { path: string }) => void