From d9a04a377372981091f8ff946433c0cdc2e75905 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 12 Jul 2026 19:32:25 -0300 Subject: [PATCH] Fix query API in purplequartz --- purplequartz/README.md | 8 +- purplequartz/SPEC.md | 16 +- .../QuartzCompatibilityInstrumentedTest.kt | 2 +- .../dev/zapstore/purplequartz/PurpleQuartz.kt | 6 +- .../purplequartz/QueryRefreshCache.kt | 6 +- .../dev/zapstore/purplequartz/QueryTypes.kt | 6 +- .../purplequartz/PublicContractsTest.kt | 6 +- .../purplequartz/QueryInvariantTest.kt | 8 +- src/main/kotlin/dev/zapstore/app/Catalog.kt | 48 ++++-- .../kotlin/dev/zapstore/app/Components.kt | 6 + .../kotlin/dev/zapstore/app/MainActivity.kt | 3 + .../dev/zapstore/app/ProfileComponent.kt | 163 ++++++++++++++++++ src/main/kotlin/dev/zapstore/app/Screens.kt | 31 +++- .../kotlin/dev/zapstore/app/ViewModels.kt | 11 +- src/main/res/values/strings.xml | 1 + .../kotlin/dev/zapstore/app/ViewModelsTest.kt | 14 +- 16 files changed, 279 insertions(+), 56 deletions(-) create mode 100644 src/main/kotlin/dev/zapstore/app/ProfileComponent.kt diff --git a/purplequartz/README.md b/purplequartz/README.md index 0dcb837..5fe3614 100644 --- a/purplequartz/README.md +++ b/purplequartz/README.md @@ -69,7 +69,7 @@ Every network query requires an explicit, nonempty relay set. The library does n ### Cached local-and-remote queries -Set `maxAge` when data may be served from the local store without immediately opening a relay request. For example, this profile query refreshes at most once every six hours: +Set `cachedFor` when data may be served from the local store without immediately opening a relay request. For example, this profile query refreshes at most once every six hours: ```kotlin purpleQuartz.query( @@ -81,19 +81,19 @@ purpleQuartz.query( source = QuerySource.LocalAndRemote( relays = relays, mode = RemoteMode.OneShot(), - maxAge = 6.hours, + cachedFor = 6.hours, ), ).collect(::renderState) ``` Freshness is recorded only after every requested relay reaches EOSE and preceding events are committed. Empty successful responses are cached too. Timeouts, failures, partial responses, cancellation, and local-only changes do not refresh the timestamp. -The cache key contains the complete filter set and exact normalized relay set; `maxAge` and remote mode are policy and are not part of the key. Only a SHA-256 fingerprint and refresh timestamp are persisted, not filter contents. Freshness survives process restarts and is reset when the Quartz database file is deleted or replaced. Out-of-band in-place mutation of the owned Quartz database is unsupported. +The cache key contains the complete filter set and exact normalized relay set; `cachedFor` and remote mode are policy and are not part of the key. Only a SHA-256 fingerprint and refresh timestamp are persisted, not filter contents. Freshness survives process restarts and is reset when the Quartz database file is deleted or replaced. Out-of-band in-place mutation of the owned Quartz database is unsupported. - Fresh `OneShot` queries emit the local projection as `Complete` and open no request. - Fresh `Stream` queries report `Cached`, keep observing local commits, and defer their relay subscription until the max-age window expires. A successful concurrent refresh extends that delay. - `Remote` always opens its requested subscription and bypasses this local cache policy. -- Omitting `maxAge` preserves the existing always-refresh behavior. +- Omitting `cachedFor` preserves the existing always-refresh behavior. ## State and failure handling diff --git a/purplequartz/SPEC.md b/purplequartz/SPEC.md index 1337f69..d77ce38 100644 --- a/purplequartz/SPEC.md +++ b/purplequartz/SPEC.md @@ -1,4 +1,4 @@ -# purplequartz — v1 Specification +# purplequartz âÿÿ v1 Specification **Status:** Implementation-ready **Created:** 2026-07-11 @@ -142,7 +142,7 @@ Kinds `20000..29999` have no persistent local projection: ### 3.6 Query freshness is synchronization metadata -`LocalAndRemote.maxAge` controls when a local projection may suppress or defer a relay request. Freshness is not inferred from event `createdAt`: that timestamp is author-controlled and cannot represent when an empty or nonempty query last synchronized. +`LocalAndRemote.cachedFor` controls when a local projection may suppress or defer a relay request. Freshness is not inferred from event `createdAt`: that timestamp is author-controlled and cannot represent when an empty or nonempty query last synchronized. Freshness metadata contains only a versioned SHA-256 query fingerprint and a successful-refresh timestamp. Quartz remains the sole persistent event source of truth. Deleting or replacing the Quartz database file changes its filesystem identity and rotates the metadata namespace so an old marker cannot suppress synchronization against a new store. Out-of-band in-place mutation of the façade-owned database file is unsupported. @@ -205,11 +205,11 @@ sealed interface QuerySource { data class LocalAndRemote( val relays: Set, val mode: RemoteMode = RemoteMode.Stream, - val maxAge: Duration? = null, + val cachedFor: Duration? = null, ) : QuerySource { init { require(relays.isNotEmpty()) - require(maxAge == null || (maxAge.isFinite() && maxAge.isPositive())) + require(cachedFor == null || (cachedFor.isFinite() && cachedFor.isPositive())) } } @@ -232,7 +232,7 @@ sealed interface RemoteMode { } ``` -`LocalAndRemote` and `Remote` reject an empty relay set. `OneShot.timeout == null` uses `PurpleQuartzConfig.oneShotTimeout`; an explicit timeout must be finite and greater than zero. A non-null `maxAge` must be finite and greater than zero; null preserves always-refresh behavior. +`LocalAndRemote` and `Remote` reject an empty relay set. `OneShot.timeout == null` uses `PurpleQuartzConfig.oneShotTimeout`; an explicit timeout must be finite and greater than zero. A non-null `cachedFor` must be finite and greater than zero; null preserves always-refresh behavior. At collection start, the implementation snapshots the relay set and deep-copies each filter's lists and tag maps. That immutable snapshot is used for both store queries and the Quartz subscription, so caller mutation after collection starts cannot change an active query. @@ -266,7 +266,7 @@ At collection start, the implementation snapshots the relay set and deep-copies - The cache identity is a canonical, versioned fingerprint of every snapshotted filter field and the exact normalized relay set. - Filter and relay ordering are canonicalized where query semantics are order-independent. Null and empty fields remain distinct. -- `maxAge`, `RemoteMode`, timeout, subscription ID, and connection generation are excluded because they do not change query coverage. +- `cachedFor`, `RemoteMode`, timeout, subscription ID, and connection generation are excluded because they do not change query coverage. - Filter values are hashed before persistence; search text, tag values, authors, IDs, and relay URLs are not stored in plaintext metadata. - A marker advances only when all current relay generations reach EOSE after every preceding accepted EVENT reaches a terminal verification/persistence outcome. One-shot local-and-remote queries also complete their final local projection first. - Empty successful responses establish freshness. Timeout, failure, partial EOSE, cancellation, local mutation, and clock rollback do not. @@ -626,7 +626,7 @@ Amethyst's outbox implementation combines application caches, relay hints, defau ### Query freshness -- [ ] Null `maxAge` preserves always-refresh local-and-remote behavior. +- [ ] Null `cachedFor` preserves always-refresh local-and-remote behavior. - [ ] A successful all-relay EOSE caches empty and nonempty local-and-remote results. - [ ] A fresh one-shot emits `Complete` without subscribing. - [ ] A fresh stream emits `Cached`, keeps observing the store, and subscribes after expiry. @@ -716,7 +716,7 @@ Expected implementation artifacts: Version 1 is complete only when: -1. requirements R1–R10 are implemented; +1. requirements R1âÿÿR10 are implemented; 2. every verification checkbox passes; 3. only `QuerySource.Remote` exposes a relay-to-consumer event path; 4. Android tests and lint pass; diff --git a/purplequartz/purplequartz/src/androidTest/kotlin/dev/zapstore/purplequartz/QuartzCompatibilityInstrumentedTest.kt b/purplequartz/purplequartz/src/androidTest/kotlin/dev/zapstore/purplequartz/QuartzCompatibilityInstrumentedTest.kt index 39e0098..b49500e 100644 --- a/purplequartz/purplequartz/src/androidTest/kotlin/dev/zapstore/purplequartz/QuartzCompatibilityInstrumentedTest.kt +++ b/purplequartz/purplequartz/src/androidTest/kotlin/dev/zapstore/purplequartz/QuartzCompatibilityInstrumentedTest.kt @@ -119,7 +119,7 @@ class QuartzCompatibilityInstrumentedTest { QuerySync.Connecting, purpleQuartz.query( Filter(kinds = listOf(1)), - QuerySource.LocalAndRemote(setOf(relay), maxAge = 6.hours), + QuerySource.LocalAndRemote(setOf(relay), cachedFor = 6.hours), ).take(1).toList().single().sync, ) assertEquals( diff --git a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/PurpleQuartz.kt b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/PurpleQuartz.kt index 5503fe0..c17691d 100644 --- a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/PurpleQuartz.kt +++ b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/PurpleQuartz.kt @@ -225,7 +225,7 @@ class PurpleQuartz private constructor( is QuerySource.Remote -> source.mode QuerySource.Local -> null } - private val maxAge = (source as? QuerySource.LocalAndRemote)?.maxAge + private val cachedFor = (source as? QuerySource.LocalAndRemote)?.cachedFor private val queryFingerprint = if (source is QuerySource.LocalAndRemote) { runCatching { QueryFingerprint.create(filters, relays) }.getOrNull() } else { @@ -300,7 +300,7 @@ class PurpleQuartz private constructor( private fun startLocalAndRemote() { observerJob = startObserver( initialSync = { - val fresh = maxAge?.let(::freshness)?.isFresh == true + val fresh = cachedFor?.let(::freshness)?.isFresh == true remoteDeferred.set(fresh) if (!fresh) prepareRemote() when { @@ -320,7 +320,7 @@ class PurpleQuartz private constructor( } private fun scheduleRemoteAfterCache() { - val cacheDuration = maxAge ?: return + val cacheDuration = cachedFor ?: return remoteDelayJob = scope.launch { while (!stopped.get()) { val status = freshness(cacheDuration) diff --git a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryRefreshCache.kt b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryRefreshCache.kt index 5211cb6..0cebfe2 100644 --- a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryRefreshCache.kt +++ b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryRefreshCache.kt @@ -26,7 +26,7 @@ internal data class QueryFreshness( internal fun QueryRefreshCache.freshness( fingerprint: String, - maxAge: Duration, + cachedFor: Duration, now: Long, ): QueryFreshness { val refreshedAt = lastRefresh(fingerprint) @@ -35,9 +35,9 @@ internal fun QueryRefreshCache.freshness( return QueryFreshness(isFresh = false, remainingMillis = 0) } - val maxAgeMillis = maxOf(1, maxAge.inWholeMilliseconds) + val cachedForMillis = maxOf(1, cachedFor.inWholeMilliseconds) val age = now - refreshedAt - val remaining = maxAgeMillis - age + val remaining = cachedForMillis - age return QueryFreshness( isFresh = remaining > 0, remainingMillis = remaining.coerceAtLeast(0), diff --git a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryTypes.kt b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryTypes.kt index 32cbdd8..e2aaaaf 100644 --- a/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryTypes.kt +++ b/purplequartz/purplequartz/src/main/kotlin/dev/zapstore/purplequartz/QueryTypes.kt @@ -10,12 +10,12 @@ sealed interface QuerySource { data class LocalAndRemote( val relays: Set, val mode: RemoteMode = RemoteMode.Stream, - val maxAge: Duration? = null, + val cachedFor: Duration? = null, ) : QuerySource { init { require(relays.isNotEmpty()) { "At least one relay is required" } - require(maxAge == null || (maxAge.isFinite() && maxAge.isPositive())) { - "Cache max age must be finite and positive" + require(cachedFor == null || (cachedFor.isFinite() && cachedFor.isPositive())) { + "Cache duration must be finite and positive" } } } diff --git a/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/PublicContractsTest.kt b/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/PublicContractsTest.kt index 3b18e1f..44fc220 100644 --- a/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/PublicContractsTest.kt +++ b/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/PublicContractsTest.kt @@ -34,10 +34,10 @@ class PublicContractsTest { assertFails { RemoteMode.OneShot(0.milliseconds) } val relay = "wss://relay.example".normalizeRelayUrl() - assertFails { QuerySource.LocalAndRemote(setOf(relay), maxAge = 0.milliseconds) } - assertFails { QuerySource.LocalAndRemote(setOf(relay), maxAge = Duration.INFINITE) } + assertFails { QuerySource.LocalAndRemote(setOf(relay), cachedFor = 0.milliseconds) } + assertFails { QuerySource.LocalAndRemote(setOf(relay), cachedFor = Duration.INFINITE) } assertTrue(QuerySource.Remote(setOf(relay)).relays.contains(relay)) - assertTrue(QuerySource.LocalAndRemote(setOf(relay), maxAge = 6.hours).relays.contains(relay)) + assertTrue(QuerySource.LocalAndRemote(setOf(relay), cachedFor = 6.hours).relays.contains(relay)) } @Test diff --git a/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/QueryInvariantTest.kt b/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/QueryInvariantTest.kt index 4ce631f..9b21181 100644 --- a/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/QueryInvariantTest.kt +++ b/purplequartz/purplequartz/src/test/kotlin/dev/zapstore/purplequartz/QueryInvariantTest.kt @@ -42,7 +42,7 @@ class QueryInvariantTest { val source = QuerySource.LocalAndRemote( relays = setOf(relay), mode = RemoteMode.OneShot(5.seconds), - maxAge = 6.hours, + cachedFor = 6.hours, ) val firstClient = ControlledClient() val first = PurpleQuartz.createForTesting( @@ -97,7 +97,7 @@ class QueryInvariantTest { val source = QuerySource.LocalAndRemote( relays = setOf(relay), mode = RemoteMode.Stream, - maxAge = 1.seconds, + cachedFor = 1.seconds, ) val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay)) cache.recordRefresh(fingerprint, System.currentTimeMillis()) @@ -136,7 +136,7 @@ class QueryInvariantTest { val source = QuerySource.LocalAndRemote( relays = setOf(relay), mode = RemoteMode.OneShot(50.milliseconds), - maxAge = 1.hours, + cachedFor = 1.hours, ) val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay)) @@ -186,7 +186,7 @@ class QueryInvariantTest { val source = QuerySource.LocalAndRemote( relays = setOf(relay), mode = RemoteMode.OneShot(5.seconds), - maxAge = 1.hours, + cachedFor = 1.hours, ) val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay)) val purpleQuartz = PurpleQuartz.createForTesting( diff --git a/src/main/kotlin/dev/zapstore/app/Catalog.kt b/src/main/kotlin/dev/zapstore/app/Catalog.kt index 7379746..e175152 100644 --- a/src/main/kotlin/dev/zapstore/app/Catalog.kt +++ b/src/main/kotlin/dev/zapstore/app/Catalog.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import okhttp3.OkHttpClient +import kotlin.time.Duration object Catalog { const val relay = "wss://relay.zapstore.dev" @@ -24,13 +25,21 @@ object Catalog { } interface CatalogRepository { - fun query(filter: Filter): Flow - - fun queryRemote(filter: Filter): Flow + fun query( + filter: Filter, + type: QueryType, + cachedFor: Duration? = null, + ): Flow fun refreshConnections() } +enum class QueryType { + Local, + LocalAndRemote, + Remote, +} + class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository { private val applicationContext = context.applicationContext private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -43,23 +52,30 @@ class PurpleQuartzCatalogRepository(context: Context) : CatalogRepository { scope, ).also { client = it } - override fun query(filter: Filter): Flow = - client().query( - filter = filter, - source = QuerySource.LocalAndRemote( + override fun query( + filter: Filter, + type: QueryType, + cachedFor: Duration?, + ): Flow { + require(type == QueryType.LocalAndRemote || cachedFor == null) { + "cachedFor is supported only for local-and-remote queries" + } + + val source = when (type) { + QueryType.Local -> QuerySource.Local + QueryType.LocalAndRemote -> QuerySource.LocalAndRemote( relays = setOf(Catalog.relay.normalizeRelayUrl()), mode = RemoteMode.Stream, - ), - ) - - override fun queryRemote(filter: Filter): Flow = - client().query( - filter = filter, - source = QuerySource.Remote( + cachedFor = cachedFor, + ) + QueryType.Remote -> QuerySource.Remote( relays = setOf(Catalog.relay.normalizeRelayUrl()), mode = RemoteMode.OneShot(), - ), - ) + ) + } + + return client().query(filter = filter, source = source) + } override fun refreshConnections() { client?.refreshConnections() diff --git a/src/main/kotlin/dev/zapstore/app/Components.kt b/src/main/kotlin/dev/zapstore/app/Components.kt index 37196a8..834e955 100644 --- a/src/main/kotlin/dev/zapstore/app/Components.kt +++ b/src/main/kotlin/dev/zapstore/app/Components.kt @@ -63,6 +63,7 @@ fun AppIcon( fun AppCard( app: AppInfo, onClick: () -> Unit, + repository: CatalogRepository? = null, modifier: Modifier = Modifier, release: ReleaseInfo? = null, ) { @@ -104,6 +105,11 @@ fun AppCard( ) } } + ProfileComponent( + pubkey = app.event.pubKey, + repository = repository, + modifier = Modifier.padding(top = 10.dp), + ) if (app.summary.isNotBlank()) { Spacer(Modifier.height(10.dp)) MarkdownText( diff --git a/src/main/kotlin/dev/zapstore/app/MainActivity.kt b/src/main/kotlin/dev/zapstore/app/MainActivity.kt index d158749..da0f6dd 100644 --- a/src/main/kotlin/dev/zapstore/app/MainActivity.kt +++ b/src/main/kotlin/dev/zapstore/app/MainActivity.kt @@ -76,6 +76,7 @@ class MainActivity : ComponentActivity() { val state by viewModel.uiState.collectAsStateWithLifecycle() HomeScreen( state = state, + repository = repository, onSearchQueryChanged = viewModel::onSearchQueryChanged, onSearchSubmitted = viewModel::submitSearch, onSearchCleared = viewModel::clearSearch, @@ -100,6 +101,7 @@ class MainActivity : ComponentActivity() { val state by viewModel.uiState.collectAsStateWithLifecycle() StackDetailScreen( state = state, + repository = repository, onAppClick = { identifier, author -> navController.navigate(appRoute(identifier, author)) }, @@ -132,6 +134,7 @@ class MainActivity : ComponentActivity() { } } }, + repository = repository, ) } } diff --git a/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt b/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt new file mode 100644 index 0000000..28c929a --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/ProfileComponent.kt @@ -0,0 +1,163 @@ +package dev.zapstore.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.runtime.getValue +import androidx.compose.runtime.produceState +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.flow.collect +import org.json.JSONObject +import kotlin.time.Duration.Companion.hours + +@Composable +fun ProfileComponent( + pubkey: String, + repository: CatalogRepository?, + modifier: Modifier = Modifier, +) { + val profile by produceState(initialValue = null, pubkey, repository) { + if (repository == null) return@produceState + + repository.query( + Filter(authors = listOf(pubkey), kinds = listOf(PROFILE_KIND), limit = 1), + type = QueryType.LocalAndRemote, + cachedFor = PROFILE_CACHE_DURATION, + ).collect { state -> + value = state.items.maxByOrNull(Event::createdAt)?.let(::ProfileInfo) + } + } + + val npub = pubkey.toNpub() + val displayName = profile?.displayName + ?.takeIf(String::isNotBlank) + ?: profile?.name?.takeIf(String::isNotBlank) + ?: npub + val pictureUrl = profile?.picture?.takeIf(::isHttpUrl) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(ZapSurfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text("•", color = ZapMuted, style = MaterialTheme.typography.titleLarge) + pictureUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = stringResource(R.string.profile_avatar_description, displayName), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + } + } + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private data class ProfileInfo( + val name: String?, + val displayName: String?, + val picture: String?, +) { + constructor(event: Event) : this( + name = runCatching { JSONObject(event.content).optString("name") }.getOrNull(), + displayName = runCatching { JSONObject(event.content).optString("display_name") }.getOrNull(), + picture = runCatching { + JSONObject(event.content).optString("picture").ifBlank { + JSONObject(event.content).optString("image") + } + }.getOrNull(), + ) +} + +private const val PROFILE_KIND = 0 +private val PROFILE_CACHE_DURATION = 6.hours + +private val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +private val BECH32_GENERATOR = longArrayOf( + 0x3b6a57b2L, + 0x26508e6dL, + 0x1ea119faL, + 0x3d4233ddL, + 0x2a1462b3L, +) + +private fun String.toNpub(): String { + if (length != 64 || any { it.digitToIntOrNull(16) == null }) return this + val bytes = chunked(2).map { it.toInt(16) } + val data = convertBits(bytes, 8, 5, true) + val checksum = createBech32Checksum("npub", data) + return buildString { + append("npub1") + (data + checksum).forEach { append(BECH32_CHARSET[it]) } + } +} + +private fun convertBits(data: List, fromBits: Int, toBits: Int, pad: Boolean): List { + var accumulator = 0 + var bits = 0 + val result = mutableListOf() + val maxValue = (1 shl toBits) - 1 + data.forEach { value -> + accumulator = (accumulator shl fromBits) or value + bits += fromBits + while (bits >= toBits) { + bits -= toBits + result += (accumulator shr bits) and maxValue + } + } + if (pad && bits > 0) result += (accumulator shl (toBits - bits)) and maxValue + return result +} + +private fun createBech32Checksum(hrp: String, data: List): List { + val values = expandHrp(hrp) + data + List(6) { 0 } + val polymod = bech32Polymod(values) xor 1L + return (0 until 6).map { shift -> + ((polymod shr (5 * (5 - shift))) and 31).toInt() + } +} + +private fun expandHrp(hrp: String): List = + hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } + +private fun bech32Polymod(values: List): Long { + var checksum = 1L + values.forEach { value -> + val top = checksum shr 25 + checksum = ((checksum and 0x1ffffffL) shl 5) xor value.toLong() + BECH32_GENERATOR.forEachIndexed { index, generator -> + if (((top shr index) and 1L) != 0L) checksum = checksum xor generator + } + } + return checksum +} diff --git a/src/main/kotlin/dev/zapstore/app/Screens.kt b/src/main/kotlin/dev/zapstore/app/Screens.kt index ddb52a5..8152a28 100644 --- a/src/main/kotlin/dev/zapstore/app/Screens.kt +++ b/src/main/kotlin/dev/zapstore/app/Screens.kt @@ -49,6 +49,7 @@ import java.util.Date @Composable fun HomeScreen( state: HomeUiState, + repository: CatalogRepository? = null, onSearchQueryChanged: (String) -> Unit, onSearchSubmitted: () -> Unit, onSearchCleared: () -> Unit, @@ -102,6 +103,7 @@ fun HomeScreen( AppCard( app = app, onClick = { onAppClick(app.identifier, app.event.pubKey) }, + repository = repository, modifier = Modifier.testTag("searchResult:${app.address}"), ) } @@ -149,6 +151,7 @@ fun HomeScreen( app = app, release = release, onClick = { onAppClick(app.identifier, app.event.pubKey) }, + repository = repository, modifier = Modifier.testTag("release:${release.event.id}"), ) } @@ -158,6 +161,7 @@ fun HomeScreen( @Composable fun StackDetailScreen( state: StackDetailUiState, + repository: CatalogRepository? = null, onAppClick: (identifier: String, author: String) -> Unit, modifier: Modifier = Modifier, ) { @@ -214,6 +218,7 @@ fun StackDetailScreen( AppCard( app = app, onClick = { onAppClick(app.identifier, app.event.pubKey) }, + repository = repository, modifier = Modifier.testTag("stackApp:${app.address}"), ) } @@ -236,6 +241,7 @@ fun StackDetailScreen( fun AppDetailScreen( state: AppDetailUiState, onOpenUrl: (String) -> Unit, + repository: CatalogRepository? = null, modifier: Modifier = Modifier, ) { LazyColumn( @@ -304,8 +310,9 @@ fun AppDetailScreen( } } item { - StatusText( - stringResource(R.string.published_by, app.event.pubKey.take(16)), + ProfileComponent( + pubkey = app.event.pubKey, + repository = repository, ) } @@ -382,6 +389,7 @@ fun AppDetailScreen( app = app, release = state.release, onOpenUrl = onOpenUrl, + repository = repository, modifier = Modifier.padding(top = 8.dp), ) } @@ -416,6 +424,7 @@ private fun AppInfoCard( app: AppInfo, release: ReleaseInfo?, onOpenUrl: (String) -> Unit, + repository: CatalogRepository?, modifier: Modifier = Modifier, ) { Column( @@ -435,11 +444,19 @@ private fun AppInfoCard( InfoRow(stringResource(R.string.license), it, onOpenUrl = onOpenUrl) } InfoRow(stringResource(R.string.app_id), app.identifier, onOpenUrl = onOpenUrl) - InfoRow( - stringResource(R.string.author), - "${app.event.pubKey.take(16)}…", - onOpenUrl = onOpenUrl, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) { + StatusText(stringResource(R.string.author), Modifier.weight(1f)) + ProfileComponent( + pubkey = app.event.pubKey, + repository = repository, + modifier = Modifier.weight(1f), + ) + } release?.let { InfoRow( stringResource(R.string.release_date), diff --git a/src/main/kotlin/dev/zapstore/app/ViewModels.kt b/src/main/kotlin/dev/zapstore/app/ViewModels.kt index ab60220..7c29863 100644 --- a/src/main/kotlin/dev/zapstore/app/ViewModels.kt +++ b/src/main/kotlin/dev/zapstore/app/ViewModels.kt @@ -99,8 +99,9 @@ class HomeViewModel( ) } searchJob = viewModelScope.launch { - repository.queryRemote( + repository.query( Filter(kinds = listOf(Catalog.appKind), search = query, limit = 20), + type = QueryType.Remote, ).collect { state -> val apps = state.items.map(::AppInfo).distinctBy(AppInfo::address) _uiState.update { @@ -127,6 +128,7 @@ class HomeViewModel( kinds = listOf(Catalog.appStackKind), limit = 20, ), + type = QueryType.LocalAndRemote, ).collect { state -> val stacks = state.items.map(::StackInfo).sortedByDescending { it.event.createdAt } _uiState.update { @@ -161,6 +163,7 @@ class HomeViewModel( tags = mapOf("d" to coordinates.map(AppCoordinate::identifier).distinct()), limit = coordinates.size * 2, ), + type = QueryType.LocalAndRemote, ).collect { state -> _uiState.update { it.copy(stackApps = state.items.map(::AppInfo).associateBy(AppInfo::address)) @@ -173,6 +176,7 @@ class HomeViewModel( viewModelScope.launch { repository.query( Filter(kinds = listOf(Catalog.releaseKind), limit = 20), + type = QueryType.LocalAndRemote, ).collect { state -> val releases = state.items.map(::ReleaseInfo) .sortedByDescending { it.event.createdAt } @@ -207,6 +211,7 @@ class HomeViewModel( tags = mapOf("d" to identifiers.toList()), limit = identifiers.size * 3, ), + type = QueryType.LocalAndRemote, ).collect { state -> _uiState.update { it.copy(releaseApps = state.items.map(::AppInfo).associateBy(AppInfo::identifier)) @@ -244,6 +249,7 @@ class StackDetailViewModel( viewModelScope.launch { repository.query( Filter(ids = listOf(stackId), kinds = listOf(Catalog.appStackKind), limit = 1), + type = QueryType.LocalAndRemote, ).collect { state -> val stack = state.items.firstOrNull()?.let(::StackInfo) _uiState.update { @@ -278,6 +284,7 @@ class StackDetailViewModel( tags = mapOf("d" to coordinates.map(AppCoordinate::identifier).distinct()), limit = coordinates.size * 2, ), + type = QueryType.LocalAndRemote, ).collect { state -> val apps = state.items.map(::AppInfo).associateBy(AppInfo::address) _uiState.update { @@ -323,6 +330,7 @@ class AppDetailViewModel( tags = mapOf("d" to listOf(identifier)), limit = 3, ), + type = QueryType.LocalAndRemote, ).collect { state -> val app = state.items.firstOrNull()?.let(::AppInfo) _uiState.update { @@ -344,6 +352,7 @@ class AppDetailViewModel( tags = mapOf("i" to listOf(identifier)), limit = 10, ), + type = QueryType.LocalAndRemote, ).collect { state -> val release = state.items.map(::ReleaseInfo).maxByOrNull { it.event.createdAt } _uiState.update { diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index f910594..7ddf05d 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -28,4 +28,5 @@ Not available %1$s icon %1$s screenshot + %1$s avatar diff --git a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt b/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt index 45c3d86..9fda6a2 100644 --- a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt +++ b/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import kotlin.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class ViewModelsTest { @@ -105,9 +106,16 @@ private class FakeCatalogRepository( private val localQuery: (Filter) -> Flow = { emptyFlow() }, private val remoteQuery: (Filter) -> Flow = { emptyFlow() }, ) : CatalogRepository { - override fun query(filter: Filter): Flow = localQuery(filter) - - override fun queryRemote(filter: Filter): Flow = remoteQuery(filter) + override fun query( + filter: Filter, + type: QueryType, + cachedFor: Duration?, + ): Flow = when (type) { + QueryType.Local, + QueryType.LocalAndRemote, + -> localQuery(filter) + QueryType.Remote -> remoteQuery(filter) + } override fun refreshConnections() = Unit }