Skip to content

feat(app): Universal Links for roadflare.app share pages (#63)#113

Merged
variablefate merged 2 commits into
mainfrom
feat/universal-links-63
May 11, 2026
Merged

feat(app): Universal Links for roadflare.app share pages (#63)#113
variablefate merged 2 commits into
mainfrom
feat/universal-links-63

Conversation

@variablefate
Copy link
Copy Markdown
Owner

Closes #63.

Summary

Tap https://roadflare.app/share/d/<npub> or /share/r/<npub> on a
device with RoadFlare installed → app opens with the Add Driver sheet
pre-filled. Falls back to the existing share page (with App Store CTA)
when the app isn't installed — the long-term primary share UX, replacing
the custom roadflared: scheme from PR #66 as the marketing-link path
once the AASA file ships.

Architecture

This is the deferred Universal Links work anticipated by ADR-0012
("the AppState.handleIncomingURL plumbing is reusable when Universal
Links land"). No new ADR — the routing pattern is the same one the ADR
documents, just with a second SwiftUI scene modifier feeding into it.

Routing pattern: .onContinueUserActivityAppState.handleIncomingUserActivity → existing handleIncomingURL → existing parser → existing pendingDriverDeepLink → existing DriversTab observer → existing AddDriverSheet(prefill:).

Symmetric with the .onOpenURL modifier added in PR #66. The
DriverQRCodeParser.parseURLOrEmbeddedNpub arm already accepts the
https://roadflare.app/share/... URL shape, so this PR is purely
routing-layer wiring.

What's in the diff

File Change
RoadFlare/RoadFlare/RoadFlare.entitlements Add applinks:roadflare.app alongside the existing webcredentials: entry
RoadFlare/RoadFlare/RoadFlareApp.swift Add .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) modifier mirroring .onOpenURL
RoadFlare/RoadFlareCore/ViewModels/AppState.swift Add handleIncomingUserActivity(_ activity: NSUserActivity) — thin wrapper that extracts webpageURL and routes through handleIncomingURL
RoadFlare/RoadFlareTests/AppState/HandleIncomingURLTests.swift 4 new Swift Testing cases

Total: 4 files, 91 insertions, 0 deletions. All additive — no existing signatures change. No parser changes; no view changes; no SDK changes.

⚠️ AASA prerequisite — required for production

The Associated Domains entitlement is only half of Universal Links.
Apple's CDN must be able to fetch a valid apple-app-site-association
file from https://roadflare.app/.well-known/apple-app-site-association

or this PR is a no-op in production.

The site work is tracked separately at roadflare-site#4 (related draft). The AASA JSON should look like:

{
  \"applinks\": {
    \"details\": [
      {
        \"appIDs\": [\"6Y98438M9X.com.roadflare.RoadFlare\"],
        \"components\": [
          { \"/\": \"/share/d/*\", \"comment\": \"Driver profile share\" },
          { \"/\": \"/share/r/*\", \"comment\": \"Rider profile share\" }
        ]
      }
    ]
  },
  \"webcredentials\": {
    \"apps\": [\"6Y98438M9X.com.roadflare.RoadFlare\"]
  }
}

Team ID 6Y98438M9X and Bundle ID com.roadflare.RoadFlare confirmed against RoadFlare.xcodeproj/project.pbxproj. The webcredentials section preserves the existing Sign in with Apple Passwords integration.

Notes for shipping:

  • Must be served with Content-Type: application/json (GitHub Pages does this by default for .well-known/ static files).
  • Apple's CDN caches AASA aggressively. New installs pull fresh; existing installs may need Settings → Developer → Associated Domains Development to re-fetch during testing.
  • HTTPS is required (it is — roadflare.app is HTTPS-only).

Tests

4 new tests in HandleIncomingURLTests:

  • universalLinkDriverShareRoutesToDriversTabhttps://roadflare.app/share/d/<npub>?name=... populates pendingDriverDeepLink with the parsed npub + display name, switches selectedTab to drivers
  • universalLinkRiderShareRoutesToDriversTab/share/r/<npub> goes through the same parser path (parser is segment-agnostic; existing DriverLookupDraftTests confirms this is consistent with the rest of the codebase)
  • userActivityWithoutWebpageURLIsDroppedNSUserActivity without a webpageURL is a no-op
  • universalLinkUnknownPathIsDroppedhttps://roadflare.app/about (no embedded npub) is dropped silently

The tests construct synthetic NSUserActivity instances with the browsing-web type and exercise the AppState entry point directly. The SwiftUI .onContinueUserActivity modifier itself is a one-liner that just calls appState.handleIncomingUserActivity(activity) and isn't unit-testable without a host app + simulator, but it's covered by the manual test plan below.

Verification

xcodebuild -scheme RoadFlare -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' build
** BUILD SUCCEEDED **

xcodebuild -scheme RoadFlareTests -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO test
** TEST SUCCEEDED **
Test run with 295 tests in 35 suites passed

HandleIncomingURLTests itself: 11 tests passed (7 pre-existing + 4 new).

Manual test checklist (run after merge + AASA is live)

  • Confirm AASA fetch: curl -i https://roadflare.app/.well-known/apple-app-site-association returns 200 with Content-Type: application/json and the JSON above
  • Install fresh App Store build (delete TestFlight first if installed)
  • Long-press a https://roadflare.app/share/d/npub1... link in Messages → "Open in RoadFlare" option appears
  • Tap the link → app opens, Drivers tab selected, Add Driver sheet pre-filled with that npub
  • Repeat the cold-start case (force-quit between attempts) — the AppState.pendingDriverDeepLink + DriversTab .onChange(of:initial:) path from ADR-0012 handles this
  • Tap a https://roadflare.app/about link → opens in Safari (no embedded npub, AASA components filter doesn't claim it)
  • Existing roadflared:npub1... flow from PR feat(app): register roadflared: URL scheme for driver-share deep links #66 still works unchanged

