mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Sync a compact local catalog and surface available updates.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Project-specific
|
||||
reference/
|
||||
.android-sdk/
|
||||
src/main/assets/catalog.db
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,8 +408,21 @@ 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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user