Merge branch 'main' into feat/desktop-relay-latency-health

This commit is contained in:
Róbert Nagy
2026-06-19 10:28:07 +03:00
committed by GitHub
228 changed files with 8479 additions and 452 deletions
@@ -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"
+124 -1
View File
@@ -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-<ver>-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
+111 -5
View File
@@ -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-<version>-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 <KEY_ID> # 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-<version>-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:
+3
View File
@@ -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"
@@ -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)
}
@@ -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<String> {
val hashes = mutableListOf<String>()
@@ -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
}
@@ -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
@@ -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 =
@@ -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
}
}
@@ -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())
}
}
}
}
@@ -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<Color>? = 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<Color>? = 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,
@@ -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,
@@ -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
@@ -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()
},
)
}
@@ -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
* (<https://github.com/jooray/roadstr>): 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
}
@@ -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 (<https://github.com/jooray/roadstr>) 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 (<https://github.com/jooray/roadstr>) 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<Double, Double>?,
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<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun RoadEventConfirmationEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun resolveRoadEventPoint(
latitude: Double?,
longitude: Double?,
geohashes: List<String>,
): Pair<Double, Double>? {
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
}
}
@@ -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) },
@@ -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)
@@ -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)
@@ -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(
@@ -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<Note>(full.size)
val hiddenCounts = HashMap<String, Int>()
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<Note>,
val hiddenCounts: Map<String, Int>,
)
/**
* 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) {
@@ -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
* (`<filesDir>/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
}
}
@@ -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
}
@@ -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 }
}
}
@@ -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
@@ -674,6 +674,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -586,6 +586,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1341,7 +1341,6 @@
<string name="call_settings_turn_credential">Pověření</string>
<string name="always_on_notif_channel_description">Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase</string>
<string name="always_on_notif_title">Amethyst oznámení aktivní</string>
<string name="always_on_notif_connected">Připojeno k %1$d inbox relayím</string>
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3108,6 +3107,7 @@
<item quantity="many">%1$s +%2$d dalších</item>
<item quantity="other">%1$s +%2$d dalších</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Cílová částka (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Popište svůj cíl</string>
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1325,7 +1325,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_settings_turn_credential">Anmeldeinformationen</string>
<string name="always_on_notif_channel_description">Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen</string>
<string name="always_on_notif_title">Amethyst-Benachrichtigungen aktiv</string>
<string name="always_on_notif_connected">Mit %1$d Inbox-Relays verbunden</string>
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3046,6 +3045,7 @@ anz der Bedingungen ist erforderlich</string>
<item quantity="one">%1$s +%2$d weitere</item>
<item quantity="other">%1$s +%2$d weitere</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Zielbetrag (Sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beschreibe dein Ziel</string>
@@ -528,6 +528,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -21,6 +21,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -456,6 +456,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1603,6 +1603,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1578,6 +1578,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1578,6 +1578,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -961,6 +961,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -583,6 +583,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1451,6 +1451,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1311,7 +1311,6 @@
<string name="call_settings_turn_credential">योग्यता प्रमाण</string>
<string name="always_on_notif_channel_description">आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए</string>
<string name="always_on_notif_title">अमेथिस्ट सूचनाएँ सक्रिय</string>
<string name="always_on_notif_connected">संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ</string>
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
@@ -3033,6 +3032,7 @@
<item quantity="one">%1$s सं॰ %2$d अधिक</item>
<item quantity="other">%1$s सं॰ %2$d अधिक</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">उद्देश्य मात्रा (साट्स)</string>
<string name="goal_amount_placeholder">१०००००</string>
<string name="goal_description_label">आपके उद्देश्य का विवरण करें</string>
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1327,7 +1327,6 @@
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
<string name="always_on_notif_channel_description">Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében</string>
<string name="always_on_notif_title">Amethyst értesítések aktíválva</string>
<string name="always_on_notif_connected">Kapcsolódva %1$d beérkező üzenetátjátszóhoz</string>
<string name="always_on_notif_connecting">Kapcsolódás a beérkező üzenetátjátszókhoz\u2026</string>
<string name="always_on_notif_setting_title">Folyamatos értesítési szolgáltatás</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3049,6 +3048,7 @@
<item quantity="one">%1$s +%2$d további</item>
<item quantity="other">%1$s +%2$d további</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Célösszeg (satoshiban)</string>
<string name="goal_amount_placeholder">100 000</string>
<string name="goal_description_label">Cél leírása, bemutatása</string>
@@ -525,6 +525,7 @@ Seharusnya %3$s</string>
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -446,6 +446,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -403,6 +403,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -185,6 +185,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
+450 -1
View File
@@ -8,6 +8,14 @@
<string name="show_anyway">Toch tonen</string>
<string name="post_was_hidden">Dit bericht is verborgen omdat het verborgen gebruikers of woorden bevat</string>
<string name="post_was_flagged_as_inappropriate_by">Bericht gemarkeerd als ongepast door</string>
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="one">Dit bericht bevat meer dan %1$d hashtag</item>
<item quantity="other">Dit bericht bevat meer dan %1$d hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d asset gebundeld</item>
<item quantity="other">%1$d assets gebundeld</item>
</plurals>
<string name="post_not_found">Bericht niet gevonden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanaalafbeelding</string>
@@ -95,7 +103,63 @@
<string name="log_out">Uitloggen</string>
<string name="show_more">Meer</string>
<string name="lightning_invoice">Lightning-invoice</string>
<string name="invoice_expired">Verlopen</string>
<string name="clink_lightning_offer">CLINK-aanbod</string>
<string name="clink_offer_pay_to">Aan</string>
<string name="clink_requesting_invoice">Invoice aanvragen…</string>
<string name="clink_debit_no_response">De debit-service heeft de betaling niet voltooid.</string>
<string name="clink_confirm_payment_title">Betaling bevestigen</string>
<string name="clink_confirm_pay_amount_via_source">Betaal %1$s via %2$s?</string>
<string name="clink_confirm_pay_via_source">Betaal deze invoice via %1$s?</string>
<string name="clink_offer_amount_sats">Bedrag (sats)</string>
<string name="clink_offer_invalid_amount">Voer een geldig bedrag in voor dit aanbod.</string>
<string name="clink_offer_amount_range">Toegestane range: %1$s–%2$s sats</string>
<string name="clink_offer_label">CLINK-aanbod (noffer)</string>
<string name="clink_budget_title">Uitgavenbudget</string>
<string name="clink_budget_amount_sats">Bedrag (sats)</string>
<string name="clink_budget_request">Aanvragen</string>
<string name="clink_budget_approved">Budget goedgekeurd</string>
<string name="clink_budget_one_time">Eenmalig</string>
<string name="clink_budget_daily">Dagelijks</string>
<string name="clink_budget_weekly">Wekelijks</string>
<string name="clink_budget_monthly">Maandelijks</string>
<string name="clink_debit_pay_only">Alleen betalen</string>
<string name="wallet_add_clink_description">Betaal en zap vanuit een wallet die je account vooraf heeft geautoriseerd. Alleen uitgeven — geen saldo of geschiedenis.</string>
<string name="wallet_add_clink_invalid">Ongeldige CLINK debit-pointer. Verwacht een ndebit1… string.</string>
<string name="wallet_paste_ndebit">Plak ndebit-pointer</string>
<string name="pay">Betalen</string>
<string name="send_payment_title">Betaling versturen</string>
<string name="send_payment_method">Ontvangen op</string>
<string name="send_payment_from">Betalen vanuit</string>
<string name="send_payment_source_external">Andere wallet-app</string>
<string name="send_payment_method_clink">CLINK-aanbod</string>
<string name="send_payment_amount">Bedrag</string>
<string name="send_payment_fixed_price">Prijs vastgelegd door het aanbod</string>
<string name="send_payment_receipt_section">Zap-ontvangstbewijs</string>
<string name="send_payment_receipt_clink">Directe Lightning-betaling — geen zap-ontvangstbewijs wordt gepubliceerd op Nostr.</string>
<string name="send_payment_receipt_onchain">Een on-chain zap-ontvangstbewijs wordt gepubliceerd op Nostr zodat de ontvanger de betaling kan vinden.</string>
<string name="send_payment_receipt_onchain_address">Betaalt het aangekondigde Bitcoin-adres van het profiel (%1$s) direct vanuit je on-chain wallet — geen zap-ontvangstbewijs wordt gepubliceerd.</string>
<string name="send_payment_receipt_cashu">Het nutzap-event levert de ecash zelf, dus het wordt altijd gepubliceerd.</string>
<string name="send_payment_requesting_invoice">Invoice aanvragen…</string>
<string name="send_payment_requesting_invoice_nostr">Invoice aanvragen via Nostr…</string>
<string name="send_payment_paying_via">Betalen via %1$s…</string>
<string name="send_payment_sending_nutzap">Ecash vergrendelen en versturen…</string>
<string name="send_payment_building_tx">Transactie bouwen en uitzenden…</string>
<string name="send_payment_sent_to_wallet">Invoice overhandigd aan je wallet-app om de betaling te voltooien.</string>
<string name="send_payment_success">Betaling verzonden!</string>
<string name="send_payment_failed">Betaling mislukt</string>
<string name="send_payment_try_again">Probeer opnieuw</string>
<string name="send_payment_message_recipient">Bericht de ontvanger over deze betaling</string>
<string name="send_payment_done">Klaar</string>
<string name="send_payment_pay_button">Betaal %1$s sats</string>
<string name="send_payment_pay_button_empty">Betalen</string>
<string name="send_payment_no_methods">Dit profiel heeft geen betalingsmethodes die Amethyst direct kan betalen.</string>
<string name="send_payment_min_onchain">On-chain zaps vereisen minstens %1$s sats</string>
<string name="send_payment_cashu_insufficient">Onvoldoende saldo in een mint die deze ontvanger accepteert. Vul eerst je Cashu-wallet aan.</string>
<string name="send_payment_cashu_balance">Besteedbaar voor deze ontvanger: %1$s sats</string>
<string name="send_payment_onchain_fee">Netwerkkosten</string>
<string name="send_payment_external_wallet">je wallet-app</string>
<string name="send_payment_onchain_txid">Transactie: %1$s</string>
<string name="lightning_tips">Lightning-tips</string>
<string name="note_to_receiver">Bericht aan ontvanger</string>
<string name="thank_you_so_much">Hartelijk bedankt!</string>
@@ -123,6 +187,7 @@
<string name="write_a_message">Schrijf een bericht…</string>
<string name="post">Plaatsen</string>
<string name="save">Opslaan</string>
<string name="send">Verzenden</string>
<string name="create">Aanmaken</string>
<string name="rename">Hernoemen</string>
<string name="cancel">Annuleren</string>
@@ -165,6 +230,9 @@
<string name="image_saved_to_the_gallery">Afbeelding opgeslagen in galerij</string>
<string name="video_download_has_started_toast">Video download gestart…</string>
<string name="media_download_has_started_toast">Media download gestart…</string>
<string name="pin_conversation">Vastzetten bovenaan</string>
<string name="unpin_conversation">Losmaken</string>
<string name="pinned_to_top">Vastgezet bovenaan</string>
<string name="failed_to_save_the_image">Afbeelding opslaan mislukt</string>
<string name="video_saved_to_the_gallery">Video opgeslagen in galerij</string>
<string name="failed_to_save_the_video">Video opslaan mislukt</string>
@@ -194,6 +262,7 @@
<string name="voice_anonymize_description">Verandert de toonhoogte van je stem. Let op: eenvoudige toonhoogteveranderingen kunnen mogelijk worden teruggedraaid door vastberaden luisteraars.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
<string name="reply_here">"hier reageren…"</string>
<string name="user_suggestion_in_this_chat">In deze chat</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopieert note-ID naar klembord om te delen</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopieer kanaal-ID (note) naar klembord</string>
<string name="edits_the_channel_metadata">Past kanaal-metadata aan</string>
@@ -254,8 +323,12 @@
<string name="generate_a_new_key">Nieuwe sleutel genereren</string>
<string name="loading_feed">Feed laden…</string>
<string name="loading_account">Account laden…</string>
<string name="chats_history_proto_nip17">versleuteld</string>
<string name="chats_reply_searching_history">Op zoek naar het originele bericht…</string>
<!-- A reply whose target was searched for across all reachable history and never found. -->
<string name="chats_reply_not_found">Kon dit bericht niet vinden</string>
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
<string name="chats_reply_searched">Alle relays doorzocht · tik om te zien</string>
<string name="error_loading_replies">"Fout bij laden reacties:"</string>
<string name="try_again">Opnieuw proberen</string>
<string name="notification_feed_is_empty">Nog geen meldingen.</string>
@@ -348,6 +421,10 @@
<string name="quick_action_select">Selecteren</string>
<string name="quick_action_share_browser_link">Browserlink delen</string>
<string name="quick_action_share">Delen</string>
<string name="share_as_image">Delen als afbeelding</string>
<string name="share_as_image_url">Delen als afbeeldings-URL</string>
<string name="share_as_image_generating">Voorbeeld genereren…</string>
<string name="share_as_image_watermark">Gedeeld via Amethyst</string>
<string name="quick_action_copy_user_id">Auteur-ID</string>
<string name="quick_action_copy_note_id">Note-ID</string>
<string name="quick_action_copy_text">Tekst kopiëren</string>
@@ -536,7 +613,65 @@
<string name="profile_badges_header">Badges · %1$d</string>
<string name="profile_badges_description">Kies welke van de ontvangen badges op je profiel verschijnen.</string>
<string name="profile_badges_empty">Je hebt nog geen badges ontvangen.</string>
<string name="profile_apps_empty_hint">Aanbevolen de Nostr-apps die je gebruikt zodat anderen ze kunnen ontdekken.</string>
<string name="profile_app_recommendations_title">Aanbevolen apps</string>
<string name="profile_app_recommendations_description">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.</string>
<string name="profile_app_recommendations_empty">Nog geen apps gevonden. Apps verschijnen hier zodra ze ontdekt worden op je relays.</string>
<string name="app_definition_untitled">Naamloze app</string>
<string name="app_definition_no_supported_kinds">Geeft niet aan welke inhoud het ondersteunt</string>
<string name="app_definition_recommend">Aanbevelen</string>
<string name="app_definition_recommended">Aanbevolen</string>
<string name="app_definition_handles">Behandelt</string>
<string name="app_definition_by">door</string>
<string name="app_definition_available_on">Beschikbaar op</string>
<string name="pictures">Afbeeldingen</string>
<string name="new_workout">Nieuwe workout</string>
<string name="workout_title">Titel</string>
<string name="workout_duration">Duur</string>
<string name="workout_distance">Afstand</string>
<string name="workout_pace">Tempo</string>
<string name="workout_speed">Snelheid</string>
<string name="workout_elevation">Hoogtewinst</string>
<string name="workout_elevation_loss">Hoogteverlies</string>
<string name="workout_calories">Calorieën</string>
<string name="workout_steps">Stappen</string>
<string name="workout_heart_rate">Hartslag</string>
<string name="workout_max_heart_rate">Maximale hartslag</string>
<string name="workout_reps">Herhalingen</string>
<string name="workout_weight">Gewicht</string>
<string name="workout_notes">Notities</string>
<string name="workout_hours">Uren</string>
<string name="workout_minutes">Minuten</string>
<string name="workout_seconds">Seconden</string>
<string name="workout_suggestion_connect_title">Deel je workouts</string>
<string name="workout_suggestion_connect_message">Laat Amethyst voltooide workouts lezen uit Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) en stel voor om ze als bericht te delen.</string>
<string name="workout_suggestion_connect_button">Verbinden</string>
<string name="workout_suggestion_detected_title">Nieuwe workout gedetecteerd</string>
<string name="workout_suggestion_share">Delen</string>
<string name="workout_suggestion_dismiss">Sluiten</string>
<string name="workout_from_health_connect">Van Health Connect</string>
<string name="exercise_running">Hardlopen</string>
<string name="exercise_walking">Wandelen</string>
<string name="exercise_cycling">Fietsen</string>
<string name="exercise_hiking">Wandelen</string>
<string name="exercise_swimming">Zwemmen</string>
<string name="exercise_rowing">Roeien</string>
<string name="exercise_strength">Krachttraining</string>
<string name="exercise_meditation">Meditatie</string>
<string name="exercise_diet">Dieet</string>
<string name="exercise_fasting">Vasten</string>
<string name="nip82_repository_label">Bron: %1$s</string>
<string name="nip82_download">Downloaden</string>
<string name="nip82_section_about">Over</string>
<string name="nip82_section_platforms">Platformen</string>
<string name="nip82_section_topics">Onderwerpen</string>
<string name="nip82_section_latest_release">Laatste release</string>
<string name="nip82_section_comments">Reacties</string>
<string name="nip82_no_comments">Wees de eerste die reageert.</string>
<string name="nip82_by_author">door</string>
<string name="nip82_older_releases_show">Toon oudere releases</string>
<string name="nip82_older_releases_hide">Verberg oudere releases</string>
<string name="calendars">Kalenders</string>
<string name="shorts">Shorts</string>
<string name="public_chats">Publieke chats</string>
<string name="follow_packs">Volgpakketten</string>
@@ -696,12 +831,52 @@
<string name="nests_servers_empty">Nog geen nest-servers. Voeg er een toe of kies een aanbevolen server.</string>
<string name="longs">Video\'s</string>
<string name="articles">Artikelen</string>
<string name="route_music_tracks">Muziek</string>
<string name="route_music_playlists">Afspeellijsten</string>
<string name="route_podcast_episodes">Afleveringen</string>
<string name="podcast_view_episodes">Afleveringen bekijken</string>
<string name="podcast_episodes_section">Afleveringen</string>
<string name="podcast_no_episodes">Nog geen afleveringen gevonden</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d aflevering</item>
<item quantity="other">%1$d afleveringen</item>
</plurals>
<string name="new_music_track">Nieuw muziekstuk</string>
<string name="new_music_playlist">Nieuwe afspeellijst</string>
<string name="music_track_title_label">Titel</string>
<string name="music_track_title_placeholder">Titel van het nummer</string>
<string name="music_track_artist_label">Artiest</string>
<string name="music_track_artist_placeholder">Naam artiest</string>
<string name="music_track_audio_url_label">Audio-URL</string>
<string name="music_track_image_url_label">Cover-afbeelding URL (optioneel)</string>
<string name="music_track_album_label">Album (optioneel)</string>
<string name="music_track_duration_label">Duur in seconden (optioneel)</string>
<string name="music_track_description_label">Songteksten of credits (optioneel)</string>
<string name="music_track_delete">Dit nummer verwijderen</string>
<string name="music_track_delete_confirm">Een NIP-09-verwijdering publiceren voor dit muziekstuk?</string>
<string name="add_to_music_playlist">Toevoegen aan afspeellijst</string>
<string name="add_to_music_playlist_title">Toevoegen aan afspeellijst</string>
<string name="add_to_music_playlist_empty">Je hebt nog geen afspeellijsten. Gebruik het veld hierboven om er een aan te maken.</string>
<string name="add_to_music_playlist_manage_all">Alle afspeellijsten beheren</string>
<string name="music_playlist_new_dialog_title">Nieuwe afspeellijst</string>
<string name="music_playlist_new_title_placeholder">Naam van de afspeellijst</string>
<string name="music_playlist_create_action">Aanmaken</string>
<string name="music_playlist_untitled">(naamloze afspeellijst)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d nummer</item>
<item quantity="other">%1$d nummers</item>
</plurals>
<string name="private_bookmarks">Privé-bladwijzers</string>
<string name="public_bookmarks">Publieke bladwijzers</string>
<string name="add_to_private_bookmarks">Toevoegen aan privé-bladwijzers</string>
<string name="add_to_public_bookmarks">Toevoegen aan publieke bladwijzers</string>
<string name="remove_from_private_bookmarks">Verwijderen uit privé-bladwijzers</string>
<string name="remove_from_public_bookmarks">Verwijderen uit publieke bladwijzers</string>
<string name="add_hashtag_label">Hashtag toevoegen</string>
<string name="add_hashtag_label_title">Hashtag toevoegen</string>
<string name="add_hashtag_label_explainer">Publiek labelen met een hashtag (NIP-32 label). Mensen die je volgen zien het in de feed van die hashtag.</string>
<string name="add_hashtag_label_confirm">Toevoegen</string>
<string name="hashtag_label_added_by">toegevoegd door</string>
<string name="pinned_notes">Vastgezette notities</string>
<string name="pinned_notes_explainer">Je vastgezette notities</string>
<string name="pin_to_profile">Vastmaken aan profiel</string>
@@ -748,6 +923,15 @@
<string name="article_bookmark_management_title">Dit artikel bookmarken</string>
<string name="public_bookmark_presence_indicator">is een publieke bladwijzer hier</string>
<string name="private_bookmark_presence_indicator">is een privébladwijzer hier</string>
<string name="private_rumor_mark">Privé — alleen zichtbaar voor getagde deelnemers</string>
<string name="private_note">Privé maken: gift-wrap de notitie alleen voor de genoemde gebruikers</string>
<string name="disable_private_note">Publiek maken</string>
<string name="private_note_locked">Reacties op een privénotitie blijven altijd privé</string>
<string name="private_note_visible_to">Zichtbaar voor</string>
<string name="private_note_no_receivers">Nog geen ontvangers: alleen jij kunt deze notitie zien. Voeg mensen toe om te delen.</string>
<string name="notify_add_user">Toevoegen</string>
<string name="notify_remove_user">Gebruiker verwijderen uit meldingen</string>
<string name="notify_search_and_add_user">Zoek en voeg een gebruiker toe om te melden</string>
<string name="bookmark_absence_indicator">is geen bladwijzer hier</string>
<string name="bookmark_remove_action_desc">Bladwijzer uit lijst verwijderen</string>
<string name="bookmark_add_action_desc">Bladwijzer toevoegen aan lijst</string>
@@ -769,6 +953,29 @@
<string name="wallet_connect_manual_config">Geavanceerd: handmatig verbindingsgegevens invoeren</string>
<string name="quick_zap_amounts">Snelle zap-bedragen</string>
<string name="quick_zap_amounts_explainer">Worden getoond bij het indrukken van de zap-knop. Tik op een bedrag om te verwijderen. Leeg laten opent steeds het invoerscherm.</string>
<string name="send_onchain_instead">In plaats daarvan on-chain versturen</string>
<string name="reload_mint_title">Mint aanvullen</string>
<string name="reload_mint_topup_label">Aanvullingsbedrag</string>
<string name="reload_mint_section_to">Mint om aan te vullen</string>
<string name="reload_mint_section_from">Gelden van</string>
<string name="reload_mint_pay_lightning">Betalen met Lightning</string>
<string name="reload_mint_lightning_desc">Nieuwe ecash minten vanuit je Lightning-wallet</string>
<string name="reload_mint_not_enough">Niet genoeg hier</string>
<string name="reload_mint_available">%1$s sats beschikbaar</string>
<string name="reload_mint_confirm">Aanvullen &amp; zap versturen</string>
<string name="reload_mint_send_confirm">Zap versturen</string>
<string name="reload_mint_awaiting_payment">Wachten op betaling…</string>
<string name="reload_mint_retry">Probeer opnieuw</string>
<string name="reload_mint_summary">Vul %1$s sats aan bij %2$s en zap %3$s %4$s sats · kosten ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">Zap %1$s %2$s sats vanuit %3$s</string>
<string name="reload_mint_recipient_fallback">de ontvanger</string>
<string name="reload_mint_needs_more">heeft %1$s sat meer nodig</string>
<string name="reload_mint_funded">aangevuld</string>
<string name="reload_mint_copy_invoice">Invoice kopiëren</string>
<string name="topup_mint_title">Mint aanvullen</string>
<string name="topup_mint_action">Deze mint aanvullen</string>
<string name="topup_mint_amount_label">Bedrag om toe te voegen</string>
<string name="topup_mint_confirm">Aanvullen</string>
<string name="zap_privacy_section">Zap-privacy</string>
<string name="zap_type_section_explainer">Bepaalt hoe jouw identiteit zichtbaar is bij het versturen van een zap.</string>
<string name="wallet_connect_connect_app">Wallet verbinden</string>
@@ -859,6 +1066,7 @@
<string name="payment_target_authority">Adres</string>
<string name="delete_payment_target">Betalingsdoel verwijderen</string>
<string name="no_payment_app_found">Geen app gevonden om deze betaling te verwerken. Installeer een compatibele wallet.</string>
<string name="no_payment_app_found_for_type">Geen app geïnstalleerd om %1$s-betalingen te verwerken. Installeer een compatibele wallet.</string>
<string name="error_dialog_payment_error">Kan betaling niet openen</string>
<string name="uploading_state_ready">Niet gestart</string>
<string name="uploading_state_compressing">Comprimeren</string>
@@ -886,6 +1094,7 @@
<string name="follow_list_kind3follows_proxy">Volgt via proxy</string>
<string name="follow_list_aroundme">In de buurt</string>
<string name="follow_list_global">Globaal</string>
<string name="follow_list_curated">Gecureerd</string>
<string name="follow_list_chess">Schaken</string>
<string name="follow_list_mine">Van mij</string>
<string name="follow_list_mute_list">Gedempte lijst</string>
@@ -1069,7 +1278,6 @@
<string name="call_settings_turn_credential">Credential</string>
<string name="always_on_notif_channel_description">Houdt verbindingen met je inbox-relays actief voor realtime meldingen</string>
<string name="always_on_notif_title">Amethyst-meldingen actief</string>
<string name="always_on_notif_connected">Verbonden met %1$d inbox-relays</string>
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1606,7 +1814,74 @@
<string name="wallet_invalid_uri">Ongeldige NWC-verbinding-URI</string>
<string name="wallet_move_up">Omhoog verplaatsen</string>
<string name="wallet_move_down">Omlaag verplaatsen</string>
<string name="wallet_onchain_transactions">Onchain-transacties</string>
<string name="wallet_onchain_no_address">Geen on-chain adres beschikbaar voor dit account.</string>
<string name="wallet_onchain_no_backend">Geen chain-backend geconfigureerd.</string>
<string name="wallet_onchain_pending">In behandeling</string>
<string name="wallet_onchain_public_chip">Publiek</string>
<string name="wallet_onchain_public_dialog_title">Deze wallet is publiek</string>
<string name="wallet_onchain_public_dialog_body">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.</string>
<string name="wallet_onchain_public_dialog_confirm">Begrepen</string>
<string name="wallet_onchain_copy_dialog_confirm">Adres kopiëren</string>
<!-- Onchain zap send dialog -->
<string name="onchain_send_title">On-chain zap versturen</string>
<string name="onchain_send_close">Sluiten</string>
<string name="onchain_send_to">Aan</string>
<string name="onchain_send_post_author">Berichtauteur</string>
<string name="onchain_send_recipient">Ontvanger</string>
<string name="onchain_send_recipient_placeholder">naam, NIP-05 of npub</string>
<string name="onchain_send_change_recipient">Ontvanger wijzigen</string>
<string name="onchain_send_amount">Bedrag</string>
<string name="onchain_send_min_warning">Minimum on-chain zap is %1$s sats — kleinere bedragen gaan verloren aan miner-kosten. Gebruik een Lightning-zap in plaats daarvan.</string>
<string name="onchain_send_comment_label">Opmerking (optioneel)</string>
<string name="onchain_send_priority">Prioriteit</string>
<string name="onchain_send_fee_slow">Langzaam</string>
<string name="onchain_send_fee_normal">Normaal</string>
<string name="onchain_send_fee_fast">Snel</string>
<string name="onchain_send_fee_eta_slow">~1 uur</string>
<string name="onchain_send_loading_fees">Kostenramingen laden…</string>
<string name="onchain_send_button_amount">Verstuur %1$s sats</string>
<plurals name="onchain_send_button_amount_split">
<item quantity="one">Verstuur %1$s sats, %2$d-weg</item>
<item quantity="other">Verstuur %1$s sats, %2$d-wegen</item>
</plurals>
<plurals name="onchain_send_use_note_split">
<item quantity="one">Gebruik de %1$d-weg zap-split van dit bericht</item>
<item quantity="other">Gebruik de %1$d-weg zap-split van dit bericht</item>
</plurals>
<plurals name="onchain_send_splits_label">
<item quantity="one">Splits onder %1$d ontvanger</item>
<item quantity="other">Splits onder %1$d ontvangers</item>
</plurals>
<plurals name="onchain_send_splits_skipped">
<item quantity="one">%1$d Lightning-adres-alleen-ontvanger overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden.</item>
<item quantity="other">%1$d Lightning-adres-alleen-ontvangers overgeslagen — on-chain heeft een Nostr-pubkey nodig om het adres af te leiden.</item>
</plurals>
<string name="onchain_send_dont_split">Niet splitsen — betaal één ontvanger in plaats daarvan</string>
<string name="onchain_send_sending">Transactie bouwen, ondertekenen en uitzenden…</string>
<string name="onchain_send_success">On-chain zap verstuurd</string>
<string name="onchain_send_result_transaction">Transactie</string>
<string name="onchain_send_result_fee">Kosten</string>
<string name="onchain_send_result_change">Wisselgeld</string>
<string name="onchain_send_done">Klaar</string>
<string name="onchain_send_failed_at">Mislukt bij: %1$s</string>
<string name="onchain_send_broadcast_no_receipt">Betaling uitgezonden (tx %1$s) maar het ontvangstbewijs is niet gepubliceerd.</string>
<string name="onchain_stage_loading_utxos">saldo laden</string>
<string name="onchain_stage_building">transactie bouwen</string>
<string name="onchain_stage_signing">ondertekenen</string>
<string name="onchain_stage_broadcasting">uitzenden</string>
<string name="onchain_stage_publishing">ontvangstbewijs publiceren</string>
<string name="onchain_send_error_backend_not_configured">Bitcoin chain-backend is niet geconfigureerd</string>
<string name="onchain_send_error_load_utxos">Kon je Bitcoin-saldo niet laden</string>
<string name="onchain_send_error_build">Kon de transactie niet bouwen</string>
<string name="onchain_send_error_recipient_dust">Een aandeel van een ontvanger is onder dust</string>
<string name="onchain_send_error_sign">Kon de transactie niet ondertekenen</string>
<string name="onchain_send_error_broadcast">Kon de transactie niet uitzenden</string>
<string name="onchain_send_error_publish_receipt">Betaling verstuurd, maar het zap-ontvangstbewijs kon niet worden gepubliceerd</string>
<plurals name="onchain_send_error_publish_receipt_partial">
<item quantity="one">Betaling verstuurd (%1$d van %2$d ontvangstbewijs gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd</item>
<item quantity="other">Betaling verstuurd (%1$d van %2$d ontvangstbewijzen gepubliceerd), maar het volgende ontvangstbewijs kon niet worden gepubliceerd</item>
</plurals>
<string name="route_security_filters">Beveiligingsfilters</string>
<string name="route_import_follows">Volgers importeren</string>
<string name="new_post">Nieuw bericht</string>
@@ -1619,6 +1894,119 @@
<string name="new_zap_poll">Nieuwe Zap-poll</string>
<string name="new_regular_poll">Nieuwe gewone poll</string>
<string name="new_picture">Nieuwe afbeelding</string>
<string name="new_calendar_event">Nieuw agenda-evenement</string>
<string name="edit_calendar_event">Agenda-evenement bewerken</string>
<string name="calendar_event_all_day_locked">Vergrendeld tijdens bewerken — wijzigen zou een nieuw evenement creëren.</string>
<string name="new_calendar_collection">Nieuwe agenda</string>
<string name="edit_calendar_collection">Agenda bewerken</string>
<string name="calendar_collection_events_section">Evenementen in deze agenda (%1$d)</string>
<string name="calendar_collection_no_events_yet">Je hebt nog geen agenda-evenementen aangemaakt.</string>
<string name="calendar_view_month">Maand</string>
<string name="calendar_view_day">Dag</string>
<string name="calendar_section_upcoming">Aankomend</string>
<string name="calendar_section_past">Verleden</string>
<string name="calendar_empty_feed">Nog geen aankomende of verleden agenda-evenementen van je geselecteerde feed.</string>
<string name="calendar_empty_collections">Nog geen agenda-collecties.</string>
<string name="calendar_empty_feed_title">Je agenda is leeg</string>
<string name="calendar_empty_feed_subtitle">Evenementen gedeeld door mensen die je volgt verschijnen hier. Tik op de + knop om er zelf een aan te maken.</string>
<string name="calendar_empty_collections_title">Nog geen collecties</string>
<string name="calendar_empty_collections_subtitle">Groepeer evenementen — een serie meetups, een conferentie-track, de roadmap van je team. Tik op + om er een aan te maken.</string>
<string name="calendar_empty_day_title">Niets gepland</string>
<string name="calendar_empty_day_subtitle">Geen evenementen op deze dag. Tik op + om er een toe te voegen.</string>
<string name="calendar_empty_week_title">Niets deze week</string>
<string name="calendar_empty_week_subtitle">Geen evenementen vallen in deze week.</string>
<string name="calendar_event_title">Titel</string>
<string name="calendar_event_summary">Samenvatting</string>
<string name="calendar_event_location">Locatie</string>
<string name="calendar_event_image">Afbeeldings-URL</string>
<string name="calendar_event_all_day">Hele dag evenement</string>
<string name="calendar_event_start">Begint</string>
<string name="calendar_event_end">Eindigt</string>
<string name="calendar_event_hashtags">Hashtags (komma-gescheiden)</string>
<string name="calendar_event_pick_date">Datum kiezen</string>
<string name="calendar_event_pick_time">Tijd kiezen</string>
<string name="calendar_event_invalid">Titel en starttijd zijn verplicht.</string>
<string name="calendar_event_end_before_start">Eind moet na start zijn.</string>
<string name="calendar_nav_previous_month">Vorige maand</string>
<string name="calendar_nav_next_month">Volgende maand</string>
<string name="calendar_nav_previous_week">Vorige week</string>
<string name="calendar_nav_next_week">Volgende week</string>
<string name="calendar_nav_previous_day">Vorige dag</string>
<string name="calendar_nav_next_day">Volgende dag</string>
<string name="calendar_no_events_today">Geen evenementen op deze dag</string>
<string name="calendar_no_events">Geen evenementen</string>
<string name="calendar_continues">Doorgaan</string>
<string name="calendar_day_of_total">Dag %1$d van %2$d</string>
<string name="calendar_add_to_phone_calendar">Toevoegen aan telefoon-agenda</string>
<string name="calendar_nav_jump_to_today">Spring naar vandaag</string>
<plurals name="calendar_day_a11y_events">
<item quantity="one">%1$s, %2$d evenement</item>
<item quantity="other">%1$s, %2$d evenementen</item>
</plurals>
<string name="calendar_day_a11y_no_events">%1$s, geen evenementen</string>
<string name="calendar_day_a11y_today_suffix">vandaag</string>
<string name="calendar_day_a11y_selected_suffix">geselecteerd</string>
<string name="calendar_fab_new_event">Nieuw agenda-evenement aanmaken</string>
<string name="calendar_fab_new_collection">Nieuwe agenda-collectie aanmaken</string>
<string name="calendar_fab_toggle">Aanmaakopties tonen</string>
<string name="calendar_untitled">(naamloos)</string>
<string name="calendar_all_day">Hele dag</string>
<string name="calendar_rsvp_going_prefixed">✓ Ga</string>
<string name="calendar_rsvp_maybe_prefixed">\? Misschien</string>
<string name="calendar_rsvp_not_going_prefixed">✗ Kan niet</string>
<string name="calendar_collection_title">Titel</string>
<string name="calendar_collection_description">Omschrijving</string>
<string name="calendar_collection_invalid">Een titel is verplicht.</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d evenement</item>
<item quantity="other">%1$d evenementen</item>
</plurals>
<string name="calendar_collection_empty_members">Nog geen evenementen in deze agenda.</string>
<string name="calendar_rsvp_going">Ga</string>
<string name="calendar_rsvp_maybe">Misschien</string>
<string name="calendar_rsvp_not_going">Kan niet</string>
<string name="calendar_rsvp_none">Nog geen RSVPs.</string>
<string name="calendar_participants_section">Deelnemers (%1$d)</string>
<string name="calendar_event_in_calendars">In agenda\'s (%1$d)</string>
<string name="calendar_event_in_no_calendars">Nog niet onderdeel van een agenda.</string>
<string name="calendar_event_loading">Evenement laden…</string>
<string name="calendar_relative_ongoing">Nu aan de gang</string>
<string name="calendar_relative_ongoing_with_end">%1$s · eindigt %2$s</string>
<string name="calendar_export_share_title">Agenda-evenement delen</string>
<string name="calendar_export_event">Exporteren naar agenda (.ics)</string>
<string name="calendar_reminder_channel_name">Agenda-herinneringen</string>
<string name="calendar_reminder_channel_description">Melding wanneer een evenement waar je naartoe gaat bijna begint.</string>
<string name="calendar_reminder_default_title">Agenda-evenement</string>
<plurals name="calendar_reminder_body">
<item quantity="one">Begint over %1$d minuut</item>
<item quantity="other">Begint over %1$d minuten</item>
</plurals>
<string name="calendar_add_to_calendar_action">Toevoegen aan een van je agenda\'s</string>
<string name="calendar_add_to_calendar_title">Toevoegen aan een agenda</string>
<string name="calendar_add_to_calendar_none">Je hebt nog geen agenda\'s aangemaakt.</string>
<string name="calendar_collection_delete">Agenda verwijderen</string>
<string name="calendar_collection_delete_confirm_title">Deze agenda verwijderen?</string>
<string name="calendar_collection_delete_confirm_message">De agenda-lijst wordt verwijderd. Evenementen erin worden niet verwijderd.</string>
<string name="calendar_event_pick_image">Afbeelding kiezen</string>
<string name="calendar_event_image_upload_failed">Afbeelding uploaden mislukt</string>
<string name="calendar_event_image_upload_failed_body">De gekozen afbeelding kon niet worden geüpload. Probeer opnieuw of plak een URL.</string>
<string name="calendar_event_participants_section">Deelnemers (%1$d)</string>
<string name="calendar_event_participant_input">Zoek naam, npub of nip-05</string>
<string name="calendar_event_participant_invalid">Voer een geldige npub… of 64-karakter hex pubkey in.</string>
<string name="calendar_event_participant_remove">Deelnemer verwijderen</string>
<string name="calendar_reminder_settings_title">Agenda-herinneringen</string>
<string name="calendar_reminder_settings_enabled_title">Herinneringen versturen</string>
<string name="calendar_reminder_settings_enabled_subtitle">Een melding verschijnt wanneer een evenement waar je naartoe gaat bijna begint.</string>
<string name="calendar_reminder_settings_lead_title">Herinneringstijd</string>
<string name="calendar_reminder_settings_lead_subtitle">Hoeveel minuten voor het evenement je een melding wilt ontvangen.</string>
<string name="calendar_share_nostr">Delen als nostr-link</string>
<string name="calendar_share_nostr_title">Agenda-link delen</string>
<string name="calendar_filter_all">Alle agenda\'s</string>
<string name="calendar_filter_sheet_title">Toon evenementen van…</string>
<string name="calendar_filter_no_calendars">Je hebt nog geen agenda\'s aangemaakt.</string>
<string name="calendar_open_in_maps">Openen in kaarten</string>
<string name="calendar_open_link">Link openen</string>
<string name="route_calendar_event_detail">Evenementdetails</string>
<string name="new_short_video">Nieuwe short video</string>
<string name="new_long_video">Nieuwe lange video</string>
<string name="article_title">Titel</string>
@@ -1642,6 +2030,7 @@
<string name="boost_or_quote_description">Boost of citaat</string>
<string name="like_description">Vind ik leuk</string>
<string name="zap_description">Zap</string>
<string name="onchain_zap_pending">Wachten op bevestiging</string>
<string name="change_reaction">Snelle reacties wijzigen</string>
<string name="bottom_bar_settings">Onderste navigatiebalk</string>
<string name="bottom_bar_settings_description">Sleep om te herordenen. Schakel in/uit om items toe te voegen of te verwijderen. Met nul items wordt de balk verborgen.</string>
@@ -1693,6 +2082,15 @@
<string name="video_player_settings_action_pip_description">Video in zwevend venster afspelen (verborgen als niet ondersteund)</string>
<string name="video_player_settings_action_cast">Casten naar apparaat</string>
<string name="video_player_settings_action_cast_description">Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden)</string>
<string name="audio_visualizer_settings">Audio-visualisatie</string>
<string name="audio_visualizer_settings_description">Kies de animatie die getoond wordt tijdens het afspelen van audionotities.</string>
<string name="audio_visualizer_off">Uit</string>
<string name="audio_visualizer_bars">Spectrum-balken</string>
<string name="audio_visualizer_waves">Kleur-golven</string>
<string name="audio_visualizer_radial">Radiale ring</string>
<string name="audio_visualizer_aurora">Aurora-gloed</string>
<string name="audio_visualizer_classic">Klassieke golfvorm</string>
<string name="audio_visualizer_static">Statische afbeelding</string>
<string name="profile_image_of_user">Profielfoto van %1$s</string>
<string name="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Relay-lijst uitvouwen</string>
@@ -2062,6 +2460,12 @@
<string name="kind_user_settings">Gebruikersinstellingen</string>
<string name="kind_audio_header">Audio-header</string>
<string name="kind_audio_track">Audiospoor</string>
<string name="kind_music_track">Muziekstuk</string>
<string name="kind_music_playlist">Afspeellijst</string>
<string name="kind_podcast_episode">Podcast-aflevering</string>
<string name="kind_podcast_metadata">Podcast-show</string>
<string name="kind_authored_podcasts">Gemaakt podcasts</string>
<string name="kind_favorite_podcasts">Favoriete podcasts</string>
<string name="kind_badge_awards">Badge-awards</string>
<string name="kind_badge_definitions">Badge-definities</string>
<string name="kind_accepted_badge_set">Geaccepteerde badge-set</string>
@@ -2304,6 +2708,29 @@
<item quantity="one">%1$d jaar</item>
<item quantity="other">%1$d jaar</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d openstaande invoice</item>
<item quantity="other">%1$d openstaande invoices</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Tik om door te gaan en de status te controleren.</string>
<string name="cashu_pending_quotes_resume">Doorgaan</string>
<plurals name="cashu_untrusted_mint_title">
<item quantity="one">Fondsen bewaard bij %1$d mint die je niet gebruikt</item>
<item quantity="other">Fondsen bewaard bij %1$d mints die je niet gebruikt</item>
</plurals>
<string name="cashu_untrusted_mint_subtitle">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.</string>
<string name="cashu_untrusted_mint_badge">Niet in je wallet</string>
<string name="cashu_untrusted_mint_move">Verplaatsen</string>
<string name="cashu_move_coins_title">Munten verplaatsen</string>
<string name="cashu_move_coins_body">%2$s sat bij een mint die je niet gebruikt (%1$s). Verplaats ze naar een mint die je vertrouwt.</string>
<string name="cashu_move_coins_to_mint">Verplaatsen naar een mint die je vertrouwt</string>
<string name="cashu_move_coins_move">Verplaatsen</string>
<string name="cashu_move_coins_fee_hint">Een kleine Lightning-kost wordt afgetrokken door deze mint, dus je moet mogelijk iets minder dan het volledige saldo sturen.</string>
<string name="cashu_move_coins_no_trusted">Voeg eerst een mint toe aan je wallet om munten tussen mints te verplaatsen, of gebruik de opties hieronder.</string>
<string name="cashu_move_coins_withdraw_ln">Terugtrekken via Lightning…</string>
<string name="cashu_move_coins_export_token">Exporteren als Cashu-token…</string>
<string name="cashu_move_coins_working">Munten verplaatsen…</string>
<string name="cashu_move_coins_done">%1$s sat verplaatst naar %2$s.</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Verbinden</string>
<string name="event_sync_status_downloading">Downloaden</string>
@@ -2409,6 +2836,17 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">Dit doel is gesloten</string>
<string name="goal_progress">%1$s gefinancierd van %2$s sats doel</string>
<string name="fundraiser_ends">Eindigt %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donatie</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d soorten</item>
<item quantity="other">Birdex · %1$d soorten</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d meer</item>
<item quantity="other">%1$s +%2$d meer</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Doelbedrag (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beschrijf je doel</string>
@@ -2519,6 +2957,8 @@
<string name="ai_writing_setting_description">Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen.</string>
<string name="tracked_broadcasts_setting_title">Getrackte uitzendingen</string>
<string name="tracked_broadcasts_setting_description">Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden.</string>
<string name="suggest_workouts_setting_title">Workouts voorstellen om te delen</string>
<string name="suggest_workouts_setting_description">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.</string>
<string name="compose_settings">Opstel-instellingen</string>
<string name="auto_create_drafts_setting_title">Automatisch concepten aanmaken</string>
<string name="auto_create_drafts_setting_description">Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays.</string>
@@ -2580,4 +3020,13 @@
<string name="community_rules_violation_wot_gate_failed">Je voldoet niet aan de web-of-trust-vereisten van deze community.</string>
<string name="community_rules_violation_stale_rules">Het laatste community-regelsdocument is afgewezen als verouderd.</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">Nowhere-site</string>
<string name="nowhere_link_card_event">Nowhere-evenement</string>
<string name="nowhere_link_card_fundraiser">Nowhere-donatieactie</string>
<string name="nowhere_link_card_store">Nowhere-winkel</string>
<string name="nowhere_link_card_petition">Nowhere-petitie</string>
<string name="nowhere_link_card_message">Nowhere-bericht</string>
<string name="nowhere_link_card_drop">Nowhere-drop</string>
<string name="nowhere_link_card_art">Nowhere-kunst</string>
<string name="nowhere_link_card_forum">Nowhere-forum</string>
</resources>
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1345,7 +1345,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="call_settings_turn_credential">Uwierzytelnienie</string>
<string name="always_on_notif_channel_description">Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym</string>
<string name="always_on_notif_title">Powiadomienia Ametyst Aktywne</string>
<string name="always_on_notif_connected">Połączono z %1$d transmiterami odbiorczymi</string>
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3113,6 +3112,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<item quantity="many">%1$s +%2$d więcej</item>
<item quantity="other">%1$s +%2$d więcej</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Kwota zbiorki (w satoszach)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Opisz cel zbiórki</string>
@@ -1320,7 +1320,6 @@
<string name="call_settings_turn_credential">Credencial</string>
<string name="always_on_notif_channel_description">Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real</string>
<string name="always_on_notif_title">Notificações do Amethyst ativas</string>
<string name="always_on_notif_connected">Conectado a %1$d relays de caixa de entrada</string>
<string name="always_on_notif_connecting">Conectando aos relays de caixa de entrada\u2026</string>
<string name="always_on_notif_setting_title">Serviço de notificações sempre ativo</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3041,6 +3040,7 @@
<item quantity="one">%1$s +%2$d a mais</item>
<item quantity="other">%1$s +%2$d a mais</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Valor da meta (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Descreva sua meta</string>
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -599,6 +599,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -457,6 +457,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="quick_action_select">Izberi</string>
<string name="quick_action_share_browser_link">Deli povezavo brskalnika</string>
<string name="quick_action_share">Deli</string>
<string name="share_as_image">Deli sliko</string>
<string name="share_as_image_url">Deli URL slike</string>
<string name="share_as_image_generating">Ustvarjam predogled…</string>
<string name="share_as_image_watermark">Deljeno z Amethyst</string>
<string name="quick_action_copy_user_id">Avtorjev ID</string>
<string name="quick_action_copy_note_id">ID zapiska</string>
<string name="quick_action_copy_text">Kopiraj tekst</string>
@@ -679,10 +683,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="workout_duration">Trajanje</string>
<string name="workout_distance">Razdalja</string>
<string name="workout_pace">Tempo</string>
<string name="workout_speed">Hitrost</string>
<string name="workout_elevation">Višinska razlika</string>
<string name="workout_elevation_loss">Izguba višine</string>
<string name="workout_calories">Kalorije</string>
<string name="workout_steps">Koraki</string>
<string name="workout_heart_rate">Srčni utrip</string>
<string name="workout_max_heart_rate">Max srčni utrip</string>
<string name="workout_sets">Serije</string>
<string name="workout_reps">Ponovitve</string>
<string name="workout_weight">Teža</string>
@@ -690,6 +697,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="workout_hours">Ur</string>
<string name="workout_minutes">Minut</string>
<string name="workout_seconds">Sekund</string>
<string name="workout_suggestion_connect_title">Deli svoje vadbe</string>
<string name="workout_suggestion_connect_message">Dovoli aplikaciji Amethyst branje zaključenih vadb iz Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) , da ti predlaga objavo</string>
<string name="workout_suggestion_connect_button">Poveži</string>
<string name="workout_suggestion_detected_title">Zaznana nova vadba</string>
<string name="workout_suggestion_share">Deli</string>
<string name="workout_suggestion_dismiss">Prekliči</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_suggestion_heart_rate">%1$s bpm</string>
<string name="workout_suggestion_calories">%1$s kcal</string>
<string name="workout_from_health_connect">Iz Health Connect</string>
<string name="exercise_running">Tek</string>
<string name="exercise_walking">Hoja</string>
<string name="exercise_cycling">Kolesarjenje</string>
@@ -1342,7 +1359,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="call_settings_turn_credential">Akreditivi</string>
<string name="always_on_notif_channel_description">Ohranja aktivne povezave z vašimi releji za obvestila v realnem času</string>
<string name="always_on_notif_title">Amethyst obvestila so aktivna</string>
<string name="always_on_notif_connected">Povezan z %1$d vhodnimi releji</string>
<string name="always_on_notif_connecting">Povezovanje vhodnih relejev\u2026</string>
<string name="always_on_notif_setting_title">Vedno aktivna obvestila</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1625,6 +1641,22 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="music_track_uploading">Nalagam…</string>
<string name="music_track_uploading_banner">Nalaganje naslovnice in zvoka ter objavljanje. Postopek se bo nadaljeval, tudi če zapustiš ta zaslon.</string>
<string name="music_track_upload_failed">Nalaganje ni uspelo: %1$s</string>
<string name="edit_music_playlist">Uredi seznam predvajanja</string>
<string name="music_playlist_edit_action">Uredi seznam predvajanja</string>
<string name="music_playlist_title_label">Naslov</string>
<string name="music_playlist_description_label">Opis (neobvezno)</string>
<string name="music_playlist_notes_label">Zapiski (neobvezno)</string>
<string name="music_playlist_cover_upload_cta">Naloži prikazno sliko</string>
<string name="music_playlist_cover_upload_hint">Izberi kvadratno sliko za naslovnico tega seznama predvajanja.</string>
<string name="music_playlist_private_hint">Skrij seznam predvajanja iz tvojega javnega profila.</string>
<string name="music_playlist_collaborative_hint">Dovoli drugim, da dodajo skladbe v ta seznam predvajanja.</string>
<string name="music_playlist_delete">Izbriši ta seznam predvajanja</string>
<string name="music_playlist_delete_confirm">Objavi zahtevo za izbris NIP-09 za ta seznam predvajanja?</string>
<string name="music_playlist_uploading_banner">Nalaganje in objava naslovnice. Ta postopek se bo nadaljeval, tudi če zapustite zaslon.</string>
<string name="music_playlist_no_tracks_hint">Seznam je prazen. Dodaj skladbe iz menija posamezne pesmi z možnostjo \"Dodaj na seznam predvajanja\".</string>
<string name="music_playlist_move_track_up">Premakni skladbo navzgor</string>
<string name="music_playlist_move_track_down">Premakni skladbo na dol</string>
<string name="music_playlist_remove_track">Odstrani skladbo iz seznama predvajanja</string>
<string name="loading_location">Nalaganje lokacije</string>
<string name="lack_location_permissions">Brez dovoljenj za lokacijo</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -1944,6 +1976,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="cashu_mints">Kovnice</string>
<string name="cashu_mint_url">URL kovnice</string>
<string name="cashu_remove_mint">Odstrani kovnico</string>
<string name="cashu_add_mint">Dodaj kovnico</string>
<string name="cashu_history">Zgodovina</string>
<string name="cashu_edit_wallet">Uredi denarnico</string>
<string name="cashu_p2pk_section">Ključ Nutzap (napredno)</string>
@@ -1989,6 +2022,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="cashu_settings_edit_wallet">Uredi podatke o denarnici</string>
<string name="cashu_settings_edit_wallet_subtitle">Kovnice, Nutzap ključ</string>
<string name="cashu_settings_my_recommendations">Priporočene kovnice</string>
<string name="cashu_settings_recommendations_subtitle">Javno podpri kovnice, ki jim zaupaš, in prekliči priporočila.</string>
<string name="cashu_settings_no_recommendations">Nimaš še priporočenih kovnic. Tapni palec navzgor ob kovnici in jo javno priporoči.</string>
<string name="cashu_settings_delete_recommendation">Izbriši priporočeno</string>
<string name="cashu_settings_delete_confirm_title">Umakni priporočilo?</string>
@@ -1999,6 +2033,25 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="cashu_settings_restore_running">Preverjanje kovnic…</string>
<string name="cashu_settings_restore_nothing_found">Ni najdenih dokazil za obnovo.</string>
<string name="cashu_settings_restore_result">Uspešno obnovljenih %1$s satov skozi %2$s dokazil.</string>
<string name="cashu_settings_stop_nutzaps">Ustavi prejemanje nutzapov</string>
<string name="cashu_settings_stop_nutzaps_subtitle">Umakni svojo objavo vrste 10019, da ti drugi ne morejo več pošiljati nutzapov. Tvoja denarnica in stanje ostaneta nespremenjena.</string>
<string name="cashu_settings_stop_nutzaps_confirm_title">Ustavim prejemanje nutzapov?</string>
<string name="cashu_settings_stop_nutzaps_confirm_body">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.</string>
<string name="cashu_settings_recreate_key">Ponovno ustvari ključ za nutzape</string>
<string name="cashu_settings_recreate_key_subtitle">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.</string>
<string name="cashu_settings_recreate_key_confirm_title">Ponovno ustvarim ključ za nutzape?</string>
<string name="cashu_settings_recreate_key_confirm_body">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.</string>
<string name="cashu_settings_recreate_key_action">Ponovno ustvari ključ</string>
<string name="cashu_settings_import_key">Uvozi ključ za nutzape</string>
<string name="cashu_settings_import_key_subtitle">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.</string>
<string name="cashu_settings_import_key_confirm_title">Uvozim ključ za nutzape?</string>
<string name="cashu_settings_import_key_confirm_body">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.</string>
<string name="cashu_settings_import_key_field">P\"PK zasebni ključ (hex)</string>
<string name="cashu_settings_import_key_action">Uvozi ključ</string>
<string name="cashu_settings_delete_wallet">Izbriši denarnico</string>
<string name="cashu_settings_delete_wallet_subtitle">Odstrani denarnico in ustavi nutzape. Morebitno preostalo stanje lahko postane nepopravljivo izgubljeno.</string>
<string name="cashu_settings_delete_wallet_confirm_title">Izbrišem to denarnico?</string>
<string name="cashu_settings_delete_wallet_confirm_body">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.</string>
<string name="cashu_pay_invoice">Plačaj račun</string>
<string name="cashu_get_quote">Pridobi tečaj</string>
<string name="cashu_getting_quote">Zahtevam tečaj od kovnice…</string>
@@ -2017,6 +2070,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="cashu_done">Končano</string>
<string name="cashu_requesting_invoice">ahtevam račun od kovnice…</string>
<string name="cashu_waiting_for_payment">Čakanje na plačilo računa…</string>
<string name="cashu_checking_mint">Preverjam kovnico…</string>
<string name="cashu_completing_mint">Izdajanje dokazil…</string>
<string name="cashu_paying_invoice">Plačujem prek kovnice…</string>
<string name="cashu_building_token">Zamenjava dokazil…</string>
@@ -2994,6 +3048,25 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
</plurals>
<string name="cashu_pending_quotes_subtitle">Tapni za nadaljevanje in preverjanje stanja.</string>
<string name="cashu_pending_quotes_resume">Nadaljuj</string>
<plurals name="cashu_untrusted_mint_title">
<item quantity="one">Sredstva v %1$d kovnici, ki je ne uporabljate</item>
<item quantity="two">Sredstva v %1$d kovnicah, ki ju ne uporabljate</item>
<item quantity="few">Sredstva v %1$d kovnicah, ki jih ne uporabljate</item>
<item quantity="other">Sredstva v %1$d kovnicah, ki jih ne uporabljate</item>
</plurals>
<string name="cashu_untrusted_mint_subtitle">Ti kovanci so prispeli prek nutzapa. Prenesite jih v kovnico, ki ji zaupate, ali jih umaknite na omrežje Lightning, da ostanejo na varnem.</string>
<string name="cashu_untrusted_mint_badge">Ni v vaši denarnici</string>
<string name="cashu_untrusted_mint_move">Premakni</string>
<string name="cashu_move_coins_title">Premakni kovance ven</string>
<string name="cashu_move_coins_body">%2$s satov v kovnici, ki je ne uporabljate (%1$s). Prenesite jih nekam, kjer so varni.</string>
<string name="cashu_move_coins_to_mint">Premakni v kovnico, ki ji zaupate</string>
<string name="cashu_move_coins_move">Premakni</string>
<string name="cashu_move_coins_fee_hint">Ta kovnica zaračuna manjši provizijo za Lightning, zato boste morda morali poslati nekoliko manj od celotnega stanja.</string>
<string name="cashu_move_coins_no_trusted">Najprej v denarnico dodajte kovnico za prenos kovancev med njimi, ali uporabite spodnje možnosti.</string>
<string name="cashu_move_coins_withdraw_ln">Izplačaj prek Lightninga…</string>
<string name="cashu_move_coins_export_token">Izvozi kot žeton Cashu…</string>
<string name="cashu_move_coins_working">Premikam kovance…</string>
<string name="cashu_move_coins_done">%1$s satov premaknjenih v %2$s</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Povezujem</string>
<string name="event_sync_status_downloading">Prenašam</string>
@@ -3113,6 +3186,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<item quantity="few">%1$s in še %2$d druge</item>
<item quantity="other">%1$s in še %2$d drugih</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Ciljni znesek (sat)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Opiši svoj cilj</string>
@@ -3223,6 +3297,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="ai_writing_setting_description">Uporablja model umetne inteligence v napravi za predloge popravkov besedila in spremembe tona.</string>
<string name="tracked_broadcasts_setting_title">Sledeni prenosi</string>
<string name="tracked_broadcasts_setting_description">Pri pošiljanju dogodkov uporabi sledilnik oddajanja. V živo prikazuje napredek in stanje posameznih relejev med oddajanjem.</string>
<string name="suggest_workouts_setting_title">Predlagaj vadbo za deljenje</string>
<string name="suggest_workouts_setting_description">Prebere opravljene vadbe iz aplikacije Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) in predlaga objavo. Dovoljenje je zahtevano le ob povezavi.</string>
<string name="compose_settings">Nastavitve urejevalnika</string>
<string name="auto_create_drafts_setting_title">Samodejno ustvari osnutke</string>
<string name="auto_create_drafts_setting_description">Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje.</string>
@@ -47,6 +47,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1320,7 +1320,6 @@
<string name="call_settings_turn_credential">Inloggningsuppgift</string>
<string name="always_on_notif_channel_description">Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar</string>
<string name="always_on_notif_title">Amethyst-notifieringar aktiva</string>
<string name="always_on_notif_connected">Ansluten till %1$d inbox-relän</string>
<string name="always_on_notif_connecting">Ansluter till inbox-relän\u2026</string>
<string name="always_on_notif_setting_title">Alltid på-notifieringstjänst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3041,6 +3040,7 @@
<item quantity="one">%1$s +%2$d till</item>
<item quantity="other">%1$s +%2$d till</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">Målbelopp (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beskriv ditt mål</string>
@@ -497,6 +497,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -383,6 +383,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -828,6 +828,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -181,6 +181,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -513,6 +513,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -82,6 +82,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -25,6 +25,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -1318,7 +1318,9 @@
<string name="call_settings_turn_credential">凭证</string>
<string name="always_on_notif_channel_description">保持与收件箱中继的连接以便接收实时通知</string>
<string name="always_on_notif_title">Amethyst 通知活跃</string>
<string name="always_on_notif_connected">已连接到 %1$d 个收件箱中继</string>
<plurals name="always_on_notif_connected">
<item quantity="other">已连接到 %1$d 个收件箱中继</item>
</plurals>
<string name="always_on_notif_connecting">正在连接到收件箱中继\u2026</string>
<string name="always_on_notif_setting_title">“始终显示通知”服务</string>
<string name="always_on_notif_setting_description">保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。</string>
@@ -3071,6 +3073,7 @@
<plurals name="birdex_species_preview_more">
<item quantity="other">%1$s + 另%2$d</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="goal_amount_label">目标金额 (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">描述您的目标</string>
@@ -253,6 +253,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -14,6 +14,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
@@ -776,6 +776,7 @@
<!-- LAN cast (Chromecast) feature -->
<!-- HLS multi-resolution video sharing -->
<!-- NIP-75 Zap Goals -->
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<!-- NIP-86 Relay Management -->
<!-- Emoji packs -->
<!-- NIP-9B community rules composer-side validation. -->
+31 -1
View File
@@ -18,6 +18,10 @@
<item quantity="one">%1$d asset bundled</item>
<item quantity="other">%1$d assets bundled</item>
</plurals>
<plurals name="thread_collapsed_reply_count">
<item quantity="one">+%1$d\nreply</item>
<item quantity="other">+%1$d\nreplies</item>
</plurals>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
@@ -1438,7 +1442,14 @@
<string name="always_on_notif_channel_name" translatable="false">Relay Connection Service</string>
<string name="always_on_notif_channel_description">Keeps connections to your inbox relays active for real-time notifications</string>
<string name="always_on_notif_title">Amethyst Notifications Active</string>
<string name="always_on_notif_connected">Connected to %1$d inbox relays</string>
<plurals name="always_on_notif_connected">
<item quantity="one">Connected to %1$d inbox relay</item>
<item quantity="other">Connected to %1$d inbox relays</item>
</plurals>
<plurals name="always_on_notif_connected_foreground">
<item quantity="one">Connected to %1$d relay</item>
<item quantity="other">Connected to %1$d relays</item>
</plurals>
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -3434,6 +3445,25 @@
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Police</string>
<string name="road_event_speed_camera">Speed camera</string>
<string name="road_event_traffic_jam">Traffic jam</string>
<string name="road_event_accident">Accident</string>
<string name="road_event_road_closure">Road closure</string>
<string name="road_event_construction">Construction</string>
<string name="road_event_hazard">Hazard</string>
<string name="road_event_road_condition">Road condition</string>
<string name="road_event_pothole">Pothole</string>
<string name="road_event_fog">Fog</string>
<string name="road_event_ice">Ice</string>
<string name="road_event_animal">Animal on the road</string>
<string name="road_event_other">Road event</string>
<string name="road_event_unknown">Road event</string>
<string name="road_event_confirmed">Still there</string>
<string name="road_event_denied">No longer there</string>
<string name="road_event_confirms_report">Confirms a road report</string>
<string name="road_event_denies_report">Reports a road event cleared</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
+1 -2
View File
@@ -60,8 +60,7 @@
<locale android:name="sr-SP"/>
<locale android:name="ss-ZA"/>
<locale android:name="sv-SE"/>
<locale android:name="sw-KE"/>
<locale android:name="sw-TZ"/>
<locale android:name="sw"/>
<locale android:name="ta"/>
<locale android:name="ta-IN"/>
<locale android:name="th"/>
@@ -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
@@ -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))
}
}
@@ -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
@@ -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": []
}
}
+22
View File
@@ -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"
+47
View File
@@ -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-<version>-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
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Hardened-runtime entitlements for amy's bundled JVM (runtime/bin/java).
Required when codesigning + notarizing the macOS jlink image:
- allow-jit / allow-unsigned-executable-memory: the JIT compiler writes
and executes generated machine code.
- disable-library-validation: amy loads the secp256k1 native .dylib that
secp256k1-kmp-jni-jvm extracts from a jar at runtime; that dylib is not
signed by our Team ID, so library validation would otherwise block it.
- allow-dyld-environment-variables: the Gradle start script sets JVM env.
Applied only to Mach-O *executables* in the image; plain dylibs are signed
without entitlements. See .github/workflows/create-release.yml (build-cli).
-->
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
@@ -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(),
)
}
@@ -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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact>")
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact|reindex-fts>")
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
@@ -67,7 +67,7 @@
<string name="chats_history_subtitle">%1$s · %2$s · loaded since %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">waiting on %1$s</string>
<string name="chats_history_incomplete">Some relays didn\'t respond</string>
<string name="chats_history_incomplete">Some relays didn't respond</string>
<string name="chats_history_incomplete_sub">%1$s unreachable · tap to see which</string>
<string name="chats_history_relays_title">%1$s · history by relay</string>
<string name="chats_history_relay_since">since %1$s</string>
@@ -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)
@@ -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.
@@ -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<String, Boolean>()
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 {
@@ -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<NormalizedRelayUrl>?): 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",
)
}
}
+49
View File
@@ -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-<git-sha>.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`.
+19
View File
@@ -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]
@@ -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]
+30
View File
@@ -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 {
@@ -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<String, Duration>)
├── 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/<name>.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<String, Duration>()
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<String, Duration> = 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<SecureKeyStorage>(relaxed = true)
val tempDir = createTempDirectory("acctmgr-load-state").toFile()
writeAccountsJsonEnc(tempDir, viewOnlyAccountInfo(testNpub))
val mgr = AccountManager(storage, tempDir)
val states = mutableListOf<AccountState>()
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 <fiatjaf-npub> --limit 50 --json > fiatjaf-50.jsonl
amy fetch --kinds 0 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
# For each p-tag referenced pubkey:
amy fetch --kinds 0 --author <ref-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 3 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 10002 --author <fiatjaf-npub> --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/<date>-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/<future>-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/<future>-real-window-launch-benchmark.md`) — `application { Window { App() } }` with file-marker IPC. Catches GPU regressions.
- **Phase 5.3 follow-up** (`desktopApp/plans/<future>-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

Some files were not shown because too many files have changed in this diff Show More