Two-app partitioning

Same partitioning notes from PR #66 apply: rider shares (/share/r/*) currently route to the rider app's Add Driver sheet (consistent with existing parser + DriverLookupDraft behavior). Once a dedicated iOS driver app exists, the AASA details array can be split per app — the apple-app-site-association format supports multiple appID entries.

🤖 Generated with Claude Code

Closes #63.

Add the iOS-side plumbing so `https://roadflare.app/share/d/<npub>` and
`/share/r/<npub>` URLs open directly in RoadFlare via Universal Links:

- `applinks:roadflare.app` in the Associated Domains entitlement
- `.onContinueUserActivity(NSUserActivityTypeBrowsingWeb)` modifier in
  `RoadFlareApp`, symmetric with the existing `.onOpenURL` modifier
- `AppState.handleIncomingUserActivity(_:)` extracts `webpageURL` and
  dispatches through the existing `handleIncomingURL` → parser → Add
  Driver intent path from ADR-0012/PR #66

The `DriverQRCodeParser.parseURLOrEmbeddedNpub` arm already accepts the
`https://roadflare.app/share/...` URL shape, so no parser changes were
needed; this PR is purely the routing layer.

Hosting the AASA file at `https://roadflare.app/.well-known/apple-app-site-association`
is a prerequisite for the entitlement to take effect in production. See
the PR description for the JSON template and roadflare-site cross-link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@variablefate variablefate marked this pull request as ready for review May 11, 2026 21:17
…ation

PR #66's pass-2 review established the pattern of adding a parallel
`navigationIntentSurvivesIdentityReplacementWhenNoKeypair`-style
regression test on each new deep-link entry point. The original PR
landed coverage only for the `handleIncomingURL` shim — but Universal
Links will be the more common production entry path once the AASA
ships, so the pre-onboarding survival invariant deserves explicit
coverage on `handleIncomingUserActivity` too.

The new test mirrors the existing same-named test exactly: arrives via
the new entry point with no prior keypair, asserts intent state lands,
then runs `logout()` (which exercises `prepareForIdentityReplacement`)
and asserts both `pendingDriverDeepLink` and `selectedTab` survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@variablefate variablefate merged commit ce0d263 into main May 11, 2026
@variablefate variablefate deleted the feat/universal-links-63 branch May 11, 2026 21:52
variablefate added a commit that referenced this pull request May 11, 2026
Cleanup miss from PR #113 (`ce0d263`): the parity-regression test added
in that PR referenced `ADR-0012` for the roadflared/Universal-Links
routing pattern, but the ADR was renumbered to `ADR-0019` in PR #112
(`7c08ac8`) while #113 was in flight. The squash merge auto-resolved
existing `ADR-0012` references in this file via 3-way merge, but
couldn't catch a brand-new line added on the branch.

`decisions/0012-coordinator-ride-identity-cache.md` is now an unrelated
ADR, so the stale ref doesn't just point at the wrong number — it
points at the wrong subject.

Co-authored-by: variablefate <variablefate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
variablefate added a commit to variablefate/roadflare-site that referenced this pull request May 12, 2026
* feat(aasa): add applinks for Universal Links to driver/rider share pages

Adds the `applinks` block to the existing AASA file so iOS recognizes
`https://roadflare.app/share/d/<npub>` and `/share/r/<npub>` URLs as
deep-link targets for the RoadFlare app (App ID 6Y98438M9X.com.roadflare.RoadFlare).
The existing `webcredentials` integration (Sign in with Apple Passwords)
is preserved unchanged.

This is the production prerequisite for [variablefate/roadflare-ios#113](variablefate/roadflare-ios#113) — the
`applinks:roadflare.app` entitlement is a no-op until Apple's CDN can
fetch a valid AASA from this domain. Once this lands and the next App
Store build ships, tapping a roadflare.app share URL anywhere on iOS
(Messages, Mail, etc.) opens the app directly with the Add Driver sheet
pre-filled, with the share page as the natural fallback when the app
isn't installed.

## Path components

- `/share/d/*` — driver share. Routes to the iOS `Add Driver` flow via
  the existing `AppState.handleIncomingUserActivity` handler.
- `/share/r/*` — rider share. Same routing today (parser is npub-segment-agnostic);
  scope reserved here for when a dedicated rider-side iOS surface ships.

Other paths on roadflare.app (`/`, `/privacy`, `/terms`, etc.) remain
web-only — they are not in the `components` filter and will continue to
open in Safari as expected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(aasa): explicitly exclude deeper /share/d|r paths

AASA components evaluate first-match-wins in array order. Modern
`components` `*` wildcard semantics are ambiguously documented for
`/` handling, so be explicit: prepend exclude rules for any path
deeper than the single npub segment (e.g. `/share/d/foo/bar`),
which the site doesn't render anyway.

Defensive — the iOS app's `DriverQRCodeParser` already no-ops on
non-npub URLs, so the prior overcapture risk was a graceful silent
failure. This makes the AASA spell out the intended scope instead
of leaning on Apple's wildcard interpretation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: variablefate <variablefate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Universal Links support for roadflare.app share pages

1 participant