Read catalog screens from the local store.

This commit is contained in:
franzap
2026-09-27 20:07:26 -03:00
parent 29041ef777
commit c669549322
78 changed files with 4908 additions and 4984 deletions
+31 -28
View File
@@ -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)"
+27 -8
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
+11
View File
@@ -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(
app = app, state = AppDetailUiState(
repository = null, app = app,
onClick = {}, loaded = true,
onProfileClick = { openedProfile = app.event.pubKey }, comments = CommentsState(threads = threadComments(listOf(comment)), loading = false),
),
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,
),
), ),
onSearchQueryChanged = {}, onOpenUrl = {},
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
View File
@@ -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()
}
-469
View File
@@ -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
}
+134 -189
View File
@@ -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://")
File diff suppressed because it is too large Load Diff
@@ -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(
items(state.searchResults, key = { it.address }) { app -> value = stringResource(R.string.search_results, results.size, state.searchDurationMillis ?: 0L),
AppCard( modifier = Modifier.testTag("searchStatus"),
app = app, )
onClick = { onAppClick(app.identifier, app.event.pubKey) }, }
onProfileClick = { onProfileClick(app.event.pubKey) }, items(results, key = { "search:${it.appId}" }) { app ->
repository = repository, AppCard(
modifier = Modifier.testTag("searchResult:${app.address}"), app = app,
) author = app.authorPubkey?.let(state.profiles::get),
onClick = { onAppClick(app) },
onAuthorClick = onProfileClick,
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.eventId }) { stack ->
items(state.stacks, key = { it.event.id }) { stack -> StackCard(
StackCard( stack = stack,
stack = stack, apps = state.stackApps,
appsByAddress = state.stackApps, onClick = { onStackClick(stack) },
onClick = { onStackClick(stack.event.id) }, modifier = Modifier.testTag("stack:${stack.identifier}"),
modifier = Modifier.testTag("stack:${stack.event.id}"), )
)
}
} }
} }
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(
pubkey = state.pubkey, modifier = Modifier
name = profile.displayName ?: profile.name ?: state.pubkey, .size(84.dp)
pictureUrl = profile.picture, .clip(CircleShape)
profileVersion = profile.event.id, .background(ZapSurface2),
size = 84.dp, contentAlignment = Alignment.Center,
) ) {
ProfileImage(
pubkey = state.pubkey,
pictureUrl = profile?.picture,
profileVersion = profile?.eventId,
contentDescription = stringResource(R.string.profile_avatar_description, title),
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
)
}
Spacer(Modifier.width(16.dp)) 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,
item { )
SectionTitle( state.error?.let { item { StatusText(it) } }
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 -> items(state.updates, key = { it.app.appId }) { update ->
Column( Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.testTag("update:${update.app.appId}")) {
modifier = Modifier AppCard(app = update.app, author = null, onClick = { onAppClick(update.app) }, showAuthor = false)
.fillMaxWidth()
.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"
}
+44 -3
View File
@@ -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.