Merge pull request #475 from greenart7c3/claude/elegant-goodall-xu3f3c

Add NIP-46 client metadata support for bunker:// connections
This commit is contained in:
greenart7c3
2026-06-16 15:08:12 -03:00
committed by GitHub
28 changed files with 550 additions and 24 deletions
@@ -356,6 +356,13 @@ class Amber :
instance = this
stats.createNotificationChannel()
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
// Build Coil's singleton ImageLoader off the main thread. Its factory
// (newImageLoader) touches cacheDir, so letting the first AsyncImage
// build it lazily during composition trips StrictMode's DiskReadViolation.
applicationIOScope.launch {
SingletonImageLoader.get(this@Amber)
}
}
private fun enableStrictMode() {
@@ -134,6 +134,8 @@ class MainViewModel(val context: Context) : ViewModel() {
if (intentData != null) {
IntentUtils.addAll(listOf(intentData))
addedIntentIds.add(intentData.id)
// The calling app is visible to us now; capture its icon/name.
IntentUtils.persistNativeAppMetadata(context, acc, callingPackage)
}
intent.getStringExtra("route")?.let { route ->
@@ -8,6 +8,7 @@ import android.net.Uri
import android.util.Log
import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.permissionTypeFromContent
@@ -28,6 +29,19 @@ class SignerProvider : ContentProvider() {
private fun rejectedCursor(): Cursor = MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) }
/**
* Capture the calling app's launcher icon/name onto its saved app while it is
* visible to us. Dispatched off the binder thread so it adds no latency to the
* signing response, and gated to run once per app (see [IntentUtils.persistNativeAppMetadata]).
*/
private fun captureCallerMetadata(account: Account) {
val pkg = callingPackage ?: return
val ctx = context ?: return
scope.launch {
IntentUtils.persistNativeAppMetadata(ctx, account, pkg)
}
}
// Decodes the Base64 v3 wire value to readable plaintext for history.
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
private fun nip44v3Plaintext(wireValue: String): String = try {
@@ -93,6 +107,7 @@ class SignerProvider : ContentProvider() {
Log.d(Amber.TAG, "No account from storage")
return null
}
captureCallerMetadata(account)
val event = try {
IntentUtils.getUnsignedEvent(json, account)
} catch (e: Exception) {
@@ -243,6 +258,7 @@ class SignerProvider : ContentProvider() {
val stringType = uriString.replace("content://$appId.", "")
val pubkey = projection[1]
val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub) ?: return null
captureCallerMetadata(account)
val logDatabase = Amber.instance.getLogDatabase(account.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
@@ -452,6 +468,7 @@ class SignerProvider : ContentProvider() {
Log.d(Amber.TAG, "No account from storage")
return null
}
captureCallerMetadata(account)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission =
@@ -547,6 +564,7 @@ class SignerProvider : ContentProvider() {
if (account == null) {
return null
}
captureCallerMetadata(account)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
val permDao = Amber.instance.dao(account.npub)
val permission =
@@ -200,6 +200,10 @@ interface ApplicationDao {
@Transaction
suspend fun updateLastUsed(key: String, time: Long)
@Query("UPDATE application SET name = :name, icon = :icon WHERE `key` = :key")
@Transaction
suspend fun updateNameAndIcon(key: String, name: String, icon: String)
@Delete
@Transaction
suspend fun deletePermission(permission: ApplicationPermissionsEntity)
@@ -225,6 +225,11 @@ class CachingApplicationDao(
// lastUsed doesn't affect any cached read, so no invalidation needed.
}
override suspend fun updateNameAndIcon(key: String, name: String, icon: String) {
delegate.updateNameAndIcon(key, name, icon)
invalidateApp(key)
}
companion object {
private const val MAX_ENTRIES = 512
}
@@ -24,4 +24,5 @@ data class AmberBunkerRequest(
val encryptionType: EncryptionType,
val isNostrConnectUri: Boolean,
val signerPrivKey: String = "",
val clientMetadata: BunkerClientMetadata? = null,
)
@@ -0,0 +1,70 @@
package com.greenart7c3.nostrsigner.models
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
/**
* Optional client identification metadata a client MAY include as the 4th
* parameter (`optional_client_metadata`) of a NIP-46 `connect` request — see
* nostr-protocol/nips#2381.
*
* It is a JSON-stringified object carrying the same display-only `name`, `url`
* and `image` fields already advertised by `nostrconnect://` URIs, letting the
* signer show who is connecting over the `bunker://` flow. It is for display
* only and MUST NOT be used for authorization.
*/
data class BunkerClientMetadata(
val name: String = "",
val url: String = "",
val image: String = "",
) {
fun isEmpty(): Boolean = name.isBlank() && url.isBlank() && image.isBlank()
companion object {
private val mapper: ObjectMapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
/**
* Parses the `optional_client_metadata` JSON string. Returns null when the
* input is absent, blank or malformed — the field is optional and purely
* informational, so a bad value must never break the connect flow.
*/
fun parseOrNull(json: String?): BunkerClientMetadata? {
if (json.isNullOrBlank()) return null
return try {
fromJson(mapper.readTree(json)).takeUnless { it.isEmpty() }
} catch (_: Exception) {
null
}
}
/**
* Extracts the client metadata from a raw decrypted NIP-46 `connect`
* request JSON, reading the 4th element of its `params` array.
*
* This deliberately works off the raw request rather than a parsed
* `BunkerRequestConnect`: Quartz only keeps `params[0..2]` (remote key,
* secret, permissions) and rebuilds the `params` array from those, so the
* `optional_client_metadata` element would otherwise be lost.
*/
fun fromConnectRequest(requestJson: String?): BunkerClientMetadata? {
if (requestJson.isNullOrBlank()) return null
return try {
val params = mapper.readTree(requestJson).get("params")
if (params == null || !params.isArray) return null
parseOrNull(params.get(3)?.asText())
} catch (_: Exception) {
null
}
}
private fun fromJson(node: JsonNode): BunkerClientMetadata = BunkerClientMetadata(
name = node.get("name")?.asText()?.trim().orEmpty(),
url = node.get("url")?.asText()?.trim().orEmpty(),
image = node.get("image")?.asText()?.trim().orEmpty(),
)
}
}
@@ -0,0 +1,47 @@
package com.greenart7c3.nostrsigner.service
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import java.io.File
import java.io.FileOutputStream
/**
* Persists a native app's launcher icon to internal storage so it can be shown
* later on non-interactive screens (e.g. the applications list).
*
* Android 11+ package-visibility filtering only lets us resolve an installed
* app's icon while that app is visible to us — typically the moment it sends a
* `nostrsigner://` request. We capture it then and store the file path on the
* saved [com.greenart7c3.nostrsigner.database.ApplicationEntity], avoiding the
* broad QUERY_ALL_PACKAGES permission.
*/
object AppIconStore {
private const val DIR = "app_icons"
/**
* Returns the path to the cached PNG for [packageName], writing it from the
* PackageManager icon on first use. Returns null if the icon can't be
* resolved (e.g. the app is not currently visible).
*/
fun resolveIconPath(context: Context, packageName: String): String? {
val dir = File(context.filesDir, DIR).apply { mkdirs() }
val file = File(dir, "$packageName.png")
if (!file.exists()) {
val drawable = runCatching {
context.packageManager.getApplicationIcon(packageName)
}.getOrNull() ?: return null
val saved = runCatching {
FileOutputStream(file).use { out ->
drawable.toBitmap().compress(Bitmap.CompressFormat.PNG, 100, out)
}
}.isSuccess
if (!saved) return null
}
return file.absolutePath
}
}
@@ -341,8 +341,8 @@ object BunkerRequestUtils {
key,
appName ?: "",
relays,
"",
"",
bunkerRequest.clientMetadata?.url ?: "",
bunkerRequest.clientMetadata?.image ?: "",
"",
account.hexKey,
true,
@@ -365,6 +365,23 @@ object BunkerRequestUtils {
)
}
// Persist client metadata (nostr-protocol/nips#2381) carried by the
// connect request. The bunker:// application is created up-front when
// its URI is generated, so savedApplication is usually non-null here —
// the new-entity branch above never runs for it. Update name/url/icon
// in place so the metadata is stored on every connect.
if (bunkerRequest.request is BunkerRequestConnect) {
bunkerRequest.clientMetadata?.let { metadata ->
application = application.copy(
application = application.application.copy(
name = metadata.name.ifBlank { application.application.name },
url = metadata.url.ifBlank { application.application.url },
icon = metadata.image.ifBlank { application.application.icon },
),
)
}
}
val activity = Amber.instance.getMainActivity()
activity?.intent = null
if (application.application.closeApplication || activity is SignerActivity) {
@@ -36,6 +36,7 @@ import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.BunkerClientMetadata
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
import com.greenart7c3.nostrsigner.models.EncryptedDataKind
import com.greenart7c3.nostrsigner.models.EncryptionType
@@ -229,6 +230,17 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val encryptedDataKind = getEncryptedDataKind(bunkerRequest, acc, relay.url)
// NIP-46 connect requests may carry optional client metadata as the 4th
// param (nostr-protocol/nips#2381) so the signer can show who is
// connecting over the bunker:// flow. It must be read from the raw request
// JSON: Quartz's BunkerRequestConnect only keeps params[0..2] and drops the
// metadata element when it rebuilds its params array.
val clientMetadata = if (bunkerRequest is BunkerRequestConnect) {
BunkerClientMetadata.fromConnectRequest(requestStr)
} else {
null
}
var request = AmberBunkerRequest(
request = bunkerRequest,
localKey = event.pubKey,
@@ -236,12 +248,13 @@ class EventNotificationConsumer(private val applicationContext: Context) {
currentAccount = acc.npub,
nostrConnectSecret = "",
closeApplication = true,
name = "",
name = clientMetadata?.name ?: "",
signedEvent = signedEvent,
encryptedData = encryptedDataKind,
encryptionType = encryptionType,
isNostrConnectUri = false,
signerPrivKey = connectionPrivKey,
clientMetadata = clientMetadata,
)
val type = BunkerRequestUtils.getTypeFromBunker(bunkerRequest)
@@ -358,12 +371,13 @@ class EventNotificationConsumer(private val applicationContext: Context) {
currentAccount = acc.npub,
nostrConnectSecret = "",
closeApplication = true,
name = "",
name = clientMetadata?.name ?: "",
signedEvent = signedEvent,
encryptedData = encryptedDataKind,
encryptionType = encryptionType,
isNostrConnectUri = false,
signerPrivKey = connectionPrivKey,
clientMetadata = clientMetadata,
)
if (type == SignerType.SWITCH_RELAYS) {
@@ -68,6 +68,41 @@ object IntentUtils {
private val _intents = MutableStateFlow<ImmutableList<IntentData>>(persistentListOf())
val intents = _intents.asStateFlow()
/**
* Captures a native (intent/ContentProvider) app's launcher icon and label
* onto its saved [com.greenart7c3.nostrsigner.database.ApplicationEntity]
* while the app is visible to us (i.e. it just sent a request). Other,
* non-interactive screens (the applications list) can then show them without
* the broad QUERY_ALL_PACKAGES permission — Android 11+ package visibility
* otherwise hides installed apps that aren't currently interacting with us.
*
* Only edits an already-saved app and only fills blank name/icon, so it
* effectively runs once per app and never overwrites existing values.
*/
suspend fun persistNativeAppMetadata(
context: Context,
account: Account,
packageName: String?,
) {
if (packageName.isNullOrBlank()) return
val dao = Amber.instance.dao(account.npub)
val saved = dao.getByKey(packageName)?.application ?: return
if (saved.name.isNotBlank() && saved.icon.isNotBlank()) return
val newIcon = saved.icon.ifBlank {
AppIconStore.resolveIconPath(context, packageName) ?: ""
}
val newName = saved.name.ifBlank {
runCatching {
val info = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(info).toString()
}.getOrNull().orEmpty()
}
if (newName == saved.name && newIcon == saved.icon) return
dao.updateNameAndIcon(packageName, newName.ifBlank { saved.name }, newIcon)
}
data class InvalidIntentInfo(
val id: String,
val dataString: String?,
@@ -860,6 +895,10 @@ object IntentUtils {
if (packageName != null) {
dao.insertApplicationWithPermissions(application)
// The calling app is visible to us now (mid-request); capture its
// launcher icon + label so a first-ever connection populates them
// immediately, not only on a later request.
persistNativeAppMetadata(context, account, packageName)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
Amber.instance.applicationIOScope.launch {
historyDatabase.dao().addHistory(
@@ -6,6 +6,7 @@ import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.BunkerClientMetadata
import com.greenart7c3.nostrsigner.models.BunkerMetadata
import com.greenart7c3.nostrsigner.models.EncryptionType
import com.greenart7c3.nostrsigner.models.Permission
@@ -32,6 +33,8 @@ object NostrConnectUtils {
val split = data.split("?")
val relays: MutableList<NormalizedRelayUrl> = mutableListOf()
var name = ""
var url = ""
var image = ""
val pubKey = split.first()
val parsedData = IntentUtils.decodeData(split.drop(1).joinToString { it })
val splitParsedData = parsedData.split("&")
@@ -53,6 +56,14 @@ object NostrConnectUtils {
name = json
}
if (paramName == "url") {
url = json
}
if (paramName == "image") {
image = json
}
if (paramName == "secret") {
nostrConnectSecret = json
}
@@ -83,6 +94,14 @@ object NostrConnectUtils {
if (paramName == "metadata") {
val bunkerMetada = metaDataFromJson(json)
name = bunkerMetada.name
if (bunkerMetada.url.isNotEmpty()) {
url = bunkerMetada.url
}
BunkerClientMetadata.parseOrNull(json)?.let { clientMetadata ->
if (clientMetadata.image.isNotEmpty()) {
image = clientMetadata.image
}
}
if (bunkerMetada.perms.isNotEmpty()) {
val splitPerms = bunkerMetada.perms.split(",")
splitPerms.forEach { perm ->
@@ -109,6 +128,9 @@ object NostrConnectUtils {
permissions.removeIf { it.kind == null && (it.type == "sign_event" || it.type == "nip") }
permissions.removeIf { it.type == "nip" && (it.kind == null || !it.kind.containsNip()) }
val clientMetadata = BunkerClientMetadata(name = name, url = url, image = image)
.takeUnless { it.isEmpty() }
BunkerRequestUtils.addRequest(
AmberBunkerRequest(
BunkerRequestConnect(
@@ -131,6 +153,7 @@ object NostrConnectUtils {
encryptedData = null,
encryptionType = EncryptionType.NIP44,
isNostrConnectUri = true,
clientMetadata = clientMetadata,
),
)
} catch (e: Exception) {
@@ -49,6 +49,7 @@ import com.greenart7c3.nostrsigner.service.KillSwitchReceiver
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.components.AmberWarningCard
import com.greenart7c3.nostrsigner.ui.components.AppAvatar
import com.greenart7c3.nostrsigner.ui.navigation.Route
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.Dispatchers
@@ -190,6 +191,13 @@ fun ApplicationsScreen(
},
verticalAlignment = Alignment.CenterVertically,
) {
AppAvatar(
modifier = Modifier.padding(end = 12.dp),
iconUrl = applicationWithHistory.icon,
packageName = applicationWithHistory.key,
name = applicationWithHistory.name.ifBlank { applicationWithHistory.key.toShortenHex() },
size = 40.dp,
)
Column(
verticalArrangement = Arrangement.Center,
) {
@@ -253,6 +253,18 @@ fun rememberAppDisplayInfo(packageName: String): AppDisplayInfo {
}
}
/** Resolves a native app's launcher icon, or null when it can't be queried. */
@Composable
fun rememberAppIcon(packageName: String?): Drawable? {
if (packageName.isNullOrBlank()) return null
val context = LocalContext.current
return remember(packageName) {
runCatching {
context.packageManager.getApplicationIcon(packageName)
}.getOrNull()
}
}
data class AppDisplayInfo(
val name: String,
val icon: Drawable? = null,
@@ -0,0 +1,90 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import coil3.compose.AsyncImage
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import java.io.File
/**
* App avatar resolved in priority order:
* 1. [iconUrl] — a remote NIP-46 client icon (skipped on the offline flavor,
* which has no network image loading).
* 2. [packageName] — a native Android app's launcher icon, via PackageManager.
* 3. A letter placeholder built from [name] for apps without any icon.
*/
@Composable
fun AppAvatar(
modifier: Modifier = Modifier,
iconUrl: String? = null,
packageName: String? = null,
name: String = "",
size: Dp = 48.dp,
) {
val avatarModifier = modifier
.size(size)
.clip(CircleShape)
// A persisted native-app icon is a local file path; a remote client icon is
// an http(s) URL. Local files load on every flavor (no network); remote URLs
// are skipped on the offline flavor.
val isRemote = iconUrl?.startsWith("http", ignoreCase = true) == true
val model: Any? = when {
iconUrl.isNullOrBlank() -> null
isRemote -> if (BuildFlavorChecker.isOfflineFlavor()) null else iconUrl
else -> File(iconUrl)
}
// Only query PackageManager when there's no stored icon to show — avoids a
// futile (and log-noisy) lookup for rows that aren't native apps.
val systemIcon = if (model == null) rememberAppIcon(packageName) else null
when {
model != null -> {
AsyncImage(
model = model,
contentDescription = name.ifBlank { null },
modifier = avatarModifier,
)
}
systemIcon != null -> {
Image(
bitmap = systemIcon.toBitmap().asImageBitmap(),
contentDescription = name.ifBlank { null },
contentScale = ContentScale.Crop,
modifier = avatarModifier,
)
}
else -> {
Box(
modifier = avatarModifier.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
fontSize = (size.value / 2).sp,
)
}
}
}
}
@@ -204,6 +204,9 @@ fun BunkerConnectRequestScreen(
.verticalScroll(scrollState)
.padding(horizontal = horizontalPadding),
) {
val clientImage = bunkerRequest.clientMetadata?.image.orEmpty()
RemoteAppIcon(imageUrl = clientImage, name = appName.value, size = 64.dp)
Text(
modifier = Modifier
.fillMaxWidth()
@@ -214,6 +217,21 @@ fun BunkerConnectRequestScreen(
textAlign = TextAlign.Center,
)
val clientUrl = bunkerRequest.clientMetadata?.url.orEmpty()
if (clientUrl.isNotBlank()) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
text = clientUrl,
fontSize = 13.sp,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Account section
SectionLabel(stringResource(R.string.account))
AccountPickerRow(
@@ -24,12 +24,15 @@ fun BunkerGetPubKeyScreen(
applicationName: String,
onAccept: (List<Permission>?, Int, Boolean?, RememberType) -> Unit,
onReject: (RememberType) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
Column(
modifier,
) {
RemoteAppIcon(iconUrl, applicationName)
Text(
modifier = Modifier.fillMaxWidth(),
text = applicationName,
@@ -83,6 +83,7 @@ fun BunkerMultiEventHomeScreen(
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var relayAuthScope by remember { mutableStateOf(RelayAuthScope.SPECIFIC) }
var appName by remember { mutableStateOf(ApplicationNameCache["$localAccount-$key"] ?: key.toShortenHex()) }
var appIcon by remember { mutableStateOf(bunkerRequests.first().clientMetadata?.image ?: "") }
LaunchedEffect(Unit) {
MultiEventScreenIntents.checkedStates.clear()
@@ -97,8 +98,8 @@ fun BunkerMultiEventHomeScreen(
bunkerRequests.first().currentAccount,
)?.npub?.toShortenHex() ?: ""
val app = Amber.instance.getDatabase(accountParam.npub).dao().getByKey(key)
if (ApplicationNameCache["$localAccount-$key"] == null) {
val app = Amber.instance.getDatabase(accountParam.npub).dao().getByKey(key)
app?.let {
appName = it.application.name
ApplicationNameCache["$localAccount-$key"] = it.application.name
@@ -108,12 +109,15 @@ fun BunkerMultiEventHomeScreen(
appName = it
}
}
app?.application?.icon?.let { if (it.isNotBlank()) appIcon = it }
}
}
Column(
modifier,
) {
RemoteAppIcon(appIcon, appName)
Text(
stringResource(R.string.is_requiring_some_permissions_please_review_them, appName),
Modifier
@@ -328,10 +332,10 @@ fun BunkerMultiEventHomeScreen(
savedApplication ?: ApplicationWithPermissions(
application = ApplicationEntity(
localKey,
"",
request.clientMetadata?.name ?: "",
listOf(),
"",
"",
request.clientMetadata?.url ?: "",
request.clientMetadata?.image ?: "",
"",
thisAccount.hexKey,
true,
@@ -31,6 +31,7 @@ fun BunkerPingScreen(
appName: String,
onAccept: (RememberType) -> Unit,
onReject: (RememberType) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember {
mutableStateOf(RememberType.NEVER)
@@ -39,6 +40,8 @@ fun BunkerPingScreen(
Column(
modifier,
) {
RemoteAppIcon(iconUrl, appName)
val message = stringResource(R.string.ping)
Text(
@@ -47,6 +47,7 @@ fun BunkerRelayAuthScreen(
account: Account,
onAccept: (RememberType, RelayAuthScope) -> Unit,
onReject: (RememberType, RelayAuthScope) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var scope by remember { mutableStateOf(defaultScope) }
@@ -67,6 +68,8 @@ fun BunkerRelayAuthScreen(
) {
Spacer(Modifier.size(16.dp))
RemoteAppIcon(iconUrl, appName)
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -88,6 +88,9 @@ fun BunkerSingleEventHomeScreen(
var appName = applicationEntity?.application?.name ?: bunkerRequest.name
appName = appName.ifBlank { key.toShortenHex() }
// Prefer the persisted application icon, falling back to the icon carried by
// the connect request's client metadata (nostr-protocol/nips#2381).
val appIcon = (applicationEntity?.application?.icon ?: "").ifBlank { bunkerRequest.clientMetadata?.image ?: "" }
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val type = BunkerRequestUtils.getTypeFromBunker(bunkerRequest.request)
@@ -115,6 +118,7 @@ fun BunkerSingleEventHomeScreen(
BunkerPingScreen(
modifier = modifier,
appName = appName,
iconUrl = appIcon,
shouldRunOnAccept = acceptOrReject,
onAccept = {
val result = "pong"
@@ -310,6 +314,7 @@ fun BunkerSingleEventHomeScreen(
BunkerGetPubKeyScreen(
modifier = modifier,
applicationName = appName,
iconUrl = appIcon,
onAccept = { permissions, signPolicy, closeApplication, rememberType ->
val result = account.hexKey
@@ -381,6 +386,7 @@ fun BunkerSingleEventHomeScreen(
encryptedData = bunkerRequest.encryptedData,
shouldRunOnAccept = acceptOrReject,
appName = appName,
iconUrl = appIcon,
type = type,
account = account,
onAccept = { rememberType, scope ->
@@ -454,6 +460,7 @@ fun BunkerSingleEventHomeScreen(
encryptedData = bunkerRequest.encryptedData,
shouldRunOnAccept = acceptOrReject,
appName = appName,
iconUrl = appIcon,
type = type,
account = account,
onAccept = { rememberType, scope ->
@@ -529,6 +536,7 @@ fun BunkerSingleEventHomeScreen(
encryptedData = bunkerRequest.encryptedData,
shouldRunOnAccept = acceptOrReject,
appName = appName,
iconUrl = appIcon,
type = type,
account = account,
onAccept = { rememberType, scope ->
@@ -604,6 +612,7 @@ fun BunkerSingleEventHomeScreen(
encryptedData = bunkerRequest.encryptedData,
shouldRunOnAccept = acceptOrReject,
appName = appName,
iconUrl = appIcon,
type = type,
account = account,
onAccept = { rememberType, scope ->
@@ -696,6 +705,7 @@ fun BunkerSingleEventHomeScreen(
BunkerRelayAuthScreen(
modifier = modifier,
appName = appName,
iconUrl = appIcon,
relayUrl = relayUrl,
shouldAcceptOrReject = acceptOrReject,
defaultScope = RelayAuthScope.SPECIFIC,
@@ -768,6 +778,7 @@ fun BunkerSingleEventHomeScreen(
modifier = modifier,
shouldAcceptOrReject = acceptOrReject,
appName = appName,
iconUrl = appIcon,
event = event,
account = account,
onAccept = {
@@ -831,6 +842,7 @@ fun BunkerSingleEventHomeScreen(
modifier = modifier,
isBunker = true,
appName = appName,
iconUrl = appIcon,
packageName = null,
type = type,
account = account,
@@ -968,6 +980,7 @@ fun BunkerSingleEventHomeScreen(
decoded = decoded,
shouldRunOnAccept = acceptOrReject,
appName = appName,
iconUrl = appIcon,
onAccept = {
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
try {
@@ -260,6 +260,7 @@ fun BunkerEncryptDecryptData(
defaultScope: DecryptTypeScope = DecryptTypeScope.ALL,
onAccept: (RememberType, DecryptTypeScope) -> Unit,
onReject: (RememberType, DecryptTypeScope) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember {
mutableStateOf(RememberType.NEVER)
@@ -270,6 +271,8 @@ fun BunkerEncryptDecryptData(
Column(
modifier,
) {
RemoteAppIcon(iconUrl, appName)
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -129,6 +129,7 @@ fun BunkerEventData(
account: Account,
onAccept: (RememberType) -> Unit,
onReject: (RememberType) -> Unit,
iconUrl: String = "",
) {
var showMore by androidx.compose.runtime.remember {
mutableStateOf(false)
@@ -143,6 +144,7 @@ fun BunkerEventData(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.size(16.dp))
RemoteAppIcon(iconUrl, appName)
val permission = Permission("sign_event", event.kind)
val kindTranslation = permission.toLocalizedString(context)
val unknownKindString = stringResource(R.string.event_kind, event.kind.toString())
@@ -1,24 +1,18 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
@Composable
fun LocalAppIcon(packageName: String?) {
@@ -29,16 +23,10 @@ fun LocalAppIcon(packageName: String?) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (appDisplayInfo.icon != null) {
Image(
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small),
bitmap = appDisplayInfo.icon.toBitmap().asImageBitmap(),
contentDescription = appDisplayInfo.name,
contentScale = ContentScale.Crop,
)
}
AppAvatar(
packageName = packageName,
name = appDisplayInfo.name,
)
Text(
text = appDisplayInfo.name,
@@ -55,6 +55,7 @@ fun Nip44v3ApprovalData(
defaultScope: DecryptTypeScope = DecryptTypeScope.SPECIFIC,
onAccept: (RememberType, DecryptTypeScope) -> Unit,
onReject: (RememberType, DecryptTypeScope) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var grantScope by remember { mutableStateOf(defaultScope) }
@@ -65,6 +66,7 @@ fun Nip44v3ApprovalData(
Column(modifier) {
if (isBunker) {
RemoteAppIcon(iconUrl, appName)
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -0,0 +1,35 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Centered avatar header for a request screen. Shows the remote client icon from
* [imageUrl] when present, otherwise a letter placeholder built from [name].
*/
@Composable
fun RemoteAppIcon(
imageUrl: String?,
name: String = "",
modifier: Modifier = Modifier,
size: Dp = 48.dp,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppAvatar(
iconUrl = imageUrl,
name = name,
size = size,
)
}
}
@@ -85,10 +85,13 @@ fun BunkerSignPsbt(
appName: String,
onAccept: (RememberType) -> Unit,
onReject: (RememberType) -> Unit,
iconUrl: String = "",
) {
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
Column(modifier) {
RemoteAppIcon(iconUrl, appName)
val message = stringResource(R.string.sign_psbt)
Text(
@@ -0,0 +1,92 @@
package com.greenart7c3.nostrsigner.models
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class BunkerClientMetadataTest {
@Test
fun `parses name url and image`() {
val json = """{"name":"My App","url":"https://example.com","image":"https://example.com/icon.png"}"""
val metadata = BunkerClientMetadata.parseOrNull(json)
assertEquals("My App", metadata?.name)
assertEquals("https://example.com", metadata?.url)
assertEquals("https://example.com/icon.png", metadata?.image)
}
@Test
fun `parses partial metadata`() {
val metadata = BunkerClientMetadata.parseOrNull("""{"name":"Just A Name"}""")
assertEquals("Just A Name", metadata?.name)
assertEquals("", metadata?.url)
assertEquals("", metadata?.image)
}
@Test
fun `ignores unknown properties`() {
val json = """{"name":"App","extra":"ignored","perms":"sign_event:1"}"""
val metadata = BunkerClientMetadata.parseOrNull(json)
assertEquals("App", metadata?.name)
}
@Test
fun `returns null for null input`() {
assertNull(BunkerClientMetadata.parseOrNull(null))
}
@Test
fun `returns null for blank input`() {
assertNull(BunkerClientMetadata.parseOrNull(""))
assertNull(BunkerClientMetadata.parseOrNull(" "))
}
@Test
fun `returns null for malformed json`() {
assertNull(BunkerClientMetadata.parseOrNull("not json"))
assertNull(BunkerClientMetadata.parseOrNull("{"))
}
@Test
fun `returns null when all fields are empty`() {
assertNull(BunkerClientMetadata.parseOrNull("""{"name":"","url":"","image":""}"""))
assertNull(BunkerClientMetadata.parseOrNull("{}"))
}
@Test
fun `trims whitespace in fields`() {
val metadata = BunkerClientMetadata.parseOrNull("""{"name":" App ","url":" https://x.com "}""")
assertEquals("App", metadata?.name)
assertEquals("https://x.com", metadata?.url)
}
@Test
fun `fromConnectRequest reads the 4th param`() {
val request = """
{"id":"1","method":"connect","params":["remotepk","secret","sign_event:1","{\"name\":\"Web Client\",\"url\":\"https://web.client\",\"image\":\"https://web.client/i.png\"}"]}
""".trimIndent()
val metadata = BunkerClientMetadata.fromConnectRequest(request)
assertEquals("Web Client", metadata?.name)
assertEquals("https://web.client", metadata?.url)
assertEquals("https://web.client/i.png", metadata?.image)
}
@Test
fun `fromConnectRequest returns null when metadata param is absent`() {
val request = """{"id":"1","method":"connect","params":["remotepk","secret","sign_event:1"]}"""
assertNull(BunkerClientMetadata.fromConnectRequest(request))
}
@Test
fun `fromConnectRequest returns null when params missing or not an array`() {
assertNull(BunkerClientMetadata.fromConnectRequest("""{"id":"1","method":"connect"}"""))
assertNull(BunkerClientMetadata.fromConnectRequest("""{"id":"1","method":"connect","params":"x"}"""))
}
@Test
fun `fromConnectRequest returns null for null blank or malformed input`() {
assertNull(BunkerClientMetadata.fromConnectRequest(null))
assertNull(BunkerClientMetadata.fromConnectRequest(""))
assertNull(BunkerClientMetadata.fromConnectRequest("not json"))
}
}