feat(wallet): NFT thumbnails and metadata display in Assets tab

- Add NFT metadata fetching via Koios asset_info endpoint
- Parse CIP-25 onchain_metadata for image, name, description
- Convert IPFS URLs to ipfs.io gateway URLs
- Display 64dp thumbnails with 8dp rounded corners using Coil AsyncImage
- Add bottom sheet detail view for NFT expansion (larger image + metadata)
- Graceful fallback with placeholder icons on image load failure
- Load metadata in presenter, cache results for 30 minutes
- Parallel metadata fetching for better performance
This commit is contained in:
Kayos 2026-03-29 15:21:53 -07:00
parent a57fd79098
commit 2d8df4f23f
8 changed files with 572 additions and 11 deletions

View file

@ -13,6 +13,7 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.NativeAsset
import io.element.android.features.wallet.api.NftMetadata
import io.element.android.features.wallet.api.ProtocolParameters
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.api.TxSummary
@ -73,6 +74,9 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
private data class CachedHandle(val address: String?, val timestamp: Long)
private val handleCache = mutableMapOf<String, CachedHandle>()
// NFT metadata cache
private val nftMetadataCache = mutableMapOf<String, NftMetadata?>()
override suspend fun getBalance(address: String): Result<Long> =
withRetry("getBalance($address)") {
withContext(Dispatchers.IO) {
@ -306,12 +310,14 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val assets = assetMap.map { (key, quantity) ->
val policyId = key.take(56)
val assetNameHex = key.drop(56)
// Mark as potential NFT if quantity is 1
NativeAsset(
policyId = policyId,
assetName = assetNameHex,
quantity = quantity,
displayName = null,
fingerprint = null,
isNft = quantity == 1L,
)
}
@ -414,6 +420,152 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
override suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?> =
withRetry("getNftMetadata($policyId, $assetName)") {
withContext(Dispatchers.IO) {
val cacheKey = "$policyId$assetName"
// Check cache first
if (nftMetadataCache.containsKey(cacheKey)) {
return@withContext Result.success(nftMetadataCache[cacheKey])
}
throttleRequest()
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_info"
val body = JSONObject().apply {
put("_asset_list", JSONArray().put(JSONArray().apply {
put(policyId)
put(assetName)
}))
}.toString()
Timber.tag(TAG).d("getNftMetadata calling: $url with policy=$policyId, asset=$assetName")
val request = Request.Builder()
.url(url)
.post(body.toRequestBody(JSON_MEDIA_TYPE))
.header("Accept", "application/json")
.build()
val response = httpClient.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
Timber.tag(TAG).d("getNftMetadata response: code=${response.code}, body=${responseBody.take(1000)}")
if (!response.isSuccessful) {
return@withContext Result.failure(parseHttpError(response.code, responseBody))
}
val jsonArray = JSONArray(responseBody)
if (jsonArray.length() == 0) {
nftMetadataCache[cacheKey] = null
return@withContext Result.success(null)
}
val assetInfo = jsonArray.getJSONObject(0)
// Parse CIP-25 onchain_metadata
val metadata = try {
parseCip25Metadata(assetInfo)
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Failed to parse CIP-25 metadata")
null
}
nftMetadataCache[cacheKey] = metadata
Result.success(metadata)
}
}
/**
* Parse CIP-25 metadata from Koios asset_info response.
*/
private fun parseCip25Metadata(assetInfo: JSONObject): NftMetadata? {
// Check for onchain_metadata (CIP-25)
val onchainMetadata = assetInfo.optJSONObject("onchain_metadata") ?: return null
// Get asset name for lookup (decoded)
val assetNameHex = assetInfo.optString("asset_name", "")
val assetNameDecoded = assetInfo.optString("asset_name_ascii", "")
// Extract name - could be in various places
val name = onchainMetadata.optString("name")
.takeIf { it.isNotEmpty() }
?: assetNameDecoded.takeIf { it.isNotEmpty() }
?: assetNameHex
// Extract image - handle both string and array formats
val imageUrl = extractImageUrl(onchainMetadata)
// Extract description
val description = onchainMetadata.optString("description")
.takeIf { it.isNotEmpty() }
// Build raw metadata map
val rawMetadata = mutableMapOf<String, Any>()
onchainMetadata.keys().forEach { key ->
val value = onchainMetadata.get(key)
if (value != null && value != JSONObject.NULL) {
rawMetadata[key] = convertJsonValue(value)
}
}
return NftMetadata(
name = name,
image = imageUrl,
description = description,
rawMetadata = rawMetadata,
)
}
/**
* Extract image URL from CIP-25 metadata, handling various formats.
*/
private fun extractImageUrl(metadata: JSONObject): String? {
return try {
when (val imageValue = metadata.opt("image")) {
is String -> NftMetadata.resolveImageUrl(imageValue)
is JSONArray -> {
// Some NFTs split the URL across multiple array elements
val parts = (0 until imageValue.length()).mapNotNull {
imageValue.optString(it).takeIf { s -> s.isNotEmpty() }
}
NftMetadata.joinImageParts(parts)
}
else -> null
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Failed to extract image URL")
null
}
}
/**
* Convert JSON value to Kotlin type for raw metadata map.
*/
private fun convertJsonValue(value: Any): Any {
return when (value) {
is JSONObject -> {
val map = mutableMapOf<String, Any>()
value.keys().forEach { key ->
val v = value.get(key)
if (v != null && v != JSONObject.NULL) {
map[key] = convertJsonValue(v)
}
}
map
}
is JSONArray -> {
(0 until value.length()).mapNotNull { i ->
val v = value.opt(i)
if (v != null && v != JSONObject.NULL) convertJsonValue(v) else null
}
}
else -> value
}
}
private suspend fun <T> withRetry(
operation: String,
block: suspend () -> Result<T>,

View file

@ -18,7 +18,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.NativeAsset
import io.element.android.features.wallet.api.NftMetadata
import io.element.android.features.wallet.api.TxSummary
import io.element.android.features.wallet.api.backup.WalletBackupService
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
@ -24,6 +25,9 @@ import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ -83,9 +87,11 @@ class WalletPanelPresenter @Inject constructor(
walletManager.refreshBalance(matrixClient.sessionId, balance)
}
// Fetch assets
// Fetch assets and enrich with NFT metadata
cardanoClient.getAddressAssets(address)
.onSuccess { assets = it }
.onSuccess { fetchedAssets ->
assets = enrichAssetsWithMetadata(fetchedAssets)
}
.onFailure { Timber.w(it, "Failed to fetch assets") }
// Fetch transactions
@ -280,4 +286,59 @@ class WalletPanelPresenter @Inject constructor(
eventSink = ::handleEvent,
)
}
/**
* Enrich assets with NFT metadata from Koios.
* Fetches CIP-25 metadata for potential NFTs (quantity == 1) in parallel.
*/
private suspend fun enrichAssetsWithMetadata(assets: List<NativeAsset>): List<NativeAsset> {
if (assets.isEmpty()) return assets
// Identify potential NFTs (quantity == 1 or marked as NFT)
val potentialNfts = assets.filter { it.quantity == 1L || it.isNft }
if (potentialNfts.isEmpty()) return assets
Timber.d("Enriching ${potentialNfts.size} potential NFTs with metadata")
// Fetch metadata in parallel (max 10 concurrent to avoid rate limiting)
val metadataMap = mutableMapOf<String, NftMetadata>()
try {
coroutineScope {
potentialNfts.chunked(10).forEach { chunk ->
chunk.map { asset ->
async {
cardanoClient.getNftMetadata(asset.policyId, asset.assetName)
.onSuccess { metadata ->
if (metadata != null) {
metadataMap[asset.unit] = metadata
}
}
.onFailure { e ->
Timber.w(e, "Failed to fetch metadata for ${asset.unit}")
}
}
}.awaitAll()
}
}
} catch (e: Exception) {
Timber.w(e, "Error during metadata enrichment, continuing without full metadata")
}
Timber.d("Successfully fetched metadata for ${metadataMap.size} NFTs")
// Apply metadata to assets
return assets.map { asset ->
val metadata = metadataMap[asset.unit]
if (metadata != null) {
asset.copy(
displayName = metadata.name.takeIf { it.isNotEmpty() } ?: asset.displayName,
imageUrl = metadata.image,
description = metadata.description,
isNft = metadata.image != null,
)
} else {
asset
}
}
}
}

View file

@ -6,25 +6,44 @@
package io.element.android.features.wallet.impl.panel.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.wallet.api.NativeAsset
import io.element.android.features.wallet.impl.R
@ -38,6 +57,8 @@ fun AssetsTabView(
isLoading: Boolean,
modifier: Modifier = Modifier,
) {
var selectedNft by remember { mutableStateOf<NativeAsset?>(null) }
Box(modifier = modifier) {
when {
isLoading -> {
@ -73,29 +94,77 @@ fun AssetsTabView(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(assets) { asset ->
AssetCard(asset = asset)
AssetCard(
asset = asset,
onClick = { if (asset.isNft || asset.imageUrl != null) selectedNft = asset },
)
}
}
}
}
}
// NFT Detail Bottom Sheet
selectedNft?.let { nft ->
NftDetailBottomSheet(
asset = nft,
onDismiss = { selectedNft = null },
)
}
}
@Composable
private fun AssetCard(
asset: NativeAsset,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasImage = asset.imageUrl != null
Card(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.then(
if (hasImage) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
}
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// NFT Thumbnail (64dp square, 8dp rounded corners)
if (hasImage) {
NftThumbnail(
imageUrl = asset.imageUrl!!,
contentDescription = asset.name,
modifier = Modifier.size(64.dp),
)
} else {
// Placeholder for non-NFT tokens
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
// Asset info
Column(
modifier = Modifier.weight(1f),
) {
@ -104,21 +173,208 @@ private fun AssetCard(
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = asset.truncatedPolicyId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (asset.isNft) {
Text(
text = "NFT",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
// Quantity
Text(
text = asset.quantity.toString(),
text = asset.formatQuantity(),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
@Composable
private fun NftThumbnail(
imageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = imageUrl,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
onState = { state ->
isLoading = state is AsyncImagePainter.State.Loading
isError = state is AsyncImagePainter.State.Error
},
)
// Loading indicator
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
// Error placeholder
if (isError) {
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NftDetailBottomSheet(
asset: NativeAsset,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Large NFT image
asset.imageUrl?.let { url ->
NftDetailImage(
imageUrl = url,
contentDescription = asset.name,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(bottom = 16.dp),
)
}
// NFT Name
Text(
text = asset.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp),
)
// Policy ID
Text(
text = asset.truncatedPolicyId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
// Description if available
asset.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
// Quantity badge
Row(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Quantity: ${asset.formatQuantity()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
@Composable
private fun NftDetailImage(
imageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = imageUrl,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
onState = { state ->
isLoading = state is AsyncImagePainter.State.Loading
isError = state is AsyncImagePainter.State.Error
},
)
// Loading indicator
if (isLoading) {
CircularProgressIndicator()
}
// Error placeholder
if (isError) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Failed to load image",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AssetsTabViewPreview() = ElementPreview {
@ -133,10 +389,13 @@ internal fun AssetsTabViewPreview() = ElementPreview {
),
NativeAsset(
policyId = "11223344556677889900aabbccdd11223344556677889900aabbccdd",
assetName = "",
quantity = 5,
displayName = null,
assetName = "436f6f6c4e4654",
quantity = 1,
displayName = "CoolNFT",
fingerprint = null,
imageUrl = "https://ipfs.io/ipfs/QmTest123",
isNft = true,
description = "A really cool NFT from the Cardano blockchain",
),
),
isLoading = false,