From e74cb8e451a5cfaf1848cb612f581b4a4112222c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:26:27 +0530 Subject: [PATCH 1/5] feat(native): deferred deep linking through the store install (TASK-20772) 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. --- android/app/build.gradle | 3 + .../peanut/wallet/InstallReferrerPlugin.java | 57 ++++++ .../java/me/peanut/wallet/MainActivity.java | 2 + src/app/dev/deferred/page.tsx | 141 ++++++++++++++ src/constants/general.consts.ts | 2 + src/hooks/useNativePlugins.ts | 7 + src/utils/__tests__/deferred-link.test.ts | 162 ++++++++++++++++ src/utils/deferred-link.ts | 180 ++++++++++++++++++ 8 files changed, 554 insertions(+) create mode 100644 android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java create mode 100644 src/app/dev/deferred/page.tsx create mode 100644 src/utils/__tests__/deferred-link.test.ts create mode 100644 src/utils/deferred-link.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index cf4bb6fbc3..d856012c69 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,6 +92,9 @@ dependencies { implementation "androidx.credentials:credentials:1.5.0" implementation "androidx.credentials:credentials-play-services-auth:1.5.0" + // play install referrer — deferred deep linking (InstallReferrerPlugin.java) + implementation "com.android.installreferrer:installreferrer:2.2" + testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java b/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java new file mode 100644 index 0000000000..36b7b76e71 --- /dev/null +++ b/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java @@ -0,0 +1,57 @@ +package me.peanut.wallet; + +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; + +/** + * exposes the play install referrer to JS for deferred deep linking: the web + * store-bounce appends ?referrer= 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 — so the JS restore path + * can treat every outcome the same way. + */ +@CapacitorPlugin(name = "InstallReferrer") +public class InstallReferrerPlugin extends Plugin { + + @PluginMethod + public void getReferrer(PluginCall call) { + final InstallReferrerClient client = InstallReferrerClient.newBuilder(getContext()).build(); + try { + client.startConnection(new InstallReferrerStateListener() { + @Override + public void onInstallReferrerSetupFinished(int responseCode) { + JSObject ret = new JSObject(); + try { + if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { + ret.put("referrer", client.getInstallReferrer().getInstallReferrer()); + } else { + // SERVICE_UNAVAILABLE / FEATURE_NOT_SUPPORTED / DEVELOPER_ERROR + ret.put("referrer", (String) null); + } + } catch (Exception e) { + ret.put("referrer", (String) null); + } finally { + try { + client.endConnection(); + } catch (Exception ignored) {} + } + call.resolve(ret); + } + + @Override + public void onInstallReferrerServiceDisconnected() { + // one-shot read; the JS consumed flag prevents retries + } + }); + } catch (Exception e) { + JSObject ret = new JSObject(); + ret.put("referrer", (String) null); + call.resolve(ret); + } + } +} diff --git a/android/app/src/main/java/me/peanut/wallet/MainActivity.java b/android/app/src/main/java/me/peanut/wallet/MainActivity.java index 943813c9fc..761e55f945 100644 --- a/android/app/src/main/java/me/peanut/wallet/MainActivity.java +++ b/android/app/src/main/java/me/peanut/wallet/MainActivity.java @@ -14,6 +14,8 @@ public class MainActivity extends BridgeActivity { @Override protected void onCreate(Bundle savedInstanceState) { + // app-local plugin, not auto-discovered — must register before super.onCreate + registerPlugin(InstallReferrerPlugin.class); super.onCreate(savedInstanceState); Bridge bridge = this.getBridge(); diff --git a/src/app/dev/deferred/page.tsx b/src/app/dev/deferred/page.tsx new file mode 100644 index 0000000000..de945c866a --- /dev/null +++ b/src/app/dev/deferred/page.tsx @@ -0,0 +1,141 @@ +'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 { registerPlugin } from '@capacitor/core' +import { + applyDeferredPayload, + buildDeferredPayload, + copyIOSHandoff, + iosHandoffString, + parseDeferredPayload, + playStoreUrlWithReferrer, + CONSUMED_KEY, + PREFERRED_LOCALE_KEY, +} from '@/utils/deferred-link' +import { getFromCookie } from '@/utils/general.utils' +import { getPlatform } from '@/utils/capacitor' + +const InstallReferrer = registerPlugin<{ getReferrer(): Promise<{ referrer: string | null }> }>('InstallReferrer') + +export default function DeferredLinkDevPage() { + const [state, setState] = useState>({}) + 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(), + consumedFlag: localStorage.getItem(CONSUMED_KEY) ?? '(unset)', + preferredLocale: localStorage.getItem(PREFERRED_LOCALE_KEY) ?? '(unset)', + inviteCodeCookie: String(getFromCookie('inviteCode') ?? '(unset)'), + campaignTagCookie: String(getFromCookie('campaignTag') ?? '(unset)'), + }) + }, []) + + useEffect(() => refresh(), [refresh]) + + const readReferrer = async () => { + try { + const { referrer } = await InstallReferrer.getReferrer() + setRawReferrer(referrer ?? '(null)') + } catch (e) { + setRawReferrer(`unavailable: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const simulate = () => { + const parsed = parseDeferredPayload(simulateInput) + if (!parsed) { + setSimulateResult('rejected (no pnutdl marker)') + } else { + const dest = applyDeferredPayload(parsed) + setSimulateResult(`applied ${JSON.stringify(parsed)} → dest: ${dest ?? 'none'}`) + } + refresh() + } + + return ( +
+

deferred deep link — dev

+ +
+

state

+
{JSON.stringify(state, null, 2)}
+
+ + +
+
+ +
+

web → store hand-off

+ + {payload && ( +
+                        payload: {payload}
+                        {'\n\n'}play url: {playStoreUrlWithReferrer(payload)}
+                        {'\n\n'}ios hand-off: {iosHandoffString(payload)}
+                    
+ )} + {payload && ( + + )} +
+ +
+

native: raw install referrer

+ +
{rawReferrer}
+
+ +
+

simulate restore

+