diff --git a/.github/workflows/content-pipeline-watchdog.yml b/.github/workflows/content-pipeline-watchdog.yml index c230a0c79f..b3aad9a5ea 100644 --- a/.github/workflows/content-pipeline-watchdog.yml +++ b/.github/workflows/content-pipeline-watchdog.yml @@ -49,11 +49,15 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -uo pipefail - - # Every API call is `|| true`: the step shell is `bash -e`, so an - # unguarded 403 aborts mid-script and surfaces as a bare red run - # indistinguishable from a real content alert. That happened once - # (actions:read 403). Collect what we can, then decide explicitly. + # `set +e` is deliberate, and `-e` here is actively harmful. + # GitHub runs this step as `bash -e {0}`, which combined with + # pipefail means ANY non-zero anywhere kills the script at that + # line with no output at all — a red run and an empty log. It bit + # twice: a 403 from the Actions API, then a `grep` with no match + # inside the mirrored-paths filter (grep exits 1 on no-match, which + # is a normal answer, not an error). This script decides everything + # explicitly below; it must never die silently mid-check. + set +e # ---- hop 3: what production is serving ----------------------- LIVE=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/contents/src/content?ref=main" --jq '.sha' || true) @@ -87,6 +91,15 @@ jobs: if [ -n "$PUBLISHED" ]; then EXPECTED="$SHA"; break; fi done + # On failure `gh` prints its error body to stdout, which would land + # in these vars and read as a bogus sha. Keep only real ones, so a + # broken call resolves to empty and trips the DEGRADED branch below + # instead of being compared as if it were data. + only_sha() { printf '%s' "${1:-}" | grep -Eox '[0-9a-f]{40}' || true; } + LIVE=$(only_sha "$LIVE") + TIP=$(only_sha "$TIP") + EXPECTED=$(only_sha "$EXPECTED") + MIRRORED=$(printf '%s' "$TIP_MSG" | sed -nE 's/.*mono@([0-9a-f]+).*/\1/p') echo "expected mono@${EXPECTED:0:7} | mirrored @${MIRRORED:-none} | peanut-content ${TIP:0:7} | live ${LIVE:0:7}" 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..93bedddc65 --- /dev/null +++ b/src/app/dev/deferred/page.tsx @@ -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>({}) + 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() + } + + 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

+