From 63db160b80bb8595fc59fcb1db741405518a82cc Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 12:39:00 +0700 Subject: [PATCH] fix(amber): Amethyst-style foreground-intent signer to end the GrapheneOS sign hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the stock nostr-signer-capacitor-plugin (0.0.5) tries Amber's background ContentProvider first, which GrapheneOS blocks and never returns, with no clean fallback — so signing hung 90s and Amber never appeared. Fix (matches Amethyst, the reference NIP-55 client): - New native Capacitor plugin AmberIntent (android-templates/AmberIntentPlugin.java) that goes STRAIGHT to a foreground nostrsigner: intent via a properly-registered ActivityResultLauncher, tracks each request by id, and times out gracefully. - MainActivity now registers the launcher in onCreate (AndroidX contract) and hands it to the plugin, while staying a safe stock BridgeActivity (no WebViewClient override → no black screen). - inject-main-activity.mjs now installs both MainActivity.java and AmberIntentPlugin.java. - capacitorAmberSigner.ts rewired to registerPlugin('AmberIntent'); every op is a user-visible Amber Approve/Reject popup; rejections surface as a friendly message. - Removed the dead npm nostr-signer-capacitor-plugin dependency (and its phantom coracle fork spec) plus its vite external entry — signing no longer uses it. - Delete flow copy updated to the new foreground UX (Approve in Amber / Cancelled on reject) instead of the diagnostic SIGN/PUBLISH narration. Co-authored-by: shakespeare.diy --- android-templates/AmberIntentPlugin.java | 220 +++++++++++++++++++ android-templates/MainActivity.java | 51 ++++- package.json | 1 - scripts/inject-main-activity.mjs | 40 ++-- src/hooks/useDeleteEntity.ts | 16 +- src/lib/capacitorAmberSigner.ts | 262 ++++++++--------------- vite.config.ts | 9 +- 7 files changed, 395 insertions(+), 204 deletions(-) create mode 100644 android-templates/AmberIntentPlugin.java diff --git a/android-templates/AmberIntentPlugin.java b/android-templates/AmberIntentPlugin.java new file mode 100644 index 0000000..13c738f --- /dev/null +++ b/android-templates/AmberIntentPlugin.java @@ -0,0 +1,220 @@ +package org.skatespots.app; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.util.HashMap; +import java.util.Map; + +/** + * AmberIntentPlugin — the Amethyst-style NIP-55 foreground signer for Capacitor. + * + * WHY THIS EXISTS + * ─────────────── + * The stock `nostr-signer-capacitor-plugin` (0.0.5) tries Amber's background + * ContentProvider FIRST. On GrapheneOS (and other hardened ROMs) that silent + * cross-app ContentResolver query is blocked/stalls, and the plugin does not + * fall back cleanly to a foreground intent — so signing hangs until a 90s + * timeout and Amber never even appears. + * + * Amethyst (the reference NIP-55 client) never hits this: it goes STRAIGHT to a + * foreground Intent via an ActivityResultLauncher, tracks each request by id, + * and awaits the Activity result with a timeout. That is exactly what this + * plugin does — the reliable path that OPENS Amber's Approve/Reject screen and + * returns the signature, on stock Android AND on GrapheneOS. + * + * We deliberately do NOT touch the ContentProvider at all. Every request is a + * user-visible Amber popup (which is also the intuitive UX we want for + * newcomers). The launcher is registered from MainActivity.onCreate(). + * + * Reference: NIP-55 "Using Intents"; Amethyst IntentRequestManager. + */ +@CapacitorPlugin(name = "AmberIntent") +public class AmberIntentPlugin extends Plugin { + + private static final String AMBER_PACKAGE = "com.greenart7c3.nostrsigner"; + // How long we wait for Amber's Activity result before giving up gracefully. + // Short enough that the user is never left staring at a dead spinner. + private static final long TIMEOUT_MS = 60_000; + + // The launcher is created/registered by MainActivity (must happen before the + // Activity is STARTED, per the AndroidX contract). We keep a static handle so + // the plugin instance can invoke it. + private static ActivityResultLauncher launcher; + + // Pending calls keyed by request id, so a result can be matched back to the + // JS promise that started it — exactly like Amethyst's IntentRequestManager. + private static final Map pending = new HashMap<>(); + private static final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private static class PendingCall { + final PluginCall call; + final Runnable timeout; + PendingCall(PluginCall call, Runnable timeout) { + this.call = call; + this.timeout = timeout; + } + } + + /** Called by MainActivity to hand us the pre-registered launcher. */ + public static void setLauncher(ActivityResultLauncher l) { + launcher = l; + } + + /** + * Called by MainActivity's ActivityResultCallback when Amber returns. + * Matches the result to its pending call by the "id" extra and resolves it. + */ + public static void handleResult(ActivityResult result) { + Intent data = result.getData(); + // The request id is echoed back by Amber in the "id" extra. If missing + // (older Amber), fall back to resolving the single most-recent pending + // call so we still complete rather than hang. + String id = data != null ? data.getStringExtra("id") : null; + + PendingCall pc = null; + synchronized (pending) { + if (id != null && pending.containsKey(id)) { + pc = pending.remove(id); + } else if (pending.size() == 1) { + String onlyKey = pending.keySet().iterator().next(); + pc = pending.remove(onlyKey); + } + } + if (pc == null) { + // Duplicate / unknown / late delivery — drop it (never resolve twice). + return; + } + mainHandler.removeCallbacks(pc.timeout); + + if (result.getResultCode() != Activity.RESULT_OK || data == null) { + // A non-OK result means Amber failed/crashed (NOT a user rejection). + pc.call.reject("Amber returned no result (the signer may have been closed)."); + return; + } + + // NIP-55: user rejection comes back as RESULT_OK with a "rejected" extra. + if (data.getBooleanExtra("rejected", false) + || "true".equals(data.getStringExtra("rejected"))) { + pc.call.reject("rejected"); + return; + } + + JSObject ret = new JSObject(); + ret.put("result", data.getStringExtra("result")); + ret.put("event", data.getStringExtra("event")); + ret.put("package", data.getStringExtra("package")); + ret.put("id", id); + pc.call.resolve(ret); + } + + // ── JS-facing methods ──────────────────────────────────────────────────── + + /** + * getPublicKey: opens Amber to request the pubkey (and pre-authorize + * permissions). For get_public_key the NIP-55 spec says to OMIT the package + * on the intent so Android shows the signer chooser / opens Amber's UI. + */ + @PluginMethod + public void getPublicKey(final PluginCall call) { + String permissions = call.getString("permissions", ""); + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")); + intent.putExtra("type", "get_public_key"); + if (permissions != null && !permissions.isEmpty()) { + intent.putExtra("permissions", permissions); + } + launch(call, intent, "getPublicKey"); + } + + /** signEvent: foreground intent that opens Amber's Approve/Reject popup. */ + @PluginMethod + public void signEvent(final PluginCall call) { + String eventJson = call.getString("eventJson", ""); + String npub = call.getString("npub", ""); + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:" + eventJson)); + intent.setPackage(AMBER_PACKAGE); + intent.putExtra("type", "sign_event"); + intent.putExtra("current_user", npub); + launch(call, intent, "signEvent"); + } + + @PluginMethod + public void nip04Encrypt(final PluginCall call) { encryptDecrypt(call, "nip04_encrypt"); } + @PluginMethod + public void nip04Decrypt(final PluginCall call) { encryptDecrypt(call, "nip04_decrypt"); } + @PluginMethod + public void nip44Encrypt(final PluginCall call) { encryptDecrypt(call, "nip44_encrypt"); } + @PluginMethod + public void nip44Decrypt(final PluginCall call) { encryptDecrypt(call, "nip44_decrypt"); } + + private void encryptDecrypt(final PluginCall call, String type) { + String payload = call.getString("payload", ""); + String pubKey = call.getString("pubKey", ""); + String npub = call.getString("npub", ""); + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:" + payload)); + intent.setPackage(AMBER_PACKAGE); + intent.putExtra("type", type); + intent.putExtra("pubKey", pubKey); + intent.putExtra("current_user", npub); + launch(call, intent, type); + } + + // ── Core launch + result-tracking machinery ──────────────────────────────── + + private void launch(final PluginCall call, final Intent intent, final String label) { + if (launcher == null) { + call.reject("Amber launcher not ready. Reopen the app and try again."); + return; + } + + // Track by a random id echoed back by Amber, so we can match the result. + final String id = System.currentTimeMillis() + "_" + Math.round(Math.random() * 1e9); + intent.putExtra("id", id); + // singleTop|clearTop lets Amber return without stacking duplicate tasks — + // the same flags Amethyst uses for reliable round-trips. + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + // Graceful timeout: if Amber never returns, reject with a clear message + // instead of hanging forever (the old 90s "Waiting for Amber" bug). + final Runnable timeout = new Runnable() { + @Override public void run() { + PendingCall pc; + synchronized (pending) { pc = pending.remove(id); } + if (pc != null) { + pc.call.reject("Amber didn't respond. Make sure Amber is installed and try again."); + } + } + }; + + synchronized (pending) { pending.put(id, new PendingCall(call, timeout)); } + mainHandler.postDelayed(timeout, TIMEOUT_MS); + + // Launch on the UI thread — ActivityResultLauncher requires it. + mainHandler.post(new Runnable() { + @Override public void run() { + try { + launcher.launch(intent); + } catch (Exception e) { + PendingCall pc; + synchronized (pending) { pc = pending.remove(id); } + mainHandler.removeCallbacks(timeout); + if (pc != null) { + pc.call.reject("Couldn't open Amber (" + label + "). Is Amber installed?"); + } + } + } + }); + } +} diff --git a/android-templates/MainActivity.java b/android-templates/MainActivity.java index 58a02f2..997b604 100644 --- a/android-templates/MainActivity.java +++ b/android-templates/MainActivity.java @@ -1,20 +1,53 @@ package org.skatespots.app; +import android.content.Intent; +import android.os.Bundle; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; + import com.getcapacitor.BridgeActivity; /** - * Default Capacitor launcher activity. + * SkateSpots launcher activity. * - * This is the STOCK Capacitor MainActivity — it does NOT override the - * WebViewClient or anything else. An earlier custom version overrode the - * WebViewClient and wiped out the client Capacitor needs to load the web layer, - * causing a black-screen crash on launch. This minimal version simply exists so - * the AndroidManifest has a valid launcher class to point at, and lets Capacitor - * boot normally. + * SAFE BY DESIGN: this extends the stock Capacitor BridgeActivity and does NOT + * override the WebViewClient (an earlier custom version did and caused a + * black-screen crash). The ONLY thing we add is the registration of the + * ActivityResultLauncher used by AmberIntentPlugin. + * + * WHY THE LAUNCHER LIVES HERE + * ─────────────────────────── + * AndroidX requires registerForActivityResult(...) to be called BEFORE the + * Activity is STARTED (i.e. during onCreate). A Capacitor Plugin is instantiated + * too late to register its own launcher, so we create it here and hand it to the + * plugin via AmberIntentPlugin.setLauncher(). Results are routed straight into + * AmberIntentPlugin.handleResult(). + * + * This is the Amethyst NIP-55 pattern: a foreground-intent signer whose result + * comes back through a properly-registered ActivityResultLauncher — the reliable + * path that OPENS Amber's Approve/Reject screen and never hangs, on stock Android + * and on GrapheneOS alike. * * Injected into the generated android/ project at build time by - * scripts/inject-main-activity.mjs so the correct class is always present even - * after `rm -rf android` / `cap add`. + * scripts/inject-main-activity.mjs. */ public class MainActivity extends BridgeActivity { + + private ActivityResultLauncher amberLauncher; + + @Override + public void onCreate(Bundle savedInstanceState) { + // Register the launcher BEFORE super.onCreate() puts us past STARTED. + amberLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> AmberIntentPlugin.handleResult(result) + ); + AmberIntentPlugin.setLauncher(amberLauncher); + + // Register our native plugin so the WebView can call it. + registerPlugin(AmberIntentPlugin.class); + + super.onCreate(savedInstanceState); + } } diff --git a/package.json b/package.json index f9e5776..3210fd2 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.462.0", "ngeohash": "^0.6.3", - "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-tools": "2.13.0", "npubcash-sdk": "^0.2.0", "qrcode": "^1.5.4", diff --git a/scripts/inject-main-activity.mjs b/scripts/inject-main-activity.mjs index 69ff414..ff30835 100644 --- a/scripts/inject-main-activity.mjs +++ b/scripts/inject-main-activity.mjs @@ -19,8 +19,13 @@ import { dirname, join } from 'node:path'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const targetDir = join(root, 'android', 'app', 'src', 'main', 'java', 'org', 'skatespots', 'app'); -const target = join(targetDir, 'MainActivity.java'); -const template = join(root, 'android-templates', 'MainActivity.java'); +const templatesDir = join(root, 'android-templates'); + +// Native Java sources kept as the source of truth in android-templates/ and +// copied into the generated project on every build. MainActivity registers the +// Amber ActivityResultLauncher; AmberIntentPlugin is the Amethyst-style NIP-55 +// foreground signer that opens Amber's Approve/Reject screen and never hangs. +const files = ['MainActivity.java', 'AmberIntentPlugin.java']; // If the android/ project hasn't been generated yet, do nothing. if (!existsSync(join(root, 'android', 'app'))) { @@ -28,22 +33,27 @@ if (!existsSync(join(root, 'android', 'app'))) { process.exit(0); } -if (!existsSync(template)) { - console.error('[inject-main-activity] template android-templates/MainActivity.java missing — aborting.'); - process.exit(1); -} - if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } -const desired = readFileSync(template, 'utf8'); -const current = existsSync(target) ? readFileSync(target, 'utf8') : null; +for (const file of files) { + const template = join(templatesDir, file); + const target = join(targetDir, file); -if (current === desired) { - console.log('[inject-main-activity] MainActivity.java already correct.'); - process.exit(0); + if (!existsSync(template)) { + console.error(`[inject-main-activity] template android-templates/${file} missing — aborting.`); + process.exit(1); + } + + const desired = readFileSync(template, 'utf8'); + const current = existsSync(target) ? readFileSync(target, 'utf8') : null; + + if (current === desired) { + console.log(`[inject-main-activity] ${file} already correct.`); + continue; + } + + copyFileSync(template, target); + console.log(`[inject-main-activity] ✓ Ensured ${file} is present (Amber foreground signer + safe launcher).`); } - -copyFileSync(template, target); -console.log('[inject-main-activity] ✓ Ensured stock MainActivity.java is present (fixes black-screen crash).'); diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index 0ebbf52..53851fc 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -86,9 +86,12 @@ export function useDeleteEntity() { }; // ── PHASE 1: SIGN ──────────────────────────────────────────────────────── + // With the Amethyst-style foreground signer, Amber pops up its Approve/Reject + // screen directly — no more silent ContentProvider stall. The signer has its + // own graceful timeout, but we keep an outer guard as a belt-and-braces. toast({ - title: 'Step 1/2: Signing…', - description: 'Approve the request in Amber, then come back to the app.', + title: 'Approve in Amber', + description: 'Amber will open — tap Approve to confirm the deletion.', }); if (!user.signer) { @@ -104,9 +107,14 @@ export function useDeleteEntity() { ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); + // Distinguish a deliberate rejection from a real failure. + if (/reject/i.test(msg)) { + toast({ title: 'Cancelled', description: 'You rejected the request in Amber.' }); + throw new Error('Signing cancelled.'); + } toast({ - title: 'Stuck at SIGNING', - description: `Amber step failed: ${msg}`, + title: "Couldn't sign", + description: msg, variant: 'destructive', }); throw new Error(`SIGN step failed: ${msg}`); diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index 718dc40..59f950e 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -1,30 +1,29 @@ /** * CapacitorAmberSigner * - * Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the - * Capacitor bridge. + * Talks to Amber (NIP-55) using the AMETHYST FOREGROUND-INTENT pattern via our + * own native `AmberIntent` Capacitor plugin (android-templates/AmberIntentPlugin.java). * - * WHY WE DON'T `import('nostr-signer-capacitor-plugin')` ANYMORE - * ------------------------------------------------------------- - * The package is marked `external` in vite.config.ts and the native build uses - * `inlineDynamicImports`, so a runtime `import('nostr-signer-capacitor-plugin')` - * stays in the bundle as a BARE specifier the Android WebView cannot resolve. - * That import threw, `getPlugin()` returned null, and every signing operation - * (including the Blossom upload auth event) failed with - * "Capacitor Amber plugin not available". + * WHY WE REPLACED THE OLD `NostrSignerPlugin` PATH + * ------------------------------------------------ + * The stock `nostr-signer-capacitor-plugin` (0.0.5) tries Amber's background + * ContentProvider FIRST. On GrapheneOS (and other hardened ROMs) that silent + * cross-app ContentResolver query is blocked and never returns, and the plugin + * does not fall back cleanly to a foreground intent — so signing hung for 90s + * and Amber never even appeared. That is the delete/zap/crew hang. * - * THE FIX: the plugin's JS layer is just a thin wrapper around - * `registerPlugin('NostrSignerPlugin')` from @capacitor/core. @capacitor/core - * IS bundled, so we call `registerPlugin` ourselves to get the native bridge - * proxy directly and reimplement the (tiny, well-known) wrapper logic here. - * This is the same runtime-plugin pattern that fixed the camera + white-screen. + * Amethyst (the reference NIP-55 client) never hits this: it goes STRAIGHT to a + * foreground Intent through a properly-registered ActivityResultLauncher, tracks + * each request by id, and awaits the Activity result with a timeout. Our + * `AmberIntent` plugin does exactly that — the reliable path that OPENS Amber's + * Approve/Reject screen and returns the signature, on stock Android AND on + * GrapheneOS. Every request is a user-visible Amber popup (the intuitive UX). * - * API shape reference: coracle-social/nostr-signer-capacitor-plugin#main - * native.signEvent({ packageName, eventJson, eventId, npub }) - * → { signature, id, event } - * native.getPublicKey({ packageName, permissions? }) → { npub, package } - * native.nipXXEncrypt/Decrypt({ packageName, plainText|encryptedText, pubKey, npub, id }) - * → { result, id } + * NATIVE API (AmberIntent): + * getPublicKey({ permissions? }) → { result: , package } + * signEvent({ eventJson, npub }) → { result: , event } + * nipXXEncrypt/Decrypt({ payload, pubKey, npub }) → { result } + * A user rejection rejects the promise with message "rejected". */ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; @@ -32,114 +31,55 @@ import { Capacitor, registerPlugin } from '@capacitor/core'; import { nip19 } from 'nostr-tools'; import { reconstructSignedEvent } from '@/lib/amber'; -const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner'; - -/** Raw native bridge interface (Android). All params are options objects. */ -interface NostrSignerNative { - setPackageName(options: { packageName: string }): Promise; - isExternalSignerInstalled(options?: { packageName?: string }): Promise<{ installed: boolean }>; - getPublicKey(options?: { packageName?: string; permissions?: string }): Promise<{ npub: string; package?: string }>; - signEvent(options: { packageName?: string; eventJson: string; eventId: string; npub: string }): Promise<{ signature?: string; id?: string; event: string }>; - nip04Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; - nip04Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; - nip44Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; - nip44Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; -} - -function makeId(): string { - return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; -} - -// How long to wait for Amber to return before giving up. Amber is a separate -// app: the user has to switch to it, approve, and switch back. If the native -// activity-result never fires (user backs out, Amber not granted, etc.) the -// bridge promise would otherwise hang FOREVER — which is exactly the "stuck -// loading" symptom on the crew logo upload and crew publish. This turns that -// silent hang into a clear, catchable error. -const AMBER_TIMEOUT_MS = 90_000; - -/** - * Race a bridge promise against a timeout so a hung Amber round-trip surfaces - * as an error instead of spinning forever. - */ -function withTimeout(promise: Promise, label: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( - new Error( - `Amber didn't respond to the ${label} request. Open Amber, approve the request, then try again. ` + - `If nothing appeared, make sure Amber is installed and SkateSpots is allowed to use it.`, - ), - ); - }, AMBER_TIMEOUT_MS); - - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (err) => { - clearTimeout(timer); - reject(err); - }, - ); - }); +/** Raw native bridge interface for our AmberIntent plugin (Android). */ +interface AmberIntentNative { + getPublicKey(options?: { permissions?: string }): Promise<{ result?: string; package?: string; id?: string }>; + signEvent(options: { eventJson: string; npub: string }): Promise<{ result?: string; event?: string; id?: string }>; + nip04Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; + nip04Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; + nip44Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; + nip44Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>; } // Lazily build the native bridge proxy once. `registerPlugin` is a pure factory // (builds a proxy, touches no native APIs at call time), so it's safe on web too // — the proxy simply has no implementation there and calls reject. -let nativePlugin: NostrSignerNative | null = null; -function getNativeSigner(): NostrSignerNative | null { +let nativePlugin: AmberIntentNative | null = null; +function getNativeSigner(): AmberIntentNative | null { try { if (!Capacitor?.isNativePlatform?.()) return null; } catch { return null; } if (!nativePlugin) { - nativePlugin = registerPlugin('NostrSignerPlugin'); + nativePlugin = registerPlugin('AmberIntent'); } return nativePlugin; } -// NOTE: We deliberately do NOT call setPackageName / pass packageName on -// sign/encrypt/decrypt calls. Providing a packageName makes the native plugin -// try Amber's ContentProvider (a background, no-UI query) first, which stalls on -// GrapheneOS (and some other ROMs) so Amber's approval screen never appears and -// the request just times out. Omitting it forces the plugin straight to -// startActivityForResult(nostrsigner:) which launches Amber's UI — the reliable -// path the login flow already uses. +/** Turn Amber's "rejected" rejection into a friendly, catchable message. */ +function normalizeAmberError(e: unknown): Error { + const msg = (e as Error)?.message ?? String(e); + if (/rejected/i.test(msg)) { + return new Error('You rejected the request in Amber.'); + } + return e instanceof Error ? e : new Error(msg); +} /** - * Login: get public key from Amber. + * Login: get public key from Amber via the foreground intent. * - * The Java bridge calls npubToHex() before returning, so the "npub" field is - * actually a hex pubkey string despite the misleading name (older builds). We - * handle both hex and real npub just in case. + * The native plugin OMITS the package on get_public_key (per NIP-55) so Android + * opens Amber's UI directly — no ContentProvider, no GrapheneOS stall. We + * pre-authorize all the permissions the app needs so Amber can (optionally) + * remember the user's choice for a smoother, popup-light experience afterwards. */ export async function getAmberPublicKeyViaPlugin(): Promise { - // Get the raw native bridge directly. CRITICALLY we do NOT call - // setPackageName here and we do NOT pass packageName to getPublicKey. - // - // WHY: the native getPublicKey first tries Amber's ContentProvider (a - // background, no-UI query) whenever a packageName is known. On GrapheneOS that - // cross-app ContentResolver query is blocked/stalls and never returns cleanly, - // so the code never falls through to the intent that actually opens Amber's - // approval screen → eternal "Waiting for Amber". By omitting packageName, the - // plugin skips the content-resolver branch and goes STRAIGHT to - // startActivityForResult(nostrsigner:) which launches Amber's UI. Amber returns - // both the pubkey AND its package, so subsequent signing still works. const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } - // Request ALL permissions the app will need UP FRONT, so Amber pre-authorizes - // signing + encryption at login time. Without this, later signEvent/encrypt - // calls trigger a per-action permission check that, on GrapheneOS, routes - // through Amber's ContentProvider and stalls WITHOUT ever showing a prompt — - // the "Amber never pops up, delete hangs" bug. Pre-granting means signing - // afterwards is a fast background round-trip with no surprise stall. const permissions = JSON.stringify([ { type: 'get_public_key' }, { type: 'sign_event' }, @@ -149,14 +89,16 @@ export async function getAmberPublicKeyViaPlugin(): Promise { { type: 'nip44_decrypt' }, ]); - const result = await withTimeout( - plugin.getPublicKey({ permissions }), - 'login', - ); + let result: { result?: string; package?: string }; + try { + result = await plugin.getPublicKey({ permissions }); + } catch (e) { + throw normalizeAmberError(e); + } // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; - const value = plain.npub ?? plain.pubkey ?? plain.result ?? ''; + const value = plain.result ?? plain.npub ?? plain.pubkey ?? ''; if (!value) throw new Error('Amber returned empty key'); @@ -186,50 +128,35 @@ export class CapacitorAmberSigner implements NostrSigner { return this.pubkey; } - /** - * Get the native plugin AND ensure the Amber package name is set on it. - * - * THE REAL FIX (verified against the plugin's Java source): - * The plugin's signEvent/nip*Encrypt/Decrypt IMMEDIATELY reject with - * "Signer package name not set. Call setPackageName first." if no package is - * configured — which is exactly what happened when we (wrongly) omitted it, and - * that rejection surfaced as the endless spinner. - * - * With the package set, the plugin tries Amber's Content Resolver first; when - * that returns null (permission not "remembered"), it FALLS BACK to - * startActivityForResult(nostrsigner:…) — the SAME native call login uses that - * reliably OPENS AMBER's approval screen. So the correct behaviour is: set the - * package, then call the native method. No JS-intent navigation needed. - */ - private async plugin(): Promise { + /** Get the native AmberIntent bridge, or throw a friendly error off-native. */ + private plugin(): AmberIntentNative { const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } - // Idempotent; safe to call before every op. This is what unblocks signing. - await plugin.setPackageName({ packageName: AMBER_PACKAGE }); return plugin; } async signEvent(event: Omit): Promise { - const plugin = await this.plugin(); - const res = await withTimeout( - plugin.signEvent({ - packageName: AMBER_PACKAGE, + const plugin = this.plugin(); + let res: { result?: string; event?: string }; + try { + res = await plugin.signEvent({ eventJson: JSON.stringify(event), - eventId: makeId(), npub: this.npub, - }), - 'signature', - ); + }); + } catch (e) { + throw normalizeAmberError(e); + } - // Amber may return a full event JSON in `event`, or only a bare `signature`. - // Reconstruct defensively so signature-only responses (common for kind:5 - // deletions and kind:1985 labels) still produce a valid, publishable event. + // Amber may return a full event JSON in `event`, or only a bare signature in + // `result`. Reconstruct defensively so signature-only responses (common for + // kind:5 deletions and kind:1985 labels) still produce a valid, publishable + // event. const raw = res.event && res.event.trim() ? res.event - : (res.signature ?? ''); + : (res.result ?? ''); const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey); if (!signed || !signed.sig) { @@ -242,39 +169,31 @@ export class CapacitorAmberSigner implements NostrSigner { readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = await this.plugin(); - const { result } = await withTimeout( - plugin.nip04Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }), - 'encryption', - ); - return result; + try { + const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); + return result; + } catch (e) { throw normalizeAmberError(e); } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = await this.plugin(); - const { result } = await withTimeout( - plugin.nip04Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }), - 'decryption', - ); - return result; + try { + const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); + return result; + } catch (e) { throw normalizeAmberError(e); } }, }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = await this.plugin(); - const { result } = await withTimeout( - plugin.nip44Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }), - 'encryption', - ); - return result; + try { + const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub }); + return result; + } catch (e) { throw normalizeAmberError(e); } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = await this.plugin(); - const { result } = await withTimeout( - plugin.nip44Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }), - 'decryption', - ); - return result; + try { + const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub }); + return result; + } catch (e) { throw normalizeAmberError(e); } }, }; } @@ -295,15 +214,14 @@ export function isNativeAmberAvailable(): boolean { } /** - * Is an external signer (Amber) installed on this device? + * Is an external signer (Amber) usable on this device? + * + * We no longer do an explicit package-visibility check: on Android 11+/GrapheneOS + * that check is unreliable (it can report false even when Amber IS installed), + * and the login flow already attempts the foreground intent directly and surfaces + * a clear error if Amber can't be opened. So this simply mirrors native + * availability of the AmberIntent bridge. */ export async function isAmberInstalled(): Promise { - const plugin = getNativeSigner(); - if (!plugin) return false; - try { - const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE }); - return installed; - } catch { - return false; - } + return isNativeAmberAvailable(); } diff --git a/vite.config.ts b/vite.config.ts index cb921c7..731ad78 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -64,13 +64,16 @@ export default defineConfig(() => ({ // (components/CameraCapture.tsx). It may not be installed on every build // machine, and it's optional/native-only, so it must stay external. The // dynamic import() remains a runtime call that degrades gracefully. - // - nostr-signer-capacitor-plugin — installed from GitHub with a broken - // package entry point; cannot be bundled. Guarded dynamic import() only. // - @capacitor/app|haptics|keyboard|status-bar — NOT installed and never // statically imported (accessed via window.Capacitor.Plugins at runtime). + // + // NOTE: Amber signing no longer uses the npm `nostr-signer-capacitor-plugin`. + // It now goes through our OWN native plugin `AmberIntent` + // (android-templates/AmberIntentPlugin.java), which implements the Amethyst + // foreground-intent NIP-55 flow and is reached via registerPlugin('AmberIntent') + // from @capacitor/core (already bundled). No bare specifier to externalize. external: [ '@capacitor/camera', - 'nostr-signer-capacitor-plugin', '@capacitor/app', '@capacitor/haptics', '@capacitor/keyboard',