SkateSpots/android-templates/AmberIntentPlugin.java
SkateSpots cc7eb58828 fix(amber): disable result compression (native) + decode Signer1 fallback
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.
2026-10-13 21:05:29 +07:00

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?");
}
}
}
});
}
}