Skip to content

feat(native): deferred deep linking through the store install (TASK-20772) - #2560

Open
kushagrasarathe wants to merge 2 commits into
mainfrom
feat/deferred-deep-link-main
Open

feat(native): deferred deep linking through the store install (TASK-20772)#2560
kushagrasarathe wants to merge 2 commits into
mainfrom
feat/deferred-deep-link-main

Conversation

@kushagrasarathe

@kushagrasarathe kushagrasarathe commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Base is main (not dev): the native release pipeline (AAB / Capgo production / release tags) builds from main, and this feature must be in a store-delivered binary to be device-tested (Play internal testing). Supersedes #2557/#2559 (dev-based). Back-merge debt: when main→dev back-merges, deferred-link.ts locale handling should be reconciled with dev's src/i18n/app (use its resolveLocale + live setLocale; the file carries a marker comment).

Summary

TASK-20772 — deferred deep linking v1 (DIY). When a mobile-web user is bounced to the Play Store / App Store, their context (locale, invite code, campaign tag, destination path) now survives the install into the native app:

  • Android — the web side appends &referrer=<payload> to the Play URL; a new app-local Capacitor plugin (InstallReferrerPlugin.java, Play Install Referrer library) reads it back on first launch. Deterministic, first-party.
  • iOS — clipboard hand-off: the store-bounce tap copies https://peanut.me/?pnutdl=1&… to the clipboard; first launch reads it via @capacitor/clipboard, gated by the existing prompt-free clipboardHasStrings() so organic installs with an empty clipboard never see the iOS paste prompt.
  • Restore (one-shot, deferredLinkConsumed flag): sets the existing inviteCode/campaignTag cookies (setup flow already reads them), persists the normalized locale under the app-locale key that dev's in-app i18n (src/i18n/app/locale-store.ts) reads — localStorage + @capacitor/preferences (dep added; the plugin ships in this binary, which dev's i18n also needs to OTA onto it) — and navigates to the destination only when the launch wasn't already a real deep link. Unsupported language tags resolve to null so a garbage payload can never override the device language.
  • Web side ships helpers + /dev/deferred test page only — the user-facing download modal (TASK-20769) will consume buildDeferredPayload / playStoreUrlWithReferrer / copyIOSHandoff when it lands.

Design notes / accepted trade-offs

  • Consumed flag lives in localStorage (survives Capgo OTA; resets on reinstall — which is exactly fresh-install semantics).
  • iOS clipboard is cleared after a successful consume, after the flag is set, so an interrupted clear can't cause a re-read.
  • The iOS paste prompt is double-gated by prompt-free metadata checks (hasStrings + new detectPatterns probable-web-URL in ClipboardDetectPlugin.swift): organic installs with unrelated clipboard text (a password, a draft) are never prompted — only a clipboard that plausibly holds the hand-off URL is worth the alert.
  • Restore is deliberately not awaited in native init: a pending paste prompt or a slow referrer service can't block the push-tap listener or splash hide. The Java plugin additionally resolves exactly once with a 5s timeout so its promise can never hang.
  • A deep link that actually navigated wins the landing over the deferred dest (a rejected/off-host launch URL doesn't burn it); cookies/locale are restored regardless. The restored locale applies after the startup locale paints, so the provider's initial swap can't clobber it.
  • Cookies are written with 30-day expiry and normalized (trim/lowercase/strip-@) to match the existing invite/campaign writers — a session cookie died before signup completed, and an uppercase campaign tag would break the case-sensitive badge award.
  • Locale-prefixed marketing paths (/es-419/…) are stripped from dests on both sides — those routes don't exist in the native static export.
  • Organic Play referrer (utm_source=google-play…) is rejected by the pnutdl=1 marker.

Risks / breaking changes

  • Requires a new Android binary (new plugin + Gradle dep) — cannot ship via Capgo OTA. Older binaries no-op safely (plugin bridge throws → caught → null).
  • iOS: adds one prompt-free method to ClipboardDetectPlugin.swift — rides the next IPA (iOS is mid-submission; no binaries in the field). On a binary without the method the JS skips the clipboard read entirely: no prompt, no restore, status quo.
  • No backend changes. No user-facing web surface changes.

QA

  • 15 unit tests (deferred-link.test.ts): build→parse round-trip, Play URL encoding hop, iOS hand-off form, organic-referrer rejection, one-shot consume, off-origin dest rejection, locale normalization.
  • :app:compileDebugJavaWithJavac green with the new plugin.
  • Device plan: iOS e2e via dev build (copy hand-off in Safari → fresh install → paste prompt → /dev/deferred shows restored state); Android via Play internal testing track with a &referrer= install link; sideloaded builds verified to no-op.

Screenshots: N/A (no visible change — dev-only page + native plumbing)

Summary by CodeRabbit

  • New Features
    • Added deferred deep-linking to carry destination, language, invitation, and campaign details through web-to-install flows.
    • Added automatic first-launch restoration for both Android and iOS handoff mechanisms.
    • Added a developer page to inspect and simulate deferred payloads, including generated store/clipboard handoffs.
  • Bug Fixes
    • Ensured actually-handled direct deep links take priority over restored deferred destinations.
    • Added stronger validation to avoid unsafe/invalid restoration and improved handling of unsupported locales and restore failures.
  • Tests
    • Added comprehensive Jest coverage for deferred payload build/parse/apply and restoration edge cases.
  • Chores
    • Added Capacitor Preferences support for persisting restored state.

…0772)

