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
|
GRADLE ?= ./gradlew
|
||||||
ANDROID_SDK_ROOT ?= $(ANDROID_HOME)
|
ANDROID_SDK_ROOT ?= $(ANDROID_HOME)
|
||||||
ADB ?= $(ANDROID_SDK_ROOT)/platform-tools/adb
|
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
|
PACKAGE ?= dev.zapstore.beta
|
||||||
ACTIVITY ?= dev.zapstore.app.MainActivity
|
ACTIVITY ?= dev.zapstore.app.MainActivity
|
||||||
KEYSTORE ?= release.keystore
|
KEYSTORE ?= release.keystore
|
||||||
@@ -15,16 +9,33 @@ KEY_ALIAS ?=
|
|||||||
KEY_PASSWORD ?= $(KEYSTORE_PASSWORD)
|
KEY_PASSWORD ?= $(KEYSTORE_PASSWORD)
|
||||||
DEBUG_KEYSTORE ?= $(HOME)/.android/debug.keystore
|
DEBUG_KEYSTORE ?= $(HOME)/.android/debug.keystore
|
||||||
|
|
||||||
ZSP_CATALOG_DB ?= ../zsp/testdata/catalog/catalog.db
|
ASSETS_CATALOG_BUNDLE := src/main/assets/bundle-0-1.tar.zst
|
||||||
ASSETS_CATALOG_DB := src/main/assets/catalog.db
|
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
|
@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:
|
build:
|
||||||
$(GRADLE) assembleDebug
|
$(GRADLE) assembleDebug
|
||||||
@@ -59,12 +70,18 @@ release:
|
|||||||
-Pandroid.injected.signing.key.alias="$$alias" \
|
-Pandroid.injected.signing.key.alias="$$alias" \
|
||||||
-Pandroid.injected.signing.key.password="$$keypass"
|
-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:
|
install:
|
||||||
$(GRADLE) installDebug
|
$(GRADLE) installDebug
|
||||||
|
$(ADB) shell pm clear $(PACKAGE)
|
||||||
|
|
||||||
run: install
|
run: install
|
||||||
@$(ADB) reverse tcp:3334 tcp:3334 || true
|
@$(ADB) reverse tcp:$(RELAY_PORT) tcp:$(RELAY_PORT) || true
|
||||||
@$(ADB) reverse tcp:3336 tcp:3336 || true
|
|
||||||
@$(ADB) shell am force-stop $(PACKAGE)
|
@$(ADB) shell am force-stop $(PACKAGE)
|
||||||
@$(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY)
|
@$(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY)
|
||||||
@if [ -t 0 ]; then \
|
@if [ -t 0 ]; then \
|
||||||
@@ -84,17 +101,3 @@ deploy: run
|
|||||||
refresh:
|
refresh:
|
||||||
$(ADB) shell am force-stop $(PACKAGE)
|
$(ADB) shell am force-stop $(PACKAGE)
|
||||||
$(ADB) shell am start -n $(PACKAGE)/$(ACTIVITY)
|
$(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**
|
- JDK **21**
|
||||||
- Android SDK (compile / target API **37**, min SDK **29**)
|
- 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
|
## Build from source
|
||||||
|
|
||||||
@@ -28,14 +28,28 @@ The production APK is still published from the [Flutter client](https://github.c
|
|||||||
make build
|
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
|
```bash
|
||||||
make emulator # Pixel_9 AVD (created on first run)
|
make vendor
|
||||||
make run # installDebug, launch, then press r to rebuild
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
@@ -43,7 +57,7 @@ Or call Gradle directly:
|
|||||||
./gradlew test
|
./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
|
```bash
|
||||||
./gradlew connectedDebugAndroidTest
|
./gradlew connectedDebugAndroidTest
|
||||||
@@ -51,8 +65,13 @@ Or call Gradle directly:
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `src/main/kotlin/dev/zapstore/app/` — Compose UI, navigation, view models, and catalog wiring
|
- `src/main/kotlin/dev/zapstore/app/` — `ZapstoreApplication` (singletons), `MainActivity` (routes), `AppConfig` (build-time endpoints and query options)
|
||||||
- `iolite/` — local-first Nostr client (SQLite event store, relay sessions, outbox routing) on top of [Quartz](https://github.com/vitorpamplona/amethyst)
|
- `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
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+136
-1
@@ -1,3 +1,8 @@
|
|||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
@@ -16,10 +21,60 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
buildFeatures {
|
||||||
compose = true
|
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)
|
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 {
|
dependencies {
|
||||||
implementation(project(":iolite"))
|
implementation(project(":iolite"))
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material.icons.extended)
|
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
@@ -46,11 +178,14 @@ dependencies {
|
|||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.sqlite)
|
implementation(libs.sqlite)
|
||||||
implementation(libs.sqlite.bundled)
|
implementation(libs.sqlite.bundled)
|
||||||
|
implementation(libs.onnxruntime.android)
|
||||||
|
|
||||||
|
testImplementation("org.json:json:20250517")
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
testImplementation(libs.sqlite.bundled)
|
testImplementation(libs.sqlite.bundled)
|
||||||
|
testImplementation(libs.onnxruntime)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.compose.bom))
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
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
|
package dev.zapstore.app
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
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.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performImeAction
|
import androidx.compose.ui.test.performScrollToKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -18,71 +38,112 @@ class ScreensTest {
|
|||||||
val composeRule = createComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun homeSearchAndStackCallbacksAreWired() {
|
fun homeCallbacksAreWired() {
|
||||||
var submitted = false
|
|
||||||
var cleared = false
|
var cleared = false
|
||||||
var updatesOpened = false
|
var updatesOpened = false
|
||||||
var openedStack: String? = null
|
var openedStack: String? = null
|
||||||
val stack = StackInfo(
|
var openedApp: String? = null
|
||||||
event(
|
val stack = StackRecord(
|
||||||
id = "stack-id",
|
pubkey = AUTHOR,
|
||||||
kind = Catalog.appStackKind,
|
identifier = "privacy",
|
||||||
tags = arrayOf(arrayOf("name", "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 {
|
composeRule.setContent {
|
||||||
ZapstoreTheme {
|
ZapstoreTheme {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
state = HomeUiState(
|
state = HomeUiState(
|
||||||
searchQuery = "zap",
|
searchQuery = "zap",
|
||||||
|
submittedQuery = "zap",
|
||||||
|
searchResults = listOf(app),
|
||||||
stacks = listOf(stack),
|
stacks = listOf(stack),
|
||||||
stacksLoading = false,
|
stacksLoading = false,
|
||||||
|
feed = AppListState(loading = false),
|
||||||
),
|
),
|
||||||
onSearchQueryChanged = {},
|
onSearchQueryChanged = {},
|
||||||
onSearchSubmitted = { submitted = true },
|
onSearchSubmitted = {},
|
||||||
onSearchCleared = { cleared = true },
|
onSearchCleared = { cleared = true },
|
||||||
|
onAppClick = { openedApp = it.appId },
|
||||||
|
onStackClick = { openedStack = it.identifier },
|
||||||
onUpdatesClick = { updatesOpened = true },
|
onUpdatesClick = { updatesOpened = true },
|
||||||
onStackClick = { openedStack = it },
|
|
||||||
onAppClick = { _, _ -> },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithTag("searchField").performImeAction()
|
composeRule.onNodeWithTag("clearSearch").performClick()
|
||||||
composeRule.onNodeWithText("×").performClick()
|
|
||||||
composeRule.onNodeWithTag("searchField").assertIsFocused()
|
|
||||||
composeRule.onNodeWithTag("updatesButton").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(cleared)
|
||||||
assertTrue(updatesOpened)
|
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
|
@Test
|
||||||
fun appDetailExternalLinkCallbackIsWired() {
|
fun appDetailExternalLinkCallbackIsWired() {
|
||||||
var openedUrl: String? = null
|
var openedUrl: String? = null
|
||||||
val app = AppInfo(
|
val app = app("dev.zap", "Zap", repository = "https://example.com/repo")
|
||||||
event(
|
|
||||||
kind = Catalog.appKind,
|
|
||||||
tags = arrayOf(
|
|
||||||
arrayOf("d", "zap"),
|
|
||||||
arrayOf("name", "Zap"),
|
|
||||||
arrayOf("repository", "https://example.com/repo"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
ZapstoreTheme {
|
ZapstoreTheme {
|
||||||
AppDetailScreen(
|
AppDetailScreen(
|
||||||
state = AppDetailUiState(
|
state = AppDetailUiState(app = app, loaded = true),
|
||||||
app = app,
|
|
||||||
appLoading = false,
|
|
||||||
releaseLoading = false,
|
|
||||||
),
|
|
||||||
onOpenUrl = { openedUrl = it },
|
onOpenUrl = { openedUrl = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,76 +155,250 @@ class ScreensTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun appProfileCallbackIsWired() {
|
fun appDetailCommentsAreListed() {
|
||||||
var openedProfile: String? = null
|
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 {
|
composeRule.setContent {
|
||||||
ZapstoreTheme {
|
ZapstoreTheme {
|
||||||
AppCard(
|
AppDetailScreen(
|
||||||
|
state = AppDetailUiState(
|
||||||
app = app,
|
app = app,
|
||||||
repository = null,
|
loaded = true,
|
||||||
onClick = {},
|
comments = CommentsState(threads = threadComments(listOf(comment)), loading = false),
|
||||||
onProfileClick = { openedProfile = app.event.pubKey },
|
),
|
||||||
|
onOpenUrl = {},
|
||||||
|
onProfileClick = { openedProfile = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithTag("profile:${app.event.pubKey}").performClick()
|
composeRule.onNodeWithTag("comment:${comment.eventId}").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Nice release").assertIsDisplayed()
|
||||||
assertEquals(app.event.pubKey, openedProfile)
|
composeRule.onNodeWithTag("profile:$AUTHOR").performClick()
|
||||||
|
assertEquals(AUTHOR, openedProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun releaseFeedShowsItsLoadingFooter() {
|
fun appDetailCommentsAreThreaded() {
|
||||||
val app = AppInfo(
|
val app = app("dev.zap", "Zap")
|
||||||
event(
|
val root = CommentRecord(
|
||||||
id = "release-id",
|
eventId = "a".repeat(64),
|
||||||
kind = Catalog.appKind,
|
pubkey = AUTHOR,
|
||||||
),
|
createdAt = 1_750_000_000,
|
||||||
|
content = "Root comment",
|
||||||
|
appId = "dev.zap",
|
||||||
|
stack = null,
|
||||||
|
parentEventId = null,
|
||||||
)
|
)
|
||||||
val release = ReleaseInfo(
|
val reply = CommentRecord(
|
||||||
event(
|
eventId = "b".repeat(64),
|
||||||
id = "release-id",
|
pubkey = AUTHOR,
|
||||||
kind = Catalog.releaseKind,
|
createdAt = 1_750_000_100,
|
||||||
tags = arrayOf(arrayOf("i", "zap")),
|
content = "Nested reply",
|
||||||
),
|
appId = "dev.zap",
|
||||||
|
stack = null,
|
||||||
|
parentEventId = root.eventId,
|
||||||
)
|
)
|
||||||
|
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
ZapstoreTheme {
|
ZapstoreTheme {
|
||||||
HomeScreen(
|
AppDetailScreen(
|
||||||
state = HomeUiState(
|
state = AppDetailUiState(
|
||||||
releaseFeed = ReleaseFeedUiState(
|
app = app,
|
||||||
entries = listOf(ReleaseFeedEntry(app, release)),
|
loaded = true,
|
||||||
initialLoading = false,
|
comments = CommentsState(threads = threadComments(listOf(reply, root)), loading = false),
|
||||||
loadingMore = true,
|
|
||||||
),
|
),
|
||||||
),
|
onOpenUrl = {},
|
||||||
onSearchQueryChanged = {},
|
|
||||||
onSearchSubmitted = {},
|
|
||||||
onSearchCleared = {},
|
|
||||||
onStackClick = {},
|
|
||||||
onAppClick = { _, _ -> },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithTag("app:release-id").assertIsDisplayed()
|
composeRule.onNodeWithTag("commentDepth:${root.eventId}:0").assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag("releaseLoading").assertIsDisplayed()
|
composeRule.onNodeWithTag("commentDepth:${reply.eventId}:1").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Root comment").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Nested reply").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appDetailCommentErrorOffersRetryAndSettings() {
|
||||||
|
var retried = false
|
||||||
|
var settingsOpened = false
|
||||||
|
val app = app("dev.zap", "Zap")
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
AppDetailScreen(
|
||||||
|
state = AppDetailUiState(
|
||||||
|
app = app,
|
||||||
|
loaded = true,
|
||||||
|
comments = CommentsState(loading = false, error = CommentsError.RelaysUnreachable),
|
||||||
|
),
|
||||||
|
onOpenUrl = {},
|
||||||
|
onRetryComments = { retried = true },
|
||||||
|
onSettingsClick = { settingsOpened = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("commentsEmpty").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("commentsRetry").performClick()
|
||||||
|
composeRule.onNodeWithTag("commentsNetworkSettings").performClick()
|
||||||
|
assertTrue(retried)
|
||||||
|
assertTrue(settingsOpened)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appCardAuthorCallbackIsWired() {
|
||||||
|
var openedProfile: String? = null
|
||||||
|
val app = app("dev.zap", "Zap", proofPubkey = AUTHOR)
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
AppCard(app = app, author = null, onClick = {}, onAuthorClick = { openedProfile = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("profile:$AUTHOR").performClick()
|
||||||
|
|
||||||
|
assertEquals(AUTHOR, openedProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updatesListsAvailableUpdatesAndSyncs() {
|
||||||
|
var synced = false
|
||||||
|
val app = app("dev.zap", "Zap", version = "2.0")
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
UpdatesScreen(
|
||||||
|
state = UpdatesUiState(
|
||||||
|
status = SyncStatus(),
|
||||||
|
updates = listOf(AvailableUpdate(app, installedVersion = "1.0", installedVersionCode = 1)),
|
||||||
|
loaded = true,
|
||||||
|
),
|
||||||
|
onSync = { synced = true },
|
||||||
|
onAppClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("update:dev.zap").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("1.0 → 2.0").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("syncCatalog").performClick()
|
||||||
|
assertTrue(synced)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsShowsLastSyncDuration() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
SettingsScreen(
|
||||||
|
state = SettingsUiState(
|
||||||
|
status = SyncStatus(
|
||||||
|
lastSyncedAtMillis = System.currentTimeMillis() - 3 * 60_000,
|
||||||
|
lastSyncDurationMillis = 2430,
|
||||||
|
lastSyncBytes = 1_234_567,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onNetworkModeChange = {},
|
||||||
|
onSync = {},
|
||||||
|
onWipe = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithTag("lastSyncAgo").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Last sync 3 minutes ago").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("lastSyncSize").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("transferred", substring = true).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("lastSyncDuration").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Last sync took", substring = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsShowsLastSyncNotModified() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
SettingsScreen(
|
||||||
|
state = SettingsUiState(
|
||||||
|
status = SyncStatus(
|
||||||
|
lastSyncedAtMillis = System.currentTimeMillis(),
|
||||||
|
lastSyncDurationMillis = 120,
|
||||||
|
lastSyncBytes = 0,
|
||||||
|
lastSyncNotModified = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onNetworkModeChange = {},
|
||||||
|
onSync = {},
|
||||||
|
onWipe = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithTag("lastSyncAgo").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Last sync just now").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("lastSyncNotModified").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Already current (HTTP 304)").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsWipeButtonIsWired() {
|
||||||
|
var wiped = false
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
SettingsScreen(state = SettingsUiState(), onNetworkModeChange = {}, onSync = {}, onWipe = { wiped = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithTag("wipeLocalDb").performClick()
|
||||||
|
assertTrue(wiped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsShowsAllNetworkModes() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ZapstoreTheme {
|
||||||
|
SettingsScreen(state = SettingsUiState(), onNetworkModeChange = {}, onSync = {}, onWipe = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithTag("networkTor").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("networkDirect").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("networkTorFallback").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Tor only").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Direct only").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Tor with Direct fallback").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun event(
|
private val AUTHOR = "1".repeat(64)
|
||||||
id: String = "a".repeat(64),
|
|
||||||
kind: Int,
|
private fun app(
|
||||||
tags: Array<Array<String>> = emptyArray(),
|
appId: String,
|
||||||
): Event = Event(
|
name: String,
|
||||||
id = id,
|
version: String = "1.0",
|
||||||
pubKey = "1".repeat(64),
|
repository: String? = null,
|
||||||
|
proofPubkey: String? = null,
|
||||||
|
): AppRecord = AppRecord(
|
||||||
|
id = appId.toByteArray(),
|
||||||
|
catalogId = 1,
|
||||||
|
appId = appId,
|
||||||
|
certificateHash = "a".repeat(64),
|
||||||
createdAt = 1_750_000_000,
|
createdAt = 1_750_000_000,
|
||||||
kind = kind,
|
proofPubkey = proofPubkey,
|
||||||
tags = tags,
|
eventPubkey = AUTHOR,
|
||||||
content = "",
|
catalogManifestPubkey = null,
|
||||||
sig = "2".repeat(128),
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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" />
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<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? {
|
fun profileCdnUrl(pubkey: String): String? {
|
||||||
if (pubkey.length != 64 || pubkey.any { it.digitToIntOrNull(16) == null }) return null
|
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()
|
.build()
|
||||||
.toString()
|
.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
|
package dev.zapstore.app
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -14,21 +13,34 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
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.core.net.toUri
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val zapstore: ZapstoreApplication
|
private val zapstore: ZapstoreApplication get() = application as ZapstoreApplication
|
||||||
get() = getApplication() as ZapstoreApplication
|
|
||||||
private val repository: CatalogRepository
|
|
||||||
get() = zapstore.catalogRepository
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -36,170 +48,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
ZapstoreTheme {
|
ZapstoreTheme {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
ZapstoreNavHost(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()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,27 +62,117 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
repository.refreshConnections()
|
zapstore.iolite.refreshConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.compose.runtime.Composable
|
||||||
|
private fun ZapstoreNavHost(navController: NavHostController) {
|
||||||
|
val iolite = zapstore.iolite
|
||||||
|
val openApp = { app: AppRecord -> navController.navigate(Routes.app(app.appId)) }
|
||||||
|
val openProfile = { pubkey: String -> navController.navigate(Routes.profile(pubkey)) }
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Routes.HOME,
|
||||||
|
enterTransition = { slide(AnimatedContentTransitionScope.SlideDirection.Left) },
|
||||||
|
exitTransition = { slideOut(AnimatedContentTransitionScope.SlideDirection.Left) },
|
||||||
|
popEnterTransition = { slide(AnimatedContentTransitionScope.SlideDirection.Right) },
|
||||||
|
popExitTransition = { slideOut(AnimatedContentTransitionScope.SlideDirection.Right) },
|
||||||
|
) {
|
||||||
|
composable(Routes.HOME) {
|
||||||
|
val viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory(iolite, zapstore.queryEncoder::encode))
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val updateCount by zapstore.catalogSync.updateCount.collectAsStateWithLifecycle(0)
|
||||||
|
HomeScreen(
|
||||||
|
state = state,
|
||||||
|
onSearchQueryChanged = viewModel::onSearchQueryChanged,
|
||||||
|
onSearchSubmitted = viewModel::submitSearch,
|
||||||
|
onSearchCleared = viewModel::clearSearch,
|
||||||
|
onAppClick = openApp,
|
||||||
|
onStackClick = { navController.navigate(Routes.stack(it.pubkey, it.identifier)) },
|
||||||
|
onProfileClick = openProfile,
|
||||||
|
onUpdatesClick = { navController.navigate(Routes.UPDATES) },
|
||||||
|
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
updateCount = updateCount,
|
||||||
|
onLoadMore = viewModel::loadMore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.UPDATES) {
|
||||||
|
val viewModel: UpdatesViewModel = viewModel(factory = UpdatesViewModel.factory(zapstore.catalogSync))
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
UpdatesScreen(state = state, onSync = viewModel::sync, onAppClick = openApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.SETTINGS) {
|
||||||
|
val viewModel: SettingsViewModel =
|
||||||
|
viewModel(
|
||||||
|
factory = SettingsViewModel.factory(
|
||||||
|
iolite,
|
||||||
|
zapstore.catalogSync,
|
||||||
|
zapstore.network,
|
||||||
|
applicationContext::localStorageBytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
SettingsScreen(
|
||||||
|
state = state,
|
||||||
|
onNetworkModeChange = viewModel::setNetworkMode,
|
||||||
|
onSync = viewModel::sync,
|
||||||
|
onWipe = viewModel::wipe,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Routes.STACK,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.STACK_AUTHOR_ARG) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.STACK_ID_ARG) { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val viewModel: StackDetailViewModel = viewModel(factory = StackDetailViewModel.factory(iolite))
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
StackDetailScreen(state = state, onAppClick = openApp, onProfileClick = openProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Routes.APP,
|
||||||
|
arguments = listOf(navArgument(Routes.APP_ID_ARG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
val viewModel: AppDetailViewModel = viewModel(factory = AppDetailViewModel.factory(iolite))
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
AppDetailScreen(
|
||||||
|
state = state,
|
||||||
|
onOpenUrl = ::openUrl,
|
||||||
|
onProfileClick = openProfile,
|
||||||
|
onRetryComments = viewModel::retryComments,
|
||||||
|
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Routes.PROFILE,
|
||||||
|
arguments = listOf(navArgument(Routes.PUBKEY_ARG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
val viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.factory(iolite))
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
ProfileScreen(state = state, onOpenUrl = ::openUrl, onAppClick = openApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUrl(value: String) {
|
||||||
|
if (!isHttpUrl(value)) return
|
||||||
|
runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AnimatedContentTransitionScope<*>.slide(direction: AnimatedContentTransitionScope.SlideDirection) =
|
||||||
|
slideIntoContainer(direction, tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing))
|
||||||
|
|
||||||
|
private fun AnimatedContentTransitionScope<*>.slideOut(direction: AnimatedContentTransitionScope.SlideDirection) =
|
||||||
|
slideOutOfContainer(direction, tween(NAVIGATION_TRANSITION_DURATION, easing = FastOutSlowInEasing))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NAVIGATION_TRANSITION_DURATION = 150
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HOME_ROUTE = "home"
|
|
||||||
private const val STACK_ROUTE = "stack/{$STACK_ID_ARGUMENT}"
|
|
||||||
private const val APP_ROUTE =
|
|
||||||
"app/{$APP_IDENTIFIER_ARGUMENT}?$APP_AUTHOR_ARGUMENT={$APP_AUTHOR_ARGUMENT}"
|
|
||||||
private const val PROFILE_ROUTE = "profile/{$PROFILE_PUBKEY_ARGUMENT}"
|
|
||||||
private const val UPDATES_ROUTE = "updates"
|
|
||||||
private const val NAVIGATION_TRANSITION_DURATION = 150
|
|
||||||
|
|
||||||
private fun stackRoute(stackId: String): String =
|
|
||||||
"stack/${Uri.encode(stackId)}"
|
|
||||||
|
|
||||||
private fun appRoute(identifier: String, author: String?): String = buildString {
|
|
||||||
append("app/").append(Uri.encode(identifier))
|
|
||||||
author?.let {
|
|
||||||
append("?").append(APP_AUTHOR_ARGUMENT).append("=").append(Uri.encode(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun profileRoute(pubkey: String): String =
|
|
||||||
"profile/${Uri.encode(pubkey)}"
|
|
||||||
|
|||||||
@@ -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
|
package dev.zapstore.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import dev.zapstore.app.catalogsync.CatalogBootstrapper
|
import coil3.ImageLoader
|
||||||
import dev.zapstore.app.catalogsync.CatalogSyncClient
|
import coil3.SingletonImageLoader
|
||||||
import dev.zapstore.app.catalogsync.CatalogSyncRepository
|
import coil3.annotation.ExperimentalCoilApi
|
||||||
import dev.zapstore.app.catalogsync.CompactEventStore
|
import coil3.network.ConnectivityChecker
|
||||||
import dev.zapstore.app.catalogsync.InstalledAppsProvider
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Process-wide singletons: network, the Iolite client, and catalog sync. Screens get these from here. */
|
||||||
class ZapstoreApplication : Application() {
|
class ZapstoreApplication : Application() {
|
||||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
val catalogDatabase by lazy { CatalogBootstrapper(this).open() }
|
lateinit var network: NetworkRuntime
|
||||||
val compactStore by lazy { CompactEventStore(catalogDatabase) }
|
private set
|
||||||
val catalogRepository: CatalogRepository by lazy {
|
lateinit var iolite: Iolite
|
||||||
IoliteCatalogRepository(this) { compactStore }
|
private set
|
||||||
}
|
lateinit var queryEncoder: LeafQueryEncoder
|
||||||
val catalogSyncRepository by lazy {
|
private set
|
||||||
CatalogSyncRepository(
|
lateinit var catalogSync: CatalogSync
|
||||||
database = catalogDatabase,
|
private set
|
||||||
store = compactStore,
|
|
||||||
client = CatalogSyncClient(Catalog.updatesUrl),
|
|
||||||
installedApps = { InstalledAppsProvider(this).installedApps() },
|
|
||||||
trustedPubkey = Catalog.catalogRelayPubkey,
|
|
||||||
purpleQuartz = { catalogRepository.purpleQuartz() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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 {
|
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
if (!Catalog.catalogLocalOnly) {
|
network.setForeground(true)
|
||||||
catalogRepository.setRelayTrafficEnabled(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.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -25,6 +36,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -117,6 +129,7 @@ fun ZapSearchField(
|
|||||||
trailingIcon: (@Composable () -> Unit)? = null,
|
trailingIcon: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val shape = RoundedCornerShape(ZapRadius.full)
|
val shape = RoundedCornerShape(ZapRadius.full)
|
||||||
val textStyle = MaterialTheme.typography.bodyMedium.copy(color = ZapTextPrimary)
|
val textStyle = MaterialTheme.typography.bodyMedium.copy(color = ZapTextPrimary)
|
||||||
|
|
||||||
@@ -132,7 +145,10 @@ fun ZapSearchField(
|
|||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
cursorBrush = SolidColor(ZapActionText),
|
cursorBrush = SolidColor(ZapActionText),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
|
keyboardActions = KeyboardActions(onSearch = {
|
||||||
|
onSearch()
|
||||||
|
keyboardController?.hide()
|
||||||
|
}),
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Row(
|
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.background
|
||||||
import androidx.compose.foundation.border
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
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.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -41,44 +47,71 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
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.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coil3.compose.AsyncImage
|
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.DateFormat
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private val SmallIconSize = 26.dp
|
private val CompactIconSize = 26.dp
|
||||||
private val SmallHeaderHeight = 32.dp
|
private val CompactHeaderHeight = 32.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppDetailScreen(
|
fun AppDetailScreen(
|
||||||
state: AppDetailUiState,
|
state: AppDetailUiState,
|
||||||
onOpenUrl: (String) -> Unit,
|
onOpenUrl: (String) -> Unit,
|
||||||
repository: CatalogRepository? = null,
|
|
||||||
onProfileClick: (String) -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onProfileClick: (String) -> Unit = {},
|
||||||
|
onRetryComments: () -> Unit = {},
|
||||||
|
onSettingsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var selectedScreenshot by remember { mutableStateOf<Int?>(null) }
|
var selectedScreenshot by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var showDeveloperDescription by remember { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val showCompactHeader by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
|
||||||
val app = state.app
|
val app = state.app
|
||||||
val authorPubkey = app?.let { rememberAppAuthor(it, repository) }
|
val comments = state.comments
|
||||||
val c1AuthorPubkey = app?.let { rememberC1Author(it, repository) }
|
val threaded = remember(comments.threads) { comments.threads.flattenComments() }
|
||||||
val authorProfile = authorPubkey?.let { rememberProfile(it, repository) }
|
|
||||||
val showCompactHeader by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
listState.firstVisibleItemIndex > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -94,37 +127,19 @@ fun AppDetailScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
item {
|
item { if (state.loaded) NotFoundCard() else AppDetailSkeleton() }
|
||||||
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) } }
|
|
||||||
return@LazyColumn
|
return@LazyColumn
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RegularAppHeader(
|
AppIdentityRow(
|
||||||
app = app,
|
name = app.name,
|
||||||
release = state.release,
|
iconUrl = app.iconUrl,
|
||||||
repository = repository,
|
iconFile = app.iconFile,
|
||||||
authorPubkey = authorPubkey,
|
version = app.version,
|
||||||
authorProfile = authorProfile,
|
authorPubkey = app.authorPubkey,
|
||||||
onProfileClick = { authorPubkey?.let(onProfileClick) },
|
author = state.author,
|
||||||
|
onAuthorClick = onProfileClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,54 +156,41 @@ fun AppDetailScreen(
|
|||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(ZapSurface3)
|
.background(ZapSurface3)
|
||||||
.clickable {
|
.clickable { selectedScreenshot = app.screenshots.indexOf(url) },
|
||||||
selectedScreenshot = app.screenshots.indexOf(url)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val description = app.event.content.ifBlank { app.summary }
|
if (app.about.isNotBlank()) {
|
||||||
if (description.isNotBlank()) {
|
|
||||||
item {
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
value = description,
|
value = app.about,
|
||||||
color = ZapTextSecondary,
|
color = ZapTextSecondary,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
onOpenUrl = onOpenUrl,
|
onOpenUrl = onOpenUrl,
|
||||||
collapsible = true,
|
|
||||||
)
|
)
|
||||||
}
|
if (app.description.isNotBlank()) {
|
||||||
}
|
TextButton(
|
||||||
|
onClick = { showDeveloperDescription = true },
|
||||||
if (state.zapSummary.isLoading || state.zapSummary.zapCount > 0 || state.zapSummary.error != null) {
|
contentPadding = PaddingValues(0.dp),
|
||||||
item { ZapSummaryCard(state.zapSummary) }
|
modifier = Modifier.testTag("developerDescription"),
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
HorizontalDivider(Modifier.weight(1f), color = ZapLine)
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.latest_release).uppercase(),
|
text = stringResource(R.string.see_developer_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
color = ZapActionText,
|
||||||
fontWeight = FontWeight.Bold,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(Modifier.weight(1f), color = ZapLine)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
state.release?.let { release ->
|
}
|
||||||
item { VersionRow(release) }
|
} else if (app.description.isNotBlank()) {
|
||||||
if (release.notes.isNotBlank()) {
|
|
||||||
item {
|
item {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
value = release.notes,
|
value = app.description,
|
||||||
color = ZapTextSecondary,
|
color = ZapTextSecondary,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
onOpenUrl = onOpenUrl,
|
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 {
|
item {
|
||||||
Box(
|
FlowRow(
|
||||||
Modifier
|
horizontalArrangement = Arrangement.spacedBy(ZapSpacing.space2),
|
||||||
.width(220.dp)
|
verticalArrangement = Arrangement.spacedBy(ZapSpacing.space2),
|
||||||
.height(32.dp)
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.fillMaxWidth()
|
||||||
.background(ZapSurface2),
|
.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 {
|
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 {
|
item {
|
||||||
AppInfoCard(
|
AppInfoCard(
|
||||||
app = app,
|
app = app,
|
||||||
release = state.release,
|
author = state.author,
|
||||||
onOpenUrl = onOpenUrl,
|
onOpenUrl = onOpenUrl,
|
||||||
repository = repository,
|
onProfileClick = onProfileClick,
|
||||||
authorPubkey = authorPubkey,
|
|
||||||
c1AuthorPubkey = c1AuthorPubkey,
|
|
||||||
authorProfile = authorProfile,
|
|
||||||
onProfileClick = { authorPubkey?.let(onProfileClick) },
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
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) {
|
if (showCompactHeader && app != null) {
|
||||||
state.app?.let { app ->
|
|
||||||
CompactAppHeader(
|
CompactAppHeader(
|
||||||
app = app,
|
app = app,
|
||||||
repository = repository,
|
author = state.author,
|
||||||
authorPubkey = authorPubkey,
|
onProfileClick = onProfileClick,
|
||||||
authorProfile = authorProfile,
|
|
||||||
onProfileClick = { authorPubkey?.let(onProfileClick) },
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
state.app?.let { app ->
|
if (app != null) {
|
||||||
selectedScreenshot?.let { initialPage ->
|
selectedScreenshot?.let { initialPage ->
|
||||||
ScreenshotCarousel(
|
ScreenshotCarousel(
|
||||||
screenshots = app.screenshots,
|
screenshots = app.screenshots,
|
||||||
@@ -250,6 +297,111 @@ fun AppDetailScreen(
|
|||||||
onDismiss = { selectedScreenshot = null },
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
SkeletonBlock(74.dp, 74.dp, 16.dp)
|
SkeletonBlock(74.dp, 74.dp, 16.dp)
|
||||||
Column(
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
SkeletonBlock(width = 190.dp, height = 22.dp, cornerRadius = 6.dp)
|
SkeletonBlock(width = 190.dp, height = 22.dp, cornerRadius = 6.dp)
|
||||||
SkeletonBlock(width = 140.dp, height = 16.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)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
items(4) {
|
items(4) { SkeletonBlock(width = 120.dp, height = 200.dp, cornerRadius = 16.dp) }
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
ParagraphSkeleton()
|
||||||
SkeletonBlock(width = 220.dp, height = 32.dp, cornerRadius = 8.dp)
|
SkeletonBlock(width = 220.dp, height = 32.dp, cornerRadius = 8.dp)
|
||||||
ReleaseNotesSkeleton()
|
ParagraphSkeleton()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
@Composable
|
||||||
private fun CompactAppHeader(
|
private fun CompactAppHeader(
|
||||||
app: AppInfo,
|
app: AppRecord,
|
||||||
repository: CatalogRepository?,
|
author: ProfileRecord?,
|
||||||
authorPubkey: String?,
|
onProfileClick: (String) -> Unit,
|
||||||
authorProfile: ProfileInfo?,
|
|
||||||
onProfileClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -375,16 +439,12 @@ private fun CompactAppHeader(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(ZapCanvas)
|
.background(ZapCanvas)
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
.height(SmallHeaderHeight)
|
.height(CompactHeaderHeight)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
AppIcon(
|
AppIcon(title = app.name, iconUrl = app.iconUrl, iconFile = app.iconFile, size = CompactIconSize)
|
||||||
title = app.name,
|
|
||||||
iconUrl = app.iconUrl,
|
|
||||||
size = SmallIconSize,
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = app.name,
|
text = app.name,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Black),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Black),
|
||||||
@@ -392,13 +452,8 @@ private fun CompactAppHeader(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
authorPubkey?.let { pubkey ->
|
app.authorPubkey?.let { pubkey ->
|
||||||
AppAuthorByline(
|
AuthorByline(pubkey = pubkey, profile = author, onClick = { onProfileClick(pubkey) })
|
||||||
pubkey = pubkey,
|
|
||||||
repository = repository,
|
|
||||||
profile = authorProfile,
|
|
||||||
onClick = onProfileClick,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +467,7 @@ private fun ScreenshotCarousel(
|
|||||||
) {
|
) {
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(
|
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = initialPage.coerceIn(0, screenshots.lastIndex),
|
initialPage = initialPage.coerceIn(0, screenshots.lastIndex),
|
||||||
@@ -426,16 +478,10 @@ private fun ScreenshotCarousel(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
HorizontalPager(
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
state = pagerState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) { page ->
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = cdnImageUrl(screenshots[page], CdnImageVariant.ThumbnailLarge),
|
model = cdnImageUrl(screenshots[page], CdnImageVariant.ThumbnailLarge),
|
||||||
contentDescription = stringResource(
|
contentDescription = stringResource(R.string.screenshot_description, appName),
|
||||||
R.string.screenshot_description,
|
|
||||||
appName,
|
|
||||||
),
|
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -467,9 +513,8 @@ private fun ScreenshotCarousel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ZapSummaryCard(summary: ZapSummaryUiState) {
|
private fun ZapSummaryCard(summary: ZapSummary) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -485,22 +530,14 @@ private fun ZapSummaryCard(summary: ZapSummaryUiState) {
|
|||||||
color = ZapTextSecondary,
|
color = ZapTextSecondary,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
if (summary.isLoading && summary.zapCount == 0) {
|
when {
|
||||||
Box(
|
summary.loading && summary.count == 0 -> SkeletonBlock(width = 148.dp, height = 24.dp, cornerRadius = 6.dp)
|
||||||
Modifier
|
summary.count == 0 -> StatusText(summary.error.orEmpty())
|
||||||
.width(148.dp)
|
else -> Text(
|
||||||
.height(24.dp)
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.background(ZapSurface2),
|
|
||||||
)
|
|
||||||
} else if (summary.zapCount == 0) {
|
|
||||||
StatusText(summary.error.orEmpty())
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.zap_summary,
|
R.string.zap_summary,
|
||||||
NumberFormat.getInstance().format(summary.totalSats),
|
NumberFormat.getInstance().format(summary.totalSats),
|
||||||
summary.zapCount,
|
summary.count,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -509,9 +546,11 @@ private fun ZapSummaryCard(summary: ZapSummaryUiState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -519,31 +558,113 @@ private fun VersionRow(release: ReleaseInfo) {
|
|||||||
.clip(RoundedCornerShape(ZapRadius.md))
|
.clip(RoundedCornerShape(ZapRadius.md))
|
||||||
.background(ZapSurface2)
|
.background(ZapSurface2)
|
||||||
.border(1.dp, ZapLine, RoundedCornerShape(ZapRadius.md))
|
.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))
|
StatusText(stringResource(R.string.version))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = release.version,
|
text = app.version,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
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
|
@Composable
|
||||||
private fun AppInfoCard(
|
private fun AppInfoCard(
|
||||||
app: AppInfo,
|
app: AppRecord,
|
||||||
release: ReleaseInfo?,
|
author: ProfileRecord?,
|
||||||
onOpenUrl: (String) -> Unit,
|
onOpenUrl: (String) -> Unit,
|
||||||
repository: CatalogRepository?,
|
onProfileClick: (String) -> Unit,
|
||||||
authorPubkey: String?,
|
|
||||||
c1AuthorPubkey: String?,
|
|
||||||
authorProfile: ProfileInfo?,
|
|
||||||
onProfileClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -560,65 +681,122 @@ private fun AppInfoCard(
|
|||||||
link = app.repository,
|
link = app.repository,
|
||||||
onOpenUrl = onOpenUrl,
|
onOpenUrl = onOpenUrl,
|
||||||
)
|
)
|
||||||
app.license?.let {
|
app.website?.let { InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) }
|
||||||
InfoRow(stringResource(R.string.license), it, onOpenUrl = onOpenUrl)
|
app.license?.let { InfoRow(stringResource(R.string.license), it) }
|
||||||
}
|
InfoRow(stringResource(R.string.app_id), app.appId)
|
||||||
InfoRow(stringResource(R.string.app_id), app.identifier, onOpenUrl = onOpenUrl)
|
app.authorPubkey?.let { pubkey ->
|
||||||
authorPubkey?.let { pubkey ->
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 10.dp),
|
.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))
|
Spacer(Modifier.weight(1f))
|
||||||
Box(
|
AuthorByline(pubkey = pubkey, profile = author, showBy = false, onClick = { onProfileClick(pubkey) })
|
||||||
modifier = Modifier.weight(1f),
|
}
|
||||||
contentAlignment = Alignment.CenterEnd,
|
}
|
||||||
|
InfoRow(stringResource(R.string.release_date), formatDate(app.releasedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CommentsEmpty(
|
||||||
|
error: CommentsError?,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.testTag("commentsEmpty"),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ZapSpacing.space2),
|
||||||
) {
|
) {
|
||||||
AppAuthorByline(
|
StatusText(
|
||||||
pubkey = pubkey,
|
stringResource(
|
||||||
repository = repository,
|
when (error) {
|
||||||
profile = authorProfile,
|
CommentsError.RelaysUnreachable -> R.string.comments_error_relays
|
||||||
showBy = false,
|
CommentsError.TimedOut -> R.string.comments_error_timeout
|
||||||
onClick = onProfileClick,
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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(
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.CenterEnd,
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(ZapSurface3),
|
||||||
) {
|
) {
|
||||||
AppAuthorByline(
|
ProfileImage(
|
||||||
pubkey = pubkey,
|
pubkey = comment.pubkey,
|
||||||
repository = repository,
|
pictureUrl = author?.picture,
|
||||||
showBy = false,
|
profileVersion = author?.eventId,
|
||||||
|
contentDescription = stringResource(R.string.profile_avatar_description, shortDisplayName(comment.pubkey, author)),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
text = shortDisplayName(comment.pubkey, author),
|
||||||
|
color = ZapTextSecondary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatusText(formatDate(comment.createdAt))
|
||||||
}
|
}
|
||||||
}
|
MarkdownText(
|
||||||
release?.let {
|
value = comment.content,
|
||||||
InfoRow(
|
color = ZapTextSecondary,
|
||||||
stringResource(R.string.release_date),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
formatDate(it.event.createdAt),
|
|
||||||
onOpenUrl = onOpenUrl,
|
onOpenUrl = onOpenUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun formatDate(createdAt: Long): String =
|
private fun formatDate(createdAt: Long): String =
|
||||||
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(createdAt * 1_000))
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
repository: CatalogRepository? = null,
|
|
||||||
onSearchQueryChanged: (String) -> Unit,
|
onSearchQueryChanged: (String) -> Unit,
|
||||||
onSearchSubmitted: () -> Unit,
|
onSearchSubmitted: () -> Unit,
|
||||||
onSearchCleared: () -> Unit,
|
onSearchCleared: () -> Unit,
|
||||||
onUpdatesClick: () -> Unit = {},
|
onAppClick: (AppRecord) -> Unit,
|
||||||
updateCount: Int = 0,
|
onStackClick: (StackRecord) -> Unit,
|
||||||
databaseRowCount: Long = 0,
|
|
||||||
catalogError: String? = null,
|
|
||||||
onStackClick: (String) -> Unit,
|
|
||||||
onAppClick: (identifier: String, author: String) -> Unit,
|
|
||||||
onProfileClick: (String) -> Unit = {},
|
|
||||||
onLoadMoreReleases: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onProfileClick: (String) -> Unit = {},
|
||||||
|
onUpdatesClick: () -> Unit = {},
|
||||||
|
onSettingsClick: () -> Unit = {},
|
||||||
|
updateCount: Int = 0,
|
||||||
|
onLoadMore: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val latestReleases = stringResource(R.string.latest_releases)
|
val latestReleases = stringResource(R.string.latest_releases)
|
||||||
val noReleases = stringResource(R.string.no_releases)
|
val noReleases = stringResource(R.string.no_releases)
|
||||||
LoadMoreReleasesWhenNearEnd(
|
LoadMoreWhenNearEnd(listState, state.feed, onLoadMore)
|
||||||
listState = listState,
|
LaunchedEffect(state.submittedQuery) {
|
||||||
entries = state.releaseFeed.entries,
|
listState.scrollToItem(0)
|
||||||
loading = state.releaseFeed.loadingMore,
|
}
|
||||||
canLoadMore = state.releaseFeed.canLoadMore,
|
|
||||||
onLoadMore = onLoadMoreReleases,
|
|
||||||
)
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(ZapCanvas)
|
.background(ZapCanvas)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
.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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -111,7 +110,8 @@ fun HomeScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(ZapSize.control)
|
.size(ZapSize.control)
|
||||||
.semantics { contentDescription = clearDescription },
|
.semantics { contentDescription = clearDescription }
|
||||||
|
.testTag("clearSearch"),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_close),
|
painter = painterResource(R.drawable.ic_close),
|
||||||
@@ -128,10 +128,7 @@ fun HomeScreen(
|
|||||||
.testTag("searchField"),
|
.testTag("searchField"),
|
||||||
)
|
)
|
||||||
Box(contentAlignment = Alignment.TopEnd) {
|
Box(contentAlignment = Alignment.TopEnd) {
|
||||||
IconButton(
|
IconButton(onClick = onUpdatesClick, modifier = Modifier.testTag("updatesButton")) {
|
||||||
onClick = onUpdatesClick,
|
|
||||||
modifier = Modifier.testTag("updatesButton"),
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_launcher),
|
painter = painterResource(R.drawable.ic_launcher),
|
||||||
contentDescription = stringResource(R.string.updates),
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.testTag("homeList"),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
state = listState,
|
state = listState,
|
||||||
) {
|
) {
|
||||||
state.searchMessage?.let { message ->
|
state.searchResults?.let { results ->
|
||||||
item { StatusText(message, Modifier.testTag("searchStatus")) }
|
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(
|
AppCard(
|
||||||
app = app,
|
app = app,
|
||||||
onClick = { onAppClick(app.identifier, app.event.pubKey) },
|
author = app.authorPubkey?.let(state.profiles::get),
|
||||||
onProfileClick = { onProfileClick(app.event.pubKey) },
|
onClick = { onAppClick(app) },
|
||||||
repository = repository,
|
onAuthorClick = onProfileClick,
|
||||||
modifier = Modifier.testTag("searchResult:${app.address}"),
|
modifier = Modifier.testTag("searchResult:${app.appId}"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item { SectionTitle(stringResource(R.string.curated_stacks), Modifier.padding(top = 16.dp)) }
|
||||||
SectionTitle(
|
|
||||||
value = stringResource(R.string.curated_stacks),
|
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
when {
|
when {
|
||||||
state.stacks.isNotEmpty() -> {
|
state.stacks.isNotEmpty() -> LazyRow(
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
contentPadding = PaddingValues(end = 8.dp),
|
contentPadding = PaddingValues(end = 8.dp),
|
||||||
) {
|
) {
|
||||||
items(state.stacks, key = { it.event.id }) { stack ->
|
items(state.stacks, key = { it.eventId }) { stack ->
|
||||||
StackCard(
|
StackCard(
|
||||||
stack = stack,
|
stack = stack,
|
||||||
appsByAddress = state.stackApps,
|
apps = state.stackApps,
|
||||||
onClick = { onStackClick(stack.event.id) },
|
onClick = { onStackClick(stack) },
|
||||||
modifier = Modifier.testTag("stack:${stack.event.id}"),
|
modifier = Modifier.testTag("stack:${stack.identifier}"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
state.stacksLoading -> LoadingIndicator(Modifier.padding(vertical = 12.dp))
|
state.stacksLoading -> LoadingIndicator(Modifier.padding(vertical = 12.dp))
|
||||||
state.stacksError != null -> StatusText(state.stacksError)
|
state.stacksError != null -> StatusText(state.stacksError)
|
||||||
else -> StatusText(stringResource(R.string.no_stacks))
|
else -> StatusText(stringResource(R.string.no_stacks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseFeed(
|
appList(
|
||||||
state = state.releaseFeed,
|
state = state.feed,
|
||||||
title = latestReleases,
|
title = latestReleases,
|
||||||
emptyMessage = noReleases,
|
emptyMessage = noReleases,
|
||||||
repository = repository,
|
|
||||||
onAppClick = onAppClick,
|
onAppClick = onAppClick,
|
||||||
onProfileClick = onProfileClick,
|
onAuthorClick = onProfileClick,
|
||||||
modifier = Modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import android.content.ClipData
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
state: ProfileUiState,
|
state: ProfileUiState,
|
||||||
onOpenUrl: (String) -> Unit,
|
onOpenUrl: (String) -> Unit,
|
||||||
repository: CatalogRepository? = null,
|
onAppClick: (AppRecord) -> Unit,
|
||||||
onAppClick: (identifier: String, author: String) -> Unit = { _, _ -> },
|
|
||||||
onLoadMoreReleases: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val noApps = stringResource(R.string.no_profile_apps)
|
||||||
val noProfileApps = stringResource(R.string.no_profile_apps)
|
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
LoadMoreReleasesWhenNearEnd(
|
val npub = remember(state.pubkey) { state.pubkey.toNpub() }
|
||||||
listState = listState,
|
val profile = state.profile
|
||||||
entries = state.releaseFeed.entries,
|
val title = profile?.displayNameOrNpub ?: npub
|
||||||
loading = state.releaseFeed.loadingMore,
|
|
||||||
canLoadMore = state.releaseFeed.canLoadMore,
|
|
||||||
onLoadMore = onLoadMoreReleases,
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(ZapCanvas)
|
.background(ZapCanvas)
|
||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
state = listState,
|
|
||||||
contentPadding = PaddingValues(bottom = 36.dp),
|
contentPadding = PaddingValues(bottom = 36.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.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 {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -97,27 +77,37 @@ fun ProfileScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
ProfileAvatar(
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(84.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(ZapSurface2),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
ProfileImage(
|
||||||
pubkey = state.pubkey,
|
pubkey = state.pubkey,
|
||||||
name = profile.displayName ?: profile.name ?: state.pubkey,
|
pictureUrl = profile?.picture,
|
||||||
pictureUrl = profile.picture,
|
profileVersion = profile?.eventId,
|
||||||
profileVersion = profile.event.id,
|
contentDescription = stringResource(R.string.profile_avatar_description, title),
|
||||||
size = 84.dp,
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(CircleShape),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = profile.displayName ?: profile.name ?: state.pubkey,
|
text = title,
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Black),
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Black),
|
||||||
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
profile.name?.takeIf { it != profile.displayName }?.let {
|
profile?.name?.takeIf { it != profile.displayName }?.let { StatusText("@$it") }
|
||||||
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 {
|
item {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
value = about,
|
value = about,
|
||||||
@@ -130,18 +120,16 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseFeed(
|
appList(
|
||||||
state = state.releaseFeed,
|
state = state.apps,
|
||||||
emptyMessage = noProfileApps,
|
emptyMessage = noApps,
|
||||||
repository = repository,
|
|
||||||
onAppClick = onAppClick,
|
onAppClick = onAppClick,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
showAuthor = false,
|
showAuthor = false,
|
||||||
)
|
)
|
||||||
state.error?.let { error -> item { StatusText(error) } }
|
state.error?.let { item { StatusText(it, Modifier.padding(horizontal = 16.dp)) } }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
val npub = remember(state.pubkey) { state.pubkey.toNpub() }
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -151,51 +139,16 @@ fun ProfileScreen(
|
|||||||
.border(1.dp, ZapLine, RoundedCornerShape(16.dp))
|
.border(1.dp, ZapLine, RoundedCornerShape(16.dp))
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
profile.website?.let {
|
profile?.website?.let { InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl) }
|
||||||
InfoRow(stringResource(R.string.website), it, link = it, onOpenUrl = onOpenUrl)
|
profile?.nip05?.let { InfoRow(stringResource(R.string.nip05), it) }
|
||||||
}
|
|
||||||
profile.nip05?.let {
|
|
||||||
InfoRow(stringResource(R.string.nip05), it, onOpenUrl = onOpenUrl)
|
|
||||||
}
|
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.npub),
|
label = stringResource(R.string.npub),
|
||||||
value = npub,
|
value = npub,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("npub", npub))) }
|
||||||
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.safeDrawing
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,15 +16,23 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun StackDetailScreen(
|
fun StackDetailScreen(
|
||||||
state: StackDetailUiState,
|
state: StackDetailUiState,
|
||||||
repository: CatalogRepository? = null,
|
onAppClick: (AppRecord) -> Unit,
|
||||||
onAppClick: (identifier: String, author: String) -> Unit,
|
|
||||||
onProfileClick: (String) -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onProfileClick: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val appsTitle = stringResource(R.string.apps)
|
||||||
|
val noApps = stringResource(R.string.no_stack_apps)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -40,52 +47,21 @@ fun StackDetailScreen(
|
|||||||
if (state.stackLoading) {
|
if (state.stackLoading) {
|
||||||
LoadingIndicator(Modifier.padding(vertical = 20.dp))
|
LoadingIndicator(Modifier.padding(vertical = 20.dp))
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(text = stringResource(R.string.stack_not_found), style = MaterialTheme.typography.headlineSmall)
|
||||||
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
|
return@LazyColumn
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item { StackCard(stack = stack, apps = state.appsById, modifier = Modifier.testTag("stack:${stack.identifier}")) }
|
||||||
StackCard(
|
appList(
|
||||||
stack = stack,
|
state = state.apps,
|
||||||
appsByAddress = state.appsByAddress,
|
title = appsTitle,
|
||||||
modifier = Modifier.testTag("stack:${stack.event.id}"),
|
emptyMessage = noApps,
|
||||||
|
onAppClick = onAppClick,
|
||||||
|
onAuthorClick = onProfileClick,
|
||||||
)
|
)
|
||||||
}
|
state.error?.let { item { StatusText(it) } }
|
||||||
item {
|
|
||||||
SectionTitle(
|
|
||||||
value = stringResource(R.string.apps),
|
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(
|
|
||||||
items = stack.appAddresses.mapNotNull(state.appsByAddress::get),
|
|
||||||
key = { it.address },
|
|
||||||
) { app ->
|
|
||||||
AppCard(
|
|
||||||
app = app,
|
|
||||||
release = state.releasesByAddress[app.address],
|
|
||||||
onClick = { onAppClick(app.identifier, app.event.pubKey) },
|
|
||||||
onProfileClick = { onProfileClick(app.event.pubKey) },
|
|
||||||
repository = repository,
|
|
||||||
modifier = Modifier.testTag("stackApp:${app.address}"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.appsByAddress.isEmpty()) {
|
|
||||||
item {
|
|
||||||
if (state.appsLoading) {
|
|
||||||
LoadingIndicator(Modifier.padding(vertical = 12.dp))
|
|
||||||
} else {
|
|
||||||
StatusText(stringResource(R.string.no_stack_apps))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.error?.let { error -> item { StatusText(error) } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdatesScreen(
|
fun UpdatesScreen(
|
||||||
state: CatalogSyncUiState,
|
state: UpdatesUiState,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onAppClick: (identifier: String, author: String?) -> Unit = { _, _ -> },
|
onAppClick: (AppRecord) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -41,67 +46,52 @@ fun UpdatesScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.updates),
|
|
||||||
style = MaterialTheme.typography.displaySmall,
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
StatusText(stringResource(R.string.catalog_epoch, state.epoch))
|
Text(text = stringResource(R.string.updates), style = MaterialTheme.typography.displaySmall)
|
||||||
state.lastSyncedAtMillis?.let { synced ->
|
state.status.lastSyncedAtMillis?.let { synced ->
|
||||||
StatusText(
|
StatusText(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.last_synced,
|
R.string.last_synced,
|
||||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(synced)),
|
||||||
.format(Date(synced)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onSync,
|
onClick = onSync,
|
||||||
enabled = !state.syncing,
|
enabled = !state.status.syncing,
|
||||||
modifier = Modifier.testTag("syncCatalog"),
|
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")) }
|
state.status.error?.let { StatusText(it, Modifier.testTag("updatesError")) }
|
||||||
if (state.syncing && state.availableUpdates.isEmpty()) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (state.availableUpdates.isEmpty() && !state.syncing) {
|
if (state.updates.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
StatusText(
|
if (!state.loaded || state.status.syncing) {
|
||||||
stringResource(R.string.no_updates),
|
LoadingIndicator()
|
||||||
Modifier.testTag("noUpdates"),
|
} else {
|
||||||
)
|
StatusText(stringResource(R.string.no_updates), Modifier.testTag("noUpdates"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.availableUpdates, key = { it.appId }) { update ->
|
}
|
||||||
Column(
|
items(state.updates, key = { it.app.appId }) { update ->
|
||||||
modifier = Modifier
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.testTag("update:${update.app.appId}")) {
|
||||||
.fillMaxWidth()
|
AppCard(app = update.app, author = null, onClick = { onAppClick(update.app) }, showAuthor = false)
|
||||||
.testTag("update:${update.appId}"),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
|
||||||
Text(update.name, style = MaterialTheme.typography.titleMedium)
|
|
||||||
EvidenceText(update.appId, color = ZapTextSecondary)
|
|
||||||
StatusText(
|
StatusText(
|
||||||
stringResource(
|
stringResource(R.string.update_version, update.installedVersion, update.app.version),
|
||||||
R.string.update_version,
|
Modifier.padding(horizontal = 4.dp),
|
||||||
update.installedVersion,
|
|
||||||
update.availableVersion,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="search_apps">Search apps</string>
|
||||||
<string name="clear_search">Clear search</string>
|
<string name="clear_search">Clear search</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="notifications">Notifications</string>
|
|
||||||
<string name="updates">Updates</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="no_updates">All catalog apps are up to date.</string>
|
||||||
<string name="sync_now">Sync</string>
|
<string name="sync_now">Sync</string>
|
||||||
<string name="syncing">Syncing</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_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_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="update_version">%1$s → %2$s</string>
|
||||||
<string name="database_rows">%1$d database rows</string>
|
<string name="search_results">Search results: %1$d in %2$dms</string>
|
||||||
<string name="no_notifications">You are all caught up.</string>
|
<string name="verified_author">Verified author</string>
|
||||||
<string name="curated_stacks">Curated stacks</string>
|
<string name="curated_stacks">Curated stacks</string>
|
||||||
<string name="latest_releases">Latest releases</string>
|
<string name="latest_releases">Latest releases</string>
|
||||||
<string name="no_releases">No releases found.</string>
|
<string name="no_releases">No releases found.</string>
|
||||||
@@ -32,7 +48,16 @@
|
|||||||
<string name="latest_release">Latest release</string>
|
<string name="latest_release">Latest release</string>
|
||||||
<string name="support">Support</string>
|
<string name="support">Support</string>
|
||||||
<string name="zap_summary">%1$s sats · %2$d zaps</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="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="version">Version:</string>
|
||||||
<string name="source">Source</string>
|
<string name="source">Source</string>
|
||||||
<string name="license">License</string>
|
<string name="license">License</string>
|
||||||
@@ -44,7 +69,23 @@
|
|||||||
<string name="npub">Npub</string>
|
<string name="npub">Npub</string>
|
||||||
<string name="not_available">Not available</string>
|
<string name="not_available">Not available</string>
|
||||||
<string name="copy">Copy</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="app_icon_description">%1$s icon</string>
|
||||||
<string name="screenshot_description">%1$s screenshot</string>
|
<string name="screenshot_description">%1$s screenshot</string>
|
||||||
<string name="profile_avatar_description">%1$s avatar</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>
|
</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