-
Notifications
You must be signed in to change notification settings - Fork 14
feat(native): deferred deep linking through the store install (TASK-20772) #2557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e7c688b
20ddaf3
7a951f0
62047d7
b39fffa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package me.peanut.wallet; | ||
|
|
||
| import android.os.Handler; | ||
| import android.os.Looper; | ||
|
|
||
| import com.android.installreferrer.api.InstallReferrerClient; | ||
| import com.android.installreferrer.api.InstallReferrerStateListener; | ||
| import com.getcapacitor.JSObject; | ||
| import com.getcapacitor.Plugin; | ||
| import com.getcapacitor.PluginCall; | ||
| import com.getcapacitor.PluginMethod; | ||
| import com.getcapacitor.annotation.CapacitorPlugin; | ||
|
|
||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
|
|
||
| /** | ||
| * exposes the play install referrer to JS for deferred deep linking: the web | ||
| * store-bounce appends ?referrer=<payload> to the play url, and after install | ||
| * the JS side (src/utils/deferred-link.ts) reads it back here exactly once. | ||
| * always resolves — {referrer: null} on any failure or after a 5s timeout | ||
| * (the referrer service can bind without ever calling back, and the JS side | ||
| * awaits this before finishing app init) — so the promise can never hang. | ||
| */ | ||
| @CapacitorPlugin(name = "InstallReferrer") | ||
| public class InstallReferrerPlugin extends Plugin { | ||
|
|
||
| private static final long TIMEOUT_MS = 5000; | ||
|
|
||
| @PluginMethod | ||
| public void getReferrer(PluginCall call) { | ||
| final InstallReferrerClient client = InstallReferrerClient.newBuilder(getContext()).build(); | ||
| final AtomicBoolean resolved = new AtomicBoolean(false); | ||
| final Handler timeoutHandler = new Handler(Looper.getMainLooper()); | ||
| final Runnable timeout = () -> resolveOnce(call, client, resolved, null); | ||
| timeoutHandler.postDelayed(timeout, TIMEOUT_MS); | ||
|
|
||
| try { | ||
| client.startConnection(new InstallReferrerStateListener() { | ||
| @Override | ||
| public void onInstallReferrerSetupFinished(int responseCode) { | ||
| timeoutHandler.removeCallbacks(timeout); | ||
| String referrer = null; | ||
| try { | ||
| if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { | ||
| referrer = client.getInstallReferrer().getInstallReferrer(); | ||
| } | ||
| // else: SERVICE_UNAVAILABLE / FEATURE_NOT_SUPPORTED / DEVELOPER_ERROR | ||
| } catch (Exception ignored) {} | ||
| resolveOnce(call, client, resolved, referrer); | ||
| } | ||
|
|
||
| @Override | ||
| public void onInstallReferrerServiceDisconnected() { | ||
| // one-shot read; resolve null instead of leaving the call pending | ||
| timeoutHandler.removeCallbacks(timeout); | ||
| resolveOnce(call, client, resolved, null); | ||
| } | ||
| }); | ||
| } catch (Exception e) { | ||
| timeoutHandler.removeCallbacks(timeout); | ||
| resolveOnce(call, client, resolved, null); | ||
| } | ||
| } | ||
|
|
||
| private void resolveOnce(PluginCall call, InstallReferrerClient client, AtomicBoolean resolved, String referrer) { | ||
| if (!resolved.compareAndSet(false, true)) return; | ||
| try { | ||
| client.endConnection(); | ||
| } catch (Exception ignored) {} | ||
| JSObject ret = new JSObject(); | ||
| ret.put("referrer", referrer); | ||
| call.resolve(ret); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| 'use client' | ||
|
|
||
| // dev tool for TASK-20772 deferred deep linking: inspect the hand-off state, | ||
| // build/copy payloads on web, read the raw android referrer, and simulate a | ||
| // restore from any raw string (the android dev loop — real referrer data only | ||
| // exists for play-delivered installs). | ||
| import { useCallback, useEffect, useState } from 'react' | ||
| import { | ||
| applyDeferredPayload, | ||
| buildDeferredPayload, | ||
| copyIOSHandoff, | ||
| iosHandoffString, | ||
| parseDeferredPayload, | ||
| playStoreUrlWithReferrer, | ||
| readInstallReferrer, | ||
| CONSUMED_KEY, | ||
| } from '@/utils/deferred-link' | ||
| import { getFromCookie } from '@/utils/general.utils' | ||
| import { getPlatform } from '@/utils/capacitor' | ||
| import { useAppLocale } from '@/i18n/app/AppIntlProvider' | ||
|
|
||
| export default function DeferredLinkDevPage() { | ||
| const { locale, setLocale } = useAppLocale() | ||
| const [state, setState] = useState<Record<string, string>>({}) | ||
| const [payload, setPayload] = useState('') | ||
| const [rawReferrer, setRawReferrer] = useState('(not read)') | ||
| const [simulateInput, setSimulateInput] = useState('') | ||
| const [simulateResult, setSimulateResult] = useState('') | ||
| const [copied, setCopied] = useState(false) | ||
|
|
||
| const refresh = useCallback(() => { | ||
| setState({ | ||
| platform: getPlatform(), | ||
| appLocale: locale, | ||
| consumedFlag: localStorage.getItem(CONSUMED_KEY) ?? '(unset)', | ||
| inviteCodeCookie: String(getFromCookie('inviteCode') ?? '(unset)'), | ||
| campaignTagCookie: String(getFromCookie('campaignTag') ?? '(unset)'), | ||
| }) | ||
| }, [locale]) | ||
|
|
||
| useEffect(() => refresh(), [refresh]) | ||
|
|
||
| const readReferrer = async () => { | ||
| setRawReferrer((await readInstallReferrer()) ?? '(null / unavailable)') | ||
| } | ||
|
|
||
| const simulate = async () => { | ||
| const parsed = parseDeferredPayload(simulateInput) | ||
| if (!parsed) { | ||
| setSimulateResult('rejected (no pnutdl marker)') | ||
| } else { | ||
| const { dest, locale: restoredLocale } = applyDeferredPayload(parsed) | ||
| if (restoredLocale) await setLocale(restoredLocale) | ||
| setSimulateResult( | ||
| `applied ${JSON.stringify(parsed)} → dest: ${dest ?? 'none'}, locale: ${restoredLocale ?? 'none'}` | ||
| ) | ||
| } | ||
| refresh() | ||
| } | ||
|
Comment on lines
+22
to
+59
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
fd -t f middleware.ts src
rg -n "NODE_ENV|process.env" src/app/dev --type=tsx --type=ts -g '!**/node_modules/**'
fd -t d dev src/appRepository: peanutprotocol/peanut-ui Length of output: 260 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== app/dev tree ==\n'
find src/app/dev -maxdepth 3 -type f | sort
printf '\n== app route/layout files near dev ==\n'
find src/app -maxdepth 3 \( -name 'layout.tsx' -o -name 'page.tsx' -o -name 'middleware.ts' -o -name 'route.ts' \) | sort | sed -n '1,200p'
printf '\n== search for dev-only gating ==\n'
rg -n "NODE_ENV|process\.env|DEV|development|production|draftMode|redirect\\(|notFound\\(" src/app src/middleware.ts src -g '!**/node_modules/**' || trueRepository: peanutprotocol/peanut-ui Length of output: 50380 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- src/app/dev/deferred/page.tsx ---'
cat -n src/app/dev/deferred/page.tsx | sed -n '1,220p'
echo
echo '--- src/app/dev tree files ---'
find src/app/dev -maxdepth 3 -type f | sort | xargs -I{} sh -c 'echo; echo "### {}"; cat -n "{}" | sed -n "1,220p"'
echo
echo '--- search for route-level gating around dev paths ---'
rg -n "redirect\\(|notFound\\(|draftMode|process\\.env|NODE_ENV|export const dynamic|export const runtime|cookies\\(|headers\\(" src/app/dev src/app -g '!**/node_modules/**' || trueRepository: peanutprotocol/peanut-ui Length of output: 50382 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- src/proxy.ts ---'
cat -n src/proxy.ts | sed -n '1,120p'
echo
echo '--- deferred-link utilities ---'
rg -n "export .*applyDeferredPayload|export .*parseDeferredPayload|export .*CONSUMED_KEY|inviteCode|campaignTag|setCookie|cookie" src/utils/deferred-link* src/utils -g '!**/node_modules/**' || true
echo
echo '--- src/utils/deferred-link file(s) ---'
for f in $(git ls-files 'src/utils/deferred-link*'); do
echo
echo "### $f"
cat -n "$f" | sed -n '1,260p'
doneRepository: peanutprotocol/peanut-ui Length of output: 27478 Gate 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="mx-auto flex max-w-2xl flex-col gap-6 p-6 font-mono text-sm"> | ||
| <h1 className="text-lg font-bold">deferred deep link — dev</h1> | ||
|
|
||
| <section> | ||
| <h2 className="font-bold">state</h2> | ||
| <pre className="whitespace-pre-wrap border border-n-1 p-2">{JSON.stringify(state, null, 2)}</pre> | ||
| <div className="flex gap-2"> | ||
| <button className="border border-n-1 px-2 py-1" onClick={refresh}> | ||
| refresh | ||
| </button> | ||
| <button | ||
| className="border border-n-1 px-2 py-1" | ||
| onClick={() => { | ||
| localStorage.removeItem(CONSUMED_KEY) | ||
| refresh() | ||
| }} | ||
| > | ||
| reset consumed flag | ||
| </button> | ||
| </div> | ||
| </section> | ||
|
|
||
| <section> | ||
| <h2 className="font-bold">web → store hand-off</h2> | ||
| <button | ||
| className="border border-n-1 px-2 py-1" | ||
| onClick={() => setPayload(buildDeferredPayload('/home'))} | ||
| > | ||
| build payload from current context (dest=/home) | ||
| </button> | ||
| {payload && ( | ||
| <pre className="whitespace-pre-wrap break-all border border-n-1 p-2"> | ||
| payload: {payload} | ||
| {'\n\n'}play url: {playStoreUrlWithReferrer(payload)} | ||
| {'\n\n'}ios hand-off: {iosHandoffString(payload)} | ||
| </pre> | ||
| )} | ||
| {payload && ( | ||
| <button | ||
| className="border border-n-1 px-2 py-1" | ||
| onClick={async () => { | ||
| await copyIOSHandoff(payload) | ||
| setCopied(true) | ||
| }} | ||
| > | ||
| {copied ? 'copied ✓' : 'copy ios hand-off to clipboard'} | ||
| </button> | ||
| )} | ||
| </section> | ||
|
|
||
| <section> | ||
| <h2 className="font-bold">native: raw install referrer</h2> | ||
| <button className="border border-n-1 px-2 py-1" onClick={readReferrer}> | ||
| read raw referrer (android native only) | ||
| </button> | ||
| <pre className="whitespace-pre-wrap break-all border border-n-1 p-2">{rawReferrer}</pre> | ||
| </section> | ||
|
|
||
| <section> | ||
| <h2 className="font-bold">simulate restore</h2> | ||
| <textarea | ||
| className="w-full border border-n-1 p-2" | ||
| rows={3} | ||
| placeholder="pnutdl=1&lang=es-419&invite=test&dest=%2Fhome — or a full hand-off url" | ||
| value={simulateInput} | ||
| onChange={(e) => setSimulateInput(e.target.value)} | ||
| /> | ||
| <button className="border border-n-1 px-2 py-1" onClick={simulate}> | ||
| parse + apply | ||
| </button> | ||
| {simulateResult && ( | ||
| <pre className="whitespace-pre-wrap break-all border border-n-1 p-2">{simulateResult}</pre> | ||
| )} | ||
| </section> | ||
| </div> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Sanitize all API-derived values before continuing.
only_sha()protectsLIVE,TIP, andEXPECTED, but a failedTIP_ATrequest can still placegh’s error body intoTIP_AT; Line [135] then passes it todate -d, potentially leavingAGEinvalid and aborting underset -u. Also validateMIRROREDfrom Line [103] as an exact 40-character SHA instead of accepting[0-9a-f]+before the prefix comparison.🤖 Prompt for AI Agents