Carry locale + invite code + campaign tag + destination path from mobile
web through the app-store hop. Android rides the Play Install Referrer
(app-local InstallReferrerPlugin, resolve-once + 5s timeout); iOS rides a
clipboard hand-off double-gated by prompt-free UIPasteboard checks
(hasStrings + detectPatterns probable-web-url) so unrelated clipboard
content never triggers the paste alert. One-shot restore on first launch:
30-day normalized inviteCode/campaignTag cookies (setup flow already
reads them), locale persisted under the app-locale key the upcoming
in-app i18n (on dev) reads — @capacitor/preferences added so the
preference lands in native storage and the plugin ships in this binary —
and navigation to the destination only when no real deep link landed.
Restore is not awaited in init so a pending paste prompt or slow
referrer service can never block the push listener or splash hide.
Base is main (not dev) so the native release pipeline can cut a
testable binary; back-merge to dev will need a small locale-handling
reconcile with dev's src/i18n/app (marked in deferred-link.ts).
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 28, 2026 6:11pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1d06c5ee-d86e-489b-9967-e646d6356c60

📥 Commits

Reviewing files that changed from the base of the PR and between 573fa2e and 5231ab7.

📒 Files selected for processing (1)
  • src/app/dev/deferred/page.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/dev/deferred/page.tsx

📝 Walkthrough

Walkthrough

Adds deferred deep-link payload generation, Android and iOS restoration, native navigation coordination, a guarded development page, Capacitor Preferences support, and comprehensive tests.

Changes

Deferred deep-linking

Layer / File(s) Summary
Payload construction and parsing
src/utils/deferred-link.ts, src/constants/general.consts.ts, src/utils/__tests__/deferred-link.test.ts
Defines payload formats, Play Store and iOS handoffs, locale handling, parsing, and round-trip/rejection tests.
Native restoration and persistence
src/utils/deferred-link.ts, src/utils/clipboard-detect.ts, package.json, src/utils/__tests__/deferred-link.test.ts
Reads Android referrers or gated iOS clipboard content, consumes restoration once, persists cookies and locale preferences, and tests platform-specific behavior.
Deep-link navigation coordination
src/hooks/useNativePlugins.ts, src/hooks/__tests__/useNativePlugins.test.tsx
Runs deferred restoration asynchronously and prevents it from overriding successful launch deep-link navigation.
Deferred-link development page
src/app/dev/deferred/page.tsx
Adds tooling to inspect state, generate handoffs, read referrers, and simulate payload restoration with production web access blocked.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: enhancement

Sequence Diagram(s)

sequenceDiagram
  participant WebPage
  participant Store
  participant NativeApp
  participant DeferredContext
  WebPage->>Store: Open handoff URL with deferred payload
  Store->>NativeApp: Install application
  NativeApp->>DeferredContext: Restore referrer or clipboard payload
  DeferredContext-->>NativeApp: Return destination and locale
  NativeApp->>NativeApp: Navigate when no launch deep link handled
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing deferred deep linking through store installation.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/deferred-deep-link-main

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Comment @coderabbitai help to get the list of available commands.

@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6309.2 → 6324.39 (+15.19)
Findings: +6 net (+27 new, -21 resolved)

🆕 New findings (27)

  • critical complexity — src/utils/deferred-link.ts — CC 50, MI 58.74, SLOC 186
  • high complexity — src/hooks/useNativePlugins.ts — CC 37, MI 62.24, SLOC 177
  • medium high-mdd — src/hooks/useNativePlugins.ts:21 — useNativePlugins: MDD 40.8 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/useNativePlugins.ts:24 — : MDD 37.8 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useNativePlugins.ts:21 — useNativePlugins: DLT 34 (calls 34 distinct functions — high context load)
  • medium high-mdd — src/app/dev/deferred/page.tsx:24 — DeferredLinkDevPage: MDD 31.9 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useNativePlugins.ts:24 — : DLT 32 (calls 32 distinct functions — high context load)
  • medium high-mdd — src/hooks/useNativePlugins.ts:53 — init: MDD 24.6 (uses across many lines from declarations)
  • medium complexity — src/app/dev/deferred/page.tsx — CC 17, MI 64.67, SLOC 73
  • low high-dlt — src/hooks/useNativePlugins.ts:53 — init: DLT 26 (calls 26 distinct functions — high context load)
  • low high-dlt — src/app/dev/deferred/page.tsx:24 — DeferredLinkDevPage: DLT 25 (calls 25 distinct functions — high context load)
  • low high-mdd — src/utils/deferred-link.ts:184 — doRestore: MDD 11.4 (uses across many lines from declarations)
  • low unused-dep — package.json:78 — unused dependency: @sumsub/cordova-idensic-mobile-sdk-plugin
  • low unused-dep — package.json:88 — unused dependency: circle-flags
  • low unused-dep — package.json:92 — unused dependency: ethers
  • low unused-dep — package.json:107 — unused dependency: posthog-node
  • low unused-dep — package.json:119 — unused dependency: redux
  • low unused-dep — package.json:122 — unused dependency: siwe
  • low missing-return-type — src/app/dev/deferred/page.tsx:24 — DeferredLinkDevPage: exported fn missing return type annotation
  • info unused-dep — package.json:136 — unused devDependency: @testing-library/dom

…and 7 more.

✅ Resolved (21)

  • src/hooks/useNativePlugins.ts — CC 32, MI 61.67, SLOC 160
  • src/hooks/useNativePlugins.ts:21 — useNativePlugins: MDD 36.5 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:24 — : MDD 34.6 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:21 — useNativePlugins: DLT 33 (calls 33 distinct functions — high context load)
  • src/hooks/useNativePlugins.ts:24 — : DLT 31 (calls 31 distinct functions — high context load)
  • src/hooks/useNativePlugins.ts:45 — init: MDD 24.6 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:45 — init: DLT 24 (calls 24 distinct functions — high context load)
  • package.json:77 — unused dependency: @sumsub/cordova-idensic-mobile-sdk-plugin
  • package.json:87 — unused dependency: circle-flags
  • package.json:91 — unused dependency: ethers
  • package.json:106 — unused dependency: posthog-node
  • package.json:118 — unused dependency: redux
  • package.json:121 — unused dependency: siwe
  • package.json:135 — unused devDependency: @testing-library/dom
  • package.json:148 — unused devDependency: eslint
  • package.json:156 — unused devDependency: jest-junit
  • package.json:157 — unused devDependency: jest-transform-stub
  • package.json:159 — unused devDependency: openapi-typescript
  • package.json:162 — unused devDependency: prettier
  • package.json:165 — unused devDependency: size-limit

