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"); // NIP-55: opt out of result gzip compression. Without this a large result // (e.g. a long nip44_decrypt payload) comes back as `Signer1…` base64, // which the JS would parse as garbage. Matches the web/PWA path. intent.putExtra("compressionType", "none"); 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); intent.putExtra("compressionType", "none"); 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); intent.putExtra("compressionType", "none"); 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 in time. 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?"); } } } }); } }