mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Fix query API in purplequartz
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<NormalizedRelayUrl>,
|
||||
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;
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-3
@@ -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),
|
||||
|
||||
@@ -10,12 +10,12 @@ sealed interface QuerySource {
|
||||
data class LocalAndRemote(
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
@@ -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<QueryState>
|
||||
|
||||
fun queryRemote(filter: Filter): Flow<QueryState>
|
||||
fun query(
|
||||
filter: Filter,
|
||||
type: QueryType,
|
||||
cachedFor: Duration? = null,
|
||||
): Flow<QueryState>
|
||||
|
||||
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<QueryState> =
|
||||
client().query(
|
||||
filter = filter,
|
||||
source = QuerySource.LocalAndRemote(
|
||||
override fun query(
|
||||
filter: Filter,
|
||||
type: QueryType,
|
||||
cachedFor: Duration?,
|
||||
): Flow<QueryState> {
|
||||
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<QueryState> =
|
||||
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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProfileInfo?>(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<Int>, fromBits: Int, toBits: Int, pad: Boolean): List<Int> {
|
||||
var accumulator = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Int>()
|
||||
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<Int>): List<Int> {
|
||||
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<Int> =
|
||||
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
|
||||
|
||||
private fun bech32Polymod(values: List<Int>): 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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,4 +28,5 @@
|
||||
<string name="not_available">Not available</string>
|
||||
<string name="app_icon_description">%1$s icon</string>
|
||||
<string name="screenshot_description">%1$s screenshot</string>
|
||||
<string name="profile_avatar_description">%1$s avatar</string>
|
||||
</resources>
|
||||
|
||||
@@ -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<QueryState> = { emptyFlow() },
|
||||
private val remoteQuery: (Filter) -> Flow<QueryState> = { emptyFlow() },
|
||||
) : CatalogRepository {
|
||||
override fun query(filter: Filter): Flow<QueryState> = localQuery(filter)
|
||||
|
||||
override fun queryRemote(filter: Filter): Flow<QueryState> = remoteQuery(filter)
|
||||
override fun query(
|
||||
filter: Filter,
|
||||
type: QueryType,
|
||||
cachedFor: Duration?,
|
||||
): Flow<QueryState> = when (type) {
|
||||
QueryType.Local,
|
||||
QueryType.LocalAndRemote,
|
||||
-> localQuery(filter)
|
||||
QueryType.Remote -> remoteQuery(filter)
|
||||
}
|
||||
|
||||
override fun refreshConnections() = Unit
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user