Skip to content

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

Closed
kushagrasarathe wants to merge 5 commits into
devfrom
feat/deferred-deep-linking
Closed

feat(native): deferred deep linking through the store install (TASK-20772)#2559
kushagrasarathe wants to merge 5 commits into
devfrom
feat/deferred-deep-linking

Conversation

@kushagrasarathe

@kushagrasarathe kushagrasarathe commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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), applies the locale live via useAppLocale().setLocale (normalized through resolveLocale — unsupported tags are dropped so a garbage payload can never override the device language), and navigates to the destination only when the launch wasn't already a real deep link.
  • 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 across Android, iOS, and web.
    • Preserves supported language, invitation, campaign, and destination details when opening the app after installation.
    • Restores deferred navigation without overriding an active deep link.
    • Added Google Play Store integration for deferred hand-off links.
    • Added safeguards to prevent repeated restoration and safely handle unavailable native capabilities.
    • Added an internal development page for inspecting and testing deferred-link hand-offs.

…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); iOS rides a clipboard hand-off gated
by the prompt-free clipboardHasStrings check. One-shot restore on first
launch applies the existing inviteCode/campaignTag cookies, persists
preferredLocale for future in-app i18n, and navigates only when the
launch wasn't already a real deep link.
dev's in-app i18n landed while this branched off main — the restore now
returns the normalized AppLocale and applies it via useAppLocale's
setLocale (live switch + native Preferences persistence) instead of a
parallel localStorage key. unsupported tags return null so a garbage
payload can never override the device language.
- 30-day normalized cookies (session cookies died before signup; uppercase
  campaign tags broke the case-sensitive badge award)
- strip marketing locale prefix from dest (native export has no /{locale})
- InstallReferrerPlugin: resolve exactly once + disconnect handler + 5s
  timeout so the PluginCall can never hang app init
- restore no longer awaited in init: iOS paste prompt / slow referrer
  service can't block the push listener or splash hide
- dest yields only to a deep link that actually navigated, not to a
  rejected launch url; locale applies after startup locale paints
- iOS prompt gated by prompt-free UIPasteboard.detectPatterns probable-URL
  check: unrelated clipboard text never triggers the paste alert
- in-flight guard: strict-mode double-effects share one read
- hook wiring tests (dest precedence, locale apply, failure isolation)
@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 5:48pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds deferred deep-link payload generation and parsing, Android/iOS native restoration, locale-aware routing safeguards, comprehensive tests, and a Capacitor-only development page for inspecting and simulating handoffs.

Changes

Deferred deep-linking

Layer / File(s) Summary
Payload contracts and handoffs
src/utils/deferred-link.ts, src/constants/general.consts.ts
Defines payload construction, parsing, Play Store and iOS handoffs, normalized cookie persistence, and the Play Store URL constant.
Native restore and clipboard detection
src/utils/deferred-link.ts, src/utils/clipboard-detect.ts, src/utils/__tests__/deferred-link.test.ts
Restores Android referrer or qualifying iOS clipboard data once, applies locale and destination state, clears consumed clipboard data, and tests parsing, persistence, failure, and concurrency behavior.
Native initialization routing
src/hooks/useNativePlugins.ts, src/hooks/__tests__/useNativePlugins.test.tsx
Integrates deferred restoration into initialization, avoids overriding successful deep-link navigation, applies restored locale, and validates failure handling.
Deferred-link development page
src/app/dev/deferred/page.tsx
Provides state inspection, handoff generation, referrer reading, clipboard copying, and simulated payload application controls with a production-web access gate.

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

Sequence Diagram(s)