…and 1 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/deferred-link.ts 0.0 7.5 +7.5
src/app/dev/deferred/page.tsx 0.0 6.7 +6.7
src/utils/clipboard-detect.ts 4.2 4.7 +0.6

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2259 ran, 0 failed, 0 skipped, 38.6s

📊 Coverage (unit)

metric %
statements 62.1%
branches 45.4%
functions 51.5%
lines 62.5%
⏱ 10 slowest test cases
time test
4.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.3s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/demo-balance.test.ts › keeps a spent-down balance across a cold start within the TTL
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a wallet older than the TTL on cold start
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a stored balance that has no timestamp (legacy install)
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@notion-workspace

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/dev/deferred/page.tsx`:
- Around line 92-114: Reset the copied state when building a new payload in the
deferred page’s payload-generation button. Update the onClick handler that calls
buildDeferredPayload to also set copied to false, while preserving the existing
setCopied(true) behavior after copyIOSHandoff completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 530cb036-dd58-415a-979d-78df4ff5291d

📥 Commits

Reviewing files that changed from the base of the PR and between 3c4c211 and 573fa2e.

⛔ Files ignored due to path filters (7)
  • android/app/build.gradle is excluded by !android/**
  • android/app/capacitor.build.gradle is excluded by !android/**
  • android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java is excluded by !android/**
  • android/app/src/main/java/me/peanut/wallet/MainActivity.java is excluded by !android/**
  • android/capacitor.settings.gradle is excluded by !android/**
  • ios/App/App/ClipboardDetectPlugin.swift is excluded by !ios/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • package.json
  • src/app/dev/deferred/page.tsx
  • src/constants/general.consts.ts
  • src/hooks/__tests__/useNativePlugins.test.tsx
  • src/hooks/useNativePlugins.ts
  • src/utils/__tests__/deferred-link.test.ts
  • src/utils/clipboard-detect.ts
  • src/utils/deferred-link.ts

Comment thread src/app/dev/deferred/page.tsx
@kushagrasarathe
kushagrasarathe marked this pull request as ready for review July 28, 2026 18:10
@kushagrasarathe
kushagrasarathe requested a review from Hugo0 July 28, 2026 18:10
innolope-dev added a commit that referenced this pull request Jul 29, 2026
…s, PIX exit, verification tasks

Cherry-picks the open native-facing work onto the release branch:

- KYC now routes every native entry point through the Sumsub Cordova SDK
  instead of the WebSDK (peanut-ui #2562) — the one change here that genuinely
  requires a new binary rather than an OTA.
- Deferred deep linking survives the store install, so a link followed before
  install lands on its destination on first launch (#2560, TASK-20772).
- PIX deposit "Done" exits to home from both the completed and processing
  states instead of dropping the user into a new deposit (#2548).
- Pending Bridge verification tasks card on home, dismissible and resurfaced
  under Unlocked regions (#2549).
- Native demo/passkey follow-ups: the awaited token clear in clearAuthState
  plus the two callers it changed (#2517 — the rest shipped in 1.0.40).
- Documents left running on a superseded deployment now reload (#2563).
- Android safe zone sized from natively measured insets.

Not carried: the CSP allow-list gaps (#2564). It builds on the #2519 collector
rework — a /api/csp-report route and ten commits that are not on this branch —
and the policy is report-only, so the gaps cost report fidelity, not behaviour.
@jjramirezn

Copy link
Copy Markdown
Contributor

🤖 High-effort code review — 10 verified findings

Automated multi-agent review (4 finders + independent verification per finding; 22 candidates, 3 refuted). The happy path works, but the restore path breaks in several everyday situations.

🔴 Blockers

1. Existing users get locked out of login (src/utils/deferred-link.ts:247)
applyDeferredPayload writes the inviteCode cookie for 30 days. The setup page's skipInviteGate = !!userInviteCode then routes every launch straight to the Signup step — past the Landing screen, which is the only screen with a Log In button (same regression class as PR #2346, this time via inviteCode instead of campaignTag). An existing user who taps a friend's invite link and installs the app can't log in on that device for up to 30 days, since useZeroDev only clears the cookie on successful signup.

2. Unmapped dests navigate to routes that don't exist (src/utils/deferred-link.ts:255)
deepLinkToNativePath(rawDest) ?? rawDest drops the must-map guard that openDeepLink enforces. If mapping returns null (e.g. a stray % making decodeURIComponent throw), the raw string passes the same-origin check and gets router.pushed verbatim — but in the native static export /send/<user> only exists as /send?recipient=..., so the fresh-install user's very first screen is a 404/blank route. Should be dropped like openDeepLink drops it.

3. Transient Android referrer failure burns the one-shot flag (src/utils/deferred-link.ts:213)
CONSUMED_KEY is set even when readInstallReferrer() resolved null from a transient failure (5s timeout, SERVICE_UNAVAILABLE, slow Play Services on first boot). Invite code / campaign / dest are then lost forever — silently — even though the Play referrer stays readable for ~90 days and a retry on next launch would be safe and prompt-free on Android. Suggest: only mark consumed on Android after a definitive read (or a definitive FEATURE_NOT_SUPPORTED-class failure).

4. Late restore yanks users out of mid-onboarding (src/hooks/useNativePlugins.ts:92)
The un-awaited restore fires router.push(restored.dest) whenever it resolves, guarded only by anyDeepLinkNavigated (which tracks deep links, not user navigation). The Android read can take up to 5s and the iOS paste prompt can sit unresolved indefinitely — a user already tapping through signup/passkey creation gets teleported to /claim/... mid-flow.

🟡 Should fix

5. iOS re-prompts after kill-during-prompt (src/utils/deferred-link.ts:213)
On iOS the consumed flag is only written after Clipboard.read() resolves. Backgrounding/killing the app while the system paste prompt is up (a common reflex) means the flag is never set — every subsequent cold start with a web URL on the clipboard re-raises the paste prompt, breaking the "never re-prompt" invariant and reading as repeated clipboard snooping.

6. cap sync swept @capacitor/camera into the Android build (android/capacitor.settings.gradle:11, android/app/capacitor.build.gradle:14)
The regenerated gradle files add the previously-absent camera native module — unrelated to this PR. The next Play binary (the one this PR needs shipped) gains camera/media merged-manifest permission entries, which can trip Play Console's sensitive-permission review or surface unexplained permissions to users. Either exclude it or ship it as a deliberate, declared change.

7. First-launch race: invite gate vs. referrer read (src/utils/deferred-link.ts:247)
Setup's determineInitialStep reads the inviteCode cookie ~100ms after mount, but the Android referrer read can take up to 5s before the cookie is written. On exactly the launch the feature was built for, the invite-driven "straight to signup" path degrades to the generic Landing step (and the later dest push may then yank the user — see #4).

8. stripLocalePrefix misses locale-with-query (src/utils/deferred-link.ts:84)
Splitting on / without separating the query string means /pt-br?x=1 fails isValidLocale (first segment is pt-br?x=1) and the locale prefix survives into the native dest → 404 in the static export.

🧹 Cleanup

9. Dev page forks the prod wall (src/app/dev/deferred/page.tsx:65) — re-implements (mobile-ui)/dev/layout.tsx's prod block inline with a native exception instead of generalizing the shared DevLayout (e.g. a native-allowed flag) and living under (mobile-ui)/dev/ like its siblings. Future policy changes to the wall won't apply here.

10. Inline invite normalization duplicates toInviteCode (src/utils/deferred-link.ts:246)payload.invite?.trim().replace(/^@/, '').toLowerCase() is byte-for-byte toInviteCode from general.utils (already imported from). Call the helper so the paths can't drift.


🤖 Generated with Claude Code

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.

2 participants