diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md
new file mode 100644
index 0000000000..efbd3507bb
--- /dev/null
+++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md
@@ -0,0 +1,184 @@
+# NIP-46 Signer — device verification checklist
+
+Everything below is behavior that JVM unit tests **cannot** exercise: interactive
+consent dialogs, the foreground service, real relay traffic, deep links, and
+cross-app interop. The protocol/authorization logic underneath is covered by
+`quartz` (`NostrConnectSignerServiceTest`) and `commons`
+(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this
+list is the manual pass that earns "first-class" on a real device.
+
+Run as the signer on one device/account ("bunker"); use a second app/account as
+the client.
+
+## Pairing
+- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the
+ `bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second
+ Amethyst via `amy login bunker://…`). Client resolves your npub via
+ `get_public_key`.
+- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a
+ code" on the signer screen pairs it and the signer turns on.
+- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that
+ carries `perms=` shows a connect sheet listing the app's requested
+ permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust
+ picker BEFORE anything is granted. Approving pre-grants exactly those ops
+ (unless Paranoid); Cancel/Block declines and nothing is registered. A
+ re-pair of a known app skips the sheet and keeps prior decisions.
+ Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt`
+ prints an offer that carries exactly those perms (see CLI interop driver).
+- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search
+ camera → lands on the signer screen and pairs.
+- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst
+ opens the signer screen and pairs (cold start AND already-running).
+
+## Note preview in the sign dialog (device-only)
+- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose
+ preview** (text + media + mentions, authored by the signing account), with
+ the "Show event" JSON toggle still available below it.
+- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent.
+- [ ] When the main Activity is gone (app fully backgrounded, only the signer
+ foreground service alive → `CallSessionBridge.accountViewModel` is null),
+ the dialog falls back to the plain content quote + JSON without crashing.
+- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone
+ dialog Activity; if it reads a CompositionLocal only provided by the main
+ scaffold it could crash at runtime (compiles fine). Verify on device; if it
+ misbehaves, the JSON fallback path is one boolean away.
+
+## Entry point + connected-apps management (2026-07-16)
+- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly
+ under Wallet (moved out of Settings). It's also available as a bottom-bar favorite.
+- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a
+ NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the
+ napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there.
+- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer
+ start (its background relay subscription goes with it); an app still signing is kept.
+
+## Comes-to-front on a request (2026-07-16)
+- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client
+ signing/connect request pops the consent dialog — via a full-screen-intent notification
+ on the high-importance "Signing requests" channel (the `startActivity` fast path is
+ BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog;
+ while actively on another app it shows a heads-up prompt to tap.
+- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra
+ notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`).
+- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps,
+ so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still
+ arrives and is tappable. Requires notification permission (already needed for the
+ always-on service).
+
+## Consent (Tier 1)
+- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret
+ shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any
+ signing; choosing a level records it in Connected Apps.
+- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no
+ silent grant).
+- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a
+ **kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog
+ appears (these are excluded from the auto-allowed set).
+- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session"
+ stops until the signer restarts; "24h/30d" expire; "deny for op" sticks.
+- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts.
+- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails
+ closed (deny) and the signer keeps serving later requests (not wedged).
+
+## Anti-spam rotation (already shipped)
+- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps
+ drop, QR updates; re-pairing a legit app keeps its trust level.
+
+## Visibility (Tier 2)
+- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed
+ (signed kind N / encrypted / decrypted / shared pubkey, green/red dot,
+ relative time), and per-app history on the Connected-App detail screen.
+- [ ] The Connected-App detail screen for a remote client shows its name/url,
+ not a raw `nip46:` coordinate.
+
+## Reliability (Tier 3)
+- [ ] **Relay health**: kill connectivity → status shows "X of N relays
+ connected"; restore → "all connected".
+- [ ] **Boot restart**: enable the signer, reboot the device → the foreground
+ service comes back and the signer answers a request without reopening the
+ app. (Same for an app update via `MY_PACKAGE_REPLACED`.)
+- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets
+ serviced (may lag by a relay reconnect).
+
+## Interop matrix
+Pair + sign + nip44 encrypt/decrypt + logout against each:
+- [ ] nsec.app
+- [ ] Coracle
+- [ ] Nostrudel
+- [ ] snort / other NIP-46 client
+
+## CLI interop driver (`amy`) — added 2026-07-17
+The `cli` module (`amy`) drives the same quartz/commons code, so it plays either
+side of every NIP-46 flow for reproducible interop tests without a second phone.
+All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no
+protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block).
+
+Amy as the **client** (Amethyst is the signer):
+- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then
+ every `amy` signing verb routes through it. Surfaces `auth_url` challenges to
+ stderr, so it completes even when Amethyst defers consent.
+- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an
+ offer for Amethyst to scan. `--perms` is what exercises the app's
+ **informed-consent** sheet (the offer carries the declared ops).
+
+Amy as the **signer** (Amethyst, or any client, is the client):
+- `amy bunker` — headless auto-approve signer for the operator's own key.
+- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows
+ only the listed ops, **rejects** the rest. Use to test how the app-as-client
+ handles a signer that says no.
+- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on
+ the terminal (TTY-only, default-deny, prompts serialized). Composes with
+ `--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable"
+ policy on the CLI).
+
+## Audit findings — known limitations (2026-07-16)
+
+An adversarial review of the signer logic surfaced these. The head-of-line
+issues below share one root cause: `authorize()`/`onConnect()` run **inline** in
+`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart
+that loop via `collectLatest`.
+
+- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect`
+ now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored
+ first-connect dialog can no longer wedge the loop forever.
+- **FIXED — consent no longer blocks other clients (needs on-device
+ validation).** The service now fans each request out into a child coroutine
+ under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on
+ the single consumer, only `handle()` runs concurrently. So a request awaiting a
+ prompt no longer stalls auto-allowed traffic, and several prompts can be pending
+ at once. Two guards keep this safe: (1) the identity signer's crypto is
+ serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt)
+ runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external
+ NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is
+ serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack
+ dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending`
+ flow drives one dialog (1 pending) or a checkbox list (>1). Covered by
+ `BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need
+ a real run:
+ - [ ] **Burst batching:** a client fires several dangerous-kind requests at once
+ → one batched sheet with checkboxes + select-all, Allow/Deny selected,
+ "Remember" toggle. Approving a subset leaves the rest pending.
+ - [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE
+ auto-allowed request from another app still gets signed and answered.
+ - [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two
+ approved requests do not drive overlapping IPC (they serialize).
+ - [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every
+ still-open request (not just the selected ones).
+- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect
+ pairing) mutates the listen set → `collectLatest` restarts the service →
+ cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost
+ but the client is leaving; a pairing-time cancel makes other clients retry).
+ Proper fix: manage subscriptions incrementally (diff add/remove) instead of a
+ full restart. Deferred (same reason).
+- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per
+ serviced request (negligible under rate-limiting); the per-author rate limiter
+ evicts by insertion order rather than LRU (the 3-arg `accessOrder`
+ `LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint
+ is unsynchronized (practically serialized on the UI thread).
+
+## Deliberately NOT changed
+The always-on foreground **notification** was left as-is: it is shared with the
+relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking
+it to the signer screen would be wrong when the service is up for another reason.
+Interactive consent uses its own dedicated dialog Activity, so it needs no
+notification actions.
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 42e59a2bb1..fa5b7740ef 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -151,6 +151,14 @@
+
+
+
+
+
+
+
+
@@ -463,14 +471,14 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
{
- val dir = File(filesDir, "datastore")
- if (!dir.exists()) return emptyMap()
- val result = mutableMapOf()
- for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
- val ds =
- cache.getOrCreate(file.absolutePath) {
- PreferenceDataStoreFactory.create(produceFile = { file })
- }
- val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
- val policy = loadPolicy(coordinate) ?: continue
- result[coordinate] = policy
+ override suspend fun allPolicies(): Map =
+ // Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the
+ // caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher).
+ withContext(Dispatchers.IO) {
+ val dir = File(filesDir, "datastore")
+ if (!dir.exists()) return@withContext emptyMap()
+ val result = mutableMapOf()
+ for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
+ val ds =
+ cache.getOrCreate(file.absolutePath) {
+ PreferenceDataStoreFactory.create(produceFile = { file })
+ }
+ val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
+ val policy = loadPolicy(coordinate) ?: continue
+ result[coordinate] = policy
+ }
+ result
}
- return result
- }
override suspend fun allOpDecisions(coordinate: String): Map {
val prefs = storeFor(coordinate).data.first()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt
new file mode 100644
index 0000000000..c4e20fe1cb
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.connectedApps.consent
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
+
+/**
+ * The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
+ * identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a
+ * raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture].
+ */
+@Composable
+fun ConnectedAccountRow(
+ name: String,
+ picture: String?,
+ pubKey: String?,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ RobohashFallbackAsyncImage(
+ robot = pubKey ?: name,
+ model = picture,
+ contentDescription = null,
+ modifier = Modifier.size(26.dp).clip(CircleShape),
+ loadProfilePicture = true,
+ loadRobohash = true,
+ )
+ Text(
+ name,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt
similarity index 74%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt
index a5c2f15811..950cb81816 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.napplet
+package com.vitorpamplona.amethyst.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -58,23 +58,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
-import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
-import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
-class NappletConnectActivity : ComponentActivity() {
+class SignerConnectActivity : ComponentActivity() {
private var token: String? = null
private var decided = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN)
+ val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN)
this.token = token
- val info = token?.let { NappletConnectCoordinator.infoFor(it) }
+ val info = token?.let { SignerConnectCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
@@ -82,21 +83,21 @@ class NappletConnectActivity : ComponentActivity() {
setContent {
AmethystTheme {
- NappletConnectScreen(
+ SignerConnectScreen(
info = info,
onConnect = { policy ->
decided = true
- NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
+ SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
finish()
},
onBlock = {
decided = true
- NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
+ SignerConnectCoordinator.complete(token, AppConnectResult.Blocked)
finish()
},
onCancel = {
decided = true
- NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
+ SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled)
finish()
},
)
@@ -105,14 +106,14 @@ class NappletConnectActivity : ComponentActivity() {
}
override fun finish() {
- if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
+ if (!decided) token?.let { SignerConnectCoordinator.cancel(it) }
super.finish()
}
}
@Composable
-private fun NappletConnectScreen(
- info: NappletConnectInfo,
+private fun SignerConnectScreen(
+ info: SignerConnectInfo,
onConnect: (AppSignerPolicy) -> Unit,
onBlock: () -> Unit,
onCancel: () -> Unit,
@@ -168,12 +169,46 @@ private fun NappletConnectScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
- Text(
- info.domain,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- )
+ // Show WHICH account is being connected (avatar + name), not a raw pubkey.
+ if (info.accountName != null) {
+ ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
+ } else {
+ Text(
+ info.domain,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ // What the app declared it needs (nostrconnect `perms`) — so consent is informed,
+ // not a silent grant. Approving connects and pre-grants exactly these.
+ if (info.requestedPermissions.isNotEmpty()) {
+ Spacer(Modifier.height(16.dp))
+ Surface(
+ modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ stringResource(R.string.nip46_connect_requests_title),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ info.requestedPermissions.forEach { perm ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(
+ MaterialSymbols.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(perm, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
}
Spacer(Modifier.height(16.dp))
@@ -194,21 +229,21 @@ private fun NappletConnectScreen(
) {
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
- icon = "❤",
+ symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
- icon = "👍",
+ symbol = MaterialSymbols.Shield,
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
- icon = "🕶",
+ symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
@@ -249,7 +284,7 @@ private fun NappletConnectScreen(
@Composable
private fun PolicyOption(
selected: Boolean,
- icon: String,
+ symbol: MaterialSymbol,
label: String,
description: String,
onClick: () -> Unit,
@@ -271,7 +306,12 @@ private fun PolicyOption(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
- Text(icon, style = MaterialTheme.typography.headlineSmall)
+ Icon(
+ symbol = symbol,
+ contentDescription = null,
+ tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(26.dp),
+ )
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt
similarity index 54%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt
index 0467578f2e..43059fa570 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt
@@ -18,21 +18,36 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.napplet
+package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
-import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the "Connect to Nostr" dialog needs to render. */
-data class NappletConnectInfo(
+data class SignerConnectInfo(
val appletTitle: String,
val coordinate: String,
val domain: String,
val iconUrl: String? = null,
+ /**
+ * The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When
+ * [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back
+ * to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture.
+ */
+ val accountName: String? = null,
+ val accountPicture: String? = null,
+ val accountPubKey: String? = null,
+ /**
+ * Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer),
+ * shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that
+ * carry no declaration (bunker connect), which just show the trust picker.
+ */
+ val requestedPermissions: List = emptyList(),
)
/**
@@ -40,9 +55,9 @@ data class NappletConnectInfo(
* the Activity resolves the deferred with the user's choice.
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
*/
-object NappletConnectCoordinator {
+object SignerConnectCoordinator {
private class Pending(
- val info: NappletConnectInfo,
+ val info: SignerConnectInfo,
val deferred: CompletableDeferred,
)
@@ -50,26 +65,41 @@ object NappletConnectCoordinator {
suspend fun requestConnect(
context: Context,
- info: NappletConnectInfo,
+ info: SignerConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred()
pending[token] = Pending(info, deferred)
- context.startActivity(
- Intent(context, NappletConnectActivity::class.java)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- .putExtra(EXTRA_TOKEN, token),
- )
+ // Fast path when Amethyst already owns the foreground; the full-screen-intent notification
+ // below is what surfaces the dialog when a connect request arrives while backgrounded (see
+ // SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs.
+ runCatching {
+ context.startActivity(
+ Intent(context, SignerConnectActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra(EXTRA_TOKEN, token),
+ )
+ }
+
+ val notificationId =
+ SignerConsentNotifier.show(
+ context = context,
+ activityClass = SignerConnectActivity::class.java,
+ extraKey = EXTRA_TOKEN,
+ token = token,
+ titleRes = R.string.nip46_signer_notif_connect_title,
+ )
return try {
deferred.await()
} finally {
pending.remove(token)
+ SignerConsentNotifier.cancel(context, notificationId)
}
}
- fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
+ fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info
fun complete(
token: String,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt
new file mode 100644
index 0000000000..e9528737f2
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt
@@ -0,0 +1,562 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.connectedApps.consent
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
+import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
+import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.service.call.CallSessionBridge
+import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
+import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
+import com.vitorpamplona.amethyst.ui.note.NoteCompose
+import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+class SignerConsentActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ AmethystTheme {
+ // The signer services requests concurrently, so more than one may await consent. We never
+ // mix accounts in one sheet: render only the requests for the OLDEST-pending account as a
+ // group. When that account's group clears, the next account's requests render (a fresh
+ // per-account sheet). One request → the rich dialog; several → a batched list. When the
+ // whole queue empties, close.
+ val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle()
+ val group =
+ run {
+ val account = pending.firstOrNull()?.info?.accountPubKey
+ pending.filter { it.info.accountPubKey == account }
+ }
+ LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() }
+ when {
+ group.isEmpty() -> Unit
+ group.size == 1 -> {
+ val p = group.first()
+ SignerConsentDialog(
+ info = p.info,
+ onGrant = { SignerConsentCoordinator.complete(p.token, it) },
+ onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) },
+ )
+ }
+ else ->
+ BatchedConsentDialog(
+ pending = group,
+ onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) },
+ // Dismissing denies only THIS account's group; other accounts' requests stay
+ // pending and render next as their own sheet.
+ onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) },
+ )
+ }
+ }
+ }
+ }
+ // Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside)
+ // denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as
+ // this Activity finishes is owned by a freshly-launched instance, and denying it here would race
+ // that instance and reject a legitimate request. A process kill falls back to the bridge's 120s
+ // timeout, which also fails closed.
+}
+
+@Composable
+private fun SignerConsentDialog(
+ info: SignerConsentInfo,
+ onGrant: (SignerOpGrant) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var showMoreOptions by remember { mutableStateOf(false) }
+ val scrollState = rememberScrollState()
+ val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .heightIn(max = maxHeight),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .verticalScroll(scrollState)
+ .padding(vertical = 24.dp),
+ ) {
+ // Centered header: icon + title + description
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val isBrowser = info.coordinate.startsWith("browser:")
+ FavoriteAppIcon(
+ app =
+ if (isBrowser) {
+ FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
+ } else {
+ FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
+ },
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(56.dp),
+ )
+ Text(
+ info.appletTitle,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ // Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex.
+ if (info.accountName != null) {
+ ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
+ }
+ }
+
+ Spacer(Modifier.height(12.dp))
+ Box(modifier = Modifier.padding(horizontal = 24.dp)) {
+ SignerConsentPreview(info)
+ }
+
+ Spacer(Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
+ // Primary: always allow this op
+ Button(
+ onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_consent_allow_always))
+ }
+
+ // Secondary: allow just once
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowOnce) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_signer_allow_once))
+ }
+
+ // "More options" toggle: session and time-bound grants
+ TextButton(
+ onClick = { showMoreOptions = !showMoreOptions },
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ if (showMoreOptions) {
+ stringResource(R.string.napplet_consent_fewer_options)
+ } else {
+ stringResource(R.string.napplet_consent_more_options)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Icon(
+ if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+
+ if (showMoreOptions) {
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_signer_allow_session))
+ }
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_signer_allow_24h))
+ }
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_signer_allow_30d))
+ }
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.AllowAll) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.napplet_signer_allow_all))
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.DenyOnce) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text(stringResource(R.string.napplet_signer_deny_once))
+ }
+ OutlinedButton(
+ onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will
+ * look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
+ * for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
+ * always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
+ * is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in.
+ */
+@Composable
+private fun SignerConsentPreview(info: SignerConsentInfo) {
+ var showRawData by remember(info) { mutableStateOf(false) }
+ val accountViewModel = remember { CallSessionBridge.accountViewModel }
+ val previewNav = remember { EmptyNav() }
+ val previewNote =
+ remember(info, accountViewModel) {
+ val template = info.previewTemplate
+ val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey
+ if (template != null && author != null && accountViewModel != null) {
+ runCatching {
+ val unsigned = RumorAssembler.assembleRumor(author, template)
+ accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author))
+ }.getOrNull()
+ } else {
+ null
+ }
+ }
+
+ val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
+ if (!hasContent) return
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ if (previewNote != null && accountViewModel != null) {
+ NoteCompose(
+ baseNote = previewNote,
+ isQuotedNote = true,
+ quotesLeft = 0,
+ accountViewModel = accountViewModel,
+ nav = previewNav,
+ )
+ } else if (info.contentPreview.isNotBlank()) {
+ Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall)
+ }
+ if (info.rawData.isNotBlank()) {
+ if (showRawData) {
+ Spacer(Modifier.height(8.dp))
+ Box(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ SelectionContainer {
+ Text(
+ info.rawData,
+ style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ softWrap = false,
+ )
+ }
+ }
+ }
+ TextButton(
+ onClick = { showRawData = !showRawData },
+ contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
+ ) {
+ Text(
+ if (showRawData) {
+ stringResource(R.string.napplet_consent_hide_event)
+ } else {
+ stringResource(R.string.napplet_consent_show_event)
+ },
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Shown when more than one request is awaiting consent at once (the signer services requests
+ * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
+ * ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]);
+ * off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render
+ * (as this list, or the single-request dialog once one remains).
+ */
+@Composable
+private fun BatchedConsentDialog(
+ pending: List,
+ onResolve: (tokens: List, grant: SignerOpGrant) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
+ val tokens = pending.map { it.token }.toSet()
+ // Seed all-selected ONCE for the initial batch the user opened. The signer services requests
+ // concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of
+ // re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a
+ // newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently
+ // re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw.
+ var selected by remember { mutableStateOf(tokens) }
+ LaunchedEffect(tokens) { selected = selected intersect tokens }
+ var rememberChoice by remember { mutableStateOf(false) }
+ // Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded.
+ var expanded by remember { mutableStateOf(emptySet()) }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .heightIn(max = maxHeight),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 20.dp)) {
+ // The sheet is single-account (grouped upstream), so the account is a header, not a
+ // per-row label. It says WHO every request in this sheet would act as.
+ val account = pending.first().info
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ account.accountName?.let { name ->
+ RobohashFallbackAsyncImage(
+ robot = account.accountPubKey ?: name,
+ model = account.accountPicture,
+ contentDescription = null,
+ modifier = Modifier.size(34.dp).clip(CircleShape),
+ loadProfilePicture = true,
+ loadRobohash = true,
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ account.accountName?.let { name ->
+ Text(
+ stringResource(R.string.nip46_signer_batch_signing_as, name),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ TextButton(
+ onClick = {
+ selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet()
+ },
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
+ ) {
+ Text(
+ stringResource(
+ if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .weight(1f, fill = false)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ pending.forEach { p ->
+ val isExpanded = p.token in expanded
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ expanded = if (isExpanded) expanded - p.token else expanded + p.token
+ }.padding(horizontal = 12.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Checkbox handles its own tap (select); tapping elsewhere on the row expands.
+ Checkbox(
+ checked = p.token in selected,
+ onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token },
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ "${p.info.appletTitle} · ${p.info.operationSummary}",
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ )
+ if (p.info.contentPreview.isNotBlank()) {
+ Text(
+ p.info.contentPreview,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+ Icon(
+ if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ if (isExpanded) {
+ Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
+ SignerConsentPreview(p.info)
+ }
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
+ Text(
+ stringResource(R.string.nip46_signer_batch_remember),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ Spacer(Modifier.height(8.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
+ Button(
+ onClick = {
+ val tokens = pending.filter { it.token in selected }
+ // Per-op remember uses each request's own op; one-time is a single AllowOnce.
+ if (rememberChoice) {
+ tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) }
+ } else {
+ onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce)
+ }
+ },
+ enabled = selected.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ ) {
+ Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
+ }
+ OutlinedButton(
+ onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
+ enabled = selected.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
+ }
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt
new file mode 100644
index 0000000000..92730ea5b7
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.connectedApps.consent
+
+import android.content.Context
+import android.content.Intent
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/** Everything the per-operation consent dialog needs to render. */
+data class SignerConsentInfo(
+ val appletTitle: String,
+ val coordinate: String,
+ val op: NostrSignerOp,
+ val operationSummary: String,
+ /** Short excerpt shown in the dialog body (≤ 160 chars). */
+ val contentPreview: String,
+ /**
+ * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations,
+ * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose).
+ */
+ val rawData: String = "",
+ val iconUrl: String? = null,
+ /**
+ * The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which
+ * logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the
+ * robohash avatar fallback when there's no picture.
+ */
+ val accountName: String? = null,
+ val accountPicture: String? = null,
+ val accountPubKey: String? = null,
+ /**
+ * The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a
+ * note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and
+ * non-event ops.
+ */
+ val previewTemplate: EventTemplate? = null,
+)
+
+/** One pending per-operation consent request, as the batched sheet renders it. */
+data class PendingConsent(
+ val token: String,
+ val info: SignerConsentInfo,
+)
+
+/**
+ * Bridges the broker to the per-operation signer consent UI. The signer services requests
+ * concurrently (so their prompts can batch), so several requests can await consent at once: they all
+ * land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog
+ * or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request
+ * resolves to [SignerOpGrant.DenyOnce] — fails closed.
+ */
+object SignerConsentCoordinator {
+ private val deferreds = ConcurrentHashMap>()
+ private val _pending = MutableStateFlow>(emptyList())
+
+ /** The live set of requests awaiting the user's decision; the Activity renders this. */
+ val pending: StateFlow> = _pending
+
+ // A stable notification id (one prompt notification for the whole batch, updated as requests
+ // arrive) so concurrent requests don't each post their own.
+ private val batchNotificationId = "nip46-signer-consent".hashCode()
+
+ // Guards the surface (post/cancel of the one shared notification) against the pending set so a
+ // concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it,
+ // request A could read "pending now empty" and then cancel AFTER request B posted a fresh
+ // notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout).
+ private val surfaceLock = Mutex()
+
+ suspend fun requestConsent(
+ context: Context,
+ info: SignerConsentInfo,
+ ): SignerOpGrant {
+ val token = UUID.randomUUID().toString()
+ val deferred = CompletableDeferred()
+ deferreds[token] = deferred
+
+ surfaceLock.withLock {
+ _pending.update { it + PendingConsent(token, info) }
+ // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app
+ // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent
+ // notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and
+ // observes [pending], and the notification uses a stable id, so concurrent requests just
+ // refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op.
+ runCatching {
+ context.startActivity(
+ Intent(context, SignerConsentActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ )
+ }
+ SignerConsentNotifier.show(
+ context = context,
+ activityClass = SignerConsentActivity::class.java,
+ extraKey = EXTRA_TOKEN,
+ token = "nip46-signer-consent",
+ titleRes = R.string.nip46_signer_notif_sign_title,
+ )
+ }
+
+ return try {
+ deferred.await()
+ } finally {
+ deferreds.remove(token)
+ surfaceLock.withLock {
+ // Remove + emptiness check + cancel are one critical section vs. another request's
+ // add + show, so a fresh notification is never cancelled out from under a live request.
+ val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } }
+ if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId)
+ }
+ }
+ }
+
+ fun complete(
+ token: String,
+ grant: SignerOpGrant,
+ ) {
+ deferreds[token]?.complete(grant)
+ }
+
+ fun completeAll(
+ tokens: Collection,
+ grant: SignerOpGrant,
+ ) {
+ tokens.forEach { complete(it, grant) }
+ }
+
+ const val EXTRA_TOKEN = "napplet_signer_consent_token"
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt
new file mode 100644
index 0000000000..b76ca93b78
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.connectedApps.consent
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.stringRes
+
+/**
+ * Surfaces a signer consent/connect [android.app.Activity] from the **background**.
+ *
+ * A bare `context.startActivity(...)` from the application context only opens a window while
+ * Amethyst already owns the foreground. When a signing request arrives over a relay while the app
+ * is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that
+ * `startActivity`, so the dialog would never appear and the request would sit until it times out.
+ *
+ * A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the
+ * `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the
+ * same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for
+ * incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is
+ * actively on another app it shows as a heads-up banner they tap to review.
+ *
+ * Each coordinator posts one notification keyed by the request token's hash so concurrent requests
+ * don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed
+ * out) so no stale prompt lingers.
+ */
+object SignerConsentNotifier {
+ private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
+
+ private fun ensureChannel(context: Context): NotificationChannel {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
+
+ val channel =
+ NotificationChannel(
+ CHANNEL_ID,
+ stringRes(context, R.string.nip46_signer_notif_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
+ }
+ manager.createNotificationChannel(channel)
+ return channel
+ }
+
+ /**
+ * Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens
+ * [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
+ * request resolves.
+ */
+ fun show(
+ context: Context,
+ activityClass: Class<*>,
+ extraKey: String,
+ token: String,
+ titleRes: Int,
+ ): Int {
+ // When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
+ // heads-up notification would just be redundant noise on top of it. Only fall back to the
+ // full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked.
+ if (appInForeground()) return NO_NOTIFICATION
+
+ val channel = ensureChannel(context)
+ val notificationId = token.hashCode()
+
+ val intent =
+ Intent(context, activityClass)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra(extraKey, token)
+
+ val pendingIntent =
+ PendingIntent.getActivity(
+ context,
+ notificationId,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ val notification =
+ NotificationCompat
+ .Builder(context, channel.id)
+ .setSmallIcon(R.drawable.amethyst)
+ .setContentTitle(stringRes(context, titleRes))
+ .setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
+ .setContentIntent(pendingIntent)
+ .setFullScreenIntent(pendingIntent, true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
+ .setAutoCancel(true)
+ .setOngoing(true)
+ .setTimeoutAfter(TIMEOUT_MS)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .build()
+
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.notify(notificationId, notification)
+ return notificationId
+ }
+
+ fun cancel(
+ context: Context,
+ notificationId: Int,
+ ) {
+ if (notificationId == NO_NOTIFICATION) return
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.cancel(notificationId)
+ }
+
+ private fun appInForeground(): Boolean =
+ // Defensive: the signer consent path only runs in the main process (where Amethyst.instance
+ // is set), but touching it from the keyless :napplet process would throw. Treat any failure
+ // as "not foreground" so the notification fallback still fires.
+ runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false)
+
+ private const val NO_NOTIFICATION = Int.MIN_VALUE
+ private const val TIMEOUT_MS = 120_000L
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt
new file mode 100644
index 0000000000..eaa68c4031
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.connectedApps.nip46
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
+import kotlinx.coroutines.flow.first
+import java.io.File
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Single-file DataStore-backed [Nip46ClientStore]. Every connected client's
+ * display + relay info lives in one `datastore/nip46_clients.preferences_pb`
+ * file; a SHA-256 prefix of the coordinate is the key so the (already public)
+ * coordinate is kept alongside for [all]'s reverse lookup. Fields are stored
+ * individually so no serialization library is needed; [relays] is newline-joined.
+ */
+class DataStoreNip46ClientStore(
+ private val filesDir: File,
+) : Nip46ClientStore {
+ constructor(context: Context) : this(context.applicationContext.filesDir)
+
+ private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb"))
+
+ override suspend fun load(coordinate: String): Nip46ClientInfo? {
+ val prefs = store.data.first()
+ if (prefs[coordKey(coordinate)] == null) return null
+ return Nip46ClientInfo(
+ name = prefs[nameKey(coordinate)],
+ url = prefs[urlKey(coordinate)],
+ image = prefs[imageKey(coordinate)],
+ relays = prefs[relaysKey(coordinate)].toRelaySet(),
+ )
+ }
+
+ override suspend fun store(
+ coordinate: String,
+ info: Nip46ClientInfo,
+ ) {
+ store.edit { prefs ->
+ prefs[coordKey(coordinate)] = coordinate
+ info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate))
+ info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate))
+ info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate))
+ if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate))
+ }
+ }
+
+ override suspend fun remove(coordinate: String) {
+ store.edit { prefs ->
+ prefs.remove(coordKey(coordinate))
+ prefs.remove(nameKey(coordinate))
+ prefs.remove(urlKey(coordinate))
+ prefs.remove(imageKey(coordinate))
+ prefs.remove(relaysKey(coordinate))
+ }
+ }
+
+ override suspend fun all(): Map {
+ val prefs = store.data.first()
+ val result = mutableMapOf()
+ for ((key, value) in prefs.asMap()) {
+ if (!key.name.startsWith(COORD_PREFIX)) continue
+ val coordinate = value as? String ?: continue
+ result[coordinate] =
+ Nip46ClientInfo(
+ name = prefs[nameKey(coordinate)],
+ url = prefs[urlKey(coordinate)],
+ image = prefs[imageKey(coordinate)],
+ relays = prefs[relaysKey(coordinate)].toRelaySet(),
+ )
+ }
+ return result
+ }
+
+ private fun String?.toRelaySet(): Set = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet()
+
+ private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}")
+
+ private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}")
+
+ private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}")
+
+ private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}")
+
+ private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}")
+
+ companion object {
+ private val stores = ConcurrentHashMap>()
+
+ private fun dataStoreFor(file: File): DataStore =
+ stores.computeIfAbsent(file.absolutePath) {
+ PreferenceDataStoreFactory.create(produceFile = { file })
+ }
+
+ private const val COORD_PREFIX = "coord:"
+
+ private fun hash(coordinate: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
+ return digest.take(8).joinToString("") { "%02x".format(it) }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index f94e7e73b2..040429e1f8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -27,6 +27,11 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -90,6 +95,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
+import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
@@ -384,6 +390,8 @@ class Account(
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
+ signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
+ nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) : IAccount {
private var userProfileCache: User? = null
@@ -438,6 +446,25 @@ class Account(
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
+ /** Connected-Apps signer permission ledger, shared by napplets and the NIP-46 bunker. */
+ val signerPermissionLedger = NostrSignerPermissionLedger(signerPermissionStore)
+
+ /**
+ * Runs this account as a NIP-46 remote signer for other apps when
+ * [AccountSettings.nip46SignerEnabled] is on, listening on the inbox relays
+ * and dispatching to [signer] (see [Nip46SignerState]).
+ */
+ val nip46Signer =
+ Nip46SignerState(
+ signer = signer,
+ client = client,
+ ledger = signerPermissionLedger,
+ clientStore = nip46ClientStore,
+ inboxRelays = nip65RelayList.inboxFlow,
+ scope = scope,
+ settings = settings,
+ )
+
val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings)
val dmRelayList = DmRelayListState(signer, cache, scope, settings)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
index 70f5e57625..69fc7d61a5 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
@@ -185,6 +185,35 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false),
+ /**
+ * NIP-46: when true, this account acts as a remote signer (a "bunker") for
+ * other apps, listening on the user's inbox relays for kind:24133 requests.
+ * See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState].
+ */
+ val nip46SignerEnabled: MutableStateFlow = MutableStateFlow(false),
+ /**
+ * The active pairing secret advertised in this account's `bunker://` URI. An
+ * app that connects with this secret is accepted and registered as a
+ * connected app; regenerating it revokes the ability of not-yet-connected
+ * apps to pair with an old string.
+ */
+ val nip46BunkerSecret: MutableStateFlow = MutableStateFlow(""),
+ /**
+ * A dedicated per-account transport keypair (hex private key) for the NIP-46
+ * bunker. The kind-24133 envelope is wrapped with THIS key, not the account's
+ * identity key, so the bunker address and on-relay traffic don't reveal which
+ * user the bunker belongs to (the identity is disclosed only to a connected
+ * app via `get_public_key`). Generated once and kept stable so the advertised
+ * `bunker://` address doesn't change.
+ */
+ val nip46TransportKey: MutableStateFlow = MutableStateFlow(""),
+ /**
+ * The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying
+ * stored ephemeral requests across an app restart doesn't make it sign the same request twice —
+ * matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark,
+ * a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window.
+ */
+ val nip46SeenRequestIds: MutableStateFlow> = MutableStateFlow(emptySet()),
/**
* NIP-9B opt-in: when true, community feeds drop events whose latest cached
* `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator].
@@ -582,6 +611,35 @@ class AccountSettings(
}
}
+ fun changeNip46SignerEnabled(enabled: Boolean) {
+ if (nip46SignerEnabled.value != enabled) {
+ nip46SignerEnabled.tryEmit(enabled)
+ saveAccountSettings()
+ }
+ }
+
+ fun changeNip46BunkerSecret(secret: String) {
+ if (nip46BunkerSecret.value != secret) {
+ nip46BunkerSecret.tryEmit(secret)
+ saveAccountSettings()
+ }
+ }
+
+ fun changeNip46TransportKey(hexPrivKey: String) {
+ if (nip46TransportKey.value != hexPrivKey) {
+ nip46TransportKey.tryEmit(hexPrivKey)
+ saveAccountSettings()
+ }
+ }
+
+ /** Replaces the recent serviced-request id set (already bounded by the caller). */
+ fun changeNip46SeenRequestIds(ids: Set) {
+ if (nip46SeenRequestIds.value != ids) {
+ nip46SeenRequestIds.tryEmit(ids)
+ saveAccountSettings()
+ }
+ }
+
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt
index 2fd320e03c..00b375d9bf 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt
@@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.model.accountsCache
import android.content.ContentResolver
import com.vitorpamplona.amethyst.LocalPreferences
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
+import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
+import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -66,9 +70,16 @@ class AccountCacheState(
val powQueue: () -> PoWPublishQueue? = { null },
/** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */
val meterSigner: (NostrSigner) -> NostrSigner = { it },
+ /** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */
+ val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
+ /** App-global store of connected NIP-46 client display + relay info. */
+ val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) {
val accounts = MutableStateFlow