Fix Amber never prompting on sign/encrypt (GrapheneOS ContentProvider stall)

ROOT CAUSE of "stuck on Deleting…" with no Amber prompt:
signEvent (and nip04/nip44 encrypt/decrypt) passed packageName to the
native plugin. With a packageName set, the plugin first tries Amber's
ContentProvider — a background, no-UI query that stalls on GrapheneOS and
never launches Amber's approval screen. So the user saw NO prompt and the
request timed out ("takedown taking longer than expected").

Login already worked around this by omitting packageName so the plugin
goes straight to startActivityForResult(nostrsigner:) which shows Amber's
UI. This mirrors that fix for every signing/crypto op: use the raw native
signer with NO packageName. Removed the now-unused getPlugin /
ensurePackageName / setPackageName machinery.

This is why deletes (and any signed action / encryption) never surfaced
Amber and hung.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 23:33:17 +07:00
parent e223e59389
commit 244cfcf787

View file

@ -101,43 +101,13 @@ function getNativeSigner(): NostrSignerNative | null {
return nativePlugin; return nativePlugin;
} }
// Set the Amber package name exactly ONCE per app session. Calling it before // NOTE: We deliberately do NOT call setPackageName / pass packageName on
// every sign/encrypt op is unnecessary bridge chatter and, on some builds, a // sign/encrypt/decrypt calls. Providing a packageName makes the native plugin
// repeated setPackageName can itself wedge. We cache the promise so concurrent // try Amber's ContentProvider (a background, no-UI query) first, which stalls on
// callers await the same setup. // GrapheneOS (and some other ROMs) so Amber's approval screen never appears and
let packageNameSet = false; // the request just times out. Omitting it forces the plugin straight to
async function ensurePackageName(plugin: NostrSignerNative): Promise<void> { // startActivityForResult(nostrsigner:) which launches Amber's UI — the reliable
if (packageNameSet) return; // path the login flow already uses.
// CRITICAL: setPackageName MUST NOT be able to wedge the login flow. On some
// devices (GrapheneOS in particular) this bridge call can hang and never
// resolve — and because getPlugin() awaited it, getPublicKey() was never even
// reached, so its 90s timeout never started → eternal "Waiting for Amber".
// We race it against a short timeout and simply continue regardless: Amber's
// package is also passed explicitly on every getPublicKey/sign/encrypt call,
// so setPackageName is a nice-to-have, not a hard requirement.
try {
await Promise.race([
plugin.setPackageName({ packageName: AMBER_PACKAGE }),
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
]);
} catch (e) {
console.warn('[CapacitorAmberSigner] setPackageName warning (continuing):', e);
}
packageNameSet = true;
}
/**
* Get the native signer, ensuring the Amber package name is set first.
* Throws a clear error if not on a native platform (caller can surface it).
*/
async function getPlugin(): Promise<NostrSignerNative> {
const plugin = getNativeSigner();
if (!plugin) {
throw new Error('Amber signing is only available in the SkateSpots Android app.');
}
await ensurePackageName(plugin);
return plugin;
}
/** /**
* Login: get public key from Amber. * Login: get public key from Amber.
@ -201,10 +171,21 @@ export class CapacitorAmberSigner implements NostrSigner {
} }
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> { async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
const plugin = await getPlugin(); // CRITICAL (GrapheneOS + others): do NOT pass packageName here. When a
// packageName is provided, the native plugin first attempts Amber's
// ContentProvider (a background, no-UI query) which stalls on GrapheneOS and
// NEVER launches Amber's approval screen — so the user sees no prompt and the
// request just times out ("stuck on Deleting…", Amber never popped up). This
// is the exact same trap the login flow (getAmberPublicKeyViaPlugin) avoids
// by omitting packageName so the plugin goes straight to
// startActivityForResult(nostrsigner:) which shows Amber's UI. We mirror that
// here: use the raw native signer with NO packageName.
const plugin = getNativeSigner();
if (!plugin) {
throw new Error('Amber signing is only available in the SkateSpots Android app.');
}
const res = await withTimeout( const res = await withTimeout(
plugin.signEvent({ plugin.signEvent({
packageName: AMBER_PACKAGE,
eventJson: JSON.stringify(event), eventJson: JSON.stringify(event),
eventId: makeId(), eventId: makeId(),
npub: this.npub, npub: this.npub,
@ -278,10 +259,9 @@ export class CapacitorAmberSigner implements NostrSigner {
readonly nip04 = { readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const plugin = await getPlugin(); const plugin = this.rawPlugin();
const { result } = await withTimeout( const { result } = await withTimeout(
plugin.nip04Encrypt({ plugin.nip04Encrypt({
packageName: AMBER_PACKAGE,
plainText: plaintext, plainText: plaintext,
pubKey: pubkey, pubKey: pubkey,
npub: this.npub, npub: this.npub,
@ -292,10 +272,9 @@ export class CapacitorAmberSigner implements NostrSigner {
return result; return result;
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const plugin = await getPlugin(); const plugin = this.rawPlugin();
const { result } = await withTimeout( const { result } = await withTimeout(
plugin.nip04Decrypt({ plugin.nip04Decrypt({
packageName: AMBER_PACKAGE,
encryptedText: ciphertext, encryptedText: ciphertext,
pubKey: pubkey, pubKey: pubkey,
npub: this.npub, npub: this.npub,
@ -309,10 +288,9 @@ export class CapacitorAmberSigner implements NostrSigner {
readonly nip44 = { readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const plugin = await getPlugin(); const plugin = this.rawPlugin();
const { result } = await withTimeout( const { result } = await withTimeout(
plugin.nip44Encrypt({ plugin.nip44Encrypt({
packageName: AMBER_PACKAGE,
plainText: plaintext, plainText: plaintext,
pubKey: pubkey, pubKey: pubkey,
npub: this.npub, npub: this.npub,
@ -323,10 +301,9 @@ export class CapacitorAmberSigner implements NostrSigner {
return result; return result;
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const plugin = await getPlugin(); const plugin = this.rawPlugin();
const { result } = await withTimeout( const { result } = await withTimeout(
plugin.nip44Decrypt({ plugin.nip44Decrypt({
packageName: AMBER_PACKAGE,
encryptedText: ciphertext, encryptedText: ciphertext,
pubKey: pubkey, pubKey: pubkey,
npub: this.npub, npub: this.npub,
@ -337,6 +314,19 @@ export class CapacitorAmberSigner implements NostrSigner {
return result; return result;
}, },
}; };
/**
* Raw native signer WITHOUT packageName — same rationale as signEvent: passing
* a packageName triggers Amber's stalling ContentProvider path on GrapheneOS
* so the approval UI never appears. Throws clearly if not on native.
*/
private rawPlugin(): NostrSignerNative {
const plugin = getNativeSigner();
if (!plugin) {
throw new Error('Amber signing is only available in the SkateSpots Android app.');
}
return plugin;
}
} }
/** /**