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() { 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/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..df8e6a75 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadata.kt @@ -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(), + ) + } +} 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/BunkerRequestUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt index 6a6e4b0c..ebd30f9b 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, @@ -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) { 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..a8dfefc5 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,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) { 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..cf69acfc 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?, @@ -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( 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/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt index 9545ced4..58ba8981 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsScreen.kt @@ -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, ) { 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 new file mode 100644 index 00000000..4bbf4f7c --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/AppAvatar.kt @@ -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, + ) + } + } + } +} 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..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 @@ -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( 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..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 @@ -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, applicationName) + 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..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 @@ -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, 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..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 @@ -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( 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..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 @@ -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)) { 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..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 @@ -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)) { 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..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 @@ -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()) 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 12812e7f..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 @@ -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)) { 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..3ebc3038 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/RemoteAppIcon.kt @@ -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, + ) + } +} 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..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 @@ -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( 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..e02cb839 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/models/BunkerClientMetadataTest.kt @@ -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")) + } +}