sequenceDiagram
  participant WebPage
  participant PlayStore
  participant NativeApp
  participant DeferredLink
  participant Router
  WebPage->>DeferredLink: build deferred payload
  WebPage->>PlayStore: open handoff URL with referrer
  NativeApp->>DeferredLink: restore install context
  DeferredLink-->>NativeApp: restored destination and locale
  NativeApp->>Router: navigate when no deep link already handled
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: innolope-dev

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly matches the main change: native deferred deep linking through the app-store install flow.
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-linking

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 timed out. The project may have too many dependencies for the sandbox.


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

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6656.05 → 6672.29 (+16.24)
Findings: +6 net (+14 new, -8 resolved)

🆕 New findings (14)

  • high complexity — src/hooks/useNativePlugins.ts — CC 43, MI 62.5, SLOC 203
  • high complexity — src/utils/deferred-link.ts — CC 42, MI 57.86, SLOC 156
  • medium high-mdd — src/hooks/useNativePlugins.ts:23 — useNativePlugins: MDD 42.5 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useNativePlugins.ts:23 — useNativePlugins: DLT 39 (calls 39 distinct functions — high context load)
  • medium high-mdd — src/hooks/useNativePlugins.ts:27 — : MDD 39.4 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useNativePlugins.ts:27 — : DLT 36 (calls 36 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:56 — init: DLT 30 (calls 30 distinct functions — high context load)
  • medium high-mdd — src/hooks/useNativePlugins.ts:56 — init: MDD 24.5 (uses across many lines from declarations)
  • medium complexity — src/app/dev/deferred/page.tsx — CC 18, MI 63.82, SLOC 79
  • low high-dlt — src/app/dev/deferred/page.tsx:24 — DeferredLinkDevPage: DLT 27 (calls 27 distinct functions — high context load)
  • low high-mdd — src/utils/deferred-link.ts:153 — doRestore: MDD 11.4 (uses across many lines from declarations)
  • low missing-return-type — src/app/dev/deferred/page.tsx:24 — DeferredLinkDevPage: exported fn missing return type annotation
  • low missing-return-type — src/hooks/useNativePlugins.ts:23 — useNativePlugins: exported fn missing return type annotation

✅ Resolved (8)

  • src/hooks/useNativePlugins.ts — CC 33, MI 61.76, SLOC 166
  • src/hooks/useNativePlugins.ts:22 — useNativePlugins: MDD 37.2 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:22 — useNativePlugins: DLT 36 (calls 36 distinct functions — high context load)
  • src/hooks/useNativePlugins.ts:25 — : MDD 35.2 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:25 — : DLT 34 (calls 34 distinct functions — high context load)
  • src/hooks/useNativePlugins.ts:46 — init: MDD 25.2 (uses across many lines from declarations)
  • src/hooks/useNativePlugins.ts:46 — init: DLT 27 (calls 27 distinct functions — high context load)
  • src/hooks/useNativePlugins.ts:22 — useNativePlugins: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/deferred-link.ts 0.0 7.8 +7.8
src/app/dev/deferred/page.tsx 0.0 7.2 +7.2
src/utils/clipboard-detect.ts 4.3 4.8 +0.5

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2332 ran, 0 failed, 0 skipped, 41.1s

📊 Coverage (unit)

metric %
statements 61.3%
branches 44.4%
functions 50.6%
lines 61.8%
⏱ 10 slowest test cases
time test
3.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s 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.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s 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__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s 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__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
📍 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: 2

🤖 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`:
- Line 31: Update the deferred page’s copied state and clipboard handler so
success is tracked for the current payload only. Reset copied whenever the
payload changes, and associate each asynchronous copy attempt with its payload
so a completion from an older payload cannot set copied for the new one.
- Around line 63-67: Update the gate in the deferred page to use the build-time
native flag rather than the runtime isCapacitor() bridge check, while preserving
the BASE_URL condition and existing notFound() behavior for web production. Keep
the change scoped to this post-hooks access guard.
🪄 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: 7b5fc5a3-641f-4b0f-ac7d-f667a9a187ff

📥 Commits

Reviewing files that changed from the base of the PR and between 134cf01 and 3428b37.

⛔ Files ignored due to path filters (4)
  • android/app/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/**
  • ios/App/App/ClipboardDetectPlugin.swift is excluded by !ios/**
📒 Files selected for processing (7)
  • 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

const [rawReferrer, setRawReferrer] = useState('(not read)')
const [simulateInput, setSimulateInput] = useState('')
const [simulateResult, setSimulateResult] = useState('')
const [copied, setCopied] = useState(false)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Track clipboard success per payload.

Building a new payload leaves copied true; a pending copy of an older payload can also later mark the new one as copied. The UI can claim success for content never copied.

Proposed fix
-    const [copied, setCopied] = useState(false)
+    const [copiedPayload, setCopiedPayload] = useState<string | null>(null)
...
-                    onClick={() => setPayload(buildDeferredPayload('/home'))}
+                    onClick={() => {
+                        setPayload(buildDeferredPayload('/home'))
+                        setCopiedPayload(null)
+                    }}
...
                             await copyIOSHandoff(payload)
-                            setCopied(true)
+                            setCopiedPayload(payload)
...
-                        {copied ? 'copied ✓' : 'copy ios hand-off to clipboard'}
+                        {copiedPayload === payload ? 'copied ✓' : 'copy ios hand-off to clipboard'}

Also applies to: 94-116

🤖 Prompt for 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.

In `@src/app/dev/deferred/page.tsx` at line 31, Update the deferred page’s copied
state and clipboard handler so success is tracked for the current payload only.
Reset copied whenever the payload changes, and associate each asynchronous copy
attempt with its payload so a completion from an older payload cannot set copied
for the new one.

Comment on lines +63 to +67
// same wall as (mobile-ui)/dev/layout.tsx, with a native exception: the
// capacitor build ships with BASE_URL=peanut.me but needs this page for
// on-device verification. web prod stays blocked. (after hooks — throwing
// before them would break the rules of hooks.)
if (BASE_URL === 'https://peanut.me' && !isCapacitor()) notFound()

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

# Inspect the platform detector's server-side path.
fd -a '^capacitor\.(ts|tsx|js|jsx)$' src/utils
rg -n -C 6 '\bisCapacitor\b' src/utils

# Check whether the app is configured for static output/prerendering.
fd -a '^next\.config\.(js|mjs|ts)$' .
rg -n -C 4 'output|static|export' next.config.* 2>/dev/null || true

Repository: peanutprotocol/peanut-ui

Length of output: 46428


🏁 Script executed:

#!/bin/bash
set -eu

sed -n '1,140p' src/app/dev/deferred/page.tsx
printf '\n---\n'
sed -n '1,120p' src/utils/capacitor.ts
printf '\n---\n'
sed -n '1,120p' next.config.native.js

Repository: peanutprotocol/peanut-ui

Length of output: 11760


🏁 Script executed:

#!/bin/bash
set -eu

rg -n -C 4 'export const BASE_URL|BASE_URL =' src/constants src/utils
sed -n '1,120p' src/constants/general.consts.ts

Repository: peanutprotocol/peanut-ui

Length of output: 9130


Defer this gate until runtime
In the native static export, the server render sees isCapacitor() as false, so this branch can turn the page into a 404 before the WebView loads. Use the build-time native flag here instead of the runtime bridge check.

🤖 Prompt for 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.

In `@src/app/dev/deferred/page.tsx` around lines 63 - 67, Update the gate in the
deferred page to use the build-time native flag rather than the runtime
isCapacitor() bridge check, while preserving the BASE_URL condition and existing
notFound() behavior for web production. Keep the change scoped to this
post-hooks access guard.

@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

Superseded by #2560 — this needs to base on main: the native release pipeline (AAB for Play internal testing, release tags, Capgo production) builds from main, and the feature must be in a store-delivered binary to be device-tested. #2560 is the same feature adapted for main (which lacks dev's in-app i18n — the restored locale is persisted under the app-locale key that system reads, instead of applied live). Closing.

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.

1 participant