diff --git a/Makefile b/Makefile index 6519a63..e0ad56b 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,6 @@ GRADLE ?= ./gradlew ANDROID_SDK_ROOT ?= $(ANDROID_HOME) ADB ?= $(ANDROID_SDK_ROOT)/platform-tools/adb -AVDMANAGER ?= $(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/avdmanager -EMULATOR ?= $(ANDROID_SDK_ROOT)/emulator/emulator -AVD_NAME ?= Pixel_9 -SYSTEM_IMAGE ?= system-images;android-35;google_apis;arm64-v8a -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 @@ -15,16 +9,33 @@ KEY_ALIAS ?= KEY_PASSWORD ?= $(KEYSTORE_PASSWORD) DEBUG_KEYSTORE ?= $(HOME)/.android/debug.keystore -ZSP_CATALOG_DB ?= ../zsp/testdata/catalog/catalog.db -ASSETS_CATALOG_DB := src/main/assets/catalog.db +ASSETS_CATALOG_BUNDLE := src/main/assets/bundle-0-1.tar.zst +RELAY_PORT := 3334 -.PHONY: build release install run deploy refresh emulator bundle-catalog +.PHONY: build release install run run-release deploy refresh vendor unbundle-catalog -bundle-catalog: $(ASSETS_CATALOG_DB) +# Drop leftover seeds so a build without CATALOG_BUNDLE does not package them. +unbundle-catalog: + rm -f "$(ASSETS_CATALOG_BUNDLE)" src/main/assets/catalog-delta.tar.zst -$(ASSETS_CATALOG_DB): $(ZSP_CATALOG_DB) +ifeq ($(CATALOG_BUNDLE),) +build install release: unbundle-catalog +else +.PHONY: $(ASSETS_CATALOG_BUNDLE) +$(ASSETS_CATALOG_BUNDLE): + @test -f "$(CATALOG_BUNDLE)" || { echo "CATALOG_BUNDLE not found: $(CATALOG_BUNDLE)" >&2; exit 1; } @mkdir -p src/main/assets - cp "$(ZSP_CATALOG_DB)" "$@" + cp "$(CATALOG_BUNDLE)" "$@" + +build install release: $(ASSETS_CATALOG_BUNDLE) +endif + +# Rebuild libarti_android.so into src/main/jniLibs/arm64-v8a. +# Needs rustup, the cargo-ndk / NDK versions pinned under tools/arti-build/, +# and a later commit of the produced .so files. +vendor: + ./tools/arti-build/build-arti.sh + $(GRADLE) verifyArtiAbis build: $(GRADLE) assembleDebug @@ -59,12 +70,18 @@ release: -Pandroid.injected.signing.key.alias="$$alias" \ -Pandroid.injected.signing.key.password="$$keypass" +run-release: release + @$(ADB) reverse tcp:$(RELAY_PORT) tcp:$(RELAY_PORT) || true + $(ADB) install -r --no-incremental build/outputs/apk/release/zapstore-release.apk + $(ADB) shell am force-stop $(PACKAGE) + $(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY) + install: $(GRADLE) installDebug + $(ADB) shell pm clear $(PACKAGE) run: install - @$(ADB) reverse tcp:3334 tcp:3334 || true - @$(ADB) reverse tcp:3336 tcp:3336 || true + @$(ADB) reverse tcp:$(RELAY_PORT) tcp:$(RELAY_PORT) || true @$(ADB) shell am force-stop $(PACKAGE) @$(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY) @if [ -t 0 ]; then \ @@ -84,17 +101,3 @@ deploy: run refresh: $(ADB) shell am force-stop $(PACKAGE) $(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY) - -emulator: - @if ! $(AVDMANAGER) list avd -c | tr -d '\r' | awk '$$0 == "$(AVD_NAME)" { found=1 } END { exit !found }'; then \ - echo "Creating Android Virtual Device $(AVD_NAME)..."; \ - echo no | $(AVDMANAGER) create avd -n "$(AVD_NAME)" -k "$(SYSTEM_IMAGE)" -d pixel_9 --force; \ - fi - @if [ -f "$(AVD_CONFIG)" ]; then \ - if grep -qE '^hw\.keyboard[[:space:]]*=[[:space:]]*no' "$(AVD_CONFIG)"; then \ - sed -i '' 's/^hw\.keyboard[[:space:]]*=[[:space:]]*no/hw.keyboard = yes/' "$(AVD_CONFIG)"; \ - elif ! grep -qE '^hw\.keyboard' "$(AVD_CONFIG)"; then \ - echo 'hw.keyboard = yes' >> "$(AVD_CONFIG)"; \ - fi; \ - fi - $(EMULATOR) -avd "$(AVD_NAME)" diff --git a/README.md b/README.md index aa6ae44..b425b6a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The production APK is still published from the [Flutter client](https://github.c - JDK **21** - Android SDK (compile / target API **37**, min SDK **29**) -- A device or emulator for `make run` +- An **arm64-v8a** device for `make run` (this is the only ABI the app ships). ## Build from source @@ -28,14 +28,28 @@ The production APK is still published from the [Flutter client](https://github.c make build ``` -That runs `./gradlew assembleDebug`. The debug APK lands in `build/outputs/apk/debug/`. +That runs `./gradlew assembleDebug`. The debug APK lands in `build/outputs/apk/debug/`. Set `CATALOG_BUNDLE` to a `tar.zst` seed and `make build`, `make run`, and `make release` copy it to `src/main/assets/bundle-0-1.tar.zst`. Without it, the APK ships no catalog bundle. First launch then requests `GET /deltas?from=0` from `wss://brelay.zapstore.dev` and imports that bundle. + +Tor uses a size-optimized `libarti_android.so` from `tools/arti-build` (Amethyst's JNI wrapper, zapstore package names). Rebuild the arm64-v8a library and commit it when bumping Arti: ```bash -make emulator # Pixel_9 AVD (created on first run) -make run # installDebug, launch, then press r to rebuild +make vendor ``` -Or call Gradle directly: +That needs rustup, `cargo-ndk` at the version in `tools/arti-build/CARGO_NDK_VERSION`, and the NDK revision in `tools/arti-build/ANDROID_NDK_VERSION`. + +```bash +make run # installDebug, launch, then press r to rebuild +make run-release # assembleRelease (R8), install, launch +``` + +`make install` clears app data and embeds `CATALOG_BUNDLE` when that path is set. `make build` only builds. + +The catalog relay URLs and signer come from that bundle's signed manifest, not from the build. `make run-release` reverses port 3334 so a bundle that lists `ws://127.0.0.1:3334` can reach a relay on the host. + +Release builds run R8 (minify + optimize + shrink resources, English locales only). Mapping lands in `build/outputs/mapping/release/`. + +Or call Gradle directly (a seed in `src/main/assets/` is packaged if present): ```bash ./gradlew assembleDebug @@ -43,7 +57,7 @@ Or call Gradle directly: ./gradlew test ``` -`./gradlew test` runs unit tests for the app and `iolite`. Instrumented UI tests need a connected device or emulator: +`./gradlew test` runs unit tests for the app and `iolite`. Instrumented UI tests need a connected device: ```bash ./gradlew connectedDebugAndroidTest @@ -51,8 +65,13 @@ Or call Gradle directly: ## Layout -- `src/main/kotlin/dev/zapstore/app/` — Compose UI, navigation, view models, and catalog wiring -- `iolite/` — local-first Nostr client (SQLite event store, relay sessions, outbox routing) on top of [Quartz](https://github.com/vitorpamplona/amethyst) +- `src/main/kotlin/dev/zapstore/app/` — `ZapstoreApplication` (singletons), `MainActivity` (routes), `AppConfig` (build-time endpoints and query options) + - `screens/` — one `*Screen.kt` + `*ViewModel.kt` per destination (Home, AppDetail, StackDetail, Profile, Updates, Settings) + - `components/` — shared UI on Iolite records (`AppCard`, `StackCard`, `AppList`, `ProfileImage`, primitives) + - `catalog/` — `CatalogSync` (foreground `GET /deltas`) and installed-package matching + - `transport/` — Tor/direct network runtime +- `tools/arti-build/` — reproducible Arti JNI build; `make vendor` writes `src/main/jniLibs/arm64-v8a/libarti_android.so` +- `iolite/` — local-first Nostr client: SQLite rows derived from verified events, relay sessions, typed queries ## Contributing diff --git a/build.gradle.kts b/build.gradle.kts index 01e977b..7ca5781 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,8 @@ +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest + plugins { alias(libs.plugins.android.application) alias(libs.plugins.android.library) apply false @@ -16,10 +21,60 @@ android { versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters += "arm64-v8a" + } + } + + // The release profile in tools/arti-build already strips libarti_android.so. + // AGP's extra llvm-strip only rewrites .comment and would break a + // reproducible match against the committed jniLibs copy. + packaging { + jniLibs { + keepDebugSymbols += "**/libarti_android.so" + } + resources { + excludes += setOf( + "META-INF/**/LICENSE*", + "META-INF/**/NOTICE*", + "META-INF/AL2.0", + "META-INF/LGPL2.1", + "META-INF/*.kotlin_module", + "kotlin/**", + "DebugProbesKt.bin", + ) + } + } + + androidResources { + localeFilters += "en" + } + + buildTypes { + // Debug builds never open relay sockets. Catalog sync uses CATALOG_RELAY. + debug { + buildConfigField("boolean", "RELAYS_ENABLED", "false") + buildConfigField("String", "CATALOG_RELAY", "\"wss://brelay.zapstore.dev\"") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + buildConfigField("boolean", "RELAYS_ENABLED", "true") + buildConfigField("String", "CATALOG_RELAY", "\"wss://brelay.zapstore.dev\"") + } } buildFeatures { compose = true + buildConfig = true + } + + sourceSets.named("main") { + assets.directories.add(layout.projectDirectory.dir(".tools/leaf-ir-assets").asFile.absolutePath) } } @@ -27,12 +82,89 @@ kotlin { jvmToolchain(21) } +tasks.register("verifyArtiAbis") { + val jniLibs = layout.projectDirectory.dir("src/main/jniLibs") + val abis = listOf("arm64-v8a") + doLast { + abis.forEach { abi -> + val so = jniLibs.file("$abi/libarti_android.so").asFile + check(so.isFile) { + "Missing ${so.path} — run make vendor" + } + } + } +} + +tasks.matching { it.name.startsWith("assembleRelease") }.configureEach { + dependsOn("verifyArtiAbis") +} + +val downloadLeafModel = tasks.register("downloadLeafModel") { + val dest = layout.projectDirectory.dir(".tools/leaf-ir-assets/leaf-ir-v1") + inputs.property( + "checksums", + listOf( + "07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3", + "b3e7c0e1ef65e39a5ef1ca3bc5e4aef5feafb6e204bd161503145ed062f12c69", + "8c08cb4ecc00bad721b117a85738c537ceb9b85b3eedc52d9c6906fddaa55718", + "e77ea96a124230e23e5bac8e26c3ffe5410154973d8635673fa0220b06c13f8b", + ), + ) + outputs.dir(dest) + doLast { + val root = dest.asFile + root.mkdirs() + val rev = "4262131b32c3182bd06e67e92ae69d7bd66e0c5c" + val base = "https://huggingface.co/MongoDB/mdbr-leaf-ir/resolve/$rev/" + listOf( + Triple("vocab.txt", base + "vocab.txt", "07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3"), + Triple("dense.safetensors", base + "2_Dense/model.safetensors", "b3e7c0e1ef65e39a5ef1ca3bc5e4aef5feafb6e204bd161503145ed062f12c69"), + Triple("model_quantized.onnx", base + "onnx/model_quantized.onnx", "8c08cb4ecc00bad721b117a85738c537ceb9b85b3eedc52d9c6906fddaa55718"), + Triple("model_quantized.onnx_data", base + "onnx/model_quantized.onnx_data", "e77ea96a124230e23e5bac8e26c3ffe5410154973d8635673fa0220b06c13f8b"), + ).forEach { (name, url, sum) -> + val file = root.resolve(name) + if (file.isFile && sha256(file) == sum) return@forEach + val tmp = root.resolve("$name.tmp") + val connection = URI(url).toURL().openConnection() as HttpURLConnection + connection.instanceFollowRedirects = true + connection.setRequestProperty("User-Agent", "zapstore-android") + connection.connectTimeout = 30_000 + connection.readTimeout = 180_000 + connection.inputStream.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + check(sha256(tmp) == sum) { "$name sha256 mismatch" } + check(tmp.renameTo(file)) { "could not replace $name" } + } + } +} + +tasks.matching { it.name == "preBuild" || it.name.endsWith("UnitTest") || (it.name.startsWith("merge") && it.name.endsWith("Assets")) }.configureEach { + dependsOn(downloadLeafModel) +} + +configurations.configureEach { + if (name.endsWith("UnitTestRuntimeClasspath")) { + exclude(group = "com.microsoft.onnxruntime", module = "onnxruntime-android") + } +} + +fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + dependencies { implementation(project(":iolite")) implementation(platform(libs.compose.bom)) implementation(libs.activity.compose) implementation(libs.compose.foundation) - implementation(libs.compose.material.icons.extended) implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) @@ -46,11 +178,14 @@ dependencies { implementation(libs.okhttp) implementation(libs.sqlite) implementation(libs.sqlite.bundled) + implementation(libs.onnxruntime.android) + testImplementation("org.json:json:20250517") testImplementation(libs.junit) testImplementation(libs.coroutines.test) testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.sqlite.bundled) + testImplementation(libs.onnxruntime) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.test.junit) diff --git a/gradle.properties b/gradle.properties index 1298cb1..9fb5f68 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,3 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 +android.enableR8.fullMode=true +android.r8.strictFullModeForKeepRules=true diff --git a/proguard-rules.pro b/proguard-rules.pro new file mode 100644 index 0000000..708fea2 --- /dev/null +++ b/proguard-rules.pro @@ -0,0 +1,11 @@ +# JNI: Rust looks up ArtiNative by class name and calls ArtiLogCallback.onLogLine. +-keep class dev.zapstore.app.transport.ArtiNative { native ; } +-keep class dev.zapstore.app.transport.ArtiLogCallback { void onLogLine(java.lang.String); } + +# secp256k1 / zstd / sqlite JNI entry points (also covered by their AARs). +-keepclasseswithmembernames,includedescriptorclasses class * { + native ; +} +# Native GetFieldID("srcPos"|"dstPos", "J") — keeping native methods is not enough. +-keep class com.github.luben.zstd.** { *; } +-keep class ai.onnxruntime.** { *; } diff --git a/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt b/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt index 54bf12f..327f667 100644 --- a/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt +++ b/src/androidTest/kotlin/dev/zapstore/app/ScreensTest.kt @@ -1,13 +1,33 @@ package dev.zapstore.app import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performImeAction -import com.vitorpamplona.quartz.nip01Core.core.Event +import androidx.compose.ui.test.performScrollToKey +import androidx.compose.ui.test.performSemanticsAction +import dev.zapstore.app.catalog.AvailableUpdate +import dev.zapstore.app.catalog.SyncStatus +import dev.zapstore.app.components.AppCard +import dev.zapstore.app.components.AppListState +import dev.zapstore.app.screens.AppDetailScreen +import dev.zapstore.app.screens.AppDetailUiState +import dev.zapstore.app.screens.CommentsError +import dev.zapstore.app.screens.CommentsState +import dev.zapstore.app.screens.threadComments +import dev.zapstore.app.screens.HomeScreen +import dev.zapstore.app.screens.HomeUiState +import dev.zapstore.app.screens.SettingsScreen +import dev.zapstore.app.screens.SettingsUiState +import dev.zapstore.app.screens.UpdatesScreen +import dev.zapstore.app.screens.UpdatesUiState +import dev.zapstore.iolite.AppCoordinate +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.CommentRecord +import dev.zapstore.iolite.StackRecord +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -18,71 +38,112 @@ class ScreensTest { val composeRule = createComposeRule() @Test - fun homeSearchAndStackCallbacksAreWired() { - var submitted = false + fun homeCallbacksAreWired() { var cleared = false var updatesOpened = false var openedStack: String? = null - val stack = StackInfo( - event( - id = "stack-id", - kind = Catalog.appStackKind, - tags = arrayOf(arrayOf("name", "Privacy")), - ), + var openedApp: String? = null + val stack = StackRecord( + pubkey = AUTHOR, + identifier = "privacy", + eventId = "e".repeat(64), + name = "Privacy", + description = "", + apps = listOf(AppCoordinate(AUTHOR, "dev.zap")), + updatedAt = 1_750_000_000, ) + val app = app("dev.zap", "Zap") composeRule.setContent { ZapstoreTheme { HomeScreen( state = HomeUiState( searchQuery = "zap", + submittedQuery = "zap", + searchResults = listOf(app), stacks = listOf(stack), stacksLoading = false, + feed = AppListState(loading = false), ), onSearchQueryChanged = {}, - onSearchSubmitted = { submitted = true }, + onSearchSubmitted = {}, onSearchCleared = { cleared = true }, + onAppClick = { openedApp = it.appId }, + onStackClick = { openedStack = it.identifier }, onUpdatesClick = { updatesOpened = true }, - onStackClick = { openedStack = it }, - onAppClick = { _, _ -> }, ) } } - composeRule.onNodeWithTag("searchField").performImeAction() - composeRule.onNodeWithText("×").performClick() - composeRule.onNodeWithTag("searchField").assertIsFocused() + composeRule.onNodeWithTag("clearSearch").performClick() composeRule.onNodeWithTag("updatesButton").performClick() - composeRule.onNodeWithTag("stack:stack-id").performClick() + composeRule.onNodeWithTag("stack:privacy").performClick() + // The card centre lands on the author byline, so trigger the card's own click action. + composeRule.onNodeWithTag("searchResult:dev.zap").performSemanticsAction(SemanticsActions.OnClick) - assertTrue(submitted) assertTrue(cleared) assertTrue(updatesOpened) - assertEquals("stack-id", openedStack) + assertEquals("privacy", openedStack) + assertEquals("dev.zap", openedApp) + } + + @Test + fun homeFeedShowsItsLoadingFooter() { + composeRule.setContent { + ZapstoreTheme { + HomeScreen( + state = HomeUiState( + stacksLoading = false, + feed = AppListState(apps = listOf(app("dev.zap", "Zap")), loading = false, loadingMore = true), + ), + onSearchQueryChanged = {}, + onSearchSubmitted = {}, + onSearchCleared = {}, + onAppClick = {}, + onStackClick = {}, + ) + } + } + + composeRule.onNodeWithTag("app:dev.zap").assertIsDisplayed() + composeRule.onNodeWithTag("appListLoading").assertIsDisplayed() + } + + @Test + fun homeFeedRequestsMoreWhenScrolledNearEnd() { + var loadMoreCount = 0 + val apps = (0 until 20).map { app("app.$it", "App $it") } + composeRule.setContent { + ZapstoreTheme { + HomeScreen( + state = HomeUiState( + stacksLoading = false, + feed = AppListState(apps = apps, loading = false, canLoadMore = true), + ), + onSearchQueryChanged = {}, + onSearchSubmitted = {}, + onSearchCleared = {}, + onAppClick = {}, + onStackClick = {}, + onLoadMore = { loadMoreCount += 1 }, + ) + } + } + + composeRule.onNodeWithTag("homeList").performScrollToKey("app:app.19") + composeRule.waitUntil { loadMoreCount > 0 } + assertTrue(loadMoreCount > 0) } @Test fun appDetailExternalLinkCallbackIsWired() { var openedUrl: String? = null - val app = AppInfo( - event( - kind = Catalog.appKind, - tags = arrayOf( - arrayOf("d", "zap"), - arrayOf("name", "Zap"), - arrayOf("repository", "https://example.com/repo"), - ), - ), - ) + val app = app("dev.zap", "Zap", repository = "https://example.com/repo") composeRule.setContent { ZapstoreTheme { AppDetailScreen( - state = AppDetailUiState( - app = app, - appLoading = false, - releaseLoading = false, - ), + state = AppDetailUiState(app = app, loaded = true), onOpenUrl = { openedUrl = it }, ) } @@ -94,76 +155,250 @@ class ScreensTest { } @Test - fun appProfileCallbackIsWired() { + fun appDetailCommentsAreListed() { var openedProfile: String? = null - val app = AppInfo(event(kind = Catalog.appKind), hasVerifiedC1 = true) + val app = app("dev.zap", "Zap") + val comment = CommentRecord( + eventId = "c".repeat(64), + pubkey = AUTHOR, + createdAt = 1_750_000_000, + content = "Nice release", + appId = "dev.zap", + stack = null, + parentEventId = null, + ) composeRule.setContent { ZapstoreTheme { - AppCard( - app = app, - repository = null, - onClick = {}, - onProfileClick = { openedProfile = app.event.pubKey }, + AppDetailScreen( + state = AppDetailUiState( + app = app, + loaded = true, + comments = CommentsState(threads = threadComments(listOf(comment)), loading = false), + ), + onOpenUrl = {}, + onProfileClick = { openedProfile = it }, ) } } - composeRule.onNodeWithTag("profile:${app.event.pubKey}").performClick() - - assertEquals(app.event.pubKey, openedProfile) + composeRule.onNodeWithTag("comment:${comment.eventId}").assertIsDisplayed() + composeRule.onNodeWithText("Nice release").assertIsDisplayed() + composeRule.onNodeWithTag("profile:$AUTHOR").performClick() + assertEquals(AUTHOR, openedProfile) } @Test - fun releaseFeedShowsItsLoadingFooter() { - val app = AppInfo( - event( - id = "release-id", - kind = Catalog.appKind, - ), + fun appDetailCommentsAreThreaded() { + val app = app("dev.zap", "Zap") + val root = CommentRecord( + eventId = "a".repeat(64), + pubkey = AUTHOR, + createdAt = 1_750_000_000, + content = "Root comment", + appId = "dev.zap", + stack = null, + parentEventId = null, ) - val release = ReleaseInfo( - event( - id = "release-id", - kind = Catalog.releaseKind, - tags = arrayOf(arrayOf("i", "zap")), - ), + val reply = CommentRecord( + eventId = "b".repeat(64), + pubkey = AUTHOR, + createdAt = 1_750_000_100, + content = "Nested reply", + appId = "dev.zap", + stack = null, + parentEventId = root.eventId, ) composeRule.setContent { ZapstoreTheme { - HomeScreen( - state = HomeUiState( - releaseFeed = ReleaseFeedUiState( - entries = listOf(ReleaseFeedEntry(app, release)), - initialLoading = false, - loadingMore = true, - ), + AppDetailScreen( + state = AppDetailUiState( + app = app, + loaded = true, + comments = CommentsState(threads = threadComments(listOf(reply, root)), loading = false), ), - onSearchQueryChanged = {}, - onSearchSubmitted = {}, - onSearchCleared = {}, - onStackClick = {}, - onAppClick = { _, _ -> }, + onOpenUrl = {}, ) } } - composeRule.onNodeWithTag("app:release-id").assertIsDisplayed() - composeRule.onNodeWithTag("releaseLoading").assertIsDisplayed() + composeRule.onNodeWithTag("commentDepth:${root.eventId}:0").assertIsDisplayed() + composeRule.onNodeWithTag("commentDepth:${reply.eventId}:1").assertIsDisplayed() + composeRule.onNodeWithText("Root comment").assertIsDisplayed() + composeRule.onNodeWithText("Nested reply").assertIsDisplayed() + } + + @Test + fun appDetailCommentErrorOffersRetryAndSettings() { + var retried = false + var settingsOpened = false + val app = app("dev.zap", "Zap") + + composeRule.setContent { + ZapstoreTheme { + AppDetailScreen( + state = AppDetailUiState( + app = app, + loaded = true, + comments = CommentsState(loading = false, error = CommentsError.RelaysUnreachable), + ), + onOpenUrl = {}, + onRetryComments = { retried = true }, + onSettingsClick = { settingsOpened = true }, + ) + } + } + + composeRule.onNodeWithTag("commentsEmpty").assertIsDisplayed() + composeRule.onNodeWithTag("commentsRetry").performClick() + composeRule.onNodeWithTag("commentsNetworkSettings").performClick() + assertTrue(retried) + assertTrue(settingsOpened) + } + + @Test + fun appCardAuthorCallbackIsWired() { + var openedProfile: String? = null + val app = app("dev.zap", "Zap", proofPubkey = AUTHOR) + + composeRule.setContent { + ZapstoreTheme { + AppCard(app = app, author = null, onClick = {}, onAuthorClick = { openedProfile = it }) + } + } + + composeRule.onNodeWithTag("profile:$AUTHOR").performClick() + + assertEquals(AUTHOR, openedProfile) + } + + @Test + fun updatesListsAvailableUpdatesAndSyncs() { + var synced = false + val app = app("dev.zap", "Zap", version = "2.0") + + composeRule.setContent { + ZapstoreTheme { + UpdatesScreen( + state = UpdatesUiState( + status = SyncStatus(), + updates = listOf(AvailableUpdate(app, installedVersion = "1.0", installedVersionCode = 1)), + loaded = true, + ), + onSync = { synced = true }, + onAppClick = {}, + ) + } + } + + composeRule.onNodeWithTag("update:dev.zap").assertIsDisplayed() + composeRule.onNodeWithText("1.0 → 2.0").assertIsDisplayed() + composeRule.onNodeWithTag("syncCatalog").performClick() + assertTrue(synced) + } + + @Test + fun settingsShowsLastSyncDuration() { + composeRule.setContent { + ZapstoreTheme { + SettingsScreen( + state = SettingsUiState( + status = SyncStatus( + lastSyncedAtMillis = System.currentTimeMillis() - 3 * 60_000, + lastSyncDurationMillis = 2430, + lastSyncBytes = 1_234_567, + ), + ), + onNetworkModeChange = {}, + onSync = {}, + onWipe = {}, + ) + } + } + composeRule.onNodeWithTag("lastSyncAgo").assertIsDisplayed() + composeRule.onNodeWithText("Last sync 3 minutes ago").assertIsDisplayed() + composeRule.onNodeWithTag("lastSyncSize").assertIsDisplayed() + composeRule.onNodeWithText("transferred", substring = true).assertIsDisplayed() + composeRule.onNodeWithTag("lastSyncDuration").assertIsDisplayed() + composeRule.onNodeWithText("Last sync took", substring = true).assertIsDisplayed() + } + + @Test + fun settingsShowsLastSyncNotModified() { + composeRule.setContent { + ZapstoreTheme { + SettingsScreen( + state = SettingsUiState( + status = SyncStatus( + lastSyncedAtMillis = System.currentTimeMillis(), + lastSyncDurationMillis = 120, + lastSyncBytes = 0, + lastSyncNotModified = true, + ), + ), + onNetworkModeChange = {}, + onSync = {}, + onWipe = {}, + ) + } + } + composeRule.onNodeWithTag("lastSyncAgo").assertIsDisplayed() + composeRule.onNodeWithText("Last sync just now").assertIsDisplayed() + composeRule.onNodeWithTag("lastSyncNotModified").assertIsDisplayed() + composeRule.onNodeWithText("Already current (HTTP 304)").assertIsDisplayed() + } + + @Test + fun settingsWipeButtonIsWired() { + var wiped = false + composeRule.setContent { + ZapstoreTheme { + SettingsScreen(state = SettingsUiState(), onNetworkModeChange = {}, onSync = {}, onWipe = { wiped = true }) + } + } + composeRule.onNodeWithTag("wipeLocalDb").performClick() + assertTrue(wiped) + } + + @Test + fun settingsShowsAllNetworkModes() { + composeRule.setContent { + ZapstoreTheme { + SettingsScreen(state = SettingsUiState(), onNetworkModeChange = {}, onSync = {}, onWipe = {}) + } + } + composeRule.onNodeWithTag("networkTor").assertIsDisplayed() + composeRule.onNodeWithTag("networkDirect").assertIsDisplayed() + composeRule.onNodeWithTag("networkTorFallback").assertIsDisplayed() + composeRule.onNodeWithText("Tor only").assertIsDisplayed() + composeRule.onNodeWithText("Direct only").assertIsDisplayed() + composeRule.onNodeWithText("Tor with Direct fallback").assertIsDisplayed() } } -private fun event( - id: String = "a".repeat(64), - kind: Int, - tags: Array> = emptyArray(), -): Event = Event( - id = id, - pubKey = "1".repeat(64), +private val AUTHOR = "1".repeat(64) + +private fun app( + appId: String, + name: String, + version: String = "1.0", + repository: String? = null, + proofPubkey: String? = null, +): AppRecord = AppRecord( + id = appId.toByteArray(), + catalogId = 1, + appId = appId, + certificateHash = "a".repeat(64), createdAt = 1_750_000_000, - kind = kind, - tags = tags, - content = "", - sig = "2".repeat(128), + proofPubkey = proofPubkey, + eventPubkey = AUTHOR, + catalogManifestPubkey = null, + name = name, + summary = "$name summary", + repository = repository, + version = version, + versionCode = 1, + channel = null, + metadata = JSONObject(), ) diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 4b6b2ab..595a6a6 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + = generalRelays + "wss://brelay.zapstore.dev".normalizeRelayUrl() + + val profileCacheDuration = 1.days + val zapRetention = 90.days + + /** Kind 0: one shot, cached for a day, author relays added when known. */ + val profileQuery: QueryOptions = remoteOrLocal { + QueryOptions.localAndRemote(profileRelays, RemoteMode.OneShot(), cachedFor = profileCacheDuration, useAuthorRelays = true) + } + + /** Kind 9735: one shot, refreshed at most every few minutes. */ + val zapQuery: QueryOptions = remoteOrLocal { + QueryOptions.localAndRemote(writeRelays, RemoteMode.OneShot(), cachedFor = 5.minutes) + } + + /** Kind 1111: always LocalAndRemote so comments are fetched even when other relays stay local. */ + val commentQuery: QueryOptions = + QueryOptions.localAndRemote(commentRelays, RemoteMode.Stream, useAuthorRelays = true) + + /** Kind 0 for comment authors; same always-remote rule as [commentQuery]. */ + val commentProfileQuery: QueryOptions = + QueryOptions.localAndRemote(profileRelays, RemoteMode.OneShot(), cachedFor = profileCacheDuration, useAuthorRelays = true) + + private inline fun remoteOrLocal(remote: () -> QueryOptions): QueryOptions = + if (relaysEnabled) remote() else QueryOptions.local() + + private fun Set.toRelayUrls(): Set = map(String::normalizeRelayUrl).toSet() +} diff --git a/src/main/kotlin/dev/zapstore/app/Catalog.kt b/src/main/kotlin/dev/zapstore/app/Catalog.kt deleted file mode 100644 index 44c3a7f..0000000 --- a/src/main/kotlin/dev/zapstore/app/Catalog.kt +++ /dev/null @@ -1,469 +0,0 @@ -package dev.zapstore.app - -import android.content.Context -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 -import dev.zapstore.iolite.QueryOptions -import dev.zapstore.iolite.QueryPhase -import dev.zapstore.iolite.QueryState -import dev.zapstore.iolite.RemoteMode -import dev.zapstore.iolite.SourceMode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import okhttp3.Request -import okhttp3.OkHttpClient -import org.json.JSONObject -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration -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 - const val c1Kind = 30_509 - const val releaseKind = 30_063 - const val appKind = 32_267 - const val appStackKind = 30_267 - const val profileKind = 0 - const val relayListKind = 10_002 - const val zapReceiptKind = 9_735 - const val communityPubkey = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84" - val releaseKinds = listOf(releaseKind, assetKind) - val defaultRelays = setOf( - "wss://relay.primal.net", - "wss://relay.damus.io", - "wss://nos.lol", - ) - val defaultZapRelays = defaultRelays + relay -} - -val PROFILE_RELAYS = setOf(Catalog.relay, Catalog.profileRelay) -internal val DEFAULT_CACHE_DURATION = 30.seconds -internal val PROFILE_CACHE_DURATION = 1.days -private const val VERIFIED_ASSET_AND_C1_LIMIT = 500 - -fun catalogQueryOptions( - 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 = setOf(Catalog.relay), -): 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( - filter: Filter, - options: QueryOptions = catalogQueryOptions(), - ): Flow = query(listOf(filter), options) - - fun query( - filters: List, - options: QueryOptions = catalogQueryOptions(), - ): Flow - - /** - * Local-first query whose remote side expands onto the [authors]' NIP-65 read - * relays once resolved. [relays] is the fallback set used until (and unless) - * resolution lands; [unionWithFallback] controls whether resolved relays are - * added to the fallback set or replace it. - */ - fun queryWithOutbox( - filters: List, - authors: List, - cachedFor: Duration? = null, - relays: Set, - remoteMode: RemoteMode = RemoteMode.Stream, - unionWithFallback: Boolean = true, - ): Flow = query( - filters, - catalogQueryOptions( - remoteMode = remoteMode, - cachedFor = cachedFor, - relays = relays, - ), - ) - - /** - * Filters app rows using C1 proofs and the relay's APK verification. - * The relay is trusted for the certificate-to-APK association; the client - * enforces proof lifecycle and certificate-hash matching. - */ - suspend fun verifiedApps(apps: List): List { - if (apps.isEmpty()) return emptyList() - val authors = apps.map { it.event.pubKey }.toSet() - val filters = apps.map { app -> - Filter( - kinds = listOf(Catalog.assetKind), - tags = mapOf("i" to listOf(app.identifier)), - limit = VERIFIED_ASSET_AND_C1_LIMIT, - ) - } + Filter( - authors = authors.toList(), - kinds = listOf(Catalog.c1Kind), - limit = VERIFIED_ASSET_AND_C1_LIMIT, - ) - val state = query( - filters, - catalogQueryOptions(remoteMode = RemoteMode.OneShot()), - ).first { it.phase.isTerminal } - val verifiedAuthors = verifiedAppAuthors(state.items, state.items, authors) - return apps.map { it.copy(hasVerifiedC1 = it.event.pubKey in verifiedAuthors) } - } - - /** - * Latest kind-0 metadata for [pubkey]. Implementations may share and cache the - * upstream session so a feed of rows costs at most one query per author. - */ - fun profile(pubkey: String): Flow = queryWithOutbox( - listOf(Filter(authors = listOf(pubkey), kinds = listOf(Catalog.profileKind), limit = 1)), - authors = listOf(pubkey), - cachedFor = PROFILE_CACHE_DURATION, - relays = PROFILE_RELAYS, - remoteMode = RemoteMode.OneShot(), - unionWithFallback = false, - ).map { state -> state.items.maxByOrNull(Event::createdAt)?.let(::ProfileInfo) } - - /** - * Resolves the public identity behind an app. A C1 proof takes precedence over - * the kind-32267 signer; an app signed by the catalog relay has no byline. - */ - fun appAuthor(app: AppInfo): Flow = flow { emit(app.event.pubKey) } - - /** The author of the app's relay-validated C1 proof, if present. */ - fun c1Author(app: AppInfo): Flow = flow { emit(null) } - - fun refreshConnections() - - /** - * Enables or suspends all relay traffic for app foreground/background. - * Local data remains fully queryable while suspended. - */ - fun setRelayTrafficEnabled(enabled: Boolean) = Unit - - fun purpleQuartz(): Iolite? = null -} - -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>() - private val appAuthorFlows = ConcurrentHashMap>() - private val c1AuthorFlows = ConcurrentHashMap>() - private val relaySigner = MutableStateFlow(null) - private var client: Iolite? = null - private var outboxRouter: OutboxRouter? = null - @Volatile - private var relayTrafficEnabled = true - - init { - scope.launch(Dispatchers.IO) { - relaySigner.value = fetchRelaySigner() - } - } - - private fun client(): Iolite = - client ?: Iolite.create( - applicationContext, - 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) - } - - private fun router(): OutboxRouter = - outboxRouter ?: OutboxRouter( - client(), - bootstrapRelays = Catalog.defaultZapRelays.map(String::normalizeRelayUrl).toSet(), - ).also { outboxRouter = it } - - override fun queryWithOutbox( - filters: List, - authors: List, - cachedFor: Duration?, - relays: Set, - remoteMode: RemoteMode, - unionWithFallback: Boolean, - ): Flow { - if (Catalog.catalogLocalOnly) return query(filters, catalogQueryOptions()) - return router().queryWithOutbox( - filters, - authors, - fallbackRelays = relays.map(String::normalizeRelayUrl).toSet(), - cachedFor = cachedFor, - remoteMode = remoteMode, - unionWithFallback = unionWithFallback, - ) - } - - override fun profile(pubkey: String): Flow = - profileFlows.getOrPut(pubkey) { - super.profile(pubkey) - .distinctUntilChanged() - .shareIn(scope, SharingStarted.WhileSubscribed(PROFILE_SESSION_GRACE_MILLIS), replay = 1) - } - - override fun appAuthor(app: AppInfo): Flow = - appAuthorFlows.getOrPut(app.address) { - combine(c1Author(app), relaySigner) { c1Author, relayPubkey -> - val author = c1Author ?: app.event.pubKey - author.takeUnless { it == relayPubkey } - }.distinctUntilChanged() - } - - override fun c1Author(app: AppInfo): Flow = - c1AuthorFlows.getOrPut(app.address) { - flow { emit(resolveC1Author(app)) } - .shareIn(scope, SharingStarted.WhileSubscribed(PROFILE_SESSION_GRACE_MILLIS), replay = 1) - } - - override fun query( - filters: List, - options: QueryOptions, - ): Flow = client().query(filters = filters, options = options) - - override fun refreshConnections() { - client?.refreshConnections() - } - - override fun setRelayTrafficEnabled(enabled: Boolean) { - relayTrafficEnabled = enabled - client?.setRelayTrafficEnabled(enabled) - } - - override fun purpleQuartz(): Iolite? = client() - - private suspend fun resolveC1Author(app: AppInfo): String? { - return query( - listOf( - Filter( - authors = listOf(app.event.pubKey), - kinds = listOf(Catalog.c1Kind), - limit = VERIFIED_ASSET_AND_C1_LIMIT, - ), - ), - catalogQueryOptions(remoteMode = RemoteMode.OneShot()), - ).first { it.phase.isTerminal }.items - .asSequence() - .filter { it.kind == Catalog.c1Kind } - .sortedWith(compareByDescending { it.createdAt }.thenBy(Event::id)) - .firstOrNull() - ?.pubKey - } - - private fun fetchRelaySigner(): String? = runCatching { - val informationUrl = Catalog.relay - .replaceFirst("wss://", "https://") - .replaceFirst("ws://", "http://") - OkHttpClient().newCall( - Request.Builder() - .url(informationUrl) - .header("Accept", "application/nostr+json") - .build(), - ).execute().use { response -> - response.takeIf { it.isSuccessful } - ?.body - ?.string() - ?.let(::JSONObject) - ?.optString("pubkey") - ?.takeIf { Regex("[0-9a-f]{64}").matches(it) } - } - }.getOrNull() - - private companion object { - /** Keeps a profile session warm briefly after the last row leaves the screen. */ - const val PROFILE_SESSION_GRACE_MILLIS = 5 * 60 * 1000L - } -} - -data class AppInfo( - val event: Event, - val hasVerifiedC1: Boolean = false, -) { - val identifier: String get() = event.tagValue("d") ?: event.id - val name: String get() = event.tagValue("name") ?: identifier - val summary: String get() = event.tagValue("summary") ?: event.content - val iconUrl: String? get() = event.tagValue("icon") ?: event.tagValue("image") - val screenshots: List get() = event.tagValues("image") - val website: String? get() = event.tagValue("url") - val repository: String? get() = event.tagValue("repository") - val license: String? get() = event.tagValue("license") - val address: String get() = "${Catalog.appKind}:${event.pubKey}:$identifier" -} - -/** - * C1 proofs are APK-verified by the catalog relay. The client still checks the - * proof's lifecycle and matches its certificate hash to a fetched asset. - */ -fun verifiedAppAuthors( - assets: Iterable, - proofs: Iterable, - appAuthors: Set = emptySet(), - now: Long = System.currentTimeMillis() / 1_000, -): Set { - val currentProofs = proofs - .filter { it.kind == Catalog.c1Kind } - .groupBy { it.pubKey to it.tagValue("d") } - .mapNotNull { (key, events) -> - key.second?.let { - val newestTimestamp = events.maxOf(Event::createdAt) - key to events - .filter { event -> event.createdAt == newestTimestamp } - .minByOrNull(Event::id) - } - } - .mapNotNull { (key, event) -> event?.let { key to it } } - .toMap() - .filterValues { it.isWellFormedC1() && it.isActiveC1(now) } - - return assets - .filter { it.kind == Catalog.assetKind } - .mapNotNull { asset -> - val hash = asset.tagValue("apk_certificate_hash") ?: return@mapNotNull null - currentProofs.entries - .firstOrNull { (key, proof) -> - key.second == hash && - (asset.pubKey == proof.pubKey || - proof.tagValue("delegation") == asset.pubKey) && - (appAuthors.isEmpty() || proof.pubKey in appAuthors) - } - ?.key?.first - } - .toSet() -} - -private fun Event.isWellFormedC1(): Boolean { - if (tags.count { it.firstOrNull() == "d" } != 1) return false - if (tags.count { it.firstOrNull() == "signature" } != 1) return false - if (tags.count { it.firstOrNull() == "expiry" } != 1) return false - if (tags.count { it.firstOrNull() == "cert" } > 1) return false - if (tags.count { it.firstOrNull() == "delegation" } > 1) return false - - val hash = tagValue("d") ?: return false - if (!Regex("[0-9a-f]{64}").matches(hash)) return false - if (tagValue("signature").isNullOrBlank()) return false - val expiry = tagValue("expiry")?.toLongOrNull() ?: return false - return expiry > createdAt -} - -private fun Event.isActiveC1(now: Long): Boolean = - !tags.any { it.firstOrNull() == "revoked" } && - tagValue("expiry")!!.toLong() > now - -data class ReleaseInfo(val event: Event) { - val appIdentifier: String? - get() = event.tagValue("i") - ?: event.tagValue("d") - ?.substringBefore("@") - ?.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 - val description: String get() = event.tagValue("description") ?: "" - val appAddresses: List get() = event.tagValues("a").filter { it.startsWith("${Catalog.appKind}:") } -} - -data class AppCoordinate(val author: String, val identifier: String) { - val address: String get() = "${Catalog.appKind}:$author:$identifier" -} - -data class ProfileInfo( - val event: Event, -) { - val name: String? get() = metadataValue("name") - val displayName: String? get() = metadataValue("display_name") - val about: String? get() = metadataValue("about") - val picture: String? get() = metadataValue("picture") ?: metadataValue("image") - val banner: String? get() = metadataValue("banner") - val website: String? get() = metadataValue("website") - val nip05: String? get() = metadataValue("nip05") - - private fun metadataValue(key: String): String? = - runCatching { JSONObject(event.content).optString(key).takeIf(String::isNotBlank) }.getOrNull() -} - -fun Event.tagValue(name: String): String? = - tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1) - -fun Event.tagValues(name: String): List = - tags.mapNotNull { tag -> tag.getOrNull(1)?.takeIf { tag.firstOrNull() == name } } - -fun String.toAppCoordinate(): AppCoordinate? { - val parts = split(":", limit = 3) - return parts.takeIf { it.size == 3 && it[0] == Catalog.appKind.toString() } - ?.let { (_, author, identifier) -> AppCoordinate(author, identifier) } -} - -private val QueryPhase.isTerminal: Boolean - get() = this == QueryPhase.Cached || - this == QueryPhase.Complete || - this == QueryPhase.TimedOut || - this == QueryPhase.Failed diff --git a/src/main/kotlin/dev/zapstore/app/DeviceKeys.kt b/src/main/kotlin/dev/zapstore/app/DeviceKeys.kt new file mode 100644 index 0000000..c8e53c6 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/DeviceKeys.kt @@ -0,0 +1,20 @@ +package dev.zapstore.app + +import android.content.Context +import dev.zapstore.iolite.Crypto +import dev.zapstore.iolite.Hex +import dev.zapstore.iolite.LocalSigner + +object DeviceKeys { + fun loadOrCreate(context: Context): LocalSigner { + val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stored = prefs.getString(KEY, null) + if (stored != null) return LocalSigner(Hex.decode(stored)) + val secret = Crypto.randomBytes(32) + prefs.edit().putString(KEY, Hex.encode(secret)).apply() + return LocalSigner(secret) + } + + private const val PREFS = "device_keys" + private const val KEY = "nsec" +} diff --git a/src/main/kotlin/dev/zapstore/app/ImageUrls.kt b/src/main/kotlin/dev/zapstore/app/ImageUrls.kt index f83a727..d9b2e4a 100644 --- a/src/main/kotlin/dev/zapstore/app/ImageUrls.kt +++ b/src/main/kotlin/dev/zapstore/app/ImageUrls.kt @@ -15,7 +15,7 @@ enum class CdnImageVariant( fun profileCdnUrl(pubkey: String): String? { if (pubkey.length != 64 || pubkey.any { it.digitToIntOrNull(16) == null }) return null - return "https://$ZAPSTORE_CDN_HOST/p/${pubkey.lowercase()}.profile.webp" + return "https://$ZAPSTORE_CDN_HOST/${pubkey.lowercase()}.profile.webp" } /** @@ -47,3 +47,5 @@ fun cdnImageUrl( .build() .toString() } + +fun isHttpUrl(value: String): Boolean = value.startsWith("https://") || value.startsWith("http://") diff --git a/src/main/kotlin/dev/zapstore/app/LocalStorage.kt b/src/main/kotlin/dev/zapstore/app/LocalStorage.kt new file mode 100644 index 0000000..f3924c4 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/LocalStorage.kt @@ -0,0 +1,30 @@ +package dev.zapstore.app + +import android.content.Context +import java.io.File + +/** Bytes used by this app's private data, caches, and external app directories. */ +fun Context.localStorageBytes(): Long { + val existing = buildList { + add(dataDir) + add(cacheDir) + add(codeCacheDir) + add(noBackupFilesDir) + add(filesDir) + externalCacheDir?.let(::add) + getExternalFilesDir(null)?.let(::add) + externalCacheDirs.filterNotNull().forEach(::add) + getExternalFilesDirs(null).filterNotNull().forEach(::add) + }.mapNotNull { runCatching { it.canonicalFile }.getOrNull() } + .filter(File::exists) + .distinct() + val roots = existing.filter { dir -> + existing.none { other -> other != dir && dir.path.startsWith(other.path + File.separator) } + } + return roots.sumOf(File::treeSize) +} + +private fun File.treeSize(): Long { + if (isFile) return length() + return listFiles()?.sumOf(File::treeSize) ?: 0L +} diff --git a/src/main/kotlin/dev/zapstore/app/MainActivity.kt b/src/main/kotlin/dev/zapstore/app/MainActivity.kt index f91c13f..041c311 100644 --- a/src/main/kotlin/dev/zapstore/app/MainActivity.kt +++ b/src/main/kotlin/dev/zapstore/app/MainActivity.kt @@ -1,7 +1,6 @@ package dev.zapstore.app import android.content.Intent -import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -14,21 +13,34 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import dev.zapstore.app.screens.AppDetailScreen +import dev.zapstore.app.screens.AppDetailViewModel +import dev.zapstore.app.screens.HomeScreen +import dev.zapstore.app.screens.HomeViewModel +import dev.zapstore.app.screens.ProfileScreen +import dev.zapstore.app.screens.ProfileViewModel +import dev.zapstore.app.screens.Routes +import dev.zapstore.app.screens.SettingsScreen +import dev.zapstore.app.screens.SettingsViewModel +import dev.zapstore.app.screens.StackDetailScreen +import dev.zapstore.app.screens.StackDetailViewModel +import dev.zapstore.app.screens.UpdatesScreen +import dev.zapstore.app.screens.UpdatesViewModel +import dev.zapstore.iolite.AppRecord class MainActivity : ComponentActivity() { - private val zapstore: ZapstoreApplication - get() = getApplication() as ZapstoreApplication - private val repository: CatalogRepository - get() = zapstore.catalogRepository + private val zapstore: ZapstoreApplication get() = application as ZapstoreApplication override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,170 +48,13 @@ class MainActivity : ComponentActivity() { setContent { ZapstoreTheme { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, ) { - val navController = rememberNavController() - val context = LocalContext.current - - NavHost( - navController = navController, - startDestination = HOME_ROUTE, - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing), - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing), - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing), - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing), - ) - }, - ) { - composable(HOME_ROUTE) { - val viewModel: HomeViewModel = viewModel( - 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, - onUpdatesClick = { - navController.navigate(UPDATES_ROUTE) - }, - updateCount = syncState.availableUpdates.size, - databaseRowCount = syncState.eventRowCount, - catalogError = syncState.error, - onLoadMoreReleases = viewModel::loadMoreReleases, - onStackClick = { stackId -> - navController.navigate(stackRoute(stackId)) - }, - onAppClick = { identifier, author -> - navController.navigate(appRoute(identifier, author)) - }, - onProfileClick = { pubkey -> - navController.navigate(profileRoute(pubkey)) - }, - ) - } - - 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( - route = STACK_ROUTE, - arguments = listOf( - navArgument(STACK_ID_ARGUMENT) { type = NavType.StringType }, - ), - ) { - val viewModel: StackDetailViewModel = viewModel( - factory = stackDetailViewModelFactory(repository), - ) - val state by viewModel.uiState.collectAsStateWithLifecycle() - StackDetailScreen( - state = state, - repository = repository, - onAppClick = { identifier, author -> - navController.navigate(appRoute(identifier, author)) - }, - onProfileClick = { pubkey -> - navController.navigate(profileRoute(pubkey)) - }, - ) - } - - composable( - route = APP_ROUTE, - arguments = listOf( - navArgument(APP_IDENTIFIER_ARGUMENT) { type = NavType.StringType }, - navArgument(APP_AUTHOR_ARGUMENT) { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - ) { - val viewModel: AppDetailViewModel = viewModel( - factory = appDetailViewModelFactory(repository), - ) - val state by viewModel.uiState.collectAsStateWithLifecycle() - AppDetailScreen( - state = state, - onOpenUrl = { value -> - if (isHttpUrl(value)) { - runCatching { - context.startActivity( - Intent(Intent.ACTION_VIEW, value.toUri()), - ) - } - } - }, - repository = repository, - onProfileClick = { pubkey -> - navController.navigate(profileRoute(pubkey)) - }, - ) - } - - composable( - route = PROFILE_ROUTE, - arguments = listOf( - navArgument(PROFILE_PUBKEY_ARGUMENT) { type = NavType.StringType }, - ), - ) { - val viewModel: ProfileViewModel = viewModel( - factory = profileViewModelFactory(repository), - ) - val state by viewModel.uiState.collectAsStateWithLifecycle() - ProfileScreen( - state = state, - repository = repository, - onAppClick = { identifier, author -> - navController.navigate(appRoute(identifier, author)) - }, - onLoadMoreReleases = viewModel::loadMoreReleases, - onOpenUrl = { value -> - if (isHttpUrl(value)) { - runCatching { - context.startActivity( - Intent(Intent.ACTION_VIEW, value.toUri()), - ) - } - } - }, - ) - } - } + ZapstoreNavHost(rememberNavController()) } } } @@ -207,27 +62,117 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() - repository.refreshConnections() + zapstore.iolite.refreshConnections() + } + + @androidx.compose.runtime.Composable + private fun ZapstoreNavHost(navController: NavHostController) { + val iolite = zapstore.iolite + val openApp = { app: AppRecord -> navController.navigate(Routes.app(app.appId)) } + val openProfile = { pubkey: String -> navController.navigate(Routes.profile(pubkey)) } + + NavHost( + navController = navController, + startDestination = Routes.HOME, + enterTransition = { slide(AnimatedContentTransitionScope.SlideDirection.Left) }, + exitTransition = { slideOut(AnimatedContentTransitionScope.SlideDirection.Left) }, + popEnterTransition = { slide(AnimatedContentTransitionScope.SlideDirection.Right) }, + popExitTransition = { slideOut(AnimatedContentTransitionScope.SlideDirection.Right) }, + ) { + composable(Routes.HOME) { + val viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory(iolite, zapstore.queryEncoder::encode)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val updateCount by zapstore.catalogSync.updateCount.collectAsStateWithLifecycle(0) + HomeScreen( + state = state, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchSubmitted = viewModel::submitSearch, + onSearchCleared = viewModel::clearSearch, + onAppClick = openApp, + onStackClick = { navController.navigate(Routes.stack(it.pubkey, it.identifier)) }, + onProfileClick = openProfile, + onUpdatesClick = { navController.navigate(Routes.UPDATES) }, + onSettingsClick = { navController.navigate(Routes.SETTINGS) }, + updateCount = updateCount, + onLoadMore = viewModel::loadMore, + ) + } + + composable(Routes.UPDATES) { + val viewModel: UpdatesViewModel = viewModel(factory = UpdatesViewModel.factory(zapstore.catalogSync)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + UpdatesScreen(state = state, onSync = viewModel::sync, onAppClick = openApp) + } + + composable(Routes.SETTINGS) { + val viewModel: SettingsViewModel = + viewModel( + factory = SettingsViewModel.factory( + iolite, + zapstore.catalogSync, + zapstore.network, + applicationContext::localStorageBytes, + ), + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + SettingsScreen( + state = state, + onNetworkModeChange = viewModel::setNetworkMode, + onSync = viewModel::sync, + onWipe = viewModel::wipe, + ) + } + + composable( + route = Routes.STACK, + arguments = listOf( + navArgument(Routes.STACK_AUTHOR_ARG) { type = NavType.StringType }, + navArgument(Routes.STACK_ID_ARG) { type = NavType.StringType }, + ), + ) { + val viewModel: StackDetailViewModel = viewModel(factory = StackDetailViewModel.factory(iolite)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + StackDetailScreen(state = state, onAppClick = openApp, onProfileClick = openProfile) + } + + composable( + route = Routes.APP, + arguments = listOf(navArgument(Routes.APP_ID_ARG) { type = NavType.StringType }), + ) { + val viewModel: AppDetailViewModel = viewModel(factory = AppDetailViewModel.factory(iolite)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + AppDetailScreen( + state = state, + onOpenUrl = ::openUrl, + onProfileClick = openProfile, + onRetryComments = viewModel::retryComments, + onSettingsClick = { navController.navigate(Routes.SETTINGS) }, + ) + } + + composable( + route = Routes.PROFILE, + arguments = listOf(navArgument(Routes.PUBKEY_ARG) { type = NavType.StringType }), + ) { + val viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.factory(iolite)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ProfileScreen(state = state, onOpenUrl = ::openUrl, onAppClick = openApp) + } + } + } + + private fun openUrl(value: String) { + if (!isHttpUrl(value)) return + runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri())) } + } + + private fun AnimatedContentTransitionScope<*>.slide(direction: AnimatedContentTransitionScope.SlideDirection) = + slideIntoContainer(direction, tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing)) + + private fun AnimatedContentTransitionScope<*>.slideOut(direction: AnimatedContentTransitionScope.SlideDirection) = + slideOutOfContainer(direction, tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing)) + + private companion object { + const val NAVIGATION_TRANSITION_DURATION = 150 } } - -private const val HOME_ROUTE = "home" -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 UPDATES_ROUTE = "updates" -private const val NAVIGATION_TRANSITION_DURATION = 150 - -private fun stackRoute(stackId: String): String = - "stack/${Uri.encode(stackId)}" - -private fun appRoute(identifier: String, author: String?): String = buildString { - append("app/").append(Uri.encode(identifier)) - author?.let { - append("?").append(APP_AUTHOR_ARGUMENT).append("=").append(Uri.encode(it)) - } -} - -private fun profileRoute(pubkey: String): String = - "profile/${Uri.encode(pubkey)}" diff --git a/src/main/kotlin/dev/zapstore/app/ViewModels.kt b/src/main/kotlin/dev/zapstore/app/ViewModels.kt deleted file mode 100644 index ceef8af..0000000 --- a/src/main/kotlin/dev/zapstore/app/ViewModels.kt +++ /dev/null @@ -1,775 +0,0 @@ -package dev.zapstore.app - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.createSavedStateHandle -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.quartz.lightning.LnInvoiceUtil -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import dev.zapstore.iolite.QueryPhase -import dev.zapstore.iolite.RemoteMode -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -const val STACK_ID_ARGUMENT = "stackId" -const val APP_IDENTIFIER_ARGUMENT = "identifier" -const val APP_AUTHOR_ARGUMENT = "author" -const val PROFILE_PUBKEY_ARGUMENT = "pubkey" - -data class HomeUiState( - val searchQuery: String = "", - val submittedSearchQuery: String? = null, - val searchResults: List = emptyList(), - val searchMessage: String? = null, - val isSearching: Boolean = false, - val stacks: List = emptyList(), - val stackApps: Map = emptyMap(), - val stacksLoading: Boolean = true, - val stacksError: String? = null, - val releaseFeed: ReleaseFeedUiState = ReleaseFeedUiState(), -) - -data class ReleaseFeedEntry( - val app: AppInfo, - val release: ReleaseInfo? = null, -) - -data class ReleaseFeedUiState( - val entries: List = emptyList(), - val initialLoading: Boolean = true, - val loadingMore: Boolean = false, - val canLoadMore: Boolean = true, - val error: String? = null, -) - -/** - * App-first feed with release-ranked presentation. - * - * Kind 32267 app events are the identity and pagination source, so exhausting the - * feed exhausts the app catalog. Kinds 30063 and 3063 only provide each app's - * latest-version metadata and sort key; they can never create a feed row. - */ -private class ReleaseFeedLoader( - private val repository: CatalogRepository, - private val scope: kotlinx.coroutines.CoroutineScope, - private val author: String? = null, - private val onStateChanged: (ReleaseFeedUiState) -> Unit, -) { - private val appsByAddress = linkedMapOf() - private val releasesByApp = mutableMapOf() - private var nextUntil: Long? = null - private val boundaryEventIds = mutableSetOf() - private var releaseLookupKeys: Set = emptySet() - private var releaseJob: Job? = null - private var verificationAuthors: Set = emptySet() - private var verificationLookupAuthors: Set = emptySet() - private var verificationJob: Job? = null - private var requestInFlight = false - private var state = ReleaseFeedUiState() - - fun loadNextPage() { - if (requestInFlight || !state.canLoadMore) return - requestInFlight = true - - updateState( - state.copy( - initialLoading = appsByAddress.isEmpty(), - loadingMore = appsByAddress.isNotEmpty(), - error = null, - ), - ) - val pageUntil = nextUntil - val pageLimit = APP_PAGE_SIZE + boundaryEventIds.size - scope.launch { - var receivedTerminalState = false - repository.query( - Filter( - authors = author?.let(::listOf), - kinds = listOf(Catalog.appKind), - limit = pageLimit, - until = pageUntil, - ), - ).takeWhile { queryState -> - val page = queryState.items - .filter { it.kind == Catalog.appKind } - .map(::AppInfo) - .sortedByDescending { it.event.createdAt } - .distinctBy { it.event.id } - page.forEach { app -> - val existing = appsByAddress[app.address] - if ( - existing == null || - app.event.createdAt > existing.event.createdAt || - app.event.createdAt == existing.event.createdAt && app.event.id < existing.event.id - ) { - appsByAddress[app.address] = app - } - } - publishEntries() - - val terminal = !queryState.phase.isLoading || queryState.error != null - if (!terminal && appsByAddress.isNotEmpty()) { - updateState(state.copy(initialLoading = false, loadingMore = true)) - } else if (terminal) { - receivedTerminalState = true - requestInFlight = false - page.minOfOrNull { it.event.createdAt }?.let { oldestTimestamp -> - val idsAtBoundary = page - .asSequence() - .filter { it.event.createdAt == oldestTimestamp } - .mapTo(mutableSetOf()) { it.event.id } - if (pageUntil != oldestTimestamp) boundaryEventIds.clear() - boundaryEventIds += idsAtBoundary - nextUntil = oldestTimestamp - } - updateState( - state.copy( - initialLoading = false, - loadingMore = false, - canLoadMore = queryState.error == null && page.size >= pageLimit, - error = queryState.error?.message, - ), - ) - } - !terminal - }.collect { - // State is handled in takeWhile so the terminal emission can stop this live query. - } - if (!receivedTerminalState) { - requestInFlight = false - updateState(state.copy(initialLoading = false, loadingMore = false, canLoadMore = false)) - } - } - } - - private fun publishEntries() { - val entries = appsByAddress.values - .sortedWith( - compareByDescending { - releasesByApp[it.address]?.event?.createdAt ?: Long.MIN_VALUE - }.thenByDescending { it.event.createdAt } - .thenBy { it.address }, - ) - .map { app -> - ReleaseFeedEntry( - app = app.copy(hasVerifiedC1 = app.event.pubKey in verificationAuthors), - release = releasesByApp[app.address], - ) - } - updateState(state.copy(entries = entries)) - observeReleases(entries.map(ReleaseFeedEntry::app)) - observeVerification(appsByAddress.values.toList()) - } - - private fun observeVerification(apps: List) { - val authors = apps.map(AppInfo::event).map { it.pubKey }.toSet() - if (authors == verificationLookupAuthors) return - verificationLookupAuthors = authors - verificationJob?.cancel() - if (authors.isEmpty()) { - verificationAuthors = emptySet() - publishEntries() - return - } - - verificationJob = scope.launch { - verificationAuthors = repository.verifiedApps(apps).map { it.event.pubKey }.toSet() - val visibleEntries = appsByAddress.values - .sortedWith( - compareByDescending { - releasesByApp[it.address]?.event?.createdAt ?: Long.MIN_VALUE - }.thenByDescending { it.event.createdAt } - .thenBy { it.address }, - ) - .map { app -> - ReleaseFeedEntry( - app = app.copy(hasVerifiedC1 = app.event.pubKey in verificationAuthors), - release = releasesByApp[app.address], - ) - } - updateState(state.copy(entries = visibleEntries)) - observeReleases(visibleEntries.map(ReleaseFeedEntry::app)) - } - } - - private fun observeReleases(apps: List) { - val lookupKeys = apps.map(AppInfo::address).toSet() - if (lookupKeys == releaseLookupKeys) return - releaseLookupKeys = lookupKeys - releaseJob?.cancel() - - if (apps.isEmpty()) return - - val releaseFilters = apps.map { app -> - Filter( - authors = listOf(app.event.pubKey), - kinds = Catalog.releaseKinds, - tags = mapOf("i" to listOf(app.identifier)), - limit = 1, - ) - } - releaseJob = scope.launch { - repository.query( - releaseFilters, - ).collect { queryState -> - 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" - if (address in appsByAddress) { - releasesByApp[address] = preferredRelease(releasesByApp[address], release) - } - } - publishEntries() - } - } - } - - private fun updateState(value: ReleaseFeedUiState) { - state = value - onStateChanged(value) - } - - private companion object { - const val APP_PAGE_SIZE = 20 - } -} - -class HomeViewModel( - private val repository: CatalogRepository, - private val savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val _uiState = MutableStateFlow( - HomeUiState(searchQuery = savedStateHandle[SEARCH_QUERY_KEY] ?: ""), - ) - val uiState: StateFlow = _uiState.asStateFlow() - - private var stackPreviewJob: Job? = null - private var searchJob: Job? = null - private var stackAddresses: List = emptyList() - private val releaseFeed = ReleaseFeedLoader(repository, viewModelScope) { feed -> - _uiState.update { it.copy(releaseFeed = feed) } - } - - init { - observeStacks() - releaseFeed.loadNextPage() - } - - fun onSearchQueryChanged(value: String) { - savedStateHandle[SEARCH_QUERY_KEY] = value - _uiState.update { it.copy(searchQuery = value) } - } - - fun clearSearch() { - searchJob?.cancel() - savedStateHandle[SEARCH_QUERY_KEY] = "" - _uiState.update { - it.copy( - searchQuery = "", - submittedSearchQuery = null, - searchResults = emptyList(), - searchMessage = null, - isSearching = false, - ) - } - } - - fun submitSearch() { - val query = _uiState.value.searchQuery.trim() - searchJob?.cancel() - if (query.length < MIN_SEARCH_LENGTH) { - _uiState.update { - it.copy( - submittedSearchQuery = null, - searchResults = emptyList(), - searchMessage = null, - isSearching = false, - ) - } - return - } - - _uiState.update { - it.copy( - submittedSearchQuery = query, - searchResults = emptyList(), - searchMessage = "Searching relay.zapstore.dev…", - isSearching = true, - ) - } - searchJob = viewModelScope.launch { - repository.query( - Filter(kinds = listOf(Catalog.appKind), search = query, limit = 20), - options = catalogQueryOptions(), - ).collect { state -> - val apps = state.items.map(::AppInfo).distinctBy(AppInfo::address) - _uiState.update { - it.copy( - submittedSearchQuery = query, - searchResults = apps, - searchMessage = state.error?.message ?: when { - apps.isEmpty() && state.phase.isLoading -> "Searching relay.zapstore.dev…" - apps.isEmpty() -> "No apps found for “$query”." - else -> "${apps.size} results for “$query”" - }, - isSearching = apps.isEmpty() && state.phase.isLoading, - ) - } - } - } - } - - fun loadMoreReleases() = releaseFeed.loadNextPage() - - private fun observeStacks() { - viewModelScope.launch { - repository.query( - Filter( - authors = listOf(Catalog.communityPubkey), - kinds = listOf(Catalog.appStackKind), - limit = 20, - ), - options = catalogQueryOptions(remoteMode = RemoteMode.Stream), - ).collect { state -> - val stacks = state.items.map(::StackInfo).sortedByDescending { it.event.createdAt } - _uiState.update { - it.copy( - stacks = stacks, - stacksLoading = stacks.isEmpty() && state.phase.isLoading, - stacksError = state.error?.message, - ) - } - observeStackPreviews(stacks) - } - } - } - - private fun observeStackPreviews(stacks: List) { - val addresses = stacks.flatMap(StackInfo::appAddresses).distinct() - if (addresses == stackAddresses) return - stackAddresses = addresses - stackPreviewJob?.cancel() - - val coordinates = addresses.mapNotNull(String::toAppCoordinate) - if (coordinates.isEmpty()) { - _uiState.update { it.copy(stackApps = emptyMap()) } - return - } - - stackPreviewJob = viewModelScope.launch { - repository.query( - Filter( - authors = coordinates.map(AppCoordinate::author).distinct(), - kinds = listOf(Catalog.appKind), - tags = mapOf("d" to coordinates.map(AppCoordinate::identifier).distinct()), - limit = coordinates.size * 2, - ), - ).collect { state -> - val apps = state.items.map(::AppInfo).associateBy(AppInfo::address) - _uiState.update { - it.copy(stackApps = apps) - } - } - } - } - - private companion object { - const val SEARCH_QUERY_KEY = "searchQuery" - const val MIN_SEARCH_LENGTH = 3 - } -} - -data class StackDetailUiState( - val stack: StackInfo? = null, - val appsByAddress: Map = emptyMap(), - val releasesByAddress: Map = emptyMap(), - val stackLoading: Boolean = true, - val appsLoading: Boolean = false, - val error: String? = null, -) - -class StackDetailViewModel( - private val repository: CatalogRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val stackId = requireNotNull(savedStateHandle.get(STACK_ID_ARGUMENT)) - private val _uiState = MutableStateFlow(StackDetailUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private var appsJob: Job? = null - private var releasesJob: Job? = null - private var appAddresses: List = emptyList() - private var releaseLookupKeys: Set = emptySet() - - init { - viewModelScope.launch { - repository.query( - Filter(ids = listOf(stackId), kinds = listOf(Catalog.appStackKind), limit = 1), - ).collect { state -> - val stack = state.items.firstOrNull()?.let(::StackInfo) - _uiState.update { - it.copy( - stack = stack, - stackLoading = stack == null && state.phase.isLoading, - error = state.error?.message, - ) - } - observeApps(stack) - } - } - } - - private fun observeApps(stack: StackInfo?) { - val addresses = stack?.appAddresses.orEmpty() - 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(), releasesByAddress = emptyMap(), appsLoading = false) - } - return - } - - _uiState.update { - it.copy(appsByAddress = emptyMap(), releasesByAddress = emptyMap(), appsLoading = true) - } - appsJob = viewModelScope.launch { - repository.query( - Filter( - authors = coordinates.map(AppCoordinate::author).distinct(), - kinds = listOf(Catalog.appKind), - tags = mapOf("d" to coordinates.map(AppCoordinate::identifier).distinct()), - limit = coordinates.size * 2, - ), - ).collect { state -> - val apps = state.items.map(::AppInfo).associateBy(AppInfo::address).values.toList() - _uiState.update { - it.copy( - appsByAddress = apps.associateBy(AppInfo::address), - appsLoading = apps.isEmpty() && state.phase.isLoading, - error = state.error?.message ?: it.error, - ) - } - observeReleases(apps) - } - } - } - - private fun observeReleases(apps: List) { - 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() - 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) } - } - } - } -} - -data class AppDetailUiState( - val app: AppInfo? = null, - val release: ReleaseInfo? = null, - val zapSummary: ZapSummaryUiState = ZapSummaryUiState(), - val appLoading: Boolean = true, - val releaseLoading: Boolean = true, - val error: String? = null, -) - -data class ZapSummaryUiState( - val zapCount: Int = 0, - val totalSats: Long = 0, - val isLoading: Boolean = true, - val error: String? = null, -) - -data class ProfileUiState( - val profile: ProfileInfo? = null, - val pubkey: String = "", - val releaseFeed: ReleaseFeedUiState = ReleaseFeedUiState(), - val profileLoading: Boolean = true, - val error: String? = null, -) - -class ProfileViewModel( - private val repository: CatalogRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val pubkey = requireNotNull(savedStateHandle.get(PROFILE_PUBKEY_ARGUMENT)) - private val _uiState = MutableStateFlow(ProfileUiState(pubkey = pubkey)) - val uiState: StateFlow = _uiState.asStateFlow() - private val releaseFeed = ReleaseFeedLoader(repository, viewModelScope, pubkey) { feed -> - _uiState.update { it.copy(releaseFeed = feed) } - } - - init { - observeProfile() - releaseFeed.loadNextPage() - } - - private fun observeProfile() { - viewModelScope.launch { - repository.queryWithOutbox( - filters = listOf( - Filter(authors = listOf(pubkey), kinds = listOf(Catalog.profileKind), limit = 1), - ), - authors = listOf(pubkey), - cachedFor = PROFILE_CACHE_DURATION, - relays = PROFILE_RELAYS, - remoteMode = RemoteMode.OneShot(), - unionWithFallback = false, - ).collect { state -> - val profile = state.items.maxByOrNull { it.createdAt }?.let(::ProfileInfo) - _uiState.update { - it.copy( - profile = profile, - profileLoading = profile == null && state.phase.isLoading, - error = state.error?.message, - ) - } - } - } - } - - fun loadMoreReleases() = releaseFeed.loadNextPage() -} - -class AppDetailViewModel( - private val repository: CatalogRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val identifier = requireNotNull(savedStateHandle.get(APP_IDENTIFIER_ARGUMENT)) - private val author = savedStateHandle.get(APP_AUTHOR_ARGUMENT)?.takeIf(String::isNotBlank) - private val _uiState = MutableStateFlow(AppDetailUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - private var zapAssetJob: Job? = null - private var zapReceiptJob: Job? = null - private var zapAddress: String? = null - private var zapAssetIds: Set = emptySet() - - init { - observeApp() - observeLatestRelease() - } - - private fun observeApp() { - viewModelScope.launch { - repository.query( - Filter( - authors = author?.let(::listOf), - kinds = listOf(Catalog.appKind), - tags = mapOf("d" to listOf(identifier)), - limit = 3, - ), - options = catalogQueryOptions(remoteMode = RemoteMode.Stream), - ).collect { state -> - val app = state.items.firstOrNull()?.let(::AppInfo) - _uiState.update { - it.copy( - app = app, - appLoading = app == null && state.phase.isLoading, - error = state.error?.message ?: it.error, - ) - } - app?.let(::observeZaps) - } - } - } - - private fun observeZaps(app: AppInfo) { - if (app.address == zapAddress) return - zapAddress = app.address - zapAssetIds = emptySet() - startZapReceiptQuery(app) - - zapAssetJob?.cancel() - zapAssetJob = viewModelScope.launch { - repository.query( - Filter( - authors = listOf(app.event.pubKey), - kinds = listOf(Catalog.assetKind), - tags = mapOf("i" to listOf(app.identifier)), - limit = ASSET_LOOKUP_LIMIT, - ), - options = catalogQueryOptions( - remoteMode = RemoteMode.Stream, - relays = Catalog.defaultZapRelays, - ), - ).collect { state -> - val nextAssetIds = state.items - .asSequence() - .filter { it.kind == Catalog.assetKind } - .map { it.id } - .toSet() - if (nextAssetIds != zapAssetIds) { - zapAssetIds = nextAssetIds - startZapReceiptQuery(app) - } - } - } - } - - private fun startZapReceiptQuery(app: AppInfo) { - zapReceiptJob?.cancel() - _uiState.update { it.copy(zapSummary = it.zapSummary.copy(isLoading = true, error = null)) } - zapReceiptJob = viewModelScope.launch { - repository.queryWithOutbox( - zapFilters(app), - authors = listOf(app.event.pubKey), - relays = Catalog.defaultZapRelays, - ).collect { state -> - val receipts = state.items - .asSequence() - .filter { it.kind == Catalog.zapReceiptKind } - .filter { receipt -> - app.address in receipt.tagValues("a") || - receipt.tagValues("e").any(zapAssetIds::contains) - } - .distinctBy { it.id } - .toList() - _uiState.update { - it.copy( - zapSummary = ZapSummaryUiState( - zapCount = receipts.size, - totalSats = receipts.sumOf { receipt -> - receipt.tagValue("bolt11") - ?.let { invoice -> - runCatching { LnInvoiceUtil.getAmountInSats(invoice).toLong() }.getOrDefault(0) - } - ?: 0 - }, - isLoading = state.phase.isLoading, - error = state.error?.message, - ), - ) - } - } - } - } - - private fun zapFilters(app: AppInfo): List = buildList { - add( - Filter( - kinds = listOf(Catalog.zapReceiptKind), - tags = mapOf("a" to listOf(app.address)), - limit = ZAP_RECEIPT_LIMIT, - ), - ) - zapAssetIds.takeIf(Set::isNotEmpty)?.let { assetIds -> - add( - Filter( - kinds = listOf(Catalog.zapReceiptKind), - tags = mapOf("e" to assetIds.toList()), - limit = ZAP_RECEIPT_LIMIT, - ), - ) - } - } - - private fun observeLatestRelease() { - viewModelScope.launch { - repository.query( - Filter( - authors = author?.let(::listOf), - kinds = Catalog.releaseKinds, - tags = mapOf("i" to listOf(identifier)), - limit = 10, - ), - ).collect { state -> - val release = state.items - .filter { it.kind in Catalog.releaseKinds } - .map(::ReleaseInfo) - .reduceOrNull(::preferredRelease) - _uiState.update { - it.copy( - release = release, - releaseLoading = release == null && state.phase.isLoading, - error = state.error?.message ?: it.error, - ) - } - } - } - } - - private companion object { - /** Caps the zap summary to the newest receipts per filter; the summary is a recent-activity signal, not a ledger. */ - const val ZAP_RECEIPT_LIMIT = 500 - const val ASSET_LOOKUP_LIMIT = 500 - } -} - -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()) } -} - -fun appDetailViewModelFactory(repository: CatalogRepository): ViewModelProvider.Factory = viewModelFactory { - initializer { AppDetailViewModel(repository, createSavedStateHandle()) } -} - -fun profileViewModelFactory(repository: CatalogRepository): ViewModelProvider.Factory = viewModelFactory { - initializer { ProfileViewModel(repository, createSavedStateHandle()) } -} - -private val QueryPhase.isLoading: Boolean - get() = this == QueryPhase.Connecting || this == QueryPhase.CatchingUp diff --git a/src/main/kotlin/dev/zapstore/app/ZapstoreApplication.kt b/src/main/kotlin/dev/zapstore/app/ZapstoreApplication.kt index acde7a4..a14fd8b 100644 --- a/src/main/kotlin/dev/zapstore/app/ZapstoreApplication.kt +++ b/src/main/kotlin/dev/zapstore/app/ZapstoreApplication.kt @@ -1,48 +1,103 @@ package dev.zapstore.app import android.app.Application +import android.os.Build 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 coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.network.ConnectivityChecker +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import dev.zapstore.app.catalog.CatalogSync +import dev.zapstore.app.catalog.installedApps +import dev.zapstore.app.search.LeafQueryEncoder +import dev.zapstore.app.transport.NetworkRuntime +import dev.zapstore.iolite.DeviceProfile +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.IoliteConfig +import dev.zapstore.iolite.Kinds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +/** Process-wide singletons: network, the Iolite client, and catalog sync. Screens get these from here. */ class ZapstoreApplication : Application() { - private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val catalogDatabase by lazy { CatalogBootstrapper(this).open() } - val compactStore by lazy { CompactEventStore(catalogDatabase) } - val catalogRepository: CatalogRepository by lazy { - 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() }, - ) - } + lateinit var network: NetworkRuntime + private set + lateinit var iolite: Iolite + private set + lateinit var queryEncoder: LeafQueryEncoder + private set + lateinit var catalogSync: CatalogSync + private set override fun onCreate() { super.onCreate() + network = NetworkRuntime(this, scope) + queryEncoder = LeafQueryEncoder(this) + registerComponentCallbacks(queryEncoder) + val deviceSigner = DeviceKeys.loadOrCreate(this) + iolite = Iolite.create( + context = this, + parentScope = scope, + config = IoliteConfig( + databaseName = "zapstore.db", + pruneRules = mapOf(Kinds.Zap to AppConfig.zapRetention), + ), + http = network.http, + webSocket = network.webSocket, + signer = deviceSigner, + deviceSigner = deviceSigner, + writeRelays = AppConfig.writeRelays, + device = DeviceProfile(abis = AppConfig.supportedAbis, sdk = Build.VERSION.SDK_INT), + ) + catalogSync = CatalogSync( + iolite, + scope, + readInstalledApps = ::installedApps, + readBundledSnapshot = { + runCatching { assets.open(BUNDLED_CATALOG_DELTA).use { it.readBytes() } }.getOrDefault(ByteArray(0)) + }, + useOnion = { network.mode.value.usesTor }, + ) + network.onConnectionsReset { + iolite.refreshConnections() + SingletonImageLoader.get(this).memoryCache?.clear() + } + + SingletonImageLoader.setSafe { context -> + @OptIn(ExperimentalCoilApi::class) + ImageLoader.Builder(context) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = { network.callFactory }, + connectivityChecker = { ConnectivityChecker.ONLINE }, + ), + ) + } + .build() + } ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { - if (!Catalog.catalogLocalOnly) { - catalogRepository.setRelayTrafficEnabled(true) - } - applicationScope.launch { catalogSyncRepository.sync() } + network.setForeground(true) + iolite.setRelayTrafficEnabled(true) + scope.launch { catalogSync.sync() } + } + + override fun onStop(owner: LifecycleOwner) { + iolite.setRelayTrafficEnabled(false) + network.setForeground(false) } - override fun onStop(owner: LifecycleOwner) = catalogRepository.setRelayTrafficEnabled(false) }) } + + private companion object { + const val BUNDLED_CATALOG_DELTA = "bundle-0-1.tar.zst" + } } diff --git a/src/main/kotlin/dev/zapstore/app/apk/ApkVerifier.kt b/src/main/kotlin/dev/zapstore/app/apk/ApkVerifier.kt new file mode 100644 index 0000000..587e1ea --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/apk/ApkVerifier.kt @@ -0,0 +1,74 @@ +package dev.zapstore.app.apk + +import android.content.Context +import android.content.pm.PackageManager +import java.io.File +import java.security.MessageDigest + +data class ApkIdentity( + val packageId: String, + val sha256: String, + val currentCertificates: Set, + val lineage: List, +) + +data class ListedApk( + val packageId: String, + val sha256: String, + val certificateHash: String, +) + +class ApkVerificationException(message: String) : Exception(message) + +object ApkVerifier { + fun inspect(context: Context, apk: File): ApkIdentity { + val info = context.packageManager.getPackageArchiveInfo( + apk.absolutePath, + PackageManager.GET_SIGNING_CERTIFICATES, + ) ?: throw ApkVerificationException("file is not an installable APK") + val signing = info.signingInfo ?: throw ApkVerificationException("APK is unsigned") + if (signing.hasMultipleSigners()) { + throw ApkVerificationException("APK has multiple current signers") + } + val current = signing.apkContentsSigners.map { it.toByteArray().sha256Hex() }.toSet() + val lineage = signing.signingCertificateHistory.map { it.toByteArray().sha256Hex() } + return ApkIdentity( + packageId = info.packageName, + sha256 = apk.sha256Hex(), + currentCertificates = current, + lineage = lineage, + ) + } + + fun isInstallable( + apk: ApkIdentity, + listed: ListedApk, + installedCertificates: Set = emptySet(), + isUpdate: Boolean = false, + ): Boolean { + if (apk.sha256 != listed.sha256) return false + if (apk.packageId != listed.packageId) return false + if (listed.certificateHash !in apk.currentCertificates) return false + if (!isUpdate || installedCertificates.isEmpty()) return true + if (installedCertificates.any { it in apk.currentCertificates }) return true + return installedCertificates.any { it in apk.lineage } + } +} + +private fun File.sha256Hex(): String = inputStream().use { input -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest().toHex() +} + +private fun ByteArray.sha256Hex(): String = + MessageDigest.getInstance("SHA-256").digest(this).toHex() + +private fun ByteArray.toHex(): String = joinToString("") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') +} diff --git a/src/main/kotlin/dev/zapstore/app/catalog/CatalogSync.kt b/src/main/kotlin/dev/zapstore/app/catalog/CatalogSync.kt new file mode 100644 index 0000000..4cbc40f --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/catalog/CatalogSync.kt @@ -0,0 +1,122 @@ +package dev.zapstore.app.catalog + +import android.os.SystemClock +import android.util.Log +import dev.zapstore.app.AppConfig +import dev.zapstore.iolite.Iolite +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +data class SyncStatus( + val syncing: Boolean = false, + val lastSyncedAtMillis: Long? = null, + val lastSyncDurationMillis: Long? = null, + val lastSyncBytes: Long? = null, + val lastSyncNotModified: Boolean = false, + val error: String? = null, +) + +/** + * Pulls catalog epochs into Iolite and derives the update list from what is stored. + * Updates are not a snapshot: they recompute whenever listings or the installed-package list change. + */ +class CatalogSync( + private val iolite: Iolite, + scope: CoroutineScope, + private val readInstalledApps: () -> List, + private val readBundledSnapshot: () -> ByteArray, + private val useOnion: () -> Boolean = { false }, + private val now: () -> Long = System::currentTimeMillis, +) { + private val bundledSnapshot: ByteArray by lazy(readBundledSnapshot) + private val mutex = Mutex() + private val _status = MutableStateFlow(SyncStatus()) + val status: StateFlow = _status.asStateFlow() + + private val installed = MutableStateFlow>(emptyList()) + + /** Newer catalog listings for packages installed on this device. */ + val updates: Flow> = + combine(iolite.observeApps(), installed) { apps, packages -> availableUpdates(packages, apps) } + .shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 1) + + val updateCount: Flow = updates.map { it.size } + + init { + scope.launch { refreshInstalledApps() } + } + + suspend fun sync() { + mutex.withLock { + _status.update { it.copy(syncing = true, error = null) } + var elapsedMs: Long? = null + val result = runCatching { + withContext(Dispatchers.IO) { + val started = SystemClock.elapsedRealtime() + val pulled = iolite.syncCatalog( + catalogId = iolite.selectedCatalog()?.id ?: 1L, + useOnion = useOnion(), + bundled = bundledSnapshot, + endpoint = AppConfig.catalogRelay, + ) + elapsedMs = SystemClock.elapsedRealtime() - started + Log.i( + TAG, + "catalog sync ${elapsedMs}ms imported=${pulled.imported?.let { "${it.from}->${it.to}" } ?: "304"} bytes=${pulled.bytes} apps=${iolite.apps().size}", + ) + pulled + } + } + refreshInstalledApps() + _status.update { + val pulled = result.getOrNull() + it.copy( + syncing = false, + lastSyncedAtMillis = if (result.isSuccess) now() else it.lastSyncedAtMillis, + lastSyncDurationMillis = if (result.isSuccess) elapsedMs else it.lastSyncDurationMillis, + lastSyncBytes = pulled?.bytes ?: it.lastSyncBytes, + lastSyncNotModified = pulled?.notModified ?: it.lastSyncNotModified, + error = result.exceptionOrNull()?.let { failure -> failure.message ?: failure::class.simpleName }, + ) + } + } + } + + suspend fun wipe() { + mutex.withLock { + _status.update { it.copy(syncing = true, error = null) } + val result = runCatching { withContext(Dispatchers.IO) { iolite.wipeLocalData() } } + _status.update { + it.copy( + syncing = false, + lastSyncedAtMillis = null, + lastSyncDurationMillis = null, + lastSyncBytes = null, + lastSyncNotModified = false, + error = result.exceptionOrNull()?.let { failure -> failure.message ?: failure::class.simpleName }, + ) + } + } + } + + private suspend fun refreshInstalledApps() { + installed.value = withContext(Dispatchers.IO) { runCatching(readInstalledApps).getOrDefault(emptyList()) } + } + + private companion object { + const val TAG = "CatalogSync" + } +} diff --git a/src/main/kotlin/dev/zapstore/app/catalog/InstalledApps.kt b/src/main/kotlin/dev/zapstore/app/catalog/InstalledApps.kt new file mode 100644 index 0000000..064d9fa --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/catalog/InstalledApps.kt @@ -0,0 +1,53 @@ +package dev.zapstore.app.catalog + +import android.content.Context +import android.content.pm.PackageManager +import dev.zapstore.iolite.AppRecord +import java.security.MessageDigest + +data class InstalledApp( + val packageId: String, + val versionCode: Long, + val versionName: String, + /** SHA-256 of every current and historical signing certificate, lowercase hex. */ + val certificateHashes: Set, +) + +data class AvailableUpdate( + val app: AppRecord, + val installedVersion: String, + val installedVersionCode: Long, +) + +/** Reads the device's installed packages and their signing certificates. */ +fun Context.installedApps(): List = + packageManager.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, + ) + } + +/** + * Catalog listings that are newer than an installed package signed by the same certificate. + * [apps] are already the device-compatible main-channel listings, so no platform filtering happens here. + */ +fun availableUpdates(installed: List, apps: List): List { + val byAppId = apps.associateBy(AppRecord::appId) + return installed.mapNotNull { package_ -> + val app = byAppId[package_.packageId] ?: return@mapNotNull null + if (app.versionCode <= package_.versionCode) return@mapNotNull null + if (app.certificateHash !in package_.certificateHashes) return@mapNotNull null + AvailableUpdate(app, package_.versionName, package_.versionCode) + }.sortedBy { it.app.name.lowercase() } +} + +private fun ByteArray.sha256Hex(): String = + MessageDigest.getInstance("SHA-256").digest(this).joinToString("") { "%02x".format(it.toInt() and 0xff) } diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogBootstrapper.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogBootstrapper.kt deleted file mode 100644 index 1c0ba14..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogBootstrapper.kt +++ /dev/null @@ -1,49 +0,0 @@ -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, - ) -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogEnvelope.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogEnvelope.kt deleted file mode 100644 index 16a32ec..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogEnvelope.kt +++ /dev/null @@ -1,208 +0,0 @@ -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, - val deletedIds: List, - 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(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 = 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"), - ) -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSchema.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSchema.kt deleted file mode 100644 index 6dfe974..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSchema.kt +++ /dev/null @@ -1,84 +0,0 @@ -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)", - ) -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncClient.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncClient.kt deleted file mode 100644 index 058251d..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncClient.kt +++ /dev/null @@ -1,48 +0,0 @@ -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() - } -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncRepository.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncRepository.kt deleted file mode 100644 index c163761..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogSyncRepository.kt +++ /dev/null @@ -1,145 +0,0 @@ -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 = emptyList(), -) - -class CatalogSyncRepository( - private val database: ZapstoreDatabase, - private val store: CompactEventStore, - private val client: CatalogSyncClient, - private val installedApps: () -> List, - 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 = _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 = - LocalUpdateMatcher.match(installedApps(), catalogAssets()) - - fun catalogAssets(): List = 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() - 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) -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogVerifier.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogVerifier.kt deleted file mode 100644 index 1ab5d8f..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CatalogVerifier.kt +++ /dev/null @@ -1,102 +0,0 @@ -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() - } -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/CompactEventStore.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/CompactEventStore.kt deleted file mode 100644 index 3e25016..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/CompactEventStore.kt +++ /dev/null @@ -1,452 +0,0 @@ -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 query(filter: Filter): List = query(listOf(filter)) - - override suspend fun query(filters: List): List { - if (filters.isEmpty()) return emptyList() - val seen = linkedSetOf() - val matches = mutableListOf() - 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 query(filter: Filter, onEach: (T) -> Unit) { - query(filter).forEach(onEach) - } - - override suspend fun query(filters: List, onEach: (T) -> Unit) { - query(filters).forEach(onEach) - } - - override suspend fun count(filter: Filter): Int = query(filter).size - - override suspend fun count(filters: List): Int = query(filters).size - - override suspend fun delete(filter: Filter) { - delete(listOf(filter)) - } - - override suspend fun delete(filters: List) { - 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) { - database.transaction { connection -> - events.forEach { insertUnlocked(connection, it) } - } - } - - fun deleteByIds(ids: List) { - if (ids.isEmpty()) return - database.transaction { connection -> - ids.forEach { deleteEvent(connection, it.hexToBytes()) } - } - } - - fun applyDelta(events: List, deletedIds: List, 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() - 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() - 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 { - val clauses = mutableListOf() - 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 = - connection.prepare("SELECT id, pubkey, created_at, kind, content, tags FROM events").use(::readEvents) - - private fun readEvents(statement: SQLiteStatement): List { - val events = mutableListOf() - 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>): 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> { - val array = JSONArray(raw) - return Array(array.length()) { index -> - val row = array.getJSONArray(index) - Array(row.length()) { row.getString(it) } - } - } - } -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/Hex.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/Hex.kt deleted file mode 100644 index 06ae78a..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/Hex.kt +++ /dev/null @@ -1,12 +0,0 @@ -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') -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/InstalledAppsProvider.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/InstalledAppsProvider.kt deleted file mode 100644 index 71857be..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/InstalledAppsProvider.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 { - 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() -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/LocalUpdateMatcher.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/LocalUpdateMatcher.kt deleted file mode 100644 index cc26a2f..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/LocalUpdateMatcher.kt +++ /dev/null @@ -1,80 +0,0 @@ -package dev.zapstore.app.catalogsync - -data class InstalledApp( - val packageId: String, - val versionCode: Long, - val versionName: String, - val certificateHashes: Set, -) - -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, - catalog: List, - channel: String = "stable", - androidPlatforms: Set = ANDROID_PLATFORMS, - ): List { - 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): 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", - ) -} diff --git a/src/main/kotlin/dev/zapstore/app/catalogsync/ZapstoreDatabase.kt b/src/main/kotlin/dev/zapstore/app/catalogsync/ZapstoreDatabase.kt deleted file mode 100644 index 91605de..0000000 --- a/src/main/kotlin/dev/zapstore/app/catalogsync/ZapstoreDatabase.kt +++ /dev/null @@ -1,149 +0,0 @@ -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 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) - } -} diff --git a/src/main/kotlin/dev/zapstore/app/components/AppCard.kt b/src/main/kotlin/dev/zapstore/app/components/AppCard.kt new file mode 100644 index 0000000..837fa87 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/AppCard.kt @@ -0,0 +1,190 @@ +package dev.zapstore.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import java.io.File +import dev.zapstore.app.CdnImageVariant +import dev.zapstore.app.R +import dev.zapstore.app.ZapLine +import dev.zapstore.app.ZapMarkdown +import dev.zapstore.app.ZapRadius +import dev.zapstore.app.ZapSpacing +import dev.zapstore.app.ZapSurface1 +import dev.zapstore.app.ZapSurface3 +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.app.ZapVersionPillBackground +import dev.zapstore.app.cdnImageUrl +import dev.zapstore.app.isHttpUrl +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.ProfileRecord + +@Composable +fun AppIcon( + title: String, + iconUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + iconFile: File? = null, + cornerRadius: Dp = size * ZapRadius.appTileFraction, +) { + val remoteReady = rememberRemoteImagesReady() + val model = when { + iconFile != null && iconFile.isFile -> iconFile + remoteReady -> iconUrl?.takeIf(::isHttpUrl)?.let { cdnImageUrl(it, CdnImageVariant.Icon) } + else -> null + } + val shape = RoundedCornerShape(cornerRadius) + Box( + modifier = modifier + .size(size) + .clip(shape) + .background(ZapSurface3), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.take(1).uppercase(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + ) + if (model != null) { + AsyncImage( + model = model, + contentDescription = stringResource(R.string.app_icon_description, title), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +/** One catalog listing in a list. [author] is the stored kind 0 for [AppRecord.authorPubkey], if any. */ +@Composable +fun AppCard( + app: AppRecord, + author: ProfileRecord?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onAuthorClick: ((String) -> Unit)? = null, + showAuthor: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ZapRadius.lg)) + .background(ZapSurface1) + .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.lg)) + .clickable(onClick = onClick) + .padding(ZapSpacing.space4), + ) { + AppIdentityRow( + name = app.name, + iconUrl = app.iconUrl, + iconFile = app.iconFile, + version = app.version, + authorPubkey = app.authorPubkey.takeIf { showAuthor }, + author = author, + onAuthorClick = onAuthorClick, + ) + if (app.summary.isNotBlank()) { + Spacer(Modifier.height(ZapSpacing.space2)) + Text( + text = ZapMarkdown.parse(app.summary).text, + color = ZapTextSecondary, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** Icon, name, version pill and author byline; shared by cards and the app detail header. */ +@Composable +fun AppIdentityRow( + name: String, + iconUrl: String?, + version: String?, + authorPubkey: String?, + author: ProfileRecord?, + modifier: Modifier = Modifier, + iconFile: File? = null, + onAuthorClick: ((String) -> Unit)? = null, +) { + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val iconSize = (maxWidth * 0.21f).coerceIn(50.dp, 68.dp) + Row(verticalAlignment = Alignment.Top) { + AppIcon(title = name, iconUrl = iconUrl, iconFile = iconFile, size = iconSize) + Spacer(Modifier.width(ZapSpacing.space3)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 23.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Black, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + version?.takeIf { it.isNotBlank() }?.let { VersionPill(it) } + authorPubkey?.let { pubkey -> + AuthorByline( + pubkey = pubkey, + profile = author, + onClick = onAuthorClick?.let { click -> { click(pubkey) } }, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + } + } +} + +@Composable +fun VersionPill(version: String, modifier: Modifier = Modifier) { + Text( + text = version, + color = ZapTextSecondary, + style = MaterialTheme.typography.labelMedium, + modifier = modifier + .clip(RoundedCornerShape(ZapRadius.full)) + .background(ZapVersionPillBackground) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) +} diff --git a/src/main/kotlin/dev/zapstore/app/components/AppList.kt b/src/main/kotlin/dev/zapstore/app/components/AppList.kt new file mode 100644 index 0000000..ab8ae44 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/AppList.kt @@ -0,0 +1,83 @@ +package dev.zapstore.app.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.ProfileRecord + +/** A page of catalog listings plus what the list needs to render loading and end states. */ +data class AppListState( + val apps: List = emptyList(), + val profiles: Map = emptyMap(), + val loading: Boolean = true, + val loadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val error: String? = null, +) + +/** App cards with a title, empty state, and load-more footer. */ +fun LazyListScope.appList( + state: AppListState, + emptyMessage: String, + onAppClick: (AppRecord) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + showAuthor: Boolean = true, + onAuthorClick: ((String) -> Unit)? = null, +) { + title?.let { item { SectionTitle(it, modifier.padding(top = 4.dp)) } } + when { + state.apps.isEmpty() && state.loading -> item { LoadingIndicator(modifier.padding(vertical = 12.dp)) } + state.apps.isEmpty() -> item { StatusText(state.error ?: emptyMessage, modifier) } + else -> { + state.error?.let { item { StatusText(it, modifier) } } + items(state.apps, key = { "app:${it.appId}" }) { app -> + AppCard( + app = app, + author = app.authorPubkey?.let(state.profiles::get), + onClick = { onAppClick(app) }, + onAuthorClick = onAuthorClick, + showAuthor = showAuthor, + modifier = modifier.testTag("app:${app.appId}"), + ) + } + if (state.loadingMore) { + item(key = "app-list-loading") { + LoadingIndicator(modifier.testTag("appListLoading").padding(vertical = 8.dp)) + } + } + } + } +} + +/** Calls [onLoadMore] when the list has a real viewport and the last few items are on screen. */ +@Composable +fun LoadMoreWhenNearEnd( + listState: LazyListState, + state: AppListState, + onLoadMore: () -> Unit, +) { + val nearEnd by remember(listState) { + derivedStateOf { + val info = listState.layoutInfo + if (info.viewportEndOffset <= info.viewportStartOffset) return@derivedStateOf false + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false + lastVisible >= info.totalItemsCount - PREFETCH_THRESHOLD + } + } + LaunchedEffect(nearEnd, state.loadingMore, state.canLoadMore) { + if (nearEnd && !state.loadingMore && state.canLoadMore) onLoadMore() + } +} + +private const val PREFETCH_THRESHOLD = 3 diff --git a/src/main/kotlin/dev/zapstore/app/components/AuthorByline.kt b/src/main/kotlin/dev/zapstore/app/components/AuthorByline.kt new file mode 100644 index 0000000..996e6d1 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/AuthorByline.kt @@ -0,0 +1,70 @@ +package dev.zapstore.app.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.zapstore.app.R +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.iolite.ProfileRecord +import dev.zapstore.iolite.toNpub + +/** "by " for an app author. [profile] is null until the kind 0 is stored. */ +@Composable +fun AuthorByline( + pubkey: String, + profile: ProfileRecord?, + modifier: Modifier = Modifier, + showBy: Boolean = true, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .testTag("profile:$pubkey") + .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (showBy) { + Text( + text = stringResource(R.string.app_by_author, ""), + color = ZapTextSecondary, + style = MaterialTheme.typography.bodySmall, + ) + } + ProfileImage( + pubkey = pubkey, + pictureUrl = profile?.picture, + profileVersion = profile?.eventId, + contentDescription = null, + modifier = Modifier + .size(17.dp) + .clip(CircleShape), + ) + Text( + text = shortDisplayName(pubkey, profile), + color = ZapTextSecondary, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Profile name, or an abbreviated npub when no kind 0 is known. */ +fun shortDisplayName(pubkey: String, profile: ProfileRecord?): String { + val name = profile?.displayNameOrNpub ?: pubkey.toNpub() + return if (name.startsWith("npub") && name.length > 18) "${name.take(10)}…${name.takeLast(6)}" else name +} diff --git a/src/main/kotlin/dev/zapstore/app/components/CatalogCards.kt b/src/main/kotlin/dev/zapstore/app/components/CatalogCards.kt deleted file mode 100644 index eab2332..0000000 --- a/src/main/kotlin/dev/zapstore/app/components/CatalogCards.kt +++ /dev/null @@ -1,285 +0,0 @@ -package dev.zapstore.app - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -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.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage - -@Composable -fun AppIcon( - title: String, - iconUrl: String?, - size: Dp, - modifier: Modifier = Modifier, - cornerRadius: Dp = size * ZapRadius.appTileFraction, -) { - val shape = RoundedCornerShape(cornerRadius) - Box( - modifier = modifier - .size(size) - .clip(shape) - .background(ZapSurface3), - contentAlignment = Alignment.Center, - ) { - Text( - text = title.take(1).uppercase(), - color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.titleLarge, - ) - iconUrl - ?.takeIf(::isHttpUrl) - ?.let { url -> - AsyncImage( - model = cdnImageUrl(url, CdnImageVariant.Icon), - contentDescription = stringResource(R.string.app_icon_description, title), - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - } -} - -@Composable -fun AppCard( - app: AppInfo, - onClick: () -> Unit, - repository: CatalogRepository? = null, - modifier: Modifier = Modifier, - release: ReleaseInfo? = null, - onProfileClick: (() -> Unit)? = null, - showAuthor: Boolean = true, -) { - val authorPubkey = rememberAppAuthor(app, repository) - val authorProfile = authorPubkey?.let { rememberProfile(it, repository) } - val authorName = authorPubkey?.let { profileDisplayName(authorProfile, it) } - - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(ZapRadius.lg)) - .background(ZapSurface1) - .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.lg)) - .clickable(onClick = onClick) - .padding(ZapSpacing.space4), - ) { - AppIdentityRow( - name = app.name, - iconUrl = app.iconUrl, - authorName = authorName, - authorProfile = authorProfile, - authorPubkey = authorPubkey, - version = release?.version, - onAuthorClick = onProfileClick, - authorTestTag = authorPubkey?.let { "profile:$it" }, - showAuthor = showAuthor, - ) - if (app.summary.isNotBlank()) { - Spacer(Modifier.height(ZapSpacing.space2)) - Text( - text = ZapMarkdown.parse(app.summary).text, - color = ZapTextSecondary, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun AppIdentityRow( - name: String, - iconUrl: String?, - authorName: String?, - authorProfile: ProfileInfo?, - authorPubkey: String?, - version: String?, - modifier: Modifier = Modifier, - onAuthorClick: (() -> Unit)? = null, - authorTestTag: String? = null, - showAuthor: Boolean = true, -) { - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val iconSize = (maxWidth * 0.21f).coerceIn(50.dp, 68.dp) - Row(verticalAlignment = Alignment.Top) { - AppIcon( - title = name, - iconUrl = iconUrl, - size = iconSize, - ) - Spacer(Modifier.width(ZapSpacing.space3)) - AppNameWithByline( - name = name, - authorName = authorName, - authorProfile = authorProfile, - authorPubkey = authorPubkey, - version = version, - onAuthorClick = onAuthorClick, - authorTestTag = authorTestTag, - showAuthor = showAuthor, - modifier = Modifier.weight(1f), - ) - } - } -} - -@Composable -fun AppNameWithByline( - name: String, - authorName: String?, - authorProfile: ProfileInfo?, - authorPubkey: String?, - version: String?, - modifier: Modifier = Modifier, - onAuthorClick: (() -> Unit)? = null, - authorTestTag: String? = null, - showAuthor: Boolean = true, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = name, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 23.sp, - lineHeight = 28.sp, - fontWeight = FontWeight.Black, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - version?.let { VersionPill(version = it) } - if (showAuthor) { - authorPubkey?.let { pubkey -> - Row( - modifier = Modifier - .weight(1f, fill = false) - .then(authorTestTag?.let { Modifier.testTag(it) } ?: Modifier) - .then(onAuthorClick?.let { Modifier.clickable(onClick = it) } ?: Modifier), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = stringResource(R.string.app_by_author, ""), - color = ZapTextSecondary, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Normal), - ) - Box( - modifier = Modifier - .size(17.dp) - .clip(RoundedCornerShape(50)), - contentAlignment = Alignment.Center, - ) { - ProfileImage( - pubkey = pubkey, - pictureUrl = authorProfile?.picture, - profileVersion = authorProfile?.event?.id, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - ) - } - Text( - text = authorName.orEmpty(), - color = ZapTextSecondary, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } - } - } - } - } -} - -@Composable -fun VersionPill( - version: String, - modifier: Modifier = Modifier, -) { - Text( - text = version, - color = ZapTextSecondary, - style = MaterialTheme.typography.labelMedium, - modifier = modifier - .clip(RoundedCornerShape(ZapRadius.full)) - .background(ZapVersionPillBackground) - .padding(horizontal = 10.dp, vertical = 5.dp), - ) -} - -@Composable -fun StackCard( - stack: StackInfo, - appsByAddress: Map, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier - .width(176.dp) - .clip(RoundedCornerShape(ZapRadius.lg)) - .background(ZapSurface1) - .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.lg)) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(ZapSpacing.space4), - ) { - Text( - text = stack.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stack.description.ifBlank { "${stack.appAddresses.size} apps" }, - color = ZapTextSecondary, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(ZapSpacing.space3)) - Row(horizontalArrangement = Arrangement.spacedBy(ZapSpacing.space1 + 2.dp)) { - stack.appAddresses.take(3).forEach { address -> - val app = appsByAddress[address] - AppIcon( - title = app?.name ?: "?", - iconUrl = app?.iconUrl, - size = 34.dp, - ) - } - } - } -} diff --git a/src/main/kotlin/dev/zapstore/app/components/InfoRow.kt b/src/main/kotlin/dev/zapstore/app/components/InfoRow.kt index 680d697..93829a3 100644 --- a/src/main/kotlin/dev/zapstore/app/components/InfoRow.kt +++ b/src/main/kotlin/dev/zapstore/app/components/InfoRow.kt @@ -1,4 +1,10 @@ -package dev.zapstore.app +package dev.zapstore.app.components + +import dev.zapstore.app.R +import dev.zapstore.app.ZapActionText +import dev.zapstore.app.ZapTextPrimary +import dev.zapstore.app.ZapTextTertiary +import dev.zapstore.app.isHttpUrl import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box diff --git a/src/main/kotlin/dev/zapstore/app/components/ProfileComponent.kt b/src/main/kotlin/dev/zapstore/app/components/ProfileComponent.kt deleted file mode 100644 index 3093d64..0000000 --- a/src/main/kotlin/dev/zapstore/app/components/ProfileComponent.kt +++ /dev/null @@ -1,314 +0,0 @@ -package dev.zapstore.app - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import coil3.SingletonImageLoader -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.memory.MemoryCache -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import dev.zapstore.app.PROFILE_CACHE_DURATION -import kotlinx.coroutines.flow.collect - -@Composable -fun rememberProfile(pubkey: String, repository: CatalogRepository?): ProfileInfo? { - val profile by produceState(initialValue = null, pubkey, repository) { - if (repository == null) return@produceState - - repository.profile(pubkey).collect { value = it } - } - return profile -} - -fun profileDisplayName(profile: ProfileInfo?, pubkey: String): String = - profile?.displayName - ?.takeIf(String::isNotBlank) - ?: profile?.name?.takeIf(String::isNotBlank) - ?: pubkey.toNpub() - -@Composable -fun rememberProfileDisplayName( - pubkey: String, - repository: CatalogRepository?, -): String = profileDisplayName(rememberProfile(pubkey, repository), pubkey) - -@Composable -fun ProfileComponent( - pubkey: String, - repository: CatalogRepository?, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, -) { - val profile = rememberProfile(pubkey, repository) - val displayName = profileDisplayName(profile, pubkey) - - Row( - modifier = modifier - .testTag("profile:$pubkey") - .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(ZapSurface2), - contentAlignment = Alignment.Center, - ) { - ProfileImage( - pubkey = pubkey, - pictureUrl = profile?.picture, - profileVersion = profile?.event?.id, - contentDescription = stringResource(R.string.profile_avatar_description, displayName), - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - ) - } - Text( - text = displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -fun AppAuthorByline( - pubkey: String, - repository: CatalogRepository?, - profile: ProfileInfo? = null, - showBy: Boolean = true, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, -) { - val loadedProfile = profile ?: rememberProfile(pubkey, repository) - val displayName = loadedProfile - ?.let { profileDisplayName(it, pubkey) } - ?.let(::shortProfileName) - ?: if (profile == null && repository == null) shortProfileName(pubkey.toNpub()) else "" - - Row( - modifier = modifier - .testTag("profile:$pubkey") - .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (showBy) { - Text( - text = stringResource(R.string.app_by_author, ""), - color = ZapTextSecondary, - style = MaterialTheme.typography.bodySmall, - ) - } - ProfileImage( - pubkey = pubkey, - pictureUrl = loadedProfile?.picture, - profileVersion = loadedProfile?.event?.id, - contentDescription = null, - modifier = Modifier - .size(17.dp) - .clip(CircleShape), - ) - Text( - text = displayName, - color = ZapTextSecondary, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -fun rememberAppAuthor( - app: AppInfo, - repository: CatalogRepository?, -): String? { - val author by produceState( - initialValue = app.event.pubKey, - key1 = app.address, - key2 = repository, - ) { - if (repository == null) return@produceState - repository.appAuthor(app).collect { value = it } - } - return author -} - -@Composable -fun rememberC1Author( - app: AppInfo, - repository: CatalogRepository?, -): String? { - val author by produceState( - initialValue = null, - key1 = app.address, - key2 = repository, - ) { - if (repository == null) return@produceState - repository.c1Author(app).collect { value = it } - } - return author -} - -private fun shortProfileName(value: String): String = - if (value.startsWith("npub") && value.length > 18) { - "${value.take(10)}…${value.takeLast(6)}" - } else { - value - } - -@Composable -fun ProfileImage( - pubkey: String, - pictureUrl: String?, - profileVersion: String? = null, - contentDescription: String?, - modifier: Modifier = Modifier, -) { - val cdnUrl = remember(pubkey) { - profileCdnUrl(pubkey)?.let { cdnImageUrl(it, CdnImageVariant.IconSmall) } - } - var useFallback by remember(pubkey) { mutableStateOf(cdnUrl == null) } - val fallbackUrl = pictureUrl?.takeIf(::isHttpUrl) - val imageUrl = if (useFallback) fallbackUrl else cdnUrl - val context = LocalPlatformContext.current - var previousProfileVersion by remember(pubkey) { mutableStateOf(null) } - - LaunchedEffect(profileVersion) { - if (profileVersion != null && - previousProfileVersion != null && - previousProfileVersion != profileVersion - ) { - evictProfileIconCache( - context = context, - urls = listOfNotNull(cdnUrl, fallbackUrl), - ) - } - if (profileVersion != null) previousProfileVersion = profileVersion - } - - imageUrl?.let { url -> - val request = remember(url) { - ImageRequest.Builder(context) - .data(url) - .memoryCacheKey(profileIconCacheKey(url)) - .diskCacheKey(profileIconCacheKey(url)) - .memoryCachePolicy(CachePolicy.ENABLED) - .diskCachePolicy(CachePolicy.ENABLED) - .build() - } - AsyncImage( - model = request, - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - onError = { - if (!useFallback && fallbackUrl != null) useFallback = true - }, - modifier = modifier, - ) - } -} - -private fun evictProfileIconCache(context: android.content.Context, urls: List) { - val imageLoader = SingletonImageLoader.get(context) - urls.forEach { url -> - val cacheKey = profileIconCacheKey(url) - imageLoader.memoryCache?.remove(MemoryCache.Key(cacheKey)) - imageLoader.diskCache?.remove(cacheKey) - } -} - -internal fun profileIconCacheKey(url: String, nowMillis: Long = System.currentTimeMillis()): String { - val cachePeriod = PROFILE_CACHE_DURATION.inWholeMilliseconds - val period = nowMillis / cachePeriod - return "profile-icon:$period:$url" -} - -private val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" -private val BECH32_GENERATOR = longArrayOf( - 0x3b6a57b2L, - 0x26508e6dL, - 0x1ea119faL, - 0x3d4233ddL, - 0x2a1462b3L, -) - -internal fun String.toNpub(): String { - if (length != 64 || any { it.digitToIntOrNull(16) == null }) return this - val bytes = chunked(2).map { it.toInt(16) } - val data = convertBits(bytes, 8, 5, true) - val checksum = createBech32Checksum("npub", data) - return buildString { - append("npub1") - (data + checksum).forEach { append(BECH32_CHARSET[it]) } - } -} - -private fun convertBits(data: List, fromBits: Int, toBits: Int, pad: Boolean): List { - var accumulator = 0 - var bits = 0 - val result = mutableListOf() - val maxValue = (1 shl toBits) - 1 - data.forEach { value -> - accumulator = (accumulator shl fromBits) or value - bits += fromBits - while (bits >= toBits) { - bits -= toBits - result += (accumulator shr bits) and maxValue - } - } - if (pad && bits > 0) result += (accumulator shl (toBits - bits)) and maxValue - return result -} - -private fun createBech32Checksum(hrp: String, data: List): List { - val values = expandHrp(hrp) + data + List(6) { 0 } - val polymod = bech32Polymod(values) xor 1L - return (0 until 6).map { shift -> - ((polymod shr (5 * (5 - shift))) and 31).toInt() - } -} - -private fun expandHrp(hrp: String): List = - hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } - -private fun bech32Polymod(values: List): Long { - var checksum = 1L - values.forEach { value -> - val top = checksum shr 25 - checksum = ((checksum and 0x1ffffffL) shl 5) xor value.toLong() - BECH32_GENERATOR.forEachIndexed { index, generator -> - if (((top shr index) and 1L) != 0L) checksum = checksum xor generator - } - } - return checksum -} diff --git a/src/main/kotlin/dev/zapstore/app/components/ProfileImage.kt b/src/main/kotlin/dev/zapstore/app/components/ProfileImage.kt new file mode 100644 index 0000000..3589d3e --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/ProfileImage.kt @@ -0,0 +1,95 @@ +package dev.zapstore.app.components + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.SingletonImageLoader +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.memory.MemoryCache +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import dev.zapstore.app.AppConfig +import dev.zapstore.app.CdnImageVariant +import dev.zapstore.app.ZapstoreApplication +import dev.zapstore.app.cdnImageUrl +import dev.zapstore.app.isHttpUrl +import dev.zapstore.app.profileCdnUrl + +/** + * Avatar from the sealed catalog file, then the Zapstore CDN, then the profile's own picture URL. + * A new [profileVersion] (kind 0 event ID) evicts the cached image so an updated avatar shows. + */ +@Composable +fun ProfileImage( + pubkey: String, + pictureUrl: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + profileVersion: String? = null, +) { + val remoteReady = rememberRemoteImagesReady() + val context = LocalPlatformContext.current + val localAvatar = profileCdnUrl(pubkey)?.let { + (context.applicationContext as? ZapstoreApplication)?.iolite?.avatarFile(pubkey) + }?.takeIf { it.isFile } + val cdnUrl = remember(pubkey) { profileCdnUrl(pubkey)?.let { cdnImageUrl(it, CdnImageVariant.IconSmall) } } + var useFallback by remember(pubkey) { mutableStateOf(cdnUrl == null) } + val fallbackUrl = pictureUrl?.takeIf(::isHttpUrl) + val imageUrl = if (useFallback) fallbackUrl else cdnUrl + var previousVersion by remember(pubkey) { mutableStateOf(null) } + + LaunchedEffect(profileVersion) { + if (profileVersion != null && previousVersion != null && previousVersion != profileVersion) { + evictProfileIconCache(context, listOfNotNull(cdnUrl, fallbackUrl)) + } + if (profileVersion != null) previousVersion = profileVersion + } + + if (localAvatar != null) { + AsyncImage( + model = localAvatar, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else if (remoteReady) imageUrl?.let { url -> + val request = remember(url) { + ImageRequest.Builder(context) + .data(url) + .memoryCacheKey(profileIconCacheKey(url)) + .diskCacheKey(profileIconCacheKey(url)) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .build() + } + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + onError = { if (!useFallback && fallbackUrl != null) useFallback = true }, + modifier = modifier, + ) + } +} + +private fun evictProfileIconCache(context: Context, urls: List) { + val imageLoader = SingletonImageLoader.get(context) + urls.forEach { url -> + val cacheKey = profileIconCacheKey(url) + imageLoader.memoryCache?.remove(MemoryCache.Key(cacheKey)) + imageLoader.diskCache?.remove(cacheKey) + } +} + +/** Cache key that rolls over once per profile cache period so stale avatars refresh on their own. */ +internal fun profileIconCacheKey(url: String, nowMillis: Long = System.currentTimeMillis()): String { + val period = nowMillis / AppConfig.profileCacheDuration.inWholeMilliseconds + return "profile-icon:$period:$url" +} diff --git a/src/main/kotlin/dev/zapstore/app/components/ReleaseFeed.kt b/src/main/kotlin/dev/zapstore/app/components/ReleaseFeed.kt deleted file mode 100644 index b7dcdfc..0000000 --- a/src/main/kotlin/dev/zapstore/app/components/ReleaseFeed.kt +++ /dev/null @@ -1,93 +0,0 @@ -package dev.zapstore.app - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp - -fun LazyListScope.releaseFeed( - state: ReleaseFeedUiState, - emptyMessage: String, - repository: CatalogRepository?, - onAppClick: (identifier: String, author: String) -> Unit, - onProfileClick: (String) -> Unit = {}, - modifier: Modifier, - title: String? = null, - showAuthor: Boolean = true, -) { - title?.let { - item { - SectionTitle( - value = it, - modifier = modifier.padding(top = 4.dp), - ) - } - } - when { - state.entries.isEmpty() && state.initialLoading -> item { - LoadingIndicator(modifier.padding(vertical = 12.dp)) - } - - state.entries.isEmpty() -> item { - StatusText(state.error ?: emptyMessage, modifier) - } - - else -> { - state.error?.let { error -> item { StatusText(error, modifier) } } - items(state.entries, key = { "app:${it.app.event.id}" }) { entry -> - val app = entry.app - AppCard( - app = app, - release = entry.release, - onClick = { onAppClick(app.identifier, app.event.pubKey) }, - onProfileClick = { onProfileClick(app.event.pubKey) }, - repository = repository, - modifier = modifier.testTag("app:${app.event.id}"), - showAuthor = showAuthor, - ) - } - if (state.loadingMore) { - item(key = "release-loading") { - LoadingIndicator( - modifier - .testTag("releaseLoading") - .padding(vertical = 8.dp), - ) - } - } - } - } -} - -@Composable -fun LoadMoreReleasesWhenNearEnd( - listState: LazyListState, - entries: List, - loading: Boolean, - canLoadMore: Boolean, - onLoadMore: () -> Unit, -) { - val prefetchKeys = remember(entries) { - entries.takeLast(RELEASE_PREFETCH_THRESHOLD) - .mapTo(mutableSetOf()) { "app:${it.app.event.id}" } - } - val nearEnd by remember(listState, prefetchKeys) { - derivedStateOf { - listState.layoutInfo.visibleItemsInfo.any { it.key in prefetchKeys } - } - } - LaunchedEffect(nearEnd, loading, canLoadMore) { - if (nearEnd && !loading && canLoadMore) onLoadMore() - } -} - -private const val RELEASE_PREFETCH_THRESHOLD = 3 - diff --git a/src/main/kotlin/dev/zapstore/app/components/RemoteImages.kt b/src/main/kotlin/dev/zapstore/app/components/RemoteImages.kt new file mode 100644 index 0000000..e5f52d0 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/RemoteImages.kt @@ -0,0 +1,16 @@ +package dev.zapstore.app.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.zapstore.app.ZapstoreApplication + +/** True once remote HTTP (CDN icons, avatars) can be fetched on the current network path. */ +@Composable +fun rememberRemoteImagesReady(): Boolean { + val context = LocalContext.current + val network = remember(context) { (context.applicationContext as? ZapstoreApplication)?.network } + if (network == null) return true + return network.remoteReady.collectAsStateWithLifecycle().value +} diff --git a/src/main/kotlin/dev/zapstore/app/components/Skeletons.kt b/src/main/kotlin/dev/zapstore/app/components/Skeletons.kt new file mode 100644 index 0000000..430a0a3 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/Skeletons.kt @@ -0,0 +1,46 @@ +package dev.zapstore.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.zapstore.app.ZapSurface2 + +@Composable +fun SkeletonBlock( + width: Dp, + height: Dp, + cornerRadius: Dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .then(if (width == Dp.Infinity) Modifier.fillMaxWidth() else Modifier.width(width)) + .height(height) + .clip(RoundedCornerShape(cornerRadius)) + .background(ZapSurface2), + ) +} + +/** Three placeholder lines standing in for a paragraph. */ +@Composable +fun ParagraphSkeleton(modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(vertical = 4.dp), + ) { + SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) + SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) + SkeletonBlock(width = 200.dp, height = 16.dp, cornerRadius = 5.dp) + } +} diff --git a/src/main/kotlin/dev/zapstore/app/components/StackCard.kt b/src/main/kotlin/dev/zapstore/app/components/StackCard.kt new file mode 100644 index 0000000..4d32fd9 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/components/StackCard.kt @@ -0,0 +1,71 @@ +package dev.zapstore.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.zapstore.app.R +import dev.zapstore.app.ZapLine +import dev.zapstore.app.ZapRadius +import dev.zapstore.app.ZapSpacing +import dev.zapstore.app.ZapSurface1 +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.StackRecord + +/** A curated stack tile. [apps] is keyed by app ID and may lack entries not in the local catalog. */ +@Composable +fun StackCard( + stack: StackRecord, + apps: Map, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier + .width(176.dp) + .clip(RoundedCornerShape(ZapRadius.lg)) + .background(ZapSurface1) + .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.lg)) + .then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier) + .padding(ZapSpacing.space4), + ) { + Text( + text = stack.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stack.description.ifBlank { + pluralStringResource(R.plurals.curated_apps, stack.apps.size, stack.apps.size) + }, + color = ZapTextSecondary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(ZapSpacing.space3)) + Row(horizontalArrangement = Arrangement.spacedBy(ZapSpacing.space1 + 2.dp)) { + stack.appIds.take(3).forEach { appId -> + val app = apps[appId] + AppIcon(title = app?.name ?: "?", iconUrl = app?.iconUrl, iconFile = app?.iconFile, size = 34.dp) + } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/components/UiPrimitives.kt b/src/main/kotlin/dev/zapstore/app/components/UiPrimitives.kt index 2275e9e..637799c 100644 --- a/src/main/kotlin/dev/zapstore/app/components/UiPrimitives.kt +++ b/src/main/kotlin/dev/zapstore/app/components/UiPrimitives.kt @@ -1,4 +1,15 @@ -package dev.zapstore.app +package dev.zapstore.app.components + +import dev.zapstore.app.ZapAction +import dev.zapstore.app.ZapActionText +import dev.zapstore.app.ZapLine +import dev.zapstore.app.ZapRadius +import dev.zapstore.app.ZapSize +import dev.zapstore.app.ZapSpacing +import dev.zapstore.app.ZapSurface1 +import dev.zapstore.app.ZapTextPrimary +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.app.ZapTextTertiary import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -25,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -117,6 +129,7 @@ fun ZapSearchField( trailingIcon: (@Composable () -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } + val keyboardController = LocalSoftwareKeyboardController.current val shape = RoundedCornerShape(ZapRadius.full) val textStyle = MaterialTheme.typography.bodyMedium.copy(color = ZapTextPrimary) @@ -132,7 +145,10 @@ fun ZapSearchField( textStyle = textStyle, cursorBrush = SolidColor(ZapActionText), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), + keyboardActions = KeyboardActions(onSearch = { + onSearch() + keyboardController?.hide() + }), interactionSource = interactionSource, decorationBox = { innerTextField -> Row( @@ -170,6 +186,3 @@ fun LoadingIndicator( ) } } - -fun isHttpUrl(value: String): Boolean = - value.startsWith("https://") || value.startsWith("http://") diff --git a/src/main/kotlin/dev/zapstore/app/screens/AppDetailScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/AppDetailScreen.kt index c404bf6..2168f60 100644 --- a/src/main/kotlin/dev/zapstore/app/screens/AppDetailScreen.kt +++ b/src/main/kotlin/dev/zapstore/app/screens/AppDetailScreen.kt @@ -1,4 +1,4 @@ -package dev.zapstore.app +package dev.zapstore.app.screens import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -10,9 +10,11 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size @@ -24,14 +26,18 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -41,154 +47,150 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil3.compose.AsyncImage +import dev.zapstore.app.CdnImageVariant +import dev.zapstore.app.MarkdownText +import dev.zapstore.app.R +import dev.zapstore.app.ZapActionText +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.ZapLine +import dev.zapstore.app.ZapRadius +import dev.zapstore.app.ZapSpacing +import dev.zapstore.app.ZapSurface1 +import dev.zapstore.app.ZapSurface2 +import dev.zapstore.app.ZapSurface3 +import dev.zapstore.app.ZapDanger +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.app.ZapVerified +import dev.zapstore.app.ZapWarning +import dev.zapstore.app.cdnImageUrl +import dev.zapstore.app.components.AppIcon +import dev.zapstore.app.components.AppIdentityRow +import dev.zapstore.app.components.AuthorByline +import dev.zapstore.app.components.InfoRow +import dev.zapstore.app.components.ProfileImage +import dev.zapstore.app.components.shortDisplayName +import dev.zapstore.app.components.ParagraphSkeleton +import dev.zapstore.app.components.SkeletonBlock +import dev.zapstore.app.components.LoadingIndicator +import dev.zapstore.app.components.StatusText +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.CommentRecord +import dev.zapstore.iolite.ProfileRecord import java.text.DateFormat import java.text.NumberFormat import java.util.Date -private val SmallIconSize = 26.dp -private val SmallHeaderHeight = 32.dp +private val CompactIconSize = 26.dp +private val CompactHeaderHeight = 32.dp @Composable fun AppDetailScreen( state: AppDetailUiState, onOpenUrl: (String) -> Unit, - repository: CatalogRepository? = null, - onProfileClick: (String) -> Unit = {}, modifier: Modifier = Modifier, + onProfileClick: (String) -> Unit = {}, + onRetryComments: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, ) { var selectedScreenshot by remember { mutableStateOf(null) } + var showDeveloperDescription by remember { mutableStateOf(false) } val listState = rememberLazyListState() + val showCompactHeader by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } val app = state.app - val authorPubkey = app?.let { rememberAppAuthor(it, repository) } - val c1AuthorPubkey = app?.let { rememberC1Author(it, repository) } - val authorProfile = authorPubkey?.let { rememberProfile(it, repository) } - val showCompactHeader by remember { - derivedStateOf { - listState.firstVisibleItemIndex > 0 - } - } + val comments = state.comments + val threaded = remember(comments.threads) { comments.threads.flattenComments() } Box( modifier = modifier .fillMaxSize() .background(ZapCanvas), ) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing), - contentPadding = PaddingValues(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 36.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - if (app == null) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentPadding = PaddingValues(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 36.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (app == null) { + item { if (state.loaded) NotFoundCard() else AppDetailSkeleton() } + return@LazyColumn + } + item { - if (state.appLoading) { - AppDetailSkeleton() - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - .clip(RoundedCornerShape(16.dp)) - .background(ZapSurface2), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.app_not_found), + AppIdentityRow( + name = app.name, + iconUrl = app.iconUrl, + iconFile = app.iconFile, + version = app.version, + authorPubkey = app.authorPubkey, + author = state.author, + onAuthorClick = onProfileClick, + ) + } + + if (app.screenshots.isNotEmpty()) { + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(app.screenshots.take(5), key = { it }) { url -> + AsyncImage( + model = cdnImageUrl(url, CdnImageVariant.ThumbnailSmall), + contentDescription = stringResource(R.string.screenshot_description, app.name), + contentScale = ContentScale.Crop, + modifier = Modifier + .width(120.dp) + .height(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(ZapSurface3) + .clickable { selectedScreenshot = app.screenshots.indexOf(url) }, + ) + } + } + } + } + + if (app.about.isNotBlank()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + MarkdownText( + value = app.about, color = ZapTextSecondary, + style = MaterialTheme.typography.bodyLarge, + onOpenUrl = onOpenUrl, ) + if (app.description.isNotBlank()) { + TextButton( + onClick = { showDeveloperDescription = true }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.testTag("developerDescription"), + ) { + Text( + text = stringResource(R.string.see_developer_description), + color = ZapActionText, + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } - } - state.error?.let { error -> item { StatusText(error) } } - return@LazyColumn - } - - item { - RegularAppHeader( - app = app, - release = state.release, - repository = repository, - authorPubkey = authorPubkey, - authorProfile = authorProfile, - onProfileClick = { authorPubkey?.let(onProfileClick) }, - ) - } - - if (app.screenshots.isNotEmpty()) { - item { - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(app.screenshots.take(5), key = { it }) { url -> - AsyncImage( - model = cdnImageUrl(url, CdnImageVariant.ThumbnailSmall), - contentDescription = stringResource(R.string.screenshot_description, app.name), - contentScale = ContentScale.Crop, - modifier = Modifier - .width(120.dp) - .height(200.dp) - .clip(RoundedCornerShape(16.dp)) - .background(ZapSurface3) - .clickable { - selectedScreenshot = app.screenshots.indexOf(url) - }, - ) - } - } - } - } - - val description = app.event.content.ifBlank { app.summary } - if (description.isNotBlank()) { - item { - MarkdownText( - value = description, - color = ZapTextSecondary, - style = MaterialTheme.typography.bodyLarge, - onOpenUrl = onOpenUrl, - collapsible = true, - ) - } - } - - if (state.zapSummary.isLoading || state.zapSummary.zapCount > 0 || state.zapSummary.error != null) { - item { ZapSummaryCard(state.zapSummary) } - } - - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 8.dp), - ) { - HorizontalDivider(Modifier.weight(1f), color = ZapLine) - Text( - text = stringResource(R.string.latest_release).uppercase(), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 16.dp), - ) - HorizontalDivider(Modifier.weight(1f), color = ZapLine) - } - } - - state.release?.let { release -> - item { VersionRow(release) } - if (release.notes.isNotBlank()) { + } else if (app.description.isNotBlank()) { item { MarkdownText( - value = release.notes, + value = app.description, color = ZapTextSecondary, style = MaterialTheme.typography.bodyLarge, onOpenUrl = onOpenUrl, @@ -196,52 +198,97 @@ fun AppDetailScreen( ) } } - } ?: run { + + if (app.security.isNotBlank() || app.factRows.isNotEmpty()) { + item { SectionDivider(stringResource(R.string.privacy_and_security)) } + if (app.factRows.isNotEmpty()) { + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(ZapSpacing.space2), + verticalArrangement = Arrangement.spacedBy(ZapSpacing.space2), + modifier = Modifier + .fillMaxWidth() + .testTag("facts"), + ) { + app.factRows.forEach { fact -> + FactPill(key = fact.key, yes = fact.yes) + } + } + } + } + if (app.securityWarnings.isNotBlank()) { + item { + Text( + text = app.securityWarnings, + color = ZapWarning, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + if (app.securityBody.isNotBlank()) { + item { + MarkdownText( + value = app.securityBody, + color = ZapTextSecondary, + style = MaterialTheme.typography.bodyLarge, + onOpenUrl = onOpenUrl, + ) + } + } + } + + if (state.zaps.loading || state.zaps.count > 0 || state.zaps.error != null) { + item { ZapSummaryCard(state.zaps) } + } + + item { SectionDivider(stringResource(R.string.latest_release)) } + item { VersionRow(app, onOpenUrl) } + item { - Box( - Modifier - .width(220.dp) - .height(32.dp) - .clip(RoundedCornerShape(8.dp)) - .background(ZapSurface2), + AppInfoCard( + app = app, + author = state.author, + onOpenUrl = onOpenUrl, + onProfileClick = onProfileClick, + modifier = Modifier.padding(top = 8.dp), ) } - item { - ReleaseNotesSkeleton() + + item { SectionDivider(stringResource(R.string.comments)) } + when { + comments.loading && threaded.isEmpty() -> item { + LoadingIndicator(Modifier.padding(vertical = 12.dp).testTag("commentsLoading")) + } + threaded.isEmpty() -> item { + CommentsEmpty( + error = comments.error, + onRetry = onRetryComments, + onSettings = onSettingsClick, + ) + } + else -> items(threaded, key = { it.comment.eventId }) { entry -> + CommentRow( + comment = entry.comment, + author = comments.profiles[entry.comment.pubkey], + depth = entry.depth, + onOpenUrl = onOpenUrl, + onAuthorClick = onProfileClick, + ) + } } } - item { - AppInfoCard( - app = app, - release = state.release, - onOpenUrl = onOpenUrl, - repository = repository, - authorPubkey = authorPubkey, - c1AuthorPubkey = c1AuthorPubkey, - authorProfile = authorProfile, - onProfileClick = { authorPubkey?.let(onProfileClick) }, - modifier = Modifier.padding(top = 8.dp), - ) - } - state.error?.let { error -> item { StatusText(error) } } - } - - if (showCompactHeader) { - state.app?.let { app -> + if (showCompactHeader && app != null) { CompactAppHeader( app = app, - repository = repository, - authorPubkey = authorPubkey, - authorProfile = authorProfile, - onProfileClick = { authorPubkey?.let(onProfileClick) }, + author = state.author, + onProfileClick = onProfileClick, modifier = Modifier.align(Alignment.TopCenter), ) } } - } - state.app?.let { app -> + if (app != null) { selectedScreenshot?.let { initialPage -> ScreenshotCarousel( screenshots = app.screenshots, @@ -250,6 +297,111 @@ fun AppDetailScreen( onDismiss = { selectedScreenshot = null }, ) } + if (showDeveloperDescription && app.description.isNotBlank()) { + MarkdownDialog( + title = stringResource(R.string.developer_description), + markdown = app.description, + onOpenUrl = onOpenUrl, + onDismiss = { showDeveloperDescription = false }, + ) + } + } +} + +@Composable +private fun NotFoundCard() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .clip(RoundedCornerShape(16.dp)) + .background(ZapSurface2), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(R.string.app_not_found), color = ZapTextSecondary) + } +} + +/** Yes is the good outcome for these. Every other fact is an antifeature or sensitive access. */ +private val desirableFactKeys = setOf( + "open_source", + "e2ee", + "offline_capable", + "self_hostable", +) + +/** Ordinary device access. Presence is not a warning and absence is not a virtue. */ +private val neutralFactKeys = setOf("camera", "location") + +internal enum class FactTone { Positive, Negative, Neutral } + +internal fun factTone(key: String, yes: Boolean): FactTone { + if (key in neutralFactKeys) return FactTone.Neutral + val positive = if (key in desirableFactKeys) yes else !yes + return if (positive) FactTone.Positive else FactTone.Negative +} + +internal fun factIsPositive(key: String, yes: Boolean): Boolean = + factTone(key, yes) == FactTone.Positive + +@Composable +private fun FactPill(key: String, yes: Boolean) { + val label = factLabel(key) + val text = if (yes) label else stringResource(R.string.fact_no, factAbsentBody(label)) + val fill = when (factTone(key, yes)) { + FactTone.Positive -> ZapVerified + FactTone.Negative -> ZapDanger + FactTone.Neutral -> ZapTextSecondary + } + Text( + text = text, + color = ZapCanvas, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.5.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(ZapRadius.xs)) + .background(fill) + .padding(horizontal = 8.dp, vertical = 3.dp) + .testTag("fact_$key"), + ) +} + +@Composable +private fun factLabel(key: String): String = when (key) { + "gms" -> stringResource(R.string.fact_gms) + "fcm" -> stringResource(R.string.fact_fcm) + "open_source" -> stringResource(R.string.fact_open_source) + "e2ee" -> "E2EE" + else -> key.replace('_', ' ').replaceFirstChar { it.titlecase() } +} + +/** Keeps brands and acronyms; lowercases a sentence-style label after "No". */ +internal fun factAbsentBody(label: String): String { + val letters = label.filter { it.isLetter() } + val acronym = letters.isNotEmpty() && letters.all { it.isUpperCase() } + val keepCase = acronym || label.startsWith("Google ") || label.startsWith("Firebase ") + return if (keepCase) label else label.replaceFirstChar { it.lowercase() } +} + +@Composable +private fun SectionDivider(title: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp), + ) { + HorizontalDivider(Modifier.weight(1f), color = ZapLine) + Text( + text = title.uppercase(), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp), + ) + HorizontalDivider(Modifier.weight(1f), color = ZapLine) } } @@ -259,115 +411,27 @@ private fun AppDetailSkeleton() { modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) { SkeletonBlock(74.dp, 74.dp, 16.dp) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { SkeletonBlock(width = 190.dp, height = 22.dp, cornerRadius = 6.dp) SkeletonBlock(width = 140.dp, height = 16.dp, cornerRadius = 6.dp) } } - SkeletonBlock( - width = 160.dp, - height = 18.dp, - cornerRadius = 6.dp, - ) LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(4) { - SkeletonBlock( - width = 120.dp, - height = 200.dp, - cornerRadius = 16.dp, - ) - } - } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) - SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) - SkeletonBlock(width = 180.dp, height = 16.dp, cornerRadius = 5.dp) + items(4) { SkeletonBlock(width = 120.dp, height = 200.dp, cornerRadius = 16.dp) } } + ParagraphSkeleton() SkeletonBlock(width = 220.dp, height = 32.dp, cornerRadius = 8.dp) - ReleaseNotesSkeleton() - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(ZapSurface1) - .border(1.dp, ZapLine, RoundedCornerShape(16.dp)) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - repeat(4) { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - SkeletonBlock(width = 0.dp, height = 16.dp, cornerRadius = 5.dp, modifier = Modifier.weight(1f)) - SkeletonBlock(width = 80.dp, height = 16.dp, cornerRadius = 5.dp) - } - } - } + ParagraphSkeleton() } } -@Composable -private fun ReleaseNotesSkeleton() { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(vertical = 4.dp), - ) { - SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) - SkeletonBlock(width = Dp.Infinity, height = 16.dp, cornerRadius = 5.dp) - SkeletonBlock(width = 200.dp, height = 16.dp, cornerRadius = 5.dp) - } -} - -@Composable -private fun SkeletonBlock( - width: Dp, - height: Dp, - cornerRadius: Dp, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .then(if (width == Dp.Infinity) Modifier.fillMaxWidth() else Modifier.width(width)) - .height(height) - .clip(RoundedCornerShape(cornerRadius)) - .background(ZapSurface2), - ) -} - -@Composable -private fun RegularAppHeader( - app: AppInfo, - release: ReleaseInfo?, - repository: CatalogRepository?, - authorPubkey: String?, - authorProfile: ProfileInfo?, - onProfileClick: () -> Unit, -) { - val authorName = authorPubkey?.let { profileDisplayName(authorProfile, it) } - AppIdentityRow( - name = app.name, - iconUrl = app.iconUrl, - authorName = authorName, - authorProfile = authorProfile, - authorPubkey = authorPubkey, - version = release?.version, - onAuthorClick = onProfileClick, - ) -} - @Composable private fun CompactAppHeader( - app: AppInfo, - repository: CatalogRepository?, - authorPubkey: String?, - authorProfile: ProfileInfo?, - onProfileClick: () -> Unit, + app: AppRecord, + author: ProfileRecord?, + onProfileClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -375,16 +439,12 @@ private fun CompactAppHeader( .fillMaxWidth() .background(ZapCanvas) .windowInsetsPadding(WindowInsets.statusBars) - .height(SmallHeaderHeight) + .height(CompactHeaderHeight) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - AppIcon( - title = app.name, - iconUrl = app.iconUrl, - size = SmallIconSize, - ) + AppIcon(title = app.name, iconUrl = app.iconUrl, iconFile = app.iconFile, size = CompactIconSize) Text( text = app.name, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Black), @@ -392,13 +452,8 @@ private fun CompactAppHeader( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - authorPubkey?.let { pubkey -> - AppAuthorByline( - pubkey = pubkey, - repository = repository, - profile = authorProfile, - onClick = onProfileClick, - ) + app.authorPubkey?.let { pubkey -> + AuthorByline(pubkey = pubkey, profile = author, onClick = { onProfileClick(pubkey) }) } } } @@ -412,10 +467,7 @@ private fun ScreenshotCarousel( ) { Dialog( onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), ) { val pagerState = rememberPagerState( initialPage = initialPage.coerceIn(0, screenshots.lastIndex), @@ -426,16 +478,10 @@ private fun ScreenshotCarousel( .fillMaxSize() .background(Color.Black), ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> AsyncImage( model = cdnImageUrl(screenshots[page], CdnImageVariant.ThumbnailLarge), - contentDescription = stringResource( - R.string.screenshot_description, - appName, - ), + contentDescription = stringResource(R.string.screenshot_description, appName), contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() @@ -467,9 +513,8 @@ private fun ScreenshotCarousel( } } - @Composable -private fun ZapSummaryCard(summary: ZapSummaryUiState) { +private fun ZapSummaryCard(summary: ZapSummary) { Column( modifier = Modifier .fillMaxWidth() @@ -485,22 +530,14 @@ private fun ZapSummaryCard(summary: ZapSummaryUiState) { color = ZapTextSecondary, ) Spacer(Modifier.height(4.dp)) - if (summary.isLoading && summary.zapCount == 0) { - Box( - Modifier - .width(148.dp) - .height(24.dp) - .clip(RoundedCornerShape(6.dp)) - .background(ZapSurface2), - ) - } else if (summary.zapCount == 0) { - StatusText(summary.error.orEmpty()) - } else { - Text( + when { + summary.loading && summary.count == 0 -> SkeletonBlock(width = 148.dp, height = 24.dp, cornerRadius = 6.dp) + summary.count == 0 -> StatusText(summary.error.orEmpty()) + else -> Text( text = stringResource( R.string.zap_summary, NumberFormat.getInstance().format(summary.totalSats), - summary.zapCount, + summary.count, ), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, @@ -509,9 +546,11 @@ private fun ZapSummaryCard(summary: ZapSummaryUiState) { } } - @Composable -private fun VersionRow(release: ReleaseInfo) { +private fun VersionRow(app: AppRecord, onOpenUrl: (String) -> Unit) { + val notes = app.releaseNotes + val hasNotes = notes.isNotBlank() + var showNotes by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -519,31 +558,113 @@ private fun VersionRow(release: ReleaseInfo) { .clip(RoundedCornerShape(ZapRadius.md)) .background(ZapSurface2) .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.md)) - .padding(horizontal = ZapSpacing.space3, vertical = ZapSpacing.space2), + .padding( + start = ZapSpacing.space3, + top = if (hasNotes) 0.dp else ZapSpacing.space2, + end = if (hasNotes) 0.dp else ZapSpacing.space3, + bottom = if (hasNotes) 0.dp else ZapSpacing.space2, + ), ) { StatusText(stringResource(R.string.version)) Spacer(Modifier.width(6.dp)) Text( - text = release.version, + text = app.version, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) Spacer(Modifier.width(6.dp)) - StatusText("(${formatDate(release.event.createdAt)})") + StatusText("(${formatDate(app.releasedAt)})") + if (hasNotes) { + IconButton( + onClick = { showNotes = true }, + modifier = Modifier.testTag("releaseNotes"), + ) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = stringResource(R.string.release_notes), + tint = ZapTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } + } + if (showNotes) { + MarkdownDialog( + title = stringResource(R.string.release_notes), + markdown = notes, + onOpenUrl = onOpenUrl, + onDismiss = { showNotes = false }, + ) } } +@Composable +private fun MarkdownDialog( + title: String, + markdown: String, + onOpenUrl: (String) -> Unit, + onDismiss: () -> Unit, +) { + val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight) + .clip(RoundedCornerShape(ZapRadius.lg)) + .background(ZapSurface1) + .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.lg)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = ZapSpacing.space4, end = 4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.close), + tint = ZapTextSecondary, + ) + } + } + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding( + start = ZapSpacing.space4, + end = ZapSpacing.space4, + bottom = ZapSpacing.space4, + ), + ) { + MarkdownText( + value = markdown, + color = ZapTextSecondary, + style = MaterialTheme.typography.bodyLarge, + onOpenUrl = onOpenUrl, + ) + } + } + } +} @Composable private fun AppInfoCard( - app: AppInfo, - release: ReleaseInfo?, + app: AppRecord, + author: ProfileRecord?, onOpenUrl: (String) -> Unit, - repository: CatalogRepository?, - authorPubkey: String?, - c1AuthorPubkey: String?, - authorProfile: ProfileInfo?, - onProfileClick: () -> Unit, + onProfileClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -560,65 +681,122 @@ private fun AppInfoCard( link = app.repository, onOpenUrl = onOpenUrl, ) - app.license?.let { - InfoRow(stringResource(R.string.license), it, onOpenUrl = onOpenUrl) - } - InfoRow(stringResource(R.string.app_id), app.identifier, onOpenUrl = onOpenUrl) - authorPubkey?.let { pubkey -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - ) { - StatusText(stringResource(R.string.author)) - Spacer(Modifier.weight(1f)) - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd, - ) { - AppAuthorByline( - pubkey = pubkey, - repository = repository, - profile = authorProfile, - showBy = false, - onClick = onProfileClick, - ) - } - } - } - c1AuthorPubkey?.let { pubkey -> + app.website?.let { InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) } + app.license?.let { InfoRow(stringResource(R.string.license), it) } + InfoRow(stringResource(R.string.app_id), app.appId) + app.authorPubkey?.let { pubkey -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp), ) { - StatusText("C1 author") + StatusText(stringResource(if (app.isVerified) R.string.verified_author else R.string.author)) Spacer(Modifier.weight(1f)) - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd, - ) { - AppAuthorByline( - pubkey = pubkey, - repository = repository, - showBy = false, - ) + AuthorByline(pubkey = pubkey, profile = author, showBy = false, onClick = { onProfileClick(pubkey) }) + } + } + InfoRow(stringResource(R.string.release_date), formatDate(app.releasedAt)) + } +} + +@Composable +private fun CommentsEmpty( + error: CommentsError?, + onRetry: () -> Unit, + onSettings: () -> Unit, +) { + Column( + modifier = Modifier.testTag("commentsEmpty"), + verticalArrangement = Arrangement.spacedBy(ZapSpacing.space2), + ) { + StatusText( + stringResource( + when (error) { + CommentsError.RelaysUnreachable -> R.string.comments_error_relays + CommentsError.TimedOut -> R.string.comments_error_timeout + null -> R.string.no_comments + }, + ), + ) + if (error != null) { + Row(horizontalArrangement = Arrangement.spacedBy(ZapSpacing.space2)) { + TextButton(onClick = onRetry, modifier = Modifier.testTag("commentsRetry")) { + Text(stringResource(R.string.try_again)) + } + TextButton(onClick = onSettings, modifier = Modifier.testTag("commentsNetworkSettings")) { + Text(stringResource(R.string.network_settings)) } } } - release?.let { - InfoRow( - stringResource(R.string.release_date), - formatDate(it.event.createdAt), + } +} + +@Composable +private fun CommentRow( + comment: CommentRecord, + author: ProfileRecord?, + onOpenUrl: (String) -> Unit, + onAuthorClick: (String) -> Unit, + depth: Int = 0, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = ZapSpacing.space5 * depth) + .testTag("commentDepth:${comment.eventId}:$depth"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ZapRadius.md)) + .background(ZapSurface1) + .border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.md)) + .padding(ZapSpacing.space3) + .testTag("comment:${comment.eventId}"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { onAuthorClick(comment.pubkey) } + .testTag("profile:${comment.pubkey}"), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(ZapSurface3), + ) { + ProfileImage( + pubkey = comment.pubkey, + pictureUrl = author?.picture, + profileVersion = author?.eventId, + contentDescription = stringResource(R.string.profile_avatar_description, shortDisplayName(comment.pubkey, author)), + modifier = Modifier.fillMaxSize(), + ) + } + Text( + text = shortDisplayName(comment.pubkey, author), + color = ZapTextSecondary, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + StatusText(formatDate(comment.createdAt)) + } + MarkdownText( + value = comment.content, + color = ZapTextSecondary, + style = MaterialTheme.typography.bodyMedium, onOpenUrl = onOpenUrl, ) } } } - private fun formatDate(createdAt: Long): String = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(createdAt * 1_000)) - diff --git a/src/main/kotlin/dev/zapstore/app/screens/AppDetailViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/AppDetailViewModel.kt new file mode 100644 index 0000000..472fd7c --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/AppDetailViewModel.kt @@ -0,0 +1,130 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.AppConfig +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.CommentRecord +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.ProfileRecord +import dev.zapstore.iolite.Query +import dev.zapstore.iolite.QueryState +import dev.zapstore.iolite.ZapRecord +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +data class ZapSummary( + val count: Int = 0, + val totalSats: Long = 0, + val loading: Boolean = true, + val error: String? = null, +) + +data class CommentsState( + val threads: List = emptyList(), + val profiles: Map = emptyMap(), + val loading: Boolean = true, + val error: CommentsError? = null, +) + +data class AppDetailUiState( + val app: AppRecord? = null, + /** False until the first SQLite read; afterwards a null [app] means "not in the catalog". */ + val loaded: Boolean = false, + val author: ProfileRecord? = null, + val zaps: ZapSummary = ZapSummary(), + val comments: CommentsState = CommentsState(), +) + +@OptIn(ExperimentalCoroutinesApi::class) +class AppDetailViewModel( + private val iolite: Iolite, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val appId: String = requireNotNull(savedStateHandle[Routes.APP_ID_ARG]) + + private val app: Flow = iolite.observeApp(appId) + + private val author: Flow = app + .map { it?.authorPubkey } + .distinctUntilChanged() + .flatMapLatest { pubkey -> + if (pubkey == null) flowOf(null) else iolite.query(Query.profile(pubkey), AppConfig.profileQuery).map { it.items } + } + + private val zaps: Flow>?> = app + .map { it?.coordinate } + .distinctUntilChanged() + .flatMapLatest { coordinate -> + if (coordinate == null) flowOf(null) else iolite.query(Query.zaps(coordinate, limit = ZAP_LIMIT), AppConfig.zapQuery) + } + + private val comments: Flow>?> = app + .map { it?.coordinate } + .distinctUntilChanged() + .flatMapLatest { coordinate -> + if (coordinate == null) { + flowOf(null) + } else { + iolite.query(Query.comments(coordinate, limit = COMMENT_LIMIT), AppConfig.commentQuery) + } + } + + private val commentProfiles: Flow> = comments + .map { state -> state?.items.orEmpty().map(CommentRecord::pubkey).toSet() } + .distinctUntilChanged() + .flatMapLatest { pubkeys -> + if (pubkeys.isEmpty()) { + flowOf(emptyMap()) + } else { + iolite.query(Query.profiles(pubkeys), AppConfig.commentProfileQuery).map { it.items } + } + } + + val uiState: StateFlow = combine(app, author, zaps, comments, commentProfiles) { app, author, zaps, comments, commentProfiles -> + AppDetailUiState( + app = app, + loaded = true, + author = author, + zaps = ZapSummary( + count = zaps?.items?.size ?: 0, + totalSats = zaps?.items?.sumOf(ZapRecord::amountSats) ?: 0, + loading = zaps?.isLoading ?: false, + error = zaps?.error?.message, + ), + comments = CommentsState( + threads = threadComments(comments?.items.orEmpty()), + profiles = commentProfiles, + loading = comments?.isLoading ?: false, + error = comments?.error.toCommentsError(), + ), + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), AppDetailUiState()) + + fun retryComments() { + iolite.refreshConnections() + } + + companion object { + /** The summary is a recent-activity signal, not a ledger. */ + private const val ZAP_LIMIT = 500 + private const val COMMENT_LIMIT = 50 + + fun factory(iolite: Iolite): ViewModelProvider.Factory = viewModelFactory { + initializer { AppDetailViewModel(iolite, createSavedStateHandle()) } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/CommentThreads.kt b/src/main/kotlin/dev/zapstore/app/screens/CommentThreads.kt new file mode 100644 index 0000000..94d756a --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/CommentThreads.kt @@ -0,0 +1,51 @@ +package dev.zapstore.app.screens + +import dev.zapstore.iolite.CommentRecord +import dev.zapstore.iolite.QueryError + +data class CommentThread( + val comment: CommentRecord, + val replies: List, +) + +data class ThreadedComment( + val comment: CommentRecord, + val depth: Int, +) + +enum class CommentsError { + RelaysUnreachable, + TimedOut, +} + +/** Roots newest-first; replies stay under their parent, oldest first. Missing parents become roots. */ +fun threadComments(items: List): List { + val ids = items.mapTo(hashSetOf()) { it.eventId } + val children = items.groupBy { comment -> + comment.parentEventId?.takeIf { it in ids } + } + fun build(comment: CommentRecord, trail: Set): CommentThread { + if (comment.eventId in trail) return CommentThread(comment, emptyList()) + val next = trail + comment.eventId + val replies = children[comment.eventId].orEmpty() + .sortedBy { it.createdAt } + .map { build(it, next) } + return CommentThread(comment, replies) + } + return children[null].orEmpty() + .sortedByDescending { it.createdAt } + .map { build(it, emptySet()) } +} + +fun List.flattenComments(maxDepth: Int = 4): List = + flatMap { it.flatten(0, maxDepth) } + +private fun CommentThread.flatten(depth: Int, maxDepth: Int): List = + listOf(ThreadedComment(comment, depth.coerceAtMost(maxDepth))) + + replies.flatMap { it.flatten(depth + 1, maxDepth) } + +internal fun QueryError?.toCommentsError(): CommentsError? = when (this) { + is QueryError.Timeout -> CommentsError.TimedOut + is QueryError.RelayFailure, is QueryError.IncompatibleDependency -> CommentsError.RelaysUnreachable + else -> null +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/HomeScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/HomeScreen.kt index 79bd85b..2fb2b81 100644 --- a/src/main/kotlin/dev/zapstore/app/screens/HomeScreen.kt +++ b/src/main/kotlin/dev/zapstore/app/screens/HomeScreen.kt @@ -1,4 +1,4 @@ -package dev.zapstore.app +package dev.zapstore.app.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -23,6 +23,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,53 +36,51 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import dev.zapstore.app.R +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.ZapSize +import dev.zapstore.app.ZapTextTertiary +import dev.zapstore.app.components.AppCard +import dev.zapstore.app.components.LoadMoreWhenNearEnd +import dev.zapstore.app.components.LoadingIndicator +import dev.zapstore.app.components.SectionTitle +import dev.zapstore.app.components.StackCard +import dev.zapstore.app.components.StatusText +import dev.zapstore.app.components.ZapSearchField +import dev.zapstore.app.components.appList +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.StackRecord @Composable fun HomeScreen( state: HomeUiState, - repository: CatalogRepository? = null, onSearchQueryChanged: (String) -> Unit, onSearchSubmitted: () -> Unit, onSearchCleared: () -> Unit, - onUpdatesClick: () -> Unit = {}, - updateCount: Int = 0, - databaseRowCount: Long = 0, - catalogError: String? = null, - onStackClick: (String) -> Unit, - onAppClick: (identifier: String, author: String) -> Unit, - onProfileClick: (String) -> Unit = {}, - onLoadMoreReleases: () -> Unit = {}, + onAppClick: (AppRecord) -> Unit, + onStackClick: (StackRecord) -> Unit, modifier: Modifier = Modifier, + onProfileClick: (String) -> Unit = {}, + onUpdatesClick: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, + updateCount: Int = 0, + onLoadMore: () -> Unit = {}, ) { val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } val latestReleases = stringResource(R.string.latest_releases) val noReleases = stringResource(R.string.no_releases) - LoadMoreReleasesWhenNearEnd( - listState = listState, - entries = state.releaseFeed.entries, - loading = state.releaseFeed.loadingMore, - canLoadMore = state.releaseFeed.canLoadMore, - onLoadMore = onLoadMoreReleases, - ) + LoadMoreWhenNearEnd(listState, state.feed, onLoadMore) + LaunchedEffect(state.submittedQuery) { + listState.scrollToItem(0) + } + Column( modifier = modifier .fillMaxSize() .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() @@ -111,7 +110,8 @@ fun HomeScreen( }, modifier = Modifier .size(ZapSize.control) - .semantics { contentDescription = clearDescription }, + .semantics { contentDescription = clearDescription } + .testTag("clearSearch"), ) { Icon( painter = painterResource(R.drawable.ic_close), @@ -128,10 +128,7 @@ fun HomeScreen( .testTag("searchField"), ) Box(contentAlignment = Alignment.TopEnd) { - IconButton( - onClick = onUpdatesClick, - modifier = Modifier.testTag("updatesButton"), - ) { + IconButton(onClick = onUpdatesClick, modifier = Modifier.testTag("updatesButton")) { Icon( painter = painterResource(R.drawable.ic_launcher), contentDescription = stringResource(R.string.updates), @@ -152,67 +149,71 @@ fun HomeScreen( ) } } + IconButton(onClick = onSettingsClick, modifier = Modifier.testTag("settingsButton")) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = stringResource(R.string.settings), + tint = ZapTextTertiary, + modifier = Modifier.size(22.dp), + ) + } } LazyColumn( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("homeList"), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp), state = listState, ) { - state.searchMessage?.let { message -> - item { StatusText(message, Modifier.testTag("searchStatus")) } - } - items(state.searchResults, key = { it.address }) { app -> - AppCard( - app = app, - onClick = { onAppClick(app.identifier, app.event.pubKey) }, - onProfileClick = { onProfileClick(app.event.pubKey) }, - repository = repository, - modifier = Modifier.testTag("searchResult:${app.address}"), - ) + state.searchResults?.let { results -> + item { + StatusText( + value = stringResource(R.string.search_results, results.size, state.searchDurationMillis ?: 0L), + modifier = Modifier.testTag("searchStatus"), + ) + } + items(results, key = { "search:${it.appId}" }) { app -> + AppCard( + app = app, + author = app.authorPubkey?.let(state.profiles::get), + onClick = { onAppClick(app) }, + onAuthorClick = onProfileClick, + modifier = Modifier.testTag("searchResult:${app.appId}"), + ) + } } - item { - SectionTitle( - value = stringResource(R.string.curated_stacks), - modifier = Modifier.padding(top = 16.dp), - ) - } + item { SectionTitle(stringResource(R.string.curated_stacks), Modifier.padding(top = 16.dp)) } item { when { - state.stacks.isNotEmpty() -> { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(end = 8.dp), - ) { - items(state.stacks, key = { it.event.id }) { stack -> - StackCard( - stack = stack, - appsByAddress = state.stackApps, - onClick = { onStackClick(stack.event.id) }, - modifier = Modifier.testTag("stack:${stack.event.id}"), - ) - } + state.stacks.isNotEmpty() -> LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(end = 8.dp), + ) { + items(state.stacks, key = { it.eventId }) { stack -> + StackCard( + stack = stack, + apps = state.stackApps, + onClick = { onStackClick(stack) }, + modifier = Modifier.testTag("stack:${stack.identifier}"), + ) } } - state.stacksLoading -> LoadingIndicator(Modifier.padding(vertical = 12.dp)) state.stacksError != null -> StatusText(state.stacksError) else -> StatusText(stringResource(R.string.no_stacks)) } } - releaseFeed( - state = state.releaseFeed, + appList( + state = state.feed, title = latestReleases, emptyMessage = noReleases, - repository = repository, onAppClick = onAppClick, - onProfileClick = onProfileClick, - modifier = Modifier, + onAuthorClick = onProfileClick, ) } } } - diff --git a/src/main/kotlin/dev/zapstore/app/screens/HomeViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/HomeViewModel.kt new file mode 100644 index 0000000..0ede560 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/HomeViewModel.kt @@ -0,0 +1,183 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.components.AppListState +import dev.zapstore.iolite.AppFilter +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.CatalogStackPubkey +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.ProfileRecord +import dev.zapstore.iolite.Query +import dev.zapstore.iolite.QueryState +import dev.zapstore.iolite.StackRecord +import dev.zapstore.iolite.normalizeSearchQuery +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import kotlin.time.TimeSource + +data class HomeUiState( + val searchQuery: String = "", + /** The query last submitted with Enter; empty until then. */ + val submittedQuery: String = "", + /** Null when no search is active; empty when the query matched nothing. */ + val searchResults: List? = null, + /** Encode plus first ranking pass for [searchResults]. Null when no search is active. */ + val searchDurationMillis: Long? = null, + val stacks: List = emptyList(), + val stackApps: Map = emptyMap(), + val stacksLoading: Boolean = true, + val stacksError: String? = null, + val feed: AppListState = AppListState(), + val profiles: Map = emptyMap(), +) + +/** + * Home: local search, catalog stacks, and the recently-updated feed. Every flow here is a SQLite + * observer and stops with the collector. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModel( + private val iolite: Iolite, + private val savedStateHandle: SavedStateHandle, + private val encodeQuery: (String) -> ByteArray?, +) : ViewModel() { + private val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY_KEY, "") + private val submittedQuery = savedStateHandle.getStateFlow(SUBMITTED_QUERY_KEY, "") + private val requestedPages = MutableStateFlow(1) + + private val search: Flow = submittedQuery + .map { it.trim() } + .distinctUntilChanged() + .flatMapLatest { raw -> + val query = normalizeSearchQuery(raw) + if (query.length < MIN_SEARCH_LENGTH) { + flowOf(SearchSnapshot()) + } else { + flow { + val started = TimeSource.Monotonic.markNow() + val vector = try { + withContext(Dispatchers.Default) { encodeQuery(query) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + var durationMillis: Long? = null + iolite.observeApps(AppFilter(search = query, queryVector = vector, limit = SEARCH_LIMIT)).collect { apps -> + if (durationMillis == null) durationMillis = started.elapsedNow().inWholeMilliseconds + emit(SearchSnapshot(apps, durationMillis)) + } + } + } + } + + private class FeedPage(val apps: List, val limit: Int) + + private val feedPage: Flow = requestedPages.flatMapLatest { pages -> + val limit = pages * PAGE_SIZE + iolite.observeApps(AppFilter(limit = limit)).map { FeedPage(it, limit) } + } + + private val stacks: Flow>> = + iolite.query(Query.stacks(CatalogStackPubkey, limit = STACK_LIMIT)) + + private val stackApps: Flow> = stacks + .map { state -> state.items.flatMap(StackRecord::appIds).toSet() } + .distinctUntilChanged() + .flatMapLatest(iolite::observeAppsById) + + private val profiles: Flow> = + combine(feedPage, search) { page, snapshot -> page.apps + snapshot.results.orEmpty() }.authorProfiles(iolite) + + val uiState: StateFlow = combine( + combine(searchQuery, submittedQuery, search, ::Triple), + feedPage, + combine(stacks, stackApps, ::Pair), + profiles, + requestedPages, + ) { (query, submitted, snapshot), page, (stackState, stackApps), profiles, pages -> + HomeUiState( + searchQuery = query, + submittedQuery = submitted, + searchResults = snapshot.results, + searchDurationMillis = snapshot.durationMillis, + stacks = stackState.items, + stackApps = stackApps, + stacksLoading = stackState.items.isEmpty() && stackState.isLoading, + stacksError = stackState.error?.message, + feed = AppListState( + apps = page.apps, + profiles = profiles, + loading = false, + loadingMore = page.limit < pages * PAGE_SIZE, + canLoadMore = page.apps.size >= page.limit, + ), + profiles = profiles, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + HomeUiState(searchQuery = searchQuery.value, submittedQuery = submittedQuery.value), + ) + + fun onSearchQueryChanged(value: String) { + savedStateHandle[SEARCH_QUERY_KEY] = value + } + + fun submitSearch() { + val query = searchQuery.value.trim() + savedStateHandle[SEARCH_QUERY_KEY] = query + savedStateHandle[SUBMITTED_QUERY_KEY] = query + } + + fun clearSearch() { + savedStateHandle[SEARCH_QUERY_KEY] = "" + savedStateHandle[SUBMITTED_QUERY_KEY] = "" + } + + fun loadMore() { + val current = uiState.value.feed + if (current.canLoadMore && !current.loadingMore) requestedPages.update { it + 1 } + } + + companion object { + private const val SEARCH_QUERY_KEY = "searchQuery" + private const val SUBMITTED_QUERY_KEY = "submittedQuery" + private const val MIN_SEARCH_LENGTH = 2 + private const val SEARCH_LIMIT = 12 + private const val PAGE_SIZE = 20 + private const val STACK_LIMIT = 20 + + fun factory(iolite: Iolite, encodeQuery: (String) -> ByteArray?): ViewModelProvider.Factory = viewModelFactory { + initializer { HomeViewModel(iolite, createSavedStateHandle(), encodeQuery) } + } + } +} + +internal const val STOP_TIMEOUT_MILLIS = 5_000L + +/** [results] is null until a query is long enough to run. [durationMillis] is that first pass. */ +private data class SearchSnapshot( + val results: List? = null, + val durationMillis: Long? = null, +) diff --git a/src/main/kotlin/dev/zapstore/app/screens/NotificationsScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/NotificationsScreen.kt deleted file mode 100644 index c495e95..0000000 --- a/src/main/kotlin/dev/zapstore/app/screens/NotificationsScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -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.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp - -@Composable -fun NotificationsScreen(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.notifications), - style = MaterialTheme.typography.displaySmall, - ) - StatusText(stringResource(R.string.no_notifications)) - } -} - diff --git a/src/main/kotlin/dev/zapstore/app/screens/ProfileScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/ProfileScreen.kt index 04ccec2..966eea5 100644 --- a/src/main/kotlin/dev/zapstore/app/screens/ProfileScreen.kt +++ b/src/main/kotlin/dev/zapstore/app/screens/ProfileScreen.kt @@ -1,5 +1,6 @@ -package dev.zapstore.app +package dev.zapstore.app.screens +import android.content.ClipData import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -16,11 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import android.content.ClipData import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -33,63 +32,44 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import dev.zapstore.app.MarkdownText +import dev.zapstore.app.R +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.ZapLine +import dev.zapstore.app.ZapSurface1 +import dev.zapstore.app.ZapSurface2 +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.app.components.InfoRow +import dev.zapstore.app.components.LoadingIndicator +import dev.zapstore.app.components.ProfileImage +import dev.zapstore.app.components.StatusText +import dev.zapstore.app.components.appList +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.toNpub import kotlinx.coroutines.launch @Composable fun ProfileScreen( state: ProfileUiState, onOpenUrl: (String) -> Unit, - repository: CatalogRepository? = null, - onAppClick: (identifier: String, author: String) -> Unit = { _, _ -> }, - onLoadMoreReleases: () -> Unit = {}, + onAppClick: (AppRecord) -> Unit, modifier: Modifier = Modifier, ) { - val listState = rememberLazyListState() - val noProfileApps = stringResource(R.string.no_profile_apps) + val noApps = stringResource(R.string.no_profile_apps) val clipboard = LocalClipboard.current val coroutineScope = rememberCoroutineScope() - LoadMoreReleasesWhenNearEnd( - listState = listState, - entries = state.releaseFeed.entries, - loading = state.releaseFeed.loadingMore, - canLoadMore = state.releaseFeed.canLoadMore, - onLoadMore = onLoadMoreReleases, - ) + val npub = remember(state.pubkey) { state.pubkey.toNpub() } + val profile = state.profile + val title = profile?.displayNameOrNpub ?: npub LazyColumn( modifier = modifier .fillMaxSize() .background(ZapCanvas) .navigationBarsPadding(), - state = listState, contentPadding = PaddingValues(bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - val profile = state.profile - if (profile == null) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - .clip(RoundedCornerShape(16.dp)) - .background(ZapSurface2), - contentAlignment = Alignment.Center, - ) { - if (state.profileLoading) { - LoadingIndicator() - } else { - Text( - text = stringResource(R.string.profile_not_found), - color = ZapTextSecondary, - ) - } - } - } - state.error?.let { error -> item { StatusText(error) } } - return@LazyColumn - } - item { Row( modifier = Modifier @@ -97,27 +77,37 @@ fun ProfileScreen( .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - ProfileAvatar( - pubkey = state.pubkey, - name = profile.displayName ?: profile.name ?: state.pubkey, - pictureUrl = profile.picture, - profileVersion = profile.event.id, - size = 84.dp, - ) + Box( + modifier = Modifier + .size(84.dp) + .clip(CircleShape) + .background(ZapSurface2), + contentAlignment = Alignment.Center, + ) { + ProfileImage( + pubkey = state.pubkey, + pictureUrl = profile?.picture, + profileVersion = profile?.eventId, + contentDescription = stringResource(R.string.profile_avatar_description, title), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } Spacer(Modifier.width(16.dp)) Column { Text( - text = profile.displayName ?: profile.name ?: state.pubkey, + text = title, style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Black), + maxLines = 2, ) - profile.name?.takeIf { it != profile.displayName }?.let { - StatusText("@$it") - } + profile?.name?.takeIf { it != profile.displayName }?.let { StatusText("@$it") } + if (profile == null && state.profileLoading) LoadingIndicator(Modifier.padding(top = 8.dp)) } } } - profile.about?.takeIf(String::isNotBlank)?.let { about -> + profile?.about?.takeIf(String::isNotBlank)?.let { about -> item { MarkdownText( value = about, @@ -130,18 +120,16 @@ fun ProfileScreen( } } - releaseFeed( - state = state.releaseFeed, - emptyMessage = noProfileApps, - repository = repository, + appList( + state = state.apps, + emptyMessage = noApps, onAppClick = onAppClick, modifier = Modifier.padding(horizontal = 16.dp), showAuthor = false, ) - state.error?.let { error -> item { StatusText(error) } } + state.error?.let { item { StatusText(it, Modifier.padding(horizontal = 16.dp)) } } item { - val npub = remember(state.pubkey) { state.pubkey.toNpub() } Column( modifier = Modifier .fillMaxWidth() @@ -151,51 +139,16 @@ fun ProfileScreen( .border(1.dp, ZapLine, RoundedCornerShape(16.dp)) .padding(horizontal = 16.dp, vertical = 8.dp), ) { - profile.website?.let { - InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) - } - profile.nip05?.let { - InfoRow(stringResource(R.string.nip05), it, onOpenUrl = onOpenUrl) - } + profile?.website?.let { InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) } + profile?.nip05?.let { InfoRow(stringResource(R.string.nip05), it) } InfoRow( label = stringResource(R.string.npub), value = npub, onCopy = { - coroutineScope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("npub", npub))) - } + coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("npub", npub))) } }, ) } } } } - - -@Composable -private fun ProfileAvatar( - pubkey: String, - name: String, - pictureUrl: String?, - profileVersion: String, - size: androidx.compose.ui.unit.Dp, -) { - Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(ZapSurface2), - contentAlignment = Alignment.Center, - ) { - ProfileImage( - pubkey = pubkey, - pictureUrl = pictureUrl, - profileVersion = profileVersion, - contentDescription = stringResource(R.string.profile_avatar_description, name), - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), - ) - } -} - diff --git a/src/main/kotlin/dev/zapstore/app/screens/ProfileViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/ProfileViewModel.kt new file mode 100644 index 0000000..8a23cb8 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/ProfileViewModel.kt @@ -0,0 +1,53 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.AppConfig +import dev.zapstore.app.components.AppListState +import dev.zapstore.iolite.AppFilter +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.ProfileRecord +import dev.zapstore.iolite.Query +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +data class ProfileUiState( + val pubkey: String, + val profile: ProfileRecord? = null, + val profileLoading: Boolean = true, + val apps: AppListState = AppListState(), + val error: String? = null, +) + +class ProfileViewModel( + iolite: Iolite, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val pubkey: String = requireNotNull(savedStateHandle[Routes.PUBKEY_ARG]) + + private val profile = iolite.query(Query.profile(pubkey), AppConfig.profileQuery) + private val apps = iolite.observeApps(AppFilter(author = pubkey)) + + val uiState: StateFlow = combine(profile, apps) { profileState, apps -> + ProfileUiState( + pubkey = pubkey, + profile = profileState.items, + profileLoading = profileState.items == null && profileState.isLoading, + apps = AppListState(apps = apps, loading = false), + error = profileState.error?.message, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), ProfileUiState(pubkey)) + + companion object { + fun factory(iolite: Iolite): ViewModelProvider.Factory = viewModelFactory { + initializer { ProfileViewModel(iolite, createSavedStateHandle()) } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/Routes.kt b/src/main/kotlin/dev/zapstore/app/screens/Routes.kt new file mode 100644 index 0000000..8541043 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/Routes.kt @@ -0,0 +1,22 @@ +package dev.zapstore.app.screens + +import android.net.Uri + +/** Navigation routes and the argument keys their ViewModels read from `SavedStateHandle`. */ +object Routes { + const val APP_ID_ARG = "appId" + const val STACK_AUTHOR_ARG = "author" + const val STACK_ID_ARG = "identifier" + const val PUBKEY_ARG = "pubkey" + + const val HOME = "home" + const val UPDATES = "updates" + const val SETTINGS = "settings" + const val APP = "app/{$APP_ID_ARG}" + const val STACK = "stack/{$STACK_AUTHOR_ARG}/{$STACK_ID_ARG}" + const val PROFILE = "profile/{$PUBKEY_ARG}" + + fun app(appId: String): String = "app/${Uri.encode(appId)}" + fun stack(author: String, identifier: String): String = "stack/${Uri.encode(author)}/${Uri.encode(identifier)}" + fun profile(pubkey: String): String = "profile/${Uri.encode(pubkey)}" +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/ScreenQueries.kt b/src/main/kotlin/dev/zapstore/app/screens/ScreenQueries.kt new file mode 100644 index 0000000..9757720 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/ScreenQueries.kt @@ -0,0 +1,29 @@ +package dev.zapstore.app.screens + +import dev.zapstore.app.AppConfig +import dev.zapstore.iolite.AppFilter +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.ProfileRecord +import dev.zapstore.iolite.Query +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** Stored kind 0s for [pubkeys], fetched from relays under [AppConfig.profileQuery] when allowed. */ +internal fun Iolite.observeProfiles(pubkeys: Set): Flow> = + if (pubkeys.isEmpty()) flowOf(emptyMap()) else query(Query.profiles(pubkeys), AppConfig.profileQuery).map { it.items } + +/** Profiles for the authors of whatever list of apps [this] emits. */ +@OptIn(ExperimentalCoroutinesApi::class) +internal fun Flow>.authorProfiles(iolite: Iolite): Flow> = + map { apps -> apps.mapNotNull(AppRecord::authorPubkey).toSet() } + .distinctUntilChanged() + .flatMapLatest(iolite::observeProfiles) + +/** Listings for [appIds], keyed by app ID; apps missing from the local catalog are absent. */ +internal fun Iolite.observeAppsById(appIds: Collection): Flow> = + if (appIds.isEmpty()) flowOf(emptyMap()) else observeApps(AppFilter(appIds = appIds)).map { it.associateBy(AppRecord::appId) } diff --git a/src/main/kotlin/dev/zapstore/app/screens/SettingsScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/SettingsScreen.kt new file mode 100644 index 0000000..cce9bd5 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/SettingsScreen.kt @@ -0,0 +1,197 @@ +package dev.zapstore.app.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import android.text.format.Formatter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.delay +import dev.zapstore.app.R +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.ZapTextSecondary +import dev.zapstore.app.components.EvidenceText +import dev.zapstore.app.components.SectionLabel +import dev.zapstore.app.components.StatusText +import dev.zapstore.app.transport.NetworkMode +import dev.zapstore.app.transport.TorRuntimeState + +@Composable +fun SettingsScreen( + state: SettingsUiState, + onNetworkModeChange: (NetworkMode) -> Unit, + onSync: () -> Unit, + onWipe: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column( + modifier = modifier + .fillMaxSize() + .background(ZapCanvas) + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text(text = stringResource(R.string.settings), style = MaterialTheme.typography.displaySmall) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionLabel(stringResource(R.string.network)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + FilterChip( + selected = state.networkMode == NetworkMode.TorOnly, + onClick = { onNetworkModeChange(NetworkMode.TorOnly) }, + label = { Text(stringResource(R.string.network_tor)) }, + modifier = Modifier.testTag("networkTor"), + ) + FilterChip( + selected = state.networkMode == NetworkMode.DirectOnly, + onClick = { onNetworkModeChange(NetworkMode.DirectOnly) }, + label = { Text(stringResource(R.string.network_direct)) }, + modifier = Modifier.testTag("networkDirect"), + ) + FilterChip( + selected = state.networkMode == NetworkMode.TorWithDirectFallback, + onClick = { onNetworkModeChange(NetworkMode.TorWithDirectFallback) }, + label = { Text(stringResource(R.string.network_tor_fallback)) }, + modifier = Modifier.testTag("networkTorFallback"), + ) + } + StatusText( + networkStatusText(state.networkMode, state.torState), + Modifier.testTag("networkStatus"), + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionLabel(stringResource(R.string.catalog)) + EvidenceText(state.catalogUrl, color = ZapTextSecondary) + StatusText(stringResource(R.string.catalog_epoch, state.catalogEpoch)) + StatusText(stringResource(R.string.catalog_apps, state.appCount)) + state.status.lastSyncedAtMillis?.let { syncedAt -> + StatusText(lastSyncRelativeText(syncedAt), Modifier.testTag("lastSyncAgo")) + } + if (state.status.lastSyncNotModified) { + StatusText( + stringResource(R.string.last_sync_not_modified), + Modifier.testTag("lastSyncNotModified"), + ) + } else { + state.status.lastSyncBytes?.let { bytes -> + StatusText( + stringResource(R.string.last_sync_size, Formatter.formatFileSize(context, bytes)), + Modifier.testTag("lastSyncSize"), + ) + } + } + state.status.lastSyncDurationMillis?.let { durationMs -> + StatusText( + stringResource( + R.string.last_sync_duration, + String.format(Locale.getDefault(), "%.2f", durationMs / 1000.0), + ), + Modifier.testTag("lastSyncDuration"), + ) + } + EvidenceText( + stringResource( + R.string.local_storage, + Formatter.formatFileSize(context, state.localStorageBytes), + ), + color = ZapTextSecondary, + modifier = Modifier.testTag("localStorage"), + ) + state.status.error?.let { StatusText(it, Modifier.testTag("catalogError")) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + onClick = onSync, + enabled = !state.status.syncing, + modifier = Modifier.testTag("syncCatalog"), + ) { + Text(stringResource(if (state.status.syncing) R.string.syncing else R.string.sync_now)) + } + OutlinedButton( + onClick = onWipe, + enabled = !state.status.syncing, + modifier = Modifier.testTag("wipeLocalDb"), + ) { + Text(stringResource(R.string.wipe_local_db)) + } + } + } + } +} + +@Composable +private fun lastSyncRelativeText(syncedAtMillis: Long): String { + var now by remember(syncedAtMillis) { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(syncedAtMillis) { + while (true) { + now = System.currentTimeMillis() + delay(30_000) + } + } + val elapsed = (now - syncedAtMillis).coerceAtLeast(0) + val minutes = (elapsed / 60_000L).toInt() + val hours = (elapsed / 3_600_000L).toInt() + return when { + minutes < 1 -> stringResource(R.string.last_sync_just_now) + minutes < 60 -> pluralStringResource(R.plurals.last_sync_minutes_ago, minutes, minutes) + hours < 48 -> pluralStringResource(R.plurals.last_sync_hours_ago, hours, hours) + else -> stringResource( + R.string.last_synced, + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(syncedAtMillis)), + ) + } +} + +@Composable +private fun networkStatusText(mode: NetworkMode, torState: TorRuntimeState): String = when (mode) { + NetworkMode.DirectOnly -> stringResource(R.string.network_direct_warning) + NetworkMode.TorOnly -> when (torState) { + TorRuntimeState.Bootstrapping -> stringResource(R.string.tor_bootstrapping) + TorRuntimeState.Ready -> stringResource(R.string.tor_ready) + TorRuntimeState.Dormant -> stringResource(R.string.tor_dormant) + TorRuntimeState.Failed -> stringResource(R.string.tor_failed) + TorRuntimeState.Stopped -> stringResource(R.string.tor_stopped) + } + NetworkMode.TorWithDirectFallback -> when (torState) { + TorRuntimeState.Bootstrapping -> stringResource(R.string.tor_bootstrapping) + TorRuntimeState.Ready -> stringResource(R.string.tor_ready) + TorRuntimeState.Dormant -> stringResource(R.string.tor_dormant) + TorRuntimeState.Failed, TorRuntimeState.Stopped -> stringResource(R.string.tor_failed_fallback) + } +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/SettingsViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/SettingsViewModel.kt new file mode 100644 index 0000000..2dd211a --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/SettingsViewModel.kt @@ -0,0 +1,107 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.catalog.CatalogSync +import dev.zapstore.app.catalog.SyncStatus +import dev.zapstore.app.transport.NetworkMode +import dev.zapstore.app.transport.NetworkRuntime +import dev.zapstore.app.transport.TorRuntimeState +import dev.zapstore.iolite.CatalogRecord +import dev.zapstore.iolite.Iolite +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class SettingsUiState( + val status: SyncStatus = SyncStatus(), + val catalogUrl: String = "", + val catalogEpoch: Long = 0, + val appCount: Int = 0, + val localStorageBytes: Long = 0, + val networkMode: NetworkMode = NetworkMode.TorOnly, + val torState: TorRuntimeState = TorRuntimeState.Stopped, +) + +class SettingsViewModel( + iolite: Iolite, + private val catalogSync: CatalogSync, + private val network: NetworkRuntime, + private val measureLocalStorage: () -> Long, +) : ViewModel() { + private val localStorageBytes = MutableStateFlow(0L) + + val uiState: StateFlow = combine( + combine( + catalogSync.status, + iolite.observeCatalogs(), + iolite.observeApps().map { it.size }, + localStorageBytes, + ) { status, catalogs, appCount, storageBytes -> + SettingsSnapshot(status, catalogs.firstOrNull(), appCount, storageBytes) + }, + network.mode, + network.torState, + ) { snapshot, mode, torState -> + SettingsUiState( + status = snapshot.status, + catalogUrl = snapshot.catalog?.relays?.joinToString("\n") { it.url }.orEmpty(), + catalogEpoch = snapshot.catalog?.epoch ?: 0, + appCount = snapshot.appCount, + localStorageBytes = snapshot.localStorageBytes, + networkMode = mode, + torState = torState, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), SettingsUiState()) + + init { + viewModelScope.launch { + combine(iolite.observeApps(), iolite.observeCatalogs(), catalogSync.status) { _, _, _ -> } + .collect { refreshLocalStorage() } + } + } + + fun setNetworkMode(mode: NetworkMode) = network.setMode(mode) + + fun sync() { + viewModelScope.launch { catalogSync.sync() } + } + + fun wipe() { + viewModelScope.launch { + catalogSync.wipe() + network.clearArtiCache() + refreshLocalStorage() + } + } + + private suspend fun refreshLocalStorage() { + localStorageBytes.value = withContext(Dispatchers.IO) { measureLocalStorage() } + } + + companion object { + fun factory( + iolite: Iolite, + catalogSync: CatalogSync, + network: NetworkRuntime, + measureLocalStorage: () -> Long, + ): ViewModelProvider.Factory = + viewModelFactory { initializer { SettingsViewModel(iolite, catalogSync, network, measureLocalStorage) } } + } +} + +private data class SettingsSnapshot( + val status: SyncStatus, + val catalog: CatalogRecord?, + val appCount: Int, + val localStorageBytes: Long, +) diff --git a/src/main/kotlin/dev/zapstore/app/screens/StackDetailScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/StackDetailScreen.kt index 35b18b6..983dfde 100644 --- a/src/main/kotlin/dev/zapstore/app/screens/StackDetailScreen.kt +++ b/src/main/kotlin/dev/zapstore/app/screens/StackDetailScreen.kt @@ -1,4 +1,4 @@ -package dev.zapstore.app +package dev.zapstore.app.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -9,7 +9,6 @@ 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.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,15 +16,23 @@ 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.R +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.components.LoadingIndicator +import dev.zapstore.app.components.StackCard +import dev.zapstore.app.components.StatusText +import dev.zapstore.app.components.appList +import dev.zapstore.iolite.AppRecord @Composable fun StackDetailScreen( state: StackDetailUiState, - repository: CatalogRepository? = null, - onAppClick: (identifier: String, author: String) -> Unit, - onProfileClick: (String) -> Unit = {}, + onAppClick: (AppRecord) -> Unit, modifier: Modifier = Modifier, + onProfileClick: (String) -> Unit = {}, ) { + val appsTitle = stringResource(R.string.apps) + val noApps = stringResource(R.string.no_stack_apps) LazyColumn( modifier = modifier .fillMaxSize() @@ -40,52 +47,21 @@ fun StackDetailScreen( if (state.stackLoading) { LoadingIndicator(Modifier.padding(vertical = 20.dp)) } else { - Text( - text = stringResource(R.string.stack_not_found), - style = MaterialTheme.typography.headlineSmall, - ) + Text(text = stringResource(R.string.stack_not_found), style = MaterialTheme.typography.headlineSmall) } } - state.error?.let { error -> item { StatusText(error) } } + state.error?.let { item { StatusText(it) } } return@LazyColumn } - item { - StackCard( - stack = stack, - appsByAddress = state.appsByAddress, - modifier = Modifier.testTag("stack:${stack.event.id}"), - ) - } - item { - SectionTitle( - value = stringResource(R.string.apps), - modifier = Modifier.padding(top = 12.dp), - ) - } - items( - items = stack.appAddresses.mapNotNull(state.appsByAddress::get), - key = { it.address }, - ) { app -> - AppCard( - app = app, - release = state.releasesByAddress[app.address], - onClick = { onAppClick(app.identifier, app.event.pubKey) }, - onProfileClick = { onProfileClick(app.event.pubKey) }, - repository = repository, - modifier = Modifier.testTag("stackApp:${app.address}"), - ) - } - if (state.appsByAddress.isEmpty()) { - item { - if (state.appsLoading) { - LoadingIndicator(Modifier.padding(vertical = 12.dp)) - } else { - StatusText(stringResource(R.string.no_stack_apps)) - } - } - } - state.error?.let { error -> item { StatusText(error) } } + item { StackCard(stack = stack, apps = state.appsById, modifier = Modifier.testTag("stack:${stack.identifier}")) } + appList( + state = state.apps, + title = appsTitle, + emptyMessage = noApps, + onAppClick = onAppClick, + onAuthorClick = onProfileClick, + ) + state.error?.let { item { StatusText(it) } } } } - diff --git a/src/main/kotlin/dev/zapstore/app/screens/StackDetailViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/StackDetailViewModel.kt new file mode 100644 index 0000000..ff184ab --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/StackDetailViewModel.kt @@ -0,0 +1,71 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.components.AppListState +import dev.zapstore.iolite.AppRecord +import dev.zapstore.iolite.Iolite +import dev.zapstore.iolite.Query +import dev.zapstore.iolite.StackRecord +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +data class StackDetailUiState( + val stack: StackRecord? = null, + val stackLoading: Boolean = true, + /** Listings in stack order; apps not in the local catalog are omitted. */ + val apps: AppListState = AppListState(), + val appsById: Map = emptyMap(), + val error: String? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class StackDetailViewModel( + private val iolite: Iolite, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val author: String = requireNotNull(savedStateHandle[Routes.STACK_AUTHOR_ARG]) + private val identifier: String = requireNotNull(savedStateHandle[Routes.STACK_ID_ARG]) + + private val stack = iolite.query(Query.stack(author, identifier)) + + private val appsById: Flow> = stack + .map { it.items?.appIds.orEmpty() } + .distinctUntilChanged() + .flatMapLatest(iolite::observeAppsById) + + private val profiles = appsById.map { it.values.toList() }.authorProfiles(iolite) + + val uiState: StateFlow = combine(stack, appsById, profiles) { stackState, appsById, profiles -> + val stack = stackState.items + StackDetailUiState( + stack = stack, + stackLoading = stack == null && stackState.isLoading, + apps = AppListState( + apps = stack?.appIds.orEmpty().mapNotNull(appsById::get), + profiles = profiles, + loading = false, + ), + appsById = appsById, + error = stackState.error?.message, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), StackDetailUiState()) + + companion object { + fun factory(iolite: Iolite): ViewModelProvider.Factory = viewModelFactory { + initializer { StackDetailViewModel(iolite, createSavedStateHandle()) } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/screens/UpdatesScreen.kt b/src/main/kotlin/dev/zapstore/app/screens/UpdatesScreen.kt index a1ba66a..37ed0b4 100644 --- a/src/main/kotlin/dev/zapstore/app/screens/UpdatesScreen.kt +++ b/src/main/kotlin/dev/zapstore/app/screens/UpdatesScreen.kt @@ -1,4 +1,4 @@ -package dev.zapstore.app +package dev.zapstore.app.screens import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -22,15 +22,20 @@ 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 dev.zapstore.app.R +import dev.zapstore.app.ZapCanvas +import dev.zapstore.app.components.AppCard +import dev.zapstore.app.components.LoadingIndicator +import dev.zapstore.app.components.StatusText +import dev.zapstore.iolite.AppRecord import java.text.DateFormat import java.util.Date @Composable fun UpdatesScreen( - state: CatalogSyncUiState, + state: UpdatesUiState, onSync: () -> Unit, - onAppClick: (identifier: String, author: String?) -> Unit = { _, _ -> }, + onAppClick: (AppRecord) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -41,67 +46,52 @@ fun UpdatesScreen( .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 -> + Text(text = stringResource(R.string.updates), style = MaterialTheme.typography.displaySmall) + state.status.lastSyncedAtMillis?.let { synced -> StatusText( stringResource( R.string.last_synced, - DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) - .format(Date(synced)), + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(synced)), ), ) } } FilledTonalButton( onClick = onSync, - enabled = !state.syncing, + enabled = !state.status.syncing, modifier = Modifier.testTag("syncCatalog"), ) { - Text(stringResource(if (state.syncing) R.string.syncing else R.string.sync_now)) + Text(stringResource(if (state.status.syncing) R.string.syncing else R.string.sync_now)) } } - state.error?.let { StatusText(it, Modifier.testTag("updatesError")) } - if (state.syncing && state.availableUpdates.isEmpty()) { - LoadingIndicator() - } + state.status.error?.let { StatusText(it, Modifier.testTag("updatesError")) } + LazyColumn( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 24.dp), ) { - if (state.availableUpdates.isEmpty() && !state.syncing) { + if (state.updates.isEmpty()) { item { - StatusText( - stringResource(R.string.no_updates), - Modifier.testTag("noUpdates"), - ) + if (!state.loaded || state.status.syncing) { + LoadingIndicator() + } else { + 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) + items(state.updates, key = { it.app.appId }) { update -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.testTag("update:${update.app.appId}")) { + AppCard(app = update.app, author = null, onClick = { onAppClick(update.app) }, showAuthor = false) StatusText( - stringResource( - R.string.update_version, - update.installedVersion, - update.availableVersion, - ), + stringResource(R.string.update_version, update.installedVersion, update.app.version), + Modifier.padding(horizontal = 4.dp), ) } } diff --git a/src/main/kotlin/dev/zapstore/app/screens/UpdatesViewModel.kt b/src/main/kotlin/dev/zapstore/app/screens/UpdatesViewModel.kt new file mode 100644 index 0000000..b50cb14 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/screens/UpdatesViewModel.kt @@ -0,0 +1,37 @@ +package dev.zapstore.app.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.zapstore.app.catalog.AvailableUpdate +import dev.zapstore.app.catalog.CatalogSync +import dev.zapstore.app.catalog.SyncStatus +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class UpdatesUiState( + val status: SyncStatus = SyncStatus(), + val updates: List = emptyList(), + val loaded: Boolean = false, +) + +class UpdatesViewModel(private val catalogSync: CatalogSync) : ViewModel() { + val uiState: StateFlow = combine(catalogSync.status, catalogSync.updates) { status, updates -> + UpdatesUiState(status, updates, loaded = true) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), UpdatesUiState()) + + fun sync() { + viewModelScope.launch { catalogSync.sync() } + } + + companion object { + fun factory(catalogSync: CatalogSync): ViewModelProvider.Factory = viewModelFactory { + initializer { UpdatesViewModel(catalogSync) } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/search/LeafQueryEncoder.kt b/src/main/kotlin/dev/zapstore/app/search/LeafQueryEncoder.kt new file mode 100644 index 0000000..9f23b91 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/search/LeafQueryEncoder.kt @@ -0,0 +1,162 @@ +package dev.zapstore.app.search + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.res.Configuration +import dev.zapstore.iolite.normalizeSearchQuery +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException + +/** + * Encodes a search query with bundled leaf-ir-v1. + * The call blocks while the model runs; callers should leave the main thread. + * The session stays open for the search session and closes under memory pressure. + */ +class LeafQueryEncoder private constructor( + private val context: Context?, + private val explicitDir: File?, +) : ComponentCallbacks2 { + constructor(context: Context) : this(context, null) + + internal constructor(modelDir: File) : this(null, modelDir) + + private val lock = Any() + private var loaded: Loaded? = null + + fun encode(query: String): ByteArray? { + val text = normalizeSearchQuery(query) + if (text.isEmpty()) return null + return try { + synchronized(lock) { embedLocked(QUERY_PROMPT + text) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + warn("query encoding failed", error) + null + } + } + + fun release() { + synchronized(lock) { + loaded?.session?.close() + loaded = null + } + } + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onTrimMemory(level: Int) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) release() + } + + @Deprecated("Deprecated in Java") + override fun onLowMemory() { + release() + } + + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + private fun embedLocked(text: String): ByteArray { + val model = load() + val ids = model.vocab.encode(text) + val idTensor = OnnxTensor.createTensor(model.env, arrayOf(ids)) + val maskTensor = OnnxTensor.createTensor(model.env, arrayOf(LongArray(ids.size) { 1L })) + val typeTensor = OnnxTensor.createTensor(model.env, arrayOf(LongArray(ids.size))) + try { + val inputs = linkedMapOf() + if ("input_ids" in model.session.inputNames) inputs["input_ids"] = idTensor + if ("attention_mask" in model.session.inputNames) inputs["attention_mask"] = maskTensor + if ("token_type_ids" in model.session.inputNames) inputs["token_type_ids"] = typeTensor + check(inputs.isNotEmpty()) { "onnx: no known inputs" } + model.session.run(inputs).use { result -> + val output = result.get(0) as OnnxTensor + val dims = output.info.shape + check(dims.size == 3 && dims[1] == ids.size.toLong() && dims[2] == HIDDEN_DIM.toLong()) { + "onnx: unexpected shape ${dims.contentToString()}" + } + val hidden = FloatArray(ids.size * HIDDEN_DIM) + output.floatBuffer.get(hidden) + val pooled = FloatArray(HIDDEN_DIM) + for (token in ids.indices) { + val offset = token * HIDDEN_DIM + for (dim in 0 until HIDDEN_DIM) pooled[dim] += hidden[offset + dim] + } + val count = ids.size.toFloat() + for (dim in pooled.indices) pooled[dim] /= count + return projectQuantize(pooled, model.weight, model.bias) + } + } finally { + idTensor.close() + maskTensor.close() + typeTensor.close() + } + } + + private fun load(): Loaded { + loaded?.let { return it } + val dir = explicitDir ?: installFromAssets() + val vocab = WordPiece.load(File(dir, "vocab.txt")) + val (weight, bias) = loadDense(File(dir, "dense.safetensors")) + val env = OrtEnvironment.getEnvironment() + val options = OrtSession.SessionOptions() + try { + options.setIntraOpNumThreads(1) + options.setInterOpNumThreads(1) + options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT) + val session = env.createSession(File(dir, "model_quantized.onnx").absolutePath, options) + return Loaded(env, vocab, weight, bias, session).also { loaded = it } + } finally { + options.close() + } + } + + private fun installFromAssets(): File { + val appContext = context ?: error("query model unavailable") + val dest = File(appContext.filesDir, "leaf-ir-v1") + val marker = File(dest, "revision") + if (marker.isFile && marker.readText() == MODEL_REV && File(dest, "model_quantized.onnx").isFile) return dest + dest.mkdirs() + try { + for (name in MODEL_FILES) { + appContext.assets.open("leaf-ir-v1/$name").use { input -> + File(dest, "$name.tmp").outputStream().use { output -> input.copyTo(output) } + } + val tmp = File(dest, "$name.tmp") + val file = File(dest, name) + if (!tmp.renameTo(file)) { + tmp.copyTo(file, overwrite = true) + tmp.delete() + } + } + } catch (error: IOException) { + throw IOException("query model is not in the app", error) + } + marker.writeText(MODEL_REV) + return dest + } + + private class Loaded( + val env: OrtEnvironment, + val vocab: WordPiece, + val weight: FloatArray, + val bias: FloatArray, + val session: OrtSession, + ) + + private companion object { + const val QUERY_PROMPT = "Represent this sentence for searching relevant passages: " + const val MODEL_REV = "4262131b32c3182bd06e67e92ae69d7bd66e0c5c" + val MODEL_FILES = listOf("vocab.txt", "dense.safetensors", "model_quantized.onnx", "model_quantized.onnx_data") + + fun warn(message: String, error: Throwable) { + try { + android.util.Log.w("LeafQueryEncoder", message, error) + } catch (_: RuntimeException) { + // Host unit tests link the Android stub, which throws on Log. + } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/search/LeafVectors.kt b/src/main/kotlin/dev/zapstore/app/search/LeafVectors.kt new file mode 100644 index 0000000..070e2a7 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/search/LeafVectors.kt @@ -0,0 +1,75 @@ +package dev.zapstore.app.search + +import dev.zapstore.iolite.EMBEDDING_DIMS +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.sqrt +import org.json.JSONObject + +internal const val HIDDEN_DIM = 384 + +private const val QUANT_ABS = 0.3 +private const val QUANT_MAX = 127 + +internal fun quantize(values: FloatArray): ByteArray { + val scale = QUANT_ABS / QUANT_MAX + return ByteArray(values.size) { index -> + roundHalfAway(values[index] / scale).coerceIn(-QUANT_MAX, QUANT_MAX).toByte() + } +} + +internal fun loadDense(file: File): Pair { + val raw = file.readBytes() + require(raw.size >= 8) { "dense: short file" } + val headerLength = ByteBuffer.wrap(raw, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long + require(headerLength > 0 && 8 + headerLength <= raw.size) { "dense: bad header" } + val headerEnd = (8 + headerLength).toInt() + val header = JSONObject(raw.decodeToString(8, headerEnd)) + val weight = f32Tensor(header, raw, headerEnd, "linear.weight") + val bias = f32Tensor(header, raw, headerEnd, "linear.bias") + require(weight.size == HIDDEN_DIM * EMBEDDING_DIMS && bias.size == EMBEDDING_DIMS) { + "dense: want ${HIDDEN_DIM}x$EMBEDDING_DIMS, got ${weight.size} + ${bias.size}" + } + return weight to bias +} + +internal fun projectQuantize(pooled: FloatArray, weight: FloatArray, bias: FloatArray): ByteArray { + val output = bias.copyOf() + for (row in 0 until EMBEDDING_DIMS) { + var sum = 0f + val offset = row * HIDDEN_DIM + for (column in 0 until HIDDEN_DIM) sum += pooled[column] * weight[offset + column] + output[row] += sum + } + var norm = 0.0 + for (value in output) norm += value.toDouble() * value.toDouble() + val length = sqrt(norm) + if (length != 0.0) { + val inverse = (1.0 / length).toFloat() + for (index in output.indices) output[index] *= inverse + } + return quantize(output) +} + +private fun f32Tensor(header: JSONObject, raw: ByteArray, bodyStart: Int, name: String): FloatArray { + val meta = header.getJSONObject(name) + require(meta.getString("dtype") == "F32") { "dense: $name dtype" } + val offsets = meta.getJSONArray("data_offsets") + val start = offsets.getInt(0) + val end = offsets.getInt(1) + val bytes = raw.copyOfRange(bodyStart + start, bodyStart + end) + val out = FloatArray(bytes.size / 4) + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + for (index in out.indices) out[index] = buffer.float + return out +} + +/** Matches Go math.Round: ties move away from zero. */ +private fun roundHalfAway(value: Double): Int = when { + value.isNaN() -> 0 + value >= 0 -> floor(value + 0.5).toInt() + else -> ceil(value - 0.5).toInt() +} diff --git a/src/main/kotlin/dev/zapstore/app/search/WordPiece.kt b/src/main/kotlin/dev/zapstore/app/search/WordPiece.kt new file mode 100644 index 0000000..e60d914 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/search/WordPiece.kt @@ -0,0 +1,130 @@ +package dev.zapstore.app.search + +import java.io.File + +/** WordPiece tokenizer matching the sealer's leaf-ir-v1 encoder. */ +internal class WordPiece( + private val ids: Map, + private val unk: Int, + private val cls: Int, + private val sep: Int, +) { + fun encode(text: String): LongArray { + val out = ArrayList(64) + out.add(cls.toLong()) + for (word in basicTokens(text.lowercase())) { + for (id in wordPiece(word)) out.add(id) + if (out.size >= MAX_TOKENS - 1) break + } + while (out.size > MAX_TOKENS - 1) out.removeAt(out.lastIndex) + out.add(sep.toLong()) + return out.toLongArray() + } + + private fun wordPiece(word: String): LongArray { + if (word.isEmpty()) return LongArray(0) + ids[word]?.let { return longArrayOf(it.toLong()) } + val out = ArrayList() + var start = 0 + while (start < word.length) { + var end = word.length + var found: Int? = null + while (end > start) { + val piece = word.substring(start, end) + val key = if (start > 0) "##$piece" else piece + val id = ids[key] + if (id != null) { + found = id + break + } + val prev = word.offsetByCodePoints(end, -1) + if (prev == end) break + end = prev + } + if (found == null) return longArrayOf(unk.toLong()) + out.add(found.toLong()) + start = end + } + return out.toLongArray() + } + + companion object { + private const val MAX_TOKENS = 512 + + fun load(file: File): WordPiece { + val ids = HashMap(30_000) + file.bufferedReader().useLines { lines -> + lines.forEach { line -> ids[line] = ids.size } + } + fun required(token: String) = ids[token] ?: error("vocab missing $token") + return WordPiece(ids, required("[UNK]"), required("[CLS]"), required("[SEP]")) + } + } +} + +private fun basicTokens(text: String): List { + val builder = StringBuilder(text.length) + var index = 0 + while (index < text.length) { + val code = text.codePointAt(index) + index += Character.charCount(code) + when { + code == 0 || (isControl(code) && !isSpace(code)) -> Unit + isCjk(code) -> { + builder.append(' ') + builder.appendCodePoint(code) + builder.append(' ') + } + isSpace(code) -> builder.append(' ') + else -> builder.appendCodePoint(code) + } + } + return builder.split(' ').filter { it.isNotEmpty() }.flatMap(::splitPunct) +} + +private fun splitPunct(word: String): List { + val out = ArrayList() + val current = StringBuilder() + var index = 0 + while (index < word.length) { + val code = word.codePointAt(index) + index += Character.charCount(code) + if (isPunctOrSymbol(code)) { + if (current.isNotEmpty()) { + out.add(current.toString()) + current.clear() + } + out.add(Character.toString(code)) + } else { + current.appendCodePoint(code) + } + } + if (current.isNotEmpty()) out.add(current.toString()) + return out +} + +private fun isControl(code: Int): Boolean = Character.getType(code) == Character.CONTROL.toInt() + +private fun isSpace(code: Int): Boolean = Character.isWhitespace(code) + +private fun isCjk(code: Int): Boolean = + Character.UnicodeScript.of(code) == Character.UnicodeScript.HAN || + code in 0x3040..0x30FF || + code in 0x3400..0x4DBF || + code in 0x20000..0x2A6DF + +private fun isPunctOrSymbol(code: Int): Boolean = when (Character.getType(code)) { + Character.CONNECTOR_PUNCTUATION.toInt(), + Character.DASH_PUNCTUATION.toInt(), + Character.START_PUNCTUATION.toInt(), + Character.END_PUNCTUATION.toInt(), + Character.INITIAL_QUOTE_PUNCTUATION.toInt(), + Character.FINAL_QUOTE_PUNCTUATION.toInt(), + Character.OTHER_PUNCTUATION.toInt(), + Character.MATH_SYMBOL.toInt(), + Character.CURRENCY_SYMBOL.toInt(), + Character.MODIFIER_SYMBOL.toInt(), + Character.OTHER_SYMBOL.toInt(), + -> true + else -> false +} diff --git a/src/main/kotlin/dev/zapstore/app/transport/AppTransport.kt b/src/main/kotlin/dev/zapstore/app/transport/AppTransport.kt new file mode 100644 index 0000000..ecef009 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/transport/AppTransport.kt @@ -0,0 +1,289 @@ +package dev.zapstore.app.transport + +import dev.zapstore.iolite.HttpResponse +import dev.zapstore.iolite.HttpTransport +import dev.zapstore.iolite.WebSocketFactory +import dev.zapstore.iolite.WebSocketListener +import dev.zapstore.iolite.WebSocketSession +import okhttp3.Call +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +enum class NetworkMode { + TorOnly, + DirectOnly, + TorWithDirectFallback, + ; + + val usesTor: Boolean get() = this != DirectOnly + val allowsDirectFallback: Boolean get() = this == TorWithDirectFallback + + companion object { + fun fromStored(value: String?): NetworkMode = when (value) { + "Direct", DirectOnly.name -> DirectOnly + "TorFallback", TorWithDirectFallback.name -> TorWithDirectFallback + else -> TorOnly + } + } +} + +enum class TorRuntimeState { + Stopped, + Bootstrapping, + Ready, + Dormant, + Failed, +} + +/** Remote image hosts can be reached without throwing [TorUnavailableException]. */ +fun remoteImagesReady(mode: NetworkMode, torState: TorRuntimeState): Boolean = + mode == NetworkMode.DirectOnly || + torState == TorRuntimeState.Ready || + torState == TorRuntimeState.Dormant || + (mode.allowsDirectFallback && + (torState == TorRuntimeState.Failed || torState == TorRuntimeState.Stopped)) + +class TorUnavailableException(message: String) : IllegalStateException(message) + +interface ArtiEngine : AutoCloseable { + val socksPort: Int + fun start() + fun stop() + /** Drop the in-process TorClient so cache files can be deleted. */ + fun destroy() +} + +data class AppTransport( + val http: HttpTransport, + val webSocket: WebSocketFactory, + val callFactory: Call.Factory, +) + +object AppTransports { + fun create(mode: NetworkMode, arti: ArtiEngine? = null): AppTransport = when (mode) { + NetworkMode.DirectOnly -> directTransport() + NetworkMode.TorOnly, NetworkMode.TorWithDirectFallback -> { + val socksPort = arti?.socksPort ?: 0 + when { + socksPort > 0 -> torTransport(socksPort) + mode.allowsDirectFallback -> directTransport() + arti == null -> throw TorUnavailableException( + "Tor is selected and Arti is not available; refusing Direct fallback", + ) + else -> throw TorUnavailableException( + "Tor is selected and Arti is not ready; refusing Direct fallback", + ) + } + } + } + + fun directTransport(): AppTransport { + val transport = OkHttpAppTransport(directClient()) + return AppTransport(transport, transport, transport.client) + } + + fun torTransport(socksPort: Int): AppTransport { + val anonymous = OkHttpAppTransport(socksClient(socksPort)) + val identity = OkHttpAppTransport(socksClient(socksPort)) + return AppTransport(anonymous, identity, anonymous.client) + } + + fun directImageTransport(): AppTransport { + val transport = OkHttpAppTransport(imageDirectClient()) + return AppTransport(transport, transport, transport.client) + } + + fun torImageTransport(socksPort: Int): AppTransport { + val transport = OkHttpAppTransport(imageSocksClient(socksPort)) + return AppTransport(transport, transport, transport.client) + } + + fun split( + mode: () -> NetworkMode, + torState: () -> TorRuntimeState, + direct: AppTransport, + tor: () -> AppTransport?, + ): AppTransport { + val router = SplitRouter(mode, torState, direct, tor) + return AppTransport(router, router, router) + } +} + +fun isLoopbackHost(host: String): Boolean { + val value = host.trim().trimStart('[').trimEnd(']').lowercase() + return value == "localhost" || + value == "127.0.0.1" || + value == "::1" || + value == "0:0:0:0:0:0:0:1" +} + +fun isLoopbackUrl(url: String): Boolean { + val host = runCatching { URI(url).host }.getOrNull() ?: return false + return isLoopbackHost(host) +} + +object TorFakeDns : Dns { + private val hosts = ConcurrentHashMap() + + override fun lookup(hostname: String): List { + if (isLoopbackHost(hostname)) return Dns.SYSTEM.lookup(hostname) + val address = fakeTorAddress(hostname) + hosts[checkNotNull(address.hostAddress)] = hostname + return listOf(address) + } + + fun hostnameFor(address: InetAddress): String? = hosts[address.hostAddress] +} + +/** HTTP/1.1 only: HTTP/2 over Arti's SOCKS port fails the CDN fetches Coil uses for icons. */ +internal fun socksClient(socksPort: Int): OkHttpClient = baseClient() + .proxy(Proxy.NO_PROXY) + .socketFactory(Socks5SocketFactory(InetSocketAddress("127.0.0.1", socksPort))) + .dns(TorFakeDns) + .protocols(listOf(Protocol.HTTP_1_1)) + .build() + +internal fun imageSocksClient(socksPort: Int): OkHttpClient = socksClient(socksPort).newBuilder() + .followRedirects(true) + .followSslRedirects(true) + .build() + +internal fun directClient(): OkHttpClient = baseClient().build() + +internal fun imageDirectClient(): OkHttpClient = directClient().newBuilder() + .followRedirects(true) + .followSslRedirects(true) + .build() + +private fun fakeTorAddress(hostname: String): InetAddress { + val hash = hostname.hashCode() + val bytes = byteArrayOf( + 10, + (hash ushr 16).toByte(), + (hash ushr 8).toByte(), + (hash.let { if (it and 0xff == 0) 1 else it }).toByte(), + ) + return InetAddress.getByAddress(hostname, bytes) +} + +private fun baseClient(): OkHttpClient.Builder = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .protocols(listOf(Protocol.HTTP_1_1, Protocol.HTTP_2)) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + +fun OkHttpClient.closeAppConnections() { + dispatcher.cancelAll() + runCatching { connectionPool.evictAll() } +} + +class OkHttpAppTransport( + val client: OkHttpClient, +) : HttpTransport, WebSocketFactory { + constructor() : this(directClient()) + + override fun get(url: String, headers: Map): HttpResponse { + val builder = Request.Builder().url(url).get() + headers.forEach { (name, value) -> builder.header(name, value) } + client.newCall(builder.build()).execute().use { response -> + return HttpResponse( + status = response.code, + headers = buildMap { + response.header("X-Error-Code")?.let { put("X-Error-Code", it) } + response.header("X-Reason")?.let { put("X-Reason", it) } + }, + body = response.body.bytes().takeIf { response.code == 200 }, + ) + } + } + + override fun open(url: String, listener: WebSocketListener): WebSocketSession { + val socket = client.newWebSocket( + Request.Builder().url(url).build(), + object : okhttp3.WebSocketListener() { + override fun onOpen(webSocket: okhttp3.WebSocket, response: okhttp3.Response) { + listener.onOpen() + } + + override fun onMessage(webSocket: okhttp3.WebSocket, text: String) { + listener.onMessage(text) + } + + override fun onClosing(webSocket: okhttp3.WebSocket, code: Int, reason: String) { + listener.onClosing(code, reason) + } + + override fun onFailure(webSocket: okhttp3.WebSocket, t: Throwable, response: okhttp3.Response?) { + listener.onFailure(t) + } + }, + ) + return object : WebSocketSession { + override fun send(text: String) { + check(socket.send(text)) { "WebSocket send failed" } + } + + override fun close() { + socket.close(1000, null) + } + } + } + + fun closeConnections() { + client.closeAppConnections() + } +} + +internal class SplitRouter( + private val mode: () -> NetworkMode, + private val torState: () -> TorRuntimeState, + private val direct: AppTransport, + private val tor: () -> AppTransport?, +) : HttpTransport, WebSocketFactory, Call.Factory { + override fun get(url: String, headers: Map): HttpResponse = + route(url).http.get(url, headers) + + override fun open(url: String, listener: WebSocketListener): WebSocketSession = + route(url).webSocket.open(url, listener) + + override fun newCall(request: Request): Call = routeHost(request.url.host).callFactory.newCall(request) + + private fun route(url: String): AppTransport = + if (!mode().usesTor || isLoopbackUrl(url)) direct else remote() + + private fun routeHost(host: String): AppTransport = + if (!mode().usesTor || isLoopbackHost(host)) direct else remote() + + private fun remote(): AppTransport { + val fallback = mode().allowsDirectFallback + when (torState()) { + TorRuntimeState.Ready, TorRuntimeState.Dormant -> { + return tor() ?: if (fallback) { + direct + } else { + throw TorUnavailableException( + "Tor is selected and Arti is not available; refusing Direct fallback", + ) + } + } + TorRuntimeState.Bootstrapping -> throw TorUnavailableException( + "Tor is bootstrapping; refusing Direct fallback", + ) + TorRuntimeState.Failed, TorRuntimeState.Stopped -> { + if (fallback) return direct + throw TorUnavailableException( + "Tor is not available; refusing Direct fallback", + ) + } + } + } +} diff --git a/src/main/kotlin/dev/zapstore/app/transport/ArtiNative.kt b/src/main/kotlin/dev/zapstore/app/transport/ArtiNative.kt new file mode 100644 index 0000000..0ab307d --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/transport/ArtiNative.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package dev.zapstore.app.transport + +/** + * JNI bridge to the custom-built Arti native library (`libarti_android.so`). + * + * The native TorClient is created once via [initialize] and persists for the + * process lifetime. The SOCKS proxy can be started and stopped independently + * via [startSocksProxy] and [stopSocksProxy] without dropping the client. + */ +object ArtiNative { + init { + System.loadLibrary("arti_android") + } + + external fun getVersion(): String + + external fun setLogCallback(callback: ArtiLogCallback) + + /** Create the Arti runtime. Returns 0 on success, negative on error. */ + external fun initialize(dataDir: String): Int + + /** 1 when circuits can be built, 0 when not yet, -1 when there is no client. */ + external fun isBootstrapped(): Int + + /** Directory-download progress in permille (0..1000), or -1 when there is no client. */ + external fun bootstrapProgressPermille(): Int + + /** Bind SOCKS5 on [port]. Returns 0 on success, negative on error. */ + external fun startSocksProxy(port: Int): Int + + /** Stop the SOCKS listener. The TorClient stays alive. */ + external fun stopSocksProxy(): Int + + /** Drop the in-process TorClient so the next [initialize] rebuilds it. */ + external fun destroy(): Int +} + +fun interface ArtiLogCallback { + fun onLogLine(line: String) +} diff --git a/src/main/kotlin/dev/zapstore/app/transport/EmbeddedArtiEngine.kt b/src/main/kotlin/dev/zapstore/app/transport/EmbeddedArtiEngine.kt new file mode 100644 index 0000000..94e8a06 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/transport/EmbeddedArtiEngine.kt @@ -0,0 +1,60 @@ +package dev.zapstore.app.transport + +import android.content.Context +import java.io.File +import java.util.concurrent.atomic.AtomicInteger + +class EmbeddedArtiEngine( + context: Context, +) : ArtiEngine { + private val dataDir = File(context.applicationContext.filesDir, "arti") + private val port = AtomicInteger(0) + + override val socksPort: Int get() = port.get() + + @Synchronized + override fun start() { + if (socksPort > 0) return + dataDir.mkdirs() + val initialized = ArtiNative.initialize(dataDir.absolutePath) + if (initialized != 0) { + throw TorUnavailableException("Arti initialize failed ($initialized)") + } + var candidate = DEFAULT_SOCKS_PORT + repeat(MAX_PORT_RETRIES) { + if (ArtiNative.startSocksProxy(candidate) == 0) { + port.set(candidate) + return + } + candidate++ + } + throw TorUnavailableException("Arti did not bind a SOCKS port") + } + + @Synchronized + override fun stop() { + runCatching { ArtiNative.stopSocksProxy() } + port.set(0) + } + + @Synchronized + override fun destroy() { + stop() + runCatching { ArtiNative.destroy() } + } + + override fun close() = destroy() + + companion object { + private const val DEFAULT_SOCKS_PORT = 17392 + private const val MAX_PORT_RETRIES = 10 + + fun cacheDir(context: Context): File = File(context.applicationContext.filesDir, "arti/cache") + } +} + +/** Deletes Arti's disposable directory cache. Safe only after [ArtiEngine.destroy]. */ +internal fun deleteArtiCache(cacheDir: File) { + cacheDir.deleteRecursively() + cacheDir.mkdirs() +} diff --git a/src/main/kotlin/dev/zapstore/app/transport/NetworkRuntime.kt b/src/main/kotlin/dev/zapstore/app/transport/NetworkRuntime.kt new file mode 100644 index 0000000..f1d6ed4 --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/transport/NetworkRuntime.kt @@ -0,0 +1,188 @@ +package dev.zapstore.app.transport + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Call +import java.util.concurrent.CopyOnWriteArrayList + +class NetworkRuntime( + context: Context, + private val scope: CoroutineScope, + private val engineFactory: (Context) -> ArtiEngine? = { ctx -> + runCatching { EmbeddedArtiEngine(ctx) }.getOrNull() + }, +) { + private val appContext = context.applicationContext + private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val lock = Mutex() + private val resetListeners = CopyOnWriteArrayList<() -> Unit>() + private val direct = AppTransports.directTransport() + private val directImages = AppTransports.directImageTransport() + private var arti: ArtiEngine? = null + private var torTransport: AppTransport? = null + private var torImages: AppTransport? = null + + private val _mode = MutableStateFlow(loadMode()) + val mode: StateFlow = _mode.asStateFlow() + + private val _torState = MutableStateFlow( + if (_mode.value.usesTor) TorRuntimeState.Bootstrapping else TorRuntimeState.Stopped, + ) + val torState: StateFlow = _torState.asStateFlow() + + val remoteReady: StateFlow = combine(mode, torState, ::remoteImagesReady) + .stateIn(scope, SharingStarted.Eagerly, remoteImagesReady(_mode.value, _torState.value)) + + val transport: AppTransport = AppTransports.split( + mode = { _mode.value }, + torState = { _torState.value }, + direct = direct, + tor = { torTransport }, + ) + + val images: AppTransport = AppTransports.split( + mode = { _mode.value }, + torState = { _torState.value }, + direct = directImages, + tor = { torImages }, + ) + + val http get() = transport.http + val webSocket get() = transport.webSocket + val callFactory: Call.Factory get() = images.callFactory + + init { + if (_mode.value.usesTor) startArti() + } + + fun onConnectionsReset(listener: () -> Unit) { + resetListeners += listener + } + + fun setMode(mode: NetworkMode) { + if (mode == _mode.value) return + val wasUsingTor = _mode.value.usesTor + prefs.edit().putString(KEY_MODE, mode.name).apply() + _mode.value = mode + if (!mode.usesTor) { + _torState.value = TorRuntimeState.Stopped + scope.launch(Dispatchers.IO) { + lock.withLock { + closeConnections() + stopArtiLocked(clearCache = true) + } + notifyReset() + } + } else if (!wasUsingTor) { + startArti() + } else { + notifyReset() + } + } + + fun setForeground(foreground: Boolean) { + if (!_mode.value.usesTor) return + val state = _torState.value + if (foreground) { + if (state == TorRuntimeState.Dormant) _torState.value = TorRuntimeState.Ready + if (state == TorRuntimeState.Failed || state == TorRuntimeState.Stopped) startArti() + } else if (state == TorRuntimeState.Ready) { + _torState.value = TorRuntimeState.Dormant + scope.launch(Dispatchers.IO) { + lock.withLock { closeConnections() } + } + } + } + + private fun startArti() { + _torState.value = TorRuntimeState.Bootstrapping + scope.launch(Dispatchers.IO) { + lock.withLock { + if (!_mode.value.usesTor) return@withLock + try { + val engine = arti ?: engineFactory(appContext) ?: throw TorUnavailableException( + "Arti is not available; refusing Direct fallback", + ) + try { + engine.start() + } catch (failure: Throwable) { + runCatching { engine.destroy() } + throw failure + } + arti = engine + closeTorTransport() + torTransport = AppTransports.torTransport(engine.socksPort) + torImages = AppTransports.torImageTransport(engine.socksPort) + if (_mode.value.usesTor) { + _torState.value = TorRuntimeState.Ready + notifyReset() + } + } catch (_: Throwable) { + stopArtiLocked() + if (_mode.value.usesTor) _torState.value = TorRuntimeState.Failed + } + } + } + } + + /** Drops the Tor client and deletes `files/arti/cache`. Restarts Arti if the mode still uses Tor. */ + suspend fun clearArtiCache() { + withContext(Dispatchers.IO) { + lock.withLock { + closeTorTransport() + stopArtiLocked(clearCache = true) + } + } + if (_mode.value.usesTor) startArti() + } + + private fun stopArtiLocked(clearCache: Boolean = false) { + closeTorTransport() + runCatching { arti?.stop() } + runCatching { arti?.destroy() } + arti = null + if (clearCache) deleteArtiCache(EmbeddedArtiEngine.cacheDir(appContext)) + } + + private fun closeTorTransport() { + val current = torTransport + val currentImages = torImages + torTransport = null + torImages = null + (current?.http as? OkHttpAppTransport)?.closeConnections() + (current?.webSocket as? OkHttpAppTransport)?.closeConnections() + (currentImages?.http as? OkHttpAppTransport)?.closeConnections() + } + + private fun closeConnections() { + (direct.http as? OkHttpAppTransport)?.closeConnections() + (torTransport?.http as? OkHttpAppTransport)?.closeConnections() + (torTransport?.webSocket as? OkHttpAppTransport)?.closeConnections() + } + + private fun notifyReset() { + resetListeners.forEach { runCatching(it) } + } + + private fun loadMode(): NetworkMode { + val debug = appContext.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0 + val fallback = if (debug) NetworkMode.DirectOnly else NetworkMode.TorOnly + return NetworkMode.fromStored(prefs.getString(KEY_MODE, fallback.name)) + } + + private companion object { + const val PREFS = "network" + const val KEY_MODE = "mode" + } +} diff --git a/src/main/kotlin/dev/zapstore/app/transport/Socks5.kt b/src/main/kotlin/dev/zapstore/app/transport/Socks5.kt new file mode 100644 index 0000000..c1ea6eb --- /dev/null +++ b/src/main/kotlin/dev/zapstore/app/transport/Socks5.kt @@ -0,0 +1,117 @@ +package dev.zapstore.app.transport + +import java.io.DataInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import javax.net.SocketFactory + +/** + * SOCKS5 client that always sends the destination hostname (ATYP=DOMAIN). + * + * Java's [java.net.Proxy.Type.SOCKS] often CONNECTs to `0.0.0.0` instead of the name + * when [TorFakeDns] is used; Arti then replies with "SOCKS server general failure". + */ +class Socks5SocketFactory( + private val proxyAddress: InetSocketAddress, +) : SocketFactory() { + override fun createSocket(): Socket = Socks5Socket(proxyAddress) + + override fun createSocket(host: String, port: Int): Socket = + createSocket().apply { connect(InetSocketAddress(host, port)) } + + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket = + createSocket(host, port) + + override fun createSocket(host: InetAddress, port: Int): Socket = + createSocket().apply { connect(InetSocketAddress(host, port)) } + + override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket = + createSocket(address, port) +} + +internal class Socks5Socket( + private val proxyAddress: InetSocketAddress, +) : Socket() { + override fun connect(endpoint: SocketAddress) = connect(endpoint, 0) + + override fun connect(endpoint: SocketAddress, timeout: Int) { + val target = endpoint as? InetSocketAddress + ?: throw IOException("SOCKS5 requires an InetSocketAddress") + super.connect(proxyAddress, timeout) + socks5Connect(getOutputStream(), getInputStream(), socks5TargetHost(target), target.port) + } +} + +internal fun socks5TargetHost(address: InetSocketAddress): String { + val raw = address.hostString + if (raw.isNotBlank() && !isNumericIp(raw)) return raw + address.address?.let { ip -> + TorFakeDns.hostnameFor(ip)?.let { return it } + ip.hostName?.takeIf { it.isNotBlank() && !isNumericIp(it) }?.let { return it } + } + throw IOException("SOCKS5 target hostname is missing") +} + +internal fun socks5Connect(output: OutputStream, input: InputStream, host: String, port: Int) { + val hostBytes = host.toByteArray(Charsets.US_ASCII) + require(hostBytes.isNotEmpty() && hostBytes.size <= 255) { "SOCKS5 hostname length ${hostBytes.size}" } + require(port in 1..65535) { "SOCKS5 port $port" } + + output.write(byteArrayOf(0x05, 0x01, 0x00)) + output.flush() + val greeting = ByteArray(2) + DataInputStream(input).readFully(greeting) + if (greeting[0] != 0x05.toByte()) throw IOException("SOCKS5 version ${greeting[0]}") + if (greeting[1] != 0x00.toByte()) throw IOException("SOCKS5 authentication rejected") + + val request = ByteArray(7 + hostBytes.size) + request[0] = 0x05 + request[1] = 0x01 + request[2] = 0x00 + request[3] = 0x03 + request[4] = hostBytes.size.toByte() + hostBytes.copyInto(request, 5) + request[5 + hostBytes.size] = (port ushr 8).toByte() + request[6 + hostBytes.size] = port.toByte() + output.write(request) + output.flush() + + val header = ByteArray(4) + val reply = DataInputStream(input) + reply.readFully(header) + if (header[0] != 0x05.toByte()) throw IOException("SOCKS5 reply version ${header[0]}") + val status = header[1].toInt() and 0xff + if (status != 0) throw IOException(socks5StatusMessage(status)) + when (header[3].toInt() and 0xff) { + 0x01 -> reply.readFully(ByteArray(6)) + 0x04 -> reply.readFully(ByteArray(18)) + 0x03 -> { + val length = ByteArray(1) + reply.readFully(length) + reply.readFully(ByteArray((length[0].toInt() and 0xff) + 2)) + } + else -> throw IOException("SOCKS5 unknown address type ${header[3]}") + } +} + +private fun isNumericIp(host: String): Boolean = + host == "0.0.0.0" || + host == "::" || + host == "0:0:0:0:0:0:0:0" || + host.all { it.isDigit() || it == '.' } || + host.contains(':') + +private fun socks5StatusMessage(status: Int): String = when (status) { + 0x01 -> "Arti rejected the circuit" + 0x03 -> "Arti cannot reach the destination network" + 0x04 -> "Arti cannot reach the destination host" + 0x05 -> "Arti connection refused" + 0x07 -> "SOCKS5 command not supported" + 0x08 -> "SOCKS5 address type not supported" + else -> "Arti SOCKS5 status $status" +} diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index af12dd9..a5e1159 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -3,16 +3,32 @@ Search apps Clear search Close - Notifications Updates + Settings + Catalog All catalog apps are up to date. Sync Syncing + Wipe local DB Catalog epoch %1$d + %1$d apps in catalog + %1$s on device Last sync %1$s + Last sync just now + + Last sync 1 minute ago + Last sync %d minutes ago + + + Last sync 1 hour ago + Last sync %d hours ago + + Last sync took %1$s seconds + %1$s transferred + Already current (HTTP 304) %1$s → %2$s - %1$d database rows - You are all caught up. + Search results: %1$d in %2$dms + Verified author Curated stacks Latest releases No releases found. @@ -32,7 +48,16 @@ Latest release Support %1$s sats · %2$d zaps + Comments + No comments yet. + Couldn\'t reach comment relays. Try again, or check Network in Settings. + Comment relays timed out. Try again, or check Network in Settings. + Try again + Network settings Read more + Release notes + See developer description + Developer description Version: Source License @@ -44,7 +69,23 @@ Npub Not available Copy + Privacy and security + No %1$s + Google Play services + Verified open source + Firebase Cloud Messaging %1$s icon %1$s screenshot %1$s avatar + Network + Tor only + Direct only + Tor with Direct fallback + Direct exposes your IP address to relays, catalog servers, and download hosts. + Tor bootstrapping + Tor ready + Tor dormant + Tor failed. Direct is not used as a fallback. + Tor failed. Using Direct. + Tor stopped diff --git a/src/test/kotlin/dev/zapstore/app/MainDispatcherRule.kt b/src/test/kotlin/dev/zapstore/app/MainDispatcherRule.kt deleted file mode 100644 index 90e96b6..0000000 --- a/src/test/kotlin/dev/zapstore/app/MainDispatcherRule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.zapstore.app - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -@OptIn(ExperimentalCoroutinesApi::class) -class MainDispatcherRule( - val dispatcher: TestDispatcher = StandardTestDispatcher(), -) : TestWatcher() { - override fun starting(description: Description) { - Dispatchers.setMain(dispatcher) - } - - override fun finished(description: Description) { - Dispatchers.resetMain() - } -} diff --git a/src/test/kotlin/dev/zapstore/app/ProfileIconCacheTest.kt b/src/test/kotlin/dev/zapstore/app/ProfileIconCacheTest.kt deleted file mode 100644 index 1b7067a..0000000 --- a/src/test/kotlin/dev/zapstore/app/ProfileIconCacheTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.zapstore.app - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Test - -class ProfileIconCacheTest { - @Test - fun `profile icon cache key is stable for profile cache period`() { - val url = "https://cdn.zapstore.dev/p/example.webp" - val start = 10 * PROFILE_CACHE_DURATION.inWholeMilliseconds - - assertEquals( - profileIconCacheKey(url, start), - profileIconCacheKey(url, start + PROFILE_CACHE_DURATION.inWholeMilliseconds - 1), - ) - assertNotEquals( - profileIconCacheKey(url, start), - profileIconCacheKey(url, start + PROFILE_CACHE_DURATION.inWholeMilliseconds), - ) - } -} diff --git a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt b/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt deleted file mode 100644 index 59982b1..0000000 --- a/src/test/kotlin/dev/zapstore/app/ViewModelsTest.kt +++ /dev/null @@ -1,627 +0,0 @@ -package dev.zapstore.app - -import androidx.lifecycle.SavedStateHandle -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import dev.zapstore.iolite.QueryOptions -import dev.zapstore.iolite.QueryState -import dev.zapstore.iolite.QueryPhase -import dev.zapstore.iolite.RemoteMode -import dev.zapstore.iolite.SourceMode -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalCoroutinesApi::class) -class ViewModelsTest { - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - @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) - assertEquals(null, forced.cachedFor) - } - - @Test - fun `C1 author is verified only when current proof matches an asset`() { - val pubkey = "1".repeat(64) - val hash = "a".repeat(64) - val asset = event( - pubKey = pubkey, - kind = Catalog.assetKind, - tags = arrayOf(arrayOf("apk_certificate_hash", hash)), - ) - val proof = event( - pubKey = pubkey, - kind = Catalog.c1Kind, - tags = arrayOf( - arrayOf("d", hash), - arrayOf("signature", "signature"), - arrayOf("expiry", "1800000000"), - ), - ) - - assertEquals(setOf(pubkey), verifiedAppAuthors(listOf(asset), listOf(proof), now = 1750000000)) - assertTrue(verifiedAppAuthors(listOf(asset), emptyList(), now = 1750000000).isEmpty()) - } - - @Test - fun `newer revoked C1 replaces older active proof`() { - val pubkey = "1".repeat(64) - val hash = "a".repeat(64) - val asset = event( - pubKey = pubkey, - kind = Catalog.assetKind, - tags = arrayOf(arrayOf("apk_certificate_hash", hash)), - ) - val oldProof = event( - id = "f".repeat(64), - pubKey = pubkey, - kind = Catalog.c1Kind, - createdAt = 1_750_000_000, - tags = arrayOf( - arrayOf("d", hash), - arrayOf("signature", "signature"), - arrayOf("expiry", "1800000000"), - ), - ) - val revokedProof = event( - id = "e".repeat(64), - pubKey = pubkey, - createdAt = 1_750_000_001, - kind = Catalog.c1Kind, - tags = arrayOf(arrayOf("d", hash), arrayOf("revoked", "retired")), - ) - - assertTrue(verifiedAppAuthors(listOf(asset), listOf(oldProof, revokedProof), now = 1750000000).isEmpty()) - } - - @Test - fun `home search reads local results and cancels the previous query`() = runTest { - val localFlows = mutableListOf>() - val repository = FakeCatalogRepository( - localQuery = { filter -> - if (filter.search != null) { - MutableSharedFlow().also(localFlows::add) - } else { - emptyFlow() - } - }, - ) - val viewModel = HomeViewModel(repository, SavedStateHandle()) - runCurrent() - - viewModel.onSearchQueryChanged("first") - viewModel.submitSearch() - runCurrent() - assertEquals(1, localFlows.single().subscriptionCount.value) - - viewModel.onSearchQueryChanged("second") - viewModel.submitSearch() - runCurrent() - assertEquals(0, localFlows.first().subscriptionCount.value) - assertEquals(1, localFlows.last().subscriptionCount.value) - - localFlows.last().emit( - queryState( - event( - kind = Catalog.appKind, - tags = arrayOf( - arrayOf("d", "second"), - arrayOf("name", "Second App"), - ), - ), - ), - ) - runCurrent() - - assertEquals("Second App", viewModel.uiState.value.searchResults.single().name) - assertEquals("1 results for “second”", viewModel.uiState.value.searchMessage) - } - - @Test - fun `stack changes cancel the old app query`() = runTest { - val stackFlow = MutableSharedFlow() - val appFlows = mutableListOf>() - var queryCount = 0 - val repository = FakeCatalogRepository( - localQuery = { - if (queryCount++ == 0) { - stackFlow - } else { - MutableSharedFlow().also(appFlows::add) - } - }, - ) - StackDetailViewModel( - repository, - SavedStateHandle(mapOf(STACK_ID_ARGUMENT to "a".repeat(64))), - ) - runCurrent() - - stackFlow.emit(stackState("first")) - runCurrent() - assertEquals(1, appFlows.single().subscriptionCount.value) - - stackFlow.emit(stackState("second")) - runCurrent() - assertEquals(0, appFlows.first().subscriptionCount.value) - assertEquals(1, appFlows.last().subscriptionCount.value) - } - - @Test - fun `release feed pages through apps and stops after a short page`() = runTest { - val appFilters = mutableListOf() - val appFlows = mutableListOf>() - val repository = FakeCatalogRepository( - localQuery = { filter -> - if (filter.kinds == listOf(Catalog.appKind)) { - appFilters += filter - MutableSharedFlow().also(appFlows::add) - } else { - emptyFlow() - } - }, - ) - val viewModel = HomeViewModel(repository, SavedStateHandle()) - runCurrent() - - appFlows.single().emit( - queryState( - *(0 until 20).map { index -> - event( - id = "app-$index", - kind = Catalog.appKind, - createdAt = 1_750_000_000L - index, - tags = arrayOf(arrayOf("d", "app-$index")), - ) - }.toTypedArray(), - event( - id = "not-an-app", - kind = Catalog.releaseKind, - createdAt = 1_750_000_001L, - tags = arrayOf(arrayOf("i", "not-an-app")), - ), - ), - ) - runCurrent() - - assertEquals(20, viewModel.uiState.value.releaseFeed.entries.size) - assertEquals(true, viewModel.uiState.value.releaseFeed.canLoadMore) - - viewModel.loadMoreReleases() - runCurrent() - - assertEquals(2, appFilters.size) - assertEquals(1_749_999_981L, appFilters.last().until) - assertEquals(21, appFilters.last().limit) - - appFlows.last().emit( - queryState( - event( - id = "app-last", - kind = Catalog.appKind, - createdAt = 1_749_999_979L, - tags = arrayOf(arrayOf("d", "last-app")), - ), - ), - ) - runCurrent() - - assertEquals(21, viewModel.uiState.value.releaseFeed.entries.size) - assertEquals(false, viewModel.uiState.value.releaseFeed.canLoadMore) - } - - @Test - fun `release feed does not skip apps tied at a page boundary`() = runTest { - val appFilters = mutableListOf() - val appFlows = mutableListOf>() - val repository = FakeCatalogRepository( - localQuery = { filter -> - if (filter.kinds == listOf(Catalog.appKind)) { - appFilters += filter - MutableSharedFlow().also(appFlows::add) - } else { - emptyFlow() - } - }, - ) - val viewModel = HomeViewModel(repository, SavedStateHandle()) - runCurrent() - - val timestamp = 1_750_000_000L - val apps = (0 until 25).map { index -> - event( - id = "tied-app-$index", - kind = Catalog.appKind, - createdAt = timestamp, - tags = arrayOf(arrayOf("d", "tied-app-$index")), - ) - } - appFlows.single().emit(queryState(*apps.take(20).toTypedArray())) - runCurrent() - - viewModel.loadMoreReleases() - runCurrent() - - assertEquals(timestamp, appFilters.last().until) - assertEquals(40, appFilters.last().limit) - - appFlows.last().emit(queryState(*apps.toTypedArray())) - runCurrent() - - assertEquals(25, viewModel.uiState.value.releaseFeed.entries.size) - assertEquals(false, viewModel.uiState.value.releaseFeed.canLoadMore) - } - - @Test - fun `release feed outputs apps ordered by latest release or asset`() = runTest { - val appFlow = MutableSharedFlow() - val releaseFlow = MutableSharedFlow() - var releaseFilters = emptyList() - val repository = FakeCatalogRepository( - localQuery = { filter -> - if (filter.kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() - }, - localQueries = { filters -> - if (filters.size == 1) { - if (filters.single().kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() - } else { - releaseFilters = filters - releaseFlow - } - }, - ) - val viewModel = HomeViewModel(repository, SavedStateHandle()) - runCurrent() - - appFlow.emit( - queryState( - event( - id = "app-one", - kind = Catalog.appKind, - createdAt = 100, - tags = arrayOf(arrayOf("d", "one")), - ), - event( - id = "app-two", - kind = Catalog.appKind, - createdAt = 90, - tags = arrayOf(arrayOf("d", "two")), - ), - event( - id = "app-without-release", - kind = Catalog.appKind, - createdAt = 500, - tags = arrayOf(arrayOf("d", "without-release")), - ), - ), - ) - runCurrent() - - assertEquals(3, releaseFilters.size) - assertEquals(true, releaseFilters.all { it.kinds == Catalog.releaseKinds }) - assertEquals(true, releaseFilters.all { it.limit == 1 }) - - releaseFlow.emit( - queryState( - event( - id = "release-one", - kind = Catalog.releaseKind, - createdAt = 200, - tags = arrayOf( - arrayOf("i", "one"), - arrayOf("version", "1.0"), - ), - ), - event( - id = "release-two", - kind = Catalog.releaseKind, - createdAt = 250, - tags = arrayOf( - arrayOf("i", "two"), - arrayOf("version", "2.0"), - ), - ), - event( - id = "asset-two", - kind = Catalog.assetKind, - createdAt = 300, - tags = arrayOf( - arrayOf("i", "two"), - arrayOf("version", "2.1"), - ), - ), - event( - id = "not-a-release", - kind = Catalog.appStackKind, - createdAt = 1_000, - tags = arrayOf(arrayOf("i", "one")), - ), - ), - ) - runCurrent() - - val entries = viewModel.uiState.value.releaseFeed.entries - assertEquals(listOf("two", "one", "without-release"), entries.map { it.app.identifier }) - assertEquals(listOf(Catalog.assetKind, Catalog.releaseKind, null), entries.map { it.release?.event?.kind }) - assertEquals(true, entries.all { it.app.event.kind == Catalog.appKind }) - } - - @Test - fun `legacy release d tag resolves its app identifier`() { - val release = ReleaseInfo( - event( - kind = Catalog.releaseKind, - tags = arrayOf(arrayOf("d", "dev.example.app@1.2.3")), - ), - ) - - 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? = null - var outboxRelays: Set? = null - val appFlow = MutableSharedFlow() - val repository = FakeCatalogRepository( - localQuery = { filter -> - if (filter.kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() - }, - outboxQuery = { _, authors, relays -> - outboxAuthors = authors - outboxRelays = relays - emptyFlow() - }, - ) - AppDetailViewModel( - repository, - SavedStateHandle(mapOf(APP_IDENTIFIER_ARGUMENT to "dev.example.app")), - ) - runCurrent() - appFlow.emit(queryState(event(kind = Catalog.appKind, pubKey = "7".repeat(64)))) - runCurrent() - - assertEquals(listOf("7".repeat(64)), outboxAuthors) - assertEquals(Catalog.defaultZapRelays, outboxRelays) - } - - @Test - fun `app detail loads latest release from release and asset kinds`() = runTest { - val filters = mutableListOf() - 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() - val appFlow = MutableSharedFlow() - val repository = FakeCatalogRepository( - localQuery = { filter -> - filters += filter - if (filter.kinds == listOf(Catalog.appKind)) appFlow else emptyFlow() - }, - ) - AppDetailViewModel( - repository, - SavedStateHandle(mapOf(APP_IDENTIFIER_ARGUMENT to "dev.example.app")), - ) - runCurrent() - - appFlow.emit( - queryState( - event( - kind = Catalog.appKind, - tags = arrayOf(arrayOf("d", "dev.example.app")), - ), - ), - ) - runCurrent() - - val zapFilters = filters.filter { it.kinds == listOf(Catalog.zapReceiptKind) } - val assetFilters = filters.filter { it.kinds == listOf(Catalog.assetKind) } - assertTrue(zapFilters.isNotEmpty()) - assertTrue(assetFilters.isNotEmpty()) - assertTrue(zapFilters.all { it.limit != null }) - assertTrue(assetFilters.all { it.limit != null }) - } - - @Test - fun `profile release feed filters apps by profile pubkey`() = runTest { - val filters = mutableListOf() - val pubkey = "b".repeat(64) - val repository = FakeCatalogRepository( - localQuery = { filter -> - filters += filter - emptyFlow() - }, - ) - - ProfileViewModel( - repository, - SavedStateHandle(mapOf(PROFILE_PUBKEY_ARGUMENT to pubkey)), - ) - runCurrent() - - val appFilter = filters.single { it.kinds == listOf(Catalog.appKind) } - assertEquals(listOf(pubkey), appFilter.authors) - } - - private fun stackState(identifier: String): QueryState = queryState( - event( - id = "stack-$identifier", - kind = Catalog.appStackKind, - tags = arrayOf( - arrayOf("name", identifier), - arrayOf("a", "${Catalog.appKind}:${"1".repeat(64)}:$identifier"), - ), - ), - ) -} - -private class FakeCatalogRepository( - private val localQuery: (Filter) -> Flow = { emptyFlow() }, - private val remoteQuery: (Filter) -> Flow = { emptyFlow() }, - private val localQueries: ((List) -> Flow)? = null, - private val remoteQueries: ((List) -> Flow)? = null, - private val outboxQuery: ((List, List, Set) -> Flow)? = null, -) : CatalogRepository { - override suspend fun verifiedApps(apps: List): List = apps - - override fun query( - filters: List, - options: QueryOptions, - ): Flow = when (options.sourceMode) { - SourceMode.Local, - SourceMode.LocalAndRemote, - -> localQueries?.invoke(filters) ?: filters.singleOrNull()?.let(localQuery) ?: emptyFlow() - SourceMode.Remote -> remoteQueries?.invoke(filters) ?: filters.singleOrNull()?.let(remoteQuery) ?: emptyFlow() - } - - override fun queryWithOutbox( - filters: List, - authors: List, - cachedFor: Duration?, - relays: Set, - remoteMode: RemoteMode, - unionWithFallback: Boolean, - ): Flow = outboxQuery?.invoke(filters, authors, relays) - ?: super.queryWithOutbox(filters, authors, cachedFor, relays, remoteMode, unionWithFallback) - - override fun refreshConnections() = Unit -} - -private fun queryState(vararg events: Event): QueryState = - QueryState( - items = events.toList(), - phase = QueryPhase.Complete, - ) - -private fun event( - id: String = "a".repeat(64), - pubKey: String = "1".repeat(64), - kind: Int, - createdAt: Long = 1_750_000_000, - tags: Array> = emptyArray(), - content: String = "", -): Event = Event( - id = id, - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "2".repeat(128), -) diff --git a/src/test/kotlin/dev/zapstore/app/apk/ApkVerifierTest.kt b/src/test/kotlin/dev/zapstore/app/apk/ApkVerifierTest.kt new file mode 100644 index 0000000..a482add --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/apk/ApkVerifierTest.kt @@ -0,0 +1,38 @@ +package dev.zapstore.app.apk + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ApkVerifierTest { + @Test + fun acceptsMatchingHashPackageAndCertificate() { + val apk = ApkIdentity("com.example.app", "aa", setOf("cert"), listOf("cert")) + val listed = ListedApk("com.example.app", "aa", "cert") + assertTrue(ApkVerifier.isInstallable(apk, listed)) + } + + @Test + fun rejectsHashPackageOrCertificateMismatch() { + val apk = ApkIdentity("com.example.app", "aa", setOf("cert"), listOf("cert")) + assertFalse(ApkVerifier.isInstallable(apk, ListedApk("com.example.app", "bb", "cert"))) + assertFalse(ApkVerifier.isInstallable(apk, ListedApk("com.other", "aa", "cert"))) + assertFalse(ApkVerifier.isInstallable(apk, ListedApk("com.example.app", "aa", "other"))) + } + + @Test + fun updateAcceptsInstalledCertificateOrLineageSuccessor() { + val successor = ApkIdentity("com.example.app", "aa", setOf("new"), listOf("old", "new")) + val listed = ListedApk("com.example.app", "aa", "new") + assertTrue(ApkVerifier.isInstallable(successor, listed, installedCertificates = setOf("old"), isUpdate = true)) + assertTrue(ApkVerifier.isInstallable(successor, listed, installedCertificates = setOf("new"), isUpdate = true)) + assertFalse(ApkVerifier.isInstallable(successor, listed, installedCertificates = setOf("unrelated"), isUpdate = true)) + } + + @Test + fun c1ReputationIsNotASubstitute() { + val apk = ApkIdentity("com.example.app", "aa", setOf("cert"), listOf("cert")) + val listed = ListedApk("com.example.app", "wrong", "cert") + assertFalse(ApkVerifier.isInstallable(apk, listed, installedCertificates = setOf("cert"), isUpdate = true)) + } +} diff --git a/src/test/kotlin/dev/zapstore/app/catalog/InstalledAppsTest.kt b/src/test/kotlin/dev/zapstore/app/catalog/InstalledAppsTest.kt new file mode 100644 index 0000000..3582ff2 --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/catalog/InstalledAppsTest.kt @@ -0,0 +1,61 @@ +package dev.zapstore.app.catalog + +import dev.zapstore.iolite.AppRecord +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class InstalledAppsTest { + @Test + fun `an update needs a newer version code and a matching signing certificate`() { + 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")), + InstalledApp("dev.missing", 1, "0.1", setOf("dd")), + ) + val catalog = listOf( + app("dev.one", "One", "1.1", 11, certificateHash = "aa"), + app("dev.two", "Two", "2.1", 21, certificateHash = "zz"), + app("dev.three", "Three", "0.4", 4, certificateHash = "cc"), + ) + + val updates = availableUpdates(installed, catalog) + + assertEquals(listOf("dev.one"), updates.map { it.app.appId }) + assertEquals("1.0", updates.single().installedVersion) + assertEquals("1.1", updates.single().app.version) + } + + @Test + fun `updates are sorted by app name`() { + val installed = listOf( + InstalledApp("dev.b", 1, "1", setOf("aa")), + InstalledApp("dev.a", 1, "1", setOf("aa")), + ) + val catalog = listOf( + app("dev.b", "Zed", "2", 2, certificateHash = "aa"), + app("dev.a", "alpha", "2", 2, certificateHash = "aa"), + ) + + assertEquals(listOf("alpha", "Zed"), availableUpdates(installed, catalog).map { it.app.name }) + } +} + +internal fun app(appId: String, name: String, version: String, versionCode: Long, certificateHash: String): AppRecord = AppRecord( + id = appId.toByteArray(), + catalogId = 1, + appId = appId, + certificateHash = certificateHash, + createdAt = 1_750_000_000, + proofPubkey = null, + eventPubkey = "1".repeat(64), + catalogManifestPubkey = null, + name = name, + summary = "", + repository = null, + version = version, + versionCode = versionCode, + channel = null, + metadata = JSONObject(), +) diff --git a/src/test/kotlin/dev/zapstore/app/catalogsync/CatalogSyncTest.kt b/src/test/kotlin/dev/zapstore/app/catalogsync/CatalogSyncTest.kt deleted file mode 100644 index 0ea2e47..0000000 --- a/src/test/kotlin/dev/zapstore/app/catalogsync/CatalogSyncTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -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()) - } -} diff --git a/src/test/kotlin/dev/zapstore/app/components/ProfileImageTest.kt b/src/test/kotlin/dev/zapstore/app/components/ProfileImageTest.kt new file mode 100644 index 0000000..56b2182 --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/components/ProfileImageTest.kt @@ -0,0 +1,25 @@ +package dev.zapstore.app.components + +import dev.zapstore.app.AppConfig +import dev.zapstore.app.profileCdnUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ProfileImageTest { + @Test + fun profileCdnUrlUsesBarePubkeyFilename() { + val pubkey = "78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d" + assertEquals("https://cdn.zapstore.dev/$pubkey.profile.webp", profileCdnUrl(pubkey)) + } + + @Test + fun `profile icon cache key is stable for one cache period`() { + val url = "https://cdn.zapstore.dev/p/example.webp" + val period = AppConfig.profileCacheDuration.inWholeMilliseconds + val start = 10 * period + + assertEquals(profileIconCacheKey(url, start), profileIconCacheKey(url, start + period - 1)) + assertNotEquals(profileIconCacheKey(url, start), profileIconCacheKey(url, start + period)) + } +} diff --git a/src/test/kotlin/dev/zapstore/app/screens/CommentThreadTest.kt b/src/test/kotlin/dev/zapstore/app/screens/CommentThreadTest.kt new file mode 100644 index 0000000..2fbd491 --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/screens/CommentThreadTest.kt @@ -0,0 +1,61 @@ +package dev.zapstore.app.screens + +import dev.zapstore.iolite.CommentRecord +import dev.zapstore.iolite.QueryError +import dev.zapstore.iolite.RelayUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class CommentThreadTest { + @Test + fun repliesNestUnderParentNewestRootsFirst() { + val olderRoot = comment("root-old", createdAt = 100, content = "older") + val newerRoot = comment("root-new", createdAt = 300, content = "newer") + val lateReply = comment("reply-late", createdAt = 400, content = "later", parent = "root-old") + val earlyReply = comment("reply-early", createdAt = 200, content = "earlier", parent = "root-old") + + val threads = threadComments(listOf(lateReply, newerRoot, earlyReply, olderRoot)) + assertEquals(listOf("root-new", "root-old"), threads.map { it.comment.eventId }) + assertEquals(listOf("reply-early", "reply-late"), threads[1].replies.map { it.comment.eventId }) + + val flat = threads.flattenComments() + assertEquals( + listOf("root-new" to 0, "root-old" to 0, "reply-early" to 1, "reply-late" to 1), + flat.map { it.comment.eventId to it.depth }, + ) + } + + @Test + fun missingParentBecomesRoot() { + val orphan = comment("orphan", createdAt = 50, parent = "missing") + val threads = threadComments(listOf(orphan)) + assertEquals(listOf("orphan"), threads.map { it.comment.eventId }) + assertEquals(0, threads.single().replies.size) + } + + @Test + fun queryErrorsMapToActionableCommentsErrors() { + val relay = "wss://relay.zapstore.dev".let { RelayUrl(it) } + assertEquals(CommentsError.RelaysUnreachable, QueryError.RelayFailure(relay, "SOCKS").toCommentsError()) + assertEquals(CommentsError.TimedOut, QueryError.Timeout(5.seconds, "timed out").toCommentsError()) + assertNull(QueryError.VerificationRejected(relay, "e", "bad").toCommentsError()) + assertNull((null as QueryError?).toCommentsError()) + } + + private fun comment( + id: String, + createdAt: Long, + content: String = id, + parent: String? = null, + ) = CommentRecord( + eventId = id, + pubkey = "p".repeat(64), + createdAt = createdAt, + content = content, + appId = "dev.zap", + stack = null, + parentEventId = parent, + ) +} diff --git a/src/test/kotlin/dev/zapstore/app/screens/FactPillTest.kt b/src/test/kotlin/dev/zapstore/app/screens/FactPillTest.kt new file mode 100644 index 0000000..8fd3873 --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/screens/FactPillTest.kt @@ -0,0 +1,44 @@ +package dev.zapstore.app.screens + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FactPillTest { + @Test + fun antifeatureAbsenceIsPositiveAndPresenceIsNot() { + assertTrue(factIsPositive("gms", yes = false)) + assertFalse(factIsPositive("gms", yes = true)) + assertFalse(factIsPositive("account_required", yes = true)) + assertTrue(factIsPositive("tracking", yes = false)) + } + + @Test + fun cameraAndLocationAreNeutral() { + assertEquals(FactTone.Neutral, factTone("camera", yes = true)) + assertEquals(FactTone.Neutral, factTone("camera", yes = false)) + assertEquals(FactTone.Neutral, factTone("location", yes = true)) + assertEquals(FactTone.Neutral, factTone("location", yes = false)) + assertEquals(FactTone.Negative, factTone("tracking", yes = true)) + assertEquals(FactTone.Positive, factTone("open_source", yes = true)) + } + + @Test + fun desirablePresenceIsPositive() { + assertTrue(factIsPositive("open_source", yes = true)) + assertTrue(factIsPositive("e2ee", yes = true)) + assertTrue(factIsPositive("offline_capable", yes = true)) + assertTrue(factIsPositive("self_hostable", yes = true)) + assertFalse(factIsPositive("open_source", yes = false)) + } + + @Test + fun absentLabelKeepsBrandsAndAcronyms() { + assertEquals("Google Play services", factAbsentBody("Google Play services")) + assertEquals("Firebase Cloud Messaging", factAbsentBody("Firebase Cloud Messaging")) + assertEquals("E2EE", factAbsentBody("E2EE")) + assertEquals("open source", factAbsentBody("Open source")) + assertEquals("offline capable", factAbsentBody("Offline capable")) + } +} diff --git a/src/test/kotlin/dev/zapstore/app/search/LeafQueryEncoderTest.kt b/src/test/kotlin/dev/zapstore/app/search/LeafQueryEncoderTest.kt new file mode 100644 index 0000000..b7b8d1c --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/search/LeafQueryEncoderTest.kt @@ -0,0 +1,44 @@ +package dev.zapstore.app.search + +import java.io.File +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LeafQueryEncoderTest { + @Test + fun wordPieceSplitsKnownPieces() { + val dir = File("build/tmp/vocab-${System.nanoTime()}").apply { mkdirs() } + val vocab = File(dir, "vocab.txt") + vocab.writeText("[PAD]\n[unused0]\n[UNK]\n[CLS]\n[SEP]\nzap\n##store\napp\n") + val ids = WordPiece.load(vocab).encode("Zapstore app") + assertEquals(longArrayOf(3, 5, 6, 7, 4).toList(), ids.toList()) + } + + @Test + fun quantizeClampsToInt8() { + val got = quantize(floatArrayOf(-0.3f, 0f, 0.3f, 0.6f, -0.6f)) + assertArrayEquals(byteArrayOf(-127, 0, 127, 127, -127), got) + } + + @Test + fun blankQuerySkipsTheModel() { + val encoder = LeafQueryEncoder(File("missing-leaf-model")) + assertNull(encoder.encode(" ")) + } + + @Test + fun queryVectorMatchesSealer() { + val dir = listOf(File(".tools/leaf-ir-assets/leaf-ir-v1"), File("android/.tools/leaf-ir-assets/leaf-ir-v1")) + .first { it.resolve("model_quantized.onnx").isFile } + val encoder = LeafQueryEncoder(dir) + try { + val got = encoder.encode("offline maps") + val want = javaClass.getResourceAsStream("/leaf-ir-offline-maps.bin")!!.readBytes() + assertArrayEquals(want, got) + } finally { + encoder.release() + } + } +} diff --git a/src/test/kotlin/dev/zapstore/app/transport/AppTransportTest.kt b/src/test/kotlin/dev/zapstore/app/transport/AppTransportTest.kt new file mode 100644 index 0000000..670ea63 --- /dev/null +++ b/src/test/kotlin/dev/zapstore/app/transport/AppTransportTest.kt @@ -0,0 +1,244 @@ +package dev.zapstore.app.transport + +import okhttp3.Call +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.net.Proxy +import dev.zapstore.iolite.HttpResponse +import dev.zapstore.iolite.HttpTransport +import dev.zapstore.iolite.WebSocketFactory +import dev.zapstore.iolite.WebSocketListener +import dev.zapstore.iolite.WebSocketSession + +class AppTransportTest { + @Test + fun torModeDoesNotFallBackToDirect() { + try { + AppTransports.create(NetworkMode.TorOnly) + throw AssertionError("expected TorUnavailableException") + } catch (failure: TorUnavailableException) { + assertTrue(failure.message!!.contains("refusing Direct fallback")) + } + } + + @Test + fun torModeWithClosedSocksPortDoesNotFallBackToDirect() { + try { + AppTransports.create(NetworkMode.TorOnly, FakeArti(socksPort = 0)) + throw AssertionError("expected TorUnavailableException") + } catch (failure: TorUnavailableException) { + assertTrue(failure.message!!.contains("refusing Direct fallback")) + } + } + + @Test + fun directModeUsesOkHttp() { + val transport = AppTransports.create(NetworkMode.DirectOnly) + assertTrue(transport.http is OkHttpAppTransport) + assertTrue(transport.webSocket is OkHttpAppTransport) + assertEquals(transport.http, transport.webSocket) + } + + @Test + fun fakeDnsKeepsTheHostnameAndDoesNotResolve() { + val addresses = TorFakeDns.lookup("relay.zapstore.dev") + assertEquals("relay.zapstore.dev", addresses.single().hostName) + assertTrue(addresses.single().hostAddress!!.startsWith("10.")) + assertEquals("relay.zapstore.dev", TorFakeDns.hostnameFor(addresses.single())) + } + + @Test + fun socksClientUsesHostnameSocks5AndResolvesDnsInsideArti() { + val client = socksClient(9050) + assertEquals(Proxy.NO_PROXY, client.proxy) + assertTrue(client.socketFactory is Socks5SocketFactory) + assertEquals(TorFakeDns, client.dns) + assertEquals(listOf(okhttp3.Protocol.HTTP_1_1), client.protocols) + assertTrue(!client.followRedirects) + } + + @Test + fun imageSocksClientFollowsRedirects() { + val client = imageSocksClient(9050) + assertTrue(client.followRedirects) + assertTrue(client.followSslRedirects) + assertEquals(listOf(okhttp3.Protocol.HTTP_1_1), client.protocols) + } + + @Test + fun remoteImagesReadyOnlyWhenDirectOrArtiIsUp() { + assertTrue(remoteImagesReady(NetworkMode.DirectOnly, TorRuntimeState.Stopped)) + assertTrue(remoteImagesReady(NetworkMode.TorOnly, TorRuntimeState.Ready)) + assertTrue(remoteImagesReady(NetworkMode.TorOnly, TorRuntimeState.Dormant)) + assertTrue(!remoteImagesReady(NetworkMode.TorOnly, TorRuntimeState.Bootstrapping)) + assertTrue(!remoteImagesReady(NetworkMode.TorOnly, TorRuntimeState.Failed)) + assertTrue(remoteImagesReady(NetworkMode.TorWithDirectFallback, TorRuntimeState.Failed)) + assertTrue(!remoteImagesReady(NetworkMode.TorWithDirectFallback, TorRuntimeState.Bootstrapping)) + } + + @Test + fun socks5ConnectSendsDomainNameNotIpv4Zero() { + val server = java.net.ServerSocket(0) + val port = server.localPort + val received = java.util.concurrent.CompletableFuture() + Thread { + server.use { listener -> + listener.accept().use { sock -> + val input = java.io.DataInputStream(sock.getInputStream()) + val output = sock.getOutputStream() + val greeting = ByteArray(3) + input.readFully(greeting) + output.write(byteArrayOf(0x05, 0x00)) + output.flush() + val header = ByteArray(5) + input.readFully(header) + val host = ByteArray(header[4].toInt() and 0xff) + input.readFully(host) + val destPort = ByteArray(2) + input.readFully(destPort) + received.complete(header + host + destPort) + output.write(byteArrayOf(0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0)) + output.flush() + } + } + }.apply { isDaemon = true }.start() + + val fake = java.net.InetAddress.getByAddress("relay.zapstore.dev", ByteArray(4)) + Socks5SocketFactory(java.net.InetSocketAddress("127.0.0.1", port)) + .createSocket() + .use { it.connect(java.net.InetSocketAddress(fake, 443), 2_000) } + + val request = received.get(2, java.util.concurrent.TimeUnit.SECONDS) + assertEquals(0x05, request[0].toInt() and 0xff) + assertEquals(0x01, request[1].toInt() and 0xff) + assertEquals(0x03, request[3].toInt() and 0xff) + val hostLen = request[4].toInt() and 0xff + assertEquals("relay.zapstore.dev", String(request, 5, hostLen, Charsets.US_ASCII)) + } + + @Test + fun splitKeepsLoopbackOnDirectWhileRemoteUsesTor() { + val direct = RecordingTransport("direct") + val tor = RecordingTransport("tor") + val split = AppTransports.split( + mode = { NetworkMode.TorOnly }, + torState = { TorRuntimeState.Ready }, + direct = AppTransport(direct, direct, direct), + tor = { AppTransport(tor, tor, tor) }, + ) + split.http.get("http://127.0.0.1:3334/deltas?from=0", emptyMap()) + split.http.get("https://relay.zapstore.dev", emptyMap()) + assertEquals(listOf("http://127.0.0.1:3334/deltas?from=0"), direct.urls) + assertEquals(listOf("https://relay.zapstore.dev"), tor.urls) + } + + @Test + fun splitDoesNotFallBackToDirectWhenTorFails() { + val direct = RecordingTransport("direct") + val split = AppTransports.split( + mode = { NetworkMode.TorOnly }, + torState = { TorRuntimeState.Failed }, + direct = AppTransport(direct, direct, direct), + tor = { null }, + ) + try { + split.http.get("https://relay.zapstore.dev", emptyMap()) + throw AssertionError("expected TorUnavailableException") + } catch (failure: TorUnavailableException) { + assertTrue(failure.message!!.contains("refusing Direct fallback")) + } + assertTrue(direct.urls.isEmpty()) + } + + @Test + fun splitAllowsLoopbackDuringBootstrap() { + val direct = RecordingTransport("direct") + val split = AppTransports.split( + mode = { NetworkMode.TorOnly }, + torState = { TorRuntimeState.Bootstrapping }, + direct = AppTransport(direct, direct, direct), + tor = { null }, + ) + split.http.get("http://localhost:3334/deltas?from=0", emptyMap()) + assertEquals(1, direct.urls.size) + try { + split.http.get("https://cdn.zapstore.dev/p/ab.webp", emptyMap()) + throw AssertionError("expected TorUnavailableException") + } catch (failure: TorUnavailableException) { + assertTrue(failure.message!!.contains("bootstrapping")) + } + } + + @Test + fun fallbackUsesDirectWhenTorFails() { + val transport = AppTransports.create(NetworkMode.TorWithDirectFallback) + assertTrue(transport.http is OkHttpAppTransport) + } + + @Test + fun splitFallsBackToDirectWhenFallbackModeAndTorFails() { + val direct = RecordingTransport("direct") + val split = AppTransports.split( + mode = { NetworkMode.TorWithDirectFallback }, + torState = { TorRuntimeState.Failed }, + direct = AppTransport(direct, direct, direct), + tor = { null }, + ) + split.http.get("https://relay.zapstore.dev", emptyMap()) + assertEquals(listOf("https://relay.zapstore.dev"), direct.urls) + } + + @Test + fun deleteArtiCacheRemovesContentsAndRecreatesDir() { + val cache = File.createTempFile("arti-cache", "").apply { + delete() + mkdirs() + } + File(cache, "dir.sqlite3").writeText("consensus") + File(cache, "dir_blobs").mkdirs() + File(cache, "dir_blobs/blob").writeText("blob") + deleteArtiCache(cache) + assertTrue(cache.isDirectory) + assertEquals(emptyList(), cache.list()?.toList().orEmpty()) + cache.deleteRecursively() + } + + @Test + fun fromStoredMigratesLegacyModeNames() { + assertEquals(NetworkMode.TorOnly, NetworkMode.fromStored(null)) + assertEquals(NetworkMode.TorOnly, NetworkMode.fromStored("Tor")) + assertEquals(NetworkMode.DirectOnly, NetworkMode.fromStored("Direct")) + assertEquals(NetworkMode.TorWithDirectFallback, NetworkMode.fromStored("TorWithDirectFallback")) + } +} + +private class FakeArti(override val socksPort: Int) : ArtiEngine { + override fun start() = Unit + override fun stop() = Unit + override fun destroy() = Unit + override fun close() = Unit +} + +private class RecordingTransport( + val name: String, +) : HttpTransport, WebSocketFactory, Call.Factory { + val urls = mutableListOf() + + override fun get(url: String, headers: Map): HttpResponse { + urls += url + return HttpResponse(200, emptyMap(), ByteArray(0)) + } + + override fun open(url: String, listener: WebSocketListener): WebSocketSession { + urls += url + return object : WebSocketSession { + override fun send(text: String) = Unit + override fun close() = Unit + } + } + + override fun newCall(request: Request): Call = error("unused") +} diff --git a/src/test/resources/leaf-ir-offline-maps.bin b/src/test/resources/leaf-ir-offline-maps.bin new file mode 100644 index 0000000..e472b5e Binary files /dev/null and b/src/test/resources/leaf-ir-offline-maps.bin differ