Sync a compact local catalog and surface available updates.

This commit is contained in:
franzap
2026-09-16 14:37:25 -03:00
parent cccb841734
commit 36a5002326
29 changed files with 1941 additions and 78 deletions
+1
View File
@@ -1,6 +1,7 @@
# Project-specific
reference/
.android-sdk/
src/main/assets/catalog.db
# OS
.DS_Store
+47 -1
View File
@@ -9,16 +9,62 @@ AVD_HOME ?= $(HOME)/.android/avd
AVD_CONFIG ?= $(AVD_HOME)/$(AVD_NAME).avd/config.ini
PACKAGE ?= dev.zapstore.beta
ACTIVITY ?= dev.zapstore.app.MainActivity
KEYSTORE ?= release.keystore
KEYSTORE_PASSWORD ?=
KEY_ALIAS ?=
KEY_PASSWORD ?= $(KEYSTORE_PASSWORD)
DEBUG_KEYSTORE ?= $(HOME)/.android/debug.keystore
.PHONY: build install run deploy refresh emulator
ZSP_CATALOG_DB ?= ../zsp/testdata/catalog/catalog.db
ASSETS_CATALOG_DB := src/main/assets/catalog.db
.PHONY: build release install run deploy refresh emulator bundle-catalog
bundle-catalog: $(ASSETS_CATALOG_DB)
$(ASSETS_CATALOG_DB): $(ZSP_CATALOG_DB)
@mkdir -p src/main/assets
cp "$(ZSP_CATALOG_DB)" "$@"
build:
$(GRADLE) assembleDebug
release:
@if [ -f "$(KEYSTORE)" ]; then \
store="$(abspath $(KEYSTORE))"; \
pass="$(KEYSTORE_PASSWORD)"; \
alias="$(KEY_ALIAS)"; \
keypass="$(KEY_PASSWORD)"; \
if [ -z "$$pass" ] || [ -z "$$alias" ]; then \
echo "Found $$store; set KEYSTORE_PASSWORD and KEY_ALIAS to sign the release build."; \
exit 1; \
fi; \
else \
echo "No $(KEYSTORE); signing release with the Android debug certificate"; \
store="$(DEBUG_KEYSTORE)"; \
if [ ! -f "$$store" ]; then \
mkdir -p "$$(dirname "$$store")"; \
keytool -genkeypair -keystore "$$store" \
-storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"; \
fi; \
pass=android; \
alias=androiddebugkey; \
keypass=android; \
fi; \
$(GRADLE) assembleRelease \
-Pandroid.injected.signing.store.file="$$store" \
-Pandroid.injected.signing.store.password="$$pass" \
-Pandroid.injected.signing.key.alias="$$alias" \
-Pandroid.injected.signing.key.password="$$keypass"
install:
$(GRADLE) installDebug
run: install
@$(ADB) reverse tcp:3334 tcp:3334 || true
@$(ADB) reverse tcp:3336 tcp:3336 || true
@$(ADB) shell am force-stop $(PACKAGE)
@$(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY)
@if [ -t 0 ]; then \
+4
View File
@@ -44,9 +44,13 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.okhttp)
implementation(libs.sqlite)
implementation(libs.sqlite.bundled)
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.sqlite.bundled)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.test.junit)
+4
View File
@@ -5,6 +5,7 @@ quartz = "1.13.1"
coroutines = "1.11.0"
coil = "3.5.0"
okhttp = "5.4.0"
sqlite = "2.6.2"
compose-bom = "2026.05.01"
activity = "1.13.0"
lifecycle = "2.11.0"
@@ -20,6 +21,9 @@ coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
@@ -173,6 +173,16 @@ class Iolite private constructor(
reconnectWithoutBackoff()
}
/**
* Reloads every active local projection from the store. Use after a catalog
* delta or snapshot commits so screens see the new epoch without thousands
* of per-row store-change events.
*/
fun invalidateLocalProjections() {
if (closed.get()) return
synchronized(sessionsLock) { sessions.toList() }.forEach(QuerySession::reseedLocal)
}
/**
* Enables or suspends all relay traffic for app foreground/background.
* Suspending closes every relay socket and stops the keep-alive reconnector;
@@ -354,6 +364,12 @@ class Iolite private constructor(
}
}
fun reseedLocal() {
if (stopped.get() || options.sourceMode == SourceMode.Remote) return
observerJob?.cancel()
observerJob = startObserver(initialPhase = { lastState.phase })
}
private fun startLocalOnly() {
observerJob = startObserver(initialPhase = { QueryPhase.LocalOnly })
}
@@ -937,6 +953,7 @@ class Iolite private constructor(
websocketBuilder: WebsocketBuilder,
parentScope: CoroutineScope,
config: IoliteConfig = IoliteConfig(),
eventStore: (path: String) -> IEventStore = { path -> EventStore(dbName = path, relay = null) },
): Iolite {
config.validate()
val parentJob = requireActiveParentJob(parentScope)
@@ -953,7 +970,7 @@ class Iolite private constructor(
var store: ObservableEventStore? = null
var client: INostrClient? = null
try {
store = ObservableEventStore(EventStore(dbName = path, relay = null))
store = ObservableEventStore(eventStore(path))
val refreshCache = SharedPreferencesQueryRefreshCache.create(
context = appContext,
databasePath = path,
@@ -983,8 +1000,8 @@ class Iolite private constructor(
}
/**
* Test-only construction seam. It is internal so consumers cannot replace the
* canonical Quartz-backed store or client in production.
* Test construction seam. Production code should pass [eventStore] to [create]
* instead of calling this.
*/
internal fun createForTesting(
eventStore: IEventStore,
@@ -21,7 +21,7 @@ class ScreensTest {
fun homeSearchAndStackCallbacksAreWired() {
var submitted = false
var cleared = false
var notificationsOpened = false
var updatesOpened = false
var openedStack: String? = null
val stack = StackInfo(
event(
@@ -42,7 +42,7 @@ class ScreensTest {
onSearchQueryChanged = {},
onSearchSubmitted = { submitted = true },
onSearchCleared = { cleared = true },
onNotificationsClick = { notificationsOpened = true },
onUpdatesClick = { updatesOpened = true },
onStackClick = { openedStack = it },
onAppClick = { _, _ -> },
)
@@ -52,12 +52,12 @@ class ScreensTest {
composeRule.onNodeWithTag("searchField").performImeAction()
composeRule.onNodeWithText("×").performClick()
composeRule.onNodeWithTag("searchField").assertIsFocused()
composeRule.onNodeWithTag("notificationsButton").performClick()
composeRule.onNodeWithTag("updatesButton").performClick()
composeRule.onNodeWithTag("stack:stack-id").performClick()
assertTrue(submitted)
assertTrue(cleared)
assertTrue(notificationsOpened)
assertTrue(updatesOpened)
assertEquals("stack-id", openedStack)
}
+2
View File
@@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:name=".ZapstoreApplication"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
+48 -5
View File
@@ -5,6 +5,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import dev.zapstore.app.catalogsync.CatalogSchema
import dev.zapstore.iolite.OutboxRouter
import dev.zapstore.iolite.Iolite
import dev.zapstore.iolite.IoliteConfig
@@ -35,6 +37,10 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration.Companion.days
object Catalog {
const val updatesUrl = "http://127.0.0.1:3336"
/** Temporary: browse the compact catalog only. Flip false to restore relays. */
const val catalogLocalOnly = true
const val catalogRelayPubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
const val relay = "wss://relay.zapstore.dev"
const val profileRelay = "wss://relay.vertexlab.io"
const val assetKind = 3_063
@@ -61,16 +67,26 @@ internal val PROFILE_CACHE_DURATION = 1.days
private const val VERIFIED_ASSET_AND_C1_LIMIT = 500
fun catalogQueryOptions(
sourceMode: SourceMode = SourceMode.LocalAndRemote,
sourceMode: SourceMode = if (Catalog.catalogLocalOnly) SourceMode.Local else SourceMode.LocalAndRemote,
remoteMode: RemoteMode? = if (sourceMode == SourceMode.Local) null else RemoteMode.OneShot(),
cachedFor: Duration? = if (sourceMode == SourceMode.LocalAndRemote) DEFAULT_CACHE_DURATION else null,
relays: Set<String> = setOf(Catalog.relay),
): QueryOptions = QueryOptions(
): QueryOptions {
if (Catalog.catalogLocalOnly) {
return QueryOptions(
sourceMode = SourceMode.Local,
remoteMode = null,
relays = emptySet(),
cachedFor = null,
)
}
return QueryOptions(
sourceMode = sourceMode,
remoteMode = remoteMode,
relays = if (sourceMode == SourceMode.Local) emptySet() else relays.map(String::normalizeRelayUrl).toSet(),
cachedFor = cachedFor,
)
)
}
interface CatalogRepository {
fun query(
@@ -161,9 +177,14 @@ interface CatalogRepository {
* Local data remains fully queryable while suspended.
*/
fun setRelayTrafficEnabled(enabled: Boolean) = Unit
fun purpleQuartz(): Iolite? = null
}
class IoliteCatalogRepository(context: Context) : CatalogRepository {
class IoliteCatalogRepository(
context: Context,
private val eventStoreFactory: ((String) -> IEventStore)? = null,
) : CatalogRepository {
private val applicationContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val profileFlows = ConcurrentHashMap<String, Flow<ProfileInfo?>>()
@@ -187,10 +208,14 @@ class IoliteCatalogRepository(context: Context) : CatalogRepository {
BasicOkHttpWebSocket.Builder { OkHttpClient() },
scope,
config = IoliteConfig(
databaseName = CatalogSchema.DATABASE_NAME,
// Zap receipts are the unbounded firehose. Addressable catalog kinds are
// bounded by supersession; assets back the release feed and stay.
pruneRules = mapOf(Catalog.zapReceiptKind to 90.days),
),
eventStore = eventStoreFactory ?: { path ->
com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore(dbName = path, relay = null)
},
).also {
client = it
if (!relayTrafficEnabled) it.setRelayTrafficEnabled(false)
@@ -209,7 +234,9 @@ class IoliteCatalogRepository(context: Context) : CatalogRepository {
relays: Set<String>,
remoteMode: RemoteMode,
unionWithFallback: Boolean,
): Flow<QueryState> = router().queryWithOutbox(
): Flow<QueryState> {
if (Catalog.catalogLocalOnly) return query(filters, catalogQueryOptions())
return router().queryWithOutbox(
filters,
authors,
fallbackRelays = relays.map(String::normalizeRelayUrl).toSet(),
@@ -217,6 +244,7 @@ class IoliteCatalogRepository(context: Context) : CatalogRepository {
remoteMode = remoteMode,
unionWithFallback = unionWithFallback,
)
}
override fun profile(pubkey: String): Flow<ProfileInfo?> =
profileFlows.getOrPut(pubkey) {
@@ -253,6 +281,8 @@ class IoliteCatalogRepository(context: Context) : CatalogRepository {
client?.setRelayTrafficEnabled(enabled)
}
override fun purpleQuartz(): Iolite? = client()
private suspend fun resolveC1Author(app: AppInfo): String? {
return query(
listOf(
@@ -378,9 +408,22 @@ data class ReleaseInfo(val event: Event) {
?.takeIf(String::isNotBlank)
val version: String get() = event.tagValue("version") ?: "Unknown version"
val channel: String? get() = event.tagValue("c")
?: "main".takeIf { event.kind == Catalog.assetKind }
val notes: String get() = event.content
fun outranks(other: ReleaseInfo): Boolean {
if (version == other.version && event.kind != other.event.kind) {
return event.kind == Catalog.releaseKind
}
if (event.createdAt != other.event.createdAt) return event.createdAt > other.event.createdAt
if (event.kind != other.event.kind) return event.kind == Catalog.releaseKind
return event.id < other.event.id
}
}
fun preferredRelease(current: ReleaseInfo?, candidate: ReleaseInfo): ReleaseInfo =
if (current == null || candidate.outranks(current)) candidate else current
data class StackInfo(val event: Event) {
val identifier: String get() = event.tagValue("d") ?: event.id
val name: String get() = event.tagValue("name") ?: identifier
@@ -25,8 +25,10 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
private val zapstore: ZapstoreApplication
get() = getApplication() as ZapstoreApplication
private val repository: CatalogRepository
get() = (application as ZapstoreApplication).catalogRepository
get() = zapstore.catalogRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -74,15 +76,19 @@ class MainActivity : ComponentActivity() {
factory = homeViewModelFactory(repository),
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
val syncState by zapstore.catalogSyncRepository.state.collectAsStateWithLifecycle()
HomeScreen(
state = state,
repository = repository,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onSearchSubmitted = viewModel::submitSearch,
onSearchCleared = viewModel::clearSearch,
onNotificationsClick = {
navController.navigate(NOTIFICATIONS_ROUTE)
onUpdatesClick = {
navController.navigate(UPDATES_ROUTE)
},
updateCount = syncState.availableUpdates.size,
databaseRowCount = syncState.eventRowCount,
catalogError = syncState.error,
onLoadMoreReleases = viewModel::loadMoreReleases,
onStackClick = { stackId ->
navController.navigate(stackRoute(stackId))
@@ -96,8 +102,18 @@ class MainActivity : ComponentActivity() {
)
}
composable(NOTIFICATIONS_ROUTE) {
NotificationsScreen()
composable(UPDATES_ROUTE) {
val viewModel: UpdatesViewModel = viewModel(
factory = updatesViewModelFactory(zapstore.catalogSyncRepository),
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
UpdatesScreen(
state = state,
onSync = viewModel::sync,
onAppClick = { identifier, author ->
navController.navigate(appRoute(identifier, author))
},
)
}
composable(
@@ -200,7 +216,7 @@ private const val STACK_ROUTE = "stack/{$STACK_ID_ARGUMENT}"
private const val APP_ROUTE =
"app/{$APP_IDENTIFIER_ARGUMENT}?$APP_AUTHOR_ARGUMENT={$APP_AUTHOR_ARGUMENT}"
private const val PROFILE_ROUTE = "profile/{$PROFILE_PUBKEY_ARGUMENT}"
private const val NOTIFICATIONS_ROUTE = "notifications"
private const val UPDATES_ROUTE = "updates"
private const val NAVIGATION_TRANSITION_DURATION = 150
private fun stackRoute(stackId: String): String =
+72 -8
View File
@@ -227,10 +227,7 @@ private class ReleaseFeedLoader(
val identifier = release.appIdentifier ?: return@forEach
val address = "${Catalog.appKind}:${release.event.pubKey}:$identifier"
if (address in appsByAddress) {
val existing = releasesByApp[address]
if (existing == null || existing.event.createdAt < release.event.createdAt) {
releasesByApp[address] = release
}
releasesByApp[address] = preferredRelease(releasesByApp[address], release)
}
}
publishEntries()
@@ -396,6 +393,7 @@ class HomeViewModel(
data class StackDetailUiState(
val stack: StackInfo? = null,
val appsByAddress: Map<String, AppInfo> = emptyMap(),
val releasesByAddress: Map<String, ReleaseInfo> = emptyMap(),
val stackLoading: Boolean = true,
val appsLoading: Boolean = false,
val error: String? = null,
@@ -410,7 +408,9 @@ class StackDetailViewModel(
val uiState: StateFlow<StackDetailUiState> = _uiState.asStateFlow()
private var appsJob: Job? = null
private var releasesJob: Job? = null
private var appAddresses: List<String> = emptyList()
private var releaseLookupKeys: Set<String> = emptySet()
init {
viewModelScope.launch {
@@ -435,13 +435,19 @@ class StackDetailViewModel(
if (addresses == appAddresses) return
appAddresses = addresses
appsJob?.cancel()
releasesJob?.cancel()
releaseLookupKeys = emptySet()
val coordinates = addresses.mapNotNull(String::toAppCoordinate)
if (coordinates.isEmpty()) {
_uiState.update { it.copy(appsByAddress = emptyMap(), appsLoading = false) }
_uiState.update {
it.copy(appsByAddress = emptyMap(), releasesByAddress = emptyMap(), appsLoading = false)
}
return
}
_uiState.update { it.copy(appsByAddress = emptyMap(), appsLoading = true) }
_uiState.update {
it.copy(appsByAddress = emptyMap(), releasesByAddress = emptyMap(), appsLoading = true)
}
appsJob = viewModelScope.launch {
repository.query(
Filter(
@@ -459,6 +465,40 @@ class StackDetailViewModel(
error = state.error?.message ?: it.error,
)
}
observeReleases(apps)
}
}
}
private fun observeReleases(apps: List<AppInfo>) {
val lookupKeys = apps.map(AppInfo::address).toSet()
if (lookupKeys == releaseLookupKeys) return
releaseLookupKeys = lookupKeys
releasesJob?.cancel()
if (apps.isEmpty()) {
_uiState.update { it.copy(releasesByAddress = emptyMap()) }
return
}
val releaseFilters = apps.map { app ->
Filter(
authors = listOf(app.event.pubKey),
kinds = Catalog.releaseKinds,
tags = mapOf("i" to listOf(app.identifier)),
limit = 1,
)
}
releasesJob = viewModelScope.launch {
repository.query(releaseFilters).collect { queryState ->
val releases = linkedMapOf<String, ReleaseInfo>()
queryState.items
.filter { it.kind in Catalog.releaseKinds }
.map(::ReleaseInfo)
.forEach { release ->
val identifier = release.appIdentifier ?: return@forEach
val address = "${Catalog.appKind}:${release.event.pubKey}:$identifier"
releases[address] = preferredRelease(releases[address], release)
}
_uiState.update { it.copy(releasesByAddress = releases) }
}
}
}
@@ -667,12 +707,16 @@ class AppDetailViewModel(
viewModelScope.launch {
repository.query(
Filter(
kinds = listOf(Catalog.releaseKind),
authors = author?.let(::listOf),
kinds = Catalog.releaseKinds,
tags = mapOf("i" to listOf(identifier)),
limit = 10,
),
).collect { state ->
val release = state.items.map(::ReleaseInfo).maxByOrNull { it.event.createdAt }
val release = state.items
.filter { it.kind in Catalog.releaseKinds }
.map(::ReleaseInfo)
.reduceOrNull(::preferredRelease)
_uiState.update {
it.copy(
release = release,
@@ -691,10 +735,30 @@ class AppDetailViewModel(
}
}
class UpdatesViewModel(
private val catalogSync: dev.zapstore.app.catalogsync.CatalogSyncRepository,
) : ViewModel() {
val uiState = catalogSync.state
init {
catalogSync.refreshUpdates()
}
fun sync() {
viewModelScope.launch { catalogSync.sync() }
}
}
fun homeViewModelFactory(repository: CatalogRepository): ViewModelProvider.Factory = viewModelFactory {
initializer { HomeViewModel(repository, createSavedStateHandle()) }
}
fun updatesViewModelFactory(
catalogSync: dev.zapstore.app.catalogsync.CatalogSyncRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer { UpdatesViewModel(catalogSync) }
}
fun stackDetailViewModelFactory(repository: CatalogRepository): ViewModelProvider.Factory = viewModelFactory {
initializer { StackDetailViewModel(repository, createSavedStateHandle()) }
}
@@ -4,16 +4,44 @@ import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import dev.zapstore.app.catalogsync.CatalogBootstrapper
import dev.zapstore.app.catalogsync.CatalogSyncClient
import dev.zapstore.app.catalogsync.CatalogSyncRepository
import dev.zapstore.app.catalogsync.CompactEventStore
import dev.zapstore.app.catalogsync.InstalledAppsProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class ZapstoreApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val catalogDatabase by lazy { CatalogBootstrapper(this).open() }
val compactStore by lazy { CompactEventStore(catalogDatabase) }
val catalogRepository: CatalogRepository by lazy {
IoliteCatalogRepository(this)
IoliteCatalogRepository(this) { compactStore }
}
val catalogSyncRepository by lazy {
CatalogSyncRepository(
database = catalogDatabase,
store = compactStore,
client = CatalogSyncClient(Catalog.updatesUrl),
installedApps = { InstalledAppsProvider(this).installedApps() },
trustedPubkey = Catalog.catalogRelayPubkey,
purpleQuartz = { catalogRepository.purpleQuartz() },
)
}
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = catalogRepository.setRelayTrafficEnabled(true)
override fun onStart(owner: LifecycleOwner) {
if (!Catalog.catalogLocalOnly) {
catalogRepository.setRelayTrafficEnabled(true)
}
applicationScope.launch { catalogSyncRepository.sync() }
}
override fun onStop(owner: LifecycleOwner) = catalogRepository.setRelayTrafficEnabled(false)
})
}
@@ -0,0 +1,49 @@
package dev.zapstore.app.catalogsync
import android.content.Context
import java.io.File
class CatalogBootstrapper(
private val context: Context,
private val databaseName: String = CatalogSchema.DATABASE_NAME,
private val bundledAsset: String = CatalogSchema.BUNDLED_ASSET,
) {
fun open(): ZapstoreDatabase {
val target = context.getDatabasePath(databaseName)
if (!target.exists()) {
copyBundled(target)
}
return if (target.exists()) {
val database = ZapstoreDatabase.open(target.absolutePath)
if (database.catalogState() == null) {
database.writeCatalogState(emptyState())
}
database
} else {
ZapstoreDatabase.createEmpty(target.absolutePath)
}
}
private fun copyBundled(target: File) {
val copied = runCatching {
context.assets.open(bundledAsset).use { input ->
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.copy")
temp.outputStream().use { input.copyTo(it) }
if (!temp.renameTo(target)) {
temp.copyTo(target, overwrite = true)
temp.delete()
}
}
}
copied.exceptionOrNull()
}
private fun emptyState() = CatalogState(
catalog = CatalogSchema.CATALOG,
epoch = 0,
schemaVersion = CatalogSchema.VERSION,
searchModel = CatalogSchema.SEARCH_MODEL,
generation = 0,
)
}
@@ -0,0 +1,208 @@
package dev.zapstore.app.catalogsync
import com.vitorpamplona.quartz.nip01Core.core.Event
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
data class CatalogManifest(
val event: Event,
val contentHash: String,
val oldEpoch: Long,
val newEpoch: Long,
val schemaVersion: Int,
val searchModel: String,
val catalog: String,
val kind: String,
)
data class CatalogDelta(
val events: List<Event>,
val deletedIds: List<String>,
val oldEpoch: Long,
val newEpoch: Long,
)
data class DecodedEnvelope(
val manifest: CatalogManifest,
val payload: ByteArray,
)
object CatalogEnvelope {
fun encode(manifestEventJson: String, payload: ByteArray): ByteArray {
val compressed = gzip(payload)
val manifestBytes = manifestEventJson.toByteArray(Charsets.UTF_8)
val buffer = ByteBuffer.allocate(CatalogSchema.MAGIC.size + 4 + manifestBytes.size + compressed.size)
.order(ByteOrder.BIG_ENDIAN)
buffer.put(CatalogSchema.MAGIC)
buffer.putInt(manifestBytes.size)
buffer.put(manifestBytes)
buffer.put(compressed)
return buffer.array()
}
fun decode(bytes: ByteArray): DecodedEnvelope {
require(bytes.size >= CatalogSchema.MAGIC.size + 4) { "catalog envelope is truncated" }
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
val magic = ByteArray(CatalogSchema.MAGIC.size)
buffer.get(magic)
require(magic.contentEquals(CatalogSchema.MAGIC)) { "catalog envelope magic is invalid" }
val manifestLength = buffer.int
require(manifestLength > 0 && manifestLength <= buffer.remaining()) { "catalog envelope manifest is truncated" }
val manifestBytes = ByteArray(manifestLength)
buffer.get(manifestBytes)
val compressed = ByteArray(buffer.remaining())
buffer.get(compressed)
return DecodedEnvelope(
manifest = parseManifest(JSONObject(String(manifestBytes, Charsets.UTF_8))),
payload = gunzip(compressed),
)
}
fun decodeStreamToFile(input: InputStream, payloadFile: File): CatalogManifest {
val magic = input.readExact(CatalogSchema.MAGIC.size)
require(magic.size == CatalogSchema.MAGIC.size && magic.contentEquals(CatalogSchema.MAGIC)) {
"catalog envelope magic is invalid"
}
val lengthBytes = input.readExact(4)
require(lengthBytes.size == 4) { "catalog envelope is truncated" }
val manifestLength = ByteBuffer.wrap(lengthBytes).order(ByteOrder.BIG_ENDIAN).int
require(manifestLength > 0) { "catalog envelope manifest is truncated" }
val manifestBytes = input.readExact(manifestLength)
require(manifestBytes.size == manifestLength) { "catalog envelope manifest is truncated" }
payloadFile.outputStream().use { output ->
GZIPInputStream(input).use { gzip -> gzip.copyTo(output) }
}
return parseManifest(JSONObject(String(manifestBytes, Charsets.UTF_8)))
}
private fun InputStream.readExact(count: Int): ByteArray {
val bytes = ByteArray(count)
var offset = 0
while (offset < count) {
val read = read(bytes, offset, count - offset)
if (read < 0) return bytes.copyOf(offset)
offset += read
}
return bytes
}
fun parseManifest(json: JSONObject): CatalogManifest {
val event = Event(
id = json.getString("id"),
pubKey = json.getString("pubkey"),
createdAt = json.getLong("created_at"),
kind = json.getInt("kind"),
tags = decodeEventTags(json.getJSONArray("tags")),
content = json.getString("content"),
sig = json.getString("sig"),
)
val body = JSONObject(event.content)
return CatalogManifest(
event = event,
contentHash = body.getString("content_hash"),
oldEpoch = body.getLong("old_epoch"),
newEpoch = body.getLong("new_epoch"),
schemaVersion = body.getInt("schema_version"),
searchModel = body.getString("search_model"),
catalog = body.optString("catalog", CatalogSchema.CATALOG),
kind = body.getString("kind"),
)
}
fun parseDelta(payload: ByteArray): CatalogDelta {
val json = JSONObject(String(payload, Charsets.UTF_8))
val events = json.getJSONArray("events")
val deleted = json.optJSONArray("deleted_ids") ?: JSONArray()
return CatalogDelta(
events = List(events.length()) { parseWireEvent(events.getJSONObject(it)) },
deletedIds = List(deleted.length()) { deleted.getString(it) },
oldEpoch = json.getLong("old_epoch"),
newEpoch = json.getLong("new_epoch"),
)
}
fun encodeDelta(delta: CatalogDelta): ByteArray {
val events = JSONArray()
delta.events.forEach { events.put(wireEvent(it)) }
val deleted = JSONArray()
delta.deletedIds.forEach(deleted::put)
return JSONObject()
.put("events", events)
.put("deleted_ids", deleted)
.put("old_epoch", delta.oldEpoch)
.put("new_epoch", delta.newEpoch)
.toString()
.toByteArray(Charsets.UTF_8)
}
fun parseWireEvent(json: JSONObject): Event = Event(
id = json.getString("id"),
pubKey = json.getString("pubkey"),
createdAt = json.getLong("created_at"),
kind = json.getInt("kind"),
tags = decodeEventTags(json.getJSONArray("tags")),
content = json.getString("content"),
sig = json.getString("sig"),
)
fun wireEvent(event: Event): JSONObject = JSONObject()
.put("id", event.id)
.put("pubkey", event.pubKey)
.put("created_at", event.createdAt)
.put("kind", event.kind)
.put("tags", JSONArray(event.tags.map { JSONArray(it.toList()) }))
.put("content", event.content)
.put("sig", event.sig)
fun decodeEventTags(tags: JSONArray): Array<Array<String>> =
Array(tags.length()) { index ->
val row = tags.getJSONArray(index)
Array(row.length()) { row.getString(it) }
}
fun gzip(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
GZIPOutputStream(output).use { it.write(bytes) }
return output.toByteArray()
}
fun gunzip(bytes: ByteArray): ByteArray =
GZIPInputStream(ByteArrayInputStream(bytes)).use { it.readBytes() }
}
data class UpdatesRequest(
val protocol: Int,
val catalog: String,
val schemaVersion: Int,
val epoch: Long,
val searchModel: String,
) {
fun toJson(): String =
"""{"protocol":$protocol,"catalog":"$catalog","schema_version":$schemaVersion,"epoch":$epoch,"search_model":"$searchModel"}"""
fun forbiddenFields(): Set<String> = emptySet()
}
fun parseUpdatesRequest(json: JSONObject): UpdatesRequest {
val forbidden = listOf(
"installed", "apps", "package", "packages", "search", "query", "hashes", "versions",
)
json.keys().asSequence().forEach { key ->
require(key !in forbidden) { "updates request must not include $key" }
}
return UpdatesRequest(
protocol = json.getInt("protocol"),
catalog = json.getString("catalog"),
schemaVersion = json.getInt("schema_version"),
epoch = json.getLong("epoch"),
searchModel = json.getString("search_model"),
)
}
@@ -0,0 +1,84 @@
package dev.zapstore.app.catalogsync
object CatalogSchema {
const val VERSION = 1
const val PROTOCOL = 1
const val CATALOG = "default"
const val SEARCH_MODEL = "leaf-ir-v1"
const val DATABASE_NAME = "zapstore.db"
const val BUNDLED_ASSET = "catalog.db"
const val MANIFEST_KIND = 30_078
const val CONTENT_TYPE_DELTA = "application/vnd.zapstore.catalog-delta"
val MAGIC = byteArrayOf('Z'.code.toByte(), 'S'.code.toByte(), 'C'.code.toByte(), '1'.code.toByte())
val CREATE_STATEMENTS = listOf(
"""
CREATE TABLE IF NOT EXISTS events (
id BLOB PRIMARY KEY,
pubkey BLOB NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
d_tag TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
tags TEXT NOT NULL
)
""".trimIndent(),
"CREATE INDEX IF NOT EXISTS events_kind_time ON events(kind, created_at DESC, id)",
"CREATE INDEX IF NOT EXISTS events_pubkey_kind_time ON events(pubkey, kind, created_at DESC, id)",
"CREATE INDEX IF NOT EXISTS events_kind_d ON events(kind, pubkey, d_tag)",
"""
CREATE TABLE IF NOT EXISTS event_tags (
event_id BLOB NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (key, value, event_id),
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
)
""".trimIndent(),
"CREATE INDEX IF NOT EXISTS event_tags_event ON event_tags(event_id)",
"""
CREATE TABLE IF NOT EXISTS catalog_state (
catalog TEXT PRIMARY KEY,
epoch INTEGER NOT NULL,
schema_version INTEGER NOT NULL,
search_model TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 0
)
""".trimIndent(),
"""
CREATE TABLE IF NOT EXISTS apps (
app_id TEXT PRIMARY KEY,
event_id BLOB NOT NULL,
pubkey BLOB NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""".trimIndent(),
"""
CREATE TABLE IF NOT EXISTS releases (
app_id TEXT NOT NULL,
version TEXT NOT NULL,
event_id BLOB NOT NULL,
channel TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (app_id, version)
)
""".trimIndent(),
"""
CREATE TABLE IF NOT EXISTS assets (
event_id BLOB PRIMARY KEY,
app_id TEXT NOT NULL,
version_code INTEGER,
version TEXT,
mime TEXT,
platform TEXT,
variant TEXT,
certificate_hash TEXT,
file_hash TEXT,
url TEXT,
created_at INTEGER NOT NULL
)
""".trimIndent(),
"CREATE INDEX IF NOT EXISTS assets_app ON assets(app_id, version_code)",
)
}
@@ -0,0 +1,48 @@
package dev.zapstore.app.catalogsync
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
sealed class UpdatesResponse {
data class Delta(val body: ByteArray) : UpdatesResponse()
data object NotModified : UpdatesResponse()
data class Error(val status: Int, val code: String?, val reason: String?) : UpdatesResponse()
}
class CatalogSyncClient(
private val baseUrl: String,
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build(),
) {
fun requestUpdates(request: UpdatesRequest): UpdatesResponse {
val call = http.newCall(
Request.Builder()
.url(baseUrl.trimEnd('/') + "/updates")
.header("Content-Type", "application/json")
.post(request.toJson().toRequestBody(JSON_MEDIA))
.build(),
)
call.execute().use { response ->
return when (response.code) {
200 -> UpdatesResponse.Delta(response.body.bytes())
304 -> UpdatesResponse.NotModified
else -> UpdatesResponse.Error(
status = response.code,
code = response.header("X-Error-Code"),
reason = response.header("X-Reason"),
)
}
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}
}
@@ -0,0 +1,145 @@
package dev.zapstore.app.catalogsync
import dev.zapstore.iolite.Iolite
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class CatalogSyncUiState(
val epoch: Long = 0,
val eventRowCount: Long = 0,
val lastSyncedAtMillis: Long? = null,
val syncing: Boolean = false,
val error: String? = null,
val availableUpdates: List<AvailableUpdate> = emptyList(),
)
class CatalogSyncRepository(
private val database: ZapstoreDatabase,
private val store: CompactEventStore,
private val client: CatalogSyncClient,
private val installedApps: () -> List<InstalledApp>,
private val trustedPubkey: String,
private val purpleQuartz: () -> Iolite?,
private val now: () -> Long = { System.currentTimeMillis() },
) {
private val mutex = Mutex()
private val _state = MutableStateFlow(
CatalogSyncUiState(
epoch = database.catalogState()?.epoch ?: 0,
eventRowCount = database.eventRowCount(),
availableUpdates = currentUpdates(),
),
)
val state: StateFlow<CatalogSyncUiState> = _state.asStateFlow()
fun refreshUpdates() {
_state.value = _state.value.copy(availableUpdates = currentUpdates())
}
suspend fun sync() {
mutex.withLock {
_state.value = _state.value.copy(syncing = true, error = null)
try {
applyRemoteUpdate()
_state.value = readUiState(lastSyncedAtMillis = now(), error = null)
purpleQuartz()?.invalidateLocalProjections()
} catch (failure: Throwable) {
_state.value = readUiState(
lastSyncedAtMillis = _state.value.lastSyncedAtMillis,
error = failure.message ?: failure::class.simpleName,
)
}
}
}
private fun applyRemoteUpdate() {
val current = database.catalogState() ?: error("catalog state is missing")
val request = UpdatesRequest(
protocol = CatalogSchema.PROTOCOL,
catalog = current.catalog,
schemaVersion = current.schemaVersion,
epoch = current.epoch,
searchModel = current.searchModel,
)
when (val response = client.requestUpdates(request)) {
UpdatesResponse.NotModified -> Unit
is UpdatesResponse.Delta -> applyDelta(response.body, current)
is UpdatesResponse.Error -> throw CatalogVerificationException(
response.reason ?: "catalog update failed with HTTP ${response.status}",
)
}
}
private fun applyDelta(body: ByteArray, current: CatalogState) {
val envelope = CatalogEnvelope.decode(body)
CatalogVerifier.verifyEnvelope(
envelope,
trustedPubkey = trustedPubkey,
expectedKind = "delta",
expectedOldEpoch = current.epoch,
schemaVersion = current.schemaVersion,
searchModel = current.searchModel,
catalog = current.catalog,
)
val delta = CatalogEnvelope.parseDelta(envelope.payload)
delta.events.forEach(CatalogVerifier::verifyEvent)
store.applyDelta(
events = delta.events,
deletedIds = delta.deletedIds,
next = current.copy(
epoch = delta.newEpoch,
generation = current.generation + 1,
),
)
}
private fun readUiState(
lastSyncedAtMillis: Long? = _state.value.lastSyncedAtMillis,
error: String? = _state.value.error,
) = CatalogSyncUiState(
epoch = database.catalogState()?.epoch ?: 0,
eventRowCount = database.eventRowCount(),
lastSyncedAtMillis = lastSyncedAtMillis,
syncing = false,
error = error,
availableUpdates = currentUpdates(),
)
private fun currentUpdates(): List<AvailableUpdate> =
LocalUpdateMatcher.match(installedApps(), catalogAssets())
fun catalogAssets(): List<CatalogAssetRow> = database.withConnection { connection ->
connection.prepare(
"""
SELECT assets.app_id, COALESCE(apps.name, assets.app_id), COALESCE(assets.version, ''),
COALESCE(assets.version_code, 0), releases.channel, assets.platform, assets.variant,
assets.certificate_hash, assets.mime
FROM assets
LEFT JOIN apps ON apps.app_id = assets.app_id
LEFT JOIN releases ON releases.app_id = assets.app_id AND releases.version = assets.version
""".trimIndent(),
).use { statement ->
val rows = mutableListOf<CatalogAssetRow>()
while (statement.step()) {
rows += CatalogAssetRow(
appId = statement.getText(0),
name = statement.getText(1),
version = statement.getText(2),
versionCode = statement.getLong(3),
channel = statement.nullableText(4),
platform = statement.nullableText(5),
variant = statement.nullableText(6),
certificateHash = statement.nullableText(7),
mime = statement.nullableText(8),
)
}
rows
}
}
private fun androidx.sqlite.SQLiteStatement.nullableText(index: Int): String? =
if (isNull(index)) null else getText(index)
}
@@ -0,0 +1,102 @@
package dev.zapstore.app.catalogsync
import com.vitorpamplona.quartz.nip01Core.crypto.verifyId
import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature
import java.io.File
import java.security.MessageDigest
class CatalogVerificationException(message: String) : Exception(message)
object CatalogVerifier {
fun verifyEnvelope(
envelope: DecodedEnvelope,
trustedPubkey: String,
expectedKind: String? = null,
expectedOldEpoch: Long? = null,
schemaVersion: Int = CatalogSchema.VERSION,
searchModel: String = CatalogSchema.SEARCH_MODEL,
catalog: String = CatalogSchema.CATALOG,
): CatalogManifest {
val manifest = verifyManifest(
envelope.manifest,
trustedPubkey = trustedPubkey,
expectedKind = expectedKind,
expectedOldEpoch = expectedOldEpoch,
schemaVersion = schemaVersion,
searchModel = searchModel,
catalog = catalog,
)
verifyPayloadHash(manifest, envelope.payload)
return manifest
}
fun verifyManifest(
manifest: CatalogManifest,
trustedPubkey: String,
expectedKind: String? = null,
expectedOldEpoch: Long? = null,
schemaVersion: Int = CatalogSchema.VERSION,
searchModel: String = CatalogSchema.SEARCH_MODEL,
catalog: String = CatalogSchema.CATALOG,
): CatalogManifest {
if (manifest.event.kind != CatalogSchema.MANIFEST_KIND) {
throw CatalogVerificationException("manifest kind is not a catalog manifest")
}
if (manifest.event.pubKey != trustedPubkey) {
throw CatalogVerificationException("manifest signer is not the configured catalog key")
}
if (!manifest.event.verifyId() || !manifest.event.verifySignature()) {
throw CatalogVerificationException("manifest signature is invalid")
}
if (manifest.catalog != catalog) {
throw CatalogVerificationException("manifest catalog does not match")
}
if (manifest.schemaVersion != schemaVersion) {
throw CatalogVerificationException("manifest schema is incompatible")
}
if (manifest.searchModel != searchModel) {
throw CatalogVerificationException("manifest search model is incompatible")
}
if (expectedKind != null && manifest.kind != expectedKind) {
throw CatalogVerificationException("manifest kind is ${manifest.kind}, expected $expectedKind")
}
if (expectedOldEpoch != null && manifest.oldEpoch != expectedOldEpoch) {
throw CatalogVerificationException("manifest epoch does not continue the local catalog")
}
return manifest
}
fun verifyPayloadHash(manifest: CatalogManifest, payload: ByteArray) {
if (sha256Hex(payload) != manifest.contentHash) {
throw CatalogVerificationException("payload hash does not match the manifest")
}
}
fun verifyPayloadHash(manifest: CatalogManifest, payload: File) {
if (sha256Hex(payload) != manifest.contentHash) {
throw CatalogVerificationException("payload hash does not match the manifest")
}
}
fun verifyEvent(event: com.vitorpamplona.quartz.nip01Core.core.Event) {
if (!event.verifyId() || !event.verifySignature()) {
throw CatalogVerificationException("catalog event ${event.id} failed verification")
}
}
fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
fun sha256Hex(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().toHex()
}
}
@@ -0,0 +1,452 @@
package dev.zapstore.app.catalogsync
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import dev.zapstore.app.Catalog
import org.json.JSONArray
import java.util.concurrent.atomic.AtomicBoolean
class CompactEventStore(
private val database: ZapstoreDatabase,
) : IEventStore {
override val relay: NormalizedRelayUrl? = null
private val closed = AtomicBoolean(false)
override suspend fun insert(event: Event) {
database.transaction { insertUnlocked(it, event) }
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
database.transaction { connection ->
body(object : IEventStore.ITransaction {
override fun insert(event: Event) {
insertUnlocked(connection, event)
}
})
}
}
override suspend fun <T : Event> query(filter: Filter): List<T> = query(listOf(filter))
override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
if (filters.isEmpty()) return emptyList()
val seen = linkedSetOf<String>()
val matches = mutableListOf<T>()
database.withConnection { connection ->
filters.forEach { filter ->
selectCandidates(connection, filter).forEach { event ->
@Suppress("UNCHECKED_CAST")
if (filter.match(event) && seen.add(event.id)) {
matches += event as T
}
}
}
}
return matches
}
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
query<T>(filter).forEach(onEach)
}
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
query<T>(filters).forEach(onEach)
}
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
override suspend fun delete(filter: Filter) {
delete(listOf(filter))
}
override suspend fun delete(filters: List<Filter>) {
database.transaction { connection ->
filters.forEach { filter ->
selectCandidates(connection, filter)
.filter(filter::match)
.forEach { deleteEvent(connection, it.id.hexToBytes()) }
}
}
}
override suspend fun deleteExpiredEvents() {
database.transaction { connection ->
selectAll(connection)
.filter { it.isExpired() }
.forEach { deleteEvent(connection, it.id.hexToBytes()) }
}
}
override suspend fun reindexFullTextSearch() = Unit
override suspend fun reindexFullTextSearch(resumeFrom: String?, batchSize: Int): FtsReindexProgress =
FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true)
override fun close() {
closed.set(true)
}
fun insertAll(events: List<Event>) {
database.transaction { connection ->
events.forEach { insertUnlocked(connection, it) }
}
}
fun deleteByIds(ids: List<String>) {
if (ids.isEmpty()) return
database.transaction { connection ->
ids.forEach { deleteEvent(connection, it.hexToBytes()) }
}
}
fun applyDelta(events: List<Event>, deletedIds: List<String>, next: CatalogState) {
database.transaction { connection ->
deletedIds.forEach { deleteEvent(connection, it.hexToBytes()) }
events.forEach { insertUnlocked(connection, it) }
ZapstoreDatabase.upsertCatalogState(connection, next)
}
}
private fun insertUnlocked(connection: SQLiteConnection, event: Event) {
val id = event.id.hexToBytes()
val pubkey = event.pubKey.hexToBytes()
val dTag = event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1).orEmpty()
if (event.kind.isAddressable()) {
deleteOlderAddressable(connection, event.kind, pubkey, dTag, event.createdAt, event.id)
} else if (event.kind.isReplaceable()) {
deleteOlderReplaceable(connection, event.kind, pubkey, event.createdAt, event.id)
}
connection.prepare(
"""
INSERT OR REPLACE INTO events(id, pubkey, created_at, kind, d_tag, content, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, id)
statement.bindBlob(2, pubkey)
statement.bindLong(3, event.createdAt)
statement.bindLong(4, event.kind.toLong())
statement.bindText(5, dTag)
statement.bindText(6, event.content)
statement.bindText(7, encodeTags(event.tags))
statement.step()
}
connection.prepare("DELETE FROM event_tags WHERE event_id = ?").use { statement ->
statement.bindBlob(1, id)
statement.step()
}
event.tags.forEach { tag ->
val key = tag.getOrNull(0) ?: return@forEach
val value = tag.getOrNull(1) ?: return@forEach
connection.prepare("INSERT OR IGNORE INTO event_tags(event_id, key, value) VALUES (?, ?, ?)").use { statement ->
statement.bindBlob(1, id)
statement.bindText(2, key)
statement.bindText(3, value)
statement.step()
}
}
upsertDerived(connection, event, id, pubkey)
}
private fun upsertDerived(connection: SQLiteConnection, event: Event, id: ByteArray, pubkey: ByteArray) {
when (event.kind) {
Catalog.appKind -> {
val appId = event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) ?: return
val name = event.tags.firstOrNull { it.firstOrNull() == "name" }?.getOrNull(1) ?: appId
connection.prepare(
"""
INSERT INTO apps(app_id, event_id, pubkey, name, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(app_id) DO UPDATE SET
event_id = excluded.event_id,
pubkey = excluded.pubkey,
name = excluded.name,
created_at = excluded.created_at
""".trimIndent(),
).use { statement ->
statement.bindText(1, appId)
statement.bindBlob(2, id)
statement.bindBlob(3, pubkey)
statement.bindText(4, name)
statement.bindLong(5, event.createdAt)
statement.step()
}
}
Catalog.releaseKind -> {
val appId = event.tags.firstOrNull { it.firstOrNull() == "i" }?.getOrNull(1)
?: event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1)?.substringBefore('@')
?: return
val version = event.tags.firstOrNull { it.firstOrNull() == "version" }?.getOrNull(1) ?: return
val channel = event.tags.firstOrNull { it.firstOrNull() == "c" }?.getOrNull(1)
connection.prepare(
"""
INSERT INTO releases(app_id, version, event_id, channel, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(app_id, version) DO UPDATE SET
event_id = excluded.event_id,
channel = excluded.channel,
created_at = excluded.created_at
""".trimIndent(),
).use { statement ->
statement.bindText(1, appId)
statement.bindText(2, version)
statement.bindBlob(3, id)
if (channel == null) statement.bindNull(4) else statement.bindText(4, channel)
statement.bindLong(5, event.createdAt)
statement.step()
}
}
Catalog.assetKind -> {
val appId = event.tags.firstOrNull { it.firstOrNull() == "i" }?.getOrNull(1) ?: return
connection.prepare(
"""
INSERT INTO assets(
event_id, app_id, version_code, version, mime, platform, variant,
certificate_hash, file_hash, url, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
app_id = excluded.app_id,
version_code = excluded.version_code,
version = excluded.version,
mime = excluded.mime,
platform = excluded.platform,
variant = excluded.variant,
certificate_hash = excluded.certificate_hash,
file_hash = excluded.file_hash,
url = excluded.url,
created_at = excluded.created_at
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, id)
statement.bindText(2, appId)
event.tags.firstOrNull { it.firstOrNull() == "version_code" }?.getOrNull(1)?.toLongOrNull()
?.let { statement.bindLong(3, it) }
?: statement.bindNull(3)
bindOptional(statement, 4, event.tag("version"))
bindOptional(statement, 5, event.tag("m"))
bindOptional(statement, 6, event.tags.filter { it.firstOrNull() == "f" }.mapNotNull { it.getOrNull(1) }.joinToString(",").ifEmpty { null })
bindOptional(statement, 7, event.tag("variant"))
bindOptional(statement, 8, event.tag("apk_certificate_hash"))
bindOptional(statement, 9, event.tag("x"))
bindOptional(statement, 10, event.tag("url"))
statement.bindLong(11, event.createdAt)
statement.step()
}
val version = event.tag("version") ?: return
connection.prepare(
"""
INSERT INTO releases(app_id, version, event_id, channel, created_at)
VALUES (?, ?, ?, NULL, ?)
ON CONFLICT(app_id, version) DO NOTHING
""".trimIndent(),
).use { statement ->
statement.bindText(1, appId)
statement.bindText(2, version)
statement.bindBlob(3, id)
statement.bindLong(4, event.createdAt)
statement.step()
}
}
}
}
private fun deleteOlderAddressable(
connection: SQLiteConnection,
kind: Int,
pubkey: ByteArray,
dTag: String,
createdAt: Long,
idHex: String,
) {
connection.prepare(
"""
SELECT id, created_at FROM events
WHERE kind = ? AND pubkey = ? AND d_tag = ?
""".trimIndent(),
).use { statement ->
statement.bindLong(1, kind.toLong())
statement.bindBlob(2, pubkey)
statement.bindText(3, dTag)
val obsolete = mutableListOf<ByteArray>()
while (statement.step()) {
val existingId = statement.getBlob(0)
val existingCreated = statement.getLong(1)
val existingHex = existingId.toHex()
if (existingCreated < createdAt || existingCreated == createdAt && existingHex > idHex) {
obsolete += existingId
}
}
obsolete.forEach { deleteEvent(connection, it) }
}
}
private fun deleteOlderReplaceable(
connection: SQLiteConnection,
kind: Int,
pubkey: ByteArray,
createdAt: Long,
idHex: String,
) {
connection.prepare("SELECT id, created_at FROM events WHERE kind = ? AND pubkey = ?").use { statement ->
statement.bindLong(1, kind.toLong())
statement.bindBlob(2, pubkey)
val obsolete = mutableListOf<ByteArray>()
while (statement.step()) {
val existingId = statement.getBlob(0)
val existingCreated = statement.getLong(1)
val existingHex = existingId.toHex()
if (existingCreated < createdAt || existingCreated == createdAt && existingHex > idHex) {
obsolete += existingId
}
}
obsolete.forEach { deleteEvent(connection, it) }
}
}
private fun deleteEvent(connection: SQLiteConnection, id: ByteArray) {
connection.prepare("DELETE FROM event_tags WHERE event_id = ?").use {
it.bindBlob(1, id)
it.step()
}
connection.prepare("DELETE FROM apps WHERE event_id = ?").use {
it.bindBlob(1, id)
it.step()
}
connection.prepare("DELETE FROM releases WHERE event_id = ?").use {
it.bindBlob(1, id)
it.step()
}
connection.prepare("DELETE FROM assets WHERE event_id = ?").use {
it.bindBlob(1, id)
it.step()
}
connection.prepare("DELETE FROM events WHERE id = ?").use {
it.bindBlob(1, id)
it.step()
}
}
private fun selectCandidates(connection: SQLiteConnection, filter: Filter): List<Event> {
val clauses = mutableListOf<String>()
val binders = mutableListOf<(SQLiteStatement) -> Unit>()
var index = 1
filter.ids?.takeIf { it.isNotEmpty() }?.let { ids ->
clauses += "id IN (${ids.joinToString(",") { "?" }})"
ids.forEach { id ->
val current = index++
binders += { it.bindBlob(current, id.hexToBytes()) }
}
}
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
clauses += "pubkey IN (${authors.joinToString(",") { "?" }})"
authors.forEach { author ->
val current = index++
binders += { it.bindBlob(current, author.hexToBytes()) }
}
}
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
clauses += "kind IN (${kinds.joinToString(",") { "?" }})"
kinds.forEach { kind ->
val current = index++
binders += { it.bindLong(current, kind.toLong()) }
}
}
filter.since?.let { since ->
clauses += "created_at >= ?"
val current = index++
binders += { it.bindLong(current, since) }
}
filter.until?.let { until ->
clauses += "created_at <= ?"
val current = index++
binders += { it.bindLong(current, until) }
}
filter.tags?.forEach { (key, values) ->
if (values.isEmpty()) return@forEach
val placeholders = values.joinToString(",") { "?" }
clauses += "id IN (SELECT event_id FROM event_tags WHERE key = ? AND value IN ($placeholders))"
val keyIndex = index++
binders += { it.bindText(keyIndex, key) }
values.forEach { value ->
val current = index++
binders += { it.bindText(current, value) }
}
}
filter.search?.takeIf { it.isNotBlank() }?.let { search ->
clauses += "(content LIKE ? OR id IN (SELECT event_id FROM event_tags WHERE key IN ('name','summary') AND value LIKE ?))"
val pattern = "%$search%"
val first = index++
val second = index++
binders += {
it.bindText(first, pattern)
it.bindText(second, pattern)
}
}
val sql = buildString {
append("SELECT id, pubkey, created_at, kind, content, tags FROM events")
if (clauses.isNotEmpty()) append(" WHERE ").append(clauses.joinToString(" AND "))
append(" ORDER BY created_at DESC, id ASC")
filter.limit?.let { append(" LIMIT ").append(it) }
}
return connection.prepare(sql).use { statement ->
binders.forEach { it(statement) }
readEvents(statement)
}
}
private fun selectAll(connection: SQLiteConnection): List<Event> =
connection.prepare("SELECT id, pubkey, created_at, kind, content, tags FROM events").use(::readEvents)
private fun readEvents(statement: SQLiteStatement): List<Event> {
val events = mutableListOf<Event>()
while (statement.step()) {
events += Event(
id = statement.getBlob(0).toHex(),
pubKey = statement.getBlob(1).toHex(),
createdAt = statement.getLong(2),
kind = statement.getInt(3),
tags = decodeTags(statement.getText(5)),
content = statement.getText(4),
sig = "",
)
}
return events
}
private fun Event.tag(name: String): String? =
tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
private fun bindOptional(statement: SQLiteStatement, index: Int, value: String?) {
if (value == null) statement.bindNull(index) else statement.bindText(index, value)
}
companion object {
fun encodeTags(tags: Array<Array<String>>): String {
val array = JSONArray()
tags.forEach { tag ->
val row = JSONArray()
tag.forEach(row::put)
array.put(row)
}
return array.toString()
}
fun decodeTags(raw: String): Array<Array<String>> {
val array = JSONArray(raw)
return Array(array.length()) { index ->
val row = array.getJSONArray(index)
Array(row.length()) { row.getString(it) }
}
}
}
}
@@ -0,0 +1,12 @@
package dev.zapstore.app.catalogsync
internal fun String.hexToBytes(): ByteArray {
require(length % 2 == 0) { "hex length must be even" }
return ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}
internal fun ByteArray.toHex(): String = joinToString("") { byte ->
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
}
@@ -0,0 +1,29 @@
package dev.zapstore.app.catalogsync
import android.content.Context
import android.content.pm.PackageManager
import java.security.MessageDigest
class InstalledAppsProvider(
private val context: Context,
) {
fun installedApps(): List<InstalledApp> {
val manager = context.packageManager
return manager.getInstalledPackages(PackageManager.GET_SIGNING_CERTIFICATES).map { info ->
val signing = info.signingInfo
val certificates = buildSet {
signing?.apkContentsSigners?.forEach { add(it.toByteArray().sha256Hex()) }
signing?.signingCertificateHistory?.forEach { add(it.toByteArray().sha256Hex()) }
}
InstalledApp(
packageId = info.packageName,
versionCode = info.longVersionCode,
versionName = info.versionName ?: info.longVersionCode.toString(),
certificateHashes = certificates,
)
}
}
private fun ByteArray.sha256Hex(): String =
MessageDigest.getInstance("SHA-256").digest(this).toHex()
}
@@ -0,0 +1,80 @@
package dev.zapstore.app.catalogsync
data class InstalledApp(
val packageId: String,
val versionCode: Long,
val versionName: String,
val certificateHashes: Set<String>,
)
data class CatalogAssetRow(
val appId: String,
val name: String,
val version: String,
val versionCode: Long,
val channel: String?,
val platform: String?,
val variant: String?,
val certificateHash: String?,
val mime: String?,
)
data class AvailableUpdate(
val appId: String,
val name: String,
val installedVersion: String,
val installedVersionCode: Long,
val availableVersion: String,
val availableVersionCode: Long,
val channel: String?,
)
object LocalUpdateMatcher {
fun match(
installed: List<InstalledApp>,
catalog: List<CatalogAssetRow>,
channel: String = "stable",
androidPlatforms: Set<String> = ANDROID_PLATFORMS,
): List<AvailableUpdate> {
val latestByApp = catalog
.filter { row ->
(row.channel == null || row.channel == channel) &&
platformMatches(row.platform, androidPlatforms) &&
(row.mime == null || row.mime == ANDROID_MIME)
}
.groupBy { it.appId }
.mapValues { (_, rows) -> rows.maxBy { it.versionCode } }
return installed.mapNotNull { app ->
val row = latestByApp[app.packageId] ?: return@mapNotNull null
if (row.versionCode <= app.versionCode) return@mapNotNull null
if (row.certificateHash != null &&
row.certificateHash !in app.certificateHashes
) {
return@mapNotNull null
}
AvailableUpdate(
appId = app.packageId,
name = row.name,
installedVersion = app.versionName,
installedVersionCode = app.versionCode,
availableVersion = row.version,
availableVersionCode = row.versionCode,
channel = row.channel,
)
}.sortedBy { it.name.lowercase() }
}
private fun platformMatches(platform: String?, androidPlatforms: Set<String>): Boolean {
if (platform.isNullOrBlank()) return true
return platform.split(',').any { it in androidPlatforms || it.startsWith("android") }
}
const val ANDROID_MIME = "application/vnd.android.package-archive"
val ANDROID_PLATFORMS = setOf(
"android-arm64-v8a",
"android-armeabi-v7a",
"android-x86_64",
"android-x86",
"android",
)
}
@@ -0,0 +1,149 @@
package dev.zapstore.app.catalogsync
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
data class CatalogState(
val catalog: String,
val epoch: Long,
val schemaVersion: Int,
val searchModel: String,
val generation: Long,
)
class ZapstoreDatabase private constructor(
private val path: String,
) : AutoCloseable {
private val lock = ReentrantLock()
private var connection: SQLiteConnection = openConnection(path)
val file: File get() = File(path)
fun <T> withConnection(block: (SQLiteConnection) -> T): T = lock.withLock {
block(connection)
}
fun transaction(block: (SQLiteConnection) -> Unit) = lock.withLock {
connection.execSQL("BEGIN IMMEDIATE")
try {
block(connection)
connection.execSQL("COMMIT")
} catch (failure: Throwable) {
runCatching { connection.execSQL("ROLLBACK") }
throw failure
}
}
fun catalogState(catalog: String = CatalogSchema.CATALOG): CatalogState? = withConnection { db ->
db.prepare("SELECT catalog, epoch, schema_version, search_model, generation FROM catalog_state WHERE catalog = ?").use { statement ->
statement.bindText(1, catalog)
if (!statement.step()) return@withConnection null
CatalogState(
catalog = statement.getText(0),
epoch = statement.getLong(1),
schemaVersion = statement.getInt(2),
searchModel = statement.getText(3),
generation = statement.getLong(4),
)
}
}
fun writeCatalogState(state: CatalogState) = withConnection { db ->
upsertCatalogState(db, state)
}
fun eventRowCount(): Long = withConnection { db ->
db.prepare("SELECT COUNT(*) FROM events").use { statement ->
check(statement.step()) { "event count query returned no row" }
statement.getLong(0)
}
}
fun pauseAndReplace(snapshot: File) = lock.withLock {
connection.close()
val target = File(path)
val backup = File("$path.bak")
if (target.exists()) {
backup.delete()
if (!target.renameTo(backup)) {
connection = openConnection(path)
error("could not move the active catalog aside")
}
}
try {
if (!snapshot.renameTo(target) && !snapshot.copyTo(target, overwrite = true).exists()) {
error("could not install the snapshot")
}
snapshot.delete()
connection = openConnection(path)
backup.delete()
} catch (failure: Throwable) {
if (backup.exists()) {
target.delete()
backup.renameTo(target)
}
connection = openConnection(path)
throw failure
}
}
override fun close() = lock.withLock {
connection.close()
}
companion object {
fun open(path: String): ZapstoreDatabase {
File(path).parentFile?.mkdirs()
return ZapstoreDatabase(path).also { it.withConnection(::applySchema) }
}
fun createEmpty(path: String): ZapstoreDatabase {
File(path).parentFile?.mkdirs()
File(path).delete()
val database = open(path)
database.writeCatalogState(
CatalogState(
catalog = CatalogSchema.CATALOG,
epoch = 0,
schemaVersion = CatalogSchema.VERSION,
searchModel = CatalogSchema.SEARCH_MODEL,
generation = 0,
),
)
return database
}
internal fun applySchema(connection: SQLiteConnection) {
connection.execSQL("PRAGMA foreign_keys = ON")
CatalogSchema.CREATE_STATEMENTS.forEach(connection::execSQL)
}
internal fun upsertCatalogState(connection: SQLiteConnection, state: CatalogState) {
connection.prepare(
"""
INSERT INTO catalog_state(catalog, epoch, schema_version, search_model, generation)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(catalog) DO UPDATE SET
epoch = excluded.epoch,
schema_version = excluded.schema_version,
search_model = excluded.search_model,
generation = excluded.generation
""".trimIndent(),
).use { statement ->
statement.bindText(1, state.catalog)
statement.bindLong(2, state.epoch)
statement.bindLong(3, state.schemaVersion.toLong())
statement.bindText(4, state.searchModel)
statement.bindLong(5, state.generation)
statement.step()
}
}
private fun openConnection(path: String): SQLiteConnection =
BundledSQLiteDriver().open(path)
}
}
@@ -18,8 +18,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -45,8 +43,10 @@ fun HomeScreen(
onSearchQueryChanged: (String) -> Unit,
onSearchSubmitted: () -> Unit,
onSearchCleared: () -> Unit,
onNotificationsClick: () -> Unit = {},
notificationCount: Int = 0,
onUpdatesClick: () -> Unit = {},
updateCount: Int = 0,
databaseRowCount: Long = 0,
catalogError: String? = null,
onStackClick: (String) -> Unit,
onAppClick: (identifier: String, author: String) -> Unit,
onProfileClick: (String) -> Unit = {},
@@ -70,6 +70,18 @@ fun HomeScreen(
.background(ZapCanvas)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Text(
text = buildString {
append(stringResource(R.string.database_rows, databaseRowCount))
if (!catalogError.isNullOrBlank()) {
append(" · ")
append(catalogError)
}
},
color = ZapTextTertiary,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
@@ -117,16 +129,18 @@ fun HomeScreen(
)
Box(contentAlignment = Alignment.TopEnd) {
IconButton(
onClick = onNotificationsClick,
modifier = Modifier.testTag("notificationsButton"),
onClick = onUpdatesClick,
modifier = Modifier.testTag("updatesButton"),
) {
Icon(
imageVector = Icons.Filled.Notifications,
contentDescription = stringResource(R.string.notifications),
painter = painterResource(R.drawable.ic_launcher),
contentDescription = stringResource(R.string.updates),
modifier = Modifier.size(24.dp),
)
}
if (updateCount > 0) {
Text(
text = notificationCount.toString(),
text = updateCount.toString(),
color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
@@ -134,10 +148,11 @@ fun HomeScreen(
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error)
.padding(horizontal = 4.dp, vertical = 1.dp)
.testTag("notificationBadge"),
.testTag("updatesBadge"),
)
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
@@ -15,7 +15,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -52,20 +51,10 @@ fun StackDetailScreen(
}
item {
Text(
text = stack.name,
style = MaterialTheme.typography.displaySmall,
)
}
item {
StatusText(
stack.description.ifBlank {
pluralStringResource(
R.plurals.curated_apps,
stack.appAddresses.size,
stack.appAddresses.size,
)
},
StackCard(
stack = stack,
appsByAddress = state.appsByAddress,
modifier = Modifier.testTag("stack:${stack.event.id}"),
)
}
item {
@@ -80,6 +69,7 @@ fun StackDetailScreen(
) { app ->
AppCard(
app = app,
release = state.releasesByAddress[app.address],
onClick = { onAppClick(app.identifier, app.event.pubKey) },
onProfileClick = { onProfileClick(app.event.pubKey) },
repository = repository,
@@ -0,0 +1,110 @@
package dev.zapstore.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.zapstore.app.catalogsync.CatalogSyncUiState
import java.text.DateFormat
import java.util.Date
@Composable
fun UpdatesScreen(
state: CatalogSyncUiState,
onSync: () -> Unit,
onAppClick: (identifier: String, author: String?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.background(ZapCanvas)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.updates),
style = MaterialTheme.typography.displaySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
StatusText(stringResource(R.string.catalog_epoch, state.epoch))
state.lastSyncedAtMillis?.let { synced ->
StatusText(
stringResource(
R.string.last_synced,
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(synced)),
),
)
}
}
FilledTonalButton(
onClick = onSync,
enabled = !state.syncing,
modifier = Modifier.testTag("syncCatalog"),
) {
Text(stringResource(if (state.syncing) R.string.syncing else R.string.sync_now))
}
}
state.error?.let { StatusText(it, Modifier.testTag("updatesError")) }
if (state.syncing && state.availableUpdates.isEmpty()) {
LoadingIndicator()
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.availableUpdates.isEmpty() && !state.syncing) {
item {
StatusText(
stringResource(R.string.no_updates),
Modifier.testTag("noUpdates"),
)
}
}
items(state.availableUpdates, key = { it.appId }) { update ->
Column(
modifier = Modifier
.fillMaxWidth()
.testTag("update:${update.appId}"),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(update.name, style = MaterialTheme.typography.titleMedium)
EvidenceText(update.appId, color = ZapTextSecondary)
StatusText(
stringResource(
R.string.update_version,
update.installedVersion,
update.availableVersion,
),
)
}
}
}
}
}
+8
View File
@@ -4,6 +4,14 @@
<string name="clear_search">Clear search</string>
<string name="close">Close</string>
<string name="notifications">Notifications</string>
<string name="updates">Updates</string>
<string name="no_updates">All catalog apps are up to date.</string>
<string name="sync_now">Sync</string>
<string name="syncing">Syncing</string>
<string name="catalog_epoch">Catalog epoch %1$d</string>
<string name="last_synced">Last sync %1$s</string>
<string name="update_version">%1$s → %2$s</string>
<string name="database_rows">%1$d database rows</string>
<string name="no_notifications">You are all caught up.</string>
<string name="curated_stacks">Curated stacks</string>
<string name="latest_releases">Latest releases</string>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -30,9 +30,16 @@ class ViewModelsTest {
@Test
fun `catalog defaults to brief one-shot freshness and profiles use one day`() {
val options = catalogQueryOptions()
if (Catalog.catalogLocalOnly) {
assertEquals(SourceMode.Local, options.sourceMode)
assertEquals(null, options.remoteMode)
assertEquals(null, options.cachedFor)
assertTrue(options.relays.isEmpty())
} else {
assertEquals(SourceMode.LocalAndRemote, options.sourceMode)
assertTrue(options.remoteMode is RemoteMode.OneShot)
assertEquals(30.seconds, options.cachedFor)
}
assertEquals(1.days, PROFILE_CACHE_DURATION)
val forced = catalogQueryOptions(cachedFor = null)
@@ -377,6 +384,74 @@ class ViewModelsTest {
assertEquals("dev.example.app", release.appIdentifier)
}
@Test
fun `standalone 3063 carries release notes and defaults channel to main`() {
val release = ReleaseInfo(
event(
kind = Catalog.assetKind,
tags = arrayOf(
arrayOf("i", "dev.example.app"),
arrayOf("version", "1.2.3"),
),
content = "Fixed a crash",
),
)
assertEquals("dev.example.app", release.appIdentifier)
assertEquals("1.2.3", release.version)
assertEquals("Fixed a crash", release.notes)
assertEquals("main", release.channel)
}
@Test
fun `30063 outranks a 3063 with the same created at`() {
val grouped = ReleaseInfo(
event(
id = "bb",
kind = Catalog.releaseKind,
createdAt = 100,
tags = arrayOf(arrayOf("i", "dev.example.app"), arrayOf("version", "1.0")),
content = "Grouped notes",
),
)
val asset = ReleaseInfo(
event(
id = "aa",
kind = Catalog.assetKind,
createdAt = 100,
tags = arrayOf(arrayOf("i", "dev.example.app"), arrayOf("version", "1.0")),
content = "Asset notes",
),
)
assertEquals(grouped, preferredRelease(asset, grouped))
assertEquals(grouped, preferredRelease(grouped, asset))
assertEquals(true, grouped.outranks(asset))
}
@Test
fun `30063 outranks a newer 3063 for the same version`() {
val grouped = ReleaseInfo(
event(
id = "rel",
kind = Catalog.releaseKind,
createdAt = 100,
tags = arrayOf(arrayOf("i", "dev.example.app"), arrayOf("version", "1.0")),
),
)
val asset = ReleaseInfo(
event(
id = "asset",
kind = Catalog.assetKind,
createdAt = 200,
tags = arrayOf(arrayOf("i", "dev.example.app"), arrayOf("version", "1.0")),
),
)
assertEquals(grouped, preferredRelease(asset, grouped))
assertEquals(false, asset.outranks(grouped))
}
@Test
fun `app detail zap receipts resolve the app author outbox`() = runTest {
var outboxAuthors: List<String>? = null
@@ -404,6 +479,31 @@ class ViewModelsTest {
assertEquals(Catalog.defaultZapRelays, outboxRelays)
}
@Test
fun `app detail loads latest release from release and asset kinds`() = runTest {
val filters = mutableListOf<Filter>()
AppDetailViewModel(
FakeCatalogRepository(
localQuery = { filter ->
filters += filter
emptyFlow()
},
),
SavedStateHandle(
mapOf(
APP_IDENTIFIER_ARGUMENT to "dev.example.app",
APP_AUTHOR_ARGUMENT to "a".repeat(64),
),
),
)
runCurrent()
val releaseFilter = filters.single { Catalog.releaseKind in it.kinds.orEmpty() }
assertEquals(Catalog.releaseKinds, releaseFilter.kinds)
assertEquals(mapOf("i" to listOf("dev.example.app")), releaseFilter.tags)
assertEquals(listOf("a".repeat(64)), releaseFilter.authors)
}
@Test
fun `app detail zap and asset queries are bounded`() = runTest {
val filters = mutableListOf<Filter>()
@@ -515,12 +615,13 @@ private fun event(
kind: Int,
createdAt: Long = 1_750_000_000,
tags: Array<Array<String>> = emptyArray(),
content: String = "",
): Event = Event(
id = id,
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = "",
content = content,
sig = "2".repeat(128),
)
@@ -0,0 +1,48 @@
package dev.zapstore.app.catalogsync
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CatalogSyncTest {
@Test
fun `updates request json has only public fields`() {
val json = UpdatesRequest(
protocol = 1,
catalog = "default",
schemaVersion = 1,
epoch = 0,
searchModel = CatalogSchema.SEARCH_MODEL,
).toJson()
assertTrue(json.contains("\"protocol\":1"))
assertTrue(json.contains("\"catalog\":\"default\""))
assertTrue(json.contains("\"schema_version\":1"))
assertTrue(json.contains("\"epoch\":0"))
assertTrue(json.contains("\"search_model\":\"${CatalogSchema.SEARCH_MODEL}\""))
assertTrue(!json.contains("installed"))
assertTrue(!json.contains("search\""))
}
@Test
fun `local matcher keeps lineage and newer version codes`() {
val installed = listOf(
InstalledApp("dev.one", 10, "1.0", setOf("aa")),
InstalledApp("dev.two", 20, "2.0", setOf("bb")),
InstalledApp("dev.three", 5, "0.5", setOf("cc")),
)
val catalog = listOf(
CatalogAssetRow("dev.one", "One", "1.1", 11, "stable", "android-arm64-v8a", null, "aa", LocalUpdateMatcher.ANDROID_MIME),
CatalogAssetRow("dev.two", "Two", "2.1", 21, "stable", "android", null, "zz", LocalUpdateMatcher.ANDROID_MIME),
CatalogAssetRow("dev.three", "Three", "0.4", 4, "stable", "android", null, "cc", LocalUpdateMatcher.ANDROID_MIME),
)
val updates = LocalUpdateMatcher.match(installed, catalog)
assertEquals(listOf("dev.one"), updates.map { it.appId })
assertEquals("1.1", updates.single().availableVersion)
}
@Test
fun `hex helpers round-trip`() {
val hex = "0a1b2c3d"
assertEquals(hex, hex.hexToBytes().toHex())
}
}