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/connectedApps/consent/SignerConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index 528eb5fe11..bd3e035688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -168,12 +168,17 @@ private fun SignerConnectScreen( 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, + ) + } } Spacer(Modifier.height(16.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index 9aee290d8f..0e54a3324f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -33,6 +33,14 @@ data class SignerConnectInfo( 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, ) /** 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 index 57318da202..965a90f1cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -167,12 +167,10 @@ private fun SignerConsentDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - Text( - info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, - style = MaterialTheme.typography.labelSmall, - 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) + } } val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() 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 index 8f863e9823..eb567170d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -42,6 +42,14 @@ data class SignerConsentInfo( */ 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, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index 53e78a2e02..455bfda6e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.nip46Signer import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant @@ -29,6 +30,7 @@ import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplet.label import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -65,7 +67,18 @@ object Nip46ConsentBridge { val meta = request.clientMetadata val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") - val info = SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image) + // The identity being connected to lives in the coordinate; show it as an avatar + name. + val face = accountFace(coordinate) + val info = + SignerConnectInfo( + appletTitle = title, + coordinate = coordinate, + domain = domain, + iconUrl = meta?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + ) // Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't // hold the single-consumer loop hostage against every other client. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { @@ -92,6 +105,7 @@ object Nip46ConsentBridge { "" } val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" + val face = accountFace(coordinate) val consentInfo = SignerConsentInfo( appletTitle = title, @@ -101,10 +115,26 @@ object Nip46ConsentBridge { contentPreview = preview, rawData = rawData, iconUrl = info?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, ) // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { SignerConsentCoordinator.requestConsent(context, consentInfo) } ?: SignerOpGrant.DenyOnce } + + /** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */ + private fun accountFace(coordinate: String): AccountFace { + val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate) + val user = pubKey?.let { LocalCache.getUserIfExists(it) } + return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) + } + + private data class AccountFace( + val name: String?, + val picture: String?, + val pubKey: String?, + ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index e2db0fae03..e91e1e6d03 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -254,6 +254,14 @@ class Nip46PermissionAuthorizer( /** The client pubkey of a `nip46::` coordinate, or `null` if it is not one. */ fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null + /** The signer (account identity) pubkey of a `nip46::` coordinate, or `null`. */ + fun signerPubKeyOf(coordinate: String): HexKey? = + if (coordinate.startsWith("$COORDINATE_PREFIX:")) { + coordinate.substringAfter("$COORDINATE_PREFIX:").substringBefore(':').ifBlank { null } + } else { + null + } + /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ fun BunkerRequest.toSignerOp(): NostrSignerOp? = when (this) {