Fix query API in purplequartz

This commit is contained in:
franzap
2026-07-12 19:32:25 -03:00
parent 71864fec9f
commit d9a04a3773
16 changed files with 279 additions and 56 deletions
+4 -4
View File
@@ -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
+8 -8
View File
@@ -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;
@@ -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)
@@ -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"
}
}
}
@@ -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
@@ -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(
+32 -16
View File
@@ -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
}
+24 -7
View File
@@ -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),
+10 -1
View File
@@ -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 {
+1
View File
@@ -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
}