Skip to content

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

Merged
jjramirezn merged 3 commits into
mainfrom
feat/deferred-deep-link-main
Jul 30, 2026
Merged

feat(native): deferred deep linking through the store install (TASK-20772)#2560
jjramirezn merged 3 commits into
mainfrom
feat/deferred-deep-link-main

Conversation

@kushagrasarathe

@kushagrasarathe kushagrasarathe commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a dev-only preview page at /dev/migration that showcases every UI surface for the PWA → app-store migration, composed from the real app components (not lookalikes) so the states read like production.

Surfaces covered:

  • 01 Download prompt — single variant, deadline in copy; mobile shows the visitor's store CTA, desktop shows the QR inline.
  • 02 Access-ending screen — full-screen after cutover (mirrors ForceIOSPWAInstall).
  • 03 Guest link pages — claim / request / invite, built from PeanutActionDetailsCard + ActionListCard + the real "Continue with Peanut" button.
  • 04 Get-the-app banner — the real home CarouselCTA.
  • 05 Landing hero — the real <Hero> component, CTA swapped to "Download now".
  • 06 Review prompt / 07 Notifications promptActionModal pre-prompts.
  • 08 Scan-to-download — a QR with an App Store / Google Play toggle (a browser can't tell iOS from Android).

Device-aware throughout: one primary CTA per device (mobile → store button; desktop → QR).

Risk

None to production. Dev-only route — /dev is notFound() on prod (dev/layout.tsx), the whole page is behind that gate. No production code touched; only a new file under src/app/(mobile-ui)/dev/.

QA

./scripts/dev   # or: PORT=3055 pnpm dev in this worktree
open http://localhost:3055/dev/migration

Toggle iOS / Android / Desktop (top-right) to see the device-aware CTAs; open each modal; on Desktop the download CTAs show the dual-store QR.

Screenshots

Dev-only preview route — best viewed live at /dev/migration (steps above). Every surface is rendered from the real components; toggling the platform pill drives the CTA variants.

Design notes

  • No "native app" wording anywhere — users don't know the term; copy is "Peanut is becoming an app" / "download from the App Store or Google Play".
  • The dual-store QR toggle is deliberate; a single smart link (peanut.me/app redirecting by device) would remove the toggle — noted in a code comment as the lighter alternative, pending a product call.
  • Components are composed inline in the dev page for review — not yet extracted into production components; that happens when the migration decisions lock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added deferred deep-linking restoration with destination, language, invitation, and campaign details, including Android/iOS handoff support.
    • Added a developer page to debug and simulate deferred payloads, with safe non-native environment blocking.
    • Added Play Store URL constant for UI verification.
  • Bug Fixes
    • Prevent restored destinations from overriding already-handled in-app navigation and avoid “teleporting” when restoration is late or fails.
    • Improved retry/consumption behavior and stricter destination validation; preserve URL query/hash when deriving destinations.
  • Tests
    • Expanded Jest coverage for deferred payload, restoration timing, retry, clipboard gating, and 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 Jul 30, 2026 5:18am

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, Android and iOS restoration, native navigation coordination, Capacitor Preferences support, a guarded development page, 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, locale-aware destinations, Play Store and iOS handoffs, parsing rules, and validation coverage.
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, applies restored context, controls retry and consumption behavior, and persists locale and cookie state.
Deep-link navigation coordination
src/hooks/useNativePlugins.ts, src/hooks/__tests__/useNativePlugins.test.tsx
Runs deferred restoration asynchronously and prevents late or lower-priority restoration from overriding handled navigation.
Deferred-link development page
src/app/dev/deferred/page.tsx
Adds tooling to inspect state, generate handoffs, read referrers, and simulate payload restoration while blocking production web access.

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

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: hugo0

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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: native deferred deep linking via store install.
✨ 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

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: 6334.17 → 6350.2 (+16.03)
Findings: +6 net (+27 new, -21 resolved)

🆕 New findings (27)

  • critical complexity — src/utils/deferred-link.ts — CC 56, MI 58.15, SLOC 204
  • high complexity — src/hooks/useNativePlugins.ts — CC 38, MI 61.55, SLOC 185
  • medium high-mdd — src/hooks/useNativePlugins.ts:21 — useNativePlugins: MDD 41.9 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/useNativePlugins.ts:24 — : MDD 38.8 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useNativePlugins.ts:21 — useNativePlugins: DLT 35 (calls 35 distinct functions — high context load)
  • medium high-dlt — src/hooks/useNativePlugins.ts:24 — : DLT 33 (calls 33 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-mdd — src/hooks/useNativePlugins.ts:53 — init: MDD 24.2 (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 27 (calls 27 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:188 — doRestore: MDD 19.8 (uses across many lines from declarations)
  • low unused-dep — package.json:83 — unused dependency: @sumsub/cordova-idensic-mobile-sdk-plugin
  • low unused-dep — package.json:93 — unused dependency: circle-flags
  • low unused-dep — package.json:97 — unused dependency: ethers
  • low unused-dep — package.json:112 — unused dependency: posthog-node
  • low unused-dep — package.json:124 — unused dependency: redux
  • low unused-dep — package.json:127 — 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:141 — 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:82 — unused dependency: @sumsub/cordova-idensic-mobile-sdk-plugin
  • package.json:92 — unused dependency: circle-flags
  • package.json:96 — unused dependency: ethers
  • package.json:111 — unused dependency: posthog-node
  • package.json:123 — unused dependency: redux
  • package.json:126 — unused dependency: siwe
  • package.json:140 — unused devDependency: @testing-library/dom
  • package.json:153 — unused devDependency: eslint
  • package.json:161 — unused devDependency: jest-junit
  • package.json:162 — unused devDependency: jest-transform-stub
  • package.json:164 — unused devDependency: openapi-typescript
  • package.json:167 — unused devDependency: prettier
  • package.json:170 — unused devDependency: size-limit

…and 1 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/deferred-link.ts 0.0 8.0 +8.0
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: 2294 ran, 0 failed, 0 skipped, 37.9s

📊 Coverage (unit)

metric %
statements 62.5%
branches 45.9%
functions 52.2%
lines 62.9%
⏱ 10 slowest test cases
time test
3.7s 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.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
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/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
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__/demo-balance.test.ts › starts at the full balance on a fresh install and stamps a timestamp
0.2s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a wallet older than the TTL on cold start
📍 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

@innolope-dev

Copy link
Copy Markdown
Collaborator

@kushagrasarathe — I'd explored this problem in a design plan a couple of days before this PR went up, and landed on the same two channels you built (Play Install Referrer + iOS clipboard). Having compared the two, your serverless shape is the better call, and I want to be specific about why, since my version had a server tier and I now think that was wrong:

  • Mine added two public unauthenticated endpoints plus a TTL store to the API to carry locale, invite code, campaign tag and dest. Invite codes are already public (derived from usernames, resolvable via public POST /invites/validate), so that's real attack surface — storage-fill, enumeration — for data that isn't sensitive.
  • It also had an IP + coarse-UA fingerprint fallback for iOS. Two users behind the same CGNAT or corporate egress on the same OS version can collide, and the loser gets someone else's invite code and destination. It's also the fingerprinting-for-attribution pattern Apple disallows, and Private Relay defeats it anyway. Not going there was right.
  • No network call on first launch is a genuine robustness win — a slow API can't delay the cold start.

Three findings, one of them shipped as a PR:

1. Telemetry — #2587 (stacked on this branch). The restore path emits nothing today, and the clipboard catch {} swallows the one distinction that matters: a declined paste prompt and an organic install both end as raw === null. So a hand-off that never works on iOS is indistinguishable from one nobody used — the only signal either way is an absence of restores, which looks identical to success. That PR splits the empty cases into no_handoff / marker_missing / clipboard_unavailable and reports them with the channel. Only booleans leave the device (no invite code, no dest). It's stacked, so retarget it to main once this merges — happy to close it if you'd rather fold the change in here yourself.

2. /invite dead-ends on iOS today. Unrelated to your PR, but it hits the same path and it's the main deferred-linking use case, so worth knowing: the AASA is paths: ["*"] for all three appIDs, so peanut.me/invite?code=… routes into the app — but /invite is stripped from the native export (scripts/native-build.js ITEMS_TO_DISABLE) and isn't in the Android intent filter. On iOS with the app installed, an invite link lands on a route that doesn't exist.

The fix looks small: rewrite /invite?code=X/setup?step=signup in the native mapper and write the code with the same normalization applyDeferredPayload already uses. Notably no auth branch is needed/setup already detects arriving with an existing session and prompts continue/start-fresh, so a signed-in user tapping a shared invite link is handled. I haven't built it: it's a routing behaviour change on your ticket and would be a third stacked PR, so it seemed better as your call than my patch.

3. Claim links — a warning about the obvious next step, not about this PR. This PR sensibly carries only non-secret context. When claim links come up, please don't do what my plan proposed: it put pnut1.<id>.<key> — record id and AES key — into the referrer/clipboard, with ciphertext server-side. I'd convinced myself that kept the secret away from the server, Google and network observers, which is true, but it hands both halves to anything that gets a pasteboard read on iOS, and since the record is single-use the thief drains the link and the real user gets nothing. That's fund loss rather than a privacy nit. iOS 16's paste prompt mitigates but doesn't remove it. The existing "paste your link" recovery is a much safer fallback for the secret-bearing case.

Separately, #2584 fixes a pre-existing bug I hit while reading this: mapDeepLink() never reads parsed.hash, so it drops the fragment — meaning a claim link's #p=<password> is lost even on a warm deep link today, independent of deferred linking. Based on dev, doesn't block this PR, but it's a prerequisite before claim links could ever ride this path.

Nice work on the double-gated clipboard read and the one-shot flag ordering — the "set the flag before the clear so an interrupted clear can't re-read" detail is the kind of thing that bites later.

- inviteCode restored as SESSION cookie matching InvitesPage (a 30-day
  cookie routes /setup past Landing, the only Log In screen — the #2346
  lockout class); campaignTag stays 30d, it no longer gates the step
- dest must map through deepLinkToNativePath like openDeepLink — an
  unmappable dest is dropped, not pushed verbatim into the static export
- android: a transient null referrer read no longer burns the one-shot;
  the read is prompt-free and the referrer lives ~90 days, so next
  launch retries. definitive reads (incl. play's organic utm) consume.
- iOS: consume BEFORE the prompt-raising read — kill-during-prompt can
  never re-prompt on later launches
- late-resolving restore (prompt left up, slow referrer service) no
  longer navigates: 10s window from launch, cookies/locale still apply
- stripLocalePrefix handles /pt-br?x=1 (query split before segmenting)
- invite normalization now calls toInviteCode instead of duplicating it
@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

Thanks @jjramirezn — all 10 triaged; 7 fixed in ffc9e79, 1 refuted with evidence, 2 accepted/declined with reasoning below.

Fixed (1–5, 8, 10):

  1. Login lockout — restored inviteCode is now a session cookie, byte-matching the web invite flow (InvitesPage writes session too). Attribution survives the install→open→signup funnel (the case this feature exists for); an existing user who wants to log in self-heals on app restart instead of being stuck 30 days. campaignTag stays 30-day — it no longer gates the setup step (that was your fix(offramp): let the offramp campaign skip the invite-code gate at signup #2346 fix), so it's safe to keep for badge attribution.
  2. Unmapped dest — now must-map like openDeepLink: deepLinkToNativePath returning null (off-host, stray %) drops the dest instead of pushing it verbatim. Test added with a /send/50% dest.
  3. Transient referrer failure — Android no longer consumes on a null read; the read is prompt-free and the referrer stays available ~90 days, so the next launch retries. A definitive read — including Play's organic utm_source string — consumes. Test covers the fail-then-succeed-next-launch sequence.
  4. Late-restore teleport — dest navigation now only fires within 10s of launch (restoreStartedAt window). A paste prompt answered minutes later, or a sluggish referrer service, applies cookies/locale but never yanks the user mid-flow. Hook test simulates a 20s-late resolve.
  5. Kill-during-prompt re-prompt — the consumed flag is now set before Clipboard.read() (after the prompt-free gates pass). Killing the app mid-prompt loses the hand-off (lesser harm) but can never re-prompt. Test added.
  6. /pt-br?x=1stripLocalePrefix splits query/hash before segmenting. Test added.
  7. toInviteCode — now called instead of duplicated.

Refuted (6 — camera in the binary): scripts/native-release.sh:44 runs pnpm exec cap sync android on every release build, and @capacitor/camera has been in main's package.json since the QR-scanner feature — so every shipped binary (v1.0.39–42) already contains the camera module; the committed gradle files were just stale copies of what CI regenerates anyway. This PR changes nothing about the shipped manifest/permissions; it only brings the committed files in sync with what cap sync produces. (Play's sensitive-permission state is whatever v1.0.42 already declared.)

Accepted, no code change (7 — invite-gate race): when the referrer read lands after setup's ~100ms cookie check, the user sees Landing instead of the signup shortcut — but the cookie lands well before they finish tapping through, so invite/campaign attribution and the badge award are intact; only the gate-skip nicety degrades, on slow reads only. Wiring a re-check event into the setup page isn't worth the coupling for v1; happy to revisit if funnel data shows it matters.

Declined (9 — dev-page wall fork): the page must be reachable unauthenticated in the native prod build — it's the landing target for the fresh-install e2e (dest=/dev/deferred) — which (mobile-ui)/dev can't offer (auth layout), and generalizing DevLayout with a native-allowed flag for exactly one consumer is speculative abstraction. The 3-line wall names (mobile-ui)/dev/layout.tsx in its comment, so a future policy change greps straight to it.

@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.

🧹 Nitpick comments (1)
src/hooks/__tests__/useNativePlugins.test.tsx (1)

82-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the Date.now spy outside the assertion path.

nowSpy.mockRestore() sits after expect, so a failure at Line 94 leaks a frozen Date.now into every later test in this file, cascading unrelated failures.

♻️ Move restoration into teardown
-        const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => fakeNow)
+        jest.spyOn(Date, 'now').mockImplementation(() => fakeNow)
         mockRestore.mockImplementation(() => {
             // e.g. the paste prompt sat unanswered for 20s before the user allowed it
             fakeNow += 20_000
             return Promise.resolve({ dest: '/claim?x=1', locale: null })
         })
 
         renderHook(() => useNativePlugins())
 
         await waitFor(() => expect(mockRestore).toHaveBeenCalled())
         await new Promise((r) => setTimeout(r, 0))
         expect(push).not.toHaveBeenCalledWith('/claim?x=1')
-        nowSpy.mockRestore()
     })

Add alongside the existing beforeEach:

afterEach(() => {
    jest.restoreAllMocks()
})
🤖 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/hooks/__tests__/useNativePlugins.test.tsx` around lines 82 - 96, Move
mock cleanup out of the assertion path in the useNativePlugins test suite by
adding an afterEach teardown alongside the existing beforeEach that calls
jest.restoreAllMocks(). Remove the local nowSpy.mockRestore() call so Date.now
and other spies are restored even when expectations fail.
🤖 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.

Nitpick comments:
In `@src/hooks/__tests__/useNativePlugins.test.tsx`:
- Around line 82-96: Move mock cleanup out of the assertion path in the
useNativePlugins test suite by adding an afterEach teardown alongside the
existing beforeEach that calls jest.restoreAllMocks(). Remove the local
nowSpy.mockRestore() call so Date.now and other spies are restored even when
expectations fail.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a11e7936-a788-4686-8816-5e27d23d4f39

📥 Commits

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

📒 Files selected for processing (4)
  • src/hooks/__tests__/useNativePlugins.test.tsx
  • src/hooks/useNativePlugins.ts
  • src/utils/__tests__/deferred-link.test.ts
  • src/utils/deferred-link.ts

@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

@innolope-dev thanks — this is a genuinely useful review, and the serverless-vs-server post-mortem is worth keeping (the CGNAT-collision + Private Relay points against fingerprinting deserve to be quotable next time someone proposes it). All three verified; here's where each lands:

1. Telemetry (#2587) — keep the PR, don't close it. Reviewed here: direction and privacy shape are right, but it's stacked on 5231ab7d1 and today's review fixes (ffc9e79e8) rewrote the exact consumption block you instrument — Android now retries on a transient null read instead of consuming, so post-rebase your no_handoff would fire once per retry launch and skew the very match-rate it measures. Two asks in the review: rebase onto the current branch tip, and give the transient path its own read_failed outcome. Then retarget to main once #2560 merges.

2. /invite dead-end — confirmed real. scripts/native-build.js:31 strips the invite dir from the export and the Android intent filter has no /invite entry, while the AASA is paths: ["*"] — so an iOS invite tap with the app installed lands nowhere. Taking your mapper-rewrite fix as a follow-up PR on TASK-20772 (it's a routing behavior change; this PR is deep in review). One implementation note for whoever picks it up: the cookie write can't live in mapDeepLink — it runs during render in the notifications list (the URIError guard comment exists for that reason) — so the side effect belongs in openDeepLink, or the code rides a query param the setup page reads. Good catch that no auth branch is needed.

3. Claim-link warning — agreed, and adopting it as a design constraint: the deferred channel carries only non-secret, replayable context (locale / invite / campaign / dest). Anything secret-bearing — #p= passwords above all — stays on "re-tap the link" / paste-your-link recovery, where the iOS pasteboard-theft → drained-single-use-link failure mode you describe can't happen. Your own plan flagging its own flaw is the most useful kind of review.

Also read #2584 — real pre-existing bug, and the fix shape is right (re-attach the fragment once above the branches so a new route can't forget it; the off-host reject still short-circuits before it). It composes cleanly with this PR: sanitizeRedirectURL already preserves hashes, and we don't touch native-routes.ts here, so no conflict in either merge order.

@jjramirezn
jjramirezn merged commit 4b1b482 into main Jul 30, 2026
25 of 27 checks passed
innolope-dev added a commit that referenced this pull request Jul 30, 2026
…telemetry branch

#2560's review pass replaced the unconditional one-shot burn with
markConsumed(): a transient android referrer read no longer consumes and
retries on the next launch.

Fold the telemetry into that: track whether the one-shot was actually burned
and skip the empty-outcome capture when it wasn't — an unconsumed restore is
a pending retry, not an organic install, and firing there would double-count
the install and file a broken read as organic. The android-null test moves to
the iOS empty-clipboard case, which is what an organic install now looks like
on the reporting side; a new test pins the silent-retry behaviour.

Merges the base branch rather than main: main carries unsigned content-submodule
commits that the signature ruleset re-flags on a feature ref (GH013).
kushagrasarathe added a commit that referenced this pull request Aug 3, 2026
…ge un-prune

Carries peanut-ui#2598: the #2560 review fixes (one-shot retry on
transient reads, iOS consume-before-prompt, session invite cookie —
fixes the login lockout live since 1.0.43 — must-map dests, 10s
late-nav window) and keeps dev/deferred in the bundled export so the
deferred-link test surface stops reload-looping into a setup bounce.
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.

3 participants