fix(amber): Amethyst-style foreground-intent signer to end the GrapheneOS sign hang

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 12:39:00 +07:00
parent be05a603be
commit 63db160b80
7 changed files with 395 additions and 204 deletions

View file

@ -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<Intent> 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<String, PendingCall> 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<Intent> 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?");
}
}
}
});
}
}

View file

@ -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<Intent> 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);
}
}

View file

@ -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",

View file

@ -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).');

View file

@ -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}`);

View file

@ -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: <pubkey>, package }
* signEvent({ eventJson, npub }) → { result: <sig>, 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<void>;
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<T>(promise: Promise<T>, label: string): Promise<T> {
return new Promise<T>((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<NostrSignerNative>('NostrSignerPlugin');
nativePlugin = registerPlugin<AmberIntentNative>('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<string> {
// 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<string> {
{ 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<string, string>;
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<NostrSignerNative> {
/** 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<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<boolean> {
const plugin = getNativeSigner();
if (!plugin) return false;
try {
const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE });
return installed;
} catch {
return false;
}
return isNativeAmberAvailable();
}

View file

@ -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',