mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
fix(native): a widget tap opens its page every time, also after iOS ended the page
A widget or Live Activity tap reached the UI as Capacitor's appUrlOpen, or as the launch URL. When iOS had ended the web view's process in the background, the event went to the old page's listeners and was lost, and the reloaded page found the same link in getLaunchUrl that it had already followed once and skipped it: the second tap on the packing widget opened the start page. The binary now takes com.liketrek.trek://navigate links itself and hands them over like a quick action, held until the UI takes it and kept for a page that is still loading. It reports the feature appLinks, and the UI only follows the link through Capacitor for binaries without it.
This commit is contained in:
@@ -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/<path>: 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/<path>, 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 }
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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/<path>.
|
||||
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/<path>. 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).
|
||||
|
||||
@@ -211,9 +211,12 @@ export interface TrekShellPlugin {
|
||||
): Promise<{ remove: () => Promise<void> }>;
|
||||
/** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */
|
||||
setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>;
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user