Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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);
}
}
2 changes: 2 additions & 0 deletions android/app/src/main/java/me/peanut/wallet/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 19 additions & 1 deletion ios/App/App/ClipboardDetectPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
}
}
146 changes: 146 additions & 0 deletions src/app/dev/deferred/page.tsx
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)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Track clipboard success per payload.

Building a new payload leaves copied true; 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
-    const [copied, setCopied] = useState(false)
+    const [copiedPayload, setCopiedPayload] = useState<string | null>(null)
...
-                    onClick={() => setPayload(buildDeferredPayload('/home'))}
+                    onClick={() => {
+                        setPayload(buildDeferredPayload('/home'))
+                        setCopiedPayload(null)
+                    }}
...
                             await copyIOSHandoff(payload)
-                            setCopied(true)
+                            setCopiedPayload(payload)
...
-                        {copied ? 'copied ✓' : 'copy ios hand-off to clipboard'}
+                        {copiedPayload === payload ? 'copied ✓' : 'copy ios hand-off to clipboard'}

Also applies to: 94-116

🤖 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/app/dev/deferred/page.tsx` at line 31, Update the deferred page’s copied
state and clipboard handler so success is tracked for the current payload only.
Reset copied whenever the payload changes, and associate each asynchronous copy
attempt with its payload so a completion from an older payload cannot set copied
for the new one.


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

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.

🎯 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 || true

Repository: 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.js

Repository: 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.ts

Repository: peanutprotocol/peanut-ui

Length of output: 9130


Defer this gate until runtime
In the native static export, the server render sees isCapacitor() as false, so this branch can turn the page into a 404 before the WebView loads. Use the build-time native flag here instead of the runtime bridge check.

🤖 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/app/dev/deferred/page.tsx` around lines 63 - 67, Update the gate in the
deferred page to use the build-time native flag rather than the runtime
isCapacitor() bridge check, while preserving the BASE_URL condition and existing
notFound() behavior for web production. Keep the change scoped to this
post-hooks access guard.


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>
)
}
2 changes: 2 additions & 0 deletions src/constants/general.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,5 @@ export const ENS_NAME_REGEX = /^(?:[-a-zA-Z0-9]+\.)+[-a-zA-Z0-9]+$/

// Mirrors the backend username minimum (USERNAME_REGEX = /^[a-z][a-z0-9]{3,11}$/).
export const USERNAME_MIN_LENGTH = 4

export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=me.peanut.wallet'
101 changes: 101 additions & 0 deletions src/hooks/__tests__/useNativePlugins.test.tsx
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()
})
})
Loading
Loading