The web/PWA Amber path set compressionType=none; the native intents set neither compressionType nor returnType, and the JS gzip decoder was never called. NIP-55 lets Amber gzip a large result (Signer1... base64), so a long nip44_decrypt (a long DM) could arrive compressed and be parsed as garbage. Native intents now send compressionType=none; the JS signer also gunzips any Signer1 result. Timeout message no longer wrongly says Amber may not be installed.
226 lines
9.7 KiB
Java
226 lines
9.7 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");
|
|
// 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?");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|