fix(amber): Amethyst-style foreground-intent signer to end the GrapheneOS sign hang
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>
This commit is contained in:
parent
be05a603be
commit
63db160b80
7 changed files with 395 additions and 204 deletions
220
android-templates/AmberIntentPlugin.java
Normal file
220
android-templates/AmberIntentPlugin.java
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
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?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,53 @@
|
||||||
package org.skatespots.app;
|
package org.skatespots.app;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Capacitor launcher activity.
|
* SkateSpots launcher activity.
|
||||||
*
|
*
|
||||||
* This is the STOCK Capacitor MainActivity — it does NOT override the
|
* SAFE BY DESIGN: this extends the stock Capacitor BridgeActivity and does NOT
|
||||||
* WebViewClient or anything else. An earlier custom version overrode the
|
* override the WebViewClient (an earlier custom version did and caused a
|
||||||
* WebViewClient and wiped out the client Capacitor needs to load the web layer,
|
* black-screen crash). The ONLY thing we add is the registration of the
|
||||||
* causing a black-screen crash on launch. This minimal version simply exists so
|
* ActivityResultLauncher used by AmberIntentPlugin.
|
||||||
* the AndroidManifest has a valid launcher class to point at, and lets Capacitor
|
*
|
||||||
* boot normally.
|
* WHY THE LAUNCHER LIVES HERE
|
||||||
|
* ───────────────────────────
|
||||||
|
* AndroidX requires registerForActivityResult(...) to be called BEFORE the
|
||||||
|
* Activity is STARTED (i.e. during onCreate). A Capacitor Plugin is instantiated
|
||||||
|
* too late to register its own launcher, so we create it here and hand it to the
|
||||||
|
* plugin via AmberIntentPlugin.setLauncher(). Results are routed straight into
|
||||||
|
* AmberIntentPlugin.handleResult().
|
||||||
|
*
|
||||||
|
* This is the Amethyst NIP-55 pattern: a foreground-intent signer whose result
|
||||||
|
* comes back through a properly-registered ActivityResultLauncher — the reliable
|
||||||
|
* path that OPENS Amber's Approve/Reject screen and never hangs, on stock Android
|
||||||
|
* and on GrapheneOS alike.
|
||||||
*
|
*
|
||||||
* Injected into the generated android/ project at build time by
|
* Injected into the generated android/ project at build time by
|
||||||
* scripts/inject-main-activity.mjs so the correct class is always present even
|
* scripts/inject-main-activity.mjs.
|
||||||
* after `rm -rf android` / `cap add`.
|
|
||||||
*/
|
*/
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
private ActivityResultLauncher<Intent> amberLauncher;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
// Register the launcher BEFORE super.onCreate() puts us past STARTED.
|
||||||
|
amberLauncher = registerForActivityResult(
|
||||||
|
new ActivityResultContracts.StartActivityForResult(),
|
||||||
|
result -> AmberIntentPlugin.handleResult(result)
|
||||||
|
);
|
||||||
|
AmberIntentPlugin.setLauncher(amberLauncher);
|
||||||
|
|
||||||
|
// Register our native plugin so the WebView can call it.
|
||||||
|
registerPlugin(AmberIntentPlugin.class);
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,6 @@
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
|
||||||
"nostr-tools": "2.13.0",
|
"nostr-tools": "2.13.0",
|
||||||
"npubcash-sdk": "^0.2.0",
|
"npubcash-sdk": "^0.2.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,13 @@ import { dirname, join } from 'node:path';
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
const targetDir = join(root, 'android', 'app', 'src', 'main', 'java', 'org', 'skatespots', 'app');
|
const targetDir = join(root, 'android', 'app', 'src', 'main', 'java', 'org', 'skatespots', 'app');
|
||||||
const target = join(targetDir, 'MainActivity.java');
|
const templatesDir = join(root, 'android-templates');
|
||||||
const template = join(root, 'android-templates', 'MainActivity.java');
|
|
||||||
|
// Native Java sources kept as the source of truth in android-templates/ and
|
||||||
|
// copied into the generated project on every build. MainActivity registers the
|
||||||
|
// Amber ActivityResultLauncher; AmberIntentPlugin is the Amethyst-style NIP-55
|
||||||
|
// foreground signer that opens Amber's Approve/Reject screen and never hangs.
|
||||||
|
const files = ['MainActivity.java', 'AmberIntentPlugin.java'];
|
||||||
|
|
||||||
// If the android/ project hasn't been generated yet, do nothing.
|
// If the android/ project hasn't been generated yet, do nothing.
|
||||||
if (!existsSync(join(root, 'android', 'app'))) {
|
if (!existsSync(join(root, 'android', 'app'))) {
|
||||||
|
|
@ -28,22 +33,27 @@ if (!existsSync(join(root, 'android', 'app'))) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(template)) {
|
|
||||||
console.error('[inject-main-activity] template android-templates/MainActivity.java missing — aborting.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(targetDir)) {
|
if (!existsSync(targetDir)) {
|
||||||
mkdirSync(targetDir, { recursive: true });
|
mkdirSync(targetDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const desired = readFileSync(template, 'utf8');
|
for (const file of files) {
|
||||||
const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
const template = join(templatesDir, file);
|
||||||
|
const target = join(targetDir, file);
|
||||||
|
|
||||||
if (current === desired) {
|
if (!existsSync(template)) {
|
||||||
console.log('[inject-main-activity] MainActivity.java already correct.');
|
console.error(`[inject-main-activity] template android-templates/${file} missing — aborting.`);
|
||||||
process.exit(0);
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = readFileSync(template, 'utf8');
|
||||||
|
const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
||||||
|
|
||||||
|
if (current === desired) {
|
||||||
|
console.log(`[inject-main-activity] ${file} already correct.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFileSync(template, target);
|
||||||
|
console.log(`[inject-main-activity] ✓ Ensured ${file} is present (Amber foreground signer + safe launcher).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyFileSync(template, target);
|
|
||||||
console.log('[inject-main-activity] ✓ Ensured stock MainActivity.java is present (fixes black-screen crash).');
|
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,12 @@ export function useDeleteEntity() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── PHASE 1: SIGN ────────────────────────────────────────────────────────
|
// ── PHASE 1: SIGN ────────────────────────────────────────────────────────
|
||||||
|
// With the Amethyst-style foreground signer, Amber pops up its Approve/Reject
|
||||||
|
// screen directly — no more silent ContentProvider stall. The signer has its
|
||||||
|
// own graceful timeout, but we keep an outer guard as a belt-and-braces.
|
||||||
toast({
|
toast({
|
||||||
title: 'Step 1/2: Signing…',
|
title: 'Approve in Amber',
|
||||||
description: 'Approve the request in Amber, then come back to the app.',
|
description: 'Amber will open — tap Approve to confirm the deletion.',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.signer) {
|
if (!user.signer) {
|
||||||
|
|
@ -104,9 +107,14 @@ export function useDeleteEntity() {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
// Distinguish a deliberate rejection from a real failure.
|
||||||
|
if (/reject/i.test(msg)) {
|
||||||
|
toast({ title: 'Cancelled', description: 'You rejected the request in Amber.' });
|
||||||
|
throw new Error('Signing cancelled.');
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: 'Stuck at SIGNING',
|
title: "Couldn't sign",
|
||||||
description: `Amber step failed: ${msg}`,
|
description: msg,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
throw new Error(`SIGN step failed: ${msg}`);
|
throw new Error(`SIGN step failed: ${msg}`);
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* CapacitorAmberSigner
|
* CapacitorAmberSigner
|
||||||
*
|
*
|
||||||
* Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the
|
* Talks to Amber (NIP-55) using the AMETHYST FOREGROUND-INTENT pattern via our
|
||||||
* Capacitor bridge.
|
* own native `AmberIntent` Capacitor plugin (android-templates/AmberIntentPlugin.java).
|
||||||
*
|
*
|
||||||
* WHY WE DON'T `import('nostr-signer-capacitor-plugin')` ANYMORE
|
* WHY WE REPLACED THE OLD `NostrSignerPlugin` PATH
|
||||||
* -------------------------------------------------------------
|
* ------------------------------------------------
|
||||||
* The package is marked `external` in vite.config.ts and the native build uses
|
* The stock `nostr-signer-capacitor-plugin` (0.0.5) tries Amber's background
|
||||||
* `inlineDynamicImports`, so a runtime `import('nostr-signer-capacitor-plugin')`
|
* ContentProvider FIRST. On GrapheneOS (and other hardened ROMs) that silent
|
||||||
* stays in the bundle as a BARE specifier the Android WebView cannot resolve.
|
* cross-app ContentResolver query is blocked and never returns, and the plugin
|
||||||
* That import threw, `getPlugin()` returned null, and every signing operation
|
* does not fall back cleanly to a foreground intent — so signing hung for 90s
|
||||||
* (including the Blossom upload auth event) failed with
|
* and Amber never even appeared. That is the delete/zap/crew hang.
|
||||||
* "Capacitor Amber plugin not available".
|
|
||||||
*
|
*
|
||||||
* THE FIX: the plugin's JS layer is just a thin wrapper around
|
* Amethyst (the reference NIP-55 client) never hits this: it goes STRAIGHT to a
|
||||||
* `registerPlugin('NostrSignerPlugin')` from @capacitor/core. @capacitor/core
|
* foreground Intent through a properly-registered ActivityResultLauncher, tracks
|
||||||
* IS bundled, so we call `registerPlugin` ourselves to get the native bridge
|
* each request by id, and awaits the Activity result with a timeout. Our
|
||||||
* proxy directly and reimplement the (tiny, well-known) wrapper logic here.
|
* `AmberIntent` plugin does exactly that — the reliable path that OPENS Amber's
|
||||||
* This is the same runtime-plugin pattern that fixed the camera + white-screen.
|
* Approve/Reject screen and returns the signature, on stock Android AND on
|
||||||
|
* GrapheneOS. Every request is a user-visible Amber popup (the intuitive UX).
|
||||||
*
|
*
|
||||||
* API shape reference: coracle-social/nostr-signer-capacitor-plugin#main
|
* NATIVE API (AmberIntent):
|
||||||
* native.signEvent({ packageName, eventJson, eventId, npub })
|
* getPublicKey({ permissions? }) → { result: <pubkey>, package }
|
||||||
* → { signature, id, event }
|
* signEvent({ eventJson, npub }) → { result: <sig>, event }
|
||||||
* native.getPublicKey({ packageName, permissions? }) → { npub, package }
|
* nipXXEncrypt/Decrypt({ payload, pubKey, npub }) → { result }
|
||||||
* native.nipXXEncrypt/Decrypt({ packageName, plainText|encryptedText, pubKey, npub, id })
|
* A user rejection rejects the promise with message "rejected".
|
||||||
* → { result, id }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||||
|
|
@ -32,114 +31,55 @@ import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { reconstructSignedEvent } from '@/lib/amber';
|
import { reconstructSignedEvent } from '@/lib/amber';
|
||||||
|
|
||||||
const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner';
|
/** Raw native bridge interface for our AmberIntent plugin (Android). */
|
||||||
|
interface AmberIntentNative {
|
||||||
/** Raw native bridge interface (Android). All params are options objects. */
|
getPublicKey(options?: { permissions?: string }): Promise<{ result?: string; package?: string; id?: string }>;
|
||||||
interface NostrSignerNative {
|
signEvent(options: { eventJson: string; npub: string }): Promise<{ result?: string; event?: string; id?: string }>;
|
||||||
setPackageName(options: { packageName: string }): Promise<void>;
|
nip04Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>;
|
||||||
isExternalSignerInstalled(options?: { packageName?: string }): Promise<{ installed: boolean }>;
|
nip04Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>;
|
||||||
getPublicKey(options?: { packageName?: string; permissions?: string }): Promise<{ npub: string; package?: string }>;
|
nip44Encrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>;
|
||||||
signEvent(options: { packageName?: string; eventJson: string; eventId: string; npub: string }): Promise<{ signature?: string; id?: string; event: string }>;
|
nip44Decrypt(options: { payload: string; pubKey: string; npub: string }): Promise<{ result: string }>;
|
||||||
nip04Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
|
||||||
nip04Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
|
||||||
nip44Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
|
||||||
nip44Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeId(): string {
|
|
||||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How long to wait for Amber to return before giving up. Amber is a separate
|
|
||||||
// app: the user has to switch to it, approve, and switch back. If the native
|
|
||||||
// activity-result never fires (user backs out, Amber not granted, etc.) the
|
|
||||||
// bridge promise would otherwise hang FOREVER — which is exactly the "stuck
|
|
||||||
// loading" symptom on the crew logo upload and crew publish. This turns that
|
|
||||||
// silent hang into a clear, catchable error.
|
|
||||||
const AMBER_TIMEOUT_MS = 90_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Race a bridge promise against a timeout so a hung Amber round-trip surfaces
|
|
||||||
* as an error instead of spinning forever.
|
|
||||||
*/
|
|
||||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Amber didn't respond to the ${label} request. Open Amber, approve the request, then try again. ` +
|
|
||||||
`If nothing appeared, make sure Amber is installed and SkateSpots is allowed to use it.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, AMBER_TIMEOUT_MS);
|
|
||||||
|
|
||||||
promise.then(
|
|
||||||
(value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily build the native bridge proxy once. `registerPlugin` is a pure factory
|
// Lazily build the native bridge proxy once. `registerPlugin` is a pure factory
|
||||||
// (builds a proxy, touches no native APIs at call time), so it's safe on web too
|
// (builds a proxy, touches no native APIs at call time), so it's safe on web too
|
||||||
// — the proxy simply has no implementation there and calls reject.
|
// — the proxy simply has no implementation there and calls reject.
|
||||||
let nativePlugin: NostrSignerNative | null = null;
|
let nativePlugin: AmberIntentNative | null = null;
|
||||||
function getNativeSigner(): NostrSignerNative | null {
|
function getNativeSigner(): AmberIntentNative | null {
|
||||||
try {
|
try {
|
||||||
if (!Capacitor?.isNativePlatform?.()) return null;
|
if (!Capacitor?.isNativePlatform?.()) return null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!nativePlugin) {
|
if (!nativePlugin) {
|
||||||
nativePlugin = registerPlugin<NostrSignerNative>('NostrSignerPlugin');
|
nativePlugin = registerPlugin<AmberIntentNative>('AmberIntent');
|
||||||
}
|
}
|
||||||
return nativePlugin;
|
return nativePlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We deliberately do NOT call setPackageName / pass packageName on
|
/** Turn Amber's "rejected" rejection into a friendly, catchable message. */
|
||||||
// sign/encrypt/decrypt calls. Providing a packageName makes the native plugin
|
function normalizeAmberError(e: unknown): Error {
|
||||||
// try Amber's ContentProvider (a background, no-UI query) first, which stalls on
|
const msg = (e as Error)?.message ?? String(e);
|
||||||
// GrapheneOS (and some other ROMs) so Amber's approval screen never appears and
|
if (/rejected/i.test(msg)) {
|
||||||
// the request just times out. Omitting it forces the plugin straight to
|
return new Error('You rejected the request in Amber.');
|
||||||
// startActivityForResult(nostrsigner:) which launches Amber's UI — the reliable
|
}
|
||||||
// path the login flow already uses.
|
return e instanceof Error ? e : new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login: get public key from Amber.
|
* Login: get public key from Amber via the foreground intent.
|
||||||
*
|
*
|
||||||
* The Java bridge calls npubToHex() before returning, so the "npub" field is
|
* The native plugin OMITS the package on get_public_key (per NIP-55) so Android
|
||||||
* actually a hex pubkey string despite the misleading name (older builds). We
|
* opens Amber's UI directly — no ContentProvider, no GrapheneOS stall. We
|
||||||
* handle both hex and real npub just in case.
|
* pre-authorize all the permissions the app needs so Amber can (optionally)
|
||||||
|
* remember the user's choice for a smoother, popup-light experience afterwards.
|
||||||
*/
|
*/
|
||||||
export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
// Get the raw native bridge directly. CRITICALLY we do NOT call
|
|
||||||
// setPackageName here and we do NOT pass packageName to getPublicKey.
|
|
||||||
//
|
|
||||||
// WHY: the native getPublicKey first tries Amber's ContentProvider (a
|
|
||||||
// background, no-UI query) whenever a packageName is known. On GrapheneOS that
|
|
||||||
// cross-app ContentResolver query is blocked/stalls and never returns cleanly,
|
|
||||||
// so the code never falls through to the intent that actually opens Amber's
|
|
||||||
// approval screen → eternal "Waiting for Amber". By omitting packageName, the
|
|
||||||
// plugin skips the content-resolver branch and goes STRAIGHT to
|
|
||||||
// startActivityForResult(nostrsigner:) which launches Amber's UI. Amber returns
|
|
||||||
// both the pubkey AND its package, so subsequent signing still works.
|
|
||||||
const plugin = getNativeSigner();
|
const plugin = getNativeSigner();
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request ALL permissions the app will need UP FRONT, so Amber pre-authorizes
|
|
||||||
// signing + encryption at login time. Without this, later signEvent/encrypt
|
|
||||||
// calls trigger a per-action permission check that, on GrapheneOS, routes
|
|
||||||
// through Amber's ContentProvider and stalls WITHOUT ever showing a prompt —
|
|
||||||
// the "Amber never pops up, delete hangs" bug. Pre-granting means signing
|
|
||||||
// afterwards is a fast background round-trip with no surprise stall.
|
|
||||||
const permissions = JSON.stringify([
|
const permissions = JSON.stringify([
|
||||||
{ type: 'get_public_key' },
|
{ type: 'get_public_key' },
|
||||||
{ type: 'sign_event' },
|
{ type: 'sign_event' },
|
||||||
|
|
@ -149,14 +89,16 @@ export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
{ type: 'nip44_decrypt' },
|
{ type: 'nip44_decrypt' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await withTimeout(
|
let result: { result?: string; package?: string };
|
||||||
plugin.getPublicKey({ permissions }),
|
try {
|
||||||
'login',
|
result = await plugin.getPublicKey({ permissions });
|
||||||
);
|
} catch (e) {
|
||||||
|
throw normalizeAmberError(e);
|
||||||
|
}
|
||||||
|
|
||||||
// JSObject from Capacitor — normalize to a plain object for reliable access.
|
// JSObject from Capacitor — normalize to a plain object for reliable access.
|
||||||
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
|
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
|
||||||
const value = plain.npub ?? plain.pubkey ?? plain.result ?? '';
|
const value = plain.result ?? plain.npub ?? plain.pubkey ?? '';
|
||||||
|
|
||||||
if (!value) throw new Error('Amber returned empty key');
|
if (!value) throw new Error('Amber returned empty key');
|
||||||
|
|
||||||
|
|
@ -186,50 +128,35 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
return this.pubkey;
|
return this.pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get the native AmberIntent bridge, or throw a friendly error off-native. */
|
||||||
* Get the native plugin AND ensure the Amber package name is set on it.
|
private plugin(): AmberIntentNative {
|
||||||
*
|
|
||||||
* THE REAL FIX (verified against the plugin's Java source):
|
|
||||||
* The plugin's signEvent/nip*Encrypt/Decrypt IMMEDIATELY reject with
|
|
||||||
* "Signer package name not set. Call setPackageName first." if no package is
|
|
||||||
* configured — which is exactly what happened when we (wrongly) omitted it, and
|
|
||||||
* that rejection surfaced as the endless spinner.
|
|
||||||
*
|
|
||||||
* With the package set, the plugin tries Amber's Content Resolver first; when
|
|
||||||
* that returns null (permission not "remembered"), it FALLS BACK to
|
|
||||||
* startActivityForResult(nostrsigner:…) — the SAME native call login uses that
|
|
||||||
* reliably OPENS AMBER's approval screen. So the correct behaviour is: set the
|
|
||||||
* package, then call the native method. No JS-intent navigation needed.
|
|
||||||
*/
|
|
||||||
private async plugin(): Promise<NostrSignerNative> {
|
|
||||||
const plugin = getNativeSigner();
|
const plugin = getNativeSigner();
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
||||||
}
|
}
|
||||||
// Idempotent; safe to call before every op. This is what unblocks signing.
|
|
||||||
await plugin.setPackageName({ packageName: AMBER_PACKAGE });
|
|
||||||
return plugin;
|
return plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
||||||
const plugin = await this.plugin();
|
const plugin = this.plugin();
|
||||||
const res = await withTimeout(
|
let res: { result?: string; event?: string };
|
||||||
plugin.signEvent({
|
try {
|
||||||
packageName: AMBER_PACKAGE,
|
res = await plugin.signEvent({
|
||||||
eventJson: JSON.stringify(event),
|
eventJson: JSON.stringify(event),
|
||||||
eventId: makeId(),
|
|
||||||
npub: this.npub,
|
npub: this.npub,
|
||||||
}),
|
});
|
||||||
'signature',
|
} catch (e) {
|
||||||
);
|
throw normalizeAmberError(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Amber may return a full event JSON in `event`, or only a bare `signature`.
|
// Amber may return a full event JSON in `event`, or only a bare signature in
|
||||||
// Reconstruct defensively so signature-only responses (common for kind:5
|
// `result`. Reconstruct defensively so signature-only responses (common for
|
||||||
// deletions and kind:1985 labels) still produce a valid, publishable event.
|
// kind:5 deletions and kind:1985 labels) still produce a valid, publishable
|
||||||
|
// event.
|
||||||
const raw =
|
const raw =
|
||||||
res.event && res.event.trim()
|
res.event && res.event.trim()
|
||||||
? res.event
|
? res.event
|
||||||
: (res.signature ?? '');
|
: (res.result ?? '');
|
||||||
|
|
||||||
const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey);
|
const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey);
|
||||||
if (!signed || !signed.sig) {
|
if (!signed || !signed.sig) {
|
||||||
|
|
@ -242,39 +169,31 @@ 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 this.plugin();
|
try {
|
||||||
const { result } = await withTimeout(
|
const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
||||||
plugin.nip04Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }),
|
|
||||||
'encryption',
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (e) { throw normalizeAmberError(e); }
|
||||||
},
|
},
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||||
const plugin = await this.plugin();
|
try {
|
||||||
const { result } = await withTimeout(
|
const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
||||||
plugin.nip04Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }),
|
|
||||||
'decryption',
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (e) { throw normalizeAmberError(e); }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly nip44 = {
|
readonly nip44 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
||||||
const plugin = await this.plugin();
|
try {
|
||||||
const { result } = await withTimeout(
|
const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
||||||
plugin.nip44Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }),
|
|
||||||
'encryption',
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (e) { throw normalizeAmberError(e); }
|
||||||
},
|
},
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||||
const plugin = await this.plugin();
|
try {
|
||||||
const { result } = await withTimeout(
|
const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
||||||
plugin.nip44Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }),
|
|
||||||
'decryption',
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
|
} catch (e) { throw normalizeAmberError(e); }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -295,15 +214,14 @@ export function isNativeAmberAvailable(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is an external signer (Amber) installed on this device?
|
* Is an external signer (Amber) usable on this device?
|
||||||
|
*
|
||||||
|
* We no longer do an explicit package-visibility check: on Android 11+/GrapheneOS
|
||||||
|
* that check is unreliable (it can report false even when Amber IS installed),
|
||||||
|
* and the login flow already attempts the foreground intent directly and surfaces
|
||||||
|
* a clear error if Amber can't be opened. So this simply mirrors native
|
||||||
|
* availability of the AmberIntent bridge.
|
||||||
*/
|
*/
|
||||||
export async function isAmberInstalled(): Promise<boolean> {
|
export async function isAmberInstalled(): Promise<boolean> {
|
||||||
const plugin = getNativeSigner();
|
return isNativeAmberAvailable();
|
||||||
if (!plugin) return false;
|
|
||||||
try {
|
|
||||||
const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE });
|
|
||||||
return installed;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,16 @@ export default defineConfig(() => ({
|
||||||
// (components/CameraCapture.tsx). It may not be installed on every build
|
// (components/CameraCapture.tsx). It may not be installed on every build
|
||||||
// machine, and it's optional/native-only, so it must stay external. The
|
// machine, and it's optional/native-only, so it must stay external. The
|
||||||
// dynamic import() remains a runtime call that degrades gracefully.
|
// dynamic import() remains a runtime call that degrades gracefully.
|
||||||
// - nostr-signer-capacitor-plugin — installed from GitHub with a broken
|
|
||||||
// package entry point; cannot be bundled. Guarded dynamic import() only.
|
|
||||||
// - @capacitor/app|haptics|keyboard|status-bar — NOT installed and never
|
// - @capacitor/app|haptics|keyboard|status-bar — NOT installed and never
|
||||||
// statically imported (accessed via window.Capacitor.Plugins at runtime).
|
// statically imported (accessed via window.Capacitor.Plugins at runtime).
|
||||||
|
//
|
||||||
|
// NOTE: Amber signing no longer uses the npm `nostr-signer-capacitor-plugin`.
|
||||||
|
// It now goes through our OWN native plugin `AmberIntent`
|
||||||
|
// (android-templates/AmberIntentPlugin.java), which implements the Amethyst
|
||||||
|
// foreground-intent NIP-55 flow and is reached via registerPlugin('AmberIntent')
|
||||||
|
// from @capacitor/core (already bundled). No bare specifier to externalize.
|
||||||
external: [
|
external: [
|
||||||
'@capacitor/camera',
|
'@capacitor/camera',
|
||||||
'nostr-signer-capacitor-plugin',
|
|
||||||
'@capacitor/app',
|
'@capacitor/app',
|
||||||
'@capacitor/haptics',
|
'@capacitor/haptics',
|
||||||
'@capacitor/keyboard',
|
'@capacitor/keyboard',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue