mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/intelligent-newton-bw5dmf
# Conflicts: # commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
This commit is contained in:
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
|
||||
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
|
||||
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
|
||||
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
|
||||
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
|
||||
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
|
||||
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
|
||||
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
|
||||
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
|
||||
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
|
||||
by lowest id; older re-inserts blocked but acknowledged as already covered.
|
||||
|
||||
**STORE-W02 — addressable supersession.** Same as W01 with unique index
|
||||
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
|
||||
|
||||
## Overview
|
||||
|
||||
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
|
||||
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background).
|
||||
|
||||
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
|
||||
|
||||
@@ -67,15 +67,23 @@ grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values
|
||||
|
||||
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
|
||||
|
||||
## Background: Crowdin strip-identical behavior
|
||||
## Background: source-identical translations and the `import_eq_suggestions` flag
|
||||
|
||||
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
|
||||
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated.
|
||||
|
||||
**That changed on 2026-09-12.** `.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve.
|
||||
|
||||
Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did.
|
||||
|
||||
**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all.
|
||||
|
||||
What this means for this skill:
|
||||
|
||||
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
|
||||
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
|
||||
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
|
||||
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated, *or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work.
|
||||
2. **Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it.
|
||||
3. **DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions:
|
||||
- **Never for `<plurals>`.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job.
|
||||
- **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve.
|
||||
|
||||
4. **A repo-side edit to a translated value only sticks where Crowdin's database
|
||||
doesn't contradict it.** Download replaces file content with Crowdin's current
|
||||
@@ -96,6 +104,13 @@ What this means for this skill:
|
||||
from `values/strings.xml` removes it project-wide, and attributes declared
|
||||
there propagate into every export.
|
||||
|
||||
**This does not contradict item 3 — the two cases differ.** Seeding a key
|
||||
Crowdin holds *nothing* for (the identical-value copy) sticks, because there is
|
||||
no stored value to contradict it; that is exactly why the copy pass works.
|
||||
*Overwriting* a value Crowdin already holds differently — including an empty
|
||||
one — still loses on the next sync. Add missing entries in the repo; change
|
||||
existing translations in the UI.
|
||||
|
||||
> **Historical note:** an earlier version of this skill tried to auto-filter the
|
||||
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
|
||||
> the last `New Crowdin translations` commit). It was **dropped** because it
|
||||
@@ -172,7 +187,7 @@ comm -23 \
|
||||
|
||||
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
|
||||
|
||||
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
|
||||
Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
|
||||
|
||||
```bash
|
||||
for locale in cs de-rDE sv-rSE pt-rBR; do
|
||||
@@ -190,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do
|
||||
done
|
||||
```
|
||||
|
||||
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
|
||||
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background).
|
||||
|
||||
### 3. Get English values for missing keys
|
||||
|
||||
@@ -458,7 +473,7 @@ When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
|
||||
```bash
|
||||
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
|
||||
@@ -535,8 +550,8 @@ When adding translated strings to locale files:
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
|
||||
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
|
||||
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
|
||||
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `<plurals>` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.)
|
||||
- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
|
||||
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
|
||||
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
|
||||
|
||||
@@ -31,6 +31,14 @@ jobs:
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
# Upload translations that are identical to the English source (brand
|
||||
# terms, loanwords like "Feed"/"Apps", bare formats like "v%1$s").
|
||||
# Without this they are SKIPPED on upload, so a locale that deliberately
|
||||
# keeps English never reaches Crowdin's DB and the key keeps coming back
|
||||
# as untranslated. They arrive as normal UNAPPROVED translations --
|
||||
# auto_approve_imported stays at its default false, so a translator still
|
||||
# approves them in the Crowdin UI (bulk-select in the Editor).
|
||||
import_eq_suggestions: true
|
||||
download_translations: true
|
||||
# Let the downloaded translations stay in the working tree; the single
|
||||
# create-pull-request step below opens the combined PR.
|
||||
|
||||
+5
-3
@@ -265,9 +265,11 @@ front:
|
||||
sequentially: `for peer in aioquic picoquic quic-go quinn; do
|
||||
quic/interop/run-matrix.sh -s $peer; done`. Plan at
|
||||
`quic/interop/plans/2026-05-06-interop-runner.md`.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
|
||||
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
|
||||
Marmot variant prompts a human to drive the Android UI.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
|
||||
relay-backed suite boots the embedded `amy serve` relay (geode) — no
|
||||
external relay binary; only the Marmot suites additionally need `cargo`
|
||||
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
|
||||
drive the Android UI.
|
||||
|
||||
If a change is documentation-only, UI-only, build-script-only, or otherwise
|
||||
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
|
||||
|
||||
@@ -399,9 +399,9 @@ dependencies {
|
||||
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
|
||||
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
|
||||
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
|
||||
debugImplementation("androidx.compose.runtime:runtime-tracing")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
|
||||
debugImplementation(libs.androidx.compose.runtime.tracing)
|
||||
debugImplementation(libs.androidx.tracing.perfetto)
|
||||
debugImplementation(libs.androidx.tracing.perfetto.binary)
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
@@ -598,6 +598,15 @@ dependencies {
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
// In-process Nostr relay (geode) so unit tests that drive a real
|
||||
// NostrClient talk to an embedded relay instead of a public one. Same
|
||||
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
|
||||
// testFixtures (RelayClientTest base, preload/publish helpers) and the
|
||||
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
|
||||
testImplementation(project(":geode"))
|
||||
testImplementation(testFixtures(project(":geode")))
|
||||
testImplementation(libs.androidx.sqlite.bundled.jvm)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.junit.ktx)
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlaybackErrorOverlayFitTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
|
||||
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioPlayerBoxOverflowTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private class Bounds {
|
||||
var top = 0f
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
@@ -70,7 +70,8 @@ import org.junit.Test
|
||||
* fallback would silently start reading a dead value too.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.relays.eventsync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSyncTest {
|
||||
companion object {
|
||||
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
|
||||
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
|
||||
.build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = {
|
||||
listOf(Constants.mom, Constants.nos)
|
||||
},
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
NostrClient(socketBuilder, appScope)
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiatjafSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = { listOf(fiatjaf) },
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
val newClient = NostrClient(socketBuilder, appScope)
|
||||
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
|
||||
import kotlin.time.DurationUnit
|
||||
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
|
||||
private const val STATE_DUMP_TAG = "STATE DUMP"
|
||||
|
||||
fun debugState(context: Context) {
|
||||
// Everything below is logged at DEBUG, and every argument is built eagerly (the
|
||||
// eager Log.d overload, not the lambda one). Gate on the level that would drop
|
||||
// those lines, because the arguments are the expensive part: nine materialising
|
||||
// LargeCache.filter scans over notes/addressables/users/channels, plus three
|
||||
// passes calling Event.countMemory() — which walks every tag of every cached
|
||||
// event. MainActivity.onPause() calls this unconditionally, so without the gate
|
||||
// a release build (minLevel WARN) did all of that on every backgrounding and
|
||||
// threw the result away. Benchmark builds sit at INFO and paid it too.
|
||||
if (Log.minLevel > LogLevel.DEBUG) return
|
||||
|
||||
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
data class VanishEventItem(
|
||||
@@ -124,7 +125,10 @@ class VanishRequestsState(
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// A cancelled check has no result. Reporting ERROR would show the relay as
|
||||
// having answered badly when it was never asked.
|
||||
if (e is CancellationException) throw e
|
||||
item.complianceResults.update {
|
||||
it + (relay to ComplianceStatus.ERROR)
|
||||
}
|
||||
|
||||
+2
-1
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -60,6 +60,7 @@ import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -162,7 +163,10 @@ class NappletResourceFetcher(
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Cancellation is not an upstream failure — do not report it to the
|
||||
// napplet as one, and do not keep the request alive past it.
|
||||
if (e is CancellationException) throw e
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -201,6 +201,9 @@ class EventNotificationConsumer(
|
||||
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||
.getOrNull()
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
private suspend fun dispatchForAccount(
|
||||
event: Event,
|
||||
account: Account,
|
||||
|
||||
+3
@@ -112,6 +112,9 @@ class NotificationDispatcher(
|
||||
// recipient account.
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
|
||||
+3
@@ -81,6 +81,9 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be rendered.
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.webrtc.RtpSender
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val TAG = "CallSession"
|
||||
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
|
||||
@@ -429,6 +430,7 @@ class CallSession(
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.withTranslation
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MathParser
|
||||
import ru.noties.jlatexmath.JLatexMathDrawable
|
||||
|
||||
@@ -130,12 +131,11 @@ fun LatexEquation(
|
||||
Canvas(modifier = equationModifier) {
|
||||
drawIntoCanvas { canvas ->
|
||||
val native = canvas.nativeCanvas
|
||||
val checkpoint = native.save()
|
||||
// Position the icon's baseline on the text baseline within the padded box.
|
||||
native.translate(0f, drawTopPx)
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(native)
|
||||
native.restoreToCount(checkpoint)
|
||||
native.withTranslation(y = drawTopPx) {
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailing.isNotEmpty()) {
|
||||
|
||||
+2
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.ColorMatrix
|
||||
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
|
||||
@@ -36,6 +35,7 @@ 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.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -159,7 +159,7 @@ fun LocationPreviewMap(
|
||||
remember(pinColor, pinEmoji) {
|
||||
if (pinColor != null && pinEmoji != null) {
|
||||
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
|
||||
BitmapDrawable(context.resources, bitmap)
|
||||
bitmap.toDrawable(context.resources)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -2647,7 +2647,6 @@ class AccountViewModel(
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
.clear()
|
||||
feedStates.destroy()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun loadMentions(
|
||||
|
||||
-1
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
|
||||
-1
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
liveJob?.cancel()
|
||||
liveJob = null
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+11
-3
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,11 +59,15 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
|
||||
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
|
||||
private val MonthShortFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM", Locale.getDefault())
|
||||
// Cached per locale rather than in a single val that captures the locale once: the month
|
||||
// names have to follow a language the user changes while the app is running.
|
||||
private val monthShortFormatters = ConcurrentHashMap<Locale, DateTimeFormatter>()
|
||||
|
||||
private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) }
|
||||
|
||||
@Composable
|
||||
fun CalendarEventListCard(
|
||||
@@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) {
|
||||
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
}
|
||||
val day = localDate.dayOfMonth.toString()
|
||||
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() }
|
||||
// LocalLocale rather than Locale.getDefault(): the latter is not observable, so a
|
||||
// locale change while the app runs would leave the month name in the old language.
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.size(width = 52.dp, height = 60.dp),
|
||||
|
||||
-1
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun toggle(address: Address) {
|
||||
|
||||
+13
-16
@@ -119,7 +119,6 @@ class MarmotFileUploader(
|
||||
// imprecisely.
|
||||
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
|
||||
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
|
||||
val v2Cipher = cipher
|
||||
|
||||
item.orchestrator.uploadEncrypted(
|
||||
uri = media.uri,
|
||||
@@ -142,21 +141,19 @@ class MarmotFileUploader(
|
||||
// compression and metadata stripping — because that is what the
|
||||
// key was derived from.
|
||||
val reference =
|
||||
v2Cipher?.let {
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = it.ciphertextSha256,
|
||||
plaintextSha256 = it.plaintextSha256,
|
||||
nonce = it.nonce,
|
||||
mediaType = it.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = cipher.ciphertextSha256,
|
||||
plaintextSha256 = cipher.plaintextSha256,
|
||||
nonce = cipher.nonce,
|
||||
mediaType = cipher.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
results.add(
|
||||
Mip04UploadResult(
|
||||
url = serverResult.url,
|
||||
|
||||
-1
@@ -801,7 +801,6 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -132,7 +132,6 @@ class ChessViewModelNew(
|
||||
fun clearFocusedGame() = logic.clearFocusedGame()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
logic.stopPolling()
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -739,7 +739,6 @@ class LongFormPostViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -612,7 +612,6 @@ open class NewProductViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
writingAssistant?.close()
|
||||
writingAssistant = null
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
|
||||
-1
@@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
cancel()
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private suspend fun rescan() {
|
||||
|
||||
-1
@@ -597,7 +597,6 @@ open class NestNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -139,6 +139,9 @@ class NotificationFeedFilter(
|
||||
AttestationRequestEvent.KIND,
|
||||
)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
val NOTIFICATION_KINDS =
|
||||
// Kinds that RENDER as a row on the Notifications tab. This is a
|
||||
// display gate over whatever is already in LocalCache — it plays no
|
||||
@@ -268,6 +271,9 @@ class NotificationFeedFilter(
|
||||
|
||||
// Shared with EventNotificationConsumer so push notifications and the
|
||||
// in-app feed apply the same per-kind "is this event for me" rule.
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
fun tagsAnEventByUser(
|
||||
note: Note,
|
||||
authorHex: HexKey,
|
||||
|
||||
-1
@@ -660,7 +660,6 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -196,6 +196,5 @@ class RelayFeedViewModel :
|
||||
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -43,10 +43,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -90,6 +92,9 @@ class EventSync(
|
||||
|
||||
/** Maximum number of completed-relay entries kept in the activity log. */
|
||||
const val MAX_ACTIVITY_LOG = 5000
|
||||
|
||||
/** Poll interval while waiting for the last forwarded events to be acknowledged. */
|
||||
const val OUTBOX_DRAIN_POLL_MS = 100L
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -392,6 +397,13 @@ class EventSync(
|
||||
|
||||
val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>()
|
||||
|
||||
// (event id, destination) pairs already counted as sent. The outbox is
|
||||
// at-least-once: it writes an event as soon as the socket is ready and
|
||||
// resends everything still unacknowledged when the connection finishes
|
||||
// syncing, so one event can hit the same relay twice before its OK lands.
|
||||
// The relay dedups the second copy; the counters must too.
|
||||
val sentPairs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val runningState =
|
||||
SyncState.Running(
|
||||
relaysCompleted = 0,
|
||||
@@ -423,7 +435,12 @@ class EventSync(
|
||||
success: Boolean,
|
||||
) {
|
||||
super.onSent(relay, cmdStr, cmd, success)
|
||||
if (cmd is EventCmd) {
|
||||
// `success` is "written to the socket", not "OK received". A write to a
|
||||
// destination that is still connecting fails and the outbox resends it
|
||||
// once the socket opens; counting the failed attempt too made every
|
||||
// cold destination report one extra event sent. Likewise a successful
|
||||
// resend of an unacknowledged event is the same send, not a second one.
|
||||
if (cmd is EventCmd && success && sentPairs.add(cmd.event.id + relay.url.url)) {
|
||||
var hasSent = false
|
||||
|
||||
if (outboxDedup.contains(cmd.event.id)) {
|
||||
@@ -586,6 +603,13 @@ class EventSync(
|
||||
},
|
||||
)
|
||||
|
||||
// `publish` is fire-and-forget through the client's outbox, and `use` closes
|
||||
// the client as soon as this block returns. Without a drain, the events
|
||||
// forwarded from the last page of the last relay are still waiting for a
|
||||
// socket or an OK when the outbox is destroyed — the sync reports Done and
|
||||
// silently never delivers them. Bounded by the same per-relay timeout.
|
||||
awaitOutboxDrain(client, outboxDedup + inboxDedup + dmDedup)
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Done(
|
||||
totalEventsReceived = runningState.eventsReceived.value,
|
||||
@@ -613,4 +637,22 @@ class EventSync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until no forwarded event in [ids] has a relay left in the client's outbox, or
|
||||
* until [RELAY_TIMEOUT_MS] passes. Ids that drain are dropped from the working set so
|
||||
* each poll only revisits what is still pending.
|
||||
*/
|
||||
private suspend fun awaitOutboxDrain(
|
||||
client: INostrClient,
|
||||
ids: Set<HexKey>,
|
||||
) {
|
||||
val pending = ids.toMutableSet()
|
||||
withTimeoutOrNull(RELAY_TIMEOUT_MS) {
|
||||
while (pending.isNotEmpty()) {
|
||||
pending.removeAll { client.pendingPublishRelaysFor(it).isNullOrEmpty() }
|
||||
if (pending.isNotEmpty()) delay(OUTBOX_DRAIN_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -116,6 +116,5 @@ open class StringFeedViewModel(
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
currentJob?.cancel()
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -459,7 +459,6 @@ class ReloadMintViewModel : ViewModel() {
|
||||
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would
|
||||
// outlive the screen — cancel it when the screen goes away.
|
||||
job?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -267,7 +267,6 @@ class TopUpMintViewModel : ViewModel() {
|
||||
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would
|
||||
// outlive the screen — cancel it when the screen goes away.
|
||||
job?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
discovery?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2608,4 +2608,5 @@
|
||||
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
|
||||
rationale screen. Needs to be an Android resource (not a commons Compose resource)
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<string name="health_connect_rationale_activity_label">Health Connect a Amethyst</string>
|
||||
</resources>
|
||||
|
||||
@@ -2390,4 +2390,5 @@
|
||||
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
|
||||
rationale screen. Needs to be an Android resource (not a commons Compose resource)
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<string name="health_connect_rationale_activity_label">Health Connect und Amethyst</string>
|
||||
</resources>
|
||||
|
||||
@@ -2384,4 +2384,9 @@
|
||||
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
|
||||
rationale screen. Needs to be an Android resource (not a commons Compose resource)
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<string name="health_connect_rationale_activity_label">Health Connect e Amethyst</string>
|
||||
<plurals name="library_directory_items">
|
||||
<item quantity="one">%1$d item</item>
|
||||
<item quantity="other">%1$d itens</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -2387,4 +2387,6 @@
|
||||
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
|
||||
rationale screen. Needs to be an Android resource (not a commons Compose resource)
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<string name="route_media">Media</string>
|
||||
<string name="health_connect_rationale_activity_label">Health Connect och Amethyst</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="transparent">#00FFFFFF</color>
|
||||
|
||||
<!-- Launch splash / window background. Tracks MaterialTheme's background so the
|
||||
first composed frame does not step to a different colour. See values-night. -->
|
||||
|
||||
+4
-1
@@ -47,6 +47,9 @@ import java.io.File
|
||||
*/
|
||||
class LocalCacheSearchParityTest {
|
||||
companion object {
|
||||
/** Hoisted out of [loadCorpus]: building a Json format is expensive enough that the compiler warns on it. */
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private lateinit var corpus: List<Event>
|
||||
|
||||
@BeforeClass
|
||||
@@ -57,7 +60,7 @@ class LocalCacheSearchParityTest {
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py")
|
||||
|
||||
val root = Json { ignoreUnknownKeys = true }.parseToJsonElement(file.readText()).jsonObject
|
||||
val root = json.parseToJsonElement(file.readText()).jsonObject
|
||||
corpus =
|
||||
root["cases"]!!
|
||||
.jsonArray
|
||||
|
||||
+1
@@ -153,6 +153,7 @@ class RelayAuthPromptBusTest {
|
||||
* answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that
|
||||
* long despite the user having answered. Marking it shown is what makes the answer land now.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() =
|
||||
runTest {
|
||||
|
||||
+3
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -185,6 +186,7 @@ class RelayAuthSessionGrantsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() =
|
||||
runTest {
|
||||
@@ -205,6 +207,7 @@ class RelayAuthSessionGrantsTest {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun neverAllowStopsAuthenticatingBeforeItsWriteLands() =
|
||||
runTest {
|
||||
|
||||
+2
@@ -70,6 +70,7 @@ class Nip34NotificationCoverageTest {
|
||||
* A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles
|
||||
* modern comments through its own separate wiring.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private val nip34ParticipantKinds =
|
||||
setOf(
|
||||
GitPatchEvent.KIND,
|
||||
@@ -131,6 +132,7 @@ class Nip34NotificationCoverageTest {
|
||||
* uppercase `E`. Asserting the wrong half passes the kind list while matching
|
||||
* nothing on the wire.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `status kinds are pulled by the lowercase-e engagement subscription`() {
|
||||
val eAnchoredActivityKinds =
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.relays.eventsync
|
||||
|
||||
import com.vitorpamplona.geode.InProcessRelays
|
||||
import com.vitorpamplona.geode.RelayEngine
|
||||
import com.vitorpamplona.geode.testing.RelayClientTest
|
||||
import com.vitorpamplona.geode.testing.preload
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Drives [EventSync] end to end against geode's in-process relays: one
|
||||
* "source" relay that already holds the account's history and three empty
|
||||
* destination relays (outbox / inbox / DM). No network, no public relay —
|
||||
* every relay is a [RelayEngine] inside this JVM, so the assertions are on
|
||||
* what actually landed in each destination store, not on "it didn't crash".
|
||||
*
|
||||
* The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy])
|
||||
* to cover the [RelayAuthenticator] wiring the sync screen relies on when a
|
||||
* user's relay demands AUTH before serving REQs.
|
||||
*/
|
||||
class EventSyncTest : RelayClientTest() {
|
||||
private val account = NostrSignerSync(KeyPair())
|
||||
private val other = NostrSignerSync(KeyPair())
|
||||
|
||||
private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/")
|
||||
private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/")
|
||||
private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/")
|
||||
private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/")
|
||||
|
||||
/** Separate hub so only the source relay demands AUTH; destinations stay open. */
|
||||
private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) })
|
||||
|
||||
@After
|
||||
fun tearDownAuthHub() {
|
||||
authHub.close()
|
||||
}
|
||||
|
||||
private fun note(
|
||||
author: NostrSignerSync,
|
||||
content: String,
|
||||
tagged: HexKey? = null,
|
||||
): Event = author.sign(eventTemplate<Event>(1, content) { tagged?.let { pTag(it) } })
|
||||
|
||||
private fun legacyDm(
|
||||
author: NostrSignerSync,
|
||||
recipient: HexKey,
|
||||
): Event = author.sign(eventTemplate<Event>(4, "ciphertext") { pTag(recipient) })
|
||||
|
||||
private val mine = List(3) { note(account, "mine $it") }
|
||||
private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) }
|
||||
private val dmToMe = legacyDm(other, account.pubKey)
|
||||
private val noise = note(other, "unrelated")
|
||||
|
||||
private fun corpus(): List<Event> = mine + mentions + dmToMe + noise
|
||||
|
||||
/** [decorate] runs on every client the sync builds, e.g. to attach an authenticator. */
|
||||
private fun eventSync(
|
||||
builder: WebsocketBuilder,
|
||||
decorate: (NostrClient) -> Unit = {},
|
||||
): EventSync =
|
||||
EventSync(
|
||||
accountPubKey = account.pubKey,
|
||||
relayDb = { listOf(source) },
|
||||
outboxTargets = { setOf(outbox) },
|
||||
inboxTargets = { setOf(inbox) },
|
||||
dmTargets = { setOf(dm) },
|
||||
clientBuilder = { NostrClient(builder, scope).also(decorate) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/**
|
||||
* Publishes are fire-and-forget on the client side, so the destination
|
||||
* store can lag `runSync` returning by a few ticks. Poll instead of
|
||||
* asserting a snapshot.
|
||||
*/
|
||||
private suspend fun RelayEngine.awaitCount(
|
||||
filter: Filter,
|
||||
expected: Int,
|
||||
): Int =
|
||||
withTimeoutOrNull(10_000) {
|
||||
while (store.count(filter) < expected) delay(25)
|
||||
store.count(filter)
|
||||
} ?: store.count(filter)
|
||||
|
||||
private suspend fun assertRouted(hubOfTargets: InProcessRelays) {
|
||||
val outboxRelay = hubOfTargets.getOrCreate(outbox)
|
||||
val inboxRelay = hubOfTargets.getOrCreate(inbox)
|
||||
val dmRelay = hubOfTargets.getOrCreate(dm)
|
||||
|
||||
assertEquals(
|
||||
"every event authored by the account lands on the outbox relay",
|
||||
mine.size,
|
||||
outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size),
|
||||
)
|
||||
assertEquals(
|
||||
"non-DM mentions land on the inbox relay",
|
||||
mentions.size,
|
||||
inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size),
|
||||
)
|
||||
assertEquals(
|
||||
"the kind-4 DM lands on the DM relay",
|
||||
1,
|
||||
dmRelay.awaitCount(Filter(kinds = listOf(4)), 1),
|
||||
)
|
||||
|
||||
// Routing is exclusive per rule: nothing leaks across destinations and the
|
||||
// unrelated note never leaves the source.
|
||||
assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter()))
|
||||
assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter()))
|
||||
assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter()))
|
||||
assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() =
|
||||
runBlocking {
|
||||
hub.getOrCreate(source).preload(corpus())
|
||||
|
||||
val sync = eventSync(hub)
|
||||
withTimeout(30_000) { sync.runSync() }
|
||||
|
||||
val done = sync.syncState.value
|
||||
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
|
||||
done as EventSync.SyncState.Done
|
||||
assertEquals(
|
||||
"mine + mentions + dm match a routing rule; noise does not",
|
||||
mine.size + mentions.size + 1,
|
||||
done.totalEventsReceived,
|
||||
)
|
||||
// runSync drains the outbox before closing its client, so by the time
|
||||
// Done is published every forwarded event has been written to its
|
||||
// destination socket — not merely queued.
|
||||
assertEquals(
|
||||
"every routed event was sent before the client closed",
|
||||
mine.size + mentions.size + 1,
|
||||
done.totalEventsSent,
|
||||
)
|
||||
|
||||
assertRouted(hub)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncReadsFromAuthRequiredSourceOnceAuthenticated() =
|
||||
runBlocking {
|
||||
authHub.getOrCreate(source).preload(corpus())
|
||||
|
||||
// Source demands NIP-42 before serving REQs; destinations are the open hub.
|
||||
val router =
|
||||
object : WebsocketBuilder {
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
out: WebSocketListener,
|
||||
): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out)
|
||||
}
|
||||
|
||||
val authSigner = NostrSignerSync(KeyPair())
|
||||
var authenticator: RelayAuthenticator? = null
|
||||
val sync =
|
||||
eventSync(router) { client ->
|
||||
authenticator =
|
||||
RelayAuthenticator(client = client, scope = scope) { _, template, _ ->
|
||||
listOf(authSigner.sign(template))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
withTimeout(30_000) { sync.runSync() }
|
||||
} finally {
|
||||
authenticator?.destroy()
|
||||
}
|
||||
|
||||
val done = sync.syncState.value
|
||||
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
|
||||
assertEquals(
|
||||
"the auth-gated source still yields every routed event",
|
||||
mine.size + mentions.size + 1,
|
||||
(done as EventSync.SyncState.Done).totalEventsReceived,
|
||||
)
|
||||
|
||||
assertRouted(hub)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class HexBenchmark {
|
||||
/** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */
|
||||
@Test
|
||||
fun hexIsHex64ThenDecode() {
|
||||
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null }
|
||||
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+9
-9
@@ -9,18 +9,18 @@ import java.util.Properties
|
||||
// compiles this buildscript {} section in an earlier stage that can't see the
|
||||
// file's imports (hence the qualified Properties) or share code with the body,
|
||||
// but it can publish values — the gate is computed once here and read below
|
||||
// via `by extra`.
|
||||
// via the project's extra properties.
|
||||
buildscript {
|
||||
val localProperties = File(rootDir, "local.properties")
|
||||
val sonarProperties by extra(
|
||||
val sonarProperties =
|
||||
java.util.Properties().apply {
|
||||
if (localProperties.exists()) localProperties.inputStream().use { load(it) }
|
||||
},
|
||||
)
|
||||
val sonarEnabled by extra(
|
||||
}
|
||||
extra.set("sonarProperties", sonarProperties)
|
||||
val sonarEnabled =
|
||||
sonarProperties.getProperty("sonar.host.url") != null &&
|
||||
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") },
|
||||
)
|
||||
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }
|
||||
extra.set("sonarEnabled", sonarEnabled)
|
||||
if (sonarEnabled) {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
@@ -110,9 +110,9 @@ subprojects {
|
||||
// `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the
|
||||
// command line. sonar.projectKey/projectName default to the root project name
|
||||
// ("Amethyst") and only need overriding in local.properties if desired.
|
||||
val sonarEnabled: Boolean by extra
|
||||
val sonarEnabled = extra["sonarEnabled"] as Boolean
|
||||
if (sonarEnabled) {
|
||||
val sonarProperties: Properties by extra
|
||||
val sonarProperties = extra["sonarProperties"] as Properties
|
||||
apply(plugin = "org.sonarqube")
|
||||
|
||||
sonarProperties
|
||||
|
||||
+1
-1
@@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage:
|
||||
| Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. |
|
||||
| JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. |
|
||||
| File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. |
|
||||
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
|
||||
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
|
||||
|
||||
The JVM suite drives `runCli` **in-process** through the shared
|
||||
`amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr,
|
||||
|
||||
+4
-4
@@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic.
|
||||
9. **Test suite** — largely in place, two layers:
|
||||
- **Shell harnesses** under `cli/tests/` — ten suites: `blossom`
|
||||
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
|
||||
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`,
|
||||
`marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`,
|
||||
`relaygroup`, `sync`, plus the shared `headless/` helpers. See
|
||||
`cli/tests/README.md`.
|
||||
None run in CI yet (the relay-backed ones need Rust + a ~3 min
|
||||
cold `nostr-rs-relay` build).
|
||||
`cli/tests/README.md`. Every relay-backed suite runs against the
|
||||
embedded `amy serve` relay (geode) — no external relay binary.
|
||||
None run in CI yet (the Marmot ones need Rust for MDK's `wn`).
|
||||
- **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing,
|
||||
exit-code contract, and `--json` shape tests driving `runCli`
|
||||
in-process via the `amy.home` isolation seam.
|
||||
|
||||
@@ -110,7 +110,7 @@ application {
|
||||
// JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy
|
||||
// then signs a kind:7 whose `content` is four replacement characters.
|
||||
// Whitenoise rejects it with "Invalid reaction content".
|
||||
val patchAmyLauncherCharset by tasks.registering {
|
||||
val patchAmyLauncherCharset = tasks.register("patchAmyLauncherCharset") {
|
||||
val appName = application.applicationName
|
||||
val startScriptsTask = tasks.named("startScripts")
|
||||
dependsOn(startScriptsTask)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
marmot/state/
|
||||
marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
cache/state-cache-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
|
||||
+46
-39
@@ -1,21 +1,24 @@
|
||||
# amy CLI test harnesses
|
||||
|
||||
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
|
||||
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
|
||||
or no relay at all, depending on the suite. Eleven directories:
|
||||
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an
|
||||
embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live
|
||||
public servers, or no relay at all, depending on the suite. No suite depends on
|
||||
an external relay binary or a Rust toolchain for its relay: every relay-backed
|
||||
harness boots geode from the `amy` binary it already built, so the relay under
|
||||
test is the same server code that runs in production. Eleven directories:
|
||||
|
||||
```
|
||||
cli/tests/
|
||||
├── lib.sh # shared logging, results, assertions
|
||||
├── headless/ # shared bits used by every harness
|
||||
│ └── helpers.sh
|
||||
│ └── helpers.sh # amy wrappers, assertions, embedded relay boot
|
||||
├── blossom/ # Blossom blob lifecycle vs LIVE public servers
|
||||
│ └── blossom-live.sh
|
||||
├── cache/ # local-store-as-cache semantics (profile show
|
||||
│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay
|
||||
│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve`
|
||||
├── clink/ # CLINK pointer decode — local-only, no relay
|
||||
│ └── clink-headless.sh
|
||||
├── dm/ # NIP-17 DM interop (amy ↔ amy)
|
||||
├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
|
||||
│ ├── dm-interop-headless.sh
|
||||
│ ├── setup.sh # preflight + identities
|
||||
│ └── tests-dm.sh
|
||||
@@ -24,7 +27,7 @@ cli/tests/
|
||||
├── marmot/ # Marmot / MLS group-messaging interop
|
||||
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
|
||||
│ ├── marmot-interop-headless.sh # zero-prompt
|
||||
│ ├── setup.sh # preflight + wn + relay + identities
|
||||
│ ├── setup.sh # preflight + wn + identities
|
||||
│ ├── tests-create.sh # tests 01–05
|
||||
│ ├── tests-manage.sh # tests 06–08, 11
|
||||
│ ├── tests-extras.sh # tests 09, 10, 12, 13
|
||||
@@ -60,7 +63,7 @@ Suite notes:
|
||||
and mined-nonce round-trips through `pow check`.
|
||||
- **`cache/cache-headless.sh`** proves the local store is the source of
|
||||
truth for reads: `profile show` served from cache vs `--refresh`, and
|
||||
`store stat` reporting the right histogram, vs a loopback nostr-rs-relay.
|
||||
`store stat` reporting the right histogram, vs the embedded `amy serve` relay.
|
||||
- **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/
|
||||
list/browse against an embedded relay (`amy serve`, which boots geode) —
|
||||
no external relay binary. geode doesn't sign 39000-39003, so browse/info
|
||||
@@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios:
|
||||
A third, slimmer harness covers the NIP-17 DM surface:
|
||||
|
||||
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
|
||||
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
|
||||
No MDK required — only `amy` and the relay binary (which
|
||||
is shared with the Marmot harness's checkout at
|
||||
`marmot/state-headless/nostr-rs-relay/`).
|
||||
Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay.
|
||||
No MDK, no Rust — only `amy`.
|
||||
|
||||
A harness covers Blossom blob storage (BUD-01/02/04/09) against **live**
|
||||
public servers rather than a loopback relay:
|
||||
@@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
|
||||
|
||||
On the machine that runs the harness:
|
||||
|
||||
- **Rust 1.90+** — install via https://rustup.rs
|
||||
- **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only;
|
||||
the relay is `amy serve`, no Rust needed for it)
|
||||
- **git**, **curl**, **jq** — package manager
|
||||
- **~5 GB disk** for the first-run build of `wn` + `wnd`
|
||||
- Public internet access (for the default relay set and fetching crates)
|
||||
- Internet access for fetching crates on the first build. Test traffic
|
||||
stays on the machine unless you pass `--public-relays`.
|
||||
|
||||
On the Android side:
|
||||
|
||||
- Amethyst installed on an **emulator** or a **physical device**
|
||||
- The device must reach the same relays the harness uses (see below)
|
||||
- The device must reach the harness's embedded relay over the network
|
||||
(see below), or the public relays when running with `--public-relays`
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -240,9 +244,11 @@ The script will, in order:
|
||||
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
|
||||
5. Ask you to paste **your Amethyst account npub** (Identity A). This is
|
||||
cached for subsequent runs.
|
||||
6. Add the default public relays to both daemons and run a sanity check
|
||||
(publish a KP from B, fetch it from C).
|
||||
7. Print an **Amethyst setup checklist** — add the same relays to Amethyst,
|
||||
6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`),
|
||||
add it to both daemons and run a sanity check (publish a KP from B,
|
||||
fetch it from C). With `--public-relays` the default public set is used
|
||||
instead and the relay is not started.
|
||||
7. Print an **Amethyst setup checklist** — add the same relay to Amethyst,
|
||||
publish a KP, verify you are logged in with A.
|
||||
8. Run all 13 tests sequentially. Each test either:
|
||||
- runs `wn` commands fully automatically and asserts on JSON output, **or**
|
||||
@@ -253,9 +259,10 @@ The script will, in order:
|
||||
## Command-line flags
|
||||
|
||||
```
|
||||
--local-relays Use ws://localhost:8080 instead of the default public relays.
|
||||
Required if the public relays reject kinds 444/445/30443.
|
||||
Run 'just docker-up' inside the mdk checkout first.
|
||||
--public-relays Use the public relay set below instead of the embedded relay.
|
||||
The only mode whose test traffic leaves the machine; the
|
||||
public relays may reject kinds 444/445/30443.
|
||||
--port N Port for the embedded relay (default 8080).
|
||||
--transponder Run Test 14 (push notifications via the transponder service).
|
||||
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
|
||||
-h, --help Show help.
|
||||
@@ -267,31 +274,31 @@ Environment overrides:
|
||||
WN_REPO=/some/path/mdk # use an existing checkout
|
||||
```
|
||||
|
||||
## Default relays
|
||||
## Relays
|
||||
|
||||
By default the harness owns the only relay: `amy serve` (geode) bound to
|
||||
`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it
|
||||
over the network:
|
||||
|
||||
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays,
|
||||
Settings → Key Package Relays and Settings → DM Inbox Relays.
|
||||
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
|
||||
|
||||
With `--public-relays` the daemons are bootstrapped on
|
||||
|
||||
```
|
||||
wss://relay.damus.io
|
||||
wss://nos.lol
|
||||
wss://relay.primal.net
|
||||
wss://nostr.bitcoiner.social
|
||||
wss://nostr.mom
|
||||
```
|
||||
|
||||
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable
|
||||
events). If the **sanity check fails** — meaning C cannot read the KeyPackage
|
||||
that B just published — the harness warns you and continues. In that case
|
||||
re-run with `--local-relays` after starting the Docker stack:
|
||||
|
||||
```bash
|
||||
cd state/mdk
|
||||
just docker-up
|
||||
cd ../..
|
||||
./marmot-interop.sh --local-relays
|
||||
```
|
||||
|
||||
For Amethyst with `--local-relays`:
|
||||
|
||||
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and
|
||||
Settings → Key Package Relays.
|
||||
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
|
||||
instead and Amethyst is left on its own relay set, so the run surfaces
|
||||
real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the
|
||||
public relays drop). If the **sanity check fails** in that mode — meaning C
|
||||
cannot read the KeyPackage that B just published — the harness warns you and
|
||||
continues; re-run without `--public-relays` to rule the relays out.
|
||||
|
||||
## How human interaction works
|
||||
|
||||
|
||||
Vendored
+11
-11
@@ -3,8 +3,8 @@
|
||||
# cache-headless.sh — verifies the file-backed event store is the
|
||||
# source of truth for `amy` reads.
|
||||
#
|
||||
# Two amy identities (A and B) talk to a local nostr-rs-relay. We
|
||||
# assert that:
|
||||
# Two amy identities (A and B) talk to a local embedded relay
|
||||
# (`amy serve`, i.e. geode). We assert that:
|
||||
#
|
||||
# 1. After A runs `amy create`, A's local store contains the bootstrap
|
||||
# events (kind:0 / 3 / 10002 / 10050 / 10051 …).
|
||||
@@ -39,10 +39,13 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Reuse the relay binary the marmot harness builds.
|
||||
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
|
||||
# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity
|
||||
# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all
|
||||
# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like
|
||||
# 127.0.0.1. Nothing here depends on that parse — amy publishes to and
|
||||
# reads from the relay it was told about.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8092}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -72,14 +75,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay
|
||||
source "$TESTS_DIR/marmot/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
|
||||
# Keep the dm setup's preflight (just checks for amy + the relay) but
|
||||
# define our own identity bootstrap so we don't pull in DM-specific
|
||||
# wiring.
|
||||
# Keep the dm setup's preflight (just checks for amy) but define our own
|
||||
# identity bootstrap so we don't pull in DM-specific wiring.
|
||||
# shellcheck source=../dm/setup.sh
|
||||
source "$TESTS_DIR/dm/setup.sh"
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness.
|
||||
#
|
||||
# Two `amy` processes (Identity A and Identity D) talk to each other
|
||||
# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No
|
||||
# whitenoise-rs, no Marmot, no public internet traffic.
|
||||
# through a local embedded relay (`amy serve`, i.e. geode) on
|
||||
# ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no
|
||||
# public internet traffic.
|
||||
#
|
||||
# Usage: ./dm-interop-headless.sh [--port N] [--no-build]
|
||||
#
|
||||
@@ -26,16 +27,16 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Share the nostr-rs-relay checkout with the Marmot harness to avoid
|
||||
# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full
|
||||
# isolation between runs.
|
||||
# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's
|
||||
# `isLocalHost()` filter doesn't silently strip it out of the kind:10050
|
||||
# inbox events during recipient-relay resolution. 127.0.0.2 is still pure
|
||||
# loopback — no network traffic, no config needed.
|
||||
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
|
||||
# start_local_relay in headless/helpers.sh. Override RELAY_DATA if you
|
||||
# want full isolation between runs.
|
||||
# 127.0.0.2 used to dodge Quartz's `isLocalHost()` strip of loopback
|
||||
# relays in kind:10050 inbox lists. That filter now covers all of
|
||||
# 127.0.0.0/8, so the strict-inbox sends (dm-01/02/05/06) fail with
|
||||
# no_dm_relays regardless of which loopback address the relay binds;
|
||||
# only the fallback-chain tests (dm-03/04) are unaffected. Kept for
|
||||
# parity with the other harnesses until that routing rule is revisited.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8090}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -68,12 +69,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
|
||||
# Reuse start_local_relay / stop_local_relay from the Marmot harness's
|
||||
# setup.sh — the relay lifecycle is identical. preflight() there also
|
||||
# builds whitenoise-rs, which we don't need; setup.sh in this dir
|
||||
# defines a slimmer preflight_dm().
|
||||
# shellcheck source=../marmot/setup.sh
|
||||
source "$TESTS_DIR/marmot/setup.sh"
|
||||
# setup.sh in this dir defines the slim preflight_dm() (amy only, no
|
||||
# MDK); the relay lifecycle (start_local_relay / stop_local_relay, the
|
||||
# embedded `amy serve`) comes from the shared headless helpers.
|
||||
# shellcheck source=setup.sh
|
||||
source "$SCRIPT_DIR/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
|
||||
+7
-29
@@ -3,20 +3,21 @@
|
||||
# setup.sh — amy-only preflight + identity bootstrap for the
|
||||
# NIP-17 DM interop harness. Much slimmer than the Marmot setup:
|
||||
#
|
||||
# - Builds `amy` (same retry-on-503 logic as setup.sh).
|
||||
# - Builds nostr-rs-relay if missing.
|
||||
# - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback
|
||||
# relay is `amy serve` (geode), so amy is the only binary needed.
|
||||
# - Bootstraps two fresh amy identities (A and D), each with its own
|
||||
# `--data-dir`, both pointed at the loopback relay.
|
||||
# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict
|
||||
# recipient-inbox routing has something to resolve to.
|
||||
#
|
||||
# The heavy `start_local_relay` / `stop_local_relay` helpers live in
|
||||
# the Marmot harness's setup.sh and are sourced by the top-level harness.
|
||||
# The `start_local_relay` / `stop_local_relay` helpers (embedded
|
||||
# `amy serve`) live in ../headless/helpers.sh and are sourced by the
|
||||
# top-level harness.
|
||||
|
||||
# --- preflight (amy + relay only, no wn / Marmot patches) -------------------
|
||||
# --- preflight (amy only, no wn / Marmot patches, no Rust) -------------------
|
||||
preflight_dm() {
|
||||
banner "Preflight (DM harness)"
|
||||
for cmd in jq git cargo; do
|
||||
for cmd in jq git curl; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
fail_msg "missing required tool: $cmd"
|
||||
exit 1
|
||||
@@ -43,29 +44,6 @@ preflight_dm() {
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
|
||||
info "amy: $AMY_BIN"
|
||||
|
||||
# nostr-rs-relay (same build path as the Marmot harness).
|
||||
if [[ ! -x "$RELAY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
if [[ ! -d "$RELAY_REPO/.git" ]]; then
|
||||
step "cloning nostr-rs-relay into $RELAY_REPO"
|
||||
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
local attempt max=4
|
||||
for attempt in $(seq 1 $max); do
|
||||
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
|
||||
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
[[ -x "$RELAY_BIN" ]] && break
|
||||
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying"
|
||||
done
|
||||
[[ -x "$RELAY_BIN" ]] || {
|
||||
fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "relay bin: $RELAY_BIN"
|
||||
}
|
||||
|
||||
# --- amy identity wrappers ---------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# tests-dm.sh — NIP-17 DM interop tests for two `amy` clients.
|
||||
#
|
||||
# Identity A (sender) and Identity D (recipient) each live in their own
|
||||
# --data-dir and share one loopback nostr-rs-relay. Tests cover:
|
||||
# --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover:
|
||||
#
|
||||
# dm-01 text round-trip (both directions)
|
||||
# dm-02 dm list surfaces prior exchange with type:text discriminator
|
||||
|
||||
@@ -66,5 +66,94 @@ assert_eq() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- embedded relay (amy serve → geode) --------------------------------------
|
||||
# Every relay-backed harness talks to ONE loopback relay, and that relay is
|
||||
# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy
|
||||
# binary the harness already built. No Rust toolchain, no clone, no cargo
|
||||
# build, no external relay binary: the relay under test is part of the
|
||||
# product, so a harness run exercises the same server code `amy serve`
|
||||
# and the standalone geode distribution run in production.
|
||||
#
|
||||
# Callers set (before sourcing or at least before calling):
|
||||
# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`)
|
||||
# RELAY_HOST host clients connect to. The harnesses use 127.0.0.2 for
|
||||
# parity with each other; note that Quartz's isLocalHost()
|
||||
# treats all of 127.0.0.0/8 as loopback, so it does NOT
|
||||
# survive the NIP-17 / NIP-65 relay-list parsers any better
|
||||
# than 127.0.0.1 does.
|
||||
# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to
|
||||
# 0.0.0.0 when a device on the LAN must reach the relay.
|
||||
# RELAY_PORT listen port
|
||||
# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT
|
||||
# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs
|
||||
#
|
||||
# The relay process runs as its own amy account ("relay") inside its own
|
||||
# $HOME under $RELAY_DATA, so its identity and store never mix with the
|
||||
# test identities. The store is in-memory (amy serve's default): every run
|
||||
# starts from an empty relay, matching the state wipe the harnesses do.
|
||||
start_local_relay() {
|
||||
banner "Starting embedded relay (amy serve / geode) on $RELAY_URL"
|
||||
local relay_home="$RELAY_DATA/home"
|
||||
local bind="${RELAY_BIND:-$RELAY_HOST}"
|
||||
mkdir -p "$relay_home" "$RELAY_DATA/logs"
|
||||
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; }
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
# with a clear error beats a mysterious-looking daemon stall later.
|
||||
# bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on
|
||||
# loopback returns immediately.
|
||||
if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then
|
||||
fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `amy serve` resolves its admin pubkey from the account, so the relay
|
||||
# needs an identity of its own. Idempotent across --reuse-state runs.
|
||||
if [[ ! -d "$relay_home/.amy/relay" ]]; then
|
||||
HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \
|
||||
>"$RELAY_DATA/logs/init.log" 2>&1 \
|
||||
|| { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; }
|
||||
fi
|
||||
|
||||
nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \
|
||||
serve --host "$bind" --port "$RELAY_PORT" \
|
||||
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
|
||||
echo "$!" > "$RELAY_DATA/pid"
|
||||
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
|
||||
|
||||
# Readiness = the NIP-11 document answers on the same port. geode serves
|
||||
# it on a plain GET with `Accept: application/nostr+json` (anything else
|
||||
# gets a 426 hint, which curl -f would treat as failure).
|
||||
local deadline=$(( $(date +%s) + 60 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sSf -m 1 -H 'Accept: application/nostr+json' \
|
||||
"http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then
|
||||
info "relay up"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
|
||||
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_local_relay() {
|
||||
local pid_file="$RELAY_DATA/pid"
|
||||
[[ -f "$pid_file" ]] || return 0
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping relay pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
|
||||
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
|
||||
# Both exist in lib.sh already; this file only adds headless-specific niceties.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
|
||||
#
|
||||
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
|
||||
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local
|
||||
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
|
||||
# machine. Matches the 13 test scenarios in marmot-interop.sh but without
|
||||
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay
|
||||
# (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever
|
||||
# leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without
|
||||
# any human prompts — all checks run to completion and the exit code
|
||||
# reflects pass/fail totals.
|
||||
#
|
||||
@@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn"
|
||||
WND_BIN="$WN_REPO/target/release/wnd"
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Local relay wiring — cloned + built during preflight, started on
|
||||
# $RELAY_PORT. The harness never touches the public internet for test
|
||||
# traffic; wn/wnd/amy all point at this one loopback endpoint.
|
||||
# Local relay wiring — the embedded `amy serve` (geode), started on
|
||||
# $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness
|
||||
# never touches the public internet for test traffic; wn/wnd/amy all
|
||||
# point at this one loopback endpoint.
|
||||
#
|
||||
# Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer
|
||||
# strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox
|
||||
@@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
# Amethyst's public defaults instead of the loopback. 127.0.0.2 is
|
||||
# still pure loopback (no network traffic) but isn't on the strip list.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -73,7 +72,7 @@ BLOSSOM_PID=""
|
||||
|
||||
NO_BUILD=0
|
||||
# Every run starts from empty stores. wnd already wipes B's and C's data dirs
|
||||
# on each start, but A's amy home and the relay's SQLite file used to survive,
|
||||
# on each start, but A's amy home and the relay's state used to survive,
|
||||
# and the leftovers are not inert: a KeyPackage A published in an earlier run
|
||||
# is still on the relay for B to invite with, an old group's kind:445 events
|
||||
# still arrive and fail to decrypt, and A's cursors still say it has seen them.
|
||||
@@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then
|
||||
# Keep the relay checkout + its build (minutes to rebuild) and the log and
|
||||
# results history; drop everything that holds protocol state.
|
||||
# Keep the log and results history; drop everything that holds protocol
|
||||
# state (the relay is in-memory, so wiping its dir just drops its identity).
|
||||
#
|
||||
# run.env counts as protocol state: it is where tests hand each other group
|
||||
# ids. Leaving it behind a wipe leaves ids naming groups nobody is in any
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
# Sequential, all-or-nothing. Script drives the `wn` side automatically and
|
||||
# prompts the human operator at each step that requires Amethyst UI action.
|
||||
#
|
||||
# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build]
|
||||
# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build]
|
||||
#
|
||||
# By default the harness boots its own relay — `amy serve`, i.e. geode — bound
|
||||
# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone
|
||||
# reaches it over the LAN (ws://<laptop-ip>:PORT, or ws://10.0.2.2:PORT from an
|
||||
# emulator). Pass --public-relays to run the old real-world path against the
|
||||
# public relay set instead; that is the only mode that touches the internet.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
B_DIR="$STATE_DIR/B"
|
||||
@@ -27,6 +34,24 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
|
||||
WN_BIN=""
|
||||
WND_BIN=""
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Embedded relay (default mode). Bound on every interface so a device on the
|
||||
# same network can reach it; the daemons connect over $RELAY_HOST. Loopback
|
||||
# `ws://` relays are only accepted by MDK behind this explicit opt-in.
|
||||
#
|
||||
# Known limit, inherited from the old --local-relays mode: the URL wn
|
||||
# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's
|
||||
# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome
|
||||
# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an
|
||||
# address the device can dial (e.g. the laptop's LAN IP) to have wn
|
||||
# advertise that instead; the daemons then connect to it too.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
|
||||
RELAY_BIND="${RELAY_BIND:-0.0.0.0}"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
export WN_ALLOW_LOOPBACK_RELAYS=1
|
||||
B_NPUB=""
|
||||
B_HEX=""
|
||||
C_NPUB=""
|
||||
@@ -34,6 +59,7 @@ C_HEX=""
|
||||
A_NPUB=""
|
||||
A_HEX=""
|
||||
|
||||
# Only used with --public-relays.
|
||||
DEFAULT_RELAYS=(
|
||||
"wss://relay.damus.io"
|
||||
"wss://nos.lol"
|
||||
@@ -41,7 +67,7 @@ DEFAULT_RELAYS=(
|
||||
"wss://nostr.bitcoiner.social"
|
||||
"wss://nostr.mom"
|
||||
)
|
||||
USE_LOCAL_RELAYS=0
|
||||
USE_PUBLIC_RELAYS=0
|
||||
ENABLE_TRANSPONDER=0
|
||||
NO_BUILD=0
|
||||
|
||||
@@ -50,7 +76,9 @@ usage() {
|
||||
marmot-interop.sh — Amethyst <-> MDK interop harness
|
||||
|
||||
Options:
|
||||
--local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up')
|
||||
--public-relays Use the public relay set instead of the embedded relay
|
||||
(amy serve / geode, the default). Only mode that leaves the machine.
|
||||
--port N Port for the embedded relay (default 8080)
|
||||
--transponder Run Test 14 (MIP-05 push notifications)
|
||||
--no-build Don't rebuild wn/wnd if binaries are missing
|
||||
-h, --help Show this help
|
||||
@@ -62,8 +90,12 @@ EOF
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--local-relays) USE_LOCAL_RELAYS=1 ;;
|
||||
--transponder) ENABLE_TRANSPONDER=1 ;;
|
||||
--public-relays) USE_PUBLIC_RELAYS=1 ;;
|
||||
--local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;;
|
||||
--port)
|
||||
[[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; }
|
||||
RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--transponder) ENABLE_TRANSPONDER=1 ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
@@ -77,6 +109,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
preflight() {
|
||||
@@ -88,6 +122,26 @@ preflight() {
|
||||
printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE"
|
||||
done
|
||||
|
||||
# The embedded relay is `amy serve`, so amy has to exist unless the run
|
||||
# goes to the public relays. Same transient-503 retry as the headless
|
||||
# harness: one bad jitpack/dl.google.com roll must not abort the run.
|
||||
if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
local attempt max_attempts=4
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
step "building :cli:installDist (attempt $attempt/$max_attempts)"
|
||||
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
|
||||
&& [[ -x "$AMY_BIN" ]]; then
|
||||
break
|
||||
fi
|
||||
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
|
||||
done
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
|
||||
printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
WN_BIN="$WN_REPO/target/release/wn"
|
||||
WND_BIN="$WN_REPO/target/release/wnd"
|
||||
|
||||
@@ -285,7 +339,7 @@ discover_a_relays() {
|
||||
if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
|
||||
info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working"
|
||||
else
|
||||
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}."
|
||||
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}."
|
||||
warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach."
|
||||
warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)."
|
||||
fi
|
||||
@@ -300,7 +354,7 @@ discover_a_relays() {
|
||||
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
warn "sqlite3 not installed — skipping wn user_relays cache probe."
|
||||
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays."
|
||||
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays."
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -383,12 +437,7 @@ discover_a_relays() {
|
||||
# --- relays ------------------------------------------------------------------
|
||||
configure_relays() {
|
||||
banner "Configuring relays"
|
||||
local relays=()
|
||||
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
|
||||
relays=( "ws://localhost:8080" )
|
||||
else
|
||||
relays=( "${DEFAULT_RELAYS[@]}" )
|
||||
fi
|
||||
local relays=( "${RELAY_LIST[@]}" )
|
||||
# Each relay × 3 types × 2 daemons produces a lot of repetitive "ok"
|
||||
# lines — the happy path doesn't need any of it on screen. Quiet the
|
||||
# per-add logging into $LOG_FILE and only surface real failures as
|
||||
@@ -527,27 +576,28 @@ configure_relays() {
|
||||
info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)"
|
||||
else
|
||||
warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)"
|
||||
warn "Consider rerunning with --local-relays."
|
||||
warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
|
||||
fi
|
||||
# best-effort cleanup so re-runs don't accumulate dead sanity groups
|
||||
wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true
|
||||
wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
|
||||
else
|
||||
warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
|
||||
warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)."
|
||||
warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
instruct_amethyst_setup() {
|
||||
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
|
||||
# Offline/sandbox path: we own the only relay, so the harness DOES
|
||||
# need to dictate Amethyst's relay config — nothing is discoverable
|
||||
# via the public network.
|
||||
prompt_human "Configure Amethyst to match this --local-relays harness:
|
||||
if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then
|
||||
# Offline/sandbox path (default): we own the only relay — the embedded
|
||||
# `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need
|
||||
# to dictate Amethyst's relay config; nothing is discoverable via the
|
||||
# public network.
|
||||
prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode):
|
||||
1. Settings -> Relays: add as READ+WRITE
|
||||
ws://10.0.2.2:8080 (Android emulator)
|
||||
ws://<your-LAN-ip>:8080 (physical device on same Wi-Fi)
|
||||
ws://10.0.2.2:$RELAY_PORT (Android emulator)
|
||||
ws://<your-LAN-ip>:$RELAY_PORT (physical device on same Wi-Fi)
|
||||
2. Settings -> Key Package Relays: add the SAME URL
|
||||
3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL
|
||||
4. Trigger key-package publish (toggle KP relay on/off if needed)
|
||||
@@ -555,7 +605,7 @@ instruct_amethyst_setup() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Public-relay path: the harness should behave like any real Nostr
|
||||
# --public-relays path: the harness should behave like any real Nostr
|
||||
# client — discover A's advertised relays via kind:10002 / 10050 /
|
||||
# 10051 and publish there, rather than forcing A to adopt the
|
||||
# harness's own relay set. That lets the tests surface real-world
|
||||
@@ -1301,6 +1351,7 @@ main() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM HUP
|
||||
stop_daemons
|
||||
stop_local_relay
|
||||
print_summary
|
||||
exit "$rc"
|
||||
}
|
||||
@@ -1311,6 +1362,12 @@ main() {
|
||||
banner "Amethyst <-> MDK interop harness ($RUN_TS)"
|
||||
|
||||
preflight
|
||||
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
|
||||
RELAY_LIST=( "${DEFAULT_RELAYS[@]}" )
|
||||
else
|
||||
RELAY_LIST=( "$RELAY_URL" )
|
||||
start_local_relay
|
||||
fi
|
||||
start_daemon B "$B_DIR" "$B_SOCKET"
|
||||
start_daemon C "$C_DIR" "$C_SOCKET"
|
||||
ensure_identity B
|
||||
@@ -1327,7 +1384,7 @@ main() {
|
||||
# plane, then summarise what wn sees. Surfaces up front the kind of
|
||||
# failure (A's 10050 unreachable from wn, missing KP list, etc.) that
|
||||
# would otherwise bite as a silent Test 03 timeout.
|
||||
if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then
|
||||
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
|
||||
discover_a_relays
|
||||
fi
|
||||
|
||||
|
||||
@@ -147,29 +147,8 @@ preflight() {
|
||||
info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))"
|
||||
info "wnd: $WND_BIN"
|
||||
|
||||
# Clone/build nostr-rs-relay — the harness's single loopback relay.
|
||||
if [[ ! -x "$RELAY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
if [[ ! -d "$RELAY_REPO/.git" ]]; then
|
||||
step "cloning nostr-rs-relay into $RELAY_REPO"
|
||||
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
local attempt max=4
|
||||
for attempt in $(seq 1 $max); do
|
||||
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
|
||||
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
[[ -x "$RELAY_BIN" ]] && break
|
||||
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying"
|
||||
done
|
||||
[[ -x "$RELAY_BIN" ]] || {
|
||||
fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "relay bin: $RELAY_BIN"
|
||||
# The loopback relay is `amy serve` (geode) — see start_local_relay in
|
||||
# ../headless/helpers.sh. Nothing to clone or build beyond amy itself.
|
||||
}
|
||||
|
||||
# --- local QUIC broker -------------------------------------------------------
|
||||
@@ -262,75 +241,8 @@ stop_quic_broker() {
|
||||
}
|
||||
|
||||
# --- local relay -------------------------------------------------------------
|
||||
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
|
||||
# runs against this one loopback endpoint — no external network traffic.
|
||||
start_local_relay() {
|
||||
banner "Starting local nostr-rs-relay on $RELAY_URL"
|
||||
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
|
||||
|
||||
# Render a minimal config file each run so port/limits come from the
|
||||
# harness rather than whatever was left on disk from a previous session.
|
||||
cat >"$RELAY_DATA/config.toml" <<EOF
|
||||
[info]
|
||||
relay_url = "$RELAY_URL"
|
||||
name = "amethyst-headless-harness"
|
||||
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
|
||||
|
||||
[database]
|
||||
data_directory = "$RELAY_DATA"
|
||||
|
||||
[network]
|
||||
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
|
||||
port = $RELAY_PORT
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 3600
|
||||
|
||||
[limits]
|
||||
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
|
||||
# exercising Marmot traffic the public relays reject.
|
||||
max_event_bytes = 524288
|
||||
max_ws_message_bytes = 1048576
|
||||
max_ws_frame_bytes = 1048576
|
||||
EOF
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
# with a clear error beats a mysterious-looking daemon stall later.
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
|
||||
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
|
||||
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
|
||||
echo "$!" > "$RELAY_DATA/pid"
|
||||
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
|
||||
|
||||
local deadline=$(( $(date +%s) + 20 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then
|
||||
info "relay up"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
|
||||
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_local_relay() {
|
||||
local pid_file="$RELAY_DATA/pid"
|
||||
[[ -f "$pid_file" ]] || return 0
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping relay pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
# start_local_relay / stop_local_relay live in ../headless/helpers.sh: the
|
||||
# relay is the embedded `amy serve` (geode), shared by every harness.
|
||||
|
||||
# --- daemons -----------------------------------------------------------------
|
||||
start_daemon() {
|
||||
@@ -480,9 +392,9 @@ configure_relays() {
|
||||
|
||||
step "publishing A's KeyPackage"
|
||||
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
|
||||
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443
|
||||
# Give the relay a breath to ingest the kind:10002 / 10050 / 30443
|
||||
# writes and push them out on the discovery subscription so that the
|
||||
# first `wn keys check` that follows actually sees them instead of
|
||||
# racing the relay's WAL flush.
|
||||
# racing the relay's ingest queue.
|
||||
sleep 2
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ tasks.withType<Test>().configureEach {
|
||||
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
|
||||
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
|
||||
// JSON / HTTP deps.
|
||||
val verifyKmpPurity by tasks.registering {
|
||||
val verifyKmpPurity = tasks.register("verifyKmpPurity") {
|
||||
group = "verification"
|
||||
description = "Fails if iOS-targeted source sets import JVM-only deps."
|
||||
val checkedDirs =
|
||||
|
||||
+20
@@ -107,6 +107,26 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android backend: EncryptedSharedPreferences.contains + getString has no
|
||||
* ambiguous-error state comparable to macOS Keychain user-cancel/deny, so
|
||||
* "key not present" and "key present" are the only two null outcomes.
|
||||
* Any thrown exception is a genuine failure and propagates.
|
||||
*/
|
||||
actual suspend fun getPrivateKeyOrThrow(npub: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val key = KEY_PREFIX + npub
|
||||
if (!encryptedPrefs.contains(key)) {
|
||||
null
|
||||
} else {
|
||||
encryptedPrefs.getString(key, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw SecureStorageException("Failed to retrieve private key", e)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun deletePrivateKey(npub: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -2759,4 +2759,83 @@
|
||||
<string name="buzz_persona_publishing">Publikování…</string>
|
||||
<string name="buzz_persona_publish">Publikovat personu</string>
|
||||
<string name="profile_card_follows_you">Sleduje vás</string>
|
||||
<string name="add_hashtag_label_field">Hashtag</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<string name="app_definition_nip">NIP-%1$s</string>
|
||||
<string name="buzz_invite_dismiss">OK</string>
|
||||
<string name="buzz_invite_role">Role</string>
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
|
||||
<string name="buzz_workflow_picker_label">Workflow</string>
|
||||
<string name="cashu_mint_label">Mint: %1$s</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="dm_sender_reported_more_count">+%1$d</string>
|
||||
<string name="dvm_offline">Offline</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="gif">Gif</string>
|
||||
<string name="git_commit">Commit</string>
|
||||
<string name="git_repo_plain_text">Text</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
|
||||
<string name="goal_website_placeholder">https://example.com</string>
|
||||
<string name="hls_codec_h264">H.264</string>
|
||||
<string name="language_preference_pair">%1$s → %2$s</string>
|
||||
<string name="live_stream_offline_tag">OFFLINE</string>
|
||||
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="marmot_user_fallback_name">%1$s…</string>
|
||||
<string name="my_awesome_name">Ostrich McAwesome</string>
|
||||
<string name="nest_tab_chat">Chat</string>
|
||||
<string name="nip46_signer_act_other">%1$s</string>
|
||||
<string name="nip46_signer_act_ping">Ping</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="nutzap">Nutzap</string>
|
||||
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
|
||||
<string name="onchain_send_sats_amount">%1$s sats</string>
|
||||
<string name="onchain_send_sats_suffix">sats</string>
|
||||
<string name="platform_android">Android</string>
|
||||
<string name="platform_ios">iOS</string>
|
||||
<string name="platform_web">Web</string>
|
||||
<string name="podcast_episode_number">Ep %1$d</string>
|
||||
<string name="podcast_role_editor">Editor</string>
|
||||
<string name="podcast_season_episode">S%1$d · E%2$d</string>
|
||||
<string name="podcast_value_stream_rate">%1$d sats/min</string>
|
||||
<string name="podcast_video">Video</string>
|
||||
<string name="profile_card_bot">Bot</string>
|
||||
<string name="reactions_settings_boost">Boost</string>
|
||||
<string name="reactions_settings_zap">Zap</string>
|
||||
<string name="relay_filter_limit">limit %1$d</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d+</string>
|
||||
<string name="reload_mint_sats_amount">%1$s sats</string>
|
||||
<string name="secret_visible_text_placeholder">😎</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="send_payment_method_cashu">Cashu</string>
|
||||
<string name="send_payment_method_onchain">On-chain</string>
|
||||
<string name="share_of">%1$d/%2$d</string>
|
||||
<string name="tip">Tip</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<string name="wallet_sats">sats</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="web_bookmark_url_placeholder">https://example.com</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="health_connect_rationale_headline">Amethyst čte dokončené tréninky, aby za vás mohl předvyplnit příspěvek o tréninku.</string>
|
||||
<string name="health_connect_rationale_title">Health Connect a Amethyst</string>
|
||||
<string name="health_connect_rationale_intro">Amethyst je sociální klient sítě Nostr. Jeho sekce Tréninky vám umožňuje zveřejnit souhrn dokončeného tréninku na relaye Nostr, které si zvolíte, aby lidé, kteří vás sledují, viděli, co jste dělali. Místo ručního vypisování každého čísla může Amethyst načíst trénink, který vaše hodinky nebo fitness aplikace už uložily do Health Connect, a příspěvek předvyplnit. Předvyplněný příspěvek vždy uvidíte a sami rozhodnete, zda ho zveřejníte.</string>
|
||||
<string name="health_connect_rationale_what_title">Co Amethyst čte a proč</string>
|
||||
<string name="health_connect_rationale_exercise">Cvičení · jakou aktivitu jste dělali, kdy začala a jak dlouho trvala — to je samotný trénink a zároveň název a doba trvání příspěvku.</string>
|
||||
<string name="health_connect_rationale_steps">Kroky · počet kroků při běhu, chůzi nebo turistice.</string>
|
||||
<string name="health_connect_rationale_distance">Vzdálenost · jak daleko jste se dostali, zobrazená jako vzdálenost běhu, jízdy, chůze nebo plavání.</string>
|
||||
<string name="health_connect_rationale_calories">Aktivní a celkové kalorie · energie spálená při tréninku. Aktivní kalorie se použijí, pokud je váš zdroj zaznamenává; celkové kalorie jsou náhradou pro zdroje, které zaznamenávají pouze celkovou energii.</string>
|
||||
<string name="health_connect_rationale_elevation">Převýšení · kolik jste nastoupali, což odlišuje rovinatou jízdu od kopcovité.</string>
|
||||
<string name="health_connect_rationale_heart_rate">Tepová frekvence · průměrná a maximální tepová frekvence během tréninku, běžné měřítko náročnosti.</string>
|
||||
<string name="health_connect_rationale_limits_title">Co Amethyst nedělá</string>
|
||||
<string name="health_connect_rationale_limit_window">Čte pouze tréninky dokončené za posledních 7 dní, a to jen když je otevřený editor Tréninků. Na pozadí nečte nikdy.</string>
|
||||
<string name="health_connect_rationale_limit_publish">Nic neopustí váš telefon, dokud sami neklepnete na návrh a příspěvek nezveřejníte. Amethyst nemá žádný server: příspěvek jde na relaye Nostr, které jste nastavili.</string>
|
||||
<string name="health_connect_rationale_limit_write">Nikdy nic nezapisuje do Health Connect a nikdy nežádá o trasu vašeho cvičení, polohu ani jiný typ zdravotních údajů.</string>
|
||||
<string name="health_connect_rationale_limit_optional">Celá funkce je volitelná. Vypnete ji v Nastavení → Nastavení editoru, nebo kdykoli odeberete oprávnění v Health Connect — zbytek Amethystu funguje dál.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Přečíst si celé zásady ochrany soukromí</string>
|
||||
<string name="workout_suggestion_connect_details">Co Amethyst čte</string>
|
||||
</resources>
|
||||
|
||||
@@ -2697,4 +2697,131 @@
|
||||
<string name="buzz_persona_publishing">Wird veröffentlicht…</string>
|
||||
<string name="buzz_persona_publish">Persona veröffentlichen</string>
|
||||
<string name="profile_card_follows_you">Folgt dir</string>
|
||||
<string name="add_hashtag_label_field">Hashtag</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<string name="app_definition_kind_app">App</string>
|
||||
<string name="app_definition_nip">NIP-%1$s</string>
|
||||
<string name="badge_name_label">Name</string>
|
||||
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_dm_workspace">Workspace</string>
|
||||
<string name="buzz_invite_dismiss">OK</string>
|
||||
<string name="buzz_invite_workspace">Workspace</string>
|
||||
<string name="buzz_job_board_title">Backlog</string>
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<string name="buzz_workflow_def_name">Name</string>
|
||||
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
|
||||
<string name="buzz_workflow_picker_label">Workflow</string>
|
||||
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="cashu_mints">Mints</string>
|
||||
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
|
||||
<string name="chat_minichat_title">Thread</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="clink_budget_set">Budget</string>
|
||||
<string name="concord_create_name">Name</string>
|
||||
<string name="concord_view_inline">Inline</string>
|
||||
<string name="dm_sender_reported_more_count">+%1$d</string>
|
||||
<string name="dvm_offline">Offline</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="event_sync_dm_relays">DMs</string>
|
||||
<string name="event_sync_inbox_relays">Inbox</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_outbox_relays">Outbox</string>
|
||||
<string name="event_sync_relays_progress">Relays: %1$d / %2$d</string>
|
||||
<string name="feed">Feed</string>
|
||||
<string name="fork">Fork</string>
|
||||
<string name="gif">Gif</string>
|
||||
<string name="git_branch">Branch</string>
|
||||
<string name="git_commit">Commit</string>
|
||||
<string name="git_repo_branches">Branches</string>
|
||||
<string name="git_repo_commits">Commits</string>
|
||||
<string name="git_repo_default_branch">default</string>
|
||||
<string name="git_repo_plain_text">Text</string>
|
||||
<string name="git_repo_settings_name">Name</string>
|
||||
<string name="git_repo_stat_branches">Branches</string>
|
||||
<string name="git_repo_stat_tags">Tags</string>
|
||||
<string name="git_repo_tab_code">Code</string>
|
||||
<string name="git_repo_tags">Tags</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
|
||||
<string name="hls_codec_h264">H.264</string>
|
||||
<string name="hls_codec_label">Codec</string>
|
||||
<string name="language_preference_pair">%1$s → %2$s</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
<string name="live_stream_offline_tag">OFFLINE</string>
|
||||
<string name="malware">Malware</string>
|
||||
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="marmot_relays_header">Relays</string>
|
||||
<string name="marmot_user_fallback_name">%1$s…</string>
|
||||
<string name="music_track_album_label">Album (optional)</string>
|
||||
<string name="my_awesome_name">Ostrich McAwesome</string>
|
||||
<string name="nest_live_chip">LIVE</string>
|
||||
<string name="nest_role_moderator">Moderator</string>
|
||||
<string name="nest_tab_chat">Chat</string>
|
||||
<string name="nests_servers_relay_label">Relay</string>
|
||||
<string name="nip46_signer_act_other">%1$s</string>
|
||||
<string name="nip46_signer_act_ping">Ping</string>
|
||||
<string name="nip46_signer_live">Live</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="nutzap">Nutzap</string>
|
||||
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
|
||||
<string name="onchain_send_sats_amount">%1$s sats</string>
|
||||
<string name="onchain_send_sats_suffix">sats</string>
|
||||
<string name="platform_android">Android</string>
|
||||
<string name="platform_ios">iOS</string>
|
||||
<string name="platform_web">Web</string>
|
||||
<string name="podcast_bookmarks">Podcasts</string>
|
||||
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
|
||||
<string name="podcast_season_episode">S%1$d · E%2$d</string>
|
||||
<string name="podcast_trailer">Trailer</string>
|
||||
<string name="podcast_value_recipient_name">Name (optional)</string>
|
||||
<string name="podcast_video">Video</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="profile_apps_header">Apps · %1$d</string>
|
||||
<string name="profile_apps_header_empty">Apps</string>
|
||||
<string name="profile_card_bot">Bot</string>
|
||||
<string name="reactions_settings_boost">Boost</string>
|
||||
<string name="reactions_settings_zap">Zap</string>
|
||||
<string name="relay_group_badge_live">LIVE</string>
|
||||
<string name="relay_group_field_name">Name</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d+</string>
|
||||
<string name="relay_group_role_moderator">Moderator</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_view_inline">Inline</string>
|
||||
<string name="relays"> Relays</string>
|
||||
<string name="search_source_relays">Relays</string>
|
||||
<string name="secret_visible_text_placeholder">😎</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="send_payment_method_cashu">Cashu</string>
|
||||
<string name="send_payment_method_lightning">Lightning</string>
|
||||
<string name="share_of">%1$d/%2$d</string>
|
||||
<string name="tags_label"># Tags</string>
|
||||
<string name="version">Version</string>
|
||||
<string name="version_name">Version %1$s</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<string name="wallet_filter_zaps">Zaps</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="website">Website</string>
|
||||
<string name="workout">Workout</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="health_connect_rationale_headline">Amethyst liest abgeschlossene Workouts, um einen Workout-Beitrag für dich vorauszufüllen.</string>
|
||||
<string name="health_connect_rationale_title">Health Connect und Amethyst</string>
|
||||
<string name="health_connect_rationale_intro">Amethyst ist ein sozialer Nostr-Client. Im Bereich Workouts kannst du eine Zusammenfassung eines abgeschlossenen Workouts an die Nostr-Relays deiner Wahl veröffentlichen, damit die Leute, die dir folgen, sehen können, was du gemacht hast. Statt jede Zahl von Hand einzutippen, kann Amethyst das Workout lesen, das deine Uhr oder Fitness-App bereits in Health Connect gespeichert hat, und den Beitrag vorausfüllen. Du siehst den vorausgefüllten Beitrag immer und entscheidest, ob du ihn veröffentlichst.</string>
|
||||
<string name="health_connect_rationale_what_title">Was Amethyst liest und warum</string>
|
||||
<string name="health_connect_rationale_exercise">Übung · welche Aktivität du gemacht hast, wann sie begann und wie lange sie dauerte — das ist das Workout selbst sowie Titel und Dauer des Beitrags.</string>
|
||||
<string name="health_connect_rationale_steps">Schritte · die Schrittzahl eines Laufs, Spaziergangs oder einer Wanderung.</string>
|
||||
<string name="health_connect_rationale_distance">Distanz · wie weit du gekommen bist, angezeigt als Distanz des Laufs, der Fahrt, des Spaziergangs oder des Schwimmens.</string>
|
||||
<string name="health_connect_rationale_calories">Aktive und gesamte Kalorien · die beim Workout verbrannte Energie. Aktive Kalorien werden verwendet, wenn deine Quelle sie aufzeichnet; gesamte Kalorien sind der Ersatz für Quellen, die nur die Gesamtenergie aufzeichnen.</string>
|
||||
<string name="health_connect_rationale_elevation">Höhenmeter · wie viel du gestiegen bist, was eine flache Fahrt von einer hügeligen unterscheidet.</string>
|
||||
<string name="health_connect_rationale_heart_rate">Herzfrequenz · die durchschnittliche und maximale Herzfrequenz während des Workouts, das übliche Maß für die Anstrengung.</string>
|
||||
<string name="health_connect_rationale_limits_title">Was Amethyst nicht tut</string>
|
||||
<string name="health_connect_rationale_limit_window">Liest nur Workouts, die in den letzten 7 Tagen beendet wurden, und nur während der Workout-Editor geöffnet ist. Im Hintergrund wird nie gelesen.</string>
|
||||
<string name="health_connect_rationale_limit_publish">Nichts verlässt dein Telefon, bis du auf einen Vorschlag tippst und den Beitrag selbst veröffentlichst. Amethyst hat keinen Server: Der Beitrag geht an die Nostr-Relays, die du eingerichtet hast.</string>
|
||||
<string name="health_connect_rationale_limit_write">Schreibt nie etwas in Health Connect und fragt nie nach deiner Trainingsroute, deinem Standort oder anderen Gesundheitsdaten.</string>
|
||||
<string name="health_connect_rationale_limit_optional">Die gesamte Funktion ist optional. Schalte sie unter Einstellungen → Editor-Einstellungen aus oder entziehe die Berechtigungen jederzeit in Health Connect — der Rest von Amethyst funktioniert weiter.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Vollständige Datenschutzerklärung lesen</string>
|
||||
<string name="workout_suggestion_connect_details">Was Amethyst liest</string>
|
||||
</resources>
|
||||
|
||||
@@ -2729,4 +2729,99 @@
|
||||
<string name="buzz_persona_publishing">Publicando…</string>
|
||||
<string name="buzz_persona_publish">Publicar persona</string>
|
||||
<string name="profile_card_follows_you">Segue você</string>
|
||||
<string name="add_hashtag_label_field">Hashtag</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<string name="amount_in_bits">%1$s bits</string>
|
||||
<string name="app_definition_nip">NIP-%1$s</string>
|
||||
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_invite_dismiss">OK</string>
|
||||
<string name="buzz_job_board_title">Backlog</string>
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<string name="cashu_mint_label">Mint: %1$s</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="cashu_mints">Mints</string>
|
||||
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="dm_sender_reported_more_count">+%1$d</string>
|
||||
<string name="dvm_offline">Offline</string>
|
||||
<string name="emoji_pack_count">%1$d emojis</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="event_sync_dm_relays">DMs</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="feed">Feed</string>
|
||||
<string name="gif">Gif</string>
|
||||
<string name="git_branch">Branch</string>
|
||||
<string name="git_commit">Commit</string>
|
||||
<string name="git_repo_branches">Branches</string>
|
||||
<string name="git_repo_commits">Commits</string>
|
||||
<string name="git_repo_stat_branches">Branches</string>
|
||||
<string name="git_repo_stat_tags">Tags</string>
|
||||
<string name="git_repo_tags">Tags</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="hls_codec_h264">H.264</string>
|
||||
<string name="hls_codec_label">Codec</string>
|
||||
<string name="interest_set_hashtag_count">%1$d hashtag(s)</string>
|
||||
<string name="language_preference_pair">%1$s → %2$s</string>
|
||||
<string name="malware">Malware</string>
|
||||
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="marmot_user_fallback_name">%1$s…</string>
|
||||
<string name="nip46_signer_act_other">%1$s</string>
|
||||
<string name="nip46_signer_act_ping">Ping</string>
|
||||
<string name="nip82_section_links">Links</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="nutzap">Nutzap</string>
|
||||
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
|
||||
<string name="onchain_send_sats_amount">%1$s sats</string>
|
||||
<string name="onchain_send_sats_suffix">sats</string>
|
||||
<string name="original">original</string>
|
||||
<string name="picture_in_picture">Picture-in-Picture</string>
|
||||
<string name="platform_android">Android</string>
|
||||
<string name="platform_ios">iOS</string>
|
||||
<string name="platform_web">Web</string>
|
||||
<string name="podcast_bookmarks">Podcasts</string>
|
||||
<string name="podcast_role_editor">Editor</string>
|
||||
<string name="podcast_trailer">Trailer</string>
|
||||
<string name="podcast_value_stream_rate">%1$d sats/min</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="profile_apps_header">Apps · %1$d</string>
|
||||
<string name="profile_card_bot">Bot</string>
|
||||
<string name="reactions_settings_boost">Boost</string>
|
||||
<string name="reactions_settings_zap">Zap</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d+</string>
|
||||
<string name="reload_mint_sats_amount">%1$s sats</string>
|
||||
<string name="search_source_local">Local</string>
|
||||
<string name="secret_visible_text_placeholder">😎</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="send_payment_method_cashu">Cashu</string>
|
||||
<string name="send_payment_method_lightning">Lightning</string>
|
||||
<string name="send_payment_method_onchain">On-chain</string>
|
||||
<string name="share_of">%1$d/%2$d</string>
|
||||
<string name="tags_label"># Tags</string>
|
||||
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<string name="wallet_filter_zaps">Zaps</string>
|
||||
<string name="wallet_sats">sats</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_volume">Volume</string>
|
||||
<string name="health_connect_rationale_headline">O Amethyst lê treinos concluídos para preencher previamente uma publicação de treino para você.</string>
|
||||
<string name="health_connect_rationale_title">Health Connect e Amethyst</string>
|
||||
<string name="health_connect_rationale_intro">O Amethyst é um cliente social do Nostr. A seção Treinos permite publicar um resumo de um treino concluído nos relays do Nostr que você escolher, para que quem segue você veja o que você fez. Em vez de digitar cada número à mão, o Amethyst pode ler o treino que seu relógio ou aplicativo de fitness já salvou no Health Connect e preencher a publicação previamente. Você sempre vê a publicação preenchida e decide se quer publicá-la.</string>
|
||||
<string name="health_connect_rationale_what_title">O que o Amethyst lê e por quê</string>
|
||||
<string name="health_connect_rationale_exercise">Exercício · qual atividade você fez, quando começou e quanto tempo durou — é o treino em si, além do título e da duração da publicação.</string>
|
||||
<string name="health_connect_rationale_steps">Passos · a contagem de passos de uma corrida, caminhada ou trilha.</string>
|
||||
<string name="health_connect_rationale_distance">Distância · o quanto você percorreu, exibido como a distância da corrida, pedalada, caminhada ou natação.</string>
|
||||
<string name="health_connect_rationale_calories">Calorias ativas e totais · a energia gasta no treino. As calorias ativas são usadas quando sua fonte as registra; as calorias totais são a alternativa para fontes que registram apenas a energia total.</string>
|
||||
<string name="health_connect_rationale_elevation">Elevação · o quanto você subiu, que é o que distingue um percurso plano de um acidentado.</string>
|
||||
<string name="health_connect_rationale_heart_rate">Frequência cardíaca · a frequência média e máxima durante o treino, a medida padrão do esforço.</string>
|
||||
<string name="health_connect_rationale_limits_title">O que o Amethyst não faz</string>
|
||||
<string name="health_connect_rationale_limit_window">Lê apenas treinos concluídos nos últimos 7 dias e somente enquanto o editor de Treinos está aberto. Nunca lê em segundo plano.</string>
|
||||
<string name="health_connect_rationale_limit_publish">Nada sai do seu telefone até você tocar em uma sugestão e publicar por conta própria. O Amethyst não tem servidor: a publicação vai para os relays do Nostr que você configurou.</string>
|
||||
<string name="health_connect_rationale_limit_write">Nunca grava nada no Health Connect e nunca solicita seu trajeto de exercício, localização ou qualquer outro tipo de dado de saúde.</string>
|
||||
<string name="health_connect_rationale_limit_optional">Todo o recurso é opcional. Desative-o em Configurações → Configurações de composição, ou revogue as permissões no Health Connect a qualquer momento — o restante do Amethyst continua funcionando.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Ler a política de privacidade completa</string>
|
||||
<string name="workout_suggestion_connect_details">O que o Amethyst lê</string>
|
||||
</resources>
|
||||
|
||||
@@ -2731,4 +2731,97 @@
|
||||
<string name="buzz_persona_publishing">Publicerar…</string>
|
||||
<string name="buzz_persona_publish">Publicera persona</string>
|
||||
<string name="profile_card_follows_you">Följer dig</string>
|
||||
<string name="add_hashtag_label_field">Hashtag</string>
|
||||
<string name="ai_tone_emojify">+ Emoji</string>
|
||||
<string name="app_definition_kind_app">App</string>
|
||||
<string name="app_definition_nip">NIP-%1$s</string>
|
||||
<string name="app_definition_via">via %1$s</string>
|
||||
<string name="banner_url">Banner URL</string>
|
||||
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_invite_dismiss">OK</string>
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<string name="cashu_mint_label">Mint: %1$s</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="cashu_mints">Mints</string>
|
||||
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
|
||||
<string name="classifieds_title_placeholder">iPhone 13</string>
|
||||
<string name="clink_budget_set">Budget</string>
|
||||
<string name="dm_sender_reported_more_count">+%1$d</string>
|
||||
<string name="dvm_offline">Offline</string>
|
||||
<string name="emoji_pack_count">%1$d emojis</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="gif">Gif</string>
|
||||
<string name="git_commit">Commit</string>
|
||||
<string name="git_repo_commits">Commits</string>
|
||||
<string name="git_repo_plain_text">Text</string>
|
||||
<string name="goal_amount_placeholder">100000</string>
|
||||
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
|
||||
<string name="hls_codec_h264">H.264</string>
|
||||
<string name="hls_codec_label">Codec</string>
|
||||
<string name="language_preference_pair">%1$s → %2$s</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
<string name="live_stream_offline_tag">OFFLINE</string>
|
||||
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="marmot_user_fallback_name">%1$s…</string>
|
||||
<string name="music_track_artist_label">Artist</string>
|
||||
<string name="nest_live_chip">LIVE</string>
|
||||
<string name="nest_role_moderator">Moderator</string>
|
||||
<string name="nip46_signer_act_other">%1$s</string>
|
||||
<string name="nip46_signer_act_ping">Ping</string>
|
||||
<string name="nip46_signer_live">Live</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="nutzap">Nutzap</string>
|
||||
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
|
||||
<string name="onchain_send_sats_amount">%1$s sats</string>
|
||||
<string name="onchain_send_sats_suffix">sats</string>
|
||||
<string name="original">original</string>
|
||||
<string name="platform_android">Android</string>
|
||||
<string name="platform_ios">iOS</string>
|
||||
<string name="platform_web">Web</string>
|
||||
<string name="podcast_explicit">Explicit</string>
|
||||
<string name="podcast_trailer">Trailer</string>
|
||||
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
|
||||
<string name="podcast_value_stream_rate">%1$d sats/min</string>
|
||||
<string name="podcast_video">Video</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="profile_card_bot">Bot</string>
|
||||
<string name="reactions_settings_zap">Zap</string>
|
||||
<string name="relay_group_badge_live">LIVE</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d+</string>
|
||||
<string name="relay_group_role_moderator">Moderator</string>
|
||||
<string name="reload_mint_sats_amount">%1$s sats</string>
|
||||
<string name="secret_visible_text_placeholder">😎</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="send_payment_method_cashu">Cashu</string>
|
||||
<string name="send_payment_method_lightning">Lightning</string>
|
||||
<string name="send_payment_method_onchain">On-chain</string>
|
||||
<string name="share_of">%1$d/%2$d</string>
|
||||
<string name="version">Version</string>
|
||||
<string name="version_name">Version %1$s</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<string name="wallet_add_clink_title">CLINK Debit</string>
|
||||
<string name="wallet_filter_zaps">Zaps</string>
|
||||
<string name="wallet_sats">sats</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="health_connect_rationale_headline">Amethyst läser avslutade träningspass för att kunna förifylla ett träningsinlägg åt dig.</string>
|
||||
<string name="health_connect_rationale_title">Health Connect och Amethyst</string>
|
||||
<string name="health_connect_rationale_intro">Amethyst är en social Nostr-klient. I avsnittet Träningspass kan du publicera en sammanfattning av ett avslutat träningspass till de Nostr-reläer du väljer, så att de som följer dig kan se vad du har gjort. I stället för att skriva in varje siffra för hand kan Amethyst läsa det träningspass som din klocka eller träningsapp redan har sparat i Health Connect och förifylla inlägget. Du ser alltid det förifyllda inlägget och avgör själv om det ska publiceras.</string>
|
||||
<string name="health_connect_rationale_what_title">Vad Amethyst läser och varför</string>
|
||||
<string name="health_connect_rationale_exercise">Övning · vilken aktivitet du gjorde, när den började och hur länge den varade — det är själva träningspasset och även inläggets titel och varaktighet.</string>
|
||||
<string name="health_connect_rationale_steps">Steg · antalet steg under en löprunda, promenad eller vandring.</string>
|
||||
<string name="health_connect_rationale_distance">Distans · hur långt du tog dig, som visas som distansen för löprundan, cykelturen, promenaden eller simningen.</string>
|
||||
<string name="health_connect_rationale_calories">Aktiva och totala kalorier · energin som träningspasset förbrände. Aktiva kalorier används när din källa registrerar dem; totala kalorier är reserven för källor som bara registrerar total energi.</string>
|
||||
<string name="health_connect_rationale_elevation">Höjdmeter · hur mycket du klättrade, vilket skiljer en platt tur från en kuperad.</string>
|
||||
<string name="health_connect_rationale_heart_rate">Puls · genomsnittlig och maximal puls under träningspasset, det vanliga måttet på hur ansträngande det var.</string>
|
||||
<string name="health_connect_rationale_limits_title">Vad Amethyst inte gör</string>
|
||||
<string name="health_connect_rationale_limit_window">Läser bara träningspass som avslutades de senaste 7 dagarna, och bara medan träningsredigeraren är öppen. Den läser aldrig i bakgrunden.</string>
|
||||
<string name="health_connect_rationale_limit_publish">Ingenting lämnar din telefon förrän du trycker på ett förslag och publicerar inlägget själv. Amethyst har ingen server: inlägget går till de Nostr-reläer du har ställt in.</string>
|
||||
<string name="health_connect_rationale_limit_write">Skriver aldrig något till Health Connect och begär aldrig din träningsrutt, plats eller någon annan typ av hälsodata.</string>
|
||||
<string name="health_connect_rationale_limit_optional">Hela funktionen är valfri. Stäng av den under Inställningar → Skrivinställningar, eller återkalla behörigheterna i Health Connect när som helst — resten av Amethyst fortsätter att fungera.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Läs hela integritetspolicyn</string>
|
||||
<string name="workout_suggestion_connect_details">Vad Amethyst läser</string>
|
||||
</resources>
|
||||
|
||||
+22
@@ -76,12 +76,34 @@ expect class SecureKeyStorage private constructor() {
|
||||
* **Security Warning:** The returned String cannot be securely zeroed from memory (JVM limitation).
|
||||
* Dereference the returned value immediately after use to minimize exposure time.
|
||||
*
|
||||
* Callers that MUST distinguish "key does not exist" from "backend refused/locked/failed"
|
||||
* (for example, before generating a replacement key on disk) should use
|
||||
* [getPrivateKeyOrThrow] instead. This method returns null on any error and cannot
|
||||
* safely be used as an "is this the first launch?" probe.
|
||||
*
|
||||
* @param npub The public key in npub (Bech32) format
|
||||
* @return The private key in hexadecimal format, or null if not found
|
||||
* @throws SecureStorageException if retrieval operation fails
|
||||
*/
|
||||
suspend fun getPrivateKey(npub: String): String?
|
||||
|
||||
/**
|
||||
* Retrieves a private key for the given npub, distinguishing "definitively absent"
|
||||
* from any other failure mode.
|
||||
*
|
||||
* On success, returns the key. When the backend confirms the item does not exist,
|
||||
* returns null. Any other outcome (backend unavailable, user denied the OS prompt,
|
||||
* keychain locked, I/O error) throws [SecureStorageException]. This is the safe
|
||||
* primitive for compare-and-swap style flows where a null must not be interpreted
|
||||
* as permission to generate and persist a replacement.
|
||||
*
|
||||
* @param npub The public key in npub (Bech32) format
|
||||
* @return The private key in hexadecimal format, or null only when the backend
|
||||
* confirms the item does not exist
|
||||
* @throws SecureStorageException on any ambiguous or transient failure
|
||||
*/
|
||||
suspend fun getPrivateKeyOrThrow(npub: String): String?
|
||||
|
||||
/**
|
||||
* Deletes a private key for the given npub.
|
||||
*
|
||||
|
||||
+5
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* What a front end renders for one live agent text stream.
|
||||
@@ -203,6 +204,9 @@ class MarmotAgentStreamWatcher(
|
||||
try {
|
||||
quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray())
|
||||
} catch (e: Exception) {
|
||||
// Without this a cancelled watcher keeps dialling the remaining
|
||||
// candidates instead of stopping.
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" }
|
||||
continue
|
||||
}
|
||||
@@ -222,6 +226,7 @@ class MarmotAgentStreamWatcher(
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" }
|
||||
} finally {
|
||||
runCatching { stream.close() }
|
||||
|
||||
+5
-6
@@ -129,12 +129,11 @@ class BlossomReadAuthTokenProvider(
|
||||
val fresh = CompletableDeferred<String?>()
|
||||
inFlight.putIfAbsent(host, fresh)?.let { return it }
|
||||
|
||||
// Third look, now that this caller holds the entry. Between the second look and
|
||||
// the `putIfAbsent` a leader can run its whole cycle — sign, cache, retire — and
|
||||
// that retirement is exactly what let this caller claim the slot. The leader's
|
||||
// put to [cache] happens-before its removal of the same key, so any token it
|
||||
// minted is visible here: hand it out (to this caller and to any follower that
|
||||
// already picked up [fresh]) and retire the entry, instead of signing again.
|
||||
// Third look, now that this caller owns the slot. The look above still leaves a
|
||||
// gap: a leader can insert, sign, cache and retire its entry entirely between
|
||||
// that read and the putIfAbsent, so the map is empty again and this caller wins
|
||||
// it. Any leader that retired before this insert cached first, so a token
|
||||
// present now is theirs — take it and give the slot back instead of re-signing.
|
||||
cachedHeader(host)?.let {
|
||||
inFlight.remove(host, fresh)
|
||||
fresh.complete(it)
|
||||
|
||||
-1
@@ -68,6 +68,5 @@ abstract class FeedViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.toLongValue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -162,7 +163,7 @@ class LiveStreamTopZappersViewModel(
|
||||
when (val ev = note.event) {
|
||||
is LnZapEvent -> {
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
val sats = ev.amount()?.toLongValue() ?: return null
|
||||
ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
is Bolt12ZapEvent -> {
|
||||
@@ -178,7 +179,7 @@ class LiveStreamTopZappersViewModel(
|
||||
): ZapContribution? {
|
||||
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
|
||||
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
|
||||
val sats = receiptEv.amount()?.toLong() ?: return null
|
||||
val sats = receiptEv.amount()?.toLongValue() ?: return null
|
||||
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.toLongValue
|
||||
|
||||
/**
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on the
|
||||
@@ -59,7 +60,7 @@ data class RoomZap(
|
||||
eventId = event.id,
|
||||
sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey,
|
||||
targetPubkey = event.zappedAuthor().firstOrNull(),
|
||||
amountSats = event.amount?.toLong(),
|
||||
amountSats = event.amount?.toLongValue(),
|
||||
createdAtSec = event.createdAt,
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ class PartialTokensTest {
|
||||
fun aHalfWrittenFromOpensThePeoplePicker() {
|
||||
val picker = pickerAtEnd("zaps from:ali")
|
||||
assertTrue(picker is ActivePicker.People)
|
||||
assertEquals(KeyField.FROM, (picker as ActivePicker.People).keyField)
|
||||
assertEquals(KeyField.FROM, picker.keyField)
|
||||
assertEquals("ali", picker.token.partial)
|
||||
assertEquals(5, picker.token.start)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class PartialTokensTest {
|
||||
fun aHalfWrittenDateOpensTheCalendar() {
|
||||
val picker = pickerAtEnd("since:2026-0")
|
||||
assertTrue(picker is ActivePicker.Calendar)
|
||||
assertEquals(DateField.SINCE, (picker as ActivePicker.Calendar).dateField)
|
||||
assertEquals(DateField.SINCE, picker.dateField)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
|
||||
actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
|
||||
|
||||
actual suspend fun getPrivateKeyOrThrow(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
|
||||
|
||||
actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
|
||||
|
||||
actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
|
||||
|
||||
-1
@@ -838,7 +838,6 @@ class NestViewModel(
|
||||
closed = true
|
||||
teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true)
|
||||
teardown(targetState = ConnectionUiState.Closed, finalCleanup = true)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun observeSpeakerState(s: NestsSpeaker) {
|
||||
|
||||
+161
@@ -149,6 +149,75 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant that distinguishes "backend confirms item not found" from every
|
||||
* other outcome. This matters on macOS: `javakeyring` collapses `errSecItemNotFound`
|
||||
* (-25300), `errSecAuthFailed` (-25293), `errSecUserCanceled` (-128), and
|
||||
* `errSecInteractionNotAllowed` (-25308) into the same `PasswordAccessException`.
|
||||
* A caller that mistook "user clicked Deny" for "first launch, generate a fresh
|
||||
* key" would silently rotate the metadata AES key and permanently destroy the
|
||||
* accounts.json.enc it was supposed to unlock.
|
||||
*
|
||||
* On macOS this shells out to `/usr/bin/security find-generic-password`, whose
|
||||
* exit codes are documented and unambiguous (44 = not found, 128 = user cancel /
|
||||
* dialog dismissed, others = backend failure). On Windows / Linux, javakeyring
|
||||
* has no such ambiguity for the equivalent flows in practice, but we still treat
|
||||
* any `PasswordAccessException` here as ambiguous (throw) to keep the contract
|
||||
* strict on the getOrCreate path.
|
||||
*/
|
||||
actual suspend fun getPrivateKeyOrThrow(npub: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!keyringAvailable) {
|
||||
return@withContext getFromFallback(npub)
|
||||
}
|
||||
if (isMacOs()) {
|
||||
return@withContext getFromMacSecurityCli(SERVICE_NAME, npub)
|
||||
}
|
||||
try {
|
||||
keyring().getPassword(SERVICE_NAME, npub)
|
||||
} catch (e: PasswordAccessException) {
|
||||
// Non-mac backends: keep the strict contract by refusing to
|
||||
// treat this as "definitively absent". A caller that needs a
|
||||
// permissive lookup should use getPrivateKey() instead.
|
||||
throw SecureStorageException(
|
||||
"Keyring backend refused access or returned ambiguous not-found",
|
||||
e,
|
||||
)
|
||||
}
|
||||
} catch (e: SecureStorageException) {
|
||||
throw e
|
||||
} catch (e: BackendNotSupportedException) {
|
||||
keyringAvailable = false
|
||||
println("OS keyring not available, using fallback encrypted storage")
|
||||
getFromFallback(npub)
|
||||
} catch (e: Exception) {
|
||||
throw SecureStorageException("Failed to retrieve private key (strict)", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: overridable strategy for the strict macOS lookup. Production wires
|
||||
* to [defaultMacSecurityLookup] which spawns `/usr/bin/security`. Tests replace
|
||||
* this with a stub so unit tests run hermetically on any OS.
|
||||
*/
|
||||
internal var macSecurityLookup: (String, String) -> MacSecurityResult =
|
||||
::defaultMacSecurityLookup
|
||||
|
||||
private fun getFromMacSecurityCli(
|
||||
service: String,
|
||||
account: String,
|
||||
): String? {
|
||||
val result = macSecurityLookup(service, account)
|
||||
return when (result) {
|
||||
is MacSecurityResult.Found -> result.password
|
||||
is MacSecurityResult.NotFound -> null
|
||||
is MacSecurityResult.Ambiguous -> throw SecureStorageException(
|
||||
"macOS Keychain access failed (${result.reason}, exit=${result.exitCode})",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun deletePrivateKey(npub: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -466,6 +535,98 @@ internal interface KeyringHandle {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a strict macOS `/usr/bin/security find-generic-password` lookup.
|
||||
* Kept as a sealed hierarchy so [SecureKeyStorage.getPrivateKeyOrThrow] can
|
||||
* cleanly translate to `null` versus `SecureStorageException`.
|
||||
*/
|
||||
internal sealed class MacSecurityResult {
|
||||
data class Found(
|
||||
val password: String,
|
||||
) : MacSecurityResult()
|
||||
|
||||
object NotFound : MacSecurityResult()
|
||||
|
||||
/**
|
||||
* Any exit code other than 0 (found) or 44 (item not found). Reason is a short
|
||||
* human string derived from stderr / documented codes:
|
||||
* 128 = user cancelled or dismissed the Keychain Access dialog
|
||||
* -25293 (errSecAuthFailed) surfaces as exit 51 in practice
|
||||
* -25308 (errSecInteractionNotAllowed) surfaces when Keychain is locked
|
||||
*/
|
||||
data class Ambiguous(
|
||||
val exitCode: Int,
|
||||
val reason: String,
|
||||
) : MacSecurityResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure parser split out for testability on non-macOS CI runners. Maps the
|
||||
* documented exit code contract of `/usr/bin/security find-generic-password`
|
||||
* to a [MacSecurityResult]. `stdout` is the raw password body (`-w` prints it
|
||||
* followed by a newline; strip the trailing newline only). `stderr` is used
|
||||
* as a hint for the ambiguous [MacSecurityResult.Ambiguous.reason] string.
|
||||
*/
|
||||
internal fun parseMacSecurityFindResult(
|
||||
exitCode: Int,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
): MacSecurityResult =
|
||||
when (exitCode) {
|
||||
0 -> MacSecurityResult.Found(stdout.trimEnd('\n', '\r'))
|
||||
44 -> MacSecurityResult.NotFound
|
||||
else -> {
|
||||
val reason =
|
||||
when {
|
||||
exitCode == 128 -> "user cancelled Keychain dialog"
|
||||
stderr.contains("-25293") -> "errSecAuthFailed"
|
||||
stderr.contains("-25308") -> "errSecInteractionNotAllowed"
|
||||
stderr.contains("-128") -> "user cancelled Keychain dialog"
|
||||
stderr.isNotBlank() ->
|
||||
stderr
|
||||
.lineSequence()
|
||||
.first()
|
||||
.trim()
|
||||
.take(120)
|
||||
else -> "unknown"
|
||||
}
|
||||
MacSecurityResult.Ambiguous(exitCode, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMacOs(): Boolean = System.getProperty("os.name").orEmpty().startsWith("Mac")
|
||||
|
||||
/**
|
||||
* Production implementation: spawn `/usr/bin/security` and read exit code + streams.
|
||||
* Kept package-private so tests can also reach it if they want to run the real path
|
||||
* on a mac host, but production always goes through the [SecureKeyStorage.macSecurityLookup]
|
||||
* indirection.
|
||||
*/
|
||||
internal fun defaultMacSecurityLookup(
|
||||
service: String,
|
||||
account: String,
|
||||
): MacSecurityResult {
|
||||
val process =
|
||||
try {
|
||||
ProcessBuilder(
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
).redirectErrorStream(false).start()
|
||||
} catch (e: Exception) {
|
||||
return MacSecurityResult.Ambiguous(-1, "failed to spawn /usr/bin/security: ${e.message ?: e::class.simpleName ?: "unknown"}")
|
||||
}
|
||||
process.outputStream.close()
|
||||
val stdout = process.inputStream.bufferedReader().use { it.readText() }
|
||||
val stderr = process.errorStream.bufferedReader().use { it.readText() }
|
||||
val exitCode = process.waitFor()
|
||||
return parseMacSecurityFindResult(exitCode, stdout, stderr)
|
||||
}
|
||||
|
||||
internal class RealKeyringHandle(
|
||||
private val keyring: Keyring,
|
||||
) : KeyringHandle {
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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.keystorage
|
||||
|
||||
import com.github.javakeyring.PasswordAccessException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the strict `getPrivateKeyOrThrow` lookup and its pure macOS
|
||||
* `/usr/bin/security` exit-code parser.
|
||||
*
|
||||
* These tests must never touch the OS keychain and must run on any host, so:
|
||||
* - the macOS integration paths are gated behind [MacSecurityResult] stubs
|
||||
* injected via `SecureKeyStorage.macSecurityLookup`;
|
||||
* - the parser test operates on captured stdout/stderr/exitCode triples;
|
||||
* - non-mac backends are exercised via the `KeyringHandle` test seam already
|
||||
* used by [SecureKeyStorageKeyringCacheTest].
|
||||
*/
|
||||
class SecureKeyStorageOrThrowTest {
|
||||
private class ExplodingKeyring(
|
||||
private val onGet: () -> Nothing,
|
||||
) : KeyringHandle {
|
||||
override fun getPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
): String = onGet()
|
||||
|
||||
override fun setPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
password: String,
|
||||
) {
|
||||
// unused in these tests
|
||||
}
|
||||
|
||||
override fun deletePassword(
|
||||
service: String,
|
||||
account: String,
|
||||
) {
|
||||
// unused in these tests
|
||||
}
|
||||
}
|
||||
|
||||
private class StaticKeyring(
|
||||
private val map: Map<Pair<String, String>, String>,
|
||||
) : KeyringHandle {
|
||||
override fun getPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
): String = map[service to account] ?: throw PasswordAccessException("no entry")
|
||||
|
||||
override fun setPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
password: String,
|
||||
) {}
|
||||
|
||||
override fun deletePassword(
|
||||
service: String,
|
||||
account: String,
|
||||
) {}
|
||||
}
|
||||
|
||||
// --- macOS security(1) parser ---
|
||||
|
||||
@Test
|
||||
fun `parser exit 0 returns Found with trimmed password`() {
|
||||
val result = parseMacSecurityFindResult(0, "hunter2\n", "")
|
||||
assertTrue(result is MacSecurityResult.Found)
|
||||
assertEquals("hunter2", (result as MacSecurityResult.Found).password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser exit 0 preserves internal newlines and only strips trailing`() {
|
||||
val result = parseMacSecurityFindResult(0, "line1\nline2\n", "")
|
||||
assertEquals("line1\nline2", (result as MacSecurityResult.Found).password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser exit 44 returns NotFound`() {
|
||||
val result =
|
||||
parseMacSecurityFindResult(
|
||||
44,
|
||||
"",
|
||||
"security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.\n",
|
||||
)
|
||||
assertTrue(result is MacSecurityResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser exit 128 flagged as user-cancelled`() {
|
||||
val result = parseMacSecurityFindResult(128, "", "security: dismissed\n")
|
||||
assertTrue(result is MacSecurityResult.Ambiguous)
|
||||
val ambig = result as MacSecurityResult.Ambiguous
|
||||
assertEquals(128, ambig.exitCode)
|
||||
assertTrue(ambig.reason.contains("cancel"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser stderr -25293 mapped to errSecAuthFailed`() {
|
||||
val result = parseMacSecurityFindResult(51, "", "security: SecKeychainItemCopyContent (-25293)\n")
|
||||
val ambig = result as MacSecurityResult.Ambiguous
|
||||
assertEquals("errSecAuthFailed", ambig.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser unknown exit falls back to first stderr line`() {
|
||||
val result = parseMacSecurityFindResult(9999, "", "security: mystery: line 1\nline 2\n")
|
||||
val ambig = result as MacSecurityResult.Ambiguous
|
||||
assertEquals("security: mystery: line 1", ambig.reason)
|
||||
}
|
||||
|
||||
// --- getPrivateKeyOrThrow: strict semantics via injected macOS lookup ---
|
||||
// (Enabled unconditionally: the macSecurityLookup indirection is exercised
|
||||
// via a stub, so no `security` binary is invoked. The `isMacOs()` check
|
||||
// means this test only takes the mac path on macOS runners; on Linux it
|
||||
// takes the javakeyring path, which we validate separately below.)
|
||||
|
||||
private fun newStorage(): SecureKeyStorage = SecureKeyStorage.create()
|
||||
|
||||
@Test
|
||||
fun `mac lookup Found returns password without ambiguity`() =
|
||||
runBlocking {
|
||||
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking
|
||||
val storage = newStorage()
|
||||
storage.macSecurityLookup = { _, _ -> MacSecurityResult.Found("secretval") }
|
||||
assertEquals("secretval", storage.getPrivateKeyOrThrow("account-metadata-key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mac lookup NotFound returns null`() =
|
||||
runBlocking {
|
||||
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking
|
||||
val storage = newStorage()
|
||||
storage.macSecurityLookup = { _, _ -> MacSecurityResult.NotFound }
|
||||
assertNull(storage.getPrivateKeyOrThrow("account-metadata-key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mac lookup Ambiguous throws SecureStorageException with reason`() =
|
||||
runBlocking {
|
||||
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking
|
||||
val storage = newStorage()
|
||||
storage.macSecurityLookup = { _, _ ->
|
||||
MacSecurityResult.Ambiguous(128, "user cancelled Keychain dialog")
|
||||
}
|
||||
try {
|
||||
storage.getPrivateKeyOrThrow("account-metadata-key")
|
||||
fail("Expected SecureStorageException")
|
||||
} catch (e: SecureStorageException) {
|
||||
assertTrue(e.message?.contains("user cancelled") == true)
|
||||
assertTrue(e.message?.contains("128") == true)
|
||||
}
|
||||
}
|
||||
|
||||
// --- non-mac backend: PasswordAccessException must throw, never null ---
|
||||
|
||||
@Test
|
||||
fun `non-mac keyring PasswordAccessException throws not returns null`() =
|
||||
runBlocking {
|
||||
if (System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking
|
||||
val storage = newStorage()
|
||||
storage.keyringFactory = { ExplodingKeyring { throw PasswordAccessException("locked") } }
|
||||
try {
|
||||
storage.getPrivateKeyOrThrow("account-metadata-key")
|
||||
fail("Expected SecureStorageException")
|
||||
} catch (e: SecureStorageException) {
|
||||
assertTrue(
|
||||
e.message?.contains("ambiguous", ignoreCase = true) == true ||
|
||||
e.message?.contains("refused", ignoreCase = true) == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-mac keyring hit returns password`() =
|
||||
runBlocking {
|
||||
if (System.getProperty("os.name").orEmpty().startsWith("Mac")) return@runBlocking
|
||||
val storage = newStorage()
|
||||
storage.keyringFactory = {
|
||||
StaticKeyring(
|
||||
mapOf(
|
||||
("amethyst-desktop" to "account-metadata-key") to "abc123",
|
||||
),
|
||||
)
|
||||
}
|
||||
assertEquals("abc123", storage.getPrivateKeyOrThrow("account-metadata-key"))
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest {
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
epochs: List<ByteArray>,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
retained[nostrGroupId] = epochs
|
||||
retained[nostrGroupId] = retainedSecrets
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
|
||||
|
||||
+2
-2
@@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest {
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
epochs: List<ByteArray>,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
retained[nostrGroupId] = epochs
|
||||
retained[nostrGroupId] = retainedSecrets
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty()
|
||||
|
||||
@@ -254,7 +254,7 @@ compose.desktop {
|
||||
// The arch is selected at task-execution time from the host JVM's os.arch, so
|
||||
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
|
||||
// BUILDING.md documents local-dev fetch.
|
||||
val createReleaseAppImage by tasks.registering(Exec::class) {
|
||||
val createReleaseAppImage = tasks.register<Exec>("createReleaseAppImage") {
|
||||
group = "compose desktop"
|
||||
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
|
||||
dependsOn("createReleaseDistributable")
|
||||
@@ -330,7 +330,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) {
|
||||
// proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is
|
||||
// a regression guard, not a workaround. It's wired onto every release task so
|
||||
// it fails the build immediately if the .so disappears.
|
||||
val verifyJkeychainNativeSurvivesProguard by tasks.registering {
|
||||
val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") {
|
||||
description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)"
|
||||
group = "verification"
|
||||
dependsOn("proguardReleaseJars")
|
||||
@@ -395,7 +395,7 @@ listOf(
|
||||
// into the .app) so the subsequent bundle signing seals already-signed code.
|
||||
// Runs only on macOS with the Developer ID identity exported — a no-op on every
|
||||
// other leg and on unsigned local/PR builds.
|
||||
val signMacJarNatives by tasks.registering {
|
||||
val signMacJarNatives = tasks.register("signMacJarNatives") {
|
||||
description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized"
|
||||
group = "build"
|
||||
dependsOn("proguardReleaseJars")
|
||||
|
||||
+170
-42
@@ -23,12 +23,16 @@ package com.vitorpamplona.amethyst.desktop.account
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
|
||||
import com.vitorpamplona.amethyst.commons.model.account.AccountStorage
|
||||
import com.vitorpamplona.amethyst.commons.model.account.SignerType
|
||||
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
@@ -58,6 +62,16 @@ sealed class StorageCorruption(
|
||||
class JsonMalformed(
|
||||
backupPath: String?,
|
||||
) : StorageCorruption(backupPath)
|
||||
|
||||
/**
|
||||
* A transient failure surfaced from the read path (I/O error, keychain refused
|
||||
* or otherwise ambiguous access, OOM, etc). No backup was written and the
|
||||
* on-disk file is untouched. Callers should retry or surface an error UI rather
|
||||
* than treating this as data loss. See [DesktopAccountStorage.readMetadataFromDisk].
|
||||
*/
|
||||
class TransientError(
|
||||
val cause: Throwable,
|
||||
) : StorageCorruption(backupPath = null)
|
||||
}
|
||||
|
||||
class DesktopAccountStorage(
|
||||
@@ -68,6 +82,7 @@ class DesktopAccountStorage(
|
||||
companion object {
|
||||
private const val METADATA_KEY_ALIAS = "account-metadata-key"
|
||||
private const val ACCOUNTS_FILE = "accounts.json.enc"
|
||||
private const val ACCOUNTS_LOCK_FILE = "accounts.json.enc.lock"
|
||||
private const val AES_KEY_SIZE = 32 // 256 bits
|
||||
private const val GCM_IV_SIZE = 12
|
||||
private const val GCM_TAG_BITS = 128
|
||||
@@ -76,38 +91,51 @@ class DesktopAccountStorage(
|
||||
private val mapper = jacksonObjectMapper()
|
||||
private val amethystDir by lazy { File(homeDir, ".amethyst") }
|
||||
|
||||
// In-memory cache — read from disk once, then serve from memory
|
||||
// In-memory cache: read from disk once, then serve from memory
|
||||
private var cachedMetadata: AccountMetadata? = null
|
||||
|
||||
// In-process mutex around the cross-process file lock. Two callers inside
|
||||
// the same JVM would otherwise fail with OverlappingFileLockException from
|
||||
// FileChannel.lock(), since JVM file locks are per-JVM not per-thread.
|
||||
private val fileLockMutex = Mutex()
|
||||
|
||||
// Guards read-modify-write cycles on [cachedMetadata]. Distinct from
|
||||
// [fileLockMutex] so we can hold it across a full read + mutate + write
|
||||
// sequence (the file lock is taken and released inside each disk op).
|
||||
private val stateMutex = Mutex()
|
||||
|
||||
// --- AccountStorage interface ---
|
||||
|
||||
override suspend fun loadAccounts(): List<AccountInfo> = getCachedMetadata().accounts.map { it.toAccountInfo() }
|
||||
|
||||
override suspend fun saveAccount(info: AccountInfo) {
|
||||
val metadata = getCachedMetadata()
|
||||
val dto = AccountInfoDto.from(info)
|
||||
val updated = metadata.accounts.filter { it.npub != info.npub } + dto
|
||||
writeCachedMetadata(metadata.copy(accounts = updated))
|
||||
}
|
||||
override suspend fun saveAccount(info: AccountInfo) =
|
||||
stateMutex.withLock {
|
||||
val metadata = getCachedMetadata()
|
||||
val dto = AccountInfoDto.from(info)
|
||||
val updated = metadata.accounts.filter { it.npub != info.npub } + dto
|
||||
writeCachedMetadata(metadata.copy(accounts = updated))
|
||||
}
|
||||
|
||||
override suspend fun deleteAccount(npub: String) {
|
||||
val metadata = getCachedMetadata()
|
||||
val updated = metadata.accounts.filter { it.npub != npub }
|
||||
val newActive =
|
||||
if (metadata.activeNpub == npub) {
|
||||
updated.firstOrNull()?.npub
|
||||
} else {
|
||||
metadata.activeNpub
|
||||
}
|
||||
writeCachedMetadata(metadata.copy(accounts = updated, activeNpub = newActive))
|
||||
}
|
||||
override suspend fun deleteAccount(npub: String) =
|
||||
stateMutex.withLock {
|
||||
val metadata = getCachedMetadata()
|
||||
val updated = metadata.accounts.filter { it.npub != npub }
|
||||
val newActive =
|
||||
if (metadata.activeNpub == npub) {
|
||||
updated.firstOrNull()?.npub
|
||||
} else {
|
||||
metadata.activeNpub
|
||||
}
|
||||
writeCachedMetadata(metadata.copy(accounts = updated, activeNpub = newActive))
|
||||
}
|
||||
|
||||
override suspend fun currentAccount(): String? = getCachedMetadata().activeNpub
|
||||
|
||||
override suspend fun setCurrentAccount(npub: String) {
|
||||
val metadata = getCachedMetadata()
|
||||
writeCachedMetadata(metadata.copy(activeNpub = npub))
|
||||
}
|
||||
override suspend fun setCurrentAccount(npub: String) =
|
||||
stateMutex.withLock {
|
||||
val metadata = getCachedMetadata()
|
||||
writeCachedMetadata(metadata.copy(activeNpub = npub))
|
||||
}
|
||||
|
||||
// --- Cached I/O ---
|
||||
|
||||
@@ -118,9 +146,17 @@ class DesktopAccountStorage(
|
||||
return loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists first, caches second.
|
||||
*
|
||||
* If the disk write fails (keychain refused, I/O error, disk full) the in-memory
|
||||
* cache must NOT be left claiming a state that was never written: the rest of the
|
||||
* session would serve accounts that vanish on the next launch, and the user would
|
||||
* see a successful save that silently did nothing.
|
||||
*/
|
||||
private suspend fun writeCachedMetadata(metadata: AccountMetadata) {
|
||||
cachedMetadata = metadata
|
||||
writeMetadataToDisk(metadata)
|
||||
cachedMetadata = metadata
|
||||
}
|
||||
|
||||
// --- Encrypted file I/O ---
|
||||
@@ -129,9 +165,17 @@ class DesktopAccountStorage(
|
||||
val file = getAccountsFile()
|
||||
if (!file.exists()) return AccountMetadata()
|
||||
|
||||
ensureDir()
|
||||
return withAccountsFileLock {
|
||||
readMetadataFromDiskLocked(file)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readMetadataFromDiskLocked(file: File): AccountMetadata {
|
||||
val encrypted = file.readBytes()
|
||||
if (encrypted.size < GCM_IV_SIZE) {
|
||||
val backup = backupCorruptFile(file)
|
||||
// Genuinely unusable: not enough bytes for the IV. Back up and reset.
|
||||
val backup = backupCorruptFile(file, ".corrupt")
|
||||
onCorruption(StorageCorruption.FileCorrupted(backup))
|
||||
return AccountMetadata()
|
||||
}
|
||||
@@ -140,31 +184,43 @@ class DesktopAccountStorage(
|
||||
val decrypted = decrypt(encrypted)
|
||||
mapper.readValue<AccountMetadata>(decrypted)
|
||||
} catch (e: javax.crypto.AEADBadTagException) {
|
||||
Log.e("DesktopAccountStorage", "GCM auth tag mismatch — file corrupted or key lost", e)
|
||||
val backup = backupCorruptFile(file)
|
||||
// Genuine ciphertext corruption or lost/rotated AES key.
|
||||
Log.e("DesktopAccountStorage", "GCM auth tag mismatch, file corrupted or key lost", e)
|
||||
val backup = backupCorruptFile(file, ".corrupt")
|
||||
onCorruption(StorageCorruption.FileCorrupted(backup))
|
||||
AccountMetadata()
|
||||
} catch (e: javax.crypto.BadPaddingException) {
|
||||
Log.e("DesktopAccountStorage", "Decryption failed — file corrupted", e)
|
||||
val backup = backupCorruptFile(file)
|
||||
// Genuine ciphertext corruption.
|
||||
Log.e("DesktopAccountStorage", "Decryption failed, file corrupted", e)
|
||||
val backup = backupCorruptFile(file, ".corrupt")
|
||||
onCorruption(StorageCorruption.FileCorrupted(backup))
|
||||
AccountMetadata()
|
||||
} catch (e: com.fasterxml.jackson.core.JacksonException) {
|
||||
// Schema mismatch: decrypted cleanly but the JSON does not fit our shape.
|
||||
// Distinct suffix so operators can tell it apart from ciphertext corruption.
|
||||
Log.e("DesktopAccountStorage", "JSON malformed after decryption", e)
|
||||
val backup = backupCorruptFile(file)
|
||||
val backup = backupCorruptFile(file, ".jsonerror")
|
||||
onCorruption(StorageCorruption.JsonMalformed(backup))
|
||||
AccountMetadata()
|
||||
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e)
|
||||
val backup = backupCorruptFile(file)
|
||||
onCorruption(StorageCorruption.FileCorrupted(backup))
|
||||
AccountMetadata()
|
||||
// Transient failure: I/O error, keychain refused / ambiguous, OOM, etc.
|
||||
// DO NOT rename the on-disk file; the ciphertext is intact and the next
|
||||
// launch may succeed (for example after the user re-approves the
|
||||
// Keychain Access prompt). Surface up for the caller to decide.
|
||||
Log.e("DesktopAccountStorage", "Transient error reading accounts metadata; file preserved", e)
|
||||
onCorruption(StorageCorruption.TransientError(e))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupCorruptFile(file: File): String? =
|
||||
private fun backupCorruptFile(
|
||||
file: File,
|
||||
suffix: String,
|
||||
): String? =
|
||||
try {
|
||||
val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}")
|
||||
val backup = File(file.parent, "${file.name}$suffix.${System.currentTimeMillis()}")
|
||||
java.nio.file.Files
|
||||
.copy(file.toPath(), backup.toPath())
|
||||
file.deleteOrWarn("DesktopAccountStorage", "corrupt accounts file")
|
||||
@@ -178,31 +234,103 @@ class DesktopAccountStorage(
|
||||
val json = mapper.writeValueAsBytes(metadata)
|
||||
val encrypted = encrypt(json)
|
||||
|
||||
// Atomic write via temp file
|
||||
val file = getAccountsFile()
|
||||
val temp = File(amethystDir, "${ACCOUNTS_FILE}.tmp")
|
||||
temp.writeBytes(encrypted)
|
||||
Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
|
||||
setFilePermissions(file)
|
||||
withAccountsFileLock {
|
||||
// Atomic write via temp file, under the cross-process lock so two
|
||||
// Amethyst instances (Homebrew upgrade race, accidental double-launch)
|
||||
// cannot interleave writes and truncate the file.
|
||||
val temp = File(amethystDir, "$ACCOUNTS_FILE.tmp")
|
||||
temp.writeBytes(encrypted)
|
||||
Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
setFilePermissions(file)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-process advisory lock + in-process mutex around the accounts.json.enc
|
||||
* read/write critical section. The mutex is required because JVM
|
||||
* `FileChannel.lock()` is a per-JVM lock and would throw
|
||||
* `OverlappingFileLockException` on the second acquire from the same JVM.
|
||||
* The channel lock is required to keep two Amethyst processes serial (upgrade
|
||||
* race, accidental double-launch, cron-style relaunch).
|
||||
*
|
||||
* Mirrors the pattern used in SecureKeyStorage.withFileLock; kept private
|
||||
* to this class so the two lock lifecycles stay independent.
|
||||
*/
|
||||
private suspend inline fun <T> withAccountsFileLock(crossinline block: suspend () -> T): T =
|
||||
fileLockMutex.withLock {
|
||||
val lockFile = File(amethystDir, ACCOUNTS_LOCK_FILE)
|
||||
if (!lockFile.exists()) {
|
||||
lockFile.createNewFile()
|
||||
setFilePermissions(lockFile)
|
||||
}
|
||||
RandomAccessFile(lockFile, "rw").use { raf ->
|
||||
raf.channel.lock().use { _ ->
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAccountsFile() = File(amethystDir, ACCOUNTS_FILE)
|
||||
|
||||
// --- AES-256-GCM encryption ---
|
||||
|
||||
private var cachedKey: ByteArray? = null
|
||||
|
||||
/**
|
||||
* Reads (or creates on first launch) the metadata AES key.
|
||||
*
|
||||
* Distinguishes:
|
||||
* - key exists in keychain: use it
|
||||
* - keychain confirms definitively absent: generate + persist a fresh key
|
||||
* - any other outcome (user cancelled/denied prompt, keychain locked,
|
||||
* backend transient error): propagate the exception, do NOT rotate --
|
||||
* unless there is no accounts.json.enc yet, in which case there is no
|
||||
* ciphertext to orphan and we bootstrap a fresh key (see below).
|
||||
*
|
||||
* Rotating the AES key on an ambiguous miss silently destroys the ability
|
||||
* to decrypt the existing accounts.json.enc, wiping the logged-in accounts
|
||||
* on next launch. That is the bug this method exists to prevent.
|
||||
*/
|
||||
private suspend fun getOrCreateKey(): ByteArray {
|
||||
cachedKey?.let { return it }
|
||||
|
||||
val existing = secureStorage.getPrivateKey(METADATA_KEY_ALIAS)
|
||||
val existing =
|
||||
try {
|
||||
secureStorage.getPrivateKeyOrThrow(METADATA_KEY_ALIAS)
|
||||
} catch (e: SecureStorageException) {
|
||||
// Bootstrap escape. Every non-macOS backend java-keyring ships
|
||||
// (Windows Credential Store, Freedesktop Secret Service, KWallet)
|
||||
// throws PasswordAccessException for a *genuinely absent* credential,
|
||||
// so the strict lookup structurally cannot report "definitively
|
||||
// absent" there. Without this branch a fresh Linux/Windows install
|
||||
// could never mint the key and could never persist an account.
|
||||
//
|
||||
// Minting is only safe while there is no accounts.json.enc: with no
|
||||
// ciphertext on disk there is nothing a new key can orphan. Once the
|
||||
// file exists the strict contract applies and we propagate.
|
||||
if (getAccountsFile().exists()) throw e
|
||||
Log.w(
|
||||
"DesktopAccountStorage",
|
||||
"Keychain lookup failed and no accounts file exists; bootstrapping a fresh metadata key",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
val key = Base64.getDecoder().decode(existing)
|
||||
cachedKey = key
|
||||
return key
|
||||
}
|
||||
|
||||
// Definitively absent (or bootstrapping with nothing on disk): safe to
|
||||
// create and persist a fresh key.
|
||||
val key = ByteArray(AES_KEY_SIZE).also { SecureRandom().nextBytes(it) }
|
||||
secureStorage.savePrivateKey(METADATA_KEY_ALIAS, Base64.getEncoder().encodeToString(key))
|
||||
cachedKey = key
|
||||
|
||||
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowOverflow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -146,7 +145,6 @@ fun LivesSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
maxLines = 2,
|
||||
overflow = FlowRowOverflow.Clip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ranked.forEach { channel ->
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ class AccountManagerKeyLoginTest {
|
||||
val keySlot = slot<String>()
|
||||
val valueSlot = slot<String>()
|
||||
coEvery { storage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] }
|
||||
coEvery { storage.getPrivateKeyOrThrow(capture(keySlot)) } answers { keyStore[keySlot.captured] }
|
||||
coEvery { storage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers {
|
||||
keyStore[keySlot.captured] = valueSlot.captured
|
||||
}
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ class AccountManagerLoadAccountTest {
|
||||
storage = mockk(relaxed = true)
|
||||
// Return null so DesktopAccountStorage generates a fresh AES key
|
||||
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
|
||||
coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null
|
||||
tempDir = createTempDirectory("acctmgr-load-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ class AccountManagerLoadStateTransitionsTest {
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
|
||||
coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null
|
||||
tempDir = createTempDirectory("acctmgr-load-state").toFile()
|
||||
File(tempDir, ".amethyst").mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ class AccountManagerLogoutTest {
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
|
||||
coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null
|
||||
tempDir = createTempDirectory("acctmgr-logout-test").toFile()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ class AccountManagerNip46IsolationTest {
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
|
||||
coEvery { storage.getPrivateKeyOrThrow("account-metadata-key") } returns null
|
||||
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user