From cacc64b1f555b23e924a8107114bf8e3abb73f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 14:53:04 +0000 Subject: [PATCH 1/8] Implement NIP-46 client metadata in bunker connect flow Parse the optional 4th `connect` param (`optional_client_metadata`, nostr-protocol/nips#2381) carrying display-only name/url/image, so the signer can show who is connecting over the bunker:// flow. - Add BunkerClientMetadata model with a lenient parser - Carry it on AmberBunkerRequest and parse it in EventNotificationConsumer - Persist url/image onto the ApplicationEntity on connect - Show the client image and url on the connect approval screen --- .../nostrsigner/models/AmberBunkerRequest.kt | 1 + .../models/BunkerClientMetadata.kt | 50 +++++++++++++++ .../nostrsigner/service/BunkerRequestUtils.kt | 4 +- .../service/EventNotificationConsumer.kt | 16 ++++- .../components/BunkerConnectRequestScreen.kt | 37 +++++++++++ .../models/BunkerClientMetadataTest.kt | 62 +++++++++++++++++++ 6 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberBunkerRequest.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberBunkerRequest.kt index fbf97331..2f8bf1a3 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberBunkerRequest.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberBunkerRequest.kt @@ -24,4 +24,5 @@ data class AmberBunkerRequest( val encryptionType: EncryptionType, val isNostrConnectUri: Boolean, val signerPrivKey: String = "", + val clientMetadata: BunkerClientMetadata? = null, ) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt new file mode 100644 index 00000000..09be6ed8 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt @@ -0,0 +1,50 @@ +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 + } + } + + 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(), + ) + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt index 6a6e4b0c..151b023f 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -341,8 +341,8 @@ object BunkerRequestUtils { key, appName ?: "", relays, - "", - "", + bunkerRequest.clientMetadata?.url ?: "", + bunkerRequest.clientMetadata?.image ?: "", "", account.hexKey, true, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt index f339a241..5fdbb3b6 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -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,15 @@ 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. + val clientMetadata = if (bunkerRequest is BunkerRequestConnect) { + BunkerClientMetadata.parseOrNull(bunkerRequest.params.getOrNull(3)) + } else { + null + } + var request = AmberBunkerRequest( request = bunkerRequest, localKey = event.pubKey, @@ -236,12 +246,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 +369,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) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt index 342174ef..0ceb4b9d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -41,6 +43,7 @@ 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource @@ -52,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences @@ -204,6 +208,24 @@ fun BunkerConnectRequestScreen( .verticalScroll(scrollState) .padding(horizontal = horizontalPadding), ) { + val clientImage = bunkerRequest.clientMetadata?.image.orEmpty() + if (clientImage.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = clientImage, + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + } + } + Text( modifier = Modifier .fillMaxWidth() @@ -214,6 +236,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( diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt new file mode 100644 index 00000000..4ea9ab29 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt @@ -0,0 +1,62 @@ +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) + } +} From ed10b1a9df7008b326d4d4db403d833478372a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 15:13:12 +0000 Subject: [PATCH 2/8] Read client metadata from raw connect request, not Quartz params Quartz's BunkerRequestConnect.parse only keeps params[0..2] (remote key, secret, permissions) and rebuilds its params array from those, so the optional_client_metadata 4th element is dropped during deserialization. Parse it from the raw decrypted request JSON instead. Also populate client metadata (name/url/image) from nostrconnect:// URIs so the connect screen shows it consistently for both flows. --- .../models/BunkerClientMetadata.kt | 20 +++++++++++++ .../service/EventNotificationConsumer.kt | 6 ++-- .../nostrsigner/service/NostrConnectUtils.kt | 23 ++++++++++++++ .../models/BunkerClientMetadataTest.kt | 30 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt index 09be6ed8..df8e6a75 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt @@ -41,6 +41,26 @@ data class BunkerClientMetadata( } } + /** + * 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(), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt index 5fdbb3b6..a8dfefc5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -232,9 +232,11 @@ class EventNotificationConsumer(private val applicationContext: Context) { // 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. + // 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.parseOrNull(bunkerRequest.params.getOrNull(3)) + BunkerClientMetadata.fromConnectRequest(requestStr) } else { null } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrConnectUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrConnectUtils.kt index 859229a2..6ed1e36b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrConnectUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrConnectUtils.kt @@ -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 = 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) { diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt index 4ea9ab29..e02cb839 100644 --- a/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt +++ b/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt @@ -59,4 +59,34 @@ class BunkerClientMetadataTest { 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")) + } } From 1ce7fab4e9c822801179005f339be4980af55796 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 16:19:09 +0000 Subject: [PATCH 3/8] Show client metadata icon on request screens and app list - Add a reusable RemoteAppIcon composable (network image, gated to non-offline) - Render it on all bunker request screens (connect, get_public_key, sign, encrypt/decrypt, nip44 v3, ping, relay auth, sign_psbt) and the multi-event screen, sourcing the icon from the saved application icon or the connect request's client metadata - Show the saved app icon on each row of the main applications list - Persist client metadata (name/url/image) when approving a connect via the multi-event screen --- .../nostrsigner/ui/ApplicationsScreen.kt | 16 +++++++ .../components/BunkerConnectRequestScreen.kt | 21 +-------- .../ui/components/BunkerGetPubKeyScreen.kt | 3 ++ .../components/BunkerMultiEventHomeScreen.kt | 12 +++-- .../ui/components/BunkerPingScreen.kt | 3 ++ .../ui/components/BunkerRelayAuthScreen.kt | 3 ++ .../components/BunkerSingleEventHomeScreen.kt | 13 ++++++ .../ui/components/EncryptDecryptData.kt | 3 ++ .../nostrsigner/ui/components/EventData.kt | 2 + .../ui/components/Nip44v3ApprovalData.kt | 2 + .../ui/components/RemoteAppIcon.kt | 45 +++++++++++++++++++ .../nostrsigner/ui/components/SignPsbt.kt | 3 ++ 12 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt index 9545ced4..6990547c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -24,6 +26,7 @@ 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.res.stringResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -40,7 +43,9 @@ import androidx.navigation.NavController import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.compose.collectAsLazyPagingItems +import coil3.compose.AsyncImage import com.greenart7c3.nostrsigner.Amber +import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.models.Account @@ -190,6 +195,17 @@ fun ApplicationsScreen( }, verticalAlignment = Alignment.CenterVertically, ) { + val appIcon = applicationWithHistory.icon + if (appIcon.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) { + AsyncImage( + model = appIcon, + contentDescription = null, + modifier = Modifier + .padding(end = 12.dp) + .size(40.dp) + .clip(CircleShape), + ) + } Column( verticalArrangement = Arrangement.Center, ) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt index 0ceb4b9d..a0e33547 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt @@ -13,9 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -43,7 +41,6 @@ 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource @@ -55,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences @@ -209,22 +205,7 @@ fun BunkerConnectRequestScreen( .padding(horizontal = horizontalPadding), ) { val clientImage = bunkerRequest.clientMetadata?.image.orEmpty() - if (clientImage.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = clientImage, - contentDescription = null, - modifier = Modifier - .size(64.dp) - .clip(CircleShape), - ) - } - } + RemoteAppIcon(imageUrl = clientImage, size = 64.dp) Text( modifier = Modifier diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt index 29f8acee..bd048a7c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt @@ -24,12 +24,15 @@ fun BunkerGetPubKeyScreen( applicationName: String, onAccept: (List?, Int, Boolean?, RememberType) -> Unit, onReject: (RememberType) -> Unit, + iconUrl: String = "", ) { var rememberType by remember { mutableStateOf(RememberType.NEVER) } Column( modifier, ) { + RemoteAppIcon(iconUrl) + Text( modifier = Modifier.fillMaxWidth(), text = applicationName, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt index 3e6a6f26..ee2997bd 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt @@ -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) + 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, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt index 998571bc..bc5240e1 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt @@ -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) + val message = stringResource(R.string.ping) Text( diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt index 7a8e8cf4..9aa0762c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt @@ -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) + Text( buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt index 421aee03..baff6170 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt @@ -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 { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt index 87df8cf8..2cf870c9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt @@ -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) + Text( buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt index cc887e07..c227fc1b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt @@ -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) val permission = Permission("sign_event", event.kind) val kindTranslation = permission.toLocalizedString(context) val unknownKindString = stringResource(R.string.event_kind, event.kind.toString()) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt index 12812e7f..6edc78ac 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt @@ -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) Text( buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt new file mode 100644 index 00000000..e20793b0 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt @@ -0,0 +1,45 @@ +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.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.greenart7c3.nostrsigner.BuildFlavorChecker + +/** + * Centered avatar for a NIP-46 remote client, loaded from the `image` field of + * its client metadata (nostr-protocol/nips#2381) or the persisted application + * icon. Renders nothing when the URL is blank or on the offline flavor, which + * has no network image loading. + */ +@Composable +fun RemoteAppIcon( + imageUrl: String?, + modifier: Modifier = Modifier, + size: Dp = 48.dp, +) { + if (imageUrl.isNullOrBlank() || BuildFlavorChecker.isOfflineFlavor()) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .size(size) + .clip(CircleShape), + ) + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt index 57ed1556..82f5a15b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -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) + val message = stringResource(R.string.sign_psbt) Text( From d6b62b8dc2f5c43db8b464cce114bd07f45c5093 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 16:47:48 +0000 Subject: [PATCH 4/8] Persist client metadata on every connect, not just new apps The bunker:// application is created when its URI is generated, so on connect savedApplication is non-null and the new-entity branch that set url/icon from client metadata never ran. Update name/url/icon in place during the connect handling so the metadata is actually saved. --- .../nostrsigner/service/BunkerRequestUtils.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt index 151b023f..ebd30f9b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -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) { From 8e4de955abeb5a0c45bb10ce3d4e9339893071a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 16:58:18 +0000 Subject: [PATCH 5/8] Show native app icons and a placeholder for icon-less apps Add a unified AppAvatar composable that resolves an icon in priority order: remote client URL, native Android launcher icon (via PackageManager using the packageName stored as the app key), then a letter placeholder. - Use it on the main applications list so native apps show their launcher icon and apps without any icon get a placeholder - Route RemoteAppIcon (bunker request screens) and LocalAppIcon (intent request screens) through it, so every request screen shows an icon or a placeholder --- .../nostrsigner/ui/ApplicationsScreen.kt | 24 ++---- .../nostrsigner/ui/components/AppAvatar.kt | 79 +++++++++++++++++++ .../components/BunkerConnectRequestScreen.kt | 2 +- .../ui/components/BunkerGetPubKeyScreen.kt | 2 +- .../components/BunkerMultiEventHomeScreen.kt | 2 +- .../ui/components/BunkerPingScreen.kt | 2 +- .../ui/components/BunkerRelayAuthScreen.kt | 2 +- .../ui/components/EncryptDecryptData.kt | 2 +- .../nostrsigner/ui/components/EventData.kt | 2 +- .../nostrsigner/ui/components/LocalAppIcon.kt | 20 +---- .../ui/components/Nip44v3ApprovalData.kt | 2 +- .../ui/components/RemoteAppIcon.kt | 24 ++---- .../nostrsigner/ui/components/SignPsbt.kt | 2 +- 13 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt index 6990547c..58ba8981 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt @@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,7 +24,6 @@ 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.res.stringResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -43,9 +40,7 @@ import androidx.navigation.NavController import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.compose.collectAsLazyPagingItems -import coil3.compose.AsyncImage import com.greenart7c3.nostrsigner.Amber -import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.models.Account @@ -54,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 @@ -195,17 +191,13 @@ fun ApplicationsScreen( }, verticalAlignment = Alignment.CenterVertically, ) { - val appIcon = applicationWithHistory.icon - if (appIcon.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) { - AsyncImage( - model = appIcon, - contentDescription = null, - modifier = Modifier - .padding(end = 12.dp) - .size(40.dp) - .clip(CircleShape), - ) - } + 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, ) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt new file mode 100644 index 00000000..5a245037 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt @@ -0,0 +1,79 @@ +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 + +/** + * 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 systemIcon = packageName + ?.takeIf { it.isNotBlank() } + ?.let { rememberAppDisplayInfo(it).icon } + + val avatarModifier = modifier + .size(size) + .clip(CircleShape) + + when { + !iconUrl.isNullOrBlank() && !BuildFlavorChecker.isOfflineFlavor() -> { + AsyncImage( + model = iconUrl, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt index a0e33547..d302b080 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerConnectRequestScreen.kt @@ -205,7 +205,7 @@ fun BunkerConnectRequestScreen( .padding(horizontal = horizontalPadding), ) { val clientImage = bunkerRequest.clientMetadata?.image.orEmpty() - RemoteAppIcon(imageUrl = clientImage, size = 64.dp) + RemoteAppIcon(imageUrl = clientImage, name = appName.value, size = 64.dp) Text( modifier = Modifier diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt index bd048a7c..7fb3c26e 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerGetPubKeyScreen.kt @@ -31,7 +31,7 @@ fun BunkerGetPubKeyScreen( Column( modifier, ) { - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, applicationName) Text( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt index ee2997bd..67aab33e 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt @@ -116,7 +116,7 @@ fun BunkerMultiEventHomeScreen( Column( modifier, ) { - RemoteAppIcon(appIcon) + RemoteAppIcon(appIcon, appName) Text( stringResource(R.string.is_requiring_some_permissions_please_review_them, appName), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt index bc5240e1..202d219b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerPingScreen.kt @@ -40,7 +40,7 @@ fun BunkerPingScreen( Column( modifier, ) { - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, appName) val message = stringResource(R.string.ping) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt index 9aa0762c..25b31fa2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerRelayAuthScreen.kt @@ -68,7 +68,7 @@ fun BunkerRelayAuthScreen( ) { Spacer(Modifier.size(16.dp)) - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, appName) Text( buildAnnotatedString { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt index 2cf870c9..f95f0efa 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt @@ -271,7 +271,7 @@ fun BunkerEncryptDecryptData( Column( modifier, ) { - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, appName) Text( buildAnnotatedString { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt index c227fc1b..2dd8f0fb 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EventData.kt @@ -144,7 +144,7 @@ fun BunkerEventData( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.size(16.dp)) - RemoteAppIcon(iconUrl) + 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()) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LocalAppIcon.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LocalAppIcon.kt index da4b74d8..d6784bc4 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LocalAppIcon.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LocalAppIcon.kt @@ -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, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt index 6edc78ac..144b8a5d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt @@ -66,7 +66,7 @@ fun Nip44v3ApprovalData( Column(modifier) { if (isBunker) { - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, appName) Text( buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt index e20793b0..3ebc3038 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt @@ -3,43 +3,33 @@ 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.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape 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.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.greenart7c3.nostrsigner.BuildFlavorChecker /** - * Centered avatar for a NIP-46 remote client, loaded from the `image` field of - * its client metadata (nostr-protocol/nips#2381) or the persisted application - * icon. Renders nothing when the URL is blank or on the offline flavor, which - * has no network image loading. + * 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, ) { - if (imageUrl.isNullOrBlank() || BuildFlavorChecker.isOfflineFlavor()) return - Column( modifier = modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - modifier = Modifier - .size(size) - .clip(CircleShape), + AppAvatar( + iconUrl = imageUrl, + name = name, + size = size, ) } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt index 82f5a15b..5c086159 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -90,7 +90,7 @@ fun BunkerSignPsbt( var rememberType by remember { mutableStateOf(RememberType.NEVER) } Column(modifier) { - RemoteAppIcon(iconUrl) + RemoteAppIcon(iconUrl, appName) val message = stringResource(R.string.sign_psbt) From e9aafe8f0744872a91416b071e309268be95f1c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 17:43:56 +0000 Subject: [PATCH 6/8] Persist native app icon + name on request, drop in-memory cache Android 11+ package visibility means an installed app's launcher icon is only resolvable while it is interacting with us. When a native app sends a request (intent or ContentProvider), capture its icon to a file and its label, and edit the saved app row with them. The applications list then shows real native icons without QUERY_ALL_PACKAGES. - AppIconStore writes the launcher icon PNG to internal storage - IntentUtils.persistNativeAppMetadata edits the saved app (run-once, fills only blank name/icon), called from intent ingestion and the provider branches (off the signing thread) - ApplicationDao.updateNameAndIcon targeted update - AppAvatar loads local file-path icons on all flavors, remote URLs only when online --- .../greenart7c3/nostrsigner/MainViewModel.kt | 2 + .../greenart7c3/nostrsigner/SignerProvider.kt | 18 +++++++ .../nostrsigner/database/ApplicationDao.kt | 4 ++ .../database/CachingApplicationDao.kt | 5 ++ .../nostrsigner/service/AppIconStore.kt | 47 +++++++++++++++++++ .../nostrsigner/service/IntentUtils.kt | 35 ++++++++++++++ .../ui/components/AmberTopAppBar.kt | 12 +++++ .../nostrsigner/ui/components/AppAvatar.kt | 23 ++++++--- 8 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/AppIconStore.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt index 93c6e87c..b02e10b7 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt @@ -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 -> diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index 52bacfa2..abd855a6 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -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 = diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt index 922dc21a..30d9c04b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt @@ -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) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt index e6de66cc..34b3c790 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt @@ -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 } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/AppIconStore.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/AppIconStore.kt new file mode 100644 index 00000000..0a3a66d8 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AppIconStore.kt @@ -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 + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 14c29baf..62b8f1ee 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -68,6 +68,41 @@ object IntentUtils { private val _intents = MutableStateFlow>(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?, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AmberTopAppBar.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AmberTopAppBar.kt index b5555e5f..379d84c9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AmberTopAppBar.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AmberTopAppBar.kt @@ -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, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt index 5a245037..4bbf4f7c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt @@ -20,6 +20,7 @@ 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: @@ -36,18 +37,28 @@ fun AppAvatar( name: String = "", size: Dp = 48.dp, ) { - val systemIcon = packageName - ?.takeIf { it.isNotBlank() } - ?.let { rememberAppDisplayInfo(it).icon } - 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 { - !iconUrl.isNullOrBlank() && !BuildFlavorChecker.isOfflineFlavor() -> { + model != null -> { AsyncImage( - model = iconUrl, + model = model, contentDescription = name.ifBlank { null }, modifier = avatarModifier, ) From f02ee8d69f38c8194469f376ef68352d38118017 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 17:52:16 +0000 Subject: [PATCH 7/8] Capture native app icon + name at connect/accept time Call persistNativeAppMetadata right after the saved app is inserted in sendResult, so a first-ever connection populates its launcher icon and label immediately rather than waiting for a later request. --- .../java/com/greenart7c3/nostrsigner/service/IntentUtils.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 62b8f1ee..cf69acfc 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -895,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( From 99618d61bf12b4b8b0aa8ce680547ca534c56dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 17:56:21 +0000 Subject: [PATCH 8/8] Warm up Coil ImageLoader off the main thread The singleton ImageLoader factory reads cacheDir, so letting the first AsyncImage build it lazily during composition tripped StrictMode's DiskReadViolation on the UI thread. Pre-build it on the IO scope at startup. --- app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index 3f9c0e2c..533a4fa1 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -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() {