diff --git a/RoadFlare/RoadFlare/RoadFlare.entitlements b/RoadFlare/RoadFlare/RoadFlare.entitlements index 0e2e448..e40a278 100644 --- a/RoadFlare/RoadFlare/RoadFlare.entitlements +++ b/RoadFlare/RoadFlare/RoadFlare.entitlements @@ -4,6 +4,7 @@ com.apple.developer.associated-domains + applinks:roadflare.app webcredentials:roadflare.app diff --git a/RoadFlare/RoadFlare/RoadFlareApp.swift b/RoadFlare/RoadFlare/RoadFlareApp.swift index 9dd0c8f..884e01c 100644 --- a/RoadFlare/RoadFlare/RoadFlareApp.swift +++ b/RoadFlare/RoadFlare/RoadFlareApp.swift @@ -35,6 +35,14 @@ struct RoadFlareApp: App { // the parsed driver intent into the drivers tab. appState.handleIncomingURL(url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + // Universal Link dispatch (e.g. `https://roadflare.app/share/d/npub1...`). + // Symmetric with `.onOpenURL` above; AppState extracts the + // `webpageURL` and routes through the same `handleIncomingURL` + // path. Requires `applinks:roadflare.app` in entitlements + // plus the AASA file hosted on roadflare.app — see issue #63. + appState.handleIncomingUserActivity(activity) + } } } } diff --git a/RoadFlare/RoadFlareCore/ViewModels/AppState.swift b/RoadFlare/RoadFlareCore/ViewModels/AppState.swift index 1a35b9f..8a43e60 100644 --- a/RoadFlare/RoadFlareCore/ViewModels/AppState.swift +++ b/RoadFlare/RoadFlareCore/ViewModels/AppState.swift @@ -741,6 +741,20 @@ public final class AppState { selectedTab = 1 // Drivers tab — see MainTabView.swift } + /// Route an incoming Universal Link user activity into the app. + /// + /// Universal Links from `https://roadflare.app/share/...` arrive as an + /// `NSUserActivity` of type `NSUserActivityTypeBrowsingWeb` with the URL + /// in `webpageURL`. Extract the URL and dispatch through the same path + /// as custom-scheme URLs — `DriverQRCodeParser` already accepts the + /// `https://roadflare.app/share/d/` and `/share/r/` shapes. + /// Requires `applinks:roadflare.app` in entitlements and the AASA file + /// at `https://roadflare.app/.well-known/apple-app-site-association`. + public func handleIncomingUserActivity(_ activity: NSUserActivity) { + guard let url = activity.webpageURL else { return } + handleIncomingURL(url) + } + // MARK: - Ping Driver Hint /// Entry point for the "Ping a Driver" CTA in the ride flow. Switches to diff --git a/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift b/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift index 1aa957c..7e0e2cc 100644 --- a/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift +++ b/RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift @@ -85,6 +85,74 @@ struct HandleIncomingURLTests { #expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: "Driver Dan")) } + @MainActor + @Test func universalLinkDriverShareRoutesToDriversTab() throws { + // Universal Links from `https://roadflare.app/share/d/` arrive + // via `.onContinueUserActivity(NSUserActivityTypeBrowsingWeb)`. + // `handleIncomingUserActivity` extracts `webpageURL` and dispatches + // through the same `handleIncomingURL` path used by the custom scheme. + // Issue #63. + let appState = AppState() + appState.selectedTab = 0 + let npub = try makeNpub(hex: String(repeating: "6f", count: 32)) + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + activity.webpageURL = URL(string: "https://roadflare.app/share/d/\(npub)?name=Universal%20Linker") + + appState.handleIncomingUserActivity(activity) + + #expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: "Universal Linker")) + #expect(appState.selectedTab == 1) + } + + @MainActor + @Test func universalLinkRiderShareRoutesToDriversTab() throws { + // `/share/r/` URLs go through the same parser path — the + // parser extracts the embedded npub regardless of `d` vs `r` segment. + // Routing them to the drivers tab is consistent with the parser's + // existing behavior for the matching custom-scheme inputs. + let appState = AppState() + appState.selectedTab = 0 + let npub = try makeNpub(hex: String(repeating: "7a", count: 32)) + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + activity.webpageURL = URL(string: "https://roadflare.app/share/r/\(npub)") + + appState.handleIncomingUserActivity(activity) + + #expect(appState.pendingDriverDeepLink == ParsedDriverQRCode(pubkeyInput: npub, scannedName: nil)) + #expect(appState.selectedTab == 1) + } + + @MainActor + @Test func userActivityWithoutWebpageURLIsDropped() { + // If the activity arrives without a `webpageURL` (shouldn't happen + // for `NSUserActivityTypeBrowsingWeb` in practice, but defend anyway), + // nothing happens — tab stays put, no deep-link intent populated. + let appState = AppState() + appState.selectedTab = 0 + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + + appState.handleIncomingUserActivity(activity) + + #expect(appState.pendingDriverDeepLink == nil) + #expect(appState.selectedTab == 0) + } + + @MainActor + @Test func universalLinkUnknownPathIsDropped() { + // Unknown roadflare.app paths (no embedded npub) drop silently — + // the AASA `components` filter should keep them out of the app in + // production, but the in-app parser is the second line of defense. + let appState = AppState() + appState.selectedTab = 0 + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + activity.webpageURL = URL(string: "https://roadflare.app/about") + + appState.handleIncomingUserActivity(activity) + + #expect(appState.pendingDriverDeepLink == nil) + #expect(appState.selectedTab == 0) + } + @MainActor @Test func navigationIntentSurvivesIdentityReplacementWhenNoKeypair() async throws { // Cold-start regression: when a `roadflared:` URL arrives before the @@ -119,4 +187,29 @@ struct HandleIncomingURLTests { #expect(appState.pendingDriverDeepLink != nil, "Deep link must survive identity replacement when no prior keypair existed") #expect(appState.selectedTab == 1, "Tab selection must survive identity replacement when no prior keypair existed") } + + @MainActor + @Test func userActivityNavigationIntentSurvivesIdentityReplacementWhenNoKeypair() async throws { + // Cold-start regression parity for Universal Links: same invariant as + // `navigationIntentSurvivesIdentityReplacementWhenNoKeypair` above, but + // exercised through `handleIncomingUserActivity`. Universal Links are + // the long-term primary share path (per ADR-0012 + issue #63), so the + // pre-onboarding tap path needs explicit coverage on this entry point + // — not just the `roadflared:` shim. See PR #66 for the precedent of + // pinning parallel regression coverage on each new entry seam. + let appState = AppState() + let npub = try makeNpub(hex: String(repeating: "8e", count: 32)) + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + activity.webpageURL = URL(string: "https://roadflare.app/share/d/\(npub)") + + appState.handleIncomingUserActivity(activity) + #expect(appState.pendingDriverDeepLink != nil) + #expect(appState.selectedTab == 1) + #expect(appState.keypair == nil) + + await appState.logout() + + #expect(appState.pendingDriverDeepLink != nil, "Universal Link intent must survive identity replacement when no prior keypair existed") + #expect(appState.selectedTab == 1, "Tab selection must survive identity replacement when no prior keypair existed") + } }