mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Read catalog screens from the local store.
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+136
-1
@@ -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)
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||
android.enableR8.fullMode=true
|
||||
android.r8.strictFullModeForKeepRules=true
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# JNI: Rust looks up ArtiNative by class name and calls ArtiLogCallback.onLogLine.
|
||||
-keep class dev.zapstore.app.transport.ArtiNative { native <methods>; }
|
||||
-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 <methods>;
|
||||
}
|
||||
# Native GetFieldID("srcPos"|"dstPos", "J") — keeping native methods is not enough.
|
||||
-keep class com.github.luben.zstd.** { *; }
|
||||
-keep class ai.onnxruntime.** { *; }
|
||||
@@ -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(
|
||||
AppDetailScreen(
|
||||
state = AppDetailUiState(
|
||||
app = app,
|
||||
repository = null,
|
||||
onClick = {},
|
||||
onProfileClick = { openedProfile = app.event.pubKey },
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun event(
|
||||
id: String = "a".repeat(64),
|
||||
kind: Int,
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
): Event = Event(
|
||||
id = id,
|
||||
pubKey = "1".repeat(64),
|
||||
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 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(),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.zapstore.app
|
||||
|
||||
import dev.zapstore.iolite.QueryOptions
|
||||
import dev.zapstore.iolite.RelayUrl
|
||||
import dev.zapstore.iolite.RemoteMode
|
||||
import dev.zapstore.iolite.normalizeRelayUrl
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** Build-time endpoints and the query options each kind of data uses. */
|
||||
object AppConfig {
|
||||
/** The only native ABI this app ships and matches catalog assets against. */
|
||||
val supportedAbis = listOf("arm64-v8a")
|
||||
|
||||
/** When false every query is local-only and no relay socket is opened. */
|
||||
const val relaysEnabled: Boolean = BuildConfig.RELAYS_ENABLED
|
||||
|
||||
/** Where this install fetches catalog epochs. */
|
||||
val catalogRelay: RelayUrl = BuildConfig.CATALOG_RELAY.normalizeRelayUrl()
|
||||
|
||||
private val generalRelays = setOf("wss://relay.primal.net", "wss://relay.damus.io", "wss://nos.lol").toRelayUrls()
|
||||
private val profileRelays = setOf("wss://brelay.zapstore.dev", "wss://relay.vertexlab.io").toRelayUrls()
|
||||
private val commentRelays = generalRelays + setOf(
|
||||
"wss://brelay.zapstore.dev",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.snort.social",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr.mom",
|
||||
).toRelayUrls()
|
||||
|
||||
/** Where signed events (stacks, preferences, zaps) are published. */
|
||||
val writeRelays: Set<RelayUrl> = 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<String>.toRelayUrls(): Set<RelayUrl> = map(String::normalizeRelayUrl).toSet()
|
||||
}
|
||||
@@ -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<String> = 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<QueryState> = query(listOf(filter), options)
|
||||
|
||||
fun query(
|
||||
filters: List<Filter>,
|
||||
options: QueryOptions = catalogQueryOptions(),
|
||||
): Flow<QueryState>
|
||||
|
||||
/**
|
||||
* 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<Filter>,
|
||||
authors: List<String>,
|
||||
cachedFor: Duration? = null,
|
||||
relays: Set<String>,
|
||||
remoteMode: RemoteMode = RemoteMode.Stream,
|
||||
unionWithFallback: Boolean = true,
|
||||
): Flow<QueryState> = 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<AppInfo>): List<AppInfo> {
|
||||
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<ProfileInfo?> = 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<String?> = flow { emit(app.event.pubKey) }
|
||||
|
||||
/** The author of the app's relay-validated C1 proof, if present. */
|
||||
fun c1Author(app: AppInfo): Flow<String?> = 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<String, Flow<ProfileInfo?>>()
|
||||
private val appAuthorFlows = ConcurrentHashMap<String, Flow<String?>>()
|
||||
private val c1AuthorFlows = ConcurrentHashMap<String, Flow<String?>>()
|
||||
private val relaySigner = MutableStateFlow<String?>(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<Filter>,
|
||||
authors: List<String>,
|
||||
cachedFor: Duration?,
|
||||
relays: Set<String>,
|
||||
remoteMode: RemoteMode,
|
||||
unionWithFallback: Boolean,
|
||||
): Flow<QueryState> {
|
||||
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<ProfileInfo?> =
|
||||
profileFlows.getOrPut(pubkey) {
|
||||
super<CatalogRepository>.profile(pubkey)
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(PROFILE_SESSION_GRACE_MILLIS), replay = 1)
|
||||
}
|
||||
|
||||
override fun appAuthor(app: AppInfo): Flow<String?> =
|
||||
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<String?> =
|
||||
c1AuthorFlows.getOrPut(app.address) {
|
||||
flow { emit(resolveC1Author(app)) }
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(PROFILE_SESSION_GRACE_MILLIS), replay = 1)
|
||||
}
|
||||
|
||||
override fun query(
|
||||
filters: List<Filter>,
|
||||
options: QueryOptions,
|
||||
): Flow<QueryState> = 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<Event> { 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<String> 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<Event>,
|
||||
proofs: Iterable<Event>,
|
||||
appAuthors: Set<String> = emptySet(),
|
||||
now: Long = System.currentTimeMillis() / 1_000,
|
||||
): Set<String> {
|
||||
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<String> 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<String> =
|
||||
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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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://")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 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 openUrl(value: String) {
|
||||
if (!isHttpUrl(value)) return
|
||||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri())) }
|
||||
}
|
||||
|
||||
private fun profileRoute(pubkey: String): String =
|
||||
"profile/${Uri.encode(pubkey)}"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppInfo> = emptyList(),
|
||||
val searchMessage: String? = null,
|
||||
val isSearching: Boolean = false,
|
||||
val stacks: List<StackInfo> = emptyList(),
|
||||
val stackApps: Map<String, AppInfo> = 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<ReleaseFeedEntry> = 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<String, AppInfo>()
|
||||
private val releasesByApp = mutableMapOf<String, ReleaseInfo>()
|
||||
private var nextUntil: Long? = null
|
||||
private val boundaryEventIds = mutableSetOf<String>()
|
||||
private var releaseLookupKeys: Set<String> = emptySet()
|
||||
private var releaseJob: Job? = null
|
||||
private var verificationAuthors: Set<String> = emptySet()
|
||||
private var verificationLookupAuthors: Set<String> = 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<AppInfo> {
|
||||
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<AppInfo>) {
|
||||
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<AppInfo> {
|
||||
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<AppInfo>) {
|
||||
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<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var stackPreviewJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
private var stackAddresses: List<String> = 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<StackInfo>) {
|
||||
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<String, AppInfo> = emptyMap(),
|
||||
val releasesByAddress: Map<String, ReleaseInfo> = 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<String>(STACK_ID_ARGUMENT))
|
||||
private val _uiState = MutableStateFlow(StackDetailUiState())
|
||||
val uiState: StateFlow<StackDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var appsJob: Job? = null
|
||||
private var releasesJob: Job? = null
|
||||
private var appAddresses: List<String> = emptyList()
|
||||
private var releaseLookupKeys: Set<String> = emptySet()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
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<AppInfo>) {
|
||||
val lookupKeys = apps.map(AppInfo::address).toSet()
|
||||
if (lookupKeys == releaseLookupKeys) return
|
||||
releaseLookupKeys = lookupKeys
|
||||
releasesJob?.cancel()
|
||||
if (apps.isEmpty()) {
|
||||
_uiState.update { it.copy(releasesByAddress = emptyMap()) }
|
||||
return
|
||||
}
|
||||
val releaseFilters = apps.map { app ->
|
||||
Filter(
|
||||
authors = listOf(app.event.pubKey),
|
||||
kinds = Catalog.releaseKinds,
|
||||
tags = mapOf("i" to listOf(app.identifier)),
|
||||
limit = 1,
|
||||
)
|
||||
}
|
||||
releasesJob = viewModelScope.launch {
|
||||
repository.query(releaseFilters).collect { queryState ->
|
||||
val releases = linkedMapOf<String, ReleaseInfo>()
|
||||
queryState.items
|
||||
.filter { it.kind in Catalog.releaseKinds }
|
||||
.map(::ReleaseInfo)
|
||||
.forEach { release ->
|
||||
val identifier = release.appIdentifier ?: return@forEach
|
||||
val address = "${Catalog.appKind}:${release.event.pubKey}:$identifier"
|
||||
releases[address] = preferredRelease(releases[address], release)
|
||||
}
|
||||
_uiState.update { it.copy(releasesByAddress = releases) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String>(PROFILE_PUBKEY_ARGUMENT))
|
||||
private val _uiState = MutableStateFlow(ProfileUiState(pubkey = pubkey))
|
||||
val uiState: StateFlow<ProfileUiState> = _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<String>(APP_IDENTIFIER_ARGUMENT))
|
||||
private val author = savedStateHandle.get<String>(APP_AUTHOR_ARGUMENT)?.takeIf(String::isNotBlank)
|
||||
private val _uiState = MutableStateFlow(AppDetailUiState())
|
||||
val uiState: StateFlow<AppDetailUiState> = _uiState.asStateFlow()
|
||||
private var zapAssetJob: Job? = null
|
||||
private var zapReceiptJob: Job? = null
|
||||
private var zapAddress: String? = null
|
||||
private var zapAssetIds: Set<String> = 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<Filter> = buildList {
|
||||
add(
|
||||
Filter(
|
||||
kinds = listOf(Catalog.zapReceiptKind),
|
||||
tags = mapOf("a" to listOf(app.address)),
|
||||
limit = ZAP_RECEIPT_LIMIT,
|
||||
),
|
||||
)
|
||||
zapAssetIds.takeIf(Set<String>::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
|
||||
@@ -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)
|
||||
network.setForeground(true)
|
||||
iolite.setRelayTrafficEnabled(true)
|
||||
scope.launch { catalogSync.sync() }
|
||||
}
|
||||
applicationScope.launch { catalogSyncRepository.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
val lineage: List<String>,
|
||||
)
|
||||
|
||||
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<String> = 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')
|
||||
}
|
||||
@@ -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<InstalledApp>,
|
||||
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<SyncStatus> = _status.asStateFlow()
|
||||
|
||||
private val installed = MutableStateFlow<List<InstalledApp>>(emptyList())
|
||||
|
||||
/** Newer catalog listings for packages installed on this device. */
|
||||
val updates: Flow<List<AvailableUpdate>> =
|
||||
combine(iolite.observeApps(), installed) { apps, packages -> availableUpdates(packages, apps) }
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 1)
|
||||
|
||||
val updateCount: Flow<Int> = 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"
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
)
|
||||
|
||||
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<InstalledApp> =
|
||||
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<InstalledApp>, apps: List<AppRecord>): List<AvailableUpdate> {
|
||||
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) }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<Event>,
|
||||
val deletedIds: List<String>,
|
||||
val oldEpoch: Long,
|
||||
val newEpoch: Long,
|
||||
)
|
||||
|
||||
data class DecodedEnvelope(
|
||||
val manifest: CatalogManifest,
|
||||
val payload: ByteArray,
|
||||
)
|
||||
|
||||
object CatalogEnvelope {
|
||||
fun encode(manifestEventJson: String, payload: ByteArray): ByteArray {
|
||||
val compressed = gzip(payload)
|
||||
val manifestBytes = manifestEventJson.toByteArray(Charsets.UTF_8)
|
||||
val buffer = ByteBuffer.allocate(CatalogSchema.MAGIC.size + 4 + manifestBytes.size + compressed.size)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
buffer.put(CatalogSchema.MAGIC)
|
||||
buffer.putInt(manifestBytes.size)
|
||||
buffer.put(manifestBytes)
|
||||
buffer.put(compressed)
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
fun decode(bytes: ByteArray): DecodedEnvelope {
|
||||
require(bytes.size >= CatalogSchema.MAGIC.size + 4) { "catalog envelope is truncated" }
|
||||
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
|
||||
val magic = ByteArray(CatalogSchema.MAGIC.size)
|
||||
buffer.get(magic)
|
||||
require(magic.contentEquals(CatalogSchema.MAGIC)) { "catalog envelope magic is invalid" }
|
||||
val manifestLength = buffer.int
|
||||
require(manifestLength > 0 && manifestLength <= buffer.remaining()) { "catalog envelope manifest is truncated" }
|
||||
val manifestBytes = ByteArray(manifestLength)
|
||||
buffer.get(manifestBytes)
|
||||
val compressed = ByteArray(buffer.remaining())
|
||||
buffer.get(compressed)
|
||||
return DecodedEnvelope(
|
||||
manifest = parseManifest(JSONObject(String(manifestBytes, Charsets.UTF_8))),
|
||||
payload = gunzip(compressed),
|
||||
)
|
||||
}
|
||||
|
||||
fun decodeStreamToFile(input: InputStream, payloadFile: File): CatalogManifest {
|
||||
val magic = input.readExact(CatalogSchema.MAGIC.size)
|
||||
require(magic.size == CatalogSchema.MAGIC.size && magic.contentEquals(CatalogSchema.MAGIC)) {
|
||||
"catalog envelope magic is invalid"
|
||||
}
|
||||
val lengthBytes = input.readExact(4)
|
||||
require(lengthBytes.size == 4) { "catalog envelope is truncated" }
|
||||
val manifestLength = ByteBuffer.wrap(lengthBytes).order(ByteOrder.BIG_ENDIAN).int
|
||||
require(manifestLength > 0) { "catalog envelope manifest is truncated" }
|
||||
val manifestBytes = input.readExact(manifestLength)
|
||||
require(manifestBytes.size == manifestLength) { "catalog envelope manifest is truncated" }
|
||||
payloadFile.outputStream().use { output ->
|
||||
GZIPInputStream(input).use { gzip -> gzip.copyTo(output) }
|
||||
}
|
||||
return parseManifest(JSONObject(String(manifestBytes, Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
private fun InputStream.readExact(count: Int): ByteArray {
|
||||
val bytes = ByteArray(count)
|
||||
var offset = 0
|
||||
while (offset < count) {
|
||||
val read = read(bytes, offset, count - offset)
|
||||
if (read < 0) return bytes.copyOf(offset)
|
||||
offset += read
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun parseManifest(json: JSONObject): CatalogManifest {
|
||||
val event = Event(
|
||||
id = json.getString("id"),
|
||||
pubKey = json.getString("pubkey"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
kind = json.getInt("kind"),
|
||||
tags = decodeEventTags(json.getJSONArray("tags")),
|
||||
content = json.getString("content"),
|
||||
sig = json.getString("sig"),
|
||||
)
|
||||
val body = JSONObject(event.content)
|
||||
return CatalogManifest(
|
||||
event = event,
|
||||
contentHash = body.getString("content_hash"),
|
||||
oldEpoch = body.getLong("old_epoch"),
|
||||
newEpoch = body.getLong("new_epoch"),
|
||||
schemaVersion = body.getInt("schema_version"),
|
||||
searchModel = body.getString("search_model"),
|
||||
catalog = body.optString("catalog", CatalogSchema.CATALOG),
|
||||
kind = body.getString("kind"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseDelta(payload: ByteArray): CatalogDelta {
|
||||
val json = JSONObject(String(payload, Charsets.UTF_8))
|
||||
val events = json.getJSONArray("events")
|
||||
val deleted = json.optJSONArray("deleted_ids") ?: JSONArray()
|
||||
return CatalogDelta(
|
||||
events = List(events.length()) { parseWireEvent(events.getJSONObject(it)) },
|
||||
deletedIds = List(deleted.length()) { deleted.getString(it) },
|
||||
oldEpoch = json.getLong("old_epoch"),
|
||||
newEpoch = json.getLong("new_epoch"),
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeDelta(delta: CatalogDelta): ByteArray {
|
||||
val events = JSONArray()
|
||||
delta.events.forEach { events.put(wireEvent(it)) }
|
||||
val deleted = JSONArray()
|
||||
delta.deletedIds.forEach(deleted::put)
|
||||
return JSONObject()
|
||||
.put("events", events)
|
||||
.put("deleted_ids", deleted)
|
||||
.put("old_epoch", delta.oldEpoch)
|
||||
.put("new_epoch", delta.newEpoch)
|
||||
.toString()
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun parseWireEvent(json: JSONObject): Event = Event(
|
||||
id = json.getString("id"),
|
||||
pubKey = json.getString("pubkey"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
kind = json.getInt("kind"),
|
||||
tags = decodeEventTags(json.getJSONArray("tags")),
|
||||
content = json.getString("content"),
|
||||
sig = json.getString("sig"),
|
||||
)
|
||||
|
||||
fun wireEvent(event: Event): JSONObject = JSONObject()
|
||||
.put("id", event.id)
|
||||
.put("pubkey", event.pubKey)
|
||||
.put("created_at", event.createdAt)
|
||||
.put("kind", event.kind)
|
||||
.put("tags", JSONArray(event.tags.map { JSONArray(it.toList()) }))
|
||||
.put("content", event.content)
|
||||
.put("sig", event.sig)
|
||||
|
||||
fun decodeEventTags(tags: JSONArray): Array<Array<String>> =
|
||||
Array(tags.length()) { index ->
|
||||
val row = tags.getJSONArray(index)
|
||||
Array(row.length()) { row.getString(it) }
|
||||
}
|
||||
|
||||
fun gzip(bytes: ByteArray): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
GZIPOutputStream(output).use { it.write(bytes) }
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun gunzip(bytes: ByteArray): ByteArray =
|
||||
GZIPInputStream(ByteArrayInputStream(bytes)).use { it.readBytes() }
|
||||
}
|
||||
|
||||
data class UpdatesRequest(
|
||||
val protocol: Int,
|
||||
val catalog: String,
|
||||
val schemaVersion: Int,
|
||||
val epoch: Long,
|
||||
val searchModel: String,
|
||||
) {
|
||||
fun toJson(): String =
|
||||
"""{"protocol":$protocol,"catalog":"$catalog","schema_version":$schemaVersion,"epoch":$epoch,"search_model":"$searchModel"}"""
|
||||
|
||||
fun forbiddenFields(): Set<String> = emptySet()
|
||||
}
|
||||
|
||||
fun parseUpdatesRequest(json: JSONObject): UpdatesRequest {
|
||||
val forbidden = listOf(
|
||||
"installed", "apps", "package", "packages", "search", "query", "hashes", "versions",
|
||||
)
|
||||
json.keys().asSequence().forEach { key ->
|
||||
require(key !in forbidden) { "updates request must not include $key" }
|
||||
}
|
||||
return UpdatesRequest(
|
||||
protocol = json.getInt("protocol"),
|
||||
catalog = json.getString("catalog"),
|
||||
schemaVersion = json.getInt("schema_version"),
|
||||
epoch = json.getLong("epoch"),
|
||||
searchModel = json.getString("search_model"),
|
||||
)
|
||||
}
|
||||
@@ -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)",
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<AvailableUpdate> = emptyList(),
|
||||
)
|
||||
|
||||
class CatalogSyncRepository(
|
||||
private val database: ZapstoreDatabase,
|
||||
private val store: CompactEventStore,
|
||||
private val client: CatalogSyncClient,
|
||||
private val installedApps: () -> List<InstalledApp>,
|
||||
private val trustedPubkey: String,
|
||||
private val purpleQuartz: () -> Iolite?,
|
||||
private val now: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val _state = MutableStateFlow(
|
||||
CatalogSyncUiState(
|
||||
epoch = database.catalogState()?.epoch ?: 0,
|
||||
eventRowCount = database.eventRowCount(),
|
||||
availableUpdates = currentUpdates(),
|
||||
),
|
||||
)
|
||||
val state: StateFlow<CatalogSyncUiState> = _state.asStateFlow()
|
||||
|
||||
fun refreshUpdates() {
|
||||
_state.value = _state.value.copy(availableUpdates = currentUpdates())
|
||||
}
|
||||
|
||||
suspend fun sync() {
|
||||
mutex.withLock {
|
||||
_state.value = _state.value.copy(syncing = true, error = null)
|
||||
try {
|
||||
applyRemoteUpdate()
|
||||
_state.value = readUiState(lastSyncedAtMillis = now(), error = null)
|
||||
purpleQuartz()?.invalidateLocalProjections()
|
||||
} catch (failure: Throwable) {
|
||||
_state.value = readUiState(
|
||||
lastSyncedAtMillis = _state.value.lastSyncedAtMillis,
|
||||
error = failure.message ?: failure::class.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRemoteUpdate() {
|
||||
val current = database.catalogState() ?: error("catalog state is missing")
|
||||
val request = UpdatesRequest(
|
||||
protocol = CatalogSchema.PROTOCOL,
|
||||
catalog = current.catalog,
|
||||
schemaVersion = current.schemaVersion,
|
||||
epoch = current.epoch,
|
||||
searchModel = current.searchModel,
|
||||
)
|
||||
when (val response = client.requestUpdates(request)) {
|
||||
UpdatesResponse.NotModified -> Unit
|
||||
is UpdatesResponse.Delta -> applyDelta(response.body, current)
|
||||
is UpdatesResponse.Error -> throw CatalogVerificationException(
|
||||
response.reason ?: "catalog update failed with HTTP ${response.status}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDelta(body: ByteArray, current: CatalogState) {
|
||||
val envelope = CatalogEnvelope.decode(body)
|
||||
CatalogVerifier.verifyEnvelope(
|
||||
envelope,
|
||||
trustedPubkey = trustedPubkey,
|
||||
expectedKind = "delta",
|
||||
expectedOldEpoch = current.epoch,
|
||||
schemaVersion = current.schemaVersion,
|
||||
searchModel = current.searchModel,
|
||||
catalog = current.catalog,
|
||||
)
|
||||
val delta = CatalogEnvelope.parseDelta(envelope.payload)
|
||||
delta.events.forEach(CatalogVerifier::verifyEvent)
|
||||
store.applyDelta(
|
||||
events = delta.events,
|
||||
deletedIds = delta.deletedIds,
|
||||
next = current.copy(
|
||||
epoch = delta.newEpoch,
|
||||
generation = current.generation + 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun readUiState(
|
||||
lastSyncedAtMillis: Long? = _state.value.lastSyncedAtMillis,
|
||||
error: String? = _state.value.error,
|
||||
) = CatalogSyncUiState(
|
||||
epoch = database.catalogState()?.epoch ?: 0,
|
||||
eventRowCount = database.eventRowCount(),
|
||||
lastSyncedAtMillis = lastSyncedAtMillis,
|
||||
syncing = false,
|
||||
error = error,
|
||||
availableUpdates = currentUpdates(),
|
||||
)
|
||||
|
||||
private fun currentUpdates(): List<AvailableUpdate> =
|
||||
LocalUpdateMatcher.match(installedApps(), catalogAssets())
|
||||
|
||||
fun catalogAssets(): List<CatalogAssetRow> = database.withConnection { connection ->
|
||||
connection.prepare(
|
||||
"""
|
||||
SELECT assets.app_id, COALESCE(apps.name, assets.app_id), COALESCE(assets.version, ''),
|
||||
COALESCE(assets.version_code, 0), releases.channel, assets.platform, assets.variant,
|
||||
assets.certificate_hash, assets.mime
|
||||
FROM assets
|
||||
LEFT JOIN apps ON apps.app_id = assets.app_id
|
||||
LEFT JOIN releases ON releases.app_id = assets.app_id AND releases.version = assets.version
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
val rows = mutableListOf<CatalogAssetRow>()
|
||||
while (statement.step()) {
|
||||
rows += CatalogAssetRow(
|
||||
appId = statement.getText(0),
|
||||
name = statement.getText(1),
|
||||
version = statement.getText(2),
|
||||
versionCode = statement.getLong(3),
|
||||
channel = statement.nullableText(4),
|
||||
platform = statement.nullableText(5),
|
||||
variant = statement.nullableText(6),
|
||||
certificateHash = statement.nullableText(7),
|
||||
mime = statement.nullableText(8),
|
||||
)
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.sqlite.SQLiteStatement.nullableText(index: Int): String? =
|
||||
if (isNull(index)) null else getText(index)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 <T : Event> query(filter: Filter): List<T> = query(listOf(filter))
|
||||
|
||||
override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val seen = linkedSetOf<String>()
|
||||
val matches = mutableListOf<T>()
|
||||
database.withConnection { connection ->
|
||||
filters.forEach { filter ->
|
||||
selectCandidates(connection, filter).forEach { event ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
if (filter.match(event) && seen.add(event.id)) {
|
||||
matches += event as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
|
||||
query<T>(filter).forEach(onEach)
|
||||
}
|
||||
|
||||
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
|
||||
query<T>(filters).forEach(onEach)
|
||||
}
|
||||
|
||||
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
|
||||
|
||||
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
|
||||
|
||||
override suspend fun delete(filter: Filter) {
|
||||
delete(listOf(filter))
|
||||
}
|
||||
|
||||
override suspend fun delete(filters: List<Filter>) {
|
||||
database.transaction { connection ->
|
||||
filters.forEach { filter ->
|
||||
selectCandidates(connection, filter)
|
||||
.filter(filter::match)
|
||||
.forEach { deleteEvent(connection, it.id.hexToBytes()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteExpiredEvents() {
|
||||
database.transaction { connection ->
|
||||
selectAll(connection)
|
||||
.filter { it.isExpired() }
|
||||
.forEach { deleteEvent(connection, it.id.hexToBytes()) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reindexFullTextSearch() = Unit
|
||||
|
||||
override suspend fun reindexFullTextSearch(resumeFrom: String?, batchSize: Int): FtsReindexProgress =
|
||||
FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true)
|
||||
|
||||
override fun close() {
|
||||
closed.set(true)
|
||||
}
|
||||
|
||||
fun insertAll(events: List<Event>) {
|
||||
database.transaction { connection ->
|
||||
events.forEach { insertUnlocked(connection, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteByIds(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
database.transaction { connection ->
|
||||
ids.forEach { deleteEvent(connection, it.hexToBytes()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyDelta(events: List<Event>, deletedIds: List<String>, next: CatalogState) {
|
||||
database.transaction { connection ->
|
||||
deletedIds.forEach { deleteEvent(connection, it.hexToBytes()) }
|
||||
events.forEach { insertUnlocked(connection, it) }
|
||||
ZapstoreDatabase.upsertCatalogState(connection, next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertUnlocked(connection: SQLiteConnection, event: Event) {
|
||||
val id = event.id.hexToBytes()
|
||||
val pubkey = event.pubKey.hexToBytes()
|
||||
val dTag = event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1).orEmpty()
|
||||
if (event.kind.isAddressable()) {
|
||||
deleteOlderAddressable(connection, event.kind, pubkey, dTag, event.createdAt, event.id)
|
||||
} else if (event.kind.isReplaceable()) {
|
||||
deleteOlderReplaceable(connection, event.kind, pubkey, event.createdAt, event.id)
|
||||
}
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT OR REPLACE INTO events(id, pubkey, created_at, kind, d_tag, content, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindBlob(1, id)
|
||||
statement.bindBlob(2, pubkey)
|
||||
statement.bindLong(3, event.createdAt)
|
||||
statement.bindLong(4, event.kind.toLong())
|
||||
statement.bindText(5, dTag)
|
||||
statement.bindText(6, event.content)
|
||||
statement.bindText(7, encodeTags(event.tags))
|
||||
statement.step()
|
||||
}
|
||||
connection.prepare("DELETE FROM event_tags WHERE event_id = ?").use { statement ->
|
||||
statement.bindBlob(1, id)
|
||||
statement.step()
|
||||
}
|
||||
event.tags.forEach { tag ->
|
||||
val key = tag.getOrNull(0) ?: return@forEach
|
||||
val value = tag.getOrNull(1) ?: return@forEach
|
||||
connection.prepare("INSERT OR IGNORE INTO event_tags(event_id, key, value) VALUES (?, ?, ?)").use { statement ->
|
||||
statement.bindBlob(1, id)
|
||||
statement.bindText(2, key)
|
||||
statement.bindText(3, value)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
upsertDerived(connection, event, id, pubkey)
|
||||
}
|
||||
|
||||
private fun upsertDerived(connection: SQLiteConnection, event: Event, id: ByteArray, pubkey: ByteArray) {
|
||||
when (event.kind) {
|
||||
Catalog.appKind -> {
|
||||
val appId = event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) ?: return
|
||||
val name = event.tags.firstOrNull { it.firstOrNull() == "name" }?.getOrNull(1) ?: appId
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT INTO apps(app_id, event_id, pubkey, name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(app_id) DO UPDATE SET
|
||||
event_id = excluded.event_id,
|
||||
pubkey = excluded.pubkey,
|
||||
name = excluded.name,
|
||||
created_at = excluded.created_at
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindText(1, appId)
|
||||
statement.bindBlob(2, id)
|
||||
statement.bindBlob(3, pubkey)
|
||||
statement.bindText(4, name)
|
||||
statement.bindLong(5, event.createdAt)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
Catalog.releaseKind -> {
|
||||
val appId = event.tags.firstOrNull { it.firstOrNull() == "i" }?.getOrNull(1)
|
||||
?: event.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1)?.substringBefore('@')
|
||||
?: return
|
||||
val version = event.tags.firstOrNull { it.firstOrNull() == "version" }?.getOrNull(1) ?: return
|
||||
val channel = event.tags.firstOrNull { it.firstOrNull() == "c" }?.getOrNull(1)
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT INTO releases(app_id, version, event_id, channel, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(app_id, version) DO UPDATE SET
|
||||
event_id = excluded.event_id,
|
||||
channel = excluded.channel,
|
||||
created_at = excluded.created_at
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindText(1, appId)
|
||||
statement.bindText(2, version)
|
||||
statement.bindBlob(3, id)
|
||||
if (channel == null) statement.bindNull(4) else statement.bindText(4, channel)
|
||||
statement.bindLong(5, event.createdAt)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
Catalog.assetKind -> {
|
||||
val appId = event.tags.firstOrNull { it.firstOrNull() == "i" }?.getOrNull(1) ?: return
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT INTO assets(
|
||||
event_id, app_id, version_code, version, mime, platform, variant,
|
||||
certificate_hash, file_hash, url, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO UPDATE SET
|
||||
app_id = excluded.app_id,
|
||||
version_code = excluded.version_code,
|
||||
version = excluded.version,
|
||||
mime = excluded.mime,
|
||||
platform = excluded.platform,
|
||||
variant = excluded.variant,
|
||||
certificate_hash = excluded.certificate_hash,
|
||||
file_hash = excluded.file_hash,
|
||||
url = excluded.url,
|
||||
created_at = excluded.created_at
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindBlob(1, id)
|
||||
statement.bindText(2, appId)
|
||||
event.tags.firstOrNull { it.firstOrNull() == "version_code" }?.getOrNull(1)?.toLongOrNull()
|
||||
?.let { statement.bindLong(3, it) }
|
||||
?: statement.bindNull(3)
|
||||
bindOptional(statement, 4, event.tag("version"))
|
||||
bindOptional(statement, 5, event.tag("m"))
|
||||
bindOptional(statement, 6, event.tags.filter { it.firstOrNull() == "f" }.mapNotNull { it.getOrNull(1) }.joinToString(",").ifEmpty { null })
|
||||
bindOptional(statement, 7, event.tag("variant"))
|
||||
bindOptional(statement, 8, event.tag("apk_certificate_hash"))
|
||||
bindOptional(statement, 9, event.tag("x"))
|
||||
bindOptional(statement, 10, event.tag("url"))
|
||||
statement.bindLong(11, event.createdAt)
|
||||
statement.step()
|
||||
}
|
||||
val version = event.tag("version") ?: return
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT INTO releases(app_id, version, event_id, channel, created_at)
|
||||
VALUES (?, ?, ?, NULL, ?)
|
||||
ON CONFLICT(app_id, version) DO NOTHING
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindText(1, appId)
|
||||
statement.bindText(2, version)
|
||||
statement.bindBlob(3, id)
|
||||
statement.bindLong(4, event.createdAt)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteOlderAddressable(
|
||||
connection: SQLiteConnection,
|
||||
kind: Int,
|
||||
pubkey: ByteArray,
|
||||
dTag: String,
|
||||
createdAt: Long,
|
||||
idHex: String,
|
||||
) {
|
||||
connection.prepare(
|
||||
"""
|
||||
SELECT id, created_at FROM events
|
||||
WHERE kind = ? AND pubkey = ? AND d_tag = ?
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindLong(1, kind.toLong())
|
||||
statement.bindBlob(2, pubkey)
|
||||
statement.bindText(3, dTag)
|
||||
val obsolete = mutableListOf<ByteArray>()
|
||||
while (statement.step()) {
|
||||
val existingId = statement.getBlob(0)
|
||||
val existingCreated = statement.getLong(1)
|
||||
val existingHex = existingId.toHex()
|
||||
if (existingCreated < createdAt || existingCreated == createdAt && existingHex > idHex) {
|
||||
obsolete += existingId
|
||||
}
|
||||
}
|
||||
obsolete.forEach { deleteEvent(connection, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteOlderReplaceable(
|
||||
connection: SQLiteConnection,
|
||||
kind: Int,
|
||||
pubkey: ByteArray,
|
||||
createdAt: Long,
|
||||
idHex: String,
|
||||
) {
|
||||
connection.prepare("SELECT id, created_at FROM events WHERE kind = ? AND pubkey = ?").use { statement ->
|
||||
statement.bindLong(1, kind.toLong())
|
||||
statement.bindBlob(2, pubkey)
|
||||
val obsolete = mutableListOf<ByteArray>()
|
||||
while (statement.step()) {
|
||||
val existingId = statement.getBlob(0)
|
||||
val existingCreated = statement.getLong(1)
|
||||
val existingHex = existingId.toHex()
|
||||
if (existingCreated < createdAt || existingCreated == createdAt && existingHex > idHex) {
|
||||
obsolete += existingId
|
||||
}
|
||||
}
|
||||
obsolete.forEach { deleteEvent(connection, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteEvent(connection: SQLiteConnection, id: ByteArray) {
|
||||
connection.prepare("DELETE FROM event_tags WHERE event_id = ?").use {
|
||||
it.bindBlob(1, id)
|
||||
it.step()
|
||||
}
|
||||
connection.prepare("DELETE FROM apps WHERE event_id = ?").use {
|
||||
it.bindBlob(1, id)
|
||||
it.step()
|
||||
}
|
||||
connection.prepare("DELETE FROM releases WHERE event_id = ?").use {
|
||||
it.bindBlob(1, id)
|
||||
it.step()
|
||||
}
|
||||
connection.prepare("DELETE FROM assets WHERE event_id = ?").use {
|
||||
it.bindBlob(1, id)
|
||||
it.step()
|
||||
}
|
||||
connection.prepare("DELETE FROM events WHERE id = ?").use {
|
||||
it.bindBlob(1, id)
|
||||
it.step()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectCandidates(connection: SQLiteConnection, filter: Filter): List<Event> {
|
||||
val clauses = mutableListOf<String>()
|
||||
val binders = mutableListOf<(SQLiteStatement) -> Unit>()
|
||||
var index = 1
|
||||
filter.ids?.takeIf { it.isNotEmpty() }?.let { ids ->
|
||||
clauses += "id IN (${ids.joinToString(",") { "?" }})"
|
||||
ids.forEach { id ->
|
||||
val current = index++
|
||||
binders += { it.bindBlob(current, id.hexToBytes()) }
|
||||
}
|
||||
}
|
||||
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
|
||||
clauses += "pubkey IN (${authors.joinToString(",") { "?" }})"
|
||||
authors.forEach { author ->
|
||||
val current = index++
|
||||
binders += { it.bindBlob(current, author.hexToBytes()) }
|
||||
}
|
||||
}
|
||||
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
|
||||
clauses += "kind IN (${kinds.joinToString(",") { "?" }})"
|
||||
kinds.forEach { kind ->
|
||||
val current = index++
|
||||
binders += { it.bindLong(current, kind.toLong()) }
|
||||
}
|
||||
}
|
||||
filter.since?.let { since ->
|
||||
clauses += "created_at >= ?"
|
||||
val current = index++
|
||||
binders += { it.bindLong(current, since) }
|
||||
}
|
||||
filter.until?.let { until ->
|
||||
clauses += "created_at <= ?"
|
||||
val current = index++
|
||||
binders += { it.bindLong(current, until) }
|
||||
}
|
||||
filter.tags?.forEach { (key, values) ->
|
||||
if (values.isEmpty()) return@forEach
|
||||
val placeholders = values.joinToString(",") { "?" }
|
||||
clauses += "id IN (SELECT event_id FROM event_tags WHERE key = ? AND value IN ($placeholders))"
|
||||
val keyIndex = index++
|
||||
binders += { it.bindText(keyIndex, key) }
|
||||
values.forEach { value ->
|
||||
val current = index++
|
||||
binders += { it.bindText(current, value) }
|
||||
}
|
||||
}
|
||||
filter.search?.takeIf { it.isNotBlank() }?.let { search ->
|
||||
clauses += "(content LIKE ? OR id IN (SELECT event_id FROM event_tags WHERE key IN ('name','summary') AND value LIKE ?))"
|
||||
val pattern = "%$search%"
|
||||
val first = index++
|
||||
val second = index++
|
||||
binders += {
|
||||
it.bindText(first, pattern)
|
||||
it.bindText(second, pattern)
|
||||
}
|
||||
}
|
||||
val sql = buildString {
|
||||
append("SELECT id, pubkey, created_at, kind, content, tags FROM events")
|
||||
if (clauses.isNotEmpty()) append(" WHERE ").append(clauses.joinToString(" AND "))
|
||||
append(" ORDER BY created_at DESC, id ASC")
|
||||
filter.limit?.let { append(" LIMIT ").append(it) }
|
||||
}
|
||||
return connection.prepare(sql).use { statement ->
|
||||
binders.forEach { it(statement) }
|
||||
readEvents(statement)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectAll(connection: SQLiteConnection): List<Event> =
|
||||
connection.prepare("SELECT id, pubkey, created_at, kind, content, tags FROM events").use(::readEvents)
|
||||
|
||||
private fun readEvents(statement: SQLiteStatement): List<Event> {
|
||||
val events = mutableListOf<Event>()
|
||||
while (statement.step()) {
|
||||
events += Event(
|
||||
id = statement.getBlob(0).toHex(),
|
||||
pubKey = statement.getBlob(1).toHex(),
|
||||
createdAt = statement.getLong(2),
|
||||
kind = statement.getInt(3),
|
||||
tags = decodeTags(statement.getText(5)),
|
||||
content = statement.getText(4),
|
||||
sig = "",
|
||||
)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
private fun Event.tag(name: String): String? =
|
||||
tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
|
||||
|
||||
private fun bindOptional(statement: SQLiteStatement, index: Int, value: String?) {
|
||||
if (value == null) statement.bindNull(index) else statement.bindText(index, value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun encodeTags(tags: Array<Array<String>>): String {
|
||||
val array = JSONArray()
|
||||
tags.forEach { tag ->
|
||||
val row = JSONArray()
|
||||
tag.forEach(row::put)
|
||||
array.put(row)
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
fun decodeTags(raw: String): Array<Array<String>> {
|
||||
val array = JSONArray(raw)
|
||||
return Array(array.length()) { index ->
|
||||
val row = array.getJSONArray(index)
|
||||
Array(row.length()) { row.getString(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<InstalledApp> {
|
||||
val manager = context.packageManager
|
||||
return manager.getInstalledPackages(PackageManager.GET_SIGNING_CERTIFICATES).map { info ->
|
||||
val signing = info.signingInfo
|
||||
val certificates = buildSet {
|
||||
signing?.apkContentsSigners?.forEach { add(it.toByteArray().sha256Hex()) }
|
||||
signing?.signingCertificateHistory?.forEach { add(it.toByteArray().sha256Hex()) }
|
||||
}
|
||||
InstalledApp(
|
||||
packageId = info.packageName,
|
||||
versionCode = info.longVersionCode,
|
||||
versionName = info.versionName ?: info.longVersionCode.toString(),
|
||||
certificateHashes = certificates,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(this).toHex()
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package dev.zapstore.app.catalogsync
|
||||
|
||||
data class InstalledApp(
|
||||
val packageId: String,
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val certificateHashes: Set<String>,
|
||||
)
|
||||
|
||||
data class CatalogAssetRow(
|
||||
val appId: String,
|
||||
val name: String,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
val channel: String?,
|
||||
val platform: String?,
|
||||
val variant: String?,
|
||||
val certificateHash: String?,
|
||||
val mime: String?,
|
||||
)
|
||||
|
||||
data class AvailableUpdate(
|
||||
val appId: String,
|
||||
val name: String,
|
||||
val installedVersion: String,
|
||||
val installedVersionCode: Long,
|
||||
val availableVersion: String,
|
||||
val availableVersionCode: Long,
|
||||
val channel: String?,
|
||||
)
|
||||
|
||||
object LocalUpdateMatcher {
|
||||
fun match(
|
||||
installed: List<InstalledApp>,
|
||||
catalog: List<CatalogAssetRow>,
|
||||
channel: String = "stable",
|
||||
androidPlatforms: Set<String> = ANDROID_PLATFORMS,
|
||||
): List<AvailableUpdate> {
|
||||
val latestByApp = catalog
|
||||
.filter { row ->
|
||||
(row.channel == null || row.channel == channel) &&
|
||||
platformMatches(row.platform, androidPlatforms) &&
|
||||
(row.mime == null || row.mime == ANDROID_MIME)
|
||||
}
|
||||
.groupBy { it.appId }
|
||||
.mapValues { (_, rows) -> rows.maxBy { it.versionCode } }
|
||||
return installed.mapNotNull { app ->
|
||||
val row = latestByApp[app.packageId] ?: return@mapNotNull null
|
||||
if (row.versionCode <= app.versionCode) return@mapNotNull null
|
||||
if (row.certificateHash != null &&
|
||||
row.certificateHash !in app.certificateHashes
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
AvailableUpdate(
|
||||
appId = app.packageId,
|
||||
name = row.name,
|
||||
installedVersion = app.versionName,
|
||||
installedVersionCode = app.versionCode,
|
||||
availableVersion = row.version,
|
||||
availableVersionCode = row.versionCode,
|
||||
channel = row.channel,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun platformMatches(platform: String?, androidPlatforms: Set<String>): Boolean {
|
||||
if (platform.isNullOrBlank()) return true
|
||||
return platform.split(',').any { it in androidPlatforms || it.startsWith("android") }
|
||||
}
|
||||
|
||||
const val ANDROID_MIME = "application/vnd.android.package-archive"
|
||||
val ANDROID_PLATFORMS = setOf(
|
||||
"android-arm64-v8a",
|
||||
"android-armeabi-v7a",
|
||||
"android-x86_64",
|
||||
"android-x86",
|
||||
"android",
|
||||
)
|
||||
}
|
||||
@@ -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 <T> withConnection(block: (SQLiteConnection) -> T): T = lock.withLock {
|
||||
block(connection)
|
||||
}
|
||||
|
||||
fun transaction(block: (SQLiteConnection) -> Unit) = lock.withLock {
|
||||
connection.execSQL("BEGIN IMMEDIATE")
|
||||
try {
|
||||
block(connection)
|
||||
connection.execSQL("COMMIT")
|
||||
} catch (failure: Throwable) {
|
||||
runCatching { connection.execSQL("ROLLBACK") }
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
fun catalogState(catalog: String = CatalogSchema.CATALOG): CatalogState? = withConnection { db ->
|
||||
db.prepare("SELECT catalog, epoch, schema_version, search_model, generation FROM catalog_state WHERE catalog = ?").use { statement ->
|
||||
statement.bindText(1, catalog)
|
||||
if (!statement.step()) return@withConnection null
|
||||
CatalogState(
|
||||
catalog = statement.getText(0),
|
||||
epoch = statement.getLong(1),
|
||||
schemaVersion = statement.getInt(2),
|
||||
searchModel = statement.getText(3),
|
||||
generation = statement.getLong(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeCatalogState(state: CatalogState) = withConnection { db ->
|
||||
upsertCatalogState(db, state)
|
||||
}
|
||||
|
||||
fun eventRowCount(): Long = withConnection { db ->
|
||||
db.prepare("SELECT COUNT(*) FROM events").use { statement ->
|
||||
check(statement.step()) { "event count query returned no row" }
|
||||
statement.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseAndReplace(snapshot: File) = lock.withLock {
|
||||
connection.close()
|
||||
val target = File(path)
|
||||
val backup = File("$path.bak")
|
||||
if (target.exists()) {
|
||||
backup.delete()
|
||||
if (!target.renameTo(backup)) {
|
||||
connection = openConnection(path)
|
||||
error("could not move the active catalog aside")
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!snapshot.renameTo(target) && !snapshot.copyTo(target, overwrite = true).exists()) {
|
||||
error("could not install the snapshot")
|
||||
}
|
||||
snapshot.delete()
|
||||
connection = openConnection(path)
|
||||
backup.delete()
|
||||
} catch (failure: Throwable) {
|
||||
if (backup.exists()) {
|
||||
target.delete()
|
||||
backup.renameTo(target)
|
||||
}
|
||||
connection = openConnection(path)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = lock.withLock {
|
||||
connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun open(path: String): ZapstoreDatabase {
|
||||
File(path).parentFile?.mkdirs()
|
||||
return ZapstoreDatabase(path).also { it.withConnection(::applySchema) }
|
||||
}
|
||||
|
||||
fun createEmpty(path: String): ZapstoreDatabase {
|
||||
File(path).parentFile?.mkdirs()
|
||||
File(path).delete()
|
||||
val database = open(path)
|
||||
database.writeCatalogState(
|
||||
CatalogState(
|
||||
catalog = CatalogSchema.CATALOG,
|
||||
epoch = 0,
|
||||
schemaVersion = CatalogSchema.VERSION,
|
||||
searchModel = CatalogSchema.SEARCH_MODEL,
|
||||
generation = 0,
|
||||
),
|
||||
)
|
||||
return database
|
||||
}
|
||||
|
||||
internal fun applySchema(connection: SQLiteConnection) {
|
||||
connection.execSQL("PRAGMA foreign_keys = ON")
|
||||
CatalogSchema.CREATE_STATEMENTS.forEach(connection::execSQL)
|
||||
}
|
||||
|
||||
internal fun upsertCatalogState(connection: SQLiteConnection, state: CatalogState) {
|
||||
connection.prepare(
|
||||
"""
|
||||
INSERT INTO catalog_state(catalog, epoch, schema_version, search_model, generation)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(catalog) DO UPDATE SET
|
||||
epoch = excluded.epoch,
|
||||
schema_version = excluded.schema_version,
|
||||
search_model = excluded.search_model,
|
||||
generation = excluded.generation
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.bindText(1, state.catalog)
|
||||
statement.bindLong(2, state.epoch)
|
||||
statement.bindLong(3, state.schemaVersion.toLong())
|
||||
statement.bindText(4, state.searchModel)
|
||||
statement.bindLong(5, state.generation)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(path: String): SQLiteConnection =
|
||||
BundledSQLiteDriver().open(path)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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<AppRecord> = emptyList(),
|
||||
val profiles: Map<String, ProfileRecord> = 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
|
||||
@@ -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 <avatar> <name>" 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
|
||||
}
|
||||
@@ -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<String, AppInfo>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProfileInfo?>(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<String?>(
|
||||
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<String?>(
|
||||
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<String?>(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<String>) {
|
||||
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<Int>, fromBits: Int, toBits: Int, pad: Boolean): List<Int> {
|
||||
var accumulator = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Int>()
|
||||
val maxValue = (1 shl toBits) - 1
|
||||
data.forEach { value ->
|
||||
accumulator = (accumulator shl fromBits) or value
|
||||
bits += fromBits
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits
|
||||
result += (accumulator shr bits) and maxValue
|
||||
}
|
||||
}
|
||||
if (pad && bits > 0) result += (accumulator shl (toBits - bits)) and maxValue
|
||||
return result
|
||||
}
|
||||
|
||||
private fun createBech32Checksum(hrp: String, data: List<Int>): List<Int> {
|
||||
val values = expandHrp(hrp) + data + List(6) { 0 }
|
||||
val polymod = bech32Polymod(values) xor 1L
|
||||
return (0 until 6).map { shift ->
|
||||
((polymod shr (5 * (5 - shift))) and 31).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun expandHrp(hrp: String): List<Int> =
|
||||
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
|
||||
|
||||
private fun bech32Polymod(values: List<Int>): Long {
|
||||
var checksum = 1L
|
||||
values.forEach { value ->
|
||||
val top = checksum shr 25
|
||||
checksum = ((checksum and 0x1ffffffL) shl 5) xor value.toLong()
|
||||
BECH32_GENERATOR.forEachIndexed { index, generator ->
|
||||
if (((top shr index) and 1L) != 0L) checksum = checksum xor generator
|
||||
}
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
@@ -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<String?>(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<String>) {
|
||||
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"
|
||||
}
|
||||
@@ -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<ReleaseFeedEntry>,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, AppRecord>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://")
|
||||
|
||||
@@ -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,44 +47,71 @@ 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<Int?>(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
|
||||
@@ -94,37 +127,19 @@ fun AppDetailScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (app == null) {
|
||||
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),
|
||||
color = ZapTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.error?.let { error -> item { StatusText(error) } }
|
||||
item { if (state.loaded) NotFoundCard() else AppDetailSkeleton() }
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
item {
|
||||
RegularAppHeader(
|
||||
app = app,
|
||||
release = state.release,
|
||||
repository = repository,
|
||||
authorPubkey = authorPubkey,
|
||||
authorProfile = authorProfile,
|
||||
onProfileClick = { authorPubkey?.let(onProfileClick) },
|
||||
AppIdentityRow(
|
||||
name = app.name,
|
||||
iconUrl = app.iconUrl,
|
||||
iconFile = app.iconFile,
|
||||
version = app.version,
|
||||
authorPubkey = app.authorPubkey,
|
||||
author = state.author,
|
||||
onAuthorClick = onProfileClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,54 +156,41 @@ fun AppDetailScreen(
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ZapSurface3)
|
||||
.clickable {
|
||||
selectedScreenshot = app.screenshots.indexOf(url)
|
||||
},
|
||||
.clickable { selectedScreenshot = app.screenshots.indexOf(url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val description = app.event.content.ifBlank { app.summary }
|
||||
if (description.isNotBlank()) {
|
||||
if (app.about.isNotBlank()) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MarkdownText(
|
||||
value = description,
|
||||
value = app.about,
|
||||
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),
|
||||
if (app.description.isNotBlank()) {
|
||||
TextButton(
|
||||
onClick = { showDeveloperDescription = true },
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.testTag("developerDescription"),
|
||||
) {
|
||||
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),
|
||||
text = stringResource(R.string.see_developer_description),
|
||||
color = ZapActionText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
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 {
|
||||
Box(
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.height(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ZapSurface2),
|
||||
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 {
|
||||
ReleaseNotesSkeleton()
|
||||
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 {
|
||||
AppInfoCard(
|
||||
app = app,
|
||||
release = state.release,
|
||||
author = state.author,
|
||||
onOpenUrl = onOpenUrl,
|
||||
repository = repository,
|
||||
authorPubkey = authorPubkey,
|
||||
c1AuthorPubkey = c1AuthorPubkey,
|
||||
authorProfile = authorProfile,
|
||||
onProfileClick = { authorPubkey?.let(onProfileClick) },
|
||||
onProfileClick = onProfileClick,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
state.error?.let { error -> item { StatusText(error) } }
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
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(stringResource(R.string.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,
|
||||
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,
|
||||
) {
|
||||
AppAuthorByline(
|
||||
pubkey = pubkey,
|
||||
repository = repository,
|
||||
profile = authorProfile,
|
||||
showBy = false,
|
||||
onClick = onProfileClick,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
c1AuthorPubkey?.let { pubkey ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentRow(
|
||||
comment: CommentRecord,
|
||||
author: ProfileRecord?,
|
||||
onOpenUrl: (String) -> Unit,
|
||||
onAuthorClick: (String) -> Unit,
|
||||
depth: Int = 0,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
.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}"),
|
||||
) {
|
||||
StatusText("C1 author")
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ZapSurface3),
|
||||
) {
|
||||
AppAuthorByline(
|
||||
pubkey = pubkey,
|
||||
repository = repository,
|
||||
showBy = false,
|
||||
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))
|
||||
}
|
||||
}
|
||||
release?.let {
|
||||
InfoRow(
|
||||
stringResource(R.string.release_date),
|
||||
formatDate(it.event.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))
|
||||
|
||||
|
||||
@@ -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<CommentThread> = emptyList(),
|
||||
val profiles: Map<String, ProfileRecord> = 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<AppRecord?> = iolite.observeApp(appId)
|
||||
|
||||
private val author: Flow<ProfileRecord?> = 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<QueryState<List<ZapRecord>>?> = 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<QueryState<List<CommentRecord>>?> = 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<Map<String, ProfileRecord>> = 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<AppDetailUiState> = 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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CommentThread>,
|
||||
)
|
||||
|
||||
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<CommentRecord>): List<CommentThread> {
|
||||
val ids = items.mapTo(hashSetOf()) { it.eventId }
|
||||
val children = items.groupBy { comment ->
|
||||
comment.parentEventId?.takeIf { it in ids }
|
||||
}
|
||||
fun build(comment: CommentRecord, trail: Set<String>): 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<CommentThread>.flattenComments(maxDepth: Int = 4): List<ThreadedComment> =
|
||||
flatMap { it.flatten(0, maxDepth) }
|
||||
|
||||
private fun CommentThread.flatten(depth: Int, maxDepth: Int): List<ThreadedComment> =
|
||||
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
|
||||
}
|
||||
@@ -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")) }
|
||||
state.searchResults?.let { results ->
|
||||
item {
|
||||
StatusText(
|
||||
value = stringResource(R.string.search_results, results.size, state.searchDurationMillis ?: 0L),
|
||||
modifier = Modifier.testTag("searchStatus"),
|
||||
)
|
||||
}
|
||||
items(state.searchResults, key = { it.address }) { app ->
|
||||
items(results, key = { "search:${it.appId}" }) { app ->
|
||||
AppCard(
|
||||
app = app,
|
||||
onClick = { onAppClick(app.identifier, app.event.pubKey) },
|
||||
onProfileClick = { onProfileClick(app.event.pubKey) },
|
||||
repository = repository,
|
||||
modifier = Modifier.testTag("searchResult:${app.address}"),
|
||||
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(
|
||||
state.stacks.isNotEmpty() -> LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(end = 8.dp),
|
||||
) {
|
||||
items(state.stacks, key = { it.event.id }) { stack ->
|
||||
items(state.stacks, key = { it.eventId }) { stack ->
|
||||
StackCard(
|
||||
stack = stack,
|
||||
appsByAddress = state.stackApps,
|
||||
onClick = { onStackClick(stack.event.id) },
|
||||
modifier = Modifier.testTag("stack:${stack.event.id}"),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppRecord>? = null,
|
||||
/** Encode plus first ranking pass for [searchResults]. Null when no search is active. */
|
||||
val searchDurationMillis: Long? = null,
|
||||
val stacks: List<StackRecord> = emptyList(),
|
||||
val stackApps: Map<String, AppRecord> = emptyMap(),
|
||||
val stacksLoading: Boolean = true,
|
||||
val stacksError: String? = null,
|
||||
val feed: AppListState = AppListState(),
|
||||
val profiles: Map<String, ProfileRecord> = 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<SearchSnapshot> = 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<AppRecord>, val limit: Int)
|
||||
|
||||
private val feedPage: Flow<FeedPage> = requestedPages.flatMapLatest { pages ->
|
||||
val limit = pages * PAGE_SIZE
|
||||
iolite.observeApps(AppFilter(limit = limit)).map { FeedPage(it, limit) }
|
||||
}
|
||||
|
||||
private val stacks: Flow<QueryState<List<StackRecord>>> =
|
||||
iolite.query(Query.stacks(CatalogStackPubkey, limit = STACK_LIMIT))
|
||||
|
||||
private val stackApps: Flow<Map<String, AppRecord>> = stacks
|
||||
.map { state -> state.items.flatMap(StackRecord::appIds).toSet() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest(iolite::observeAppsById)
|
||||
|
||||
private val profiles: Flow<Map<String, ProfileRecord>> =
|
||||
combine(feedPage, search) { page, snapshot -> page.apps + snapshot.results.orEmpty() }.authorProfiles(iolite)
|
||||
|
||||
val uiState: StateFlow<HomeUiState> = 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<AppRecord>? = null,
|
||||
val durationMillis: Long? = 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ZapSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ProfileImage(
|
||||
pubkey = state.pubkey,
|
||||
name = profile.displayName ?: profile.name ?: state.pubkey,
|
||||
pictureUrl = profile.picture,
|
||||
profileVersion = profile.event.id,
|
||||
size = 84.dp,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProfileUiState> = 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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}"
|
||||
}
|
||||
@@ -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<String>): Flow<Map<String, ProfileRecord>> =
|
||||
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<List<AppRecord>>.authorProfiles(iolite: Iolite): Flow<Map<String, ProfileRecord>> =
|
||||
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<String>): Flow<Map<String, AppRecord>> =
|
||||
if (appIds.isEmpty()) flowOf(emptyMap()) else observeApps(AppFilter(appIds = appIds)).map { it.associateBy(AppRecord::appId) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsUiState> = 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,
|
||||
)
|
||||
@@ -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 { StackCard(stack = stack, apps = state.appsById, modifier = Modifier.testTag("stack:${stack.identifier}")) }
|
||||
appList(
|
||||
state = state.apps,
|
||||
title = appsTitle,
|
||||
emptyMessage = noApps,
|
||||
onAppClick = onAppClick,
|
||||
onAuthorClick = onProfileClick,
|
||||
)
|
||||
}
|
||||
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 { item { StatusText(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
state.error?.let { error -> item { StatusText(error) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, AppRecord> = 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<Map<String, AppRecord>> = stack
|
||||
.map { it.items?.appIds.orEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest(iolite::observeAppsById)
|
||||
|
||||
private val profiles = appsById.map { it.values.toList() }.authorProfiles(iolite)
|
||||
|
||||
val uiState: StateFlow<StackDetailUiState> = 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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AvailableUpdate> = emptyList(),
|
||||
val loaded: Boolean = false,
|
||||
)
|
||||
|
||||
class UpdatesViewModel(private val catalogSync: CatalogSync) : ViewModel() {
|
||||
val uiState: StateFlow<UpdatesUiState> = 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, OnnxTensor>()
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FloatArray, FloatArray> {
|
||||
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()
|
||||
}
|
||||
@@ -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<String, Int>,
|
||||
private val unk: Int,
|
||||
private val cls: Int,
|
||||
private val sep: Int,
|
||||
) {
|
||||
fun encode(text: String): LongArray {
|
||||
val out = ArrayList<Long>(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<Long>()
|
||||
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<String, Int>(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<String> {
|
||||
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<String> {
|
||||
val out = ArrayList<String>()
|
||||
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
|
||||
}
|
||||
@@ -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<String, String>()
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
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<String, String>): 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<String, String>): 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<NetworkMode> = _mode.asStateFlow()
|
||||
|
||||
private val _torState = MutableStateFlow(
|
||||
if (_mode.value.usesTor) TorRuntimeState.Bootstrapping else TorRuntimeState.Stopped,
|
||||
)
|
||||
val torState: StateFlow<TorRuntimeState> = _torState.asStateFlow()
|
||||
|
||||
val remoteReady: StateFlow<Boolean> = 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,16 +3,32 @@
|
||||
<string name="search_apps">Search apps</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="notifications">Notifications</string>
|
||||
<string name="updates">Updates</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="catalog">Catalog</string>
|
||||
<string name="no_updates">All catalog apps are up to date.</string>
|
||||
<string name="sync_now">Sync</string>
|
||||
<string name="syncing">Syncing</string>
|
||||
<string name="wipe_local_db">Wipe local DB</string>
|
||||
<string name="catalog_epoch">Catalog epoch %1$d</string>
|
||||
<string name="catalog_apps">%1$d apps in catalog</string>
|
||||
<string name="local_storage">%1$s on device</string>
|
||||
<string name="last_synced">Last sync %1$s</string>
|
||||
<string name="last_sync_just_now">Last sync just now</string>
|
||||
<plurals name="last_sync_minutes_ago">
|
||||
<item quantity="one">Last sync 1 minute ago</item>
|
||||
<item quantity="other">Last sync %d minutes ago</item>
|
||||
</plurals>
|
||||
<plurals name="last_sync_hours_ago">
|
||||
<item quantity="one">Last sync 1 hour ago</item>
|
||||
<item quantity="other">Last sync %d hours ago</item>
|
||||
</plurals>
|
||||
<string name="last_sync_duration">Last sync took %1$s seconds</string>
|
||||
<string name="last_sync_size">%1$s transferred</string>
|
||||
<string name="last_sync_not_modified">Already current (HTTP 304)</string>
|
||||
<string name="update_version">%1$s → %2$s</string>
|
||||
<string name="database_rows">%1$d database rows</string>
|
||||
<string name="no_notifications">You are all caught up.</string>
|
||||
<string name="search_results">Search results: %1$d in %2$dms</string>
|
||||
<string name="verified_author">Verified author</string>
|
||||
<string name="curated_stacks">Curated stacks</string>
|
||||
<string name="latest_releases">Latest releases</string>
|
||||
<string name="no_releases">No releases found.</string>
|
||||
@@ -32,7 +48,16 @@
|
||||
<string name="latest_release">Latest release</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="zap_summary">%1$s sats · %2$d zaps</string>
|
||||
<string name="comments">Comments</string>
|
||||
<string name="no_comments">No comments yet.</string>
|
||||
<string name="comments_error_relays">Couldn\'t reach comment relays. Try again, or check Network in Settings.</string>
|
||||
<string name="comments_error_timeout">Comment relays timed out. Try again, or check Network in Settings.</string>
|
||||
<string name="try_again">Try again</string>
|
||||
<string name="network_settings">Network settings</string>
|
||||
<string name="read_more">Read more</string>
|
||||
<string name="release_notes">Release notes</string>
|
||||
<string name="see_developer_description">See developer description</string>
|
||||
<string name="developer_description">Developer description</string>
|
||||
<string name="version">Version:</string>
|
||||
<string name="source">Source</string>
|
||||
<string name="license">License</string>
|
||||
@@ -44,7 +69,23 @@
|
||||
<string name="npub">Npub</string>
|
||||
<string name="not_available">Not available</string>
|
||||
<string name="copy">Copy</string>
|
||||
<string name="privacy_and_security">Privacy and security</string>
|
||||
<string name="fact_no">No %1$s</string>
|
||||
<string name="fact_gms">Google Play services</string>
|
||||
<string name="fact_open_source">Verified open source</string>
|
||||
<string name="fact_fcm">Firebase Cloud Messaging</string>
|
||||
<string name="app_icon_description">%1$s icon</string>
|
||||
<string name="screenshot_description">%1$s screenshot</string>
|
||||
<string name="profile_avatar_description">%1$s avatar</string>
|
||||
<string name="network">Network</string>
|
||||
<string name="network_tor">Tor only</string>
|
||||
<string name="network_direct">Direct only</string>
|
||||
<string name="network_tor_fallback">Tor with Direct fallback</string>
|
||||
<string name="network_direct_warning">Direct exposes your IP address to relays, catalog servers, and download hosts.</string>
|
||||
<string name="tor_bootstrapping">Tor bootstrapping</string>
|
||||
<string name="tor_ready">Tor ready</string>
|
||||
<string name="tor_dormant">Tor dormant</string>
|
||||
<string name="tor_failed">Tor failed. Direct is not used as a fallback.</string>
|
||||
<string name="tor_failed_fallback">Tor failed. Using Direct.</string>
|
||||
<string name="tor_stopped">Tor stopped</string>
|
||||
</resources>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<MutableSharedFlow<QueryState>>()
|
||||
val repository = FakeCatalogRepository(
|
||||
localQuery = { filter ->
|
||||
if (filter.search != null) {
|
||||
MutableSharedFlow<QueryState>().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<QueryState>()
|
||||
val appFlows = mutableListOf<MutableSharedFlow<QueryState>>()
|
||||
var queryCount = 0
|
||||
val repository = FakeCatalogRepository(
|
||||
localQuery = {
|
||||
if (queryCount++ == 0) {
|
||||
stackFlow
|
||||
} else {
|
||||
MutableSharedFlow<QueryState>().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<Filter>()
|
||||
val appFlows = mutableListOf<MutableSharedFlow<QueryState>>()
|
||||
val repository = FakeCatalogRepository(
|
||||
localQuery = { filter ->
|
||||
if (filter.kinds == listOf(Catalog.appKind)) {
|
||||
appFilters += filter
|
||||
MutableSharedFlow<QueryState>().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<Filter>()
|
||||
val appFlows = mutableListOf<MutableSharedFlow<QueryState>>()
|
||||
val repository = FakeCatalogRepository(
|
||||
localQuery = { filter ->
|
||||
if (filter.kinds == listOf(Catalog.appKind)) {
|
||||
appFilters += filter
|
||||
MutableSharedFlow<QueryState>().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<QueryState>()
|
||||
val releaseFlow = MutableSharedFlow<QueryState>()
|
||||
var releaseFilters = emptyList<Filter>()
|
||||
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<String>? = null
|
||||
var outboxRelays: Set<String>? = null
|
||||
val appFlow = MutableSharedFlow<QueryState>()
|
||||
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<Filter>()
|
||||
AppDetailViewModel(
|
||||
FakeCatalogRepository(
|
||||
localQuery = { filter ->
|
||||
filters += filter
|
||||
emptyFlow()
|
||||
},
|
||||
),
|
||||
SavedStateHandle(
|
||||
mapOf(
|
||||
APP_IDENTIFIER_ARGUMENT to "dev.example.app",
|
||||
APP_AUTHOR_ARGUMENT to "a".repeat(64),
|
||||
),
|
||||
),
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
val releaseFilter = filters.single { Catalog.releaseKind in it.kinds.orEmpty() }
|
||||
assertEquals(Catalog.releaseKinds, releaseFilter.kinds)
|
||||
assertEquals(mapOf("i" to listOf("dev.example.app")), releaseFilter.tags)
|
||||
assertEquals(listOf("a".repeat(64)), releaseFilter.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app detail zap and asset queries are bounded`() = runTest {
|
||||
val filters = mutableListOf<Filter>()
|
||||
val appFlow = MutableSharedFlow<QueryState>()
|
||||
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<Filter>()
|
||||
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<QueryState> = { emptyFlow() },
|
||||
private val remoteQuery: (Filter) -> Flow<QueryState> = { emptyFlow() },
|
||||
private val localQueries: ((List<Filter>) -> Flow<QueryState>)? = null,
|
||||
private val remoteQueries: ((List<Filter>) -> Flow<QueryState>)? = null,
|
||||
private val outboxQuery: ((List<Filter>, List<String>, Set<String>) -> Flow<QueryState>)? = null,
|
||||
) : CatalogRepository {
|
||||
override suspend fun verifiedApps(apps: List<AppInfo>): List<AppInfo> = apps
|
||||
|
||||
override fun query(
|
||||
filters: List<Filter>,
|
||||
options: QueryOptions,
|
||||
): Flow<QueryState> = 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<Filter>,
|
||||
authors: List<String>,
|
||||
cachedFor: Duration?,
|
||||
relays: Set<String>,
|
||||
remoteMode: RemoteMode,
|
||||
unionWithFallback: Boolean,
|
||||
): Flow<QueryState> = 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<Array<String>> = emptyArray(),
|
||||
content: String = "",
|
||||
): Event = Event(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "2".repeat(128),
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ByteArray>()
|
||||
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<String>(), 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<String>()
|
||||
|
||||
override fun get(url: String, headers: Map<String, String>): 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")
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user