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