-
Notifications
You must be signed in to change notification settings - Fork 14
feat(native): deferred deep linking through the store install (TASK-20772) #2559
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
e74cb8e
dbd0d50
ae74a49
d6ce7e9
3428b37
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,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<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() | ||
| } | ||
|
|
||
| // 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() | ||
|
Comment on lines
+63
to
+67
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. 🎯 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 || trueRepository: 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.jsRepository: 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.tsRepository: peanutprotocol/peanut-ui Length of output: 9130 Defer this gate until runtime 🤖 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> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| // deferred-deep-link wiring in useNativePlugins: the restored dest must land | ||
| // unless a real deep link actually navigated, and the restored locale must be | ||
| // applied via setLocale — this is the only place either happens, so it needs | ||
| // its own coverage (the pure payload logic is tested in deferred-link.test.ts). | ||
| import { renderHook, waitFor } from '@testing-library/react' | ||
| import { useNativePlugins } from '../useNativePlugins' | ||
| import { restoreDeferredContext } from '@/utils/deferred-link' | ||
|
|
||
| const push = jest.fn() | ||
| jest.mock('next/navigation', () => ({ | ||
| useRouter: () => ({ push, back: jest.fn() }), | ||
| })) | ||
|
|
||
| jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() })) | ||
|
|
||
| jest.mock('@/utils/capacitor', () => ({ | ||
| isCapacitor: jest.fn(() => true), | ||
| getPlatform: jest.fn(() => 'android-native'), | ||
| })) | ||
|
|
||
| const setLocale = jest.fn(() => Promise.resolve()) | ||
| jest.mock('@/i18n/app/AppIntlProvider', () => ({ | ||
| useAppLocale: () => ({ locale: 'en', setLocale }), | ||
| })) | ||
|
|
||
| jest.mock('@/i18n/app/locale-store', () => ({ | ||
| localeApplied: jest.fn(() => Promise.resolve()), | ||
| })) | ||
|
|
||
| jest.mock('@/services/onesignal', () => ({ | ||
| getOneSignalAdapter: jest.fn(() => Promise.resolve({ onNotificationClick: jest.fn(() => () => {}) })), | ||
| })) | ||
|
|
||
| let launchUrl: string | undefined | ||
| jest.mock('@capacitor/app', () => ({ | ||
| App: { | ||
| getLaunchUrl: jest.fn(() => Promise.resolve(launchUrl ? { url: launchUrl } : undefined)), | ||
| addListener: jest.fn(() => Promise.resolve({ remove: jest.fn() })), | ||
| minimizeApp: jest.fn(), | ||
| }, | ||
| })) | ||
|
|
||
| jest.mock('@capacitor/status-bar', () => ({ | ||
| StatusBar: { setOverlaysWebView: jest.fn(), setStyle: jest.fn(), setBackgroundColor: jest.fn() }, | ||
| Style: { Light: 'LIGHT' }, | ||
| })) | ||
|
|
||
| jest.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: jest.fn() } })) | ||
|
|
||
| jest.mock('@/utils/deferred-link', () => ({ | ||
| restoreDeferredContext: jest.fn(() => Promise.resolve(null)), | ||
| })) | ||
|
|
||
| const mockRestore = restoreDeferredContext as jest.MockedFunction<typeof restoreDeferredContext> | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks() | ||
| launchUrl = undefined | ||
| }) | ||
|
|
||
| describe('useNativePlugins deferred restore wiring', () => { | ||
| it('pushes the restored dest and applies the locale when there is no launch url', async () => { | ||
| mockRestore.mockResolvedValue({ dest: '/claim?x=1', locale: 'es-419' }) | ||
|
|
||
| renderHook(() => useNativePlugins()) | ||
|
|
||
| await waitFor(() => expect(push).toHaveBeenCalledWith('/claim?x=1')) | ||
| await waitFor(() => expect(setLocale).toHaveBeenCalledWith('es-419')) | ||
| }) | ||
|
|
||
| it('yields the landing to a deep link that actually navigated, but still applies the locale', async () => { | ||
| launchUrl = 'https://peanut.me/home' | ||
| mockRestore.mockResolvedValue({ dest: '/claim?x=1', locale: 'pt-BR' }) | ||
|
|
||
| renderHook(() => useNativePlugins()) | ||
|
|
||
| await waitFor(() => expect(setLocale).toHaveBeenCalledWith('pt-BR')) | ||
| expect(push).toHaveBeenCalledWith('/home') | ||
| expect(push).not.toHaveBeenCalledWith('/claim?x=1') | ||
| }) | ||
|
|
||
| it('still pushes the restored dest when the launch url was rejected (off-host)', async () => { | ||
| launchUrl = 'https://evil.com/x' | ||
| mockRestore.mockResolvedValue({ dest: '/claim?x=1', locale: null }) | ||
|
|
||
| renderHook(() => useNativePlugins()) | ||
|
|
||
| await waitFor(() => expect(push).toHaveBeenCalledWith('/claim?x=1')) | ||
| expect(setLocale).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('a restore failure never breaks the rest of init', async () => { | ||
| mockRestore.mockRejectedValue(new Error('boom')) | ||
|
|
||
| renderHook(() => useNativePlugins()) | ||
|
|
||
| const { SplashScreen } = jest.requireMock('@capacitor/splash-screen') | ||
| await waitFor(() => expect(SplashScreen.hide).toHaveBeenCalled()) | ||
| expect(push).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Track clipboard success per payload.
Building a new payload leaves
copiedtrue; 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
Also applies to: 94-116
🤖 Prompt for AI Agents