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..d1ab53c8e1 --- /dev/null +++ b/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java @@ -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= 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); + } +} 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/ios/App/App/ClipboardDetectPlugin.swift b/ios/App/App/ClipboardDetectPlugin.swift index 23ceab6b8f..f8a134b0e7 100644 --- a/ios/App/App/ClipboardDetectPlugin.swift +++ b/ios/App/App/ClipboardDetectPlugin.swift @@ -13,10 +13,28 @@ public class ClipboardDetectPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "ClipboardDetectPlugin" public let jsName = "ClipboardDetect" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "hasStrings", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "hasStrings", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "hasProbableWebUrl", returnType: CAPPluginReturnPromise) ] @objc func hasStrings(_ call: CAPPluginCall) { call.resolve(["value": UIPasteboard.general.hasStrings]) } + + /* + * detectPatterns is also prompt-free: it reports pattern confidence + * without exposing content. Gates the deferred-deep-link clipboard read + * so only a probable web URL (the hand-off shape) triggers the real, + * prompt-raising read. + */ + @objc func hasProbableWebUrl(_ call: CAPPluginCall) { + UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in + switch result { + case .success(let patterns): + call.resolve(["value": patterns.contains(\.probableWebURL)]) + case .failure: + call.resolve(["value": false]) + } + } + } } diff --git a/src/app/dev/deferred/page.tsx b/src/app/dev/deferred/page.tsx new file mode 100644 index 0000000000..ec4e1b1ae3 --- /dev/null +++ b/src/app/dev/deferred/page.tsx @@ -0,0 +1,146 @@ +'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 { notFound } from 'next/navigation' +import { getFromCookie } from '@/utils/general.utils' +import { getPlatform, isCapacitor } from '@/utils/capacitor' +import { useAppLocale } from '@/i18n/app/AppIntlProvider' +import { BASE_URL } from '@/constants/general.consts' + +export default function DeferredLinkDevPage() { + const { locale, setLocale } = useAppLocale() + 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(), + 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() + } + + // 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() + + 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

+