Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RoadFlare/RoadFlare/RoadFlare.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:roadflare.app</string>
<string>webcredentials:roadflare.app</string>
</array>
</dict>
Expand Down
8 changes: 8 additions & 0 deletions RoadFlare/RoadFlare/RoadFlareApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
14 changes: 14 additions & 0 deletions RoadFlare/RoadFlareCore/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<npub>` and `/share/r/<npub>` 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
Expand Down
93 changes: 93 additions & 0 deletions RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<npub>` 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/<npub>` 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
Expand Down Expand Up @@ -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")
}
}