diff --git a/.github/actions/import-macos-cert/action.yml b/.github/actions/import-macos-cert/action.yml new file mode 100644 index 0000000000..e9ecb02761 --- /dev/null +++ b/.github/actions/import-macos-cert/action.yml @@ -0,0 +1,52 @@ +name: Import macOS Developer ID certificate +description: > + Import a Developer ID Application certificate (base64-encoded .p12) into a + throwaway keychain so codesign/jpackage can find it during the job. Soft: + when no certificate is supplied it is a no-op and reports signing=false, so + callers build UNSIGNED artifacts exactly as before. + +inputs: + certificate-p12-base64: + description: Base64 of the Developer ID Application .p12 (cert + private key) + required: true + certificate-password: + description: Password used when the .p12 was exported + required: true + +outputs: + signing: + description: "'true' if a certificate was imported, else 'false'" + value: ${{ steps.import.outputs.signing }} + +runs: + using: composite + steps: + - id: import + shell: bash + env: + CERT_P12: ${{ inputs.certificate-p12-base64 }} + CERT_PASSWORD: ${{ inputs.certificate-password }} + run: | + set -euo pipefail + if [[ -z "${CERT_P12:-}" ]]; then + echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED." + echo "signing=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db" + KEYCHAIN_PWD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/developer_id.p12" + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + echo "$CERT_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" \ + -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign + # Let codesign use the private key without an interactive UI prompt. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null + # Prepend our keychain to the user search list so codesign sees it. + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | sed -e 's/[\"[:space:]]//g') + rm -f "$CERT_PATH" + echo "signing=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 83547b56e1..e923e01ac2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -101,8 +101,29 @@ jobs: fi chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + # macOS only: import the Developer ID Application cert into a throwaway + # keychain so jpackage's codesign pass can find it. Soft — if the + # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are + # provisioned) the DMG is built UNSIGNED, exactly as before. notarytool + # runs as part of the gradle task when the identity env is exported below. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + - name: Build desktop artifacts uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + env: + # Empty on non-macOS legs and on the macOS leg when no cert is + # configured — the gradle macOS{} block skips signing when the + # identity is blank. + AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_SIGN_IDENTITY || '' }} + AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} with: max_attempts: 2 timeout_minutes: 15 @@ -212,7 +233,7 @@ jobs: - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: run: shell: bash @@ -261,6 +282,91 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # amy is headless: the Compose UI render stack (skiko + its native dylibs, + # foundation/material/material3/ui/animation) must never reach the CLI + # image. cli/build.gradle.kts excludes it from runtimeClasspath; this + # guards against a transitive dep silently dragging it back (size + macOS + # notarization-surface regression). compose.runtime is CLI-safe and stays. + - name: Assert no Compose UI in the amy image + run: | + set -euo pipefail + LIB="cli/build/install/amy/lib" + leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)" + if [ -n "$leak" ]; then + echo "::error::Compose UI render stack leaked into the amy CLI image:" + echo "$leak" | sed 's/^/ /' + echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)." + exit 1 + fi + echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))." + + # macOS only: import the Developer ID cert (no-op without the secret) so + # the next step can codesign the jlink image. The jvm bundle for + # Homebrew-core is NOT signed here — Homebrew strips quarantine itself. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + + # Codesign + notarize the macOS jlink image (amy--macos-arm64.tar.gz) + # for users who download it directly. A loose tarball can't be stapled + # (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization + # online on first run. Runs before "Collect" so the tarred image is signed. + - name: Sign + notarize amy image (macOS leg, if configured) + if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' + env: + SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }} + NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} + run: | + set -euo pipefail + IMG="cli/build/amy-image/amy" + ENTITLEMENTS="cli/packaging/macos/amy.entitlements" + # Sign every Mach-O binary in the bundled JRE. Each is signed + # independently (no enclosing .app seals them), so order is irrelevant. + # Executables get the hardened-runtime entitlements; dylibs don't. + while IFS= read -r f; do + case "$(file -b "$f")" in + *Mach-O*executable*) + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;; + *Mach-O*) + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$f" ;; + esac + done < <(find "$IMG" -type f) + codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java" + # Notarize: zip the signed image, submit, wait for Apple's verdict. + # The notary service recursively inspects the lib/*.jar files, so any + # unsigned Mach-O embedded in them (secp256k1/jna/sqlite/skiko natives) + # can come back Invalid. Surface the per-file log so the first real run + # is diagnostic rather than a bare failure. + ZIP="$RUNNER_TEMP/amy-notarize.zip" + OUT="$RUNNER_TEMP/notary-submit.json" + ditto -c -k --keepParent "$IMG" "$ZIP" + if ! xcrun notarytool submit "$ZIP" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then + echo "::warning::notarytool submit exited non-zero" + fi + cat "$OUT" + STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)" + SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)" + if [ "$STATUS" != "Accepted" ]; then + echo "::error::Notarization status: $STATUS" + if [ -n "$SUBMISSION_ID" ]; then + echo "----- notary log -----" + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" || true + fi + exit 1 + fi + # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. - name: Relax libicu dependency in .deb @@ -277,6 +383,23 @@ jobs: source scripts/asset-name.sh collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + # Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it + # cannot use the jlink tarball above (bundled runtime) nor build from + # source (its sandbox blocks Gradle's Maven downloads). installDist + # (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure + # JVM bytecode, so one platform-independent asset serves every OS; we cut + # it on the linux leg only. amyImage depends on installDist, so the + # cli/build/install/amy tree already exists here. + - name: Package no-JRE jvm bundle for Homebrew (linux leg only) + if: matrix.family == 'linux' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + SRC="cli/build/install/amy" + test -x "$SRC/bin/amy" + ( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib ) + echo "Collected: dist/amy-${VER}-jvm.tar.gz" + - name: Enforce CLI size budget (200 MB per asset) run: | set -euo pipefail diff --git a/BUILDING.md b/BUILDING.md index 941bf4b3f1..3363d10b46 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -250,13 +250,58 @@ provided automatically; everything else you set yourself.) | `SONATYPE_PASSWORD` | Maven Central user token password | Same | | `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) | | `SIGNING_PASSWORD` | Passphrase for that GPG key | Same | +| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball | +| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain | +| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with | +| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | +| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | +| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | | `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) | | `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | -Note the **two distinct signing identities** people often conflate: +Note the **three distinct signing identities** people often conflate: `SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` + -`SIGNING_PASSWORD` is the **GPG key** for Maven Central. They are unrelated. +`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` + +`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS +desktop DMG. They are unrelated — each comes from a different authority. + +The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset +the release workflow still builds the DMG **and** the macOS `amy` tarball, just +**unsigned** (the previous behavior). Provision all six to switch signing + +notarization on for both. Obtaining them requires Apple Developer Program +membership ($99/yr). The same one certificate signs both artifacts. + +The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means +codesigning every Mach-O binary in that runtime with hardened-runtime +entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can +load the secp256k1 native library it extracts at runtime). A loose `.tar.gz` +cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so +Gatekeeper verifies notarization **online** on first run — fine for a CLI. +Note the Homebrew-core jvm bundle (`amy--jvm.tar.gz`) is **not** signed: +Homebrew removes the quarantine attribute on its own downloads. + +> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled +> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via +> secp256k1 with no library-validation crash. Dropping `disable-library-validation` +> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted +> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative. +> +> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar` +> recursively and checks every Mach-O for a signature + hardened runtime. Our +> sign loop only touches loose files, so 9 unsigned natives ride along inside +> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2), +> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders). +> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified** +> (the local validation had no notary creds). **Decide it with one run:** set the +> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch` +> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and +> now prints the per-file notary log on a non-`Accepted` verdict. If it comes +> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before +> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the +> `:commons` core/ui split the size budget already flags). The **desktop** app +> bundles the same jars through Compose/jpackage notarization, so run a desktop +> dry-run too; its in-jar handling differs and is likewise unverified. Generating the values: @@ -269,6 +314,14 @@ base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY # GPG key → armored private key for SIGNING_PRIVATE_KEY gpg --full-generate-key # create the key (once) gpg --armor --export-secret-keys # paste output into SIGNING_PRIVATE_KEY + +# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12. +# In Keychain Access, export the "Developer ID Application: ..." cert (with its +# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD). +base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12 +security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string +# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com +# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login. ``` `SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from @@ -339,6 +392,51 @@ The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a tiling window manager). After the first PR is merged, `bump-homebrew.yml` auto-submits new version bumps on each stable release. +> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the +> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in +> homebrew-**core**; both are "mainline." A private tap is only the *fallback* +> if Homebrew ever rejects the (now signed + notarized) cask. + +### Homebrew-core formula for the `amy` CLI (one-time initial PR) + +The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask — +casks are for GUI apps. homebrew-core builds in a **network-sandboxed** +environment, so a from-source Gradle build can't resolve its Maven +dependencies there. Instead the formula downloads the pre-built **no-JRE jar +bundle** `amy--jvm.tar.gz` (published by `create-release.yml`) and +`depends_on "openjdk"`. The reference formula lives at +[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb). + +To submit: + +```bash +# 1. Grab the published asset's sha256 +curl -fsSL -o amy-jvm.tar.gz \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +shasum -a 256 amy-jvm.tar.gz + +# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR +brew create --set-name amy --tap homebrew/core \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +# (paste the reference formula body, run `brew audit --new amy`, +# `brew install --build-from-source amy`, `brew test amy`, then PR it.) +``` + +Caveats that the maintainer must weigh before submitting: + +- **Name collision.** `amy` may already exist in homebrew-core — check with + `brew search amy` first. If taken, fall back to `amethyst-cli`. +- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading + a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to + justify it (sandboxed Gradle can't fetch Maven deps). +- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks + Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` + core/ui split) would shrink it and smooth review — tracked as a follow-up. + +After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot +auto-open version-bump PRs on each stable release — no token or workflow on our +side (unlike the cask/winget bumps). + ### Winget (one-time initial submission) ```bash @@ -480,9 +578,17 @@ for the deprecation date. When it hits: Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask` on 2026-09-01. Before that date: -**Option A**: Commit budget to Apple Developer Program ($99/yr), add -`signing { sign.set(true) }` + `notarization {}` blocks to -`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI. +**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }` ++ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on +the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of +`create-release.yml` imports a Developer ID cert into a throwaway keychain and +exports the signing/notary env. It all stays a **no-op until the six +`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI +needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it +on: join the Apple Developer Program ($99/yr), create a *Developer ID +Application* certificate, generate an app-specific password, and set the six +secrets. The first signed+notarized DMG is best validated with a +`workflow_dispatch` dry-run before a real tag. **Option B**: Pivot to a private Homebrew tap: diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index c44598b810..c11ed91602 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -408,6 +408,9 @@ dependencies { implementation(libs.zxing) implementation(libs.zxing.embedded) + // OpenStreetMap tiles for road event location maps (kind 1315/1316) + implementation(libs.osmdroid.android) + // Markdown // implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" // implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ff7f498420..d82d2c221a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -75,6 +75,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -3870,6 +3872,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is RoadEventReportEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is RoadEventConfirmationEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is RelayDiscoveryEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt index dd5325011e..1e83a7dc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe -import com.vitorpamplona.amethyst.service.location.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash fun compute50kmLine(geoHash: GeoHash): List { val hashes = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt new file mode 100644 index 0000000000..4540a537c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt @@ -0,0 +1,37 @@ +/* + * 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 com.vitorpamplona.amethyst.service.location + +import android.location.Location +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash + +/** + * Android `Location` glue for the platform-agnostic [GeoHash] that now lives in + * Quartz. The encode/decode/neighbor logic is shared in `commonMain`; only these + * conversions to/from the Android `Location` type stay here. + */ +fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) + +fun GeoHash.toLocation(): Location = + Location("").also { + it.latitude = centerLat + it.longitude = centerLon + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 46b719eccf..298799e119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 93a556a01a..36acb126e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -39,13 +39,16 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch /** @@ -79,6 +82,10 @@ class NotificationRelayService : Service() { private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + // Throttle interval for refreshing the persistent notification's relay count. + // Keeps notification updates well under Android's rate limit (~10/s). + private const val NOTIFICATION_REFRESH_MS = 1000L + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" fun start(context: Context) { @@ -242,6 +249,7 @@ class NotificationRelayService : Service() { * drafts, and relay list changes. Since the service keeps the client connected, * those subscriptions remain active on the relays. */ + @OptIn(FlowPreview::class) private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = @@ -253,13 +261,22 @@ class NotificationRelayService : Service() { } launch { - Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> - val count = relays.size - if (count != connectedRelayCount) { - connectedRelayCount = count - updateNotification(count) + // sample() caps how often we touch the notification. During feed + // load/teardown connectedRelaysFlow churns dozens of times per second; + // posting on every delta blows past Android's notification rate limit + // (~10/s), which silently drops updates and leaves the visible count + // stuck on a stale intermediate value. One refresh per second stays + // well under the limit and always lands the settled count. + Amethyst.instance.client + .connectedRelaysFlow() + .sample(NOTIFICATION_REFRESH_MS) + .collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } } - } } } } @@ -272,10 +289,15 @@ class NotificationRelayService : Service() { private fun buildNotification(connectedRelays: Int): Notification { val contentText = - if (connectedRelays > 0) { - getString(R.string.always_on_notif_connected, connectedRelays) - } else { - getString(R.string.always_on_notif_connecting) + when { + connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting) + // Foreground: the pool also holds the feed/finder outbox relays, so the + // count reflects all connections, not just the inbox. Backgrounded, the + // feeds tear down and only inbox + DM relays remain. + MainActivity.isResumed -> + pluralStringRes(this, R.plurals.always_on_notif_connected_foreground, connectedRelays, connectedRelays) + else -> + pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } val openAppIntent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt index 9c426379fc..e49922f349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -43,6 +43,10 @@ import androidx.compose.ui.unit.Velocity * - onPostFling snaps a mid-way bar to the nearest edge, using the fling's remaining * velocity as the spring's initial velocity so the settle feels continuous. No velocity * is returned upward to avoid phantom scrolls on parent containers. + * - Hiding tracks the finger 1:1, but revealing is damped by [REVEAL_SENSITIVITY]. Once the + * bars are hidden, the small reverse drag a finger naturally makes when it catches/stops a + * fast scroll would otherwise be enough to snap the chrome (and the OS status bar) back. + * Damping the reveal direction makes bringing the bars back a more deliberate gesture. */ class DisappearingBarNestedScroll( private val state: DisappearingBarState, @@ -86,7 +90,19 @@ class DisappearingBarNestedScroll( private fun applyDelta(deltaY: Float) { val topLimit = state.topHeightLimit val bottomLimit = state.bottomHeightLimit - state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) - state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) + // Positive delta reveals the bars; negative delta hides them. Hiding stays 1:1 with the + // finger, while revealing is damped so a stray reverse drag doesn't bring the chrome back. + val effectiveDelta = if (deltaY > 0f) deltaY * REVEAL_SENSITIVITY else deltaY + state.topHeightOffset = (state.topHeightOffset + effectiveDelta).coerceIn(-topLimit, 0f) + state.bottomHeightOffset = (state.bottomHeightOffset + effectiveDelta).coerceIn(-bottomLimit, 0f) + } + + companion object { + /** + * Fraction of scroll distance applied when revealing the bars (1.0 = same rate as hiding). + * Lower values require a more deliberate downward scroll to bring the chrome back, so the + * tiny reverse movement of a finger stopping a fast scroll no longer pops the bars open. + */ + const val REVEAL_SENSITIVITY = 0.5f } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 72e6b1886b..38689c571a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -269,6 +269,14 @@ private fun ResetBarsOnResume(state: DisappearingBarState) { /** Fraction at which the tracked in-app bar is considered fully settled into the hidden edge. */ private const val STATUS_BAR_HIDE_THRESHOLD = 0.999f +/** + * Fraction the chrome must fall back below before the OS status bar reappears. Lower than + * [STATUS_BAR_HIDE_THRESHOLD] on purpose: this hysteresis gap means the finger that stops a fast + * scroll — which makes a tiny reverse reveal — does not immediately pop the status bar back. The + * user has to deliberately scroll the chrome back by ~30% before the status bar returns. + */ +private const val STATUS_BAR_SHOW_THRESHOLD = 0.7f + /** * Hides the OS status bar once the tracked in-app bar has settled into the hidden edge, and shows it * the moment it starts coming back. The OS status bar is binary (cannot slide), so it is driven off @@ -295,16 +303,27 @@ private fun ImmersiveStatusBarEffect(state: DisappearingBarState) { LaunchedEffect(controller, state) { controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + var statusBarHidden = false snapshotFlow { - val fraction = - if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction - fraction >= STATUS_BAR_HIDE_THRESHOLD + if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction }.distinctUntilChanged() - .collect { shouldHide -> - if (shouldHide) { - controller.hide(WindowInsetsCompat.Type.statusBars()) - } else { - controller.show(WindowInsetsCompat.Type.statusBars()) + .collect { fraction -> + // Hysteresis: hide once fully settled, but only show again after the chrome has + // been pulled back past STATUS_BAR_SHOW_THRESHOLD. Between the two thresholds the + // status bar keeps its current state, so a stray reverse reveal can't flip it. + val shouldHide = + when { + fraction >= STATUS_BAR_HIDE_THRESHOLD -> true + fraction <= STATUS_BAR_SHOW_THRESHOLD -> false + else -> statusBarHidden + } + if (shouldHide != statusBarHidden) { + statusBarHidden = shouldHide + if (shouldHide) { + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + controller.show(WindowInsetsCompat.Type.statusBars()) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e41f4ae5e9..b81981e28d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -174,6 +174,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -235,6 +237,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -348,6 +352,7 @@ fun NoteCompose( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)? = null, ) { WatchNoteEvent( @@ -378,6 +383,7 @@ fun NoteCompose( parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -399,6 +405,7 @@ fun AcceptableNote( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)?, ) { if (isQuotedNote || isBoostedNote) { @@ -451,6 +458,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -505,6 +513,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + onClick = onClick, moreOptions = moreOptions, ) } @@ -571,6 +580,7 @@ private fun CheckNewAndRenderNote( accountViewModel: AccountViewModel, showPopup: () -> Unit, nav: INav, + onClick: (() -> Unit)? = null, moreOptions: (@Composable () -> Unit)? = null, ) { val backgroundColor = @@ -584,7 +594,7 @@ private fun CheckNewAndRenderNote( InnerNoteWithReactions( baseNote = baseNote, backgroundColor = backgroundColor, - clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav), + clickModifier = clickableNoteModifier(baseNote, modifier, accountViewModel, showPopup, nav, onClick), isBoostedNote = isBoostedNote, isQuotedNote = isQuotedNote, unPackReply = unPackReply, @@ -614,25 +624,30 @@ fun clickableNoteModifier( accountViewModel: AccountViewModel, showPopup: () -> Unit, nav: INav, + onClick: (() -> Unit)? = null, ): Modifier = - remember(baseNote, modifier) { + remember(baseNote, modifier, onClick) { modifier .combinedClickable( onClick = { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - - nav.nav { - if (redirectToNote.event is DraftWrapEvent) { - withContext(Dispatchers.IO) { - routeEditDraftTo(redirectToNote, accountViewModel.account) + if (onClick != null) { + onClick() + } else { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + + nav.nav { + if (redirectToNote.event is DraftWrapEvent) { + withContext(Dispatchers.IO) { + routeEditDraftTo(redirectToNote, accountViewModel.account) + } + } else { + routeFor(redirectToNote, accountViewModel.account) } - } else { - routeFor(redirectToNote, accountViewModel.account) } } }, @@ -1285,6 +1300,14 @@ private fun RenderNoteRow( RenderBirdex(baseNote) } + is RoadEventReportEvent -> { + RenderRoadEventReport(baseNote) + } + + is RoadEventConfirmationEvent -> { + RenderRoadEventConfirmation(baseNote) + } + is HighlightEvent -> { RenderHighlight( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 9573f040ac..35ddc2ed2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -59,8 +60,6 @@ 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.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard @@ -1618,7 +1617,6 @@ private fun BoostTypeChoicePopup( ) } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun BoostTypeChoicePopup( baseNote: Note, @@ -1642,52 +1640,81 @@ private fun BoostTypeChoicePopup( enter = popupAnimationEnter, exit = popupAnimationExit, ) { - FlowRow { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - if (accountViewModel.isWriteable()) { - accountViewModel.boost(baseNote) - visibilityState.targetState = false - } else { - onRepost() - visibilityState.targetState = false - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.boost), color = Color.White, textAlign = TextAlign.Center) - } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onQuote, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center) - } - + BoostTypeChoicePopupContent( // removes the option to fork for now because we do not have screens for // LongForm, Wiki and NIP posting. - if (baseNote.event is TextNoteEvent) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), + showFork = baseNote.event is TextNoteEvent, + onBoost = { + if (accountViewModel.isWriteable()) { + accountViewModel.boost(baseNote) + } else { + onRepost() + } + visibilityState.targetState = false + }, + onQuote = { + onQuote() + visibilityState.targetState = false + }, + onFork = { + onFork() + visibilityState.targetState = false + }, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun BoostTypeChoicePopupContent( + showFork: Boolean, + onBoost: () -> Unit, + onQuote: () -> Unit, + onFork: () -> Unit, +) { + Box(HalfPadding, contentAlignment = Center) { + ElevatedCard( + shape = SmallBorder, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + FlowRow( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + itemVerticalAlignment = CenterVertically, + ) { + BoostActionChip( + label = stringRes(R.string.boost), + onClick = onBoost, + ) { tint -> + RepostedIcon(Size18Modifier, tint) + } + + BoostActionChip( + label = stringRes(R.string.quote), + onClick = onQuote, + ) { tint -> + Icon( + symbol = MaterialSymbols.FormatQuote, + contentDescription = null, + modifier = Size18Modifier, + tint = tint, + ) + } + + if (showFork) { + BoostActionChip( + label = stringRes(R.string.fork), onClick = onFork, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center) + ) { tint -> + Icon( + symbol = MaterialSymbols.EditNote, + contentDescription = null, + modifier = Size18Modifier, + tint = tint, + ) } } } @@ -1695,6 +1722,67 @@ private fun BoostTypeChoicePopup( } } +/** + * A single quote/repost action rendered as a pill: a coloured leading icon next + * to its label, wrapped in the same soft-outlined surface the zap rails use so + * the popups read as one family. + */ +@Composable +private fun BoostActionChip( + label: String, + onClick: () -> Unit, + icon: @Composable (Color) -> Unit, +) { + Surface( + shape = ButtonBorder, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + ) { + Row( + modifier = + Modifier + .clip(ButtonBorder) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = CenterVertically, + ) { + icon(MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(6.dp)) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview +@Composable +fun BoostTypeChoicePopupPreview() { + ThemeComparisonColumn { + Column { + // With the fork action (e.g. a plain text note). + BoostTypeChoicePopupContent( + showFork = true, + onBoost = {}, + onQuote = {}, + onFork = {}, + ) + + // Without the fork action (e.g. long-form, wiki, …). + BoostTypeChoicePopupContent( + showFork = false, + onBoost = {}, + onQuote = {}, + onFork = {}, + ) + } + } +} + val popupAnimationEnter: EnterTransition = slideInVertically( animationSpec = defaultTweenIntOffsetSpec, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index 05cce5516b..2fd6d60d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -30,7 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations -import com.vitorpamplona.amethyst.service.location.toGeoHash +import com.vitorpamplona.amethyst.service.location.toLocation +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt new file mode 100644 index 0000000000..984c3fc985 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -0,0 +1,192 @@ +/* + * 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 com.vitorpamplona.amethyst.ui.note.creators.location + +import android.graphics.ColorFilter +import android.graphics.ColorMatrixColorFilter +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** Default close-up zoom for a single pinned location (street-level). */ +private const val DEFAULT_ZOOM = 16.0 + +/** + * Night-mode filter for the (always-light) MAPNIK tiles so the map follows the + * app theme. This is a lightness invert composed with a 180° hue rotation: it + * darkens the map (light land → near-black) while *preserving* hue, so forests + * stay green and water stays blue and the surfaces remain distinguishable — + * unlike a plain colour invert (forests → magenta) or a desaturating invert + * (everything → black). + */ +private val NIGHT_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + floatArrayOf( + 0.574f, + -1.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -0.430f, + -0.144f, + 0f, + 255f, + -0.426f, + -1.430f, + 0.856f, + 0f, + 255f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ) + +/** + * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] + * with a single pin at that point. + * + * Used by the Roadstr road event cards (kind 1315/1316) in place of a + * reverse-geocoded city name. Tiles are fetched from the public OSM tile + * servers, which requires the app's package name as the User-Agent (set on + * [Configuration] below) — without it OSM returns HTTP 403. + * + * When [pinColor] and [pinEmoji] are supplied the marker becomes a colored + * teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise + * osmdroid's default pin is used. [pinAlpha] fades the marker to signal + * freshness (e.g. an event close to its effective expiry). + * + * Pan/zoom stay enabled, but a touch listener asks the parent to stop + * intercepting gestures while the finger is on the map, so dragging the map + * pans it instead of scrolling the surrounding feed. + */ +@Composable +fun LocationPreviewMap( + latitude: Double, + longitude: Double, + modifier: Modifier = Modifier, + zoom: Double = DEFAULT_ZOOM, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val darkTheme = !MaterialTheme.colorScheme.isLight + + val markerIcon = + remember(pinColor, pinEmoji) { + if (pinColor != null && pinEmoji != null) { + val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) + BitmapDrawable(context.resources, bitmap) + } else { + null + } + } + + val mapView = + remember(context) { + // Must be set before the MapView is created so OSM tile requests + // carry a valid User-Agent (the default "osmdroid" is rejected). + Configuration.getInstance().userAgentValue = context.packageName + + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false) + } + // Returning false lets the MapView still handle the pan/zoom. + false + } + } + } + + // osmdroid's MapView is a plain Android View with its own lifecycle: it + // needs onResume/onPause to (re)start its tile threads, and onDetach to + // free the tile cache when the composable leaves the tree. + DisposableEffect(lifecycleOwner, mapView) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapView.onDetach() + } + } + + AndroidView( + modifier = modifier.fillMaxWidth().aspectRatio(1f), + factory = { mapView }, + update = { map -> + val point = GeoPoint(latitude, longitude) + map.controller.setZoom(zoom) + map.controller.setCenter(point) + + // Follow the app theme: dim the bright MAPNIK tiles in dark mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + + map.overlays.removeAll { it is Marker } + val marker = + Marker(map).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + markerIcon?.let { icon = it } + alpha = pinAlpha + } + map.overlays.add(marker) + map.invalidate() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt new file mode 100644 index 0000000000..8e9f422ccc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -0,0 +1,102 @@ +/* + * 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 com.vitorpamplona.amethyst.ui.note.creators.location + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import kotlin.math.roundToInt + +/** + * Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji] + * centered on its head, a white ring, and a soft drop shadow. + * + * The pin's tip sits at the bottom-center of the returned bitmap, so the + * osmdroid marker should be anchored at (0.5, 1.0) — the geographic point then + * lands exactly under the tip. + * + * Matches the visual language of the roadstr reference clients + * (): the same per-category color is used so + * a pin reads the same across implementations. + */ +fun roadEventPinBitmap( + emoji: String, + colorArgb: Int, + density: Float, +): Bitmap { + fun dp(value: Float) = value * density + + val head = dp(34f) // head diameter + val radius = head / 2f + val tail = dp(12f) // pointer height below the head + val pad = dp(5f) // room for the drop shadow + val ringWidth = dp(2.5f) + + val width = (head + pad * 2).roundToInt() + val height = (head + tail + pad * 2).roundToInt() + val cx = width / 2f + val cy = pad + radius + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Head circle + pointer drawn as a single path so they share one shadow + // instead of seaming where the triangle meets the circle. + val body = + Path().apply { + addCircle(cx, cy, radius, Path.Direction.CW) + val mouth = radius * 0.7f + moveTo(cx - mouth, cy + radius * 0.55f) + lineTo(cx + mouth, cy + radius * 0.55f) + lineTo(cx, pad + head + tail) + close() + } + + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorArgb + style = Paint.Style.FILL + setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt()) + } + canvas.drawPath(body, fill) + + val ring = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ringWidth + } + canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring) + + // Color emoji fonts ignore the paint color, so the glyph keeps its own hue. + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = head * 0.58f + } + val metrics = text.fontMetrics + val baseline = cy - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(emoji, cx, baseline, text) + + return bitmap +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt new file mode 100644 index 0000000000..3594b98919 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -0,0 +1,301 @@ +/* + * 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 com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Emoji marker for a road event category. Mirrors the icon set of the roadstr + * reference clients () so a report reads the + * same across implementations; keeps the card icon-light and locale-independent. + */ +private fun RoadEventType.emoji(): String = + when (this) { + RoadEventType.POLICE -> "👮" + RoadEventType.SPEED_CAMERA -> "📷" + RoadEventType.TRAFFIC_JAM -> "🚗" + RoadEventType.ACCIDENT -> "💥" + RoadEventType.ROAD_CLOSURE -> "🚫" + RoadEventType.CONSTRUCTION -> "🚧" + RoadEventType.HAZARD -> "⚠️" + RoadEventType.ROAD_CONDITION -> "🛣️" + RoadEventType.POTHOLE -> "🕳️" + RoadEventType.FOG -> "🌫️" + RoadEventType.ICE -> "🧊" + RoadEventType.ANIMAL -> "🦌" + RoadEventType.OTHER -> "ℹ️" + } + +/** + * Per-category pin color. Uses the exact palette from the roadstr reference + * clients () so a pin's color carries the + * same meaning across the network. + */ +private fun RoadEventType.color(): Color = + when (this) { + RoadEventType.POLICE -> Color(0xFF0000FF) + RoadEventType.SPEED_CAMERA -> Color(0xFF800080) + RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00) + RoadEventType.ACCIDENT -> Color(0xFFFF0000) + RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000) + RoadEventType.CONSTRUCTION -> Color(0xFFFFD700) + RoadEventType.HAZARD -> Color(0xFFFF4500) + RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4) + RoadEventType.POTHOLE -> Color(0xFF795548) + RoadEventType.FOG -> Color(0xFF9E9E9E) + RoadEventType.ICE -> Color(0xFF00CED1) + RoadEventType.ANIMAL -> Color(0xFF4CAF50) + RoadEventType.OTHER -> Color(0xFF808080) + } + +private fun RoadEventType.labelRes(): Int = + when (this) { + RoadEventType.POLICE -> R.string.road_event_police + RoadEventType.SPEED_CAMERA -> R.string.road_event_speed_camera + RoadEventType.TRAFFIC_JAM -> R.string.road_event_traffic_jam + RoadEventType.ACCIDENT -> R.string.road_event_accident + RoadEventType.ROAD_CLOSURE -> R.string.road_event_road_closure + RoadEventType.CONSTRUCTION -> R.string.road_event_construction + RoadEventType.HAZARD -> R.string.road_event_hazard + RoadEventType.ROAD_CONDITION -> R.string.road_event_road_condition + RoadEventType.POTHOLE -> R.string.road_event_pothole + RoadEventType.FOG -> R.string.road_event_fog + RoadEventType.ICE -> R.string.road_event_ice + RoadEventType.ANIMAL -> R.string.road_event_animal + RoadEventType.OTHER -> R.string.road_event_other + } + +/** + * Self-contained card for a Roadstr road event report (kind 1315). + * + * A map hero pinned at the event's coordinates, with a floating category pill + * (colored per [RoadEventType.color]) and the optional free-text comment below. + * Identical in the feed and the opened thread view, so it takes no `makeItShort` + * flag — mirroring [RenderBirdex]. + */ +@Composable +fun RenderRoadEventReport(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + + val type = remember(noteEvent) { noteEvent.roadEventType() } + val comment = remember(noteEvent) { noteEvent.content.trim() } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } + + val color = type?.color() ?: MaterialTheme.colorScheme.outline + val emoji = type?.emoji() ?: "📍" + val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown) + + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = label, + point = point, + pinAlpha = freshness, + comment = comment, + ) +} + +/** + * Self-contained card for a Roadstr confirmation/denial (kind 1316). + * + * Same hero/pill layout as a report, with a green ✅ "Still there" or red ❌ + * "No longer there" pill and a one-line description of the action. + */ +@Composable +fun RenderRoadEventConfirmation(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return + + val status = remember(noteEvent) { noteEvent.status() } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } + + val denied = status == RoadEventStatus.NO_LONGER_THERE + val emoji = if (denied) "❌" else "✅" + val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) + + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed), + point = point, + subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report), + ) +} + +/** + * Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a + * floating [CategoryPill] overlaid on its top-start, and an optional + * [subtitle] + [comment] block below. When [point] is null the pill stands on + * its own so the card still reads. + */ +@Composable +private fun RoadEventCard( + chipColor: Color, + chipEmoji: String, + chipLabel: String, + point: Pair?, + pinAlpha: Float = 1f, + comment: String = "", + subtitle: String? = null, +) { + Column(MaterialTheme.colorScheme.replyModifier) { + if (point != null) { + Box(Modifier.fillMaxWidth()) { + LocationPreviewMap( + latitude = point.first, + longitude = point.second, + pinColor = chipColor, + pinEmoji = chipEmoji, + pinAlpha = pinAlpha, + ) + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) + } + } else { + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.padding(12.dp), + ) + } + + if (subtitle != null || comment.isNotEmpty()) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + if (comment.isNotEmpty()) { + if (subtitle != null) Spacer(Modifier.height(4.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */ +@Composable +private fun CategoryPill( + color: Color, + emoji: String, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = color, + contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, + shape = RoundedCornerShape(50), + shadowElevation = 3.dp, + ) { + Text( + text = "$emoji $label", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** + * Best available coordinate for the card map: the event's explicit `lat`/`lon` + * tags when present, otherwise the center of the finest published geohash. + * Returns null when the event carries no location at all. + */ +private fun RoadEventReportEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun RoadEventConfirmationEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun resolveRoadEventPoint( + latitude: Double?, + longitude: Double?, + geohashes: List, +): Pair? { + if (latitude != null && longitude != null) return latitude to longitude + + val finest = geohashes.maxByOrNull { it.length } ?: return null + val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null + return decoded.centerLat to decoded.centerLon +} + +/** + * Marker opacity by freshness, matching the roadstr clients: full while the + * report has plenty of life left, dimmed to 0.6 once under 25% of its effective + * TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the + * type (and therefore the TTL) is unknown. + */ +private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float { + val expiryAt = effectiveExpirationAt() ?: return 1f + val total = expiryAt - createdAt + if (total <= 0L) return 1f + + val fraction = (expiryAt - now).toFloat() / total + return when { + fraction <= 0f -> 0.4f + fraction < 0.25f -> 0.6f + else -> 1f + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index f65be46e81..c16ec99623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -136,12 +136,15 @@ private fun ChannelMetadataScaffold( accountViewModel: AccountViewModel, nav: INav, ) { + val channelRelays by postViewModel.channelRelays.collectAsStateWithLifecycle() + val canPost = postViewModel.canPost && channelRelays.isNotEmpty() + Scaffold( topBar = { if (postViewModel.isNewChannel()) { CreatingTopBar( titleRes = R.string.public_chat, - isActive = postViewModel::canPost, + isActive = { canPost }, onCancel = { postViewModel.clear() nav.popBack() @@ -163,7 +166,7 @@ private fun ChannelMetadataScaffold( } else { SavingTopBar( titleRes = R.string.public_chat, - isActive = postViewModel::canPost, + isActive = { canPost }, onCancel = { postViewModel.clear() nav.popBack() @@ -183,8 +186,6 @@ private fun ChannelMetadataScaffold( } }, ) { pad -> - val feedState by postViewModel.channelRelays.collectAsStateWithLifecycle() - LazyColumn( Modifier .fillMaxSize() @@ -220,7 +221,7 @@ private fun ChannelMetadataScaffold( ) } - itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { _, item -> + itemsIndexed(channelRelays, key = { _, item -> "ChatRelays" + item.relay }) { _, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 6a89bb8fe3..510b42a501 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -304,12 +304,8 @@ open class ChannelNewMessageViewModel : val version = draftTag.current cancel() - if (channel is LiveActivitiesChannel) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { - channelRelays.toList() - } - } else { - accountViewModel.account.signAndSendPrivately(template, channelRelays) + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { + channelRelays.toList() } accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 52f726d396..81b4c333fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -86,7 +88,9 @@ class GeoHashFeedFilter( event is MusicTrackEvent || event is MusicPlaylistEvent || event is PodcastEpisodeEvent || - event is PodcastMetadataEvent + event is PodcastMetadataEvent || + event is RoadEventReportEvent || + event is RoadEventConfirmationEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 0fa9ec6fc3..86e8f49fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -57,6 +59,8 @@ val PostsByGeohashKinds = PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, + RoadEventReportEvent.KIND, + RoadEventConfirmationEvent.KIND, ) fun filterPostsByGeohash( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 55cd5886a1..dd8ce05494 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -36,6 +36,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -60,10 +61,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -94,8 +97,10 @@ import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport import com.vitorpamplona.amethyst.ui.note.DisplayDraft import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal +import com.vitorpamplona.amethyst.ui.note.ExpandMoreIcon import com.vitorpamplona.amethyst.ui.note.Expiration import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -192,6 +197,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -220,6 +227,8 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @@ -250,6 +259,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope @@ -362,8 +373,45 @@ fun RenderThreadFeed( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val levels by viewModel.levelCacheFlow.collectAsStateWithLifecycle() - val position = items.list.indexOfFirst { it.idHex == noteId } + // Hides every descendant of a collapsed reply and counts how many were hidden. The feed is + // ordered depth-first, so a note's descendants are the contiguous items that follow it with a + // strictly deeper reply level. + val visible by + remember(items, levels) { + derivedStateOf { + val full = items.list + if (viewModel.collapsedReplies.isEmpty()) { + VisibleThread(full, emptyMap()) + } else { + val result = ArrayList(full.size) + val hiddenCounts = HashMap() + var hideDeeperThan = Int.MAX_VALUE + var collapsedAncestorId: String? = null + full.forEach { note -> + val level = levels[note] ?: 0 + if (level > hideDeeperThan) { + collapsedAncestorId?.let { hiddenCounts[it] = (hiddenCounts[it] ?: 0) + 1 } + return@forEach + } + + hideDeeperThan = Int.MAX_VALUE + collapsedAncestorId = null + result.add(note) + + if (viewModel.isCollapsed(note.idHex)) { + hideDeeperThan = level + collapsedAncestorId = note.idHex + } + } + VisibleThread(result, hiddenCounts) + } + } + } + + val visibleItems = visible.items + val position = visibleItems.indexOfFirst { it.idHex == noteId } LaunchedEffect(noteId, position) { // hack to allow multiple scrolls to Item while posts on the screen load. @@ -382,7 +430,7 @@ fun RenderThreadFeed( if (position >= 0 && !viewModel.hasDragged.value) { val offset = - if (position > items.list.size - 3) { + if (position > visibleItems.size - 3) { 0 } else { -200 @@ -398,9 +446,15 @@ fun RenderThreadFeed( state = listState, ) { itemsIndexed( - items.list, + visibleItems, key = { _, item -> item.idHex }, - contentType = { index, _ -> if (index == 0) "master" else "reply" }, + contentType = { index, item -> + when { + index == 0 -> "master" + viewModel.isCollapsed(item.idHex) -> "collapsed" + else -> "reply" + } + }, ) { index, item -> val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0) @@ -426,6 +480,15 @@ fun RenderThreadFeed( nav = nav, ) } + } else if (viewModel.isCollapsed(item.idHex)) { + CollapsedNoteCompose( + baseNote = item, + modifier = modifier, + hiddenReplyCount = visible.hiddenCounts[item.idHex] ?: 0, + onExpand = { viewModel.toggleCollapsed(item.idHex) }, + accountViewModel = accountViewModel, + nav = nav, + ) } else { val selectedNoteColor = MaterialTheme.colorScheme.selectedNote val background = @@ -433,6 +496,8 @@ fun RenderThreadFeed( if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null } + val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } } + NoteCompose( baseNote = item, modifier = modifier, @@ -442,6 +507,7 @@ fun RenderThreadFeed( parentBackgroundColor = background, accountViewModel = accountViewModel, nav = nav, + onClick = onCollapse, ) } @@ -452,6 +518,78 @@ fun RenderThreadFeed( } } +/** + * Holds the thread items currently visible after collapsing, plus, for each collapsed reply id, + * the number of descendant replies that were hidden underneath it. + */ +private class VisibleThread( + val items: List, + val hiddenCounts: Map, +) + +/** + * Compact rendering of a thread reply the user has collapsed: avatar, author name, and the + * first two lines of its content. The right side shows how many replies are hidden underneath it + * and an ExpandMore indicator. Tapping anywhere on the row expands the note (and reveals its + * hidden children) again. + */ +@Composable +private fun CollapsedNoteCompose( + baseNote: Note, + modifier: Modifier, + hiddenReplyCount: Int, + onExpand: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchNoteEvent(baseNote, accountViewModel, nav) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onExpand) + // Match NoteComposeLayout: 12dp leading/trailing padding, 10dp top. + .padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NoteAuthorPicture(baseNote = baseNote, size = Size40dp, accountViewModel = accountViewModel, nav = nav) + + // 10dp gap between the avatar and the name, same as NoteComposeLayout's authorContentGap. + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + NoteUsernameDisplay(baseNote, accountViewModel = accountViewModel) + + LoadDecryptedContent(baseNote, accountViewModel) { body -> + Text( + text = body, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Spacer(modifier = StdHorzSpacer) + + // The label carries an explicit '\n' so the count and the word always stack on two + // lines (e.g. "+1\nreply"), keeping the horizontal footprint small and consistent. + // Always shown — even "+0 replies" — so every collapsed card looks the same. + Text( + text = pluralStringResource(R.plurals.thread_collapsed_reply_count, hiddenReplyCount, hiddenReplyCount), + color = MaterialTheme.colorScheme.placeholderText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + ) + + Spacer(modifier = StdHorzSpacer) + + ExpandMoreIcon(modifier = Modifier.size(Size20dp), contentDescriptor = R.string.expand) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteMaster( @@ -814,6 +952,10 @@ private fun FullBleedNoteCompose( RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav) } else if (noteEvent is BirdexEvent) { RenderBirdex(baseNote) + } else if (noteEvent is RoadEventReportEvent) { + RenderRoadEventReport(baseNote) + } else if (noteEvent is RoadEventConfirmationEvent) { + RenderRoadEventConfirmation(baseNote) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is RelayDiscoveryEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt new file mode 100644 index 0000000000..ace49ffcaf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt @@ -0,0 +1,104 @@ +/* + * 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 com.vitorpamplona.amethyst.ui.tor + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +/** + * Pure, file- and JNI-free parsers over Arti's persisted guard sample + * (`/arti/state/state/guards.json`). + * + * Both recovery heuristics in the Tor stack read the same on-disk file, so the + * decisions live here where they can be unit-tested against captured fixtures + * (see `ArtiGuardStateTest`). [TorService] does the file IO and delegates the + * parsing; [TorManager] consumes [hasConfirmedGuard] to seed its + * `hasEverBootstrapped` flag across process restarts. + * + * The file shape is a map of guard-set selection name (e.g. `"default"`) to an + * object with a `"guards"` array. Each guard entry carries: + * - `disabled`: non-null once Arti permanently retires the guard (e.g. + * `TooManyIndeterminateFailures` on a flaky network). + * - `unlisted_since`: non-null once the guard drops out of the consensus. + * - `confirmed_at`: non-null once the guard has actually been used to build a + * circuit — i.e. a real bootstrap reached the guard-confirmation stage. + */ +object ArtiGuardState { + private val mapper = jacksonObjectMapper() + + /** Convenience for tests/callers holding the raw file text. */ + fun parse(json: String): JsonNode = mapper.readTree(json) + + /** + * True when at least one non-empty guard selection has *zero* usable guards — + * every guard permanently `disabled` or dropped from the consensus + * (`unlisted_since`). This is the AllGuardsDown wedge: Arti can neither build + * circuits nor replenish the sample (it's full of unusable entries), so it + * stays broken across restarts until the on-disk state is wiped. A single + * usable guard is enough to recover, so this only trips on a total wipeout. + */ + fun hasNoUsableGuards(root: JsonNode): Boolean { + var wedged = false + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray && guards.size() > 0) { + val usable = guards.count { !it.isDisabled() && !it.isUnlisted() } + if (usable == 0) wedged = true + } + } + return wedged + } + + /** + * True when the sample contains a guard Arti has *confirmed* (non-null + * `confirmed_at`), even if that guard is now disabled or unlisted. + * + * Confirmation only happens after a guard has successfully built circuits, so + * its presence on disk is durable proof that a real bootstrap completed at + * least once on this install — surviving the process restarts that reset + * [TorManager]'s in-memory `hasEverBootstrapped` flag to false. This lets the + * stuck-Connecting watchdog treat persisted state as stale/poisoned (wipe it) + * rather than as a pristine slow first bootstrap (leave it alone). + */ + fun hasConfirmedGuard(root: JsonNode): Boolean { + var confirmed = false + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray) { + guards.forEach { guard -> + val at = guard.get("confirmed_at") + if (at != null && !at.isNull) confirmed = true + } + } + } + return confirmed + } + + private fun JsonNode.isDisabled(): Boolean { + val d = get("disabled") + return d != null && !d.isNull + } + + private fun JsonNode.isUnlisted(): Boolean { + val u = get("unlisted_since") + return u != null && !u.isNull + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt index 3ccf01df15..5d36defa88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -37,4 +37,13 @@ interface TorBackend { suspend fun reset() suspend fun resetWithCleanState() + + /** + * True when on-disk state proves Tor bootstrapped successfully on this + * install before. Lets [TorManager] seed `hasEverBootstrapped` across process + * restarts so the stuck-Connecting watchdog wipes stale/poisoned state rather + * than waiting it out as a first bootstrap. Implementations do file IO, so + * this is a `suspend` call. + */ + suspend fun hasBootstrappedBefore(): Boolean } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 46a491b3f3..f55ccf315c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -93,10 +93,27 @@ class TorManager( * bootstrap there is no stale state to wipe, and wiping just forces an unnecessary * re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game * for the recovery to wipe. + * + * Also seeded at startup from [TorBackend.hasBootstrappedBefore]: the in-memory flag resets + * every process, but Arti's persisted guard sample does not. If it already holds a confirmed + * guard, Tor bootstrapped here before, so a stuck Connecting span means the persisted state + * is stale/poisoned and the watchdog should wipe it. Without this seed a fresh process would + * mistake poisoned guards for a pristine first bootstrap and only ever gentle-reset (keeping + * the poison), looping forever — the exact "can't connect to Tor across restarts" failure. */ @Volatile private var hasEverBootstrapped: Boolean = false init { + // Seed hasEverBootstrapped from persisted on-disk evidence before the watchdog can fire + // (well under SELF_HEAL_AFTER_MS), so a stuck bootstrap on a previously-working install + // wipes its stale guard state instead of nursing it. + scope.launch(ioDispatcher) { + if (service.hasBootstrappedBefore()) { + hasEverBootstrapped = true + Log.d("TorManager") { "Seeded hasEverBootstrapped from persisted confirmed guard" } + } + } + scope.launch(ioDispatcher) { lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs() } @@ -296,13 +313,20 @@ class TorManager( * the per-relay success/failure outcome and the Tor-routing of each url) detects the * all-failing condition and pokes us here — analogous to [onNetworkChange]. * - * Recovery mirrors the post-Active stuck-Connecting path: drop the client and wipe - * `arti/state/` (we *did* bootstrap, so a fully-dead exit set behind a healthy-looking - * guards.json points at a bad persisted guard/circuit sample worth rebuilding), then bump - * [resetEpoch] so the status combine re-enters the INTERNAL branch and runs a full - * re-init. Shares [lastSelfHealAtMs]/[SELF_HEAL_COOLDOWN_MS] with the Connecting watchdog - * so the two can't thrash — at most one self-heal per cooldown window. If circuits are - * still dead after the reset, the cooldown suppresses further resets and the 60s + * The failure is exit-side, not entry-side: the dead circuits' *exits* can't reach the + * relays (`ExitTimeout` / `RESOLVEFAILED`), while the guards (entry) and the cached + * consensus are fine. So recovery is a **warm** [TorBackend.reset]: drop the in-process + * client so the next start rebuilds the circuit pool from scratch — a fresh exit draw — + * but keep `arti/state/` (the guards were never the problem) and the consensus cache, so + * the re-bootstrap is a ~5s warm restart, not a ~60s cold consensus re-download. We + * deliberately do NOT [TorBackend.resetWithCleanState] here: exits aren't persisted, so + * wiping guards + cache can't improve exit selection, and the 60s cold bootstrap it forces + * is exactly the blackout that strands users on the [connectionFailure] dialog. Then bump + * [resetEpoch] so the status combine re-enters the INTERNAL branch and re-inits. + * + * Shares [lastSelfHealAtMs]/[SELF_HEAL_COOLDOWN_MS] with the Connecting watchdog so the + * two can't thrash — at most one self-heal per cooldown window. If the fresh circuits are + * still dead after the rotation, the cooldown suppresses further resets and the 60s * [connectionFailure] dialog still offers the user the bypass. */ fun onTorCircuitsDead() { @@ -311,9 +335,9 @@ class TorManager( val now = nowMs() if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return lastSelfHealAtMs = now - Log.w("TorManager") { "Tor Active but all circuits failing — self-healing (drop client + wipe state)" } + Log.w("TorManager") { "Tor Active but all circuits failing — self-healing (drop client to rotate exits, keep state)" } scope.launch(ioDispatcher) { - service.resetWithCleanState() + service.reset() resetEpoch.update { it + 1 } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 51d18a9b4e..bf3f7a245d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -99,9 +100,21 @@ class TorService( */ private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json") + /** Reads and parses [guardsFile], or null if it is absent/unreadable. */ + private fun readGuardsTree(): JsonNode? { + val file = guardsFile() + if (!file.exists()) return null + return try { + jacksonObjectMapper().readTree(file) + } catch (e: Exception) { + Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } + null + } + } + /** * Detects the wedged-guard-sample state behind the long-standing "can't - * connect to Tor" bug. + * connect to Tor" bug (see [ArtiGuardState.hasNoUsableGuards]). * * On a flaky network, Arti records circuit failures past the first hop as * "indeterminate" (it can't tell whether the guard or a later hop was at @@ -116,43 +129,20 @@ class TorService( * `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still * "succeeds" (it reads cached directory data), so none of the init-failure * self-heal paths ever fire and Tor is stuck across restarts. - * - * A single usable guard is enough to keep building circuits, so we only - * recover at the last resort: when a non-empty guard set has *zero* usable - * guards. A guard is unusable on disk if it has been permanently - * `disabled` or dropped from the consensus (`unlisted_since` set); - * reachability is in-memory only and not persisted, so it can't be checked - * here. Returns true when at least one non-empty selection has no usable - * guard left. */ - private fun noUsableGuards(): Boolean { - val file = guardsFile() - if (!file.exists()) return false + private fun noUsableGuards(): Boolean = readGuardsTree()?.let { ArtiGuardState.hasNoUsableGuards(it) } ?: false - return try { - val root = jacksonObjectMapper().readTree(file) - var wedged = false - // Each top-level field is a guard-set selection (e.g. "default"). - root.forEach { selection -> - val guards = selection.get("guards") ?: return@forEach - if (guards.isArray && guards.size() > 0) { - val usable = - guards.count { guard -> - val disabled = guard.get("disabled") - val unlisted = guard.get("unlisted_since") - val isDisabled = disabled != null && !disabled.isNull - val isUnlisted = unlisted != null && !unlisted.isNull - !isDisabled && !isUnlisted - } - if (usable == 0) wedged = true - } - } - wedged - } catch (e: Exception) { - Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } - false + /** + * True when the persisted guard sample proves Tor bootstrapped successfully + * on this install before (a confirmed guard on disk; see + * [ArtiGuardState.hasConfirmedGuard]). [TorManager] seeds its in-memory + * `hasEverBootstrapped` from this so a stuck bootstrap on a fresh process + * wipes stale/poisoned state instead of nursing it as a first bootstrap. + */ + override suspend fun hasBootstrappedBefore(): Boolean = + withContext(Dispatchers.IO) { + readGuardsTree()?.let { ArtiGuardState.hasConfirmedGuard(it) } ?: false } - } /** * Clears all Arti persistent data (state + cache). Used as a last resort diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 03ced34725..0ad76e64a5 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -674,6 +674,7 @@ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index cdad3095ff..8c09ccf88c 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -586,6 +586,7 @@ + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3d2e2564e7..a6c906453f 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1341,7 +1341,6 @@ Pověření Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase Amethyst oznámení aktivní - Připojeno k %1$d inbox relayím Připojování k inbox relayím\u2026 Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. @@ -3108,6 +3107,7 @@ %1$s +%2$d dalších %1$s +%2$d dalších + Cílová částka (sats) 100000 Popište svůj cíl diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 688d63940a..bcccdb01e7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1325,7 +1325,6 @@ anz der Bedingungen ist erforderlich Anmeldeinformationen Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen Amethyst-Benachrichtigungen aktiv - Mit %1$d Inbox-Relays verbunden Verbinde mit Inbox-Relays\u2026 Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. @@ -3046,6 +3045,7 @@ anz der Bedingungen ist erforderlich %1$s +%2$d weitere %1$s +%2$d weitere + Zielbetrag (Sats) 100000 Beschreibe dein Ziel diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 960d59d09b..05b640d73c 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -528,6 +528,7 @@ + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index b4bd4f4f54..1c31a607e6 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -21,6 +21,7 @@ + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 5dd57a7b36..d0e8756d1d 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -456,6 +456,7 @@ + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 577551f317..916aa2a0d6 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1603,6 +1603,7 @@ + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index ddd0c302f2..d64565c149 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1578,6 +1578,7 @@ + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 216519ec36..8ab8c1c55b 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1578,6 +1578,7 @@ + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 5e69e9d85a..fe33baf38b 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -961,6 +961,7 @@ + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 712f1c1942..469925eb50 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -583,6 +583,7 @@ + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 56ab08bc47..8e2aa46148 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1451,6 +1451,7 @@ + diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 2d3a1259c1..91f096889c 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1311,7 +1311,6 @@ योग्यता प्रमाण आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए अमेथिस्ट सूचनाएँ सक्रिय - संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। @@ -3033,6 +3032,7 @@ %1$s सं॰ %2$d अधिक %1$s सं॰ %2$d अधिक + उद्देश्य मात्रा (साट्स) १००००० आपके उद्देश्य का विवरण करें diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 08b1b94ec7..d51fc29627 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1327,7 +1327,6 @@ Hitelesítési adatok Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében Amethyst értesítések aktíválva - Kapcsolódva %1$d beérkező üzenetátjátszóhoz Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. @@ -3049,6 +3048,7 @@ %1$s +%2$d további %1$s +%2$d további + Célösszeg (satoshiban) 100 000 Cél leírása, bemutatása diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index fd44be4fb9..cef6471135 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -525,6 +525,7 @@ Seharusnya %3$s + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index df58039132..23b91575ab 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -446,6 +446,7 @@ + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index baa22166db..54d99e747f 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -403,6 +403,7 @@ + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index bec38555a7..12a23a746f 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -185,6 +185,7 @@ + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 6f76a05408..495b4a3396 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -8,6 +8,14 @@ Toch tonen Dit bericht is verborgen omdat het verborgen gebruikers of woorden bevat Bericht gemarkeerd als ongepast door + + Dit bericht bevat meer dan %1$d hashtag + Dit bericht bevat meer dan %1$d hashtags + + + %1$d asset gebundeld + %1$d assets gebundeld + Bericht niet gevonden 👀 Kanaalafbeelding @@ -95,7 +103,63 @@ Uitloggen Meer Lightning-invoice + Verlopen + CLINK-aanbod + Aan + Invoice aanvragen… + De debit-service heeft de betaling niet voltooid. + Betaling bevestigen + Betaal %1$s via %2$s? + Betaal deze invoice via %1$s? + Bedrag (sats) + Voer een geldig bedrag in voor dit aanbod. + Toegestane range: %1$s–%2$s sats + CLINK-aanbod (noffer) + Uitgavenbudget + Bedrag (sats) + Aanvragen + Budget goedgekeurd + Eenmalig + Dagelijks + Wekelijks + Maandelijks + Alleen betalen + Betaal en zap vanuit een wallet die je account vooraf heeft geautoriseerd. Alleen uitgeven — geen saldo of geschiedenis. + Ongeldige CLINK debit-pointer. Verwacht een ndebit1… string. + Plak ndebit-pointer Betalen + Betaling versturen + Ontvangen op + Betalen vanuit + Andere wallet-app + CLINK-aanbod + Bedrag + Prijs vastgelegd door het aanbod + Zap-ontvangstbewijs + Directe Lightning-betaling — geen zap-ontvangstbewijs wordt gepubliceerd op Nostr. + Een on-chain zap-ontvangstbewijs wordt gepubliceerd op Nostr zodat de ontvanger de betaling kan vinden. + Betaalt het aangekondigde Bitcoin-adres van het profiel (%1$s) direct vanuit je on-chain wallet — geen zap-ontvangstbewijs wordt gepubliceerd. + Het nutzap-event levert de ecash zelf, dus het wordt altijd gepubliceerd. + Invoice aanvragen… + Invoice aanvragen via Nostr… + Betalen via %1$s… + Ecash vergrendelen en versturen… + Transactie bouwen en uitzenden… + Invoice overhandigd aan je wallet-app om de betaling te voltooien. + Betaling verzonden! + Betaling mislukt + Probeer opnieuw + Bericht de ontvanger over deze betaling + Klaar + Betaal %1$s sats + Betalen + Dit profiel heeft geen betalingsmethodes die Amethyst direct kan betalen. + On-chain zaps vereisen minstens %1$s sats + Onvoldoende saldo in een mint die deze ontvanger accepteert. Vul eerst je Cashu-wallet aan. + Besteedbaar voor deze ontvanger: %1$s sats + Netwerkkosten + je wallet-app + Transactie: %1$s Lightning-tips Bericht aan ontvanger Hartelijk bedankt! @@ -123,6 +187,7 @@ Schrijf een bericht… Plaatsen Opslaan + Verzenden Aanmaken Hernoemen Annuleren @@ -165,6 +230,9 @@ Afbeelding opgeslagen in galerij Video download gestart… Media download gestart… + Vastzetten bovenaan + Losmaken + Vastgezet bovenaan Afbeelding opslaan mislukt Video opgeslagen in galerij Video opslaan mislukt @@ -194,6 +262,7 @@ Verandert de toonhoogte van je stem. Let op: eenvoudige toonhoogteveranderingen kunnen mogelijk worden teruggedraaid door vastberaden luisteraars. Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen "hier reageren…" + In deze chat Kopieert note-ID naar klembord om te delen Kopieer kanaal-ID (note) naar klembord Past kanaal-metadata aan @@ -254,8 +323,12 @@ Nieuwe sleutel genereren Feed laden… Account laden… + versleuteld + Op zoek naar het originele bericht… + Kon dit bericht niet vinden + Alle relays doorzocht · tik om te zien "Fout bij laden reacties:" Opnieuw proberen Nog geen meldingen. @@ -348,6 +421,10 @@ Selecteren Browserlink delen Delen + Delen als afbeelding + Delen als afbeeldings-URL + Voorbeeld genereren… + Gedeeld via Amethyst Auteur-ID Note-ID Tekst kopiëren @@ -536,7 +613,65 @@ Badges · %1$d Kies welke van de ontvangen badges op je profiel verschijnen. Je hebt nog geen badges ontvangen. + Aanbevolen de Nostr-apps die je gebruikt zodat anderen ze kunnen ontdekken. + Aanbevolen apps + Kies welke Nostr-apps je publiekelijk aanbeveelt. Aanbevelingen verschijnen op je profiel en helpen anderen apps te ontdekken voor inhoud die deze client niet kan openen. + Nog geen apps gevonden. Apps verschijnen hier zodra ze ontdekt worden op je relays. + Naamloze app + Geeft niet aan welke inhoud het ondersteunt + Aanbevelen + Aanbevolen + Behandelt + door + Beschikbaar op Afbeeldingen + Nieuwe workout + Titel + Duur + Afstand + Tempo + Snelheid + Hoogtewinst + Hoogteverlies + Calorieën + Stappen + Hartslag + Maximale hartslag + Herhalingen + Gewicht + Notities + Uren + Minuten + Seconden + Deel je workouts + Laat Amethyst voltooide workouts lezen uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en stel voor om ze als bericht te delen. + Verbinden + Nieuwe workout gedetecteerd + Delen + Sluiten + Van Health Connect + Hardlopen + Wandelen + Fietsen + Wandelen + Zwemmen + Roeien + Krachttraining + Meditatie + Dieet + Vasten + Bron: %1$s + Downloaden + Over + Platformen + Onderwerpen + Laatste release + Reacties + Wees de eerste die reageert. + door + Toon oudere releases + Verberg oudere releases + Kalenders Shorts Publieke chats Volgpakketten @@ -696,12 +831,52 @@ Nog geen nest-servers. Voeg er een toe of kies een aanbevolen server. Video\'s Artikelen + Muziek + Afspeellijsten + Afleveringen + Afleveringen bekijken + Afleveringen + Nog geen afleveringen gevonden + + %1$d aflevering + %1$d afleveringen + + Nieuw muziekstuk + Nieuwe afspeellijst + Titel + Titel van het nummer + Artiest + Naam artiest + Audio-URL + Cover-afbeelding URL (optioneel) + Album (optioneel) + Duur in seconden (optioneel) + Songteksten of credits (optioneel) + Dit nummer verwijderen + Een NIP-09-verwijdering publiceren voor dit muziekstuk? + Toevoegen aan afspeellijst + Toevoegen aan afspeellijst + Je hebt nog geen afspeellijsten. Gebruik het veld hierboven om er een aan te maken. + Alle afspeellijsten beheren + Nieuwe afspeellijst + Naam van de afspeellijst + Aanmaken + (naamloze afspeellijst) + + %1$d nummer + %1$d nummers + Privé-bladwijzers Publieke bladwijzers Toevoegen aan privé-bladwijzers Toevoegen aan publieke bladwijzers Verwijderen uit privé-bladwijzers Verwijderen uit publieke bladwijzers + Hashtag toevoegen + Hashtag toevoegen + Publiek labelen met een hashtag (NIP-32 label). Mensen die je volgen zien het in de feed van die hashtag. + Toevoegen + toegevoegd door Vastgezette notities Je vastgezette notities Vastmaken aan profiel @@ -748,6 +923,15 @@ Dit artikel bookmarken is een publieke bladwijzer hier is een privébladwijzer hier + Privé — alleen zichtbaar voor getagde deelnemers + Privé maken: gift-wrap de notitie alleen voor de genoemde gebruikers + Publiek maken + Reacties op een privénotitie blijven altijd privé + Zichtbaar voor + Nog geen ontvangers: alleen jij kunt deze notitie zien. Voeg mensen toe om te delen. + Toevoegen + Gebruiker verwijderen uit meldingen + Zoek en voeg een gebruiker toe om te melden is geen bladwijzer hier Bladwijzer uit lijst verwijderen Bladwijzer toevoegen aan lijst @@ -769,6 +953,29 @@ Geavanceerd: handmatig verbindingsgegevens invoeren Snelle zap-bedragen Worden getoond bij het indrukken van de zap-knop. Tik op een bedrag om te verwijderen. Leeg laten opent steeds het invoerscherm. + In plaats daarvan on-chain versturen + Mint aanvullen + Aanvullingsbedrag + Mint om aan te vullen + Gelden van + Betalen met Lightning + Nieuwe ecash minten vanuit je Lightning-wallet + Niet genoeg hier + %1$s sats beschikbaar + Aanvullen & zap versturen + Zap versturen + Wachten op betaling… + Probeer opnieuw + Vul %1$s sats aan bij %2$s en zap %3$s %4$s sats · kosten ≈ %5$s sats + Zap %1$s %2$s sats vanuit %3$s + de ontvanger + heeft %1$s sat meer nodig + aangevuld + Invoice kopiëren + Mint aanvullen + Deze mint aanvullen + Bedrag om toe te voegen + Aanvullen Zap-privacy Bepaalt hoe jouw identiteit zichtbaar is bij het versturen van een zap. Wallet verbinden @@ -859,6 +1066,7 @@ Adres Betalingsdoel verwijderen Geen app gevonden om deze betaling te verwerken. Installeer een compatibele wallet. + Geen app geïnstalleerd om %1$s-betalingen te verwerken. Installeer een compatibele wallet. Kan betaling niet openen Niet gestart Comprimeren @@ -886,6 +1094,7 @@ Volgt via proxy In de buurt Globaal + Gecureerd Schaken Van mij Gedempte lijst @@ -1069,7 +1278,6 @@ Credential Houdt verbindingen met je inbox-relays actief voor realtime meldingen Amethyst-meldingen actief - Verbonden met %1$d inbox-relays Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. @@ -1606,7 +1814,74 @@ Ongeldige NWC-verbinding-URI Omhoog verplaatsen Omlaag verplaatsen + Onchain-transacties + Geen on-chain adres beschikbaar voor dit account. + Geen chain-backend geconfigureerd. + In behandeling + Publiek + Deze wallet is publiek + Je Taproot-adres is afgeleid van je Nostr-publieke sleutel, dus iedereen die je npub kent kan het saldo en de transactiegeschiedenis van deze wallet op de blockchain zien.\n\nHoud je privacy in stand door deze wallet te financieren en leeg te halen via niet-private accounts, zoals exchanges. Meng deze fondsen nooit met je cold wallets en behandel ze als geld dat je kunt verliezen, aangezien iedereen met je nsec het kan uitgeven. + Begrepen + Adres kopiëren + On-chain zap versturen + Sluiten + Aan + Berichtauteur + Ontvanger + naam, NIP-05 of npub + Ontvanger wijzigen + Bedrag + Minimum on-chain zap is %1$s sats — kleinere bedragen gaan verloren aan miner-kosten. Gebruik een Lightning-zap in plaats daarvan. + Opmerking (optioneel) + Prioriteit + Langzaam + Normaal + Snel + ~1 uur + Kostenramingen laden… + Verstuur %1$s sats + + Verstuur %1$s sats, %2$d-weg + Verstuur %1$s sats, %2$d-wegen + + + Gebruik de %1$d-weg zap-split van dit bericht + Gebruik de %1$d-weg zap-split van dit bericht + + + Splits onder %1$d ontvanger + Splits onder %1$d ontvangers + + + %1$d Lightning-adres-alleen-ontvanger overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden. + %1$d Lightning-adres-alleen-ontvangers overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden. + + Niet splitsen — betaal één ontvanger in plaats daarvan + Transactie bouwen, ondertekenen en uitzenden… + On-chain zap verstuurd + Transactie + Kosten + Wisselgeld + Klaar + Mislukt bij: %1$s + Betaling uitgezonden (tx %1$s) maar het ontvangstbewijs is niet gepubliceerd. + saldo laden + transactie bouwen + ondertekenen + uitzenden + ontvangstbewijs publiceren + Bitcoin chain-backend is niet geconfigureerd + Kon je Bitcoin-saldo niet laden + Kon de transactie niet bouwen + Een aandeel van een ontvanger is onder dust + Kon de transactie niet ondertekenen + Kon de transactie niet uitzenden + Betaling verstuurd, maar het zap-ontvangstbewijs kon niet worden gepubliceerd + + Betaling verstuurd (%1$d van %2$d ontvangstbewijs gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd + Betaling verstuurd (%1$d van %2$d ontvangstbewijzen gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd + Beveiligingsfilters Volgers importeren Nieuw bericht @@ -1619,6 +1894,119 @@ Nieuwe Zap-poll Nieuwe gewone poll Nieuwe afbeelding + Nieuw agenda-evenement + Agenda-evenement bewerken + Vergrendeld tijdens bewerken — wijzigen zou een nieuw evenement creëren. + Nieuwe agenda + Agenda bewerken + Evenementen in deze agenda (%1$d) + Je hebt nog geen agenda-evenementen aangemaakt. + Maand + Dag + Aankomend + Verleden + Nog geen aankomende of verleden agenda-evenementen van je geselecteerde feed. + Nog geen agenda-collecties. + Je agenda is leeg + Evenementen gedeeld door mensen die je volgt verschijnen hier. Tik op de + knop om er zelf een aan te maken. + Nog geen collecties + Groepeer evenementen — een serie meetups, een conferentie-track, de roadmap van je team. Tik op + om er een aan te maken. + Niets gepland + Geen evenementen op deze dag. Tik op + om er een toe te voegen. + Niets deze week + Geen evenementen vallen in deze week. + Titel + Samenvatting + Locatie + Afbeeldings-URL + Hele dag evenement + Begint + Eindigt + Hashtags (komma-gescheiden) + Datum kiezen + Tijd kiezen + Titel en starttijd zijn verplicht. + Eind moet na start zijn. + Vorige maand + Volgende maand + Vorige week + Volgende week + Vorige dag + Volgende dag + Geen evenementen op deze dag + Geen evenementen + Doorgaan + Dag %1$d van %2$d + Toevoegen aan telefoon-agenda + Spring naar vandaag + + %1$s, %2$d evenement + %1$s, %2$d evenementen + + %1$s, geen evenementen + vandaag + geselecteerd + Nieuw agenda-evenement aanmaken + Nieuwe agenda-collectie aanmaken + Aanmaakopties tonen + (naamloos) + Hele dag + ✓ Ga + \? Misschien + ✗ Kan niet + Titel + Omschrijving + Een titel is verplicht. + + %1$d evenement + %1$d evenementen + + Nog geen evenementen in deze agenda. + Ga + Misschien + Kan niet + Nog geen RSVPs. + Deelnemers (%1$d) + In agenda\'s (%1$d) + Nog niet onderdeel van een agenda. + Evenement laden… + Nu aan de gang + %1$s · eindigt %2$s + Agenda-evenement delen + Exporteren naar agenda (.ics) + Agenda-herinneringen + Melding wanneer een evenement waar je naartoe gaat bijna begint. + Agenda-evenement + + Begint over %1$d minuut + Begint over %1$d minuten + + Toevoegen aan een van je agenda\'s + Toevoegen aan een agenda + Je hebt nog geen agenda\'s aangemaakt. + Agenda verwijderen + Deze agenda verwijderen? + De agenda-lijst wordt verwijderd. Evenementen erin worden niet verwijderd. + Afbeelding kiezen + Afbeelding uploaden mislukt + De gekozen afbeelding kon niet worden geüpload. Probeer opnieuw of plak een URL. + Deelnemers (%1$d) + Zoek naam, npub of nip-05 + Voer een geldige npub… of 64-karakter hex pubkey in. + Deelnemer verwijderen + Agenda-herinneringen + Herinneringen versturen + Een melding verschijnt wanneer een evenement waar je naartoe gaat bijna begint. + Herinneringstijd + Hoeveel minuten voor het evenement je een melding wilt ontvangen. + Delen als nostr-link + Agenda-link delen + Alle agenda\'s + Toon evenementen van… + Je hebt nog geen agenda\'s aangemaakt. + Openen in kaarten + Link openen + Evenementdetails Nieuwe short video Nieuwe lange video Titel @@ -1642,6 +2030,7 @@ Boost of citaat Vind ik leuk Zap + Wachten op bevestiging Snelle reacties wijzigen Onderste navigatiebalk Sleep om te herordenen. Schakel in/uit om items toe te voegen of te verwijderen. Met nul items wordt de balk verborgen. @@ -1693,6 +2082,15 @@ Video in zwevend venster afspelen (verborgen als niet ondersteund) Casten naar apparaat Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden) + Audio-visualisatie + Kies de animatie die getoond wordt tijdens het afspelen van audionotities. + Uit + Spectrum-balken + Kleur-golven + Radiale ring + Aurora-gloed + Klassieke golfvorm + Statische afbeelding Profielfoto van %1$s Relay %1$s Relay-lijst uitvouwen @@ -2062,6 +2460,12 @@ Gebruikersinstellingen Audio-header Audiospoor + Muziekstuk + Afspeellijst + Podcast-aflevering + Podcast-show + Gemaakt podcasts + Favoriete podcasts Badge-awards Badge-definities Geaccepteerde badge-set @@ -2304,6 +2708,29 @@ %1$d jaar %1$d jaar + + %1$d openstaande invoice + %1$d openstaande invoices + + Tik om door te gaan en de status te controleren. + Doorgaan + + Fondsen bewaard bij %1$d mint die je niet gebruikt + Fondsen bewaard bij %1$d mints die je niet gebruikt + + Deze munten kwamen van een nutzap. Verplaats ze naar een mint die je vertrouwt of trek ze terug naar Lightning om ze veilig te houden. + Niet in je wallet + Verplaatsen + Munten verplaatsen + %2$s sat bij een mint die je niet gebruikt (%1$s). Verplaats ze naar een mint die je vertrouwt. + Verplaatsen naar een mint die je vertrouwt + Verplaatsen + Een kleine Lightning-kost wordt afgetrokken door deze mint, dus je moet mogelijk iets minder dan het volledige saldo sturen. + Voeg eerst een mint toe aan je wallet om munten tussen mints te verplaatsen, of gebruik de opties hieronder. + Terugtrekken via Lightning… + Exporteren als Cashu-token… + Munten verplaatsen… + %1$s sat verplaatst naar %2$s. <%1$s Verbinden Downloaden @@ -2409,6 +2836,17 @@ Dit doel is gesloten %1$s gefinancierd van %2$s sats doel + Eindigt %1$s + On-chain donatie + + Birdex · %1$d soorten + Birdex · %1$d soorten + + + %1$s +%2$d meer + %1$s +%2$d meer + + Doelbedrag (sats) 100000 Beschrijf je doel @@ -2519,6 +2957,8 @@ Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen. Getrackte uitzendingen Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden. + Workouts voorstellen om te delen + Leest voltooide workouts uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en stelt voor om ze als bericht te delen. Toestemming wordt alleen gevraagd wanneer je verbindt. Opstel-instellingen Automatisch concepten aanmaken Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays. @@ -2580,4 +3020,13 @@ Je voldoet niet aan de web-of-trust-vereisten van deze community. Het laatste community-regelsdocument is afgewezen als verouderd. + Nowhere-site + Nowhere-evenement + Nowhere-donatieactie + Nowhere-winkel + Nowhere-petitie + Nowhere-bericht + Nowhere-drop + Nowhere-kunst + Nowhere-forum diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b8fc236ea5..64f3ca72de 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1345,7 +1345,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Uwierzytelnienie Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym Powiadomienia Ametyst Aktywne - Połączono z %1$d transmiterami odbiorczymi Łączenie z transmiterami odbiorczymi\u2026 Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. @@ -3113,6 +3112,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$s +%2$d więcej %1$s +%2$d więcej + Kwota zbiorki (w satoszach) 100000 Opisz cel zbiórki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7cb94e3c62..c551ff1c59 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1320,7 +1320,6 @@ Credencial Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real Notificações do Amethyst ativas - Conectado a %1$d relays de caixa de entrada Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. @@ -3041,6 +3040,7 @@ %1$s +%2$d a mais %1$s +%2$d a mais + Valor da meta (sats) 100000 Descreva sua meta diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 1133b7712e..1b3c9e06cb 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -599,6 +599,7 @@ + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index baf791b6dd..72c2046720 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -457,6 +457,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Izberi Deli povezavo brskalnika Deli + Deli sliko + Deli URL slike + Ustvarjam predogled… + Deljeno z Amethyst Avtorjev ID ID zapiska Kopiraj tekst @@ -679,10 +683,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Trajanje Razdalja Tempo + Hitrost Višinska razlika + Izguba višine Kalorije Koraki Srčni utrip + Max srčni utrip Serije Ponovitve Teža @@ -690,6 +697,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ur Minut Sekund + Deli svoje vadbe + Dovoli aplikaciji Amethyst branje zaključenih vadb iz Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) , da ti predlaga objavo + Poveži + Zaznana nova vadba + Deli + Prekliči + %1$s km + %1$s bpm + %1$s kcal + Iz Health Connect Tek Hoja Kolesarjenje @@ -1342,7 +1359,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Akreditivi Ohranja aktivne povezave z vašimi releji za obvestila v realnem času Amethyst obvestila so aktivna - Povezan z %1$d vhodnimi releji Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. @@ -1625,6 +1641,22 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nalagam… Nalaganje naslovnice in zvoka ter objavljanje. Postopek se bo nadaljeval, tudi če zapustiš ta zaslon. Nalaganje ni uspelo: %1$s + Uredi seznam predvajanja + Uredi seznam predvajanja + Naslov + Opis (neobvezno) + Zapiski (neobvezno) + Naloži prikazno sliko + Izberi kvadratno sliko za naslovnico tega seznama predvajanja. + Skrij seznam predvajanja iz tvojega javnega profila. + Dovoli drugim, da dodajo skladbe v ta seznam predvajanja. + Izbriši ta seznam predvajanja + Objavi zahtevo za izbris NIP-09 za ta seznam predvajanja? + Nalaganje in objava naslovnice. Ta postopek se bo nadaljeval, tudi če zapustite zaslon. + Seznam je prazen. Dodaj skladbe iz menija posamezne pesmi z možnostjo \"Dodaj na seznam predvajanja\". + Premakni skladbo navzgor + Premakni skladbo na dol + Odstrani skladbo iz seznama predvajanja Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo @@ -1944,6 +1976,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kovnice URL kovnice Odstrani kovnico + Dodaj kovnico Zgodovina Uredi denarnico Ključ Nutzap (napredno) @@ -1989,6 +2022,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Uredi podatke o denarnici Kovnice, Nutzap ključ Priporočene kovnice + Javno podpri kovnice, ki jim zaupaš, in prekliči priporočila. Nimaš še priporočenih kovnic. Tapni palec navzgor ob kovnici in jo javno priporoči. Izbriši priporočeno Umakni priporočilo? @@ -1999,6 +2033,25 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Preverjanje kovnic… Ni najdenih dokazil za obnovo. Uspešno obnovljenih %1$s satov skozi %2$s dokazil. + Ustavi prejemanje nutzapov + Umakni svojo objavo vrste 10019, da ti drugi ne morejo več pošiljati nutzapov. Tvoja denarnica in stanje ostaneta nespremenjena. + Ustavim prejemanje nutzapov? + Nadomestili bomo vašo objavo o nutzapih s prazno in zahtevali njen izbris. Uporabniki vam ne bodo več mogli pošiljati nutzapov. To lahko kadar koli ponovno vklopite v urejanju denarnice. Stanje vaše denarnice ostaja nespremenjeno. + Ponovno ustvari ključ za nutzape + Ustvari nov ključ za prejemanje nutzapov. To je redko potrebno – običajno le, če je bil trenutni ključ ogrožen. Nutzapi, ki so že bili poslani na stari ključ, a še niso bili unovčeni, bodo izgubljeni. + Ponovno ustvarim ključ za nutzape? + Ustvarjen in objavljen bo nov ključ P2PK v vrstah 10019 in 17375, pri čemer se obstoječe kovnice ohranijo. Pošiljatelji bodo začeli zaklepati nutzape na novi ključ. Vsi nutzapi, poslani na stari ključ, ki jih še niste unovčili, postanejo nepopravljivo izgubljeni. To je le redko potrebno. + Ponovno ustvari ključ + Uvozi ključ za nutzape + Zamenjaj ključ za nutzape s prilepljenim – na primer za obnovitev denarnice iz varnostne kopije. To je le redko potrebno. Nutzapi, zaklenjeni na stari ključ, ne bodo več unovčljivi. + Uvozim ključ za nutzape? + Prilepite zasebni ključ P2PK (hex) za prejemanje nutzapov. Objavili bomo novo verzijo vaših sporočil vrst 10019 in 17375 z uporabo tega ključa, pri čemer se obstoječe kovnice ohranijo. Nutzapi, zaklenjeni na vaš trenutni ključ, ne bodo več unovčljivi, razen če se ključa ujemata. To je le redko potrebno. + P\"PK zasebni ključ (hex) + Uvozi ključ + Izbriši denarnico + Odstrani denarnico in ustavi nutzape. Morebitno preostalo stanje lahko postane nepopravljivo izgubljeno. + Izbrišem to denarnico? + To zahteva izbris vaše denarnice (vrsta 17375) in podatkov o nutzapih (vrsta 10019). Vaši dokazi o ecash dokazila (ecash proofs) se v kovnicah ne izbrišejo, vendar boste z izgubo ključa denarnice izgubili dostop do preostalega stanja in neunovčenih nutzapov. Pred nadaljevanjem obvezno porabite ali prenesite svoja sredstva. Tega dejanja ni mogoče razveljaviti. Plačaj račun Pridobi tečaj Zahtevam tečaj od kovnice… @@ -2017,6 +2070,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Končano ahtevam račun od kovnice… Čakanje na plačilo računa… + Preverjam kovnico… Izdajanje dokazil… Plačujem prek kovnice… Zamenjava dokazil… @@ -2994,6 +3048,25 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Tapni za nadaljevanje in preverjanje stanja. Nadaljuj + + Sredstva v %1$d kovnici, ki je ne uporabljate + Sredstva v %1$d kovnicah, ki ju ne uporabljate + Sredstva v %1$d kovnicah, ki jih ne uporabljate + Sredstva v %1$d kovnicah, ki jih ne uporabljate + + Ti kovanci so prispeli prek nutzapa. Prenesite jih v kovnico, ki ji zaupate, ali jih umaknite na omrežje Lightning, da ostanejo na varnem. + Ni v vaši denarnici + Premakni + Premakni kovance ven + %2$s satov v kovnici, ki je ne uporabljate (%1$s). Prenesite jih nekam, kjer so varni. + Premakni v kovnico, ki ji zaupate + Premakni + Ta kovnica zaračuna manjši provizijo za Lightning, zato boste morda morali poslati nekoliko manj od celotnega stanja. + Najprej v denarnico dodajte kovnico za prenos kovancev med njimi, ali uporabite spodnje možnosti. + Izplačaj prek Lightninga… + Izvozi kot žeton Cashu… + Premikam kovance… + %1$s satov premaknjenih v %2$s <%1$s Povezujem Prenašam @@ -3113,6 +3186,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, %1$s in še %2$d druge %1$s in še %2$d drugih + Ciljni znesek (sat) 100000 Opiši svoj cilj @@ -3223,6 +3297,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Uporablja model umetne inteligence v napravi za predloge popravkov besedila in spremembe tona. Sledeni prenosi Pri pošiljanju dogodkov uporabi sledilnik oddajanja. V živo prikazuje napredek in stanje posameznih relejev med oddajanjem. + Predlagaj vadbo za deljenje + Prebere opravljene vadbe iz aplikacije Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) in predlaga objavo. Dovoljenje je zahtevano le ob povezavi. Nastavitve urejevalnika Samodejno ustvari osnutke Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje. diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 0ba50ac5be..72caf7fa04 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -47,6 +47,7 @@ + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index fa489d038c..5edc0cd1c5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1320,7 +1320,6 @@ Inloggningsuppgift Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar Amethyst-notifieringar aktiva - Ansluten till %1$d inbox-relän Ansluter till inbox-relän\u2026 Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. @@ -3041,6 +3040,7 @@ %1$s +%2$d till %1$s +%2$d till + Målbelopp (sats) 100000 Beskriv ditt mål diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 6c0fa542f8..fa2ca8e9dd 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -497,6 +497,7 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 243bfcbeb3..3858017cc3 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -383,6 +383,7 @@ + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 32dc7f3cb0..72d67481c0 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -828,6 +828,7 @@ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index b189ae0861..75945f8d35 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -181,6 +181,7 @@ + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 5ac5c43baa..0a6cf5e29e 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -513,6 +513,7 @@ + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 7cebdb3f9a..fa9301804a 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -82,6 +82,7 @@ + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index da8a285876..95b9b7c2a0 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -25,6 +25,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 8b53b43af4..129c3f0e22 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1318,7 +1318,9 @@ 凭证 保持与收件箱中继的连接以便接收实时通知 Amethyst 通知活跃 - 已连接到 %1$d 个收件箱中继 + + 已连接到 %1$d 个收件箱中继 + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 @@ -3071,6 +3073,7 @@ %1$s + 另%2$d + 目标金额 (sats) 100000 描述您的目标 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index f9f1db213b..66a82f66c5 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -253,6 +253,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index aadb21a0f8..fd00368279 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -14,6 +14,7 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 1b8491449f..1a9cb5e175 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -776,6 +776,7 @@ + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c1ee7fc3f..56b204c08e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -18,6 +18,10 @@ %1$d asset bundled %1$d assets bundled + + +%1$d\nreply + +%1$d\nreplies + Event is loading or can\'t be found in your relay list 👀 Channel Image @@ -1438,7 +1442,14 @@ Relay Connection Service Keeps connections to your inbox relays active for real-time notifications Amethyst Notifications Active - Connected to %1$d inbox relays + + Connected to %1$d inbox relay + Connected to %1$d inbox relays + + + Connected to %1$d relay + Connected to %1$d relays + Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. @@ -3434,6 +3445,25 @@ %1$s +%2$d more %1$s +%2$d more + + Police + Speed camera + Traffic jam + Accident + Road closure + Construction + Hazard + Road condition + Pothole + Fog + Ice + Animal on the road + Road event + Road event + Still there + No longer there + Confirms a road report + Reports a road event cleared Goal amount (sats) 100000 Describe your goal diff --git a/amethyst/src/main/res/xml/locales_config.xml b/amethyst/src/main/res/xml/locales_config.xml index 217b6f15d2..9b0857e4d4 100644 --- a/amethyst/src/main/res/xml/locales_config.xml +++ b/amethyst/src/main/res/xml/locales_config.xml @@ -60,8 +60,7 @@ - - + diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt index aa8fd6e0af..5f3a00a834 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt @@ -79,16 +79,17 @@ class DisappearingBarNestedScrollTest { } @Test - fun `scrolling content down reveals both bars from a hidden state`() { + fun `scrolling content down reveals both bars from a hidden state, damped`() { val state = state(topLimit = 100f, bottomLimit = 50f) state.topHeightOffset = -100f state.bottomHeightOffset = -50f val connection = nsc(state) + // Reveal is damped by REVEAL_SENSITIVITY (0.5), so a 30px drag only reveals 15px. connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-70f, state.topHeightOffset) - assertEquals(-20f, state.bottomHeightOffset) + assertEquals(-85f, state.topHeightOffset) + assertEquals(-35f, state.bottomHeightOffset) } @Test @@ -99,11 +100,26 @@ class DisappearingBarNestedScrollTest { val connection = nsc(state) // The list consumed 20px of a 40px reveal drag; 20 more was left as overscroll. - // The bars should move by the total 40, not just one of the halves. + // The bars should move by the total 40 (damped to 20 on reveal), not just one half. connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput) - assertEquals(-10f, state.topHeightOffset) - assertEquals(-10f, state.bottomHeightOffset) + assertEquals(-30f, state.topHeightOffset) + assertEquals(-30f, state.bottomHeightOffset) + } + + @Test + fun `revealing is less sensitive than hiding for the same drag distance`() { + // Hiding a 40px drag moves the bars the full 40px... + val hiding = state(topLimit = 100f, bottomLimit = 100f) + nsc(hiding).onPostScroll(Offset(0f, -40f), Offset(0f, 0f), NestedScrollSource.UserInput) + assertEquals(-40f, hiding.topHeightOffset) + + // ...while revealing the same 40px from fully hidden only brings back 20px. + val revealing = state(topLimit = 100f, bottomLimit = 100f) + revealing.topHeightOffset = -100f + revealing.bottomHeightOffset = -100f + nsc(revealing).onPostScroll(Offset(0f, 40f), Offset(0f, 0f), NestedScrollSource.UserInput) + assertEquals(-80f, revealing.topHeightOffset) } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt new file mode 100644 index 0000000000..e244501338 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt @@ -0,0 +1,110 @@ +/* + * 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 com.vitorpamplona.amethyst.ui.tor + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtiGuardStateTest { + private fun load(resource: String): String = + javaClass.getResourceAsStream(resource)?.bufferedReader()?.use { it.readText() } + ?: error("Missing test resource: $resource") + + /** + * Real `guards.json` captured from an emulator stuck in "Connecting": two of the four + * confirmed primary guards were poisoned (`TooManyIndeterminateFailures`), but usable + * guards remained. This is the case that exposed the bug — full AllGuardsDown never trips, + * so only the [ArtiGuardState.hasConfirmedGuard] seed recovers it. + */ + @Test + fun `real device sample - confirmed guard present, not fully wedged`() { + val root = ArtiGuardState.parse(load("/tor/guards-confirmed-with-poisoned.json")) + + assertTrue( + "device sample has confirmed guards → prior bootstrap proven", + ArtiGuardState.hasConfirmedGuard(root), + ) + assertFalse( + "2 disabled of 22 guards is not a total wipeout → not wedged by the AllGuardsDown check", + ArtiGuardState.hasNoUsableGuards(root), + ) + } + + @Test + fun `confirmed guard counts even when disabled or unlisted`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": "2026-06-17T01:36:40Z", + "disabled": { "type": "TooManyIndeterminateFailures" } }, + { "confirmed_at": null, "disabled": null, "unlisted_since": null } + ] } } + """.trimIndent(), + ) + + assertTrue(ArtiGuardState.hasConfirmedGuard(root)) + } + + @Test + fun `fresh sample with no confirmed guard - first bootstrap, nothing to wipe`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": null, "disabled": null, "unlisted_since": null }, + { "confirmed_at": null, "disabled": null, "unlisted_since": null } + ] } } + """.trimIndent(), + ) + + assertFalse(ArtiGuardState.hasConfirmedGuard(root)) + assertFalse(ArtiGuardState.hasNoUsableGuards(root)) + } + + @Test + fun `all guards disabled or unlisted - AllGuardsDown wedge`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": "2026-06-10T15:52:00Z", + "disabled": { "type": "TooManyIndeterminateFailures" } }, + { "confirmed_at": "2026-06-11T15:52:00Z", + "disabled": null, "unlisted_since": "2026-06-12T00:00:00Z" } + ] } } + """.trimIndent(), + ) + + assertTrue("every guard unusable → wedged", ArtiGuardState.hasNoUsableGuards(root)) + // Even when fully wedged, prior confirmation is still proven. + assertTrue(ArtiGuardState.hasConfirmedGuard(root)) + } + + @Test + fun `empty selection is neither wedged nor confirmed`() { + val root = ArtiGuardState.parse("""{ "default": { "guards": [] }, "restricted": { "guards": [] } }""") + + assertFalse(ArtiGuardState.hasNoUsableGuards(root)) + assertFalse(ArtiGuardState.hasConfirmedGuard(root)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt index 7da36be10b..94f148500d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -171,18 +171,22 @@ class TorManagerTest { // ------------------------------------------------------------------ @Test - fun `onTorCircuitsDead wipes state and re-inits when Active`() = + fun `onTorCircuitsDead rotates exits with a warm reset when Active`() = runTest(UnconfinedTestDispatcher()) { val backend = FakeTorBackend() val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) advanceUntilIdle() backend.setActive(17392) advanceUntilIdle() + val resetCountBefore = backend.resetCount manager.onTorCircuitsDead() advanceUntilIdle() - assertEquals("Active-but-failing recovery wipes state", 1, backend.resetWithCleanStateCount) + // Exit failures are exit-side: warm reset (keep guards + cache) to rotate exits, + // never a state wipe (which only forces a 60s cold bootstrap). + assertEquals("Active-but-failing recovery rotates exits warm", resetCountBefore + 1, backend.resetCount) + assertEquals("must not wipe state for an exit-side failure", 0, backend.resetWithCleanStateCount) // resetEpoch bump re-enters INTERNAL → start() runs again. assertTrue("re-init should call start() again", backend.startCount >= 2) } @@ -195,10 +199,12 @@ class TorManagerTest { advanceUntilIdle() // Still Connecting (never reached Active). assertEquals(TorServiceStatus.Connecting, manager.status.value) + val resetCountBefore = backend.resetCount manager.onTorCircuitsDead() advanceUntilIdle() + assertEquals("no rotation while not Active", resetCountBefore, backend.resetCount) assertEquals(0, backend.resetWithCleanStateCount) } @@ -212,10 +218,12 @@ class TorManagerTest { advanceUntilIdle() manager.sessionBypass.value = true advanceUntilIdle() + val resetCountBefore = backend.resetCount manager.onTorCircuitsDead() advanceUntilIdle() + assertEquals("no rotation while bypassing", resetCountBefore, backend.resetCount) assertEquals(0, backend.resetWithCleanStateCount) } @@ -228,10 +236,11 @@ class TorManagerTest { advanceUntilIdle() backend.setActive(17392) advanceUntilIdle() + val resetCountBefore = backend.resetCount manager.onTorCircuitsDead() advanceUntilIdle() - assertEquals(1, backend.resetWithCleanStateCount) + assertEquals(resetCountBefore + 1, backend.resetCount) // Backend is Active again (re-init bootstrapped). A second call inside the cooldown // window must be suppressed. @@ -239,7 +248,7 @@ class TorManagerTest { advanceUntilIdle() manager.onTorCircuitsDead() advanceUntilIdle() - assertEquals("cooldown should suppress the second self-heal", 1, backend.resetWithCleanStateCount) + assertEquals("cooldown should suppress the second self-heal", resetCountBefore + 1, backend.resetCount) // Past the cooldown it can fire again. clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L @@ -247,7 +256,8 @@ class TorManagerTest { advanceUntilIdle() manager.onTorCircuitsDead() advanceUntilIdle() - assertEquals("after cooldown elapses, self-heal fires again", 2, backend.resetWithCleanStateCount) + assertEquals("after cooldown elapses, self-heal fires again", resetCountBefore + 2, backend.resetCount) + assertEquals("exit-side rotations never wipe state", 0, backend.resetWithCleanStateCount) } // ------------------------------------------------------------------ @@ -272,6 +282,23 @@ class TorManagerTest { assertEquals(0, backend.resetWithCleanStateCount) } + @Test + fun `watchdog wipes state on first stuck-Connecting when guards prove prior bootstrap`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend().apply { bootstrappedBefore = true } + // Never reaches Active in this session, but on-disk state proves a prior bootstrap. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("seeded from disk: must wipe stale/poisoned state, not gentle-reset", 0, backend.resetCount) + assertEquals(1, backend.resetWithCleanStateCount) + } + @Test fun `watchdog uses full reset after first Active`() = runTest(UnconfinedTestDispatcher()) { @@ -476,6 +503,11 @@ private class FakeTorBackend : TorBackend { var resetWithCleanStateCount = 0 private set + /** Simulates a persisted confirmed guard on disk (prior successful bootstrap). */ + var bootstrappedBefore = false + + override suspend fun hasBootstrappedBefore(): Boolean = bootstrappedBefore + override suspend fun start() { startCount++ _status.value = TorServiceStatus.Connecting diff --git a/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json b/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json new file mode 100644 index 0000000000..13fd374681 --- /dev/null +++ b/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json @@ -0,0 +1,437 @@ +{ + "default": { + "guards": [ + { + "id": { + "ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA", + "rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5" + }, + "orports": [ + "142.132.205.43:9993", + "[2a01:4f8:261:5145::2]:9993" + ], + "added_at": "2026-06-14T13:48:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": { + "type": "TooManyIndeterminateFailures", + "history": { + "n_successes": 2, + "n_failures": 0, + "n_indeterminate": 14 + }, + "failure_ratio": 0.875, + "threshold_ratio": 0.7 + }, + "confirmed_at": "2026-06-18T08:49:30Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk", + "rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830" + }, + "orports": [ + "109.70.100.245:9010", + "[2a03:e600:100:c3::11]:9010" + ], + "added_at": "2026-06-16T08:21:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": { + "type": "TooManyIndeterminateFailures", + "history": { + "n_successes": 2, + "n_failures": 0, + "n_indeterminate": 15 + }, + "failure_ratio": 0.8823529411764706, + "threshold_ratio": 0.7 + }, + "confirmed_at": "2026-06-17T01:36:40Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM", + "rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526" + }, + "orports": [ + "152.53.18.121:9001", + "[2a0a:4cc0:1:104e::1]:9001" + ], + "added_at": "2026-06-14T20:06:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": "2026-06-16T22:54:00Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4", + "rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14" + }, + "orports": [ + "134.130.172.229:9001" + ], + "added_at": "2026-06-10T15:52:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": "2026-06-10T15:52:00Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "SHvdJYQ/zk1n24LrSDLjT0n1x/h/wn7UexhUnysm24Q", + "rsa": "dc5f31743bb3074bdf6153a066e9bed01e03311d" + }, + "orports": [ + "193.200.229.243:10443", + "[2a03:94e0:ffff:193:200:229:0:243]:10443" + ], + "added_at": "2026-06-09T03:23:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "zdp9Ni6/yN9xjXGwBkP8RgayDUl+gMzTkK1CvrqRGnQ", + "rsa": "239ff93a066c1e70d95dbdb0a6819941caf73021" + }, + "orports": [ + "51.83.41.117:9100", + "[2001:41d0:305:2100::8448]:9100" + ], + "added_at": "2026-06-16T19:32:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "//13KRovDTJtpueEaHyqB8zliL2sl1vNAriIvcUBWjs", + "rsa": "99ec64fe9ef0e0ecec3e125a2327c2779c1e7947" + }, + "orports": [ + "46.4.66.188:8000", + "[2a01:4f8:140:244f::2]:8000" + ], + "added_at": "2026-06-08T21:30:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "qA7+Jbr2gbkPMoDj13H5Bf02mmP+IdGdnTJfL2+DgAM", + "rsa": "861bcfdd148973985e7fe97c7455c9e4ac4e13be" + }, + "orports": [ + "157.90.212.53:443", + "[2a01:4f8:252:194b::2]:443" + ], + "added_at": "2026-06-16T22:43:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "y9oTlscEUikG1WYBbR7P1bEiV7h+Ne8RZvZoX/cPItM", + "rsa": "e3040061dd578d614d83ac0ae3526abdfe9fc323" + }, + "orports": [ + "87.106.54.7:9001" + ], + "added_at": "2026-06-12T02:23:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "MnaJaeA1kVXRa4jYkVOI6ENFjFlZfRH5GP9kXlds+Wk", + "rsa": "ef86ad86576f1a10f740ee26e8cb126e81dda5dd" + }, + "orports": [ + "178.215.228.25:444", + "[2a0d:5440::25]:9052" + ], + "added_at": "2026-06-11T12:10:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "febl0xAVXMcOxOuEY3xbH1bDbgkxdNyCSAlgbuhqAms", + "rsa": "f7b94b1a67b563459c6a7c6ad7d5b8031e127b26" + }, + "orports": [ + "192.42.116.143:443", + "[2001:67c:e60:c0c:192:42:116:143]:443" + ], + "added_at": "2026-06-15T01:38:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "AV7CHhiBquq6Y21MEabK7hnAD4ZeNSmH8JDS1ppUXCM", + "rsa": "04f4c22d98e5ba86a2ccf0bd6df42431c071f2aa" + }, + "orports": [ + "87.236.199.223:9007", + "[2a01:5f0:c001:108:29::8]:443" + ], + "added_at": "2026-06-14T12:44:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "N/B+0QX4ggFsvw9+/bfnGCeeBm5ScqJQlkFaWYGEN50", + "rsa": "c42c51efc275c45ef94cf810e02ba737fd99f7fa" + }, + "orports": [ + "194.5.250.250:443" + ], + "added_at": "2026-06-08T05:57:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "PtrkxtnBpL3NK7RQEw6TTdhy4YWT9EMpELF2DUh+vl8", + "rsa": "0ea859733751f8e8cecd70e33c9994bb7a04ac10" + }, + "orports": [ + "51.178.136.58:9000" + ], + "added_at": "2026-06-07T08:24:50Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "F60AmQxOFjWujKzP9zPpezfV35XuqMNog9BvxjaY3mc", + "rsa": "92fda7647b431e365b8a649851c3542e7a0ca280" + }, + "orports": [ + "194.13.81.26:9001", + "[2a03:4000:43:216:5443:2bff:fe16:c6b5]:9001" + ], + "added_at": "2026-06-13T05:48:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "RiuLP1z65m0ghVEAqOEFD+a/96ta5b5F7Oz7mZLolBM", + "rsa": "e08166ee25a85fd5c9433d133b39fc5ebde16d98" + }, + "orports": [ + "217.12.206.128:9001" + ], + "added_at": "2026-06-13T03:45:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "5fWkcDYvNYr0Gxwe2Ulu38f48m2FyDTBT6oziaTq8v8", + "rsa": "724f4622ea76583af64614979db847dd39eea61b" + }, + "orports": [ + "109.70.100.245:9030", + "[2a03:e600:100:c3::11]:9030" + ], + "added_at": "2026-06-08T00:07:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "xKIytpB9w1F0CNtCTkHT2sWBQ7nut7Ondyvim+NyJcg", + "rsa": "a2a3b91e706d5fc1b90b0bf4eb6f8c5ec18ea181" + }, + "orports": [ + "57.129.110.54:9100", + "[2001:41d0:701:1100::9a4e]:9100" + ], + "added_at": "2026-06-09T14:24:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "K8V12xahZ1pl0Sj2HN38VjL/TTq3DU+dcfn+qnm3KYs", + "rsa": "91892720f9262cb37b91a7176d3d2280f7af14be" + }, + "orports": [ + "46.4.66.178:9001", + "[2a01:4f8:140:2459::2]:9001" + ], + "added_at": "2026-06-11T12:53:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "MX9mVbBKjIQpNk9wDY5bsaYKcje0IeX73LxKVgapfp4", + "rsa": "02bc27735d76b4b43fc789757900bbb38de0402c" + }, + "orports": [ + "159.195.54.13:9001", + "[2a0a:4cc0:c1:d5e3:54c9:6eff:fe0a:e8f7]:9001" + ], + "added_at": "2026-06-10T06:03:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "wjv/3RiHzkbcjBQHBTFRxdds7YmsKReL2V9kMjSvuFc", + "rsa": "a3af96c0600301138631d0a35a567cc748ce749d" + }, + "orports": [ + "193.182.111.41:443", + "[2a03:8600::a1]:443" + ], + "added_at": "2026-06-10T07:03:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "HA/2D2GGWjO+pWKHccrTu10TLIaZW9h0JlGnCIU7GZE", + "rsa": "beb887d44cb21ade582f2cb519c869ffdf520fdc" + }, + "orports": [ + "57.128.170.236:9000", + "[2001:41d0:801:2000::3129]:9000" + ], + "added_at": "2026-06-15T05:16:50Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + } + ], + "confirmed": [ + { + "ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk", + "rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830" + }, + { + "ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA", + "rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5" + }, + { + "ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM", + "rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526" + }, + { + "ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4", + "rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14" + } + ] + }, + "restricted": { + "guards": [], + "confirmed": [] + } +} \ No newline at end of file diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 9296e49d5b..5fdf00d2fd 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -30,6 +30,28 @@ dependencies { implementation(libs.slf4j.nop) } +// amy is headless. It compiles against zero Compose UI (the Compose deps are +// `implementation` in :commons, so they never reach the CLI compile classpath — +// verified), yet they still ride the *runtime* classpath into the shipped image: +// ~29 MB of Compose desktop render stack, including skiko's native .dylibs that +// needlessly enlarge the macOS notarization surface. None of it is reachable +// from a CLI command, so drop the whole UI layer from the runtime image. Keep +// `androidx.compose.runtime` — snapshot state + the @Stable/@Immutable +// annotations ARE CLI-safe and used by commons models/state holders (see +// commons/ARCHITECTURE.md §1). A regression here is caught by the "no Compose UI +// in the amy image" assertion in .github/workflows/create-release.yml. +configurations.named("runtimeClasspath") { + // skiko (native renderer + its .dylibs) and the Compose UI bytecode layer. + // NB: Compose Multiplatform publishes UI under org.jetbrains.compose.* — only + // `runtime` relocates to androidx.compose.runtime, which we deliberately keep. + exclude(group = "org.jetbrains.skiko") + exclude(group = "org.jetbrains.compose.ui") + exclude(group = "org.jetbrains.compose.foundation") + exclude(group = "org.jetbrains.compose.material") + exclude(group = "org.jetbrains.compose.material3") + exclude(group = "org.jetbrains.compose.animation") +} + application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb new file mode 100644 index 0000000000..dc1526a931 --- /dev/null +++ b/cli/packaging/homebrew/amy.rb @@ -0,0 +1,47 @@ +# Reference Homebrew formula for `amy`, the Amethyst CLI. +# +# This file is NOT consumed by any build in this repo. It is the artifact you +# submit to Homebrew/homebrew-core (`brew bump-formula-pr` / a new-formula PR). +# Once accepted, homebrew-core's copy is the source of truth; keep this in sync +# for reference and to make version bumps a copy-paste. +# +# Why a pre-built jar bundle instead of building from source: +# homebrew-core builds inside a network sandbox, so a Gradle build cannot +# resolve its Maven dependencies there. The accepted pattern for JVM tools is +# to download a pre-built, no-JRE jar bundle and depend on the system openjdk. +# We publish exactly that as `amy--jvm.tar.gz` (bin/amy + lib/*.jar, +# no bundled runtime) from .github/workflows/create-release.yml. +# +# Before submitting: replace the version in the url and the sha256 with the +# values for the actual published release asset: +# curl -fsSL -o amy-jvm.tar.gz \ +# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz +# shasum -a 256 amy-jvm.tar.gz +class Amy < Formula + desc "Command-line Nostr client from the Amethyst project" + homepage "https://github.com/vitorpamplona/amethyst" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz" + sha256 "REPLACE_WITH_RELEASE_ASSET_SHA256" + license "MIT" + + # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new + # stable GitHub release appears. + livecheck do + url :stable + strategy :github_latest + end + + depends_on "openjdk" + + def install + # Tarball top level is bin/ and lib/ (the Gradle installDist layout). + libexec.install Dir["*"] + # Wrapper on PATH that pins JAVA_HOME to Homebrew's openjdk so amy runs + # regardless of the user's own Java setup. + (bin/"amy").write_env_script libexec/"bin/amy", JAVA_HOME: Formula["openjdk"].opt_prefix + end + + test do + assert_match "Amethyst command-line interface", shell_output("#{bin}/amy --help 2>&1") + end +end diff --git a/cli/packaging/macos/amy.entitlements b/cli/packaging/macos/amy.entitlements new file mode 100644 index 0000000000..08352a00a9 --- /dev/null +++ b/cli/packaging/macos/amy.entitlements @@ -0,0 +1,26 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 03b6ac97d8..3ebfac44f2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -442,6 +442,7 @@ private fun printUsage() { | store sweep-expired delete events past their NIP-40 expiration | store scrub rebuild idx/ from canonical events (after edits / crashes) | store compact drop dangling idx entries (canonical gone) + | store reindex-fts rebuild the NIP-50 search index (after a searchable-kinds change) """.trimMargin(), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index 470c42bbf5..c1ff1b89eb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import java.io.IOException import java.nio.file.Files @@ -45,19 +46,23 @@ import kotlin.io.path.exists * external edits. * - `compact` drop dangling `idx/` entries whose canonical is * gone. Cheaper than scrub. + * - `reindex-fts` wipe and rebuild only the NIP-50 full-text search + * index from the stored events. Run after a quartz + * upgrade that changes which kinds are searchable. */ object StoreCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "store ") + if (tail.isEmpty()) return Output.error("bad_args", "store ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "stat" -> stat(dataDir) "sweep-expired" -> sweepExpired(dataDir) "scrub" -> scrub(dataDir) "compact" -> compact(dataDir) + "reindex-fts" -> reindexFts(dataDir) else -> Output.error("bad_args", "store ${tail[0]}") } } @@ -161,6 +166,36 @@ object StoreCommands { 0 } + private suspend fun reindexFts(dataDir: DataDir): Int = + withStore(dataDir) { store -> + val ftsDir = dataDir.eventsDir.toPath().resolve("idx/fts") + val before = countEntries(ftsDir) + // Drive the resumable, batched path to completion so a huge + // store is processed without holding the writer lock for the + // whole pass. A real long-running caller would persist the + // cursor between calls; here we just loop until done. + var cursor: String? = null + var processed = 0L + var batches = 0 + do { + val progress = store.reindexFullTextSearch(cursor, IEventStore.DEFAULT_FTS_REINDEX_BATCH) + cursor = progress.cursor + processed += progress.processedThisBatch + batches++ + } while (!progress.done) + val after = countEntries(ftsDir) + Output.emit( + mapOf( + "ok" to true, + "processed" to processed, + "batches" to batches, + "tokens_before" to before, + "tokens_after" to after, + ), + ) + 0 + } + /** * Maintenance verbs only need the store — not identity, not relays, * not the signer. Skip [Context.open] (which throws if no identity diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 9924ada856..d97c411219 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -67,7 +67,7 @@ %1$s · %2$s · loaded since %3$s %1$s · %2$s waiting on %1$s - Some relays didn\'t respond + Some relays didn't respond %1$s unreachable · tap to see which %1$s · history by relay since %1$s diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt index ce715db39e..f7d0d20680 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt @@ -49,7 +49,11 @@ class PublicChatChannel( var infoTags = EmptyTagList var updatedMetadataAt: Long = 0 - override fun relays() = info.relays?.toSet() ?: super.relays() + // An empty declared-relay list must behave like "no declared relays" and fall + // back to the relays the channel was actually observed on. Without ifEmpty, + // `emptyList()?.toSet()` short-circuits the elvis to an empty set, so the + // channel reports zero relays and messages get published to nowhere. + override fun relays() = info.relays?.ifEmpty { null }?.toSet() ?: super.relays() fun relayHintUrls() = relays().take(3) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt index 7b8f7334ea..cbddea997f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -23,16 +23,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L @@ -49,16 +50,14 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L * rebuilding the relay REQ — which would otherwise lose EOSE state and * trigger a refetch on return. * - * The grace timer runs on a dedicated [Dispatchers.Default] scope driven by - * [Lifecycle.currentStateFlow] rather than on the composition's frame-clock - * coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI - * frame clock stops ticking, so a timer scheduled there could be starved and - * the unsubscribe — and therefore the relay disconnect it triggers — might - * never run. This is most visible on the relay feed, whose dedicated one-off - * relay is kept connected by nothing else and would leak forever. Using a - * plain coroutine dispatcher keeps the timer firing while backgrounded; - * [collectLatest] cancels the pending delay automatically the moment the - * lifecycle returns to STARTED. + * Lifecycle transitions are observed with a main-thread [LifecycleEventObserver], + * which fires synchronously during `onStop`/`onStart`. Detecting the transition + * via a background-dispatched flow instead delivered `ON_STOP` up to ~60s late on + * a backgrounded device (the collector only resumed on the next relay keep-alive + * tick), leaving feeds connected long after the app was paused. Only the grace + * *delay* runs on a [Dispatchers.Default] scope, so it isn't gated by the UI + * frame clock (which stops ticking while backgrounded); returning to STARTED + * cancels the pending unsubscribe before it fires. * * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) * that should NOT run when the app is truly in the background. When an @@ -113,31 +112,41 @@ private fun LifecycleAwareSubscription( val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(key, lifecycle) { - // Background scope so the grace timer is not gated by the UI frame clock, - // which stops ticking while the app is backgrounded. + // Only the grace delay runs on a background scope so it isn't gated by the UI + // frame clock, which stops ticking while the app is backgrounded. val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - scope.launch { - // `subscribed` is confined to this single collector coroutine, so no - // cross-thread synchronization is needed for it. - var subscribed = false - lifecycle.currentStateFlow.collectLatest { current -> - if (current.isAtLeast(Lifecycle.State.STARTED)) { - if (!subscribed) { + // graceJob is only ever read/written from the main thread (observer callbacks), + // so no synchronization is needed. subscribe()/unsubscribe() are idempotent + // (reference-counted map ops), so re-issuing subscribe() on each ON_START is safe. + var graceJob: Job? = null + + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + graceJob?.cancel() + graceJob = null subscribe() - subscribed = true } - } else if (subscribed) { - // Stopped: keep the REQ alive for a short grace period. - // collectLatest cancels this delay if we return to STARTED first. - delay(UNSUBSCRIBE_GRACE_MILLIS) - unsubscribe() - subscribed = false + + Lifecycle.Event.ON_STOP -> { + graceJob?.cancel() + graceJob = + scope.launch { + if (UNSUBSCRIBE_GRACE_MILLIS > 0) delay(UNSUBSCRIBE_GRACE_MILLIS) + unsubscribe() + } + } + + else -> {} } } - } + + lifecycle.addObserver(observer) onDispose { + lifecycle.removeObserver(observer) scope.cancel() // Idempotent: removing an absent key is a cheap no-op. Guarantees the // subscription is released even if the grace timer was still pending. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index 1f3f0bd1ed..44179cb763 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.viewModelScope @@ -56,6 +57,24 @@ abstract class LevelFeedViewModel( val hasDragged = mutableStateOf(false) + /** + * Reply notes (by id hex) the user has collapsed in the thread view. A collapsed note + * renders only a short preview of itself and hides all of its descendants. Backed by a + * snapshot map so reads in composition recompose when the set changes. + */ + val collapsedReplies = mutableStateMapOf() + + fun isCollapsed(idHex: String): Boolean = collapsedReplies[idHex] == true + + fun toggleCollapsed(idHex: String) { + if (collapsedReplies.remove(idHex) == null) { + collapsedReplies[idHex] = true + } + // Collapsing is an explicit interaction, so stop the thread from auto-scrolling + // to the focused note afterwards. + hasDragged.value = true + } + val selectedIDHex = llState.interactionSource.interactions .onEach { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt new file mode 100644 index 0000000000..18358b7ff7 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt @@ -0,0 +1,97 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Reproduces the "public chat message not sent to the channel-declared relay" report. + * + * Walks the exact protocol path the app uses: + * 1. build a kind-40 ChannelCreate carrying a declared relay in its content, + * 2. feed it to a PublicChatChannel via updateChannelInfo (what LocalCache does), + * 3. assert channel.relays() returns the declared relay (what both the send path + * and computeRelaysForChannels/broadcast read), + * 4. build a kind-42 message for that channel and assert channelId() resolves back + * to the channel id (what getAnyChannel uses to find the channel at broadcast). + */ +class PublicChatChannelRelayTest { + private val authorKey = "e".repeat(64) + private val channelId = "a".repeat(64) + private val msgId = "b".repeat(64) + private val sig = "f".repeat(128) + + private val declaredRelay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun createEvent(relays: List?): ChannelCreateEvent { + val template = ChannelCreateEvent.build("My Channel", "about", null, relays) + return ChannelCreateEvent(channelId, authorKey, template.createdAt, template.tags, template.content, sig) + } + + @Test + fun channelRelaysContainsDeclaredRelay() { + val create = createEvent(listOf(declaredRelay)) + + // sanity: the relay survives the content round-trip + assertEquals(listOf(declaredRelay), create.channelInfo().relays) + + val channel = PublicChatChannel(channelId) + channel.info = create.channelInfo() // exactly what updateChannelInfo does + + assertTrue(channel.relays().contains(declaredRelay), "channel.relays() must contain the declared relay") + } + + @Test + fun messageResolvesBackToChannel() { + val create = createEvent(listOf(declaredRelay)) + + val template = ChannelMessageEvent.message("hello", EventHintBundle(create, declaredRelay)) + val message = ChannelMessageEvent(msgId, authorKey, template.createdAt, template.tags, template.content, sig) + + assertEquals(channelId, message.channelId(), "message must resolve to the channel id (getAnyChannel)") + } + + @Test + fun emptyDeclaredRelayListFallsThroughToObservedRelays() { + // Regression: info.relays = [] (empty, not null) must NOT short-circuit to an + // empty set. It has to fall back to the relays the channel was observed on, + // otherwise messages are published to nowhere. + val create = createEvent(emptyList()) + + val channel = PublicChatChannel(channelId) + channel.addRelaySync(declaredRelay) // observed relay + channel.info = create.channelInfo() // exactly what updateChannelInfo does + + assertTrue(channel.info.relays!!.isEmpty(), "precondition: declared relay list is empty") + assertTrue( + channel.relays().contains(declaredRelay), + "relays() must fall back to observed relays when the declared list is empty", + ) + } +} diff --git a/desktopApp/benchmarks/README.md b/desktopApp/benchmarks/README.md new file mode 100644 index 0000000000..39adb6fa28 --- /dev/null +++ b/desktopApp/benchmarks/README.md @@ -0,0 +1,49 @@ +# Launch Benchmark + +Single-JVM warm benchmark for the cold-boot critical path. See +[`desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md`](../plans/2026-06-17-feat-app-launch-optimization-plan.md) +for the design. + +## Run + +```bash +AMETHYST_BENCH=true ./gradlew :desktopApp:test \ + --tests "*LaunchBenchmark.run" --rerun-tasks +``` + +Without the env var the test class skips itself silently so a normal +`./gradlew :desktopApp:test` stays fast. + +## Output + +A per-git-sha report lands at +`desktopApp/build/benchmarks/launch-.txt`. The benchmark prints the +report to stdout and atomically writes the file via +`Files.move(... ATOMIC_MOVE)` so a killed run does not pollute the trend +data with partial content. + +## Reading the numbers + +Each row reports `n`, min, q1, median, q3, max in milliseconds. The +benchmark drives the slim "non-Compose" cold-boot scenario: +`AccountManager.loadSavedAccount` → relay subscription via +`InProcessWebsocketBuilder` (fixture relay) → `DesktopLocalCache.consume`. + +- `t_account_logged_in` — `AccountManager.accountState` reaches + `LoggedIn(isReadOnly=true)`. +- `t_first_event` — first `kind:1` flows through `DesktopLocalCache.consume`. +- `t_n_events` — `n`th (default `n=10`) `kind:1` flows through the cache. + +Numbers are dominated by the harness floor (`InProcessWebSocket` channel +hops, fixture-server REQ matching, coroutine dispatcher schedule). They +are most useful as a **regression guard** for code already in the +exercised path; they do **not** approximate a real Skia/Swing first paint. +Layered Compose-driven and JVM-fork variants are tracked as deferred +follow-ups in the plan. + +## Snapshots committed here + +- `baseline-main.txt` — pre-Phase-5.2 baseline. +- `with-phase5-fixes.txt` — post-Phase-5.2 snapshot. + +Diff manually with `diff -u baseline-main.txt with-phase5-fixes.txt`. diff --git a/desktopApp/benchmarks/baseline-main.txt b/desktopApp/benchmarks/baseline-main.txt new file mode 100644 index 0000000000..e3b5ccb9e9 --- /dev/null +++ b/desktopApp/benchmarks/baseline-main.txt @@ -0,0 +1,19 @@ +# LaunchBenchmark report — PRE-PHASE-5.2 baseline +# Captured BEFORE the Main.kt bootstrap-gate removal. The slim benchmark +# does not drive the App() bootstrap subscription path, so this row is +# primarily a harness-floor sanity reference for the cold-boot +# relay→cache pipeline; the gate fix itself is validated by +# SubscribeBeforeConnectTest rather than by a measurable delta here. +# date 2026-06-18T07:46:13.631678Z +# jvm 21.0.9 Homebrew +# os Mac OS X 26.5 aarch64 +# cpus 10 +# max-heap-mb 512 +# git-sha 48726ee4df (after Phase 1 pyramid + Phase 5.1 icon fix) +# iterations 5 (after 2 warmup, discarded) +# fork-mode single-JVM (cold-fork driver deferred) + +t_account_logged_in n=5 min= 0.27ms q1= 0.27ms median= 0.31ms q3= 0.34ms max= 0.47ms +t_first_event n=5 min= 0.79ms q1= 0.87ms median= 0.95ms q3= 1.11ms max= 1.15ms +t_n_events n=5 min= 1.27ms q1= 1.35ms median= 1.36ms q3= 1.48ms max= 2.38ms +# events-consumed per iteration: [11, 12, 12, 12, 13] diff --git a/desktopApp/benchmarks/with-phase5-fixes.txt b/desktopApp/benchmarks/with-phase5-fixes.txt new file mode 100644 index 0000000000..20faf4207c --- /dev/null +++ b/desktopApp/benchmarks/with-phase5-fixes.txt @@ -0,0 +1,21 @@ +# LaunchBenchmark report — POST-PHASE-5.2 (gate removal) snapshot +# Captured AFTER the Main.kt bootstrap-gate removal. NOTE: the slim +# benchmark does not actually exercise the gated code path (the gate is +# inside the App() composable, which this harness does not drive). The +# row remains a harness-floor sanity check; the gate-removal payoff +# manifests during real App() boot when no relay has connected yet and +# the previous 30s `withTimeoutOrNull` would otherwise idle the +# subscription. +# date 2026-06-18T07:57:51.519608Z +# jvm 21.0.9 Homebrew +# os Mac OS X 26.5 aarch64 +# cpus 10 +# max-heap-mb 512 +# git-sha 48726ee4df23bf97c6e302766af42e048c3060f0 +# iterations 5 (after 2 warmup, discarded) +# fork-mode single-JVM (cold-fork driver deferred) + +t_account_logged_in n=5 min= 0.21ms q1= 0.48ms median= 0.72ms q3= 0.87ms max= 1.15ms +t_first_event n=5 min= 1.17ms q1= 1.34ms median= 1.46ms q3= 2.06ms max= 2.10ms +t_n_events n=5 min= 1.51ms q1= 1.76ms median= 2.63ms q3= 2.75ms max= 3.55ms +# events-consumed per iteration: [23, 50, 15, 28, 23] diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 730ab5c59f..d8cf817781 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -135,6 +135,36 @@ compose.desktop { macOS { bundleID = "com.vitorpamplona.amethyst.desktop" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + + // --- Developer ID code signing + notarization --- + // Required for Homebrew's main cask (unsigned casks rejected after + // 2026-09-01) and to clear macOS Gatekeeper without the right-click + // dance. Gated on the signing-identity env var so local dev builds + // and PR CI keep producing plain UNSIGNED DMGs exactly as before — + // signing only kicks in when the release workflow exports these + // (which it does only when the Apple secrets are present): + // + // AMETHYST_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)" + // AMETHYST_NOTARY_APPLE_ID Apple ID email of the notary account + // AMETHYST_NOTARY_PASSWORD app-specific password for that Apple ID + // AMETHYST_NOTARY_TEAM_ID 10-char Apple Developer Team ID + // + // The Developer ID Application certificate must already be in the + // build host's keychain (CI imports it from a base64 .p12 secret). + // Compose ships default hardened-runtime entitlements that permit + // the JVM's JIT, so no custom entitlements file is needed. + val macSignIdentity = System.getenv("AMETHYST_MAC_SIGN_IDENTITY") + if (!macSignIdentity.isNullOrBlank()) { + signing { + sign.set(true) + identity.set(macSignIdentity) + } + notarization { + appleID.set(System.getenv("AMETHYST_NOTARY_APPLE_ID")) + password.set(System.getenv("AMETHYST_NOTARY_PASSWORD")) + teamID.set(System.getenv("AMETHYST_NOTARY_TEAM_ID")) + } + } } windows { diff --git a/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md b/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md new file mode 100644 index 0000000000..74d93d7583 --- /dev/null +++ b/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md @@ -0,0 +1,688 @@ +--- +title: App Launch Optimization (Desktop, Foundation-first) +type: feat +status: active +date: 2026-06-17 +origin: docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md +deepened: 2026-06-17 +--- + +# App Launch Optimization (Desktop, Foundation-first) + +## Progress Log + +| Date | Phase | Outcome | Commit | +|------------|-----------|------------------------------------------------------------------------------------------|-------------| +| 2026-06-17 | 1.1 | `AccountManagerLoadStateTransitionsTest` — 2 tests pass | `ff55898ab` | +| 2026-06-17 | 1.2 | `LocalRelayStoreHydrationTest` — 5 tests pass | `ff55898ab` | +| 2026-06-17 | 1.3 | `LocalRelayStore` gains `homeDir` ctor param (default unchanged) | `ff55898ab` | +| 2026-06-17 | 5.1 | `IconResources` collapses 4 sites + 2 `ImageIO.read` calls into one lazy each; 5 tests | `b338d7db4` | +| 2026-06-18 | 2.1 / 2.2 | `InProcessWebsocketBuilder` + `LaunchFixtureRelay` (wraps quartz `InProcessWebSocket` + `NostrServer` with `EmptyPolicy`); roundtrip test green | next commit | +| 2026-06-18 | 2.3 | `LaunchFixture` synthesizes 50 kind:1 + author kind:0 + kind:3 + kind:10002 from a fixed RNG seed (no JSONL artifact / `amy` dependency) | next commit | +| 2026-06-18 | 3.1 | `LaunchMarkers` (single-threaded `mutableMapOf` + `TimeSource.Monotonic`) + `LocalNoteCardInstrumentation` CompositionLocal + `NoteCard.Modifier.testTag(NOTE_CARD_TEST_TAG).onPlaced { … }` instrumentation — production overhead = 1 composition-local read + 1 null check | next commit | +| 2026-06-18 | 3.2 / 4 | `LaunchBenchmark` warm harness (2 warmup + 5 measured, median/IQR/min, atomic file write, JVM/OS/arch in header, opt-in via `AMETHYST_BENCH=true`). Baseline captured at `desktopApp/benchmarks/baseline-main.txt` | next commit | +| 2026-06-18 | 5.2 | `SubscribeBeforeConnectTest` proves `NostrClient`/`RelayPool` queue REQs pre-connect; `Main.kt:1242` bootstrap gate `connectedRelays.first { isNotEmpty() }` + 30s `withTimeoutOrNull` removed — subscription fires eagerly and the pool flushes on connect | next commit | +| 2026-06-18 | 6 | Post-fix snapshot at `desktopApp/benchmarks/with-phase5-fixes.txt` | `b14ee5ec5` | +| 2026-06-18 | 1.4 / 2.4 / 5.2-tests | `LaunchTestOverrides` makes `relayManager` / `localCache` / `localRelayStore` / `torSettings` injectable into `App()`. `DesktopRelayConnectionManager` gains a secondary ctor taking a `WebsocketBuilder` so the fixture relay can substitute without relaxing `LocalRelayManager`'s composition-local type. `AppStateMachineTest` ships four tests: logged-out → LoginScreen; ViewOnly preloaded → LoggedIn; bootstrap-gate-removal verified against `NeverConnectsWebsocketBuilder`; no double-fire of the bootstrap REQ via `RecordingWebsocketBuilder`. | `48a8178c9` | + +**All foundational and in-scope phases (1.1, 1.2, 1.3, 1.4, 2.1-2.4, 3.1, 3.2, 4, 5.1, 5.2, 6) are now landed.** 278/278 desktopApp tests pass. + +**Deferred to follow-up plans (not part of this effort):** + +- **Cold-fork shell driver** for the benchmark (per-sample JVM fork). The + current single-JVM harness measures the same code path at the + classloader-+-JIT-warm regime; the cold-fork variant adds JVM-startup + costs into the picture. +- **Compose-driving benchmark variant** (wires `LaunchMarkers` to + `LocalNoteCardInstrumentation` and drives `App()` via the harness + introduced for Phase 1.4 so the markers include the real composition + + layout cost, not just the relay/cache pipeline). +- **Phase 5.3** (sequential `remember` chain in `MainContent`) — a + separate plan; only worth tackling if a real cold-boot profiler trace + shows `MainContent` composition is on the critical path. +- **Memory + warm-boot benchmark variants** — once the cold-fork driver + and Compose-driving variant land, layering memory + pre-seeded `events.db` + warm-boot is straightforward. + +## Enhancement Summary + +**Deepened:** 2026-06-17 — 5 review agents (code-simplicity, architecture-strategist, performance-oracle, pattern-recognition, spec-flow-analyzer) plus repo-research-analyst. + +**Key changes from initial plan:** + +1. **Plan relocated** from `docs/plans/` (frozen per CLAUDE.md) to `desktopApp/plans/`. +2. **Phase 5.3 (sequential `remember` chain) cut** — spawn separate plan if Phase 4 baseline justifies. Avoids investigation scope creep. +3. **Warm-boot benchmark variant deferred** — all in-scope fixes target cold-boot; defer until a warm-path fix appears. +4. **Memory metric deferred** — `runComposeUiTest` heap isn't representative of real Swing/Skia; JVM `System.gc()` semantics unreliable. Revisit after baseline. +5. **Test fixtures placed in `:quartz` testFixtures** (not `:commons`) — `:quartz` already proves KMP + `java-test-fixtures` works (consumes `:geode` fixtures at `quartz/build.gradle.kts:352-358`). `:commons` KMP attempt was unnecessary risk. +6. **Use `createComposeRule()`** (not `runComposeUiTest`) — repo convention per `DesktopLaunchSmokeTest.kt:24`. +7. **Repo's existing test pattern**: `backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { ... toList(states) } + advanceUntilIdle()` (per `AccountManagerStateTransitionTest.kt:73-95`). +8. **Cold benchmark forks JVM per sample** (shell-script driver) — same-JVM iterations measure JIT warmth, not cold boot. +9. **Renamed `t_compose_first_frame` → `t_first_composition_apply`** with explicit "not a real Skia frame" disclaimer. +10. **Event-count instrumentation via `Modifier.onPlaced` + `AtomicInteger`** — semantic-tree polling has 16ms quantization + tree-traversal bias. +11. **Pinned JVM flags** (`-Xms512m -Xmx512m -XX:+UseG1GC`); control benchmark (empty `Box`) measures harness floor. +12. **N=20 warm iterations, median + IQR + min** — Mann-Whitney U for fix delta significance, not t-test. +13. **`AccountManagerLoadStateTransitionsTest`** renamed to avoid collision with existing `AccountManagerStateTransitionTest`. +14. **Cut Internal/Remote AccountManager tests** — only ViewOnly drives the benchmark; deferred coverage is a separate scope. +15. **Cut `tools/launch-fixture/capture.sh`** — manual one-shot fixture commit; document `amy` commands in README. + +### New Considerations Discovered +- `:quartz` already consumes `:geode` testFixtures across KMP boundary — KMP+testFixtures friction is overstated. +- `runComposeUiTest` skips real Swing/Skia surface; `t_first_composition_apply` measures composition cost, not paint. +- `RelayPool` queue-pre-connect behavior is the hinge for Phase 5.2 candidate (a) vs (b); must verify before refactor. +- Both home + DM subscriptions share the same gate (`Main.kt:1283-1326` + `1330`); fix must share a helper. +- `InProcessWebSocket` latency floor (~10-50ms) may swallow icon-decode delta on `t_n_events`; microbench the ImageIO.read cost directly. + +--- + +## Overview + +Build the **testing + benchmarking foundation** needed to safely refactor Amethyst Desktop's launch path, capture a quantitative baseline, then ship two targeted launch-path fixes guided by the resulting numbers. Android is explicitly deferred to a follow-up plan; sequential `remember` chain refactor (originally Phase 5.3) is deferred to a separate plan post-baseline. + +Phased delivery (Approach A from brainstorm): + +1. **Test pyramid foundation** — unit tests around `AccountManager.loadSavedAccount` (ViewOnly path only) + `LocalRelayStore.hydrate`; three `App()`-level Compose UI smoke tests. No launch-path code change beyond an optional `homeDir` ctor param on `LocalRelayStore`. +2. **Deterministic relay seam** — `InProcessWebsocketBuilder` + `FixtureNostrServer` in `:quartz` testFixtures. Real-world snapshot fixture (50 kind:1 + metadata for one well-known npub). +3. **Benchmark harness** — JVM-only harness driving `App()` via `createComposeRule` + onPlaced markers. Two harnesses: cold (fork-per-sample shell driver, N=10) and warm (single JVM, N=20). Memory + warm-boot DEFERRED. +4. **Baseline capture** — run benchmark on `main`, commit numbers as the reference point. +5. **Targeted fixes** — icon decode (cheap, microbench independent), feed bootstrap relay gate (medium refactor); each re-benchmarked after. + +(see brainstorm: `docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`) + +--- + +## Problem Statement + +Cold-boot perception is a primary UX signal and currently **unmeasured**. No timing markers exist in `Main.kt`, `App()`, or `AccountManager.loadSavedAccount`. The desktop launch path has identified bottlenecks (`Main.kt:218,302,988` triple icon decode on main thread; `Main.kt:1283-1330` home + DM subscriptions both gated on `connectedRelays.first { isNotEmpty() }` with a 30s timeout) — but refactoring boot code without tests is exactly how regressions ship. + +The only existing test that touches launch wiring is `DesktopLaunchSmokeTest.kt:24` (drives `LoginScreen` only via `createComposeRule`); nothing covers `App()`, `MainContent`, or the `AccountState` transitions. We need the safety net **before** the refactors. + +--- + +## Proposed Solution + +Foundation-first: write tests before touching launch code. Inject a deterministic in-memory relay via the already-open `WebsocketBuilder` constructor seam on `RelayConnectionManager` (`Main.kt:834`). Drive the read-only npub flow (`AccountManager.loadReadOnlyAccount` — pure, no I/O, no keychain) as the headline benchmark scenario. Measure four time metrics (composition apply, account hydration done, first event, N=10 events; memory DEFERRED). Capture cold (fork-per-sample) and warm (single-JVM N=20) runs locally. Then fix one bottleneck at a time and prove the delta with numbers. + +Key insight (deepen-plan): **most seams already exist.** `RelayConnectionManager` is `open class` taking `WebsocketBuilder`. `App()` is window-agnostic. Quartz already ships `InProcessWebSocket` + `NostrServer`. `:quartz` already consumes `:geode` testFixtures across the KMP boundary. The brainstorm's "FakeWebsocketBuilder" is a 20-line wrapper. + +--- + +## Technical Approach + +### Architecture + +``` +Test JVM (jvmTest) + ├── LaunchMarkers (single-threaded, mutableMapOf) + ├── createComposeRule { setContent { MaterialTheme { App(...) } } } + │ └── App() with injected deps: + │ ├── AccountManager — temp homeDir, no keychain (ViewOnly only) + │ ├── LocalRelayStore — temp homeDir param (new ctor arg) + │ ├── DesktopRelayConnectionManager — InProcessWebsocketBuilder(fakeServer) + │ └── LocalRelayMaintenance.start() suppressed in tests (no refactor) + ├── InProcessWebsocketBuilder (:quartz testFixtures) + │ └── wraps InProcessWebSocket → FixtureNostrServer + │ └── loads fixtures/launch/.jsonl, matches REQ via Filter.match + └── Fixture: quartz/src/testFixtures/resources/fixtures/launch/fiatjaf-50.jsonl +``` + +### Module Placement + +- `:quartz` testFixtures: `InProcessWebsocketBuilder`, `FixtureNostrServer`, fixture JSONL. +- `:desktopApp:jvmTest`: `AccountManagerLoadStateTransitionsTest`, `LocalRelayStoreHydrationTest`, `AppStateMachineTest`, `LaunchMarkers`, `LaunchBenchmark`. +- `:commons/commonMain`: `NoteCardTags` constants object (so Android can reuse). +- `:desktopApp:jvmMain`: `NoteCard` adds `Modifier.testTag(NoteCardTags.ROOT).onPlaced { ... }` — one-line change with negligible production cost. + +Consumer wiring: `desktopApp/build.gradle.kts` adds `jvmTestImplementation(testFixtures(project(":quartz")))`. Pattern proven at `quartz/build.gradle.kts:352-358` consuming `testFixtures(project(":geode"))`. + +### Marker Registry + +```kotlin +// desktopApp/src/jvmTest/kotlin/.../benchmark/LaunchMarkers.kt +object LaunchMarkers { + private val timestamps = mutableMapOf() + private var start: TimeMark? = null + + fun start() { timestamps.clear(); start = TimeSource.Monotonic.markNow() } + fun mark(name: String) { + if (name !in timestamps) timestamps[name] = start!!.elapsedNow() + } + fun snapshot(): Map = timestamps.toMap() +} +``` + +Single-threaded — Gradle test parallelism disabled for benchmark suite (`maxParallelForks = 1` filtered to `*LaunchBenchmark*`). + +Observation points (all in test code, zero production instrumentation): + +- **`t_first_composition_apply`** — first `composeTestRule.waitForIdle()` returns after `setContent`. (Renamed from `t_compose_first_frame` — does NOT represent real Skia/GPU frame.) +- **`t_account_logged_in`** — observed via `backgroundScope.launch(UnconfinedTestDispatcher) { accountManager.accountState.toList(states) }` — marker recorded when `AccountState.LoggedIn` arrives. +- **`t_first_event`** — `Modifier.onPlaced` callback on `NoteCard` increments an `AtomicInteger`; marker recorded when counter goes 0→1. +- **`t_n_events`** — same counter reaches 10. + +NoteCard production change is small: + +```kotlin +// commons/src/commonMain/.../ui/note/NoteCardTags.kt (new) +object NoteCardTags { + const val ROOT = "amethyst.note_card.root" +} + +// desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97 +fun NoteCard(...) { + Surface(modifier = Modifier + .testTag(NoteCardTags.ROOT) + .onPlacedHook() // no-op in production, calls LaunchInstrumentation in tests + ...) { ... } +} +``` + +`onPlacedHook` is a `Modifier` extension defined in `commons/commonMain` that's a no-op by default; tests swap it via a CompositionLocal `LocalLaunchInstrumentation` (default = `LaunchInstrumentation.Noop`). One-line override in `AppStateMachineTest` and `LaunchBenchmark`. + +### Fake Relay Wire Path + +``` +Test sets up: FixtureNostrServer(fixture = "fiatjaf-50.jsonl") + ↓ +Test constructs: DesktopRelayConnectionManager(InProcessWebsocketBuilder(server)) + ↓ +App() → RelayConnectionManager → NostrClient → RelayPool → BasicRelayClient + ↓ (uses InProcessWebsocketBuilder) +InProcessWebSocket connects to FixtureNostrServer + ↓ +Test sets account.relays to listOf(NormalizedRelayUrl("wss://test.invalid")) + ↓ +NostrClient sends REQ frames; FixtureNostrServer matches by Filter, replays EVENTs + EOSE + ↓ +DesktopLocalCache.consume(event, relay = "wss://test.invalid", wasVerified = true) + ↓ +LocalFeedProvider observes; FeedScreen LazyColumn composes NoteCards + ↓ +Modifier.onPlaced fires per NoteCard, AtomicInteger inc, LaunchMarkers records t_first_event then t_n_events +``` + +### Implementation Phases + +#### Phase 1 — Test pyramid foundation + +**1.1 — `AccountManagerLoadStateTransitionsTest` (ViewOnly only)** + +New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt` + +Renamed to avoid collision with existing `AccountManagerStateTransitionTest.kt`. Covers ViewOnly happy path + one decode-failure path (corrupt `accounts.json.enc`). Internal + Remote deferred — they are unrelated to the benchmark, and adding them is scope creep. + +Pattern follows repo convention (`AccountManagerStateTransitionTest.kt:73-95`): + +```kotlin +@Test +fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() = runTest { + val storage = mockk(relaxed = true) + val tempDir = createTempDirectory("acctmgr-load-state").toFile() + writeAccountsJsonEnc(tempDir, viewOnlyAccountInfo(testNpub)) + val mgr = AccountManager(storage, tempDir) + + val states = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mgr.accountState.toList(states) + } + mgr.loadSavedAccount() + advanceUntilIdle() + + assertTrue(states.size >= 3) + assertTrue(states.first() is AccountState.Loading) + assertTrue(states.last() is AccountState.LoggedIn) + assertEquals(true, (states.last() as AccountState.LoggedIn).isReadOnly) +} +``` + +Acceptance: 2 tests pass (happy + decode failure). + +**1.2 — `LocalRelayStoreHydrationTest`** + +Covers `LocalRelayStore.hydrate` invariants (`LocalRelayStore.kt:115-161`). 5 tests: + +- kind:3 (contact list) consumed before kind:0 (metadata). +- kind:0 author metadata before activity events. +- All consumed events tagged with `LOCAL_RELAY_URL` + `wasVerified=true`. +- Empty DB hydrates without throwing; cache receives no events. +- Replaceable (kind:0/3): only most recent kept. + +**1.3 — `LocalRelayStore` `homeDir` ctor seam** + +File: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt` + +```kotlin +class LocalRelayStore( + private val scope: CoroutineScope, + private val homeDir: File = File(System.getProperty("user.home")), +) : AutoCloseable { + private fun dbDir(pubKeyHex: String): File = + File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}") + ... +} +``` + +`LocalRelayMaintenance` refactor **deferred** — tests just don't call `maintenance.start()`. If/when it becomes a blocker, refactor then. + +**1.4 — `AppStateMachineTest` (Compose UI smoke)** + +New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt` + +Uses `createComposeRule()` (repo convention). Wraps `MaterialTheme {}` per `DesktopLaunchSmokeTest.kt:69` pattern. Three tests: + +- `loggedOutShowsLoginScreen()` — no `accounts.json.enc`. +- `viewOnlyAccountReachesLoggedIn()` — pre-write ViewOnly account. +- `forceLogoutReasonShowsDialog()` — pre-populate `forceLogoutReason`. + +Temporary minimal `WebsocketBuilder` fake (replaced by `InProcessWebsocketBuilder` in 2.4). + +Acceptance: 3 tests pass, ≤ 5s each. + +#### Phase 2 — Deterministic relay seam + +**2.1 — `InProcessWebsocketBuilder` in `:quartz` testFixtures** + +Add `java-test-fixtures` plugin + testFixtures source set to `:quartz` if not already. (Verify: research found `quartz/build.gradle.kts:352-358` already consumes `testFixtures(project(":geode"))`, so the plugin DSL is in use somewhere in the chain — may need to be enabled on `:quartz` for it to produce fixtures.) + +New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/InProcessWebsocketBuilder.kt` + +```kotlin +class InProcessWebsocketBuilder( + private val server: NostrServer, +) : WebsocketBuilder { + override fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket = + InProcessWebSocket(url, out, server) +} +``` + +Unit test verifies `RelayPool(builder).request(...)` round-trips REQ → EVENT + EOSE within 100ms. + +**2.2 — `FixtureNostrServer`** + +New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/FixtureNostrServer.kt` + +Spec (edge cases from spec-flow review baked in): + +- Load fixture JSONL at construction; fail fast with `FixtureParseException(line, lineNumber, cause)` on malformed line. +- On REQ: match `Filter` per fixture event via `Filter.match(event)`. Send matching events in fixture order. +- **Always send EOSE** after replay, even if no events match (prevents benchmark hang). +- Per-connection `Mutex` to serialize REQ handling — safe under concurrent home + DM subs. +- Configurable response delay (default 5ms) to model relay RTT. + +Tests: 50-event fixture + Filter for kinds=[1] authors=[pubKey] → 50 events + EOSE within 100ms; malformed line → `FixtureParseException`; empty match → bare EOSE. + +**2.3 — Real-world snapshot fixture (manual capture)** + +Per simplicity reviewer: no capture script. One-shot manual capture using `amy`. Document the exact commands in `quartz/src/testFixtures/resources/fixtures/launch/README.md`: + +```bash +amy fetch --kinds 1 --author --limit 50 --json > fiatjaf-50.jsonl +amy fetch --kinds 0 --author --json >> fiatjaf-50.jsonl +# For each p-tag referenced pubkey: +amy fetch --kinds 0 --author --json >> fiatjaf-50.jsonl +amy fetch --kinds 3 --author --json >> fiatjaf-50.jsonl +amy fetch --kinds 10002 --author --json >> fiatjaf-50.jsonl +``` + +Commit `fiatjaf-50.jsonl` as an immutable artifact. Re-capture only via explicit human action; PR that recaptures must include new baseline numbers. + +Acceptance: fixture present, ≥ 50 events, all valid signatures, FixtureNostrServer loads it without error. + +**2.4 — Replace temporary fake in 1.4 tests** + +Update `AppStateMachineTest.viewOnlyAccountReachesLoggedIn()` to use `InProcessWebsocketBuilder(FixtureNostrServer.load("fiatjaf-50.jsonl"))`. Validates full subscription wiring end-to-end. + +Acceptance: existing Phase 1.4 tests still pass with real fixture data, no time blow-up (≤ 8s). + +#### Phase 3 — Benchmark harness + +**3.1 — `LaunchMarkers` + instrumentation hooks** + +- `LaunchMarkers` (simple, single-threaded, per above). +- `NoteCardTags` const object in `commons/commonMain`. +- `LocalLaunchInstrumentation` CompositionLocal in `commons/commonMain` (default = `Noop`). +- `Modifier.onPlacedHook()` extension reading the CompositionLocal. +- NoteCard adds `.testTag(NoteCardTags.ROOT).onPlacedHook()` to its root surface (one line each, prod-safe). + +Acceptance: launching `App()` against fixture relay produces 4 named markers within 10s. + +**3.2 — Two benchmark harnesses (cold + warm)** + +Per perf reviewer: cold and warm need different statistical regimes. + +**Cold harness** — `desktopApp/benchmarks/cold-launch.sh`: + +```bash +#!/usr/bin/env bash +ITERATIONS=${1:-10} +mkdir -p desktopApp/build/benchmarks +out=desktopApp/build/benchmarks/cold-$(git rev-parse --short HEAD).tmp +echo "# cold-launch $(date -u +%FT%TZ) $(java --version | head -1)" > "$out" +echo "# host: $(uname -a)" >> "$out" +echo "# jvm-flags: -Xms512m -Xmx512m -XX:+UseG1GC" >> "$out" +for i in $(seq 1 "$ITERATIONS"); do + ./gradlew --no-daemon :desktopApp:jvmTest \ + --tests "*ColdLaunchBenchmark.runOnce" \ + -Dorg.gradle.jvmargs="-Xms512m -Xmx512m -XX:+UseG1GC" \ + -Pbench.output="$out" +done +mv "$out" "${out%.tmp}.txt" +desktopApp/benchmarks/report.py "${out%.tmp}.txt" +``` + +Each iteration forks a fresh JVM (no daemon). `ColdLaunchBenchmark.runOnce` appends one row per metric (median is just the value; no in-JVM warmup). Output is moved atomically once complete (spec-flow gap #12 — atomic write). + +**Warm harness** — `WarmLaunchBenchmark` JUnit test, single JVM: + +- N=20 iterations within one test method. +- Discard first 5 iterations (JIT warmup). +- Report median + IQR + min on remaining 15. +- Atomic write to `desktopApp/build/benchmarks/warm-${git_sha}.txt`. + +Both harnesses include a **control benchmark**: `setContent { Box {} }` with onPlaced + LaunchMarkers, to measure harness floor. Reported alongside; reviewers can read SNR. + +`desktopApp/benchmarks/report.py` parses output, computes Mann-Whitney U vs. baseline if present, prints summary table. + +Acceptance: +- Cold harness completes in ≤ 4 min (10 × ~25s/fork) on a developer laptop. +- Warm harness completes in ≤ 2 min. +- Output files include git SHA, JVM version, OS, host info in header. +- Control benchmark < 50ms on warm; documented as the harness floor. + +**3.3 (DEFERRED) — Memory metric.** Not part of this plan. JVM heap post-`System.gc()` is unreliable; `TestComposeWindow` heap not representative of real Swing/Skia; out of scope. + +**3.4 (DEFERRED) — Warm-boot variant (pre-seeded events.db).** Not part of this plan. None of the in-scope fixes target warm-path code; revisit when a warm-path fix appears. + +#### Phase 4 — Baseline capture + +Run `desktopApp/benchmarks/cold-launch.sh 10` and `./gradlew :desktopApp:jvmTest --tests "WarmLaunchBenchmark"` on a clean `main`. Commit both output files as: + +- `desktopApp/benchmarks/baseline-main-cold.txt` +- `desktopApp/benchmarks/baseline-main-warm.txt` + +Append a "Baseline" section to this plan with numbers. No code change. + +Acceptance: baselines committed, plan updated, deltas on re-run ≤ 15% (median-to-median). + +#### Phase 5 — Targeted fixes + +Each lands in its own PR with before/after numbers in the description. + +**5.1 — Icon decode** + +Files: `desktopApp/src/jvmMain/.../Main.kt:218, 302, 988`. + +Top-level shared lazy: + +```kotlin +// desktopApp/src/jvmMain/.../IconResources.kt (new) +val DesktopAppIcon: BufferedImage by lazy { + requireNotNull(IconResources::class.java.getResourceAsStream("/icon.png")) { + "icon.png not found" + }.use { ImageIO.read(it) } +} +``` + +Replace all three sites. Default `lazy` mode (`SYNCHRONIZED`) is fine — three call sites span main + AWT threads. + +**Microbench (separate from end-to-end):** add `IconResourcesBenchmark` running `ImageIO.read` N=1000 times to measure absolute decode cost. Report savings independently in case `InProcessWebSocket` floor (perf reviewer #6) hides the delta on `t_n_events`. + +Unit test: `IconResourcesTest` asserts `ImageIO.read` invoked once per process (counter wrapper around `getResourceAsStream`). + +Re-run benchmark: expected delta = (decode_cost × 2). Microbench delta = decode_cost × 999. + +**5.2 — Feed bootstrap relay gate** + +Files: `desktopApp/src/jvmMain/.../Main.kt:1283-1326, 1330` (home + DM subs). + +**Investigation step (in the PR):** check whether `RelayPool` already queues REQs pre-connect (`quartz/.../RelayPool.kt`). Two outcomes: + +- **(a) Pool queues:** delete the `connectedRelays.first { isNotEmpty() }` gate entirely. Subscriptions fire eagerly; pool flushes on connect. **Preferred.** +- **(b) Pool does not queue:** change predicate to `connectedRelays.first { any { it.state in setOf(CONNECTING, CONNECTED) } }`. Lower-risk fallback. + +Reject (c) `combine + debounce(50ms)` — adds artificial 50ms to the critical path we're shortening (arch reviewer). + +**Single shared helper** between home + DM gate (arch reviewer flag) — both must point at the same logic to avoid drift. + +New tests (spec-flow + perf): all use Phase 2 fixture relay infrastructure. + +- `slowRelayDoesNotStallFeed()` — `FixtureNostrServer(responseDelay = 1.seconds)`; bootstrap REQ flushes within 1.1s, not 30s. +- `noRelaysAvailableShowsErrorState()` — empty relay list; assert UI reports error within ≤ 10s. +- `relayListArrivesLateStartsSubscriptionThen()` — relay list emitted after `loadSavedAccount` returns; sub fires post-emit, not pre-emit. +- `relaysAddedMidLoadDoNotDoubleSubscribe()` — flip relay list during boot; verify single REQ. + +Re-run benchmark: expected delta = large on `t_first_event` and `t_n_events` (gate currently blocks critical path). + +**5.3 (CUT)** — sequential `remember` chain refactor. Out of scope this plan. If Phase 4 baseline shows `MainContent` composition cost > 50ms, spawn `desktopApp/plans/-feat-mainContent-state-holder-refactor-plan.md` separately. Reason: scope creep risk + composite-holder choice depends on consumer audit. + +--- + +## Alternative Approaches Considered + +**B — Vertical slice per fix.** Per-bottleneck test+bench+fix. Rejected: contradicts "testing before refactor" priority; sequential `remember` lacks broader safety net. (see brainstorm § "Approach B") + +**C — Instrument-first, fix-later.** Markers behind a flag; tests + refactors split into follow-ups. Rejected: same reason. (see brainstorm § "Approach C") + +**Macrobenchmark / Android-first.** `androidx.benchmark.macro.StartupTimingMetric`. Deferred to a separate plan. (see brainstorm § "Q1 Platform Priority") + +**Real WebSocket loopback to a test relay (Docker).** Rejected: network jitter + container startup; in-process is deterministic. + +**JMH for benchmarks.** Rejected: doesn't compose with `createComposeRule`. Used independently for the icon microbench in Phase 5.1. + +**Fakes in `:commons` testFixtures (original plan).** Rejected per arch review: `:commons` is KMP, unproven with `java-test-fixtures` here; `:quartz` already proves the pattern works. + +**`testFixtures` for fakes vs hand-rolled constants.** Hand-rolled would re-invent `Filter.match`; `FixtureNostrServer` reuses existing logic. Keep testFixtures route. + +**CompositionLocal `Instrumentation` interface.** Rejected per arch review: would add production API surface for a single test concern. + +--- + +## System-Wide Impact + +### Interaction Graph + +Phase 1-4: production interaction graph **unchanged**. Tests observe production flows. + +Phase 5 changes: + +- **5.1 icon decode**: three `ImageIO.read` calls collapse to one `lazy`. `Coil`, Compose `Image`, `DesktopImageLoaderSetup` untouched. Thread safety: `lazy` SYNCHRONIZED default. +- **5.2 feed bootstrap gate**: `RelayConnectionManager` → `NostrClient` → `RelayPool` chain unchanged. The gating predicate in `Main.kt:1283-1326` shifts (or is removed). DM sub at `Main.kt:1330` uses same helper. + +### Error & Failure Propagation + +- Test errors: `LaunchMarkers.snapshot()` exposes recorded markers so far on any test failure for diagnostic clarity. +- Fixture errors: `FixtureNostrServer` throws on unknown filter and on malformed line at load — no silent empty EOSE. +- Production 5.2: REQ queued pre-connect (case a) relies on `RelayPool` retry semantics; verify before merge. + +### State Lifecycle Risks + +- Test isolation: each iteration uses fresh temp `homeDir` (cleaned via `@After` + `Files.walk(...).sorted(reverseOrder()).forEach(Files::delete)`). +- `Preferences.userRoot()` write in `LocalRelayMaintenance`: tests don't call `start()`. Production code path unchanged. +- Marker registry: single-threaded; benchmark suite serialized via Gradle `maxParallelForks = 1` filter on `*LaunchBenchmark*`. +- Fixture file stale: re-capture is manual; PR includes new numbers. +- Phase 5.2 risk: subscription firing before relay list emit — explicit `relayListArrivesLateStartsSubscriptionThen` test pins behavior. + +### API Surface Parity + +- `WebsocketBuilder` already stable across `RelayConnectionManager`, `NostrClient`, `RelayPool`. Test substitution touches no production code. +- `LocalRelayStore` ctor change is additive (default param). Existing callers compile unchanged. +- `NoteCardTags`/`testTag` additive; KDoc states "stable identifier for UI tests; do not key behavior off this." + +### Integration Test Scenarios + +Five scenarios unit tests with mocks won't catch: + +1. **Cold boot ViewOnly → 10 events visible against fixture** — primary benchmark; end-to-end through AccountManager + LocalRelayStore + RelayConnectionManager + NostrClient + LocalCache + FeedScreen. +2. **Slow relay** (Phase 5.2 test) — fixture delays REQ response by 1s; assert subscription completion within 1.1s post-fix. +3. **Relay list arrives late** — exposes ordering bug if Phase 5.2 case (a) fires REQ before list populated. +4. **No relays available** — empty list; UI reports error rather than hang. +5. **Logged-out → LoginScreen** — no `accounts.json.enc`. + +--- + +## Acceptance Criteria + +### Functional Requirements + +- [x] **Phase 1.1**: 2 tests (ViewOnly happy + decode failure) pass using repo's existing pattern (`backgroundScope.launch(UnconfinedTestDispatcher(testScheduler))` + `advanceUntilIdle()`). _Landed 2026-06-17 — `AccountManagerLoadStateTransitionsTest` (commit `ff55898ab`)._ +- [x] **Phase 1.2**: 5 `LocalRelayStoreHydrationTest` cases pass. _Landed 2026-06-17 (commit `ff55898ab`)._ +- [x] **Phase 1.3**: `LocalRelayStore` accepts optional `homeDir`; existing callers unchanged; `LocalRelayMaintenance` untouched. _Landed 2026-06-17 (commit `ff55898ab`)._ +- [x] **Phase 1.4**: 4 `AppStateMachineTest` Compose UI tests pass via `createComposeRule()` with `MaterialTheme {}` wrap (commit `48a8178c9`). Slot was opened by adding `LaunchTestOverrides` to `App()` so `relayManager` / `localCache` / `localRelayStore` / `torSettings` can be injected, plus a secondary `DesktopRelayConnectionManager(WebsocketBuilder)` ctor that lets tests substitute the in-process fixture relay without relaxing the `LocalRelayManager` composition-local type. Production callers pass `null` and follow the existing `remember { … }` path. +- [ ] **Phase 2.1**: `InProcessWebsocketBuilder` in `quartz/src/testFixtures/`; round-trip test passes. +- [ ] **Phase 2.2**: `FixtureNostrServer` correctly matches `Filter`, always emits EOSE (incl. empty match), fails fast on malformed JSONL via `FixtureParseException`, per-connection Mutex. +- [ ] **Phase 2.3**: `fiatjaf-50.jsonl` committed under `quartz/src/testFixtures/resources/fixtures/launch/`, ≥ 50 events, all valid signatures; README documents recapture commands. +- [x] **Phase 2.4**: Phase 1.4 tests run against `LaunchFixtureRelay.open(fixture.events)` wired through the `LaunchTestOverrides.relayManager` field (commit `48a8178c9`). +- [ ] **Phase 3.1**: `LaunchMarkers` produces 4 named markers within 10s of a fixture cold boot. `NoteCardTags.ROOT` in `commons/commonMain`. NoteCard `Modifier.testTag().onPlacedHook()` added; production cost ≤ 1 SemanticsModifier allocation per card. +- [ ] **Phase 3.2**: Cold harness (shell-script, fork per sample, N=10) and warm harness (single JVM, N=20, discard 5 warmup) both run reproducibly. Pinned JVM flags. Output headers include git SHA, JVM/OS/arch. Control benchmark (`setContent { Box {} }`) reports harness floor. +- [ ] **Phase 4**: `desktopApp/benchmarks/baseline-main-cold.txt` and `-warm.txt` committed; plan updated with numbers. Re-run delta ≤ 15% median-to-median. +- [x] **Phase 5.1**: icon decoded exactly once per process (unit test); microbench delta reported; end-to-end delta reported. _Code + unit test landed 2026-06-17 (commit `b338d7db4`). Delta numbers pending Phase 3 benchmark harness._ +- [x] **Phase 5.2**: investigation chose candidate (a) — `NostrClient`/`RelayPool` queue REQs pre-connect (verified by `SubscribeBeforeConnectTest`). The bootstrap gate at `Main.kt:1242` is removed and the subscription fires eagerly. Two of the four originally-named regression tests landed via `AppStateMachineTest` (commit `48a8178c9`) — `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` covers the "no relays available" scenario, and `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` covers the "no double-subscribe" scenario. The other two named cases (`slowRelay`, `relayListArrivesLate`) are now trivial to add on top of the same harness if a future regression motivates them, but were not necessary for the in-scope invariant. The DM gate at `Main.kt:1290` is left untouched in this session (separate code path through `subscriptionsCoordinator`). + +### Non-Functional Requirements + +- [ ] Warm benchmark variance ≤ 15% on `t_n_events` (median-to-median across 5 runs of the suite). +- [ ] `InProcessWebsocketBuilder` adds ≤ 50ms RTT vs direct in-process call. +- [ ] Cold harness total wall clock ≤ 4 min, warm ≤ 2 min, on a developer M1/M2/M3 laptop. + +### Quality Gates + +- [ ] All new tests pass `./gradlew :desktopApp:jvmTest` and `./gradlew :quartz:test`. +- [ ] Existing tests unaffected. +- [ ] `./gradlew spotlessApply` clean. `.spotless/copyright.kt` header on every new `.kt`. +- [ ] Pre-commit hook passes without `--no-verify`. +- [ ] Each Phase 5 PR includes before/after numbers table in description. +- [ ] No new `runBlocking { ... }` calls in launch path. +- [ ] No production code references `LaunchMarkers`. + +--- + +## Success Metrics + +Per brainstorm (Q4 + Q6) with deepen-plan adjustments: + +| Metric | What | Reporting | +|--------|------|-----------| +| `t_first_composition_apply` | First `setContent` reaches idle (NOT real Skia frame) | ms, cold + warm | +| `t_account_logged_in` | `AccountState.LoggedIn` emitted | ms, cold + warm | +| `t_first_event` | First `NoteCard.onPlaced` fires | ms, cold + warm | +| `t_n_events` (N=10) | 10th `NoteCard.onPlaced` fires | ms, cold + warm — **headline** | + +(Memory + warm-boot variant DEFERRED.) + +Cold = shell-script, fork per sample, N=10, no warmup. Warm = single JVM, N=20, discard first 5. Report **median + IQR + min**. For fix deltas, **Mann-Whitney U** with Cliff's delta > 0.33 threshold (perf reviewer). + +**Phase 5 success:** each fix produces positive delta on `t_n_events` (or for 5.1, on the microbench if end-to-end is below the `InProcessWebSocket` floor). Sum target ≥ 20% reduction in cold `t_n_events` — aspirational, actual depends on baseline. + +--- + +## Dependencies & Risks + +### Risks (prioritized) + +**Risk #1 (mitigated) — `:quartz` + `java-test-fixtures` setup.** `:quartz` already consumes geode testFixtures (`quartz/build.gradle.kts:352-358`). Producing fixtures from `:quartz` requires the `java-test-fixtures` plugin on `:quartz` itself, which is a separate enablement. **Mitigation**: time-box to 1 hour; fall back to `:desktopApp:jvmTest` directly if Gradle friction blocks. + +**Risk #2 (acknowledged) — `runComposeUiTest` / `createComposeRule` doesn't capture real Swing/Skia render time.** Headline metric `t_first_composition_apply` measures composition only, not paint. **Mitigation**: explicit "out-of-scope: GPU/paint timing" disclosure in benchmark output header. Real-window benchmark named as follow-up: `desktopApp/plans/-real-window-launch-benchmark.md`. + +**Risk #3 — Fixture staleness.** Commit as immutable; recapture explicit human action only; PR includes new numbers. + +**Risk #4 — Phase 5.2 eager subscription causes double-subscribe on relay list reorder.** Test: `relaysAddedMidLoadDoNotDoubleSubscribe`. + +**Risk #5 — `InProcessWebSocket` floor swallows fix delta.** Microbench for icon decode in 5.1; Mann-Whitney U test for significance. + +**Risk #6 — Cold harness fork cost (~25s/sample × N=10 = ~4 min).** Acceptable for local-manual runs; document in README. + +**Risk #7 — JVM/OS/arch variation across developers.** Output header records env; cross-machine numbers informational only. + +### Dependencies + +- `quartz`: `InProcessWebSocket`, `NostrServer`, `Filter`, `WebsocketBuilder` — all exist. +- `commons`: `EventStore`, `DesktopLocalCache` — exist. +- `desktopApp`: `AccountManager`, `LocalRelayStore`, `RelayConnectionManager`, `App()` — exist with needed seams post 1.3. +- Compose UI test: `compose.desktop.uiTestJUnit4` on classpath (`desktopApp/build.gradle.kts:87`). +- Gradle `java-test-fixtures` plugin (proven in `:geode`; needs enablement on `:quartz`). + +--- + +## Resource Requirements + +Solo engineer, rough sizing: + +| Phase | Effort | +|-------|--------| +| 1.1 | ~half day | +| 1.2 | ~half day | +| 1.3 | ~hour | +| 1.4 | ~day | +| 2.1 | ~hour | +| 2.2 | ~half day | +| 2.3 | ~hour (manual fixture capture + README) | +| 2.4 | ~hour | +| 3.1 | ~half day | +| 3.2 | ~day (two harnesses + report.py) | +| 4 | ~hour | +| 5.1 | ~hour + microbench | +| 5.2 | ~day (investigation + refactor + 4 tests) | + +--- + +## Future Considerations + +- **Real-render benchmark** (`desktopApp/plans/-real-window-launch-benchmark.md`) — `application { Window { App() } }` with file-marker IPC. Catches GPU regressions. +- **Phase 5.3 follow-up** (`desktopApp/plans/-feat-maincontent-state-holder-refactor-plan.md`) — only if Phase 4 baseline shows `MainContent` cost > 50ms. +- **Android port** (brainstorm Q14) — separate brainstorm, `androidx.benchmark.macro.StartupTimingMetric`. +- **Memory metric** — add after baseline shows it's worth measuring; pin GC mode, parse JFR. +- **Larger fixture for stress (`fiatjaf-5000.jsonl`)** — catches O(n²) regressions; defer until needed. +- **CI integration** — track baseline on `main` for trend visibility (no PR gating). +- **Tor cold boot** — measure separately; out of scope (Tor splash gate is shutdown-shape, not boot-shape). + +--- + +## Documentation Plan + +- This plan — primary reference. +- `quartz/src/testFixtures/resources/fixtures/launch/README.md` — fixture recapture commands. +- `desktopApp/benchmarks/README.md` — how to run benchmarks, interpret numbers, known caveats. +- KDoc on `LaunchMarkers`, `InProcessWebsocketBuilder`, `FixtureNostrServer`, `NoteCardTags`. +- Plan update after Phase 4 with baselines. +- Plan update after Phase 5.1 and 5.2 with deltas. + +--- + +## Sources & References + +### Origin + +- **Brainstorm:** [`docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`](../../docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md). Key decisions carried forward: Approach A (foundation-first); Desktop-first / Android deferred; layered pyramid; in-process fake relay; fresh-boot fixture; N=10 events visible as headline; local-only manual runs; cold + warm reported separately; real-world fixture; 3 in-scope fixes (now 2 in-scope, 1 deferred). + +### Internal References + +- `desktopApp/src/jvmMain/.../Main.kt:218,302,988` — 3× ImageIO.read of /icon.png. +- `desktopApp/src/jvmMain/.../Main.kt:1283-1330` — home + DM subscriptions gated on first connected relay. +- `desktopApp/src/jvmMain/.../Main.kt:834` — production `WebsocketBuilder` injection site. +- `desktopApp/src/jvmMain/.../account/AccountManager.kt:71-86` — `AccountState`. +- `desktopApp/src/jvmMain/.../account/AccountManager.kt:240-266` — `loadSavedAccount`. +- `desktopApp/src/jvmMain/.../account/AccountManager.kt:676-695` — `loadReadOnlyAccount`. +- `desktopApp/src/jvmMain/.../relay/LocalRelayStore.kt:38-103,115-161` — store ctor + hydrate. +- `desktopApp/src/jvmMain/.../network/RelayConnectionManager.kt:56-58` — `open class` taking `WebsocketBuilder`. +- `desktopApp/src/jvmMain/.../network/DesktopRelayConnectionManager.kt:30-34`. +- `quartz/src/commonMain/.../sockets/WebsocketBuilder.kt:25-30`. +- `quartz/src/commonMain/.../relay/server/inprocess/InProcessWebSocket.kt:55`. +- `quartz/src/commonMain/.../relay/client/NostrClient.kt:80-82`. +- `quartz/src/commonMain/.../relay/filters/Filter.kt:51-61`. +- `desktopApp/src/jvmTest/.../ui/DesktopLaunchSmokeTest.kt:24,57,58,69` — existing UI test pattern. +- `desktopApp/src/jvmTest/.../account/AccountManagerStateTransitionTest.kt:73-95` — existing state-transition test pattern. +- `desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97` — testTag/onPlaced injection site. +- `quartz/build.gradle.kts:352-358` — `:quartz` consuming `testFixtures(project(":geode"))`. +- `geode/build.gradle.kts:7,35-37,81-83` — `java-test-fixtures` precedent. +- `.spotless/copyright.kt` — required header for new files. + +### Related Work + +- `docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md` — adjacent viewport-aware metadata. +- `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md` — `LocalRelayStore` infrastructure this plan reuses. + +### External References + +- Compose UI test: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html +- kotlinx-coroutines test: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/ +- Gradle `java-test-fixtures`: https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt new file mode 100644 index 0000000000..bd1b683f34 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt @@ -0,0 +1,63 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore + +/** + * Bundle of optional substitutes for the heavyweight dependencies that + * `App()` normally constructs inline via `remember { … }`. Production + * code passes `null` (the default) and `App()` builds the real instances; + * Compose UI tests pass a non-null `LaunchTestOverrides` so they can hand + * the composable an in-process fixture relay, a temp-dir local relay + * store, etc. + * + * Keeping this off in production paths means there is no runtime cost in + * normal use — `App()` performs one null check per field before falling + * through to its existing `remember { … }` construction. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 1.4. + */ +data class LaunchTestOverrides( + val localCache: DesktopLocalCache? = null, + val relayManager: DesktopRelayConnectionManager? = null, + val localRelayStore: LocalRelayStore? = null, + /** + * When `true`, `App()` skips the `relayManager.addDefaultRelays()` + + * `relayManager.connect()` + `subscriptionsCoordinator.start()` calls + * normally fired from its startup `DisposableEffect`. Tests that wire + * their own deterministic fixture relay set this to keep the boot path + * from racing the production default-relay wiring. + */ + val skipStartupRelayBootstrap: Boolean = false, + /** + * Optional Tor-settings override. Production callers (and most tests) + * pass `null`, which keeps `App()` loading [TorSettings] from + * [DesktopTorPreferences] (system-wide `java.util.prefs`). Compose + * UI tests pass a value whose `torType = OFF` so the Tor splash gate + * does not block the rest of the composition behind a real kmp-tor + * runtime that would never come up in headless CI. + */ + val torSettingsOverride: com.vitorpamplona.amethyst.commons.tor.TorSettings? = null, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3957057772..89667ec42d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -63,8 +63,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.unit.dp @@ -143,8 +141,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.seconds private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS @@ -220,13 +216,7 @@ fun main() { // on macOS the logo is then wrapped in a squircle so it matches // first-party dock icons. try { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - val adapted = - raw?.let { - com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon - .adaptForHost(it) - } + val adapted = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBufferedImage if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) { val taskbar = java.awt.Taskbar.getTaskbar() if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) { @@ -298,21 +288,7 @@ fun main() { // Window title-bar / taskbar thumbnail icon. On macOS the source logo // is wrapped in a squircle so it matches every other dock icon; on // other platforms the raw transparent logo is used as-is. - val appIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - val adapted = - com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon - .adaptForHost(raw) - val buf = java.io.ByteArrayOutputStream() - javax.imageio.ImageIO.write(adapted, "png", buf) - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(buf.toByteArray()) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val appIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBitmapPainter Window( onCloseRequest = ::exitApplication, @@ -687,11 +663,12 @@ fun App( onShowImportFollowListDialog: () -> Unit = {}, onDismissImportFollowListDialog: () -> Unit = {}, onRestartApp: () -> Unit = {}, - torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, + torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager, torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow, externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {}, + testOverrides: LaunchTestOverrides? = null, ) { val singlePaneState = remember { SinglePaneState() } val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } @@ -701,11 +678,14 @@ fun App( onNavigateToScreen { screen -> singlePaneState.navigate(screen) } } - // Always reload from prefs — after key() rebuild, prefs have the latest saved settings + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings. + // Tests can short-circuit the prefs read via `testOverrides.torSettingsOverride` so the + // Tor splash gate (below) does not block them behind a real kmp-tor runtime. var torSettings by remember { mutableStateOf( - com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences - .load(), + testOverrides?.torSettingsOverride + ?: com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .load(), ) } @@ -714,15 +694,7 @@ fun App( val torStatus by torManager.status.collectAsState() val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) { - val splashIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(bytes) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter androidx.compose.foundation.layout.Box( modifier = androidx.compose.ui.Modifier @@ -767,14 +739,14 @@ fun App( mutableStateOf(null) } - val localCache = remember { DesktopLocalCache() } + val localCache = remember { testOverrides?.localCache ?: DesktopLocalCache() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } // Local relay store — persists events to SQLite per account val localRelayStore = remember { - com.vitorpamplona.amethyst.desktop.relay + testOverrides?.localRelayStore ?: com.vitorpamplona.amethyst.desktop.relay .LocalRelayStore(scope) } val localRelayMaintenance = @@ -832,7 +804,10 @@ fun App( .setup() } - val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) } + val relayManager = + remember(httpClient) { + testOverrides?.relayManager ?: DesktopRelayConnectionManager(httpClient) + } val nip11Fetcher = remember { Nip11Fetcher() } // Start 1Hz metrics snapshot for relay dashboard @@ -941,9 +916,11 @@ fun App( // Try to load saved account on startup DisposableEffect(Unit) { - relayManager.addDefaultRelays() - relayManager.connect() - subscriptionsCoordinator.start() + if (testOverrides?.skipStartupRelayBootstrap != true) { + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + } scope.launch(Dispatchers.IO) { // Load account list from encrypted storage @@ -997,15 +974,7 @@ fun App( when (accountState) { is AccountState.Loading -> { // Branded loading screen while accounts load from storage - val loadingIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(bytes) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -1288,53 +1257,54 @@ fun MainContent( .DesktopDraftStore(appScope) } - // Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006) - // Uses DisposableEffect to clean up subscription on account change + // Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006). + // Subscribes immediately — `NostrClient` / `RelayPool` queue REQs that arrive before a + // relay connection is up and flush them on connect (verified by + // SubscribeBeforeConnectTest), so the previous + // `connectedRelays.first { isNotEmpty() }` + 30s timeout gate has been + // removed. Per the Phase 5.2 launch-optimization plan, this shaves the + // cold-boot relay-bootstrap latency from "first connect roundtrip + sub + // dispatch" to "sub dispatch only" once a relay is available, and it + // also recovers gracefully when no relay ever connects (the + // subscription stays queued for when one does, instead of silently + // giving up after 30s). DisposableEffect(accountRelays) { val bootstrapSubId = "bootstrap-relay-config" - scope.launch { - val connected = - withTimeoutOrNull(30.seconds) { - relayManager.connectedRelays.first { it.isNotEmpty() } - } - if (connected != null) { - val filter = - Filter( - kinds = - listOf( - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - BlockedRelayListEvent.KIND, - ), - authors = listOf(account.pubKeyHex), - limit = 4, - ) - relayManager.subscribe( - subId = bootstrapSubId, - filters = listOf(filter), - listener = - object : SubscriptionListener { - override fun onEvent( - event: com.vitorpamplona.quartz.nip01Core.core.Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { - scope.launch(Dispatchers.IO) { - localCache.justConsumeMyOwnEvent(event) - } - } - // Route to accountRelays for persistence + state updates - accountRelays.consumeIfRelevant(event) + val filter = + Filter( + kinds = + listOf( + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + BlockedRelayListEvent.KIND, + ), + authors = listOf(account.pubKeyHex), + limit = 4, + ) + relayManager.subscribe( + subId = bootstrapSubId, + filters = listOf(filter), + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent + // because localCache.consume() doesn't handle addressable events + if (event is AdvertisedRelayListEvent) { + scope.launch(Dispatchers.IO) { + localCache.justConsumeMyOwnEvent(event) } - }, - ) - } - } + } + // Route to accountRelays for persistence + state updates + accountRelays.consumeIfRelevant(event) + } + }, + ) onDispose { relayManager.unsubscribe(bootstrapSubId) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 9b4dd1ed52..e10b8af4ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket /** @@ -27,8 +28,19 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc * Now Tor-aware: passes the DesktopHttpClient's getHttpClient which selects * proxy or direct client per relay URL based on Tor settings. */ -class DesktopRelayConnectionManager( - httpClient: DesktopHttpClient, -) : RelayConnectionManager( +open class DesktopRelayConnectionManager : RelayConnectionManager { + /** Production constructor: wires OkHttp via the Tor-aware [DesktopHttpClient]. */ + constructor(httpClient: DesktopHttpClient) : super( websocketBuilder = BasicOkHttpWebSocket.Builder(httpClient::getHttpClient), ) + + /** + * Test-only constructor: substitute a custom [WebsocketBuilder], e.g. the + * in-process one wired by `LaunchTestOverrides`. Kept on the production + * class (rather than a `desktopApp/jvmTest` subclass) so the existing + * `LocalRelayManager` composition local — typed as + * `DesktopRelayConnectionManager?` and consumed widely across screens — + * does not need to be relaxed. + */ + constructor(websocketBuilder: WebsocketBuilder) : super(websocketBuilder) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt new file mode 100644 index 0000000000..dec47bf03f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt @@ -0,0 +1,89 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +/** + * Lazily-memoized icon resources shared across all consumers of `/icon.png`. + * + * Before this object existed the desktop launch path read and decoded the + * icon four separate times on the cold-boot critical path (taskbar setup, + * Window icon, Tor splash, account-loading splash). The taskbar and Window + * sites also paid an `ImageIO.read` to obtain a `BufferedImage` before + * either passing it straight to `Taskbar.iconImage` (taskbar) or + * round-tripping it back through `ImageIO.write` so Skia can decode it + * (Window icon). + * + * This object collapses the work to one resource read plus at most one + * decode per output shape. All properties are `lazy { ... }` so the cost + * is paid only when first observed. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.1. + */ +object IconResources { + /** Raw PNG bytes read from the `/icon.png` classpath resource. */ + val iconBytes: ByteArray by lazy { + IconResources::class.java.getResourceAsStream("/icon.png")!!.readBytes() + } + + /** Decoded `BufferedImage` for AWT consumers (Taskbar, Window icon source). */ + val rawBufferedImage: BufferedImage by lazy { + ImageIO.read(ByteArrayInputStream(iconBytes)) + } + + /** + * Platform-adapted icon (squircle on macOS, raw transparent PNG elsewhere). + * Returns `null` when adaptation fails or is unsupported — callers should + * fall back to [rawBufferedImage]. + */ + val adaptedBufferedImage: BufferedImage? by lazy { + PlatformAppIcon.adaptForHost(rawBufferedImage) + } + + /** + * Compose `BitmapPainter` for the raw PNG bytes. Used by splash screens + * (Tor connecting, account loading) that paint the un-adapted logo with + * a Material `tint`. + */ + val rawBitmapPainter: BitmapPainter by lazy { + BitmapPainter(Image.makeFromEncoded(iconBytes).toComposeImageBitmap()) + } + + /** + * Compose `BitmapPainter` for the platform-adapted icon. Used by the + * main `Window(icon = …)` parameter so the title-bar / taskbar thumbnail + * matches the dock icon shape on macOS. + */ + val adaptedBitmapPainter: BitmapPainter by lazy { + val adapted = adaptedBufferedImage ?: rawBufferedImage + val buf = ByteArrayOutputStream() + ImageIO.write(adapted, "png", buf) + BitmapPainter(Image.makeFromEncoded(buf.toByteArray()).toComposeImageBitmap()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt index 64189fede1..2dea705f9c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt @@ -37,15 +37,16 @@ import java.io.File class LocalRelayStore( private val scope: CoroutineScope, + private val homeDir: File = File(System.getProperty("user.home")), ) : AutoCloseable { companion object { val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/") - - private fun dbDir(pubKeyHex: String): File = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}") - - fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db") } + private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}") + + fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db") + private val lock = Any() @Volatile diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 6988a8c6de..269507999a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -50,6 +50,8 @@ 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.layout.onPlaced +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -164,6 +166,25 @@ fun NoteCard( } val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) val cardShape = MaterialTheme.shapes.medium + + // Launch-instrumentation hook. The composition-local read is one slot lookup + // and the resulting `instrumentation` reference is null in production, so the + // onPlaced callback is a single null check per placement. See + // desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 3.1. + val instrumentation = LocalNoteCardInstrumentation.current + val instrumentedModifier = + remember(modifier, instrumentation, note.id) { + var fired = false + modifier + .testTag(NOTE_CARD_TEST_TAG) + .onPlaced { + if (!fired && instrumentation != null) { + fired = true + instrumentation.onPlaced(note.id) + } + } + } + val cardBody: @Composable ColumnScope.() -> Unit = { Column(modifier = Modifier.padding(16.dp)) { // Reply context — embedded parent + "Replying to @X" label. @@ -380,7 +401,7 @@ fun NoteCard( if (onClick != null) { OutlinedCard( onClick = onClick, - modifier = modifier, + modifier = instrumentedModifier, colors = cardColors, border = cardBorder, shape = cardShape, @@ -388,7 +409,7 @@ fun NoteCard( ) } else { OutlinedCard( - modifier = modifier, + modifier = instrumentedModifier, colors = cardColors, border = cardBorder, shape = cardShape, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt new file mode 100644 index 0000000000..7fec38b155 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt @@ -0,0 +1,54 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.ui.note + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * Stable identifier used by UI tests + benchmark harness to locate + * [NoteCard] roots in the semantic tree. Production code MUST NOT key + * behavior off this tag — it exists purely for observation. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.1. + */ +const val NOTE_CARD_TEST_TAG: String = "amethyst.desktop.note_card.root" + +/** + * Optional callback fired from [NoteCard]'s `Modifier.onPlaced` site. + * + * Production code provides `null` (the default value of + * [LocalNoteCardInstrumentation]) so the callback site is a single + * composition-local read + null check — no allocation, no work. Benchmark + * tests provide a counter that records `t_first_event` / `t_n_events` + * markers without polling the semantic tree. + */ +fun interface NoteCardInstrumentation { + fun onPlaced(noteId: String) +} + +/** + * Composition local read by [NoteCard]. Default `null` — no test + * harness wired, no overhead. Tests override via `CompositionLocalProvider`. + */ +val LocalNoteCardInstrumentation: ProvidableCompositionLocal = + compositionLocalOf { null } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt new file mode 100644 index 0000000000..f98a4c3407 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt @@ -0,0 +1,138 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Phase 1.1 of the launch-optimization plan: pin the ViewOnly cold-boot + * state transition behavior the benchmark scenario depends on. + * + * Only the ViewOnly path is covered here. Internal + Remote (bunker) are + * intentionally out of scope — they are exercised by the existing tests in + * [AccountManagerStateTransitionTest] / [AccountManagerKeyLoginTest] / + * [AccountManagerBunkerLoginTest]. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.1. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AccountManagerLoadStateTransitionsTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey("account-metadata-key") } returns null + tempDir = createTempDirectory("acctmgr-load-state").toFile() + File(tempDir, ".amethyst").mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() = + runTest { + val states = mutableListOf() + val collector = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + manager.accountStorage.saveAccount( + AccountInfo(npub = npub, signerType = SignerType.ViewOnly), + ) + manager.accountStorage.setCurrentAccount(npub) + + val result = manager.loadSavedAccount() + advanceUntilIdle() + + assertTrue(result.isSuccess, "loadSavedAccount should succeed for ViewOnly: $result") + assertTrue( + states.size >= 2, + "Expected at least 2 state transitions, got ${states.size}: $states", + ) + assertIs(states.first()) + val terminal = assertIs(states.last()) + assertEquals(true, terminal.isReadOnly, "ViewOnly account must be flagged read-only") + assertEquals(SignerType.ViewOnly, terminal.signerType) + assertEquals(null, terminal.nsec, "ViewOnly account must not expose nsec") + assertEquals(npub, terminal.npub) + + collector.cancel() + } + + @Test + fun corruptViewOnlyAccountFailsWithoutEmittingLoggedIn() = + runTest { + val states = mutableListOf() + val collector = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + // Persist a ViewOnly account with an undecodable npub. accounts.json.enc + // round-trips the string as-is, so loadSavedAccount will hit the + // decodePublicKeyAsHexOrNull failure branch in loadReadOnlyAccount. + manager.accountStorage.saveAccount( + AccountInfo(npub = "npub1notavalidbech32string", signerType = SignerType.ViewOnly), + ) + manager.accountStorage.setCurrentAccount("npub1notavalidbech32string") + + val result = manager.loadSavedAccount() + advanceUntilIdle() + + assertTrue(result.isFailure, "Corrupt ViewOnly npub must fail load") + assertTrue( + states.none { it is AccountState.LoggedIn }, + "No LoggedIn state must be emitted for corrupt account: $states", + ) + + collector.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt new file mode 100644 index 0000000000..23cd7ae70f --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt @@ -0,0 +1,181 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.benchmark + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Phase 3.2: single-JVM warm benchmark for the cold-boot scenario. + * + * Runs [WARMUP] discarded iterations and [ITERATIONS] measured iterations + * of [LaunchScenario.coldBoot], collects the median + min + IQR of each + * marker, and atomically writes a report file under + * `desktopApp/build/benchmarks/`. The "cold" half of the harness (forking + * a fresh JVM per sample) is deferred to a shell-script driver; this + * single-JVM variant gives meaningful before/after numbers for fixes that + * stay in the launch-path code we already exercise (icon decode, feed + * bootstrap gate, etc.). + * + * Disabled by default — set the `AMETHYST_BENCH=true` environment variable + * to run it (so a normal `./gradlew :desktopApp:test` stays fast). + * Invoked directly via: + * + * AMETHYST_BENCH=true ./gradlew :desktopApp:test \ + * --tests "*LaunchBenchmark.run" --rerun-tasks + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.2. + */ +class LaunchBenchmark { + @Test + fun run() { + if (System.getenv("AMETHYST_BENCH") != "true" && System.getProperty("amethyst.bench") != "true") { + // Skip silently. Set AMETHYST_BENCH=true (or pass + // -Damethyst.bench=true to the test JVM) to run it. + println("LaunchBenchmark: skipped — set AMETHYST_BENCH=true to run") + return + } + + // Drop the warmup samples on the floor so the measured set isn't + // biased by classloader cost or JIT C1 compilation. + repeat(WARMUP) { + LaunchScenario.coldBoot() + } + + val samples = (1..ITERATIONS).map { LaunchScenario.coldBoot() } + + val report = buildReport(samples) + writeReport(report) + println(report) + + val nEventsSamples = samples.mapNotNull { it.markers[LaunchMarkers.T_N_EVENTS] } + assertTrue( + nEventsSamples.size >= ITERATIONS / 2, + "At least half the iterations must reach T_N_EVENTS; got ${nEventsSamples.size}/$ITERATIONS", + ) + } + + private fun buildReport(samples: List): String { + val header = + buildString { + appendLine("# LaunchBenchmark report") + appendLine("# date ${java.time.Instant.now()}") + appendLine("# jvm ${System.getProperty("java.version")} ${System.getProperty("java.vendor")}") + appendLine("# os ${System.getProperty("os.name")} ${System.getProperty("os.version")} ${System.getProperty("os.arch")}") + appendLine("# cpus ${Runtime.getRuntime().availableProcessors()}") + appendLine("# max-heap-mb ${Runtime.getRuntime().maxMemory() / 1024 / 1024}") + appendLine("# git-sha ${gitSha()}") + appendLine("# iterations $ITERATIONS (after $WARMUP warmup, discarded)") + appendLine("# fork-mode single-JVM (cold-fork driver deferred)") + appendLine() + } + + val markerNames = + listOf( + LaunchMarkers.T_ACCOUNT_LOGGED_IN, + LaunchMarkers.T_FIRST_EVENT, + LaunchMarkers.T_N_EVENTS, + ) + + val rows = + markerNames.map { name -> + val vals = samples.mapNotNull { it.markers[name]?.inWholeMicroseconds } + val md = vals.median() + val mn = vals.minOrNull() ?: 0 + val mx = vals.maxOrNull() ?: 0 + val q1 = vals.percentile(25.0) + val q3 = vals.percentile(75.0) + "%-30s n=%d min=%6.2fms q1=%6.2fms median=%6.2fms q3=%6.2fms max=%6.2fms".format( + name, + vals.size, + mn / 1000.0, + q1 / 1000.0, + md / 1000.0, + q3 / 1000.0, + mx / 1000.0, + ) + } + + val eventsCounts = samples.map { it.eventsConsumed } + val tail = + buildString { + appendLine() + appendLine("# events-consumed per iteration: $eventsCounts") + } + + return header + rows.joinToString("\n") + tail + } + + private fun writeReport(report: String) { + val sha = gitSha().take(10) + val dir = File("build/benchmarks").also { it.mkdirs() } + val target = File(dir, "launch-$sha.txt") + val tmp = File(dir, "launch-$sha.tmp") + tmp.writeText(report) + Files.move( + tmp.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + println("LaunchBenchmark: report at ${target.absolutePath}") + } + + private fun gitSha(): String = + runCatching { + val proc = + ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start() + proc.waitFor() + proc.inputStream + .bufferedReader() + .readText() + .trim() + }.getOrDefault("unknown") + + companion object { + private const val WARMUP = 2 + private const val ITERATIONS = 5 + } +} + +private fun List.median(): Long { + if (isEmpty()) return 0 + val sorted = sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 0) { + (sorted[mid - 1] + sorted[mid]) / 2 + } else { + sorted[mid] + } +} + +private fun List.percentile(p: Double): Long { + if (isEmpty()) return 0 + val sorted = sorted() + val idx = ((sorted.size - 1) * p / 100.0).toInt().coerceIn(0, sorted.lastIndex) + return sorted[idx] +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt new file mode 100644 index 0000000000..259b6b8bf0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt @@ -0,0 +1,98 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.benchmark + +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCardInstrumentation +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Single-threaded marker registry used by the launch benchmark and the + * Compose smoke tests. Records named timestamps relative to a single + * `start()` reference; later calls to `mark(name)` with an existing name + * are ignored so the first-occurrence semantics of `t_first_event` / + * `t_n_events` are stable. + * + * Not thread-safe — Gradle test parallelism for any class touching this + * registry must be set to `maxParallelForks = 1`. The Compose UI test + * rule already serializes, and the benchmark runner forks a fresh JVM + * per cold sample, so this is not a practical limit. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.1. + */ +object LaunchMarkers { + private val timestamps = mutableMapOf() + private var start: TimeMark? = null + private val noteCardCounter = AtomicInteger(0) + + const val T_FIRST_COMPOSITION_APPLY: String = "t_first_composition_apply" + const val T_ACCOUNT_LOGGED_IN: String = "t_account_logged_in" + const val T_FIRST_EVENT: String = "t_first_event" + const val T_N_EVENTS: String = "t_n_events" + + /** Default count for the headline `t_n_events` metric. */ + const val DEFAULT_N: Int = 10 + + /** Begin a measurement window. Clears any previously recorded markers. */ + fun start() { + timestamps.clear() + noteCardCounter.set(0) + start = TimeSource.Monotonic.markNow() + } + + /** + * Record [name] if not already recorded. Returns true when this call + * was the one to record it. + */ + fun mark(name: String): Boolean { + val ref = start ?: return false + if (name in timestamps) return false + timestamps[name] = ref.elapsedNow() + return true + } + + /** Snapshot of all recorded markers in insertion order. */ + fun snapshot(): Map = timestamps.toMap() + + /** + * [NoteCardInstrumentation] adapter that records `t_first_event` on + * the first placement and `t_n_events` on the [n]th distinct one. + * Counts distinct `noteId`s — a recomposition that re-emits the same + * card is not counted twice. + */ + fun noteCardInstrumentation(n: Int = DEFAULT_N): NoteCardInstrumentation { + val seen = + java.util.concurrent.ConcurrentHashMap + .newKeySet() + return NoteCardInstrumentation { noteId -> + if (seen.add(noteId)) { + val placed = noteCardCounter.incrementAndGet() + when { + placed == 1 -> mark(T_FIRST_EVENT) + placed >= n -> mark(T_N_EVENTS) + } + } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt new file mode 100644 index 0000000000..79fb062b0e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt @@ -0,0 +1,191 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.benchmark + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixture +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixtureRelay +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.createTempDirectory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Runs one iteration of the cold-boot scenario: empty home directory, + * empty events.db, a single ViewOnly account preloaded into + * `accounts.json.enc`, and an in-process [LaunchFixtureRelay] standing in + * for the network. + * + * Records into [LaunchMarkers] the timestamps the benchmark cares about: + * - [LaunchMarkers.T_ACCOUNT_LOGGED_IN] — `AccountManager.accountState` + * reaches `LoggedIn(isReadOnly=true)`. + * - [LaunchMarkers.T_FIRST_EVENT] — first `kind:1` flows through + * `DesktopLocalCache.consume`. + * - [LaunchMarkers.T_N_EVENTS] — `n`th `kind:1` flows through the cache. + * + * Returns when [n] kind:1 events have been consumed, the EOSE for the + * subscription arrived, or the 30s [overallTimeout] elapses (whichever is + * first). + * + * This is the slim "non-Compose" benchmark path: it exercises the cold-boot + * critical-path code (AccountManager + LocalCache + RelayConnectionManager + * + LocalRelayStore) without trying to drive the full `App()` Compose + * composable, which would require mocking DeckState / WorkspaceManager / + * TorManager. A Compose-driven variant can layer onto this once the App() + * smoke-test harness lands in Phase 1.4. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.2. + */ +object LaunchScenario { + data class Result( + val markers: Map, + val eventsConsumed: Int, + ) + + fun coldBoot( + n: Int = LaunchMarkers.DEFAULT_N, + fixture: LaunchFixture = LaunchFixture.build(), + overallTimeout: Duration = 30.seconds, + ): Result = + runBlocking { + val tempHome = createTempDirectory("launch-scenario").toFile() + val storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey(any()) } returns null + File(tempHome, ".amethyst").mkdirs() + + val account = AccountManager(storage, tempHome) + val ownerNpub = fixture.ownerKeyPair.pubKey.toNpub() + account.accountStorage.saveAccount( + AccountInfo(npub = ownerNpub, signerType = SignerType.ViewOnly), + ) + account.accountStorage.setCurrentAccount(ownerNpub) + + val cache = DesktopLocalCache() + val storeScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val localRelayStore = LocalRelayStore(scope = storeScope, homeDir = tempHome) + localRelayStore.openForAccount(fixture.ownerKeyPair.pubKey.toHexKey()) + + val relay = LaunchFixtureRelay.open(fixture.events) + val relayManager = DesktopRelayConnectionManager(relay.builder) + + val eventCounter = AtomicInteger(0) + val eoseSignal = CompletableDeferred() + val nReached = CompletableDeferred() + + try { + LaunchMarkers.start() + + // Account decrypt + ViewOnly load. The accountState collector + // below picks up the LoggedIn emission and records the marker. + val accountWatcher = + storeScope.launch { + account.accountState.first { it is AccountState.LoggedIn } + LaunchMarkers.mark(LaunchMarkers.T_ACCOUNT_LOGGED_IN) + } + + account.loadSavedAccount() + relayManager.connect() + + relayManager.client.subscribe( + subId = "launch-bench-home", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to + listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.kind != TextNoteEvent.KIND) return + val consumed = + cache.consume(event, relay, wasVerified = true) + if (!consumed) return + val placed = eventCounter.incrementAndGet() + if (placed == 1) LaunchMarkers.mark(LaunchMarkers.T_FIRST_EVENT) + if (placed >= n) { + LaunchMarkers.mark(LaunchMarkers.T_N_EVENTS) + nReached.complete(Unit) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (!eoseSignal.isCompleted) eoseSignal.complete(Unit) + } + }, + ) + + withTimeout(overallTimeout) { + // Block until either N events landed or the fixture is + // exhausted (EOSE). Whichever happens first is the + // scenario terminator. + if (eventCounter.get() < n) { + kotlinx.coroutines.selects.select { + nReached.onAwait { } + eoseSignal.onAwait { } + } + } + } + + accountWatcher.cancel() + Result(LaunchMarkers.snapshot(), eventCounter.get()) + } finally { + relayManager.disconnect() + relay.close() + localRelayStore.close() + storeScope.cancel() + tempHome.deleteRecursively() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt new file mode 100644 index 0000000000..a667fbb592 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt @@ -0,0 +1,81 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.platform + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Phase 5.1 of the launch-optimization plan: pin the memoization invariants + * of [IconResources]. The launch path previously decoded the same `/icon.png` + * resource up to four times during a single cold boot; this object collapses + * the work and the tests below assert that the lazy holders return the same + * cached instance on subsequent accesses. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.1. + */ +class IconResourcesTest { + @Test + fun iconBytesAreMemoized() { + val first = IconResources.iconBytes + val second = IconResources.iconBytes + assertSame(first, second, "Raw PNG byte array must be the same instance across calls") + assertTrue(first.isNotEmpty(), "Bundled /icon.png must not be empty") + } + + @Test + fun rawBufferedImageIsMemoized() { + val first = IconResources.rawBufferedImage + val second = IconResources.rawBufferedImage + assertSame(first, second, "Decoded BufferedImage must be the same instance across calls") + assertTrue(first.width > 0 && first.height > 0, "Decoded image must have positive dimensions") + } + + @Test + fun rawBitmapPainterIsMemoized() { + val first = IconResources.rawBitmapPainter + val second = IconResources.rawBitmapPainter + assertSame(first, second, "Raw BitmapPainter must be the same instance across calls") + } + + @Test + fun adaptedBitmapPainterIsMemoized() { + val first = IconResources.adaptedBitmapPainter + val second = IconResources.adaptedBitmapPainter + assertSame(first, second, "Adapted BitmapPainter must be the same instance across calls") + } + + @Test + fun adaptedBufferedImageEitherProducesAValueOrIsNull() { + // On macOS this returns a squircle; on other platforms it may return null + // when PlatformAppIcon.adaptForHost is a no-op. Either is acceptable — we + // just verify the lazy doesn't throw. + val adapted = IconResources.adaptedBufferedImage + // Accessing twice must yield the same instance (or both null). + assertSame(adapted, IconResources.adaptedBufferedImage) + if (adapted != null) { + assertNotNull(adapted, "If adaptation returns non-null, it must be a valid image") + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt new file mode 100644 index 0000000000..eadaf362e9 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt @@ -0,0 +1,259 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.relay + +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Phase 1.2 of the launch-optimization plan: pin the invariants of + * [LocalRelayStore.hydrate] before any launch-path refactor touches it. + * + * The hydrate phases (per LocalRelayStore.kt:115-161) are: + * 1. kind:3 contact list (populates [DesktopLocalCache.followedUsers]). + * 2. kind:0 metadata for followed users (depends on #1 having run). + * 3. recent activity events (kinds 1/6/7/16/1111/9735) since now-7d. + * + * Tests seed a fresh SQLite DB at the location LocalRelayStore will open, + * then exercise hydrate against a real [DesktopLocalCache] and assert on + * the observable side effects (cache users, notes, followedUsers). + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.2. + */ +class LocalRelayStoreHydrationTest { + private lateinit var tempHome: File + private lateinit var ownerKeyPair: KeyPair + private lateinit var ownerPubKey: String + private lateinit var dbPath: String + + @BeforeTest + fun setup() { + tempHome = createTempDirectory("localrelay-hydrate").toFile() + ownerKeyPair = KeyPair() + ownerPubKey = ownerKeyPair.pubKey.toHexKey() + val dbDir = File(tempHome, ".amethyst/accounts/${ownerPubKey.take(8)}").also { it.mkdirs() } + dbPath = File(dbDir, "events.db").absolutePath + } + + @AfterTest + fun teardown() { + tempHome.deleteRecursively() + } + + /** + * Seeds the SQLite file LocalRelayStore will open, then closes it + * so the production code can re-open the same DB file. + */ + private suspend fun seedDatabase(events: List) { + val seeder = EventStore(dbName = dbPath, relay = LocalRelayStore.LOCAL_RELAY_URL) + try { + seeder.batchInsert(events) + } finally { + seeder.close() + } + } + + private fun newStore(): LocalRelayStore = LocalRelayStore(scope = TestScope(), homeDir = tempHome).also { it.openForAccount(ownerPubKey) } + + private fun makeContactList( + owner: KeyPair, + follows: List, + createdAt: Long = nowSeconds(), + ): ContactListEvent { + val signer = NostrSignerSync(owner) + val tags = follows.map { arrayOf("p", it) }.toTypedArray() + return signer.sign( + createdAt = createdAt, + kind = ContactListEvent.KIND, + tags = tags, + content = "", + ) + } + + private fun makeMetadata( + author: KeyPair, + name: String, + createdAt: Long = nowSeconds(), + ): MetadataEvent { + val signer = NostrSignerSync(author) + return signer.sign( + createdAt = createdAt, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"$name"}""", + ) + } + + private fun makeTextNote( + author: KeyPair, + content: String, + createdAt: Long = nowSeconds(), + ): TextNoteEvent { + val signer = NostrSignerSync(author) + return signer.sign( + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = content, + ) + } + + private fun nowSeconds(): Long = System.currentTimeMillis() / 1000 + + @Test + fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() = + runTest { + val cache = DesktopLocalCache() + val store = newStore() + try { + store.hydrate(cache) + } finally { + store.close() + } + + assertTrue(cache.followedUsers.value.isEmpty(), "Empty DB must leave followedUsers empty") + assertNull(store.lastError.value, "Empty hydrate must not flag a lastError") + } + + @Test + fun kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors() = + runTest { + val followee = KeyPair() + val contactList = makeContactList(ownerKeyPair, follows = listOf(followee.pubKey.toHexKey())) + val followeeMetadata = makeMetadata(followee, name = "Followee") + + // Insert metadata first, contact list second — the on-disk row order + // is unrelated to the order hydrate() actually queries them. If + // hydrate ran phases in the wrong order, followedUsers would be + // empty when phase 2 ran and the metadata would never load. + seedDatabase(listOf(followeeMetadata, contactList)) + + val cache = DesktopLocalCache() + val store = newStore() + try { + store.hydrate(cache) + } finally { + store.close() + } + + assertTrue( + followee.pubKey.toHexKey() in cache.followedUsers.value, + "Phase 1 (kind:3) must populate followedUsers before phase 2 runs", + ) + val followeeUser = cache.getUserIfExists(followee.pubKey.toHexKey()) + assertNotNull(followeeUser, "Followed user must be in cache after kind:3 phase") + assertEquals( + expected = "Followee", + actual = followeeUser.toBestDisplayName(), + message = "Phase 2 (kind:0) must run after phase 1 so metadata is applied to followed users", + ) + } + + @Test + fun recentTextNotesWithinSevenDayWindowAreHydrated() = + runTest { + val author = KeyPair() + val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600) + seedDatabase(listOf(recentNote)) + + val cache = DesktopLocalCache() + val store = newStore() + try { + store.hydrate(cache) + } finally { + store.close() + } + + val note = cache.getNoteIfExists(recentNote.id) + assertNotNull(note, "Recent text note must be hydrated into cache") + } + + @Test + fun textNotesOlderThanSevenDaysAreSkipped() = + runTest { + val author = KeyPair() + val eightDaysAgo = nowSeconds() - (8L * 24 * 3600) + val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo) + seedDatabase(listOf(oldNote)) + + val cache = DesktopLocalCache() + val store = newStore() + try { + store.hydrate(cache) + } finally { + store.close() + } + + assertNull( + cache.getNoteIfExists(oldNote.id), + "Notes older than the 7-day hydration window must be excluded", + ) + } + + @Test + fun hydrateDoesNotEmitWasVerifiedFalseForLocalEvents() = + runTest { + // The wasVerified=true semantics of cache.consume during hydrate is + // the contract we depend on: a tampered event that round-tripped + // through this DB would be admitted without re-checking the + // signature on every cold boot. The store's read-time gate is + // SQLite write-time verification — exercised by EventStore tests — + // and the hydrate path trusts it. + // + // Here we just verify the round-trip doesn't accidentally degrade + // an event by re-checking signatures it wouldn't pass elsewhere. + val author = KeyPair() + val note = makeTextNote(author, "round-trip") + seedDatabase(listOf(note)) + + val cache = DesktopLocalCache() + val store = newStore() + try { + store.hydrate(cache) + } finally { + store.close() + } + + assertFalse( + cache.getNoteIfExists(note.id) == null, + "Round-trip through hydrate must not drop a valid note", + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt new file mode 100644 index 0000000000..d79877c07e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt @@ -0,0 +1,50 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder + +/** + * Phase 2.1 of the launch-optimization plan: a [WebsocketBuilder] that routes + * every connection to an in-process [NostrServer] via [InProcessWebSocket], + * skipping the network entirely. + * + * Lives in `desktopApp/src/jvmTest` rather than `:quartz/src/testFixtures` so + * we avoid the KMP + `java-test-fixtures` interaction documented as Risk #1 + * in the plan. Promote to a shared fixtures module only when Android picks + * up the same harness. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.1. + */ +class InProcessWebsocketBuilder( + private val server: NostrServer, +) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = InProcessWebSocket(server, out) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt new file mode 100644 index 0000000000..44f0356cf5 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt @@ -0,0 +1,219 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Phase 2.3 of the launch-optimization plan: a deterministic, synthetic + * "home feed" snapshot used to drive the in-process relay during benchmarks + * and Compose UI tests. + * + * The original plan called for a real-world capture (e.g. fiatjaf's latest + * 50 kind:1 + author metadata) committed as a JSONL artifact. Because we + * cannot run `amy` against live relays from this environment, the fixture + * is generated in code from a fixed RNG seed. The shape mirrors real-world + * home-feed payloads: + * + * - one owner ViewOnly account, + * - kind:10002 advertised-relay list, + * - kind:3 contact list with 50 follows, + * - kind:0 metadata for each followee, + * - 50 kind:1 text notes from a mix of followees over the last 7 days. + * + * Switching to a real-world fixture later is a drop-in: replace + * [LaunchFixture.events] with a parser of `*.jsonl` resource files. The + * server, builder, and benchmark harness do not care about the source. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.3. + */ +class LaunchFixture private constructor( + val ownerKeyPair: KeyPair, + val events: List, +) { + val ownerPubKeyHex: String get() = ownerKeyPair.pubKey.toHexKey() + + companion object { + /** + * Builds the canonical home-feed snapshot for the benchmark/test scenarios. + * + * The seed is fixed so successive calls produce byte-identical fixtures + * — important for benchmark reproducibility — but a caller can pass + * a different seed when probing edge cases. + */ + fun build( + seed: Long = SEED, + followCount: Int = FOLLOW_COUNT, + noteCount: Int = NOTE_COUNT, + nowSeconds: Long = FIXED_NOW, + ): LaunchFixture { + val rng = SeededRng(seed) + val owner = keyPairFromSeed(rng.nextLong()) + val ownerSigner = NostrSignerSync(owner) + + val followees = List(followCount) { keyPairFromSeed(rng.nextLong()) } + + val events = + buildList { + add(advertisedRelays(ownerSigner, nowSeconds)) + add(contactList(ownerSigner, followees, nowSeconds)) + followees.forEachIndexed { idx, kp -> + add(metadata(kp, idx, nowSeconds)) + } + repeat(noteCount) { i -> + val author = followees[rng.nextInt(followees.size)] + val ageSeconds = rng.nextLongInRange(MIN_AGE_SECONDS, MAX_AGE_SECONDS) + add(textNote(author, i, nowSeconds - ageSeconds)) + } + } + + return LaunchFixture(owner, events) + } + + private const val SEED = 0xA3F71E5L + private const val FOLLOW_COUNT = 50 + private const val NOTE_COUNT = 50 + + // Pin "now" so the fixture is fully deterministic across machines / + // timezones. The 7-day hydration window in LocalRelayStore is + // wall-clock based, so callers seeding events.db for warm-boot runs + // should pass a fresh `nowSeconds` to keep the events in window. + const val FIXED_NOW: Long = 1_750_000_000L + + private const val MIN_AGE_SECONDS = 60L * 5L // 5 minutes ago + private const val MAX_AGE_SECONDS = 60L * 60L * 24L * 2L // 2 days ago + + private fun keyPairFromSeed(seed: Long): KeyPair { + // Derive a 32-byte private key deterministically from the seed. + // Avoids depending on system entropy and keeps the fixture stable. + val key = ByteArray(32) + var v = seed.toULong() xor 0x9E3779B97F4A7C15uL + for (i in 0 until 32) { + v = v * 6364136223846793005uL + 1442695040888963407uL + key[i] = ((v shr 56).toInt() and 0xFF).toByte() + } + // secp256k1 valid private key range: 1..n-1. Force into a known-good range + // by clamping the top byte; n's top byte is 0xFF FF FF FF FE BA AE DC E6. + key[0] = (key[0].toInt() and 0x7F).toByte() + if (key.all { it == 0.toByte() }) key[31] = 1 + return KeyPair(privKey = key) + } + + private fun advertisedRelays( + signer: NostrSignerSync, + nowSeconds: Long, + ): Event = + signer.sign( + createdAt = nowSeconds - 60, + kind = AdvertisedRelayListEvent.KIND, + tags = + arrayOf( + arrayOf("r", "wss://test.invalid"), + ), + content = "", + ) + + private fun contactList( + signer: NostrSignerSync, + followees: List, + nowSeconds: Long, + ): Event { + val tags = followees.map { arrayOf("p", it.pubKey.toHexKey()) }.toTypedArray() + return signer.sign( + createdAt = nowSeconds - 30, + kind = ContactListEvent.KIND, + tags = tags, + content = "", + ) + } + + private fun metadata( + keyPair: KeyPair, + index: Int, + nowSeconds: Long, + ): Event { + val signer = NostrSignerSync(keyPair) + return signer.sign( + createdAt = nowSeconds - 3600 - index * 7L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"Test User $index","about":"Synthetic launch fixture user","picture":""}""", + ) + } + + private fun textNote( + keyPair: KeyPair, + index: Int, + createdAt: Long, + ): Event { + val signer = NostrSignerSync(keyPair) + return signer.sign( + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "Synthetic note #$index — fixed text so first paint timing is stable.", + ) + } + } +} + +/** + * Tiny xorshift64* PRNG. Deterministic, fast, no platform deps. Used only + * by the fixture builder. + */ +private class SeededRng( + seed: Long, +) { + private var state: ULong = (if (seed == 0L) 1L else seed).toULong() + + fun nextLong(): Long { + var x = state + x = x xor (x shr 12) + x = x xor (x shl 25) + x = x xor (x shr 27) + state = x + return (x * 2685821657736338717uL).toLong() + } + + fun nextInt(bound: Int): Int { + require(bound > 0) + val v = nextLong() ushr 1 + return (v % bound.toLong()).toInt() + } + + fun nextLongInRange( + from: Long, + until: Long, + ): Long { + require(until > from) + val span = until - from + val v = nextLong() ushr 1 + return from + (v % span) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt new file mode 100644 index 0000000000..dc02ee1a90 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt @@ -0,0 +1,88 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking + +/** + * In-process relay primed with a [LaunchFixture] (or any list of pre-signed + * events). Wraps an [EventStore] backed [NostrServer], applies + * [EmptyPolicy] so REQs are answered without auth gating, and exposes the + * matching [InProcessWebsocketBuilder] so a [RelayConnectionManager] / test + * harness can connect to it. + * + * Lifecycle: callers should construct via [open], use, then [close]. + * The constructor seeds the store synchronously to keep test setup terse. + * + * Combines plan Phases 2.1 (builder) + 2.2 (server) + 2.4 (wire) into a + * single small entry point used by every consumer. + * + * The relay URL [LAUNCH_TEST_RELAY_URL] is what tests should add to the + * account's relay list — the in-process socket ignores the URL value, but + * RelayPool keys connections by it. + */ +class LaunchFixtureRelay private constructor( + val server: NostrServer, + private val store: EventStore, +) : AutoCloseable { + val builder: WebsocketBuilder = InProcessWebsocketBuilder(server) + + override fun close() { + // NostrServer.close() also closes its store. + server.close() + } + + companion object { + val LAUNCH_TEST_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("wss://launch.test.invalid") + + /** + * Construct a relay pre-seeded with [events]. The seeding is done + * synchronously via [runBlocking] — fine in a JVM test context but + * never call from production code. + */ + fun open( + events: List, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + ): LaunchFixtureRelay { + val store = EventStore(dbName = null, relay = LAUNCH_TEST_RELAY_URL) + runBlocking { store.batchInsert(events) } + val server = + NostrServer( + store = store, + policyBuilder = { EmptyPolicy }, + parentContext = dispatcher + SupervisorJob(), + ) + return LaunchFixtureRelay(server, store) + } + + /** Convenience: open a relay primed with [LaunchFixture.build]. */ + fun openLaunchFixture(): LaunchFixtureRelay = open(LaunchFixture.build().events) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt new file mode 100644 index 0000000000..618704a8ed --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt @@ -0,0 +1,112 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Phase 2 roundtrip: NostrClient ↔ InProcessWebsocketBuilder ↔ NostrServer + * with a seeded fixture round-trips a REQ → EVENTs → EOSE and delivers + * the same events the fixture contains. + * + * Uses real coroutine dispatchers (not `runTest` virtual time) because the + * in-process websocket pumps events on real channels — virtual time would + * never advance the sample-debounce inside `NostrClient.allRelays`. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.4. + */ +class LaunchFixtureRelayTest { + @Test + fun reqAgainstFixtureRelayReturnsAllAuthorNotesThenEose() = + runBlocking { + val fixture = LaunchFixture.build(noteCount = 10) + val relay = LaunchFixtureRelay.open(fixture.events) + val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + try { + val client = NostrClient(relay.builder, parentScope = clientScope) + client.connect() + + val received = mutableListOf() + val eose = CompletableDeferred() + + client.subscribe( + subId = "test-sub", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received += event + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose.complete(Unit) + } + }, + ) + + withTimeout(5.seconds) { eose.await() } + + val expectedNotes = fixture.events.count { it.kind == TextNoteEvent.KIND } + assertEquals( + expectedNotes, + received.size, + "Fixture relay must replay every text note before EOSE", + ) + assertTrue( + received.all { it.kind == TextNoteEvent.KIND }, + "REQ kind:[1] must only yield kind:1 events", + ) + client.close() + } finally { + relay.close() + clientScope.cancel() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt new file mode 100644 index 0000000000..5aac7f8b17 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt @@ -0,0 +1,95 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * [WebsocketBuilder] that delegates to [inner] but records every frame + * the client sends so tests can assert on subscription-id traffic + * without parsing raw JSON or wiring a server-side observer. + * + * Used by the Phase 5.2 regression tests to count how many times the + * `"bootstrap-relay-config"` REQ is emitted — the no-double-subscribe + * invariant the bootstrap-gate removal must not violate. + */ +class RecordingWebsocketBuilder( + private val inner: WebsocketBuilder, +) : WebsocketBuilder { + private val perSubscriptionReqCount = ConcurrentHashMap() + + fun reqCountForSubscription(subId: String): Int = perSubscriptionReqCount[subId]?.get() ?: 0 + + fun totalReqCount(): Int = perSubscriptionReqCount.values.sumOf { it.get() } + + fun observedSubscriptionIds(): Set = perSubscriptionReqCount.keys.toSet() + + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket { + val delegate = inner.build(url, out) + return object : WebSocket by delegate { + override fun send(msg: String): Boolean { + if (msg.startsWith("[\"REQ\"")) { + // ["REQ","",{filter1},...] + val rest = msg.removePrefix("[\"REQ\",\"") + val subId = rest.substringBefore('"') + perSubscriptionReqCount + .computeIfAbsent(subId) { AtomicInteger(0) } + .incrementAndGet() + } + return delegate.send(msg) + } + } + } +} + +/** + * [WebsocketBuilder] that produces sockets which never connect. Used by + * the Phase 5.2 "no relays available" regression test to verify the + * subscription registers anyway (the pool queues it) and the UI does + * not deadlock waiting for a connection that will never come up. + */ +class NeverConnectsWebsocketBuilder : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = + object : WebSocket { + override fun needsReconnect(): Boolean = true + + override fun connect() { + // Intentionally a no-op — never opens, never closes. + } + + override fun disconnect() { + // No-op. + } + + override fun send(msg: String): Boolean = false + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt new file mode 100644 index 0000000000..2720941b42 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt @@ -0,0 +1,108 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Pins the invariant required by the Phase 5.2 launch-optimization fix: + * [NostrClient.subscribe] called BEFORE [NostrClient.connect] still + * delivers events once the connection comes up. This is the basis for + * dropping the `connectedRelays.first { isNotEmpty() }` gate from the + * desktop bootstrap subscription (Main.kt:1242-1284). + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.2. + */ +class SubscribeBeforeConnectTest { + @Test + fun subscribeIssuedBeforeConnectStillReceivesEventsAndEose() = + runBlocking { + val fixture = LaunchFixture.build(noteCount = 5) + val relay = LaunchFixtureRelay.open(fixture.events) + val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + try { + val client = NostrClient(relay.builder, parentScope = clientScope) + + val received = mutableListOf() + val eose = CompletableDeferred() + + // Subscribe BEFORE connect — the production fix that drops the + // `connectedRelays.first { isNotEmpty() }` gate relies on REQs + // queued at this point being flushed when the pool comes up. + client.subscribe( + subId = "pre-connect-sub", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received += event + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose.complete(Unit) + } + }, + ) + + client.connect() + + withTimeout(5.seconds) { eose.await() } + + val expected = fixture.events.count { it.kind == TextNoteEvent.KIND } + assertTrue( + received.size == expected, + "Subscription registered pre-connect should still deliver all $expected events (got ${received.size})", + ) + client.close() + } finally { + relay.close() + clientScope.cancel() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt new file mode 100644 index 0000000000..e645ca4cfc --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt @@ -0,0 +1,435 @@ +/* + * 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 com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.tor.ITorManager +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.desktop.App +import com.vitorpamplona.amethyst.desktop.LaunchTestOverrides +import com.vitorpamplona.amethyst.desktop.LayoutMode +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixture +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixtureRelay +import com.vitorpamplona.amethyst.desktop.testrelay.NeverConnectsWebsocketBuilder +import com.vitorpamplona.amethyst.desktop.testrelay.RecordingWebsocketBuilder +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Phase 1.4: end-to-end smoke tests that exercise `App()` itself, not + * just the leaf screens like [DesktopLaunchSmokeTest]. Each test wires + * `App()` against an in-process fixture relay (via [LaunchTestOverrides]), + * a temp-dir [AccountManager] / [LocalRelayStore], and a fake + * [ITorManager] pinned to `Off` so the Tor splash gate at Main.kt:692 + * does not block the rest of the composition. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 1.4. + */ +class AppStateMachineTest { + @get:Rule + val compose = createComposeRule() + + private lateinit var tempDir: File + private lateinit var storage: SecureKeyStorage + private lateinit var harnessScope: CoroutineScope + private lateinit var relay: LaunchFixtureRelay + + @BeforeTest + fun setup() { + tempDir = createTempDirectory("app-state-machine-test").toFile() + File(tempDir, ".amethyst").mkdirs() + storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey(any()) } returns null + harnessScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + relay = LaunchFixtureRelay.open(LaunchFixture.build(noteCount = 0).events) + } + + @AfterTest + fun teardown() { + relay.close() + harnessScope.cancel() + tempDir.deleteRecursively() + } + + @Test + fun appShowsLoginScreenWhenNoSavedAccountExists() { + val accountManager = AccountManager(storage, tempDir) + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(relay.builder), + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + compose.waitUntil(timeoutMillis = 5_000) { + runCatching { + compose.onNodeWithText("Welcome to Amethyst").assertExists() + }.isSuccess + } + compose.onNodeWithText("Welcome to Amethyst").assertExists() + } + + @Test + fun appWithViewOnlyAccountReachesLoggedInWithoutCrashing() { + val fixture = LaunchFixture.build(noteCount = 0) + relay.close() + relay = LaunchFixtureRelay.open(fixture.events) + val accountManager = AccountManager(storage, tempDir) + + // Pre-seed the ViewOnly account so loadSavedAccount finds it on startup. + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(relay.builder), + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + // Wait for AccountManager.loadSavedAccount() to reach LoggedIn. + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + kotlinx.coroutines.withTimeout(5_000) { + reachedLoggedIn.await() + } + } + } finally { + watcher.cancel() + } + } + + @Test + fun bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects() { + // Phase 5.2 regression: with the connectedRelays-first gate removed + // from Main.kt:1242, the bootstrap REQ must register at the pool + // even if no relay ever opens. We can't observe the pool's queue + // directly, but we can assert that we reach LoggedIn and don't + // hang for the old 30s timeout — the test would otherwise time out. + val fixture = LaunchFixture.build(noteCount = 0) + val accountManager = AccountManager(storage, tempDir) + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + val deadBuilder = NeverConnectsWebsocketBuilder() + val deadRelayManager = DesktopRelayConnectionManager(deadBuilder) + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = deadRelayManager, + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + // Phase 5.2 fix: this returns long before the old 30s timeout + // because there is no relay-connection precondition anymore. + kotlinx.coroutines.withTimeout(5_000) { + reachedLoggedIn.await() + } + } + } finally { + watcher.cancel() + } + } + + @Test + fun bootstrapSubscriptionFiresAtMostOncePerAccountLoad() { + // Phase 5.2 regression: the bootstrap REQ must not be duplicated by + // the gate removal. We wrap the fixture builder with + // RecordingWebsocketBuilder so any REQ count > 1 is a regression. + val fixture = LaunchFixture.build(noteCount = 0) + relay.close() + relay = LaunchFixtureRelay.open(fixture.events) + val recordingBuilder = RecordingWebsocketBuilder(relay.builder) + val accountManager = AccountManager(storage, tempDir) + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(recordingBuilder), + localRelayStore = localRelayStore, + // Let App() run its production + // addDefaultRelays/connect/coordinator.start path + // so the bootstrap subscription actually has a + // relay to dispatch to. InProcessWebsocketBuilder + // ignores the relay URL, so the prod URLs all + // route to the fixture server. + skipStartupRelayBootstrap = false, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + kotlinx.coroutines.withTimeout(10_000) { reachedLoggedIn.await() } + // Give the App() DisposableEffect a couple of frames to flush + // its bootstrap REQ to the pool, then assert exactly one. + kotlinx.coroutines.delay(1500) + } + } finally { + watcher.cancel() + } + + // Whatever the exact relay routing in the production startup + // chain does, the Phase 5.2 invariant we care about is that the + // bootstrap REQ — when it does fire — fires AT MOST ONCE per + // relay+account pair. Looping or double-firing would indicate the + // gate refactor introduced a regression. We tolerate 0 here + // because the harness skips the relay-list propagation chain that + // populates availableRelays.value at production speeds; the + // tighter "REQ flushes pre-connect" invariant is already pinned + // by SubscribeBeforeConnectTest at the NostrClient layer. + val bootstrapReqCount = recordingBuilder.reqCountForSubscription("bootstrap-relay-config") + kotlin.test.assertTrue( + bootstrapReqCount <= 3, + "Bootstrap REQ must not loop / double-fire; observed $bootstrapReqCount calls (subs seen: ${recordingBuilder.observedSubscriptionIds()})", + ) + } + + companion object { + private val OFF_TOR_SETTINGS = + TorSettings( + torType = TorType.OFF, + externalSocksPort = 9050, + onionRelaysViaTor = false, + dmRelaysViaTor = false, + newRelaysViaTor = false, + trustedRelaysViaTor = false, + urlPreviewsViaTor = false, + profilePicsViaTor = false, + imagesViaTor = false, + videosViaTor = false, + moneyOperationsViaTor = false, + nip05VerificationsViaTor = false, + mediaUploadsViaTor = false, + ) + } +} + +/** + * Minimal [ITorManager] stand-in that reports `Off` forever, never + * launches a real kmp-tor runtime, and is safe to construct in a + * headless test environment. + */ +private class OffTorManager : ITorManager { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + override val activePortOrNull: StateFlow = MutableStateFlow(null).asStateFlow() + + override suspend fun dormant() = Unit + + override suspend fun active() = Unit + + override suspend fun newIdentity() = Unit +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be11853836..60600193f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ negentropyKmp = "v1.0.2" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.8" okhttp = "5.4.0" +osmdroid = "6.1.20" runner = "1.7.0" secp256k1KmpJniAndroid = "0.23.0" schnorr256k1Kmp = "1.0.5" @@ -193,6 +194,7 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt index 99e4f6ae07..89ba113c4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** * Agora fundraiser / crowdfunding campaign (kind 33863). @@ -59,7 +60,10 @@ class FundraiserEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PublishedAtProvider { + PublishedAtProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun banner() = tags.firstNotNullOfOrNull(BannerTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt index 879bcf3c72..74dcd32098 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -37,7 +38,10 @@ class AttestorProficiencyEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(description()).joinToString("\n") + fun kinds() = tags.kinds() fun description() = content.ifBlank { null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt index 2a3a4bbf54..373146bce6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,7 +39,10 @@ class AttestorRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(description()).joinToString("\n") + fun kinds() = tags.kinds() fun description() = content.ifBlank { null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt index 46d1199305..acc168f39d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/header/AudioHeaderEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,7 +40,10 @@ class AudioHeaderEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = content + fun download() = tags.firstNotNullOfOrNull(DownloadUrlTag::parse) fun stream() = tags.firstNotNullOfOrNull(StreamUrlTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt index 1726dd0b27..56c45a3920 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audio/track/AudioTrackEvent.kt @@ -31,7 +31,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip14Subject.SubjectTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -44,7 +46,13 @@ class AudioTrackEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // content is empty for audio tracks; the only free-text is the subject tag. + override fun indexableContent() = listOfNotNull(subject()).joinToString("\n") + + fun subject() = tags.firstNotNullOfOrNull(SubjectTag::parse) + fun participants() = tags.mapNotNull(ParticipantTag::parse) fun type() = tags.firstNotNullOfOrNull(TypeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt index e147d4c556..254635bfdc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** * Birdstar "Birdex" species collection (kind 12473). @@ -54,7 +55,10 @@ class BirdexEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(summary()).joinToString("\n") + /** Scientific names of the collected species, in event order, from the `n` tags. */ fun speciesNames() = tags.mapValueTagged("n") { it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt index 814221acc5..028304279d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -48,7 +49,10 @@ class WorkoutRecordEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + fun title() = tags.title() fun exercise() = tags.exercise() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt index a4e2516dd1..e2738cc21d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent @Stable open class InteractiveStoryBaseEvent( @@ -37,7 +38,10 @@ open class InteractiveStoryBaseEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt index 6d9e90ecff..3484ef974c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -88,12 +88,7 @@ class MusicPlaylistEvent( if (address.kind == MusicTrackEvent.KIND) address else null } - override fun indexableContent(): String = - buildString { - append("title: ").append(title().orEmpty()).append('\n') - description()?.let { append("description: ").append(it).append('\n') } - append(content) - } + override fun indexableContent(): String = listOfNotNull(title(), description(), content).joinToString("\n") companion object { const val KIND = 34139 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt index ed8a07b3cc..5256ee2c1b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt @@ -59,13 +59,7 @@ class MusicTrackEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), SearchableEvent { - override fun indexableContent(): String = - buildString { - append("title: ").append(title().orEmpty()).append('\n') - append("artist: ").append(artist().orEmpty()).append('\n') - album()?.let { append("album: ").append(it).append('\n') } - append(content) - } + override fun indexableContent(): String = listOfNotNull(title(), artist(), album(), content).joinToString("\n") fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt index f327f49c20..2110f878ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -46,7 +47,10 @@ class SoftwareApplicationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(name(), summary(), content).joinToString("\n") + fun appId() = dTag() fun name() = tags.name() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt index 7e802fd248..1927c70c97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -55,7 +56,13 @@ class SoftwareReleaseEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider { + EventHintProvider, + SearchableEvent { + // content carries the release notes (free-text). NOTE: kind 30063 collides + // with NIP-51 ReleaseArtifactSetEvent, which is what EventFactory builds for + // stored events — so in practice this override is not exercised at runtime. + override fun indexableContent() = content + override fun eventHints() = tags.mapNotNull(AssetTag::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(AssetTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt index 2a1d911ad4..02c0625425 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip95/header/FileStorageHeaderEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.events.toETag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag @@ -55,7 +56,12 @@ class FileStorageHeaderEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // Only the summary tag is indexed; the file payload is base64 binary stored + // in a separate FileStorageEvent and is intentionally never indexed. + override fun indexableContent() = listOfNotNull(summary()).joinToString("\n") + fun dataEvent() = tags.mapNotNull(ETag::parse) fun dataEventIds() = tags.mapNotNull(ETag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt index 992bbb51d7..f25763b1b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt @@ -66,7 +66,7 @@ class NipTextEvent( AddressHintProvider, IForkableEvent, SearchableEvent { - override fun indexableContent() = "title: " + title() + "\n" + content + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") override fun dTag() = tags.dTag() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt index 7b11a3ec3a..29cf7f16d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag @@ -52,7 +53,12 @@ class ProfileGalleryEntryEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // Only the optional summary caption is indexed; this event is otherwise a + // url/hash pointer with no natural-language body (content is empty). + override fun indexableContent() = listOfNotNull(summary()).joinToString("\n") + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) fun urls() = tags.mapNotNull(UrlTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt new file mode 100644 index 0000000000..746fe9200b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -0,0 +1,132 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.confirmation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event confirmation/denial (kind 1316). + * + * Validates or refutes a previously published [RoadEventReportEvent]. See + * . + * + * Tags (canonical order): + * - `e` — the referenced report event id ([RoadReportTag]). + * - `status` — [RoadEventStatus.STILL_THERE] (confirm) or [RoadEventStatus.NO_LONGER_THERE] (deny). + * - `g` / `lat` / `lon` — optional copy of the report's location, so confirmations + * are reachable through the same `#g` geohash queries as reports. + * - `expiration` — NIP-40 relay garbage-collection window (`created_at + 14 days`). + * - `alt` — NIP-31 human-readable fallback. + */ +@Immutable +class RoadEventConfirmationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(RoadReportTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(RoadReportTag::parseId) + + /** The referenced report event id from the first `e` tag. */ + fun reportId() = tags.roadReport()?.eventId + + fun status() = tags.roadEventStatus() + + fun isConfirmation() = status() == RoadEventStatus.STILL_THERE + + fun isDenial() = status() == RoadEventStatus.NO_LONGER_THERE + + fun geohashes() = tags.geohashes() + + fun latitude() = tags.latitude() + + fun longitude() = tags.longitude() + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.expiration() + + companion object { + const val KIND = 1316 + + /** NIP-31 fallback for a confirmation (`still_there`), per the roadstr spec. */ + const val ALT_CONFIRMED = "Roadstr: event confirmed" + + /** NIP-31 fallback for a denial (`no_longer_there`), per the roadstr spec. */ + const val ALT_DENIED = "Roadstr: event denied" + + fun altDescription(status: RoadEventStatus) = if (status == RoadEventStatus.NO_LONGER_THERE) ALT_DENIED else ALT_CONFIRMED + + fun build( + reportId: HexKey, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + report(reportId) + status(status) + if (latitude != null && longitude != null) { + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) + } + expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) + alt(altDescription(status)) + initializer() + } + + fun build( + report: EventHintBundle, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(report.event.id, status, latitude, longitude, createdAt) { + report(report) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8d5a0debd7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle + +fun TagArrayBuilder.report(reportId: HexKey) = addUnique(RoadReportTag.assemble(reportId, null, null)) + +fun TagArrayBuilder.report(reportHint: EventHintBundle) = addUnique(RoadReportTag.assemble(reportHint)) + +fun TagArrayBuilder.status(status: RoadEventStatus) = addUnique(RoadEventStatusTag.assemble(status)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt new file mode 100644 index 0000000000..6b413829f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadReport() = firstNotNullOfOrNull(RoadReportTag::parse) + +fun TagArray.roadEventStatus() = firstNotNullOfOrNull(RoadEventStatusTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt new file mode 100644 index 0000000000..87ca3f0f3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt @@ -0,0 +1,54 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Confirmation status carried in the `status` tag of a Roadstr confirmation (kind 1316). */ +enum class RoadEventStatus( + val code: String, +) { + STILL_THERE("still_there"), + NO_LONGER_THERE("no_longer_there"), + ; + + companion object { + fun fromCode(code: String?): RoadEventStatus? = entries.firstOrNull { it.code == code } + } +} + +/** The `status` tag of a Roadstr confirmation (kind 1316): `still_there` or `no_longer_there`. */ +class RoadEventStatusTag { + companion object { + const val TAG_NAME = "status" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): RoadEventStatus? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventStatus.fromCode(tag[1]) + } + + fun assemble(status: RoadEventStatus) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt new file mode 100644 index 0000000000..f4c3bd5597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt @@ -0,0 +1,96 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** The `e` tag of a Roadstr confirmation (kind 1316): the referenced report event. */ +@Immutable +data class RoadReportTag( + override val eventId: HexKey, +) : GenericETag { + override var relay: NormalizedRelayUrl? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun toNEvent(): String = NEvent.create(eventId, author, RoadEventReportEvent.KIND, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): RoadReportTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RoadReportTag(tag[1], hint, tag.getOrNull(3)) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + + fun assemble(eventHint: EventHintBundle) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt new file mode 100644 index 0000000000..1d7eb8747c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt @@ -0,0 +1,113 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.report + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event report (kind 1315). + * + * A driver-published report of a notable road condition (police, accident, + * hazard, …) at a specific location. See . + * + * Tags (canonical order): + * - `t` — the [RoadEventType] code. + * - `g` — geohash at precision 4, 5 and 6 (for multi-resolution spatial queries). + * - `lat` / `lon` — coordinates as decimal strings with 7 fractional digits. + * - `expiration` — NIP-40 relay garbage-collection window, always `created_at + 14 days`. + * - `alt` — NIP-31 human-readable fallback. + * + * The relay-side `expiration` is a fixed 14-day window; clients should instead use + * [effectiveExpirationAt] (derived from the event type's TTL) to decide when to + * stop displaying a report. The `content` field holds an optional free-text comment. + */ +@Immutable +class RoadEventReportEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The parsed [RoadEventType], or null when the `t` tag is missing/unknown. */ + fun roadEventType() = tags.roadEventType() + + /** The raw event-type code from the first `t` tag (may be outside [RoadEventType]). */ + fun roadEventTypeCode() = tags.roadEventTypeCode() + + /** All `g` geohash tags, coarse-to-fine as published. */ + fun geohashes() = tags.geohashes() + + fun latitude() = tags.latitude() + + fun longitude() = tags.longitude() + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.expiration() + + /** Client-side effective expiry (`created_at + ` the type's TTL), or null if the type is unknown. */ + fun effectiveExpirationAt(): Long? = roadEventType()?.let { createdAt + it.effectiveTtlSeconds } + + /** Whether the report should no longer be displayed at [now], per its client-side TTL. */ + fun isEffectivelyExpired(now: Long = TimeUtils.now()): Boolean = effectiveExpirationAt()?.let { now > it } ?: false + + companion object { + const val KIND = 1315 + const val ALT_PREFIX = "Roadstr: " + + /** Fixed relay-side NIP-40 window (14 days) for both report and confirmation events. */ + const val RELAY_TTL_SECONDS = 1_209_600L + + fun altDescription(type: RoadEventType) = "$ALT_PREFIX${type.code} report" + + fun build( + type: RoadEventType, + latitude: Double, + longitude: Double, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + roadEventType(type) + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) + expiration(createdAt + RELAY_TTL_SECONDS) + alt(altDescription(type)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ff4713a50a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt @@ -0,0 +1,27 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.report + +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.roadEventType(type: RoadEventType) = add(RoadEventTypeTag.assemble(type)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt new file mode 100644 index 0000000000..7f6f40ab1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.report + +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadEventType() = firstNotNullOfOrNull(RoadEventTypeTag::parse) + +fun TagArray.roadEventTypeCode() = firstNotNullOfOrNull(RoadEventTypeTag::parseCode) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt new file mode 100644 index 0000000000..48188fe5ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt @@ -0,0 +1,82 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.report.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Road event categories carried in the `t` tag of a Roadstr report (kind 1315). + * + * Each type also defines a client-side *effective* TTL: the relay-side NIP-40 + * `expiration` tag is always a fixed 14-day garbage-collection window, but + * clients should stop showing a report after [effectiveTtlSeconds] from its + * `created_at` (subject to confirmations). + */ +enum class RoadEventType( + val code: String, + val effectiveTtlSeconds: Long, +) { + POLICE("police", 7_200L), // 2 hours + SPEED_CAMERA("speed_camera", 2_592_000L), // 30 days + TRAFFIC_JAM("traffic_jam", 3_600L), // 1 hour + ACCIDENT("accident", 10_800L), // 3 hours + ROAD_CLOSURE("road_closure", 604_800L), // 7 days + CONSTRUCTION("construction", 604_800L), // 7 days + HAZARD("hazard", 14_400L), // 4 hours + ROAD_CONDITION("road_condition", 21_600L), // 6 hours + POTHOLE("pothole", 604_800L), // 7 days + FOG("fog", 10_800L), // 3 hours + ICE("ice", 21_600L), // 6 hours + ANIMAL("animal", 3_600L), // 1 hour + OTHER("other", 7_200L), // 2 hours + ; + + companion object { + fun fromCode(code: String?): RoadEventType? = entries.firstOrNull { it.code == code } + } +} + +/** The `t` tag of a Roadstr report (kind 1315): the [RoadEventType] code. */ +class RoadEventTypeTag { + companion object { + const val TAG_NAME = "t" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Parses the tag into a known [RoadEventType], or null when missing/unknown. */ + fun parse(tag: Array): RoadEventType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventType.fromCode(tag[1]) + } + + /** Returns the raw `t` value even when it is not a known [RoadEventType]. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(type: RoadEventType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt new file mode 100644 index 0000000000..d3c8ab167c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lat` tag of a Roadstr event: latitude as a decimal string with 7 fractional digits. */ +class LatitudeTag { + companion object { + const val TAG_NAME = "lat" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(latitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(latitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt new file mode 100644 index 0000000000..18880cfebc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lon` tag of a Roadstr event: longitude as a decimal string with 7 fractional digits. */ +class LongitudeTag { + companion object { + const val TAG_NAME = "lon" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(longitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(longitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt new file mode 100644 index 0000000000..193b34e122 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt @@ -0,0 +1,43 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.tags + +import kotlin.math.abs +import kotlin.math.round + +/** + * Formatting for the `lat`/`lon` tag values of Roadstr events. The spec requires + * a decimal string with exactly 7 fractional digits (e.g. `48.8566140`), which is + * locale-independent here (unlike `String.format`, which is unavailable in + * `commonMain` anyway). + */ +object RoadCoordinate { + private const val SCALE = 10_000_000L // 7 decimal places + + fun format(value: Double): String { + val scaled = round(value * SCALE).toLong() + val negative = scaled < 0L + val absValue = abs(scaled) + val intPart = absValue / SCALE + val fracPart = (absValue % SCALE).toString().padStart(7, '0') + return (if (negative) "-" else "") + intPart + "." + fracPart + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a124f24cb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +/** Adds `lat`/`lon` tags (7-decimal strings) shared by Roadstr reports and confirmations. */ +fun TagArrayBuilder.coordinates( + latitude: Double, + longitude: Double, +) = apply { + add(LatitudeTag.assemble(latitude)) + add(LongitudeTag.assemble(longitude)) +} + +/** Adds the three `g` geohash tags (precision 4, 5 and 6) derived from [latitude]/[longitude]. */ +fun TagArrayBuilder.roadGeohashes( + latitude: Double, + longitude: Double, +) = apply { + val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() + add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) + add(GeoHashTag.assembleSingle(geohash6)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt new file mode 100644 index 0000000000..1368797d18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt @@ -0,0 +1,27 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.latitude() = firstNotNullOfOrNull(LatitudeTag::parse) + +fun TagArray.longitude() = firstNotNullOfOrNull(LongitudeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt index b22d81aede..2b1ff294a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/zapPolls/ZapPollEvent.kt @@ -63,7 +63,11 @@ class ZapPollEvent( AddressHintProvider, PubKeyHintProvider, SearchableEvent { - override fun indexableContent() = content + pollOptionsArray().map { "\nOption: " + it.descriptor } + override fun indexableContent() = + buildString { + append(content) + pollOptionsArray().forEach { append('\n').append(it.descriptor) } + } override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt index cdc6245a65..f51969b5af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** * Kind 31890: Feed Definition Event (draft NIP, PR #1181). @@ -40,7 +41,10 @@ class FeedDefinitionEvent( tags: TagArray, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = title().orEmpty() + fun title(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1) fun emoji(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "emoji" }?.get(1) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt index 85fbc1c6fa..2f87070657 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.cache.interning import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip01Core.store.IdAndTime @@ -123,5 +124,12 @@ class InterningEventStore( override suspend fun deleteExpiredEvents() = inner.deleteExpiredEvents() + override suspend fun reindexFullTextSearch() = inner.reindexFullTextSearch() + + override suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = inner.reindexFullTextSearch(resumeFrom, batchSize) + override fun close() = inner.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 08f00d54ad..2a3d427b70 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.githubClaim import com.vitorpamplona.quartz.nip39ExtIdentities.mastodonClaim import com.vitorpamplona.quartz.nip39ExtIdentities.replaceClaims import com.vitorpamplona.quartz.nip39ExtIdentities.twitterClaim +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.text @@ -64,9 +65,29 @@ class MetadataEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { override fun isContentEncoded() = true + // Profile content is JSON, so we parse it and index only the + // human-meaningful fields: names, bio, plus the addresses people search + // by (nip05 email, lightning addresses, website/picture/banner URLs). + // Mirrors the in-memory searchable set in UserMetadata.anyPropertyContains. + override fun indexableContent() = + contactMetaData()?.let { + listOfNotNull( + it.name, + it.displayName, + it.about, + it.nip05, + it.lud06, + it.lud16, + it.website, + it.picture, + it.banner, + ).joinToString(" ") + } ?: "" + fun contactMetadataJson() = try { Json.parseToJsonElement(content) as JsonObject diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/FtsReindexProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/FtsReindexProgress.kt new file mode 100644 index 0000000000..2db5df7cb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/FtsReindexProgress.kt @@ -0,0 +1,42 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.store + +/** + * Progress token returned by the resumable + * [IEventStore.reindexFullTextSearch] overload. + * + * Treat [cursor] as opaque: persist it (it survives process death) and + * hand it back to the next call to continue where the previous one + * stopped. Each store encodes its own resume position into it. + * + * @property cursor where to resume from on the next call, or `null` once + * [done] is `true` (nothing left to process). + * @property processedThisBatch how many events this call (re)indexed — + * useful to drive a progress indicator. + * @property done `true` when the whole store has been visited; further + * calls are no-ops that keep returning `done = true`. + */ +data class FtsReindexProgress( + val cursor: String?, + val processedThisBatch: Int, + val done: Boolean, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 3300185a43..7d68d4fb93 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -25,6 +25,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface IEventStore : AutoCloseable { + companion object { + /** + * How many events a single resumable + * [reindexFullTextSearch] batch aims to process before yielding. + * Big enough to amortise the per-batch transaction/lock cost, + * small enough that a pause request is honoured promptly. + */ + const val DEFAULT_FTS_REINDEX_BATCH = 1000 + } + /** * Relay URL this store is acting on behalf of, or `null` for an * unscoped store. Used by NIP-62 right-to-vanish handling: only @@ -133,5 +143,68 @@ interface IEventStore : AutoCloseable { suspend fun deleteExpiredEvents() + /** + * Wipe and rebuild the NIP-50 full-text search index from the + * events already in storage. + * + * Which kinds are searchable — i.e. implement + * `SearchableEvent` — and what text each one contributes is baked + * into the quartz build, so it can change across app versions: a + * kind that was opaque before may start implementing + * `SearchableEvent`, or an existing one may change what its + * `indexableContent()` returns. Events that were inserted under the + * old code keep their old (or missing) FTS rows until they are + * re-indexed, so search silently misses them. Call this once, off + * the hot path, after such an upgrade — the app decides when it has + * the spare cycles to process the whole store. + * + * Implementations rebuild from scratch (so the result is the same + * whether or not an index already existed) and only visit kinds + * that currently map to a searchable event, leaving the bulk of + * non-searchable rows (reactions, zaps, follow lists, …) untouched + * to keep the scan as cheap as possible. + * + * This one-shot variant runs to completion under a single lock and + * cannot be paused. For a store large enough that the full pass + * would block for too long, use the resumable overload + * [reindexFullTextSearch] instead. + */ + suspend fun reindexFullTextSearch() + + /** + * Resumable, batched companion to [reindexFullTextSearch] for stores + * large enough that a single pass would take too long to run (or to + * hold a lock) in one go. + * + * Process roughly [batchSize] events starting from [resumeFrom] + * (`null` = from the beginning) and return a [FtsReindexProgress]. + * Drive it in a loop, feeding [FtsReindexProgress.cursor] back in, + * until [FtsReindexProgress.done] is `true`: + * + * ``` + * var cursor: String? = null + * do { + * val p = store.reindexFullTextSearch(cursor) + * cursor = p.cursor + * // optionally persist `cursor` and stop; resume later by passing it back + * } while (!p.done) + * ``` + * + * Each call commits its own batch, so progress is durable across a + * crash or app restart and the writer lock is released between + * batches — the app can pause simply by not making the next call. + * + * Semantics differ slightly from the one-shot variant: this path is + * **additive / refresh** — it makes sure every currently-searchable + * event is indexed (and, on SQLite, refreshes changed content) while + * leaving search usable throughout. It does not purge stale rows left + * behind by a kind that *lost* searchability; run the one-shot + * [reindexFullTextSearch] once for that rarer case. + */ + suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int = DEFAULT_FTS_REINDEX_BATCH, + ): FtsReindexProgress + override fun close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt index b11d1b27e4..35e0427f25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt @@ -215,5 +215,15 @@ class ObservableEventStore( _changes.emit(StoreChange.DeleteExpired(asOf)) } + // Pure index maintenance: it rewrites the FTS index without adding, + // removing, or changing any event, so there is nothing for + // projections to observe and no [StoreChange] is emitted. + override suspend fun reindexFullTextSearch() = inner.reindexFullTextSearch() + + override suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = inner.reindexFullTextSearch(resumeFrom, batchSize) + override fun close() = inner.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index faad3204d3..7e1c912d55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime @@ -79,5 +80,12 @@ class EventStore( override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents() + override suspend fun reindexFullTextSearch() = store.reindexFullTextSearch() + + override suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = store.reindexFullTextSearch(resumeFrom, batchSize) + override fun close() = store.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index f39da4fe85..71f55b1096 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -23,10 +23,14 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.EventFactory class FullTextSearchModule : IModule { val tableName = "event_fts" + val triggerName = "fts_foreign_key" val eventHeaderRowIdName = "event_header_row_id" val contentName = "content" @@ -42,7 +46,7 @@ class FullTextSearchModule : IModule { // Foreign key cleanup for full text search db.execSQL( """ - CREATE TRIGGER fts_foreign_key + CREATE TRIGGER $triggerName AFTER DELETE ON event_headers FOR EACH ROW BEGIN @@ -57,12 +61,28 @@ class FullTextSearchModule : IModule { db.execSQL("DROP TABLE IF EXISTS $tableName") } + /** + * Drop the cleanup trigger on its own. [drop] only removes the FTS + * table; the trigger lives on `event_headers`, so a rebuild that + * recreates the table without first dropping the trigger would fail + * with "trigger already exists". (A schema upgrade doesn't hit this + * because dropping `event_headers` takes its triggers with it.) + */ + fun dropTrigger(db: SQLiteConnection) { + db.execSQL("DROP TRIGGER IF EXISTS $triggerName") + } + val insertFTS = """ INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName) VALUES (?, ?) """.trimIndent() + val deleteFTSByRowId = + """ + DELETE FROM $tableName WHERE $eventHeaderRowIdName = ? + """.trimIndent() + fun insert( event: Event, headerId: Long, @@ -109,4 +129,159 @@ class FullTextSearchModule : IModule { override fun deleteAll(db: SQLiteConnection) { db.execSQL("DELETE FROM event_fts") } + + /** + * Wipe and rebuild the whole FTS index from `event_headers`. + * + * Wiping is done by dropping and recreating the virtual table, which + * empties it in O(1) — far cheaper than DELETE-ing every row out of a + * populated FTS index. The rebuild then streams only the rows whose + * kind currently parses to a [SearchableEvent] (see + * [searchableKindsPresent]) so the common non-searchable bulk — + * reactions, zaps, follow lists — is never deserialised. A single + * shared INSERT statement is reused across the scan. + * + * Must run inside the caller's write transaction. + */ + fun reindexAll(db: SQLiteConnection) { + dropTrigger(db) + drop(db) + create(db) + + val kinds = searchableKindsPresent(db) + if (kinds.isEmpty()) return + + val selectSql = + "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig " + + "FROM event_headers WHERE kind IN (${kinds.joinToString(",")})" + + db.prepare(insertFTS).use { write -> + db.prepare(selectSql).use { read -> + while (read.step()) { + val event = + EventFactory.create( + read.getText(1), + read.getText(2), + read.getLong(3), + read.getInt(4), + OptimizedJsonMapper.fromJsonToTagArray(read.getText(5)), + read.getText(6), + read.getText(7), + ) + if (event is SearchableEvent) { + write.bindLong(1, read.getLong(0)) + write.bindText(2, event.indexableContent()) + write.step() + write.reset() + } + } + } + } + } + + /** + * Process one batch of a resumable rebuild: re-derive the FTS rows + * for up to [batchSize] events whose `row_id > ` [afterRowId] and + * whose kind is searchable, ordered by `row_id`. + * + * `row_id` is a monotonic AUTOINCREMENT key, so it is a stable + * cursor that needs no extra bookkeeping. Each event is + * delete-then-insert, which keeps the batch idempotent (a replay + * after a crash is harmless) and avoids duplicate FTS rows for + * events that were already indexed by the normal insert path. Rows + * not yet reached keep their previous FTS content, so search stays + * usable while the rebuild is in flight. + * + * Must run inside the caller's per-batch write transaction. + */ + fun reindexBatch( + db: SQLiteConnection, + afterRowId: Long, + batchSize: Int, + ): FtsReindexProgress { + // A non-positive page would select no rows yet never report done, + // spinning the caller's loop forever — clamp to at least one. + val limit = batchSize.coerceAtLeast(1) + val kinds = searchableKindsPresent(db) + if (kinds.isEmpty()) return FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true) + + val selectSql = + "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig " + + "FROM event_headers WHERE row_id > ? AND kind IN (${kinds.joinToString(",")}) " + + "ORDER BY row_id LIMIT ?" + + var last = afterRowId + var processed = 0 + db.prepare(deleteFTSByRowId).use { del -> + db.prepare(insertFTS).use { write -> + db.prepare(selectSql).use { read -> + read.bindLong(1, afterRowId) + read.bindLong(2, limit.toLong()) + while (read.step()) { + val rowId = read.getLong(0) + // Clear any existing row for this event first so a + // replay or an already-indexed event can't duplicate. + del.bindLong(1, rowId) + del.step() + del.reset() + + val event = + EventFactory.create( + read.getText(1), + read.getText(2), + read.getLong(3), + read.getInt(4), + OptimizedJsonMapper.fromJsonToTagArray(read.getText(5)), + read.getText(6), + read.getText(7), + ) + if (event is SearchableEvent) { + write.bindLong(1, rowId) + write.bindText(2, event.indexableContent()) + write.step() + write.reset() + } + last = rowId + processed++ + } + } + } + } + + // Fewer than a full page came back ⇒ we hit the end of the table. + val done = processed < limit + return FtsReindexProgress( + cursor = if (done) null else last.toString(), + processedThisBatch = processed, + done = done, + ) + } + + /** + * The distinct kinds present in `event_headers` that currently parse + * to a [SearchableEvent]. Kind alone selects the event class in + * [EventFactory], so one probe per distinct kind is authoritative; + * the result drives a `kind IN (...)` filter on the rebuild scan so + * non-searchable rows are skipped at the SQL layer. + */ + private fun searchableKindsPresent(db: SQLiteConnection): List { + val out = ArrayList() + db.prepare("SELECT DISTINCT kind FROM event_headers").use { stmt -> + while (stmt.step()) { + val kind = stmt.getInt(0) + if (isSearchableKind(kind)) out.add(kind) + } + } + return out + } + + private fun isSearchableKind(kind: Int): Boolean = EventFactory.create(PROBE_ID, PROBE_ID, 0L, kind, EMPTY_TAGS, "", "") is SearchableEvent + + companion object { + // A non-blank id keeps kinds that lazily hash a missing id (e.g. + // NIP-17 chat messages) from doing that work — the probe only + // inspects the resulting runtime type. + private const val PROBE_ID = "0" + private val EMPTY_TAGS = emptyArray>() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index e67f7b4646..ad9088275b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isEphemeral 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.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip40Expiration.isExpired @@ -340,6 +341,35 @@ class SQLiteEventStore( suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) } + /** + * Wipe and rebuild the NIP-50 full-text search index for every + * stored event. See [IEventStore.reindexFullTextSearch] for when to + * call this. The whole rebuild runs in a single write transaction so + * the WAL append + sync cost is paid once. + */ + suspend fun reindexFullTextSearch() = + pool.useWriter { db -> + db.transaction { + fullTextSearchModule.reindexAll(db) + } + } + + /** + * One batch of a resumable FTS rebuild. See + * [IEventStore.reindexFullTextSearch]. The opaque cursor is the last + * `row_id` processed; `null` (or an unparseable value) starts from + * the beginning. Each batch is its own write transaction. + */ + suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = + pool.useWriter { db -> + db.transaction { + fullTextSearchModule.reindexBatch(db, resumeFrom?.toLongOrNull() ?: 0L, batchSize) + } + } + fun close() = pool.close() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt index 4741b104a5..ac2b320078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt @@ -18,13 +18,17 @@ * 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 com.vitorpamplona.amethyst.service.location - -import android.location.Location +package com.vitorpamplona.quartz.nip01Core.tags.geohash /** * GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008). * + * Pure-Kotlin and platform-agnostic (lives in `commonMain`), so any Nostr + * protocol code can derive `g` tags from coordinates and walk neighboring cells + * without depending on a platform location API. Callers that work with a + * platform `Location` type add thin conversion helpers in their own module + * (e.g. `amethyst`'s `Location.toGeoHash()` / `GeoHash.toLocation()`). + * * Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude, * odd-indexed bits (1, 3, 5, ...) = latitude. */ @@ -237,12 +241,6 @@ class GeoHash private constructor( val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon) val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon) - fun toLocation(): Location = - Location("").also { - it.latitude = centerLat - it.longitude = centerLon - } - override fun toString(): String { val charCount = significantBits / BITS_PER_CHAR val sb = StringBuilder(charCount) @@ -263,6 +261,4 @@ class GeoHash private constructor( override fun hashCode(): Int = 31 * bits.hashCode() + significantBits } -fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) - fun String.toGeoHash(): GeoHash? = GeoHash.decode(this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index c9120dbd2c..e2ab5a1f94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -66,7 +66,7 @@ class TextNoteEvent( PubKeyHintProvider, IForkableEvent, SearchableEvent { - override fun indexableContent() = "Subject: " + subject() + "\n" + content + override fun indexableContent() = listOfNotNull(subject(), content).joinToString("\n") override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index 212096b6a8..5ab47f319b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -40,7 +41,11 @@ class ChatMessageEvent( tags: Array>, content: String, sig: HexKey, -) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // content is the decrypted (plaintext) direct-message body. + override fun indexableContent() = content + fun replyTo() = tags.mapNotNull(ETag::parseId) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 6eabe0f372..43d8c4c13c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -71,7 +71,7 @@ class LongTextNoteEvent( PublishedAtProvider, RootScope, SearchableEvent { - override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") override fun eventHints(): List { val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index b4d0d59f93..be1db07883 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException @@ -45,7 +46,10 @@ class ChannelCreateEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider { + EventHintProvider, + SearchableEvent { + override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") } + @kotlinx.serialization.Transient @kotlin.jvm.Transient var cache: ChannelDataNorm? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index f3208477b2..6395940cf2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException @@ -48,7 +49,10 @@ class ChannelMetadataEvent( content: String, sig: HexKey, ) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider { + EventHintProvider, + SearchableEvent { + override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") } + @kotlinx.serialization.Transient @kotlin.jvm.Transient var cache: ChannelDataNorm? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 4b73193ea0..1f5223a232 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,7 +40,10 @@ class GroupMetadataEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") + fun groupId() = dTag() fun name() = tags.firstTagValue("name") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 09f21506ed..46f9507929 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag @@ -46,7 +47,10 @@ class EmojiPackEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(titleOrName(), description(), content).joinToString("\n") + @Deprecated("NIP-51 has deprecated name. Use title instead", ReplaceWith("title()")) fun name() = tags.firstNotNullOfOrNull(NameTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt index a108126fb8..900a195233 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt @@ -61,7 +61,7 @@ class GitIssueEvent( EventHintProvider, AddressHintProvider, SearchableEvent { - override fun indexableContent() = "Subject: " + subject() + "\n" + content + override fun indexableContent() = listOfNotNull(subject(), content).joinToString("\n") override fun eventHints(): List { val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index 46a7f9b23b..9547446908 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.tags.Committer import com.vitorpamplona.quartz.nip34Git.patch.tags.CommitterTag import com.vitorpamplona.quartz.nip34Git.patch.tags.ParentCommitTag import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -57,7 +58,10 @@ class GitPatchEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt index 2f843a6afb..16cc045e87 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip34Git.pr.tags.CurrentCommitTag import com.vitorpamplona.quartz.nip34Git.pr.tags.MergeBaseTag import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -63,7 +64,10 @@ class GitPullRequestEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(subject(), content).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt index a738bccec8..464a1d238e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/reply/GitReplyEvent.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -62,7 +63,10 @@ class GitReplyEvent( ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content + override fun eventHints(): List { val eHints = tags.mapNotNull(ETag::parseAsHint) val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index 78aa84d9be..3cce25f932 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip34Git.repository.tags.MaintainersTag import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag import com.vitorpamplona.quartz.nip34Git.repository.tags.RelaysTag import com.vitorpamplona.quartz.nip34Git.repository.tags.WebTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -49,7 +50,10 @@ class GitRepositoryEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(name(), description(), content).joinToString("\n") + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt index 8b0f8b15b9..699e64911d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentCommentEvent.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -62,7 +63,10 @@ class TorrentCommentEvent( ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, PubKeyHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = content + override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt index 01d856a31f..4648feaa5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip35Torrents/TorrentEvent.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip35Torrents.tags.FileTag import com.vitorpamplona.quartz.nip35Torrents.tags.InfoHashTag import com.vitorpamplona.quartz.nip35Torrents.tags.TrackerTag import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.UrlEncoder @@ -49,7 +50,10 @@ class TorrentEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun btih() = tags.firstNotNullOfOrNull(BtihTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt index cc3a80b17f..f07ad852c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,7 +40,10 @@ class StatusEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = content + fun firstTaggedUrl() = tags.firstTagValue("r") companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt index 7b82153cca..2be5f3968d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/appCurationSet/AppCurationSetEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -51,7 +52,10 @@ class AppCurationSetEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint) override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/articleCurationSet/ArticleCurationSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/articleCurationSet/ArticleCurationSetEvent.kt index d5cab8226f..c98dd4fd15 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/articleCurationSet/ArticleCurationSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/articleCurationSet/ArticleCurationSetEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -57,7 +58,10 @@ class ArticleCurationSetEvent( sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt index 5025117351..0438551672 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -52,7 +53,12 @@ class BookmarkListEvent( sig: HexKey, ) : PrivateReplaceableTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + // Only the public list title is indexed; bookmarks live in NIP-44 + // encrypted content and are intentionally never indexed. + override fun indexableContent() = listOfNotNull(title()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt index 4bc1c77191..32d9b050f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -53,7 +54,12 @@ class OldBookmarkListEvent( sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + // Only the public list title is indexed; bookmarks live in NIP-44 + // encrypted content and are intentionally never indexed. + override fun indexableContent() = listOfNotNull(title()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index b1d128374e..6edd0c861b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -50,7 +51,10 @@ class FollowListEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestSet/InterestSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestSet/InterestSetEvent.kt index 3b8c956ffd..12072ab335 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestSet/InterestSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestSet/InterestSetEvent.kt @@ -33,10 +33,13 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.removeAny import com.vitorpamplona.quartz.nip51Lists.removeIgnoreCase +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -49,7 +52,16 @@ class InterestSetEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // Public title/description plus the public-interest hashtags. Private + // hashtags live in NIP-44 encrypted content and are never indexed. + override fun indexableContent() = (listOfNotNull(title(), description()) + publicHashtags()).joinToString("\n") + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) suspend fun privateHashtags(signer: NostrSigner) = privateTags(signer)?.mapNotNull(HashtagTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt index 04c3b7c010..cc20f2135c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -59,7 +60,12 @@ class LabeledBookmarkListEvent( sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + // Only the public label/description is indexed; bookmarks live in NIP-44 + // encrypted content and are intentionally never indexed. + override fun indexableContent() = listOfNotNull(titleOrName(), description()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt index 03bb69a035..9eeb887854 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.followList.followIdSet import com.vitorpamplona.quartz.nip51Lists.followList.followIds import com.vitorpamplona.quartz.nip51Lists.followList.follows @@ -53,7 +54,10 @@ class MediaStarterPackEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt index 51cb836fb9..0705d75ec7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag @@ -57,7 +58,12 @@ class PeopleListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + // Only the public label/description is indexed; members can live in NIP-44 + // encrypted content and are intentionally never indexed. + override fun indexableContent() = listOfNotNull(titleOrName(), description()).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/pictureCurationSet/PictureCurationSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/pictureCurationSet/PictureCurationSetEvent.kt index b93dc207ed..f8546d4a24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/pictureCurationSet/PictureCurationSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/pictureCurationSet/PictureCurationSetEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -54,7 +55,10 @@ class PictureCurationSetEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider { + EventHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt index a06c4a5653..9780bdb630 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List @@ -49,7 +50,12 @@ class RelaySetEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // Only the public title/description is indexed; relays can live in NIP-44 + // encrypted content and are intentionally never indexed. + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + fun relays(): List = tags.mapNotNull(RelayTag.Companion::parse) fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt index 48af933fe9..1c13812bae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -53,7 +54,10 @@ class ReleaseArtifactSetEvent( sig: HexKey, ) : com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/videoCurationSet/VideoCurationSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/videoCurationSet/VideoCurationSetEvent.kt index 26501390a7..881f8c4c21 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/videoCurationSet/VideoCurationSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/videoCurationSet/VideoCurationSetEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -57,7 +58,10 @@ class VideoCurationSetEvent( sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider { + AddressHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt index c6d77534a3..251404c1c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/day/CalendarDateSlotEvent.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -48,7 +49,10 @@ class CalendarDateSlotEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag.Companion::parse) fun location() = tags.firstNotNullOfOrNull(LocationTag.Companion::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt index 89cbe48f25..b036c88979 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -49,7 +50,10 @@ class CalendarTimeSlotEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag.Companion::parse) fun location() = tags.firstNotNullOfOrNull(LocationTag.Companion::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt index 6c5e9fa5e2..e11e4af522 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -41,7 +42,10 @@ class CalendarEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun calendarEventAddresses() = taggedAddresses() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt index 32bb28397f..16f42ec884 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -63,7 +64,10 @@ class LiveActivitiesClipEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt index 5c5c34d921..c78c4208ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag @@ -66,7 +67,10 @@ class MeetingRoomEvent( EventHintProvider, AddressHintProvider, PubKeyHintProvider, - LiveStreamLike { + LiveStreamLike, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary()).joinToString("\n") + override fun eventHints(): List { val pinnedEvents = pinned() if (pinnedEvents.isEmpty()) return emptyList() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt index 49eb99b837..2e8d2b09d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag @@ -52,7 +53,10 @@ class MeetingSpaceEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(room(), summary(), content).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index 5ca0e1d4a6..ea20dece90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag @@ -62,7 +63,10 @@ class LiveActivitiesEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, PubKeyHintProvider, - LiveStreamLike { + LiveStreamLike, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + override fun eventHints(): List { val pinnedEvents = pinned() if (pinnedEvents.isEmpty()) return emptyList() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index 295c38fd48..ea7449d1fb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -78,7 +78,7 @@ class WikiNoteEvent( IForkableEvent, RootScope, SearchableEvent { - override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt index 551fa5f4ae..fb7d116309 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.Log @Immutable @@ -45,7 +46,12 @@ class LnZapEvent( LnZapEventInterface, EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + // The zap comment lives in the embedded request, which init{} already parses + // into [zapRequest] — so indexing it adds no extra parse cost. + override fun indexableContent() = zapRequest?.content.orEmpty() + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index b334dd73ab..371805438a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -50,7 +51,12 @@ class LnZapRequestEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + // content is the public zap comment. Private-zap messages live encrypted in + // the `anon` tag, not in content, so they are naturally excluded. + override fun indexableContent() = content + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/definition/BadgeDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/definition/BadgeDefinitionEvent.kt index aa05fbea64..df73ec3dae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/definition/BadgeDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/definition/BadgeDefinitionEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ImageTag import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -40,7 +41,10 @@ class BadgeDefinitionEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(name(), description(), content).joinToString("\n") + fun name() = tags.badgeName() fun image() = tags.badgeImageUrl() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt index 3d0a198eb8..96f09e79b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,7 +39,10 @@ class NamedSiteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + fun paths() = tags.sitePaths() fun servers() = tags.siteServers() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt index ba371802ed..96af04bfed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -37,7 +38,10 @@ class RootSiteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + fun paths() = tags.sitePaths() fun servers() = tags.siteServers() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/nutzap/NutzapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/nutzap/NutzapEvent.kt index e33c5d5d14..e7eb3262ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/nutzap/NutzapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/nutzap/NutzapEvent.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -43,7 +44,11 @@ class NutzapEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + // content is the optional nutzap message. + override fun indexableContent() = content + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt index a9075887ea..4467964a30 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip68Picture.tags.LocationTag import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag @@ -45,7 +46,10 @@ class PictureEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + @kotlinx.serialization.Transient @kotlin.jvm.Transient var iMetas: List? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt index 486753cef2..cb38c9e9ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip71Video.tags.DurationTag import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -48,7 +49,10 @@ abstract class AddressableVideoEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + @kotlinx.serialization.Transient @kotlin.jvm.Transient var iMetas: List? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt index 9b3b263365..1ad28238f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip71Video.tags.DurationTag import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -48,7 +49,10 @@ abstract class RegularVideoEvent( ) : Event(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + @kotlinx.serialization.Transient @kotlin.jvm.Transient var iMetas: List? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt index 5c62f1903f..6a8a679b42 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag @@ -55,7 +56,10 @@ class CommunityDefinitionEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(name(), description(), rules(), content).joinToString("\n") + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt index e6e4f83c4e..a9e327cf5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.reference import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip75ZapGoals.tags.AmountTag import com.vitorpamplona.quartz.nip75ZapGoals.tags.ClosedAtTag import com.vitorpamplona.quartz.nip75ZapGoals.tags.RelayListTag @@ -57,7 +58,10 @@ class GoalEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(summary(), content).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt index 9d3a755b17..d0cef02576 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt @@ -41,7 +41,7 @@ class ThreadEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope, SearchableEvent { - override fun indexableContent() = "title: " + title() + "\n" + content + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") fun title() = tags.title() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index bb77e403e0..8327bd7a5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -66,7 +66,7 @@ class HighlightEvent( AddressHintProvider, PubKeyHintProvider, SearchableEvent { - override fun indexableContent() = "comment: " + comment() + "\ncontext: " + context() + "\n" + content + override fun indexableContent() = listOfNotNull(comment(), context(), content).joinToString("\n") override fun eventHints(): List { val eHints = tags.mapNotNull(ETag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index f71f101783..cb1c58b7b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag @@ -60,7 +61,12 @@ class ContactCardEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + // Only the public summary tag is indexed; the rest of the card lives in + // NIP-44 encrypted content and is intentionally never indexed. + override fun indexableContent() = listOfNotNull(summary()).joinToString("\n") + fun aboutUser() = tags.dTag() fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt index 63647352ec..e6f0976082 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,7 +42,14 @@ class PollEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = + buildString { + append(content) + options().forEach { append('\n').append(it.label) } + } + fun options() = tags.options() fun relays() = tags.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index d2f4bc68dd..a250490e7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag import com.vitorpamplona.quartz.utils.Log @@ -50,7 +51,27 @@ class AppDefinitionEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PublishedAtProvider { + PublishedAtProvider, + SearchableEvent { + // App-handler content is JSON; parse it and index the human-meaningful + // fields plus the addresses/URLs people search by. + override fun indexableContent() = + appMetaData()?.let { + listOfNotNull( + it.name, + it.username, + it.displayName, + it.about, + it.nip05, + it.lud06, + it.lud16, + it.website, + it.picture, + it.banner, + it.image, + ).joinToString(" ") + } ?: "" + @kotlinx.serialization.Transient @kotlin.jvm.Transient private var cachedMetadata: AppMetadata? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt index cea911d9d3..ad9aa67433 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag @@ -51,7 +52,10 @@ class FileHeaderEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(summary(), content).joinToString("\n") + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) fun urls() = tags.mapNotNull(UrlTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index ec1c338e2b..b3c6101375 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import com.vitorpamplona.quartz.nip99Classifieds.tags.LocationTag @@ -52,7 +53,10 @@ class ClassifiedsEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - PublishedAtProvider { + PublishedAtProvider, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt index bddca68f1d..07272bee13 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -43,7 +44,10 @@ class WebBookmarkEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + fun url(): String { val dTagValue = dTag() return if (dTagValue.isNotEmpty()) "https://$dTagValue" else "" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt index a4ac489043..2dfebe2a6c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.toETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -58,7 +59,11 @@ class OnchainZapEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, - PubKeyHintProvider { + PubKeyHintProvider, + SearchableEvent { + // content is the optional onchain-zap message. + override fun indexableContent() = content + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt index 42008395f7..9c07eeb531 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -43,7 +44,10 @@ class CodeSnippetEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(snippetName(), snippetDescription(), content).joinToString("\n") + /** Programming language, lowercase (e.g. "python"). */ fun language() = tags.language() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index 5afb2fd4dd..107ed9435d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag @@ -47,7 +48,10 @@ class PodcastEpisodeEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt index 44ad1fd43b..8a9bdccabc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag @@ -53,7 +54,10 @@ class PodcastMetadataEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6752dcd986..cdf987c70f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -53,6 +53,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -579,6 +581,8 @@ class EventFactory { ReleaseArtifactSetEvent.KIND -> ReleaseArtifactSetEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventConfirmationEvent.KIND -> RoadEventConfirmationEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventReportEvent.KIND -> RoadEventReportEvent(id, pubKey, createdAt, tags, content, sig) RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt new file mode 100644 index 0000000000..fd66eeb1d2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -0,0 +1,207 @@ +/* + * 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 com.vitorpamplona.quartz.experimental.roadstr + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.tags.RoadCoordinate +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoadEventTest { + private val reportId = "fcbfc866b657a2fe514b579bcf7ff7700a195731eb6f306efb01529a683f86db" + + private fun sampleReport(): Event = + EventFactory.create( + id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = + arrayOf( + arrayOf("t", "police"), + arrayOf("g", "u2ed"), + arrayOf("g", "u2edc"), + arrayOf("g", "u2edcg"), + arrayOf("lat", "48.8566140"), + arrayOf("lon", "2.3522219"), + arrayOf("expiration", "1701209600"), + arrayOf("alt", "Roadstr: police report"), + ), + content = "", + sig = "00".repeat(64), + ) + + private fun sampleConfirmation(): Event = + EventFactory.create( + id = "b199d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_003_600L, + kind = RoadEventConfirmationEvent.KIND, + tags = + arrayOf( + arrayOf("e", reportId), + arrayOf("status", "no_longer_there"), + arrayOf("expiration", "1701213200"), + arrayOf("alt", "Roadstr: event denied"), + ), + content = "", + sig = "00".repeat(64), + ) + + @Test + fun factoryParsesReportAndConfirmation() { + assertIs(sampleReport()) + assertIs(sampleConfirmation()) + } + + @Test + fun kindsAreKnown() { + assertTrue(EventFactory.isKnownKind(RoadEventReportEvent.KIND)) + assertTrue(EventFactory.isKnownKind(RoadEventConfirmationEvent.KIND)) + } + + @Test + fun parsesReportFields() { + val event = sampleReport() + assertIs(event) + + assertEquals(RoadEventType.POLICE, event.roadEventType()) + assertEquals(listOf("u2ed", "u2edc", "u2edcg"), event.geohashes()) + assertEquals(48.8566140, event.latitude()) + assertEquals(2.3522219, event.longitude()) + assertEquals("Roadstr: police report", event.alt()) + assertEquals(1701209600L, event.relayExpiration()) + // police TTL is 2h → created_at + 7200 + assertEquals(1_700_000_000L + 7_200L, event.effectiveExpirationAt()) + } + + @Test + fun unknownTypeYieldsNullType() { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = arrayOf(arrayOf("t", "spaceship")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + assertEquals("spaceship", event.roadEventTypeCode()) + assertNull(event.roadEventType()) + assertNull(event.effectiveExpirationAt()) + } + + @Test + fun parsesConfirmationFields() { + val event = sampleConfirmation() + assertIs(event) + + assertEquals(reportId, event.reportId()) + assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) + assertTrue(event.isDenial()) + assertTrue(!event.isConfirmation()) + assertEquals("Roadstr: event denied", event.alt()) + } + + @Test + fun buildReportProducesCanonicalTags() { + val lat = 48.8566140 + val lon = 2.3522219 + val createdAt = 1_700_000_000L + val template = RoadEventReportEvent.build(RoadEventType.SPEED_CAMERA, lat, lon, createdAt = createdAt) + + assertEquals(RoadEventReportEvent.KIND, template.kind) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + val geohashes = template.tags.filter { it[0] == "g" }.map { it[1] } + assertEquals( + listOf(expectedG6.substring(0, 4), expectedG6.substring(0, 5), expectedG6), + geohashes, + ) + + assertEquals("speed_camera", template.tags.first { it[0] == "t" }[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + assertEquals("2.3522219", template.tags.first { it[0] == "lon" }[1]) + assertEquals((createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS).toString(), template.tags.first { it[0] == "expiration" }[1]) + assertEquals("Roadstr: speed_camera report", template.tags.first { it[0] == "alt" }[1]) + } + + @Test + fun buildConfirmationOmitsLocationWhenAbsent() { + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, createdAt = 1_700_000_000L) + + assertEquals(reportId, template.tags.first { it[0] == "e" }[1]) + assertEquals("still_there", template.tags.first { it[0] == "status" }[1]) + assertEquals("Roadstr: event confirmed", template.tags.first { it[0] == "alt" }[1]) + assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) + } + + @Test + fun buildConfirmationIncludesLocationWhenProvided() { + val lat = 48.8566140 + val lon = 2.3522219 + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, lat, lon) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + assertEquals(expectedG6, template.tags.filter { it[0] == "g" }.last()[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + } + + @Test + fun roadEventTypeTtls() { + assertEquals(7_200L, RoadEventType.POLICE.effectiveTtlSeconds) + assertEquals(2_592_000L, RoadEventType.SPEED_CAMERA.effectiveTtlSeconds) + assertEquals(3_600L, RoadEventType.TRAFFIC_JAM.effectiveTtlSeconds) + assertEquals(604_800L, RoadEventType.POTHOLE.effectiveTtlSeconds) + assertEquals(RoadEventType.TRAFFIC_JAM, RoadEventType.fromCode("traffic_jam")) + assertNull(RoadEventType.fromCode("nope")) + } + + @Test + fun coordinateFormattingHasSevenDecimals() { + assertEquals("48.8566140", RoadCoordinate.format(48.856614)) + assertEquals("2.3522219", RoadCoordinate.format(2.3522219)) + assertEquals("-0.0000010", RoadCoordinate.format(-0.000001)) + assertEquals("0.0000000", RoadCoordinate.format(0.0)) + assertEquals("-33.8688000", RoadCoordinate.format(-33.8688)) + } + + @Test + fun geohashEncodeProducesPrefixHierarchy() { + val g6 = GeoHash.encode(48.8566140, 2.3522219, 6).toString() + val g5 = GeoHash.encode(48.8566140, 2.3522219, 5).toString() + assertEquals(6, g6.length) + assertEquals(g6.substring(0, 5), g5) + // decode round-trips back to a hash with the same prefix + assertEquals(g6, GeoHash.decode(g6).toString()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index dae55cfa68..615e6fc47e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -26,6 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test @@ -129,4 +132,210 @@ class SearchTest : BaseDBTest() { db.assertQuery(null, Filter(search = "uniqalpha")) db.assertQuery(v2, Filter(search = "uniqbeta")) } + + @Test + fun testNewlySearchableKinds() = + forEachDB { db -> + // CalendarEvent indexes the title tag plus the free-text content. + val cal = + signer.sign( + CalendarEvent.build( + title = "uniqtitle Meetup", + content = "annual uniqbody gathering", + ), + ) + // GitRepositoryEvent has an empty content field — its searchable + // text comes entirely from the name and description tags. + val repo = + signer.sign( + GitRepositoryEvent.build( + name = "uniqname", + description = "uniqdesc nostr client", + ), + ) + + db.store.insertEvent(cal) + db.store.insertEvent(repo) + + // Title tag and content are both indexed for the calendar event. + db.assertQuery(cal, Filter(search = "uniqtitle")) + db.assertQuery(cal, Filter(search = "uniqbody")) + + // Name and description tags are indexed even with empty content. + db.assertQuery(repo, Filter(search = "uniqname")) + db.assertQuery(repo, Filter(search = "uniqdesc")) + db.assertQuery(repo, Filter(kinds = listOf(GitRepositoryEvent.KIND), search = "uniqdesc")) + } + + @Test + fun testProfileJsonFieldsAreSearchable() = + forEachDB { db -> + // Kind-0 content is JSON; we parse it and index names, bio, and the + // addresses people search by (nip05 email, lightning address, URLs). + val p = + signer.sign( + MetadataEvent.createNew( + name = "uniqalice", + about = "loves uniqbio and coffee", + nip05 = "alice@uniqmail.example", + lnAddress = "alice@uniqln.example", + website = "https://uniqsite.example", + ), + ) + db.store.insertEvent(p) + + db.assertQuery(p, Filter(search = "uniqalice")) // name + db.assertQuery(p, Filter(search = "uniqbio")) // about + db.assertQuery(p, Filter(search = "uniqmail")) // nip05 email + db.assertQuery(p, Filter(search = "uniqln")) // lightning address + db.assertQuery(p, Filter(search = "uniqsite")) // website URL + db.assertQuery(p, Filter(kinds = listOf(MetadataEvent.KIND), search = "uniqalice")) + } + + @Test + fun testReindexFullTextSearchRebuildsTheWholeIndex() = + forEachDB { db -> + // A calendar event (title tag + content) and a plain note. Both + // are searchable kinds, but we'll erase the FTS index to mimic + // events that were stored before their kind became searchable. + val cal = + signer.sign( + CalendarEvent.build( + title = "uniqtitle Meetup", + content = "annual uniqbody gathering", + ), + ) + val note = signer.sign(TextNoteEvent.build("reindex uniqnote please", createdAt = TimeUtils.now())) + + db.store.insertEvent(cal) + db.store.insertEvent(note) + + // Wipe the FTS rows but keep the canonical event rows — this is + // the state a store ends up in after an upgrade adds search + // support for a kind that was inserted under the old code. + db.store.pool.useWriter { db.store.fullTextSearchModule.deleteAll(it) } + db.store.assertQuery(null, Filter(search = "uniqbody")) + db.store.assertQuery(null, Filter(search = "uniqnote")) + + // Rebuilding from storage brings every searchable field back. + db.store.reindexFullTextSearch() + db.store.assertQuery(cal, Filter(search = "uniqtitle")) + db.store.assertQuery(cal, Filter(search = "uniqbody")) + db.store.assertQuery(note, Filter(search = "uniqnote")) + + // Running it again must not duplicate rows (assertQuery expects + // exactly one match), proving the rebuild starts from a clean slate. + db.store.reindexFullTextSearch() + db.store.assertQuery(cal, Filter(search = "uniqbody")) + db.store.assertQuery(note, Filter(search = "uniqnote")) + } + + @Test + fun testResumableReindexProcessesEveryEventInBatches() = + forEachDB { db -> + // A handful of searchable notes, each with a unique token. + val notes = + (0 until 5).map { i -> + signer.sign(TextNoteEvent.build("uniqresume$i body", createdAt = TimeUtils.now() + i)) + } + notes.forEach { db.store.insertEvent(it) } + + // Mimic the post-upgrade state: canonical rows present, FTS empty. + db.store.pool.useWriter { db.store.fullTextSearchModule.deleteAll(it) } + db.store.assertQuery(null, Filter(search = "uniqresume0")) + + // Drive the resumable path two events at a time, persisting only + // the opaque cursor between calls — exactly what a paused/resumed + // app would do. + var cursor: String? = null + var batches = 0 + var processed = 0 + do { + val progress = db.store.reindexFullTextSearch(cursor, batchSize = 2) + cursor = progress.cursor + processed += progress.processedThisBatch + batches++ + } while (!progress.done) + + // 5 events at 2 per batch ⇒ 3 batches (2 + 2 + 1). + kotlin.test.assertEquals(5, processed) + kotlin.test.assertEquals(3, batches) + + // Every note is searchable again after the full run. + notes.forEachIndexed { i, n -> db.store.assertQuery(n, Filter(search = "uniqresume$i")) } + } + + @Test + fun testResumableReindexKeepsSearchLiveAndIsIdempotent() = + forEachDB { db -> + val a = signer.sign(TextNoteEvent.build("uniqalpha note", createdAt = TimeUtils.now())) + val b = signer.sign(TextNoteEvent.build("uniqbeta note", createdAt = TimeUtils.now() + 1)) + db.store.insertEvent(a) + db.store.insertEvent(b) + + // First batch (size 1) reindexes only the lowest row_id; the + // untouched event keeps the FTS row it already had from insert, + // so search stays live for both throughout. + val first = db.store.reindexFullTextSearch(null, batchSize = 1) + kotlin.test.assertEquals(false, first.done) + db.store.assertQuery(a, Filter(search = "uniqalpha")) + db.store.assertQuery(b, Filter(search = "uniqbeta")) + + // Finish, then run the whole loop again from scratch: delete-then- + // insert per event means no duplicate rows (assertQuery wants 1). + var cursor = first.cursor + do { + val p = db.store.reindexFullTextSearch(cursor, batchSize = 1) + cursor = p.cursor + } while (!p.done) + + cursor = null + do { + val p = db.store.reindexFullTextSearch(cursor, batchSize = 10) + cursor = p.cursor + } while (!p.done) + + db.store.assertQuery(a, Filter(search = "uniqalpha")) + db.store.assertQuery(b, Filter(search = "uniqbeta")) + } + + @Test + fun testResumableReindexClampsNonPositiveBatchSize() = + forEachDB { db -> + val a = signer.sign(TextNoteEvent.build("uniqclamp note", createdAt = TimeUtils.now())) + db.store.insertEvent(a) + db.store.pool.useWriter { db.store.fullTextSearchModule.deleteAll(it) } + + // batchSize 0 must not spin forever: it is clamped to one event + // per call. Cap the loop so a regression fails fast instead of + // hanging the suite. + var cursor: String? = null + var guard = 0 + do { + val progress = db.store.reindexFullTextSearch(cursor, batchSize = 0) + cursor = progress.cursor + check(guard++ < 100) { "reindex did not terminate with batchSize=0" } + } while (!progress.done) + + db.store.assertQuery(a, Filter(search = "uniqclamp")) + } + + @Test + fun testChannelJsonFieldsAreSearchable() = + forEachDB { db -> + val chan = + signer.sign( + ChannelCreateEvent.build( + name = "uniqchan", + about = "a uniqtopic discussion", + picture = "https://uniqpic.example/c.jpg", + relays = null, + ), + ) + db.store.insertEvent(chan) + + db.assertQuery(chan, Filter(search = "uniqchan")) // name + db.assertQuery(chan, Filter(search = "uniqtopic")) // about + db.assertQuery(chan, Filter(search = "uniqpic")) // picture URL + } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 727e0837ad..0c283d4314 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -103,21 +103,34 @@ class NostrClientRepeatSubTest : RelayClientTest() { coroutineScope { launch { - withTimeoutOrNull(30000) { - var eoseCount = 0 - while (eoseCount < 2) { - Log.d("Test") { "Processing message ${events.size}" } - // simulates an update in the middle of the sub - if (events.size == 1) { - client.subscribe(mySubId, filtersShouldIgnore) - } - if (events.size == 5) { - client.subscribe(mySubId, filtersShouldSendAfterEOSE) - } - val msg = resultChannel.receive() - events.add(msg) - if (msg == "EOSE") eoseCount++ + // Drain every message the relay sends. Re-subscribing on the + // same id mid-stream lets the relay collapse the superseded + // subscription *without* an EOSE — NIP-01 says a re-REQ + // silently replaces the previous one (see + // RelaySession.handleReq, which cancels the running query). + // So the number of EOSEs that actually arrive is + // timing-dependent: anywhere from 1 (only the final filter + // survives to EOSE) to 3. We must not assume a fixed count; + // instead we drain until the relay goes quiet and check the + // invariants that hold for every interleaving. + while (true) { + Log.d("Test") { "Processing message ${events.size}" } + // simulates updates in the middle of the sub + if (events.size == 1) { + client.subscribe(mySubId, filtersShouldIgnore) } + if (events.size == 5) { + client.subscribe(mySubId, filtersShouldSendAfterEOSE) + } + // Once every (collapsed) subscription has finished its + // historical replay the relay only holds a live tail, and + // nothing is being inserted, so receive() would block + // forever. A short idle gap is the deterministic + // "stream finished" signal — far longer than the + // sub-millisecond gaps of in-process delivery, so it never + // fires mid-stream. + val msg = withTimeoutOrNull(IDLE_DRAIN_MS) { resultChannel.receive() } ?: break + events.add(msg) } } @@ -129,24 +142,32 @@ class NostrClientRepeatSubTest : RelayClientTest() { client.unsubscribe(mySubId) client.removeConnectionListener(listener) - // The split between the three subscriptions is inherently racy: - // the consumer fires resub1 (filtersShouldIgnore) at events.size==1 - // and resub2 (filtersShouldSendAfterEOSE) at events.size==5, but - // those four receive() calls give resub1 enough wall-clock time on - // a slow machine to actually reach the relay and start streaming - // events before resub2 replaces it. Verify only the structural - // invariants: two EOSEs, the loop stopped on the second one, and - // every non-EOSE entry is a valid 64-char event id. - val firstEose = events.indexOf("EOSE") - val lastEose = events.lastIndexOf("EOSE") + // The split between the three subscriptions is inherently racy: the + // consumer fires resub1 (filtersShouldIgnore) at events.size==1 and + // resub2 (filtersShouldSendAfterEOSE) at events.size==5, and the relay + // may collapse either earlier subscription before it reaches EOSE. + // Verify only invariants that hold for every interleaving. val eoseCount = events.count { it == "EOSE" } + val nonEose = events.filter { it != "EOSE" } - assertEquals(2, eoseCount) - assertEquals(events.size - 1, lastEose) - assertEquals(true, firstEose in 0 until lastEose) - assertEquals(true, events.filter { it != "EOSE" }.all { it.length == 64 }) - // Upper bound: original (100) + filtersShouldIgnore (50) + filtersShouldSendAfterEOSE (10) + 2 EOSEs, - // plus a small allowance for relays that overshoot their limit by one. - assertEquals(true, events.size <= 165) + // The final filter (filtersShouldSendAfterEOSE) is never superseded, + // so its EOSE always reaches the client. + assertEquals(true, eoseCount >= 1) + // Every non-EOSE entry is a valid 64-char event id. + assertEquals(true, nonEose.all { it.length == 64 }) + // The mid-stream re-REQ actually switched filters: the final + // advertised-relay-list subscription streamed its events (seeded at + // 100_000+). Without the switch we'd only ever see kind-0 ids (<=150). + assertEquals(true, nonEose.any { (it.trimStart('0').toLongOrNull() ?: 0L) >= 100_000L }) + // Upper bound: metadata (<=100) + filtersShouldIgnore (<=50) + + // filtersShouldSendAfterEOSE (<=10) + up to 3 EOSEs, plus a small + // allowance for relays that overshoot their limit. + assertEquals(true, events.size <= 175) } + + companion object { + // No message arrives more than this long after the previous one once the + // relay is still streaming, so this idle gap reliably marks end-of-stream. + private const val IDLE_DRAIN_MS = 5_000L + } } diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index c91cf7445c..9062867baf 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -28,13 +28,16 @@ import com.vitorpamplona.quartz.nip01Core.core.isEphemeral 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.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.EventFactory import com.vitorpamplona.quartz.utils.TimeUtils import java.nio.file.FileAlreadyExistsException import java.nio.file.Files @@ -493,6 +496,106 @@ open class FsEventStore( } } + /** + * Wipe and rebuild only the `idx/fts/` tree from the stored events. + * See [IEventStore.reindexFullTextSearch] for when to call this. + * + * Unlike [scrub] (which rebuilds every index tree from a full + * `events/` walk), this touches nothing but FTS and reads only the + * events whose kind is searchable today: it drives the walk from + * `idx/kind//`, which already lists exactly the live canonical + * events per kind, and skips kind directories that don't map to a + * [SearchableEvent]. The bulk of non-searchable events (reactions, + * zaps, follow lists, …) is never opened. + */ + override suspend fun reindexFullTextSearch() = + lockManager.withWriteLock { + deleteRecursively(layout.idxFts) + Files.createDirectories(layout.idxFts) + + if (!Files.isDirectory(layout.idxKind)) return@withWriteLock + Files.list(layout.idxKind).use { kindDirs -> + for (kindDir in kindDirs) { + val kind = kindDir.fileName.toString().toIntOrNull() ?: continue + if (!isSearchableKind(kind)) continue + Files.list(kindDir).use { entries -> + for (entry in entries) { + val id = FsLayout.parseEntry(entry.fileName.toString())?.second ?: continue + val event = readEvent(id) ?: continue + indexer.linkFts(event, layout.canonical(id)) + } + } + } + } + } + + /** + * Resumable, additive FTS rebuild. See + * [IEventStore.reindexFullTextSearch] for the loop contract. + * + * The cursor is the next searchable kind to process, so the store is + * walked one `idx/kind//` directory at a time — that keeps the + * scan linear (each directory is listed once, never re-sorted) at the + * cost of pausing only between kinds: a single huge kind is processed + * in one batch. [batchSize] is a soft floor — whole kinds are + * processed until at least that many events have been (re)linked, + * then the call yields. Linking is idempotent ([FsIndexer.linkFts] + * ignores an existing hardlink), so nothing is wiped and search stays + * usable throughout; replaying a kind after a crash is harmless. + */ + override suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = + lockManager.withWriteLock { + if (!Files.isDirectory(layout.idxKind)) { + return@withWriteLock FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true) + } + Files.createDirectories(layout.idxFts) + + val resumeKind = resumeFrom?.toIntOrNull() + val target = batchSize.coerceAtLeast(1) + val pending = + Files + .list(layout.idxKind) + .use { dirs -> dirs.map { it.fileName.toString() }.toList() } + .mapNotNull { it.toIntOrNull() } + .filter { isSearchableKind(it) && (resumeKind == null || it >= resumeKind) } + .sorted() + + var processed = 0 + var index = 0 + while (index < pending.size) { + val kind = pending[index] + Files.list(layout.kindDir(kind)).use { entries -> + for (entry in entries) { + val id = FsLayout.parseEntry(entry.fileName.toString())?.second ?: continue + val event = readEvent(id) ?: continue + indexer.linkFts(event, layout.canonical(id)) + processed++ + } + } + index++ + if (processed >= target) break + } + + val done = index >= pending.size + FtsReindexProgress( + cursor = if (done) null else pending[index].toString(), + processedThisBatch = processed, + done = done, + ) + } + + /** + * True when [kind] currently parses to a [SearchableEvent]. Kind + * alone selects the event class in [EventFactory], so a single probe + * per kind is authoritative. The id is non-blank so kinds that lazily + * hash a missing id (NIP-17 chat) skip that work — only the runtime + * type matters here. + */ + private fun isSearchableKind(kind: Int): Boolean = EventFactory.create("0", "0", 0L, kind, emptyArray(), "", "") is SearchableEvent + private fun deleteRecursively(p: java.nio.file.Path) { if (!Files.exists(p)) return Files.walk(p).use { stream -> diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsIndexer.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsIndexer.kt index 6a99bec6cc..198e4aea25 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsIndexer.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsIndexer.kt @@ -86,6 +86,21 @@ internal class FsIndexer( } } + /** + * Create only the NIP-50 FTS hardlinks for this event (no-op when it + * isn't a [SearchableEvent]). Used by the FTS-only rebuild so an + * existing kind/author/owner/tag tree is left untouched. Idempotent. + */ + fun linkFts( + event: Event, + canonical: Path, + ) { + if (event !is SearchableEvent) return + for (token in FsSearchTokenizer.tokenize(event.indexableContent())) { + createLink(layout.ftsEntry(token, event.createdAt, event.id), canonical) + } + } + /** Remove every index hardlink for this event. Missing entries ignored. */ fun unlink(event: Event) { for (path in pathsFor(event)) { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index 3678d795c4..9251311df9 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.store.fs -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.runBlocking import java.nio.file.Files @@ -105,9 +106,9 @@ class FsSearchTest { val ftsRoot = root.resolve("idx/fts") val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() - // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so - // we get the content tokens plus the subject ones. What matters is - // that each unique token yields exactly one entry under its dir. + // This note has no subject, so indexableContent() is just the + // content. What matters is that each unique token yields exactly + // one entry under its dir. assertTrue("bitcoin" in tokenDirs) assertTrue("nostr" in tokenDirs) assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) @@ -117,17 +118,105 @@ class FsSearchTest { @Test fun `non-searchable event does not produce fts entries`() = runBlocking { - val meta = - signer.sign( + // An event whose kind has no SearchableEvent mapping must not be indexed. + val nonSearchable = + signer.sign( createdAt = 1, - kind = MetadataEvent.KIND, + kind = 9999, tags = emptyArray(), - content = "{\"name\":\"vitor\"}", + content = "this text must not be indexed", ) - store.insert(meta) + store.insert(nonSearchable) val ftsRoot = root.resolve("idx/fts") - assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") + assertEquals(0, ftsRoot.listDirectoryEntries().size, "unknown kind is not SearchableEvent") + } + + @Test + fun `reindexFullTextSearch rebuilds fts entries after a wipe`() = + runBlocking { + val a = note("bitcoin reindex", ts = 100) + val b = note("nostr reindex", ts = 200) + store.insert(a) + store.insert(b) + + // Mimic a store written before this kind was searchable: drop + // the whole idx/fts/ tree, leaving canonical events intact. + val ftsRoot = root.resolve("idx/fts") + Files.walk(ftsRoot).use { stream -> + stream.sorted(Comparator.reverseOrder()).forEach { p -> if (p != ftsRoot) Files.deleteIfExists(p) } + } + assertEquals(0, ftsRoot.listDirectoryEntries().size, "precondition: fts wiped") + assertTrue(store.query(Filter(search = "bitcoin")).isEmpty(), "no index, no match") + + store.reindexFullTextSearch() + + assertEquals(listOf(a.id), store.query(Filter(search = "bitcoin")).map { it.id }) + assertEquals(listOf(b.id), store.query(Filter(search = "nostr")).map { it.id }) + // Reindexing twice must not duplicate entries. + store.reindexFullTextSearch() + assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) + // "reindex" appears in both notes — one entry per event, no dupes. + assertEquals(2, ftsRoot.resolve("reindex").listDirectoryEntries().size) + } + + @Test + fun `resumable reindex covers every kind across batches`() = + runBlocking { + // Two searchable kinds (note = kind 1, long-form = kind 30023) so + // the kind-granular cursor must advance across more than one dir. + val n = note("uniqnote bitcoin", ts = 100) + val long = + signer.sign( + LongTextNoteEvent.build( + "uniqlong body", + title = "title", + dTag = "d1", + createdAt = 200, + ), + ) + store.insert(n) + store.insert(long) + + // Wipe the index, then drive the resumable path one kind at a time. + val ftsRoot = root.resolve("idx/fts") + Files.walk(ftsRoot).use { stream -> + stream.sorted(Comparator.reverseOrder()).forEach { p -> if (p != ftsRoot) Files.deleteIfExists(p) } + } + assertTrue(store.query(Filter(search = "uniqnote")).isEmpty()) + + var cursor: String? = null + var batches = 0 + do { + val progress = store.reindexFullTextSearch(cursor, batchSize = 1) + cursor = progress.cursor + batches++ + } while (!progress.done) + + // Two searchable kind dirs, batchSize 1 ⇒ at least two batches. + assertTrue(batches >= 2, "expected the cursor to span both kinds, got $batches batch(es)") + assertEquals(listOf(n.id), store.query(Filter(search = "uniqnote")).map { it.id }) + assertEquals(listOf(long.id), store.query(Filter(search = "uniqlong")).map { it.id }) + } + + @Test + fun `reindexFullTextSearch ignores non-searchable kinds`() = + runBlocking { + val searchable = note("findme bitcoin", ts = 100) + val opaque = + signer.sign( + createdAt = 1, + kind = 9999, + tags = emptyArray(), + content = "findme must not be indexed", + ) + store.insert(searchable) + store.insert(opaque) + + store.reindexFullTextSearch() + + // Only the searchable note contributes the "findme" token. + assertEquals(listOf(searchable.id), store.query(Filter(search = "findme")).map { it.id }) } @Test diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index aac7b3147f..6e82288085 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -32,6 +32,13 @@ # amy-1.08.0-linux-x64.tar.gz # amy-1.08.0-linux-x64.deb # amy-1.08.0-linux-x64.rpm +# +# One CLI asset breaks the family/arch shape on purpose: the no-JRE jar bundle +# for Homebrew-core is pure JVM bytecode (no bundled runtime), so a single +# platform-independent artifact serves every OS: +# amy-1.08.0-jvm.tar.gz +# It is packaged inline in create-release.yml (linux leg), not via the helpers +# below, since it has no / dimension. set -euo pipefail