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>
220 lines
9.4 KiB
Java
220 lines
9.4 KiB
Java
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?");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|