mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
main
92
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fde0b7f689 |
fix: harden the Arti build gates and drop initialize()'s sentinel
Follow-up to the toolchain update, from an audit of that diff. Build script: - The NDK pin rejected machines that have the pinned revision installed. An exported ANDROID_NDK_HOME or ANDROID_NDK_ROOT short-circuited the search and then failed the revision check, and GitHub runners export both at their own bundled NDK. Every candidate is now checked against its own source.properties and a mismatch moves on, so the build fails only when the pinned revision is genuinely absent, listing what it found instead. - verify_jni_symbols printed missing exports and exited 0, so a library that would throw UnsatisfiedLinkError on every call could ship. It now fails the build, and checks only the ABIs this run built. - Both post-build checks now use the pinned NDK's own llvm-readelf and llvm-nm. The stamp check silently skipped on macOS, which has no readelf, and Apple's nm cannot read ELF at all, so the symbol check would have reported every symbol missing there. - The stamp check read its note through `readelf | grep -q`, the same SIGPIPE-plus-pipefail shape this branch removed from the symbol check. - $HOME is expanded with a default, so `set -u` no longer aborts before the "NDK not found" message in an environment without HOME. verify-reproducible.sh hashed every .so under jniLibs, so --release, which rebuilds arm64 only, hashed the untouched x86_64 library identically in both runs and reported the whole tree reproducible and matching the commit. It now hashes and diffs only the ABIs the run builds, and prints which those are. lib.rs: - initialize() signalled "already initialized" out of the JNI closure as an empty string, re-tested after it. A destroy() landing in between would let the empty string through as the data directory, which resolves to relative state/ and cache/ paths against the process working directory. The check now reads the whole Option outside the closure and no sentinel exists. - Corrected the comments claiming the error policy keeps a panic from crossing extern "C". It does not: the policy's panic arm runs through catch_unwind, which catches nothing under this crate's panic = "abort" profile. The Err arm, which is what the code relies on, is unaffected. README: the troubleshooting section still told readers to install cargo-ndk unpinned and to export ANDROID_NDK_HOME at an arbitrary revision, which was the exact way to trip the old gate. Verified: two clean builds byte-for-byte identical, both ABIs stamped r30, JNI exports present, 16 KiB alignment kept. JVM tier-3 smoke test green, and a scratch harness drove getVersion, setLogCallback, initialize, a second initialize on a live client (the reuse path the sentinel used to carry) and destroy over real JNI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW |
||
|
|
e32cadc250 |
build: update Arti to 2.6.0, jni to 0.22, NDK r30, Rust 1.98.1
Moves every pin in tools/arti-build forward and rebuilds all three shipped libraries from them. Arti 2.5.0 carried two medium-severity fixes that a client reaches in normal use, so the shipped 2.3.0 was the reason to do this now: TROVE-2026-24, where a malicious directory mirror crashes the tor-netdoc parser and eventually stops tor-dirmgr, and TROVE-2026-27, an inefficient algorithm an attacker can drive into a CPU stall. Pins: - Arti 2.3.0 -> 2.6.0 (arti-client / tor-rtcompat 0.42 -> 0.46). New MSRV is 1.91, satisfied by the pinned toolchain. - Android NDK 27.3.13750724 (r27d) -> 30.0.16248370 (r30), the current LTS. clang and lld move 18.0.4 -> 21.0.0. - rustc 1.94.1 -> 1.98.1. - jni 0.21 -> 0.22. - Cargo.lock regenerated, so every transitive dependency moves to its latest semver-compatible release. cargo-ndk was already on the pinned 4.1.2. Source changes the upgrades required: - Arti 2.4.0 made every TorClient constructor return an Arc<TorClient> and dropped Clone from TorClient, so the wrapper no longer wraps it itself. - jni 0.22 splits the FFI environment pointer (EnvUnowned) from the API type (Env), which is only borrowed inside a closure. Native methods now acquire it via with_env and map failures through an ErrorPolicy instead of unwinding out of extern "C", which aborts. initialize() reads everything JNI-owned up front and resolves through Option<String>, because the policy default for jint is 0, the value that API reports as success. GlobalRef became Global<JObject>, thread attachment takes a closure (which also scopes a local-reference frame per log line), and the method name and signature are encoded at compile time via jni_str! / jni_sig!. Verification: - verify-reproducible.sh: two clean builds byte-for-byte identical, JNI symbols exported, 16 KiB LOAD alignment kept, both ABIs stamped r30, same libc/libm/libdl dependency set as before. - JVM tier-3 smoke test passes against the rebuilt host shim, and a scratch harness drove setLogCallback, getVersion, initialize and destroy through real JNI: log lines arrive over the migrated callback and initialize returns 0. - cargo audit: rsa 0.9.10 (RUSTSEC-2023-0071) remains, with no fixed version published upstream; it arrives via ssh-key-fork-arti and needs RSA private key operations, which a client without hosted onion services never does. The event-listener unsound and spin yanked warnings are gone. Not verified here: the network-dependent integration tier and the on-device instrumented test. This container blocks most outbound TCP (directory authority port 9131 among them), so Tor circuits time out regardless of which library is loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW |
||
|
|
b52f5651de |
build: pin the Arti NDK and rebuild libarti_android.so on r27d
The committed libraries were built with NDK r25b (25.1.8937393) while the build docs told everyone to install r27. Nothing pinned the NDK, so build-arti.sh took the first directory matching ~/Android/Sdk/ndk/*/. The NDK supplies the clang that compiles Arti's C dependencies (ring, zstd-sys, libsqlite3-sys) and the lld that links the cdylib, so its revision is baked into the output bytes exactly like rustc's is. The reproducible-build promise therefore only held by accident of which NDK a verifier happened to have installed. - Pin the revision in ANDROID_NDK_VERSION (27.3.13750724, r27d) and resolve it by name. A different revision now fails the build with the sdkmanager line that fixes it, instead of silently producing unverifiable bytes. - Record the verified cargo-ndk release in CARGO_NDK_VERSION. Warning only: it wraps the NDK rather than generating code. - Re-read .note.android.ident after each build, so the output has to carry the pinned NDK's stamp to pass. - Rebuild both ABIs on r27d (clang 18.0.4, lld 18.0.4, rustc 1.94.1). verify-reproducible.sh: two clean builds byte-for-byte identical, all 8 JNI symbols exported, 16 KiB LOAD alignment kept, same libc/libm/libdl dependency set as before. - Fix verify_jni_symbols reporting every exported symbol as missing: piping nm into `grep -q` per symbol lets grep exit first, nm dies of SIGPIPE, and `set -o pipefail` fails the pipeline. Pre-existing, reproduces on the old binary too. - Docs: the 16 KiB page alignment comes from rustc's Android target spec, not from "NDK 25+". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW |
||
|
|
a87222c51f |
refactor: split Compose UI out of commons into a new commonsUI module
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose resources, markdown and desktop Compose as dependencies, dragging ~40 MB of UI/Skiko jars into every `amy` distribution. This moves every Compose-dependent file into a new KMP module, `:commonsUI`, that `api`-depends on `:commons`; `:commons` keeps only the Compose runtime (stability annotations + snapshot state) and lifecycle-viewmodel. Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split is a build-graph boundary and no consumer import changed. 236 files were `git mv`'d (composables, icons, robohash, theme, Coil fetchers, the `@Composable` relay-client entry points, `composeResources`, and the tests that exercise them). Two headless files needed surgery instead of a move: `GalleryParser` lost a vestigial foundation `@OptIn`, and the `LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of `PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds` and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels depend on them. `amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`, `geode` and `marmotBench` do not. commons' androidMain gains an explicit androidx.core KTX dep it previously got transitively through Compose UI. CI, crowdin, the icon-font tools and the escaping hook point at the new composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills document the boundary. A plan doc under commons/plans records the classification method and follow-ups. Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites; both verifyKmpPurity gates; the cli runtime classpath no longer resolves Compose UI, material3, Skiko or Coil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD |
||
|
|
3b20aede9a |
test(search): pin the local search engine against a live relay's answers
Adds a recorded corpus of 64 real events from search-staging.brainstorm.world — a vespa-relay, the same software this token language was ported from — and 18 tests over it: 8 on the engine in :commons, 10 on `LocalCache.filter` in :amethyst. Recorded, not fetched. `tools/search-parity/fetch_fixtures.py` drives `amy fetch` against the relay by hand; the tests read the committed fixture, so `./gradlew test` stays offline and the pre-push hook does not depend on somebody else's uptime. The fixture stores each case's filter FIELDS rather than a prebuilt filter, so the test rebuilds the Filter in view of the reader and a wrong rebuild cannot quietly make the assertions vacuous. What the relay can and cannot referee turned out to be the whole design, and it was measured rather than assumed: - **NIP-01 it can.** Given kinds, #t, since and until there is exactly one right answer, and across all 64 events our matcher agrees with the relay about every one it chose to return — 0 violations. That is now a hard assertion, with a converse test so it cannot pass by matching everything. - **NIP-50 it cannot.** This relay retrieves topically: asked for `bitcoin` it returns a block-height summary that never says "bitcoin". Eight of 64 events carry no literal occurrence of the term that fetched them. Asserting our substring matcher reproduces that would encode someone else's semantic expansion as a requirement on a lexical one — a test that fails on correct code. So text results are deliberately not compared, and the divergence is pinned as a range instead: zero would mean the relay turned lexical and the comparison should be rewritten, a quarter would mean we regressed. The fixture uses the `include:spam` lens, which waives the web-of-trust gate. Also measured: it makes the corpus reproducible, where `observer:<pubkey>` ties every answer to one account's moving trust graph — but it does not make retrieval lexical, and in fact widens the divergence from 4 events to 8 by letting more topical matches through. The LocalCache tests cover what the relay knows nothing about and where the bugs actually were: the regular/addressable split, the viewer-policy predicate composing with rather than replacing the filter, and the result cap keeping the newest — the ordering whose absence let `take(limit)` run before the sort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV |
||
|
|
f3a72e26e0 |
fix(strings): drop tools:ignore from compose resources
`tools:` attributes are an Android-lint construct. They arrived with strings moved out of amethyst/src/main/res/, whose <resources> root declares xmlns:tools -- composeResources roots do not, so the prefix was unbound and the XML malformed: 97 occurrences across 49 locale files, none of them declaring the namespace. Nothing was visibly broken, because Compose parses namespace-unaware and drops the unknown attribute (no .cvr contains it). But nothing should rely on that, and Android lint never runs on composeResources, so the attribute carried no meaning there either. migrate.py now strips tools: attributes as it moves each element, so the remaining migration waves cannot reintroduce them. Also fixes a hazard in fix_escapes.py found while doing this: quote-unwrapping is NOT idempotent. Android wraps a value in quotes to protect whitespace, but once \" has been converted to ", a legitimately quoted value is indistinguishable from a wrapped one, and a second pass strips the real quotes -- it silently damaged 10 `import_follows_tips` translations before this was caught. Unwrapping is now opt-out via --no-unwrap-quotes for repair runs over already-migrated files, and documented as run-exactly-once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo |
||
|
|
810e342bc1 |
fix(strings): convert Android escaping when moving strings to compose resources
Strings moved from res/values/ into composeResources/values/ kept Android's escaping, which Compose does not interpret the same way, so the login screen rendered `Don\'t have a Nostr account?` with a literal backslash and the terms line showed stray quotes. Compose 1.11.1 handleSpecialCharacters resolves only \uXXXX, \n and \t (and collapses \\). It leaves \' \" \? \@ alone, and renders Android's quote-wrapping -- used to preserve leading/trailing spaces, e.g. " Following" -- literally. Convert those four escapes and unwrap the quotes, leaving \n, \t, \uXXXX and \\ untouched so Compose still resolves them. 3,717 entries across 56 locale files; translations were hit far harder than English (Uzbek 964, French ~340 per variant, Turkish ~208) because those languages use apostrophes heavily. migrate.py now applies the same conversion as it moves each element, so the next wave cannot reintroduce this; fix_escapes.py repairs what is already migrated and is idempotent. Verified on a Pixel 9 emulator: "Event is loading or can't be found in your relay list" now renders with a real apostrophe, and no visible text node contains a literal backslash escape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo |
||
|
|
8e833d9b9e |
Merge origin/main (Amethyst icon font)
Icons.kt conflicts: kept main's AmethystIconGlyph calls with this branch's migrated Res.string content descriptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV |
||
|
|
f54194f70d |
perf(icons): draw Amethyst's own icons from a generated icon font
Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises its paths into a cached graphics layer per instance, so the feed re-rasterised the same glyphs once per card. A font glyph is a blit from the shared text atlas, shared across every call site for free. tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing call sites; generated outlines land within a few units of Google's own. Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2% identical-arm noise floor, gate 18/18/18 cards): frame duration P90 -10.7% frame overrun P90 -17.4% DrawReactions 114.8 -> 76.7 ms/iteration For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this captures ~84% of the available headroom. It supersedes the shared-VectorPainter approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid cross-size cache thrashing; glyphs are atlas-shared automatically. Artwork is unchanged: this converts Amethyst's existing vectors rather than substituting Google's glyphs. Verified on device by pixel comparison -- unconverted icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing between the text and vector rasterisers. Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the build script now detects a stroke and skips the icon, leaving Following, Zap and ZapSplit on their ImageVectors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo |
||
|
|
7c6a18ee52 |
feat(i18n): bulk-migrate 2,565 mechanically-safe string keys to commons
Every key whose usages are all composable stringRes/stringResource
calls, with no XML references, no bare %s/%d, only %N$s/%N$d args and
no inline markup, moves from the app's res/ to commons Compose
resources (~105,800 locale entries across 57 locales, translations
preserved byte-for-byte for Crowdin). 568 app files repointed to
Res.string via the stringRes bridge overloads.
The app keeps 1,866 keys that are genuinely Android-bound: ctx-based
call sites, Int-typed id storage (maps/whens), @string/ XML references,
and non-positional format args.
Tool fix folded in: Crowdin emits some entries with attributes before
name= (xmlns:ns0=... name="key") - the extraction regex now matches
any attribute order; the six entries the old pattern missed (zh,
nl-rBE, es x account_backup_tips{2,3}_md) are relocated, and 11 in-file
duplicates from keys that already existed in commons are removed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
|
||
|
|
8128507c12 |
feat(i18n): build the Wave-3 strings bridge and migrate the first key
- commons/ui/StringRes.kt: the Compose-resources twin of the app's stringRes family (composable, formatted, plural, and suspend loadStringRes variants). Thin delegates - compose-resources caches parsed locale files process-wide, so the Android-style LruCache is unnecessary here. - App StringResourceCache.kt gains stringRes(StringResource) overloads delegating to the bridge, so a file can mix migrated and unmigrated keys under its existing single import; migrating a key is just R.string.x -> Res.string.x. - tools/strings-migrate: moves keys from app res to commons composeResources across all locales byte-for-byte (both trees are Crowdin-managed with the same android mapping); refuses keys using bare %s/%d since compose-resources only formats positional args. - Exemplar: profile_banner - the single string blocking the ui/layouts cluster - migrated across 57 locales, all 7 call-site files repointed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV |
||
|
|
2415565ebf |
refactor: replace org.json with kotlinx.serialization in source files
Sweeps the last org.json usages out of the Kotlin sources and moves them to kotlinx.serialization's JSON tree API (already the project standard): - nappletHost: bridge/broker envelope handling in NappletHostActivity, NappletHostService, NappletBrowserActivity, NappletBrowserService and NappletFaviconSniffer now parses with Json.parseToJsonElement via new total helpers in JsonEnvelope.kt (absent/mistyped fields degrade to empty/false instead of throwing, matching the old opt* semantics). Adds the kotlinx-serialization-json runtime to the module (tree API only, so no serialization plugin needed). - amethyst embed IME relay: EmbeddedImeBridge parses ime.* envelopes with JsonObject accessors; RemoteImeView and EmbeddedTabLayer build their outgoing envelopes with buildJsonObject. - tools/ime-test: drops the now-stale "org.json is stubbed in JVM unit tests" rationale from the README and shim-events.mjs header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW |
||
|
|
b266f1c403 |
fix(tor): keep retrying a stuck bootstrap, and stop calling a downloading Tor "Active"
A brand-new install could stop connecting to Tor entirely. Not slowly —
permanently: exactly two bootstrap attempts, then silence. Reproduced on a
Samsung SM-T220 (benchmark build, fresh install, log in, 150s offline, network
back): Tor never reached Active in the following 600s, no profile, no relay
lists, "Feed is empty." With the fix, the same scenario recovers at net+51s.
Root cause: on a native bootstrap timeout `TorService.start()` deliberately
leaves status at Connecting and delegates the retry to `TorManager`'s watchdog,
but that watchdog was `status.transformLatest { if (Connecting) { delay(45s);
emit() } }` — it fires once per Connecting *span*, and a timeout produces no
status change, so no new span ever began and the signal was never re-armed.
Nothing else covered it: `onNetworkChange` fires only on a networkId *change*
and `AppModules` drops the first non-null one, so even a network arriving from
offline did not rescue it.
Lifecycle fixes:
- the watchdog re-arms while stuck instead of firing once per span;
- it skips an attempt that is genuinely running, so a reset can no longer
queue behind the blocking JNI call and tear down a client that just
succeeded;
- an install that has never bootstrapped retries on a 30s cooldown rather
than the 5-minute one meant to protect working state;
- `service.start()` is no longer awaited before `emitAll(service.status)`, so
the app observes Connecting when the attempt starts rather than when it
ends (on device the watchdog moved 105s -> 90s);
- a hard init failure and port exhaustion no longer set the terminal Off,
where neither the watchdog nor the failure dialog arms; both leave
Connecting to be retried. The init path also no longer wipes all Arti data
on any failure, which turned a transient "no network" into a lost guard
sample — with an escalation after 3 fruitless gentle resets so corrupt
state on a fresh install is still recovered.
Arti now bootstraps on demand. `create_bootstrapped` blocked the JNI call — and
the Kotlin lifecycle lock it holds — for the whole directory download (12.6s to
51.7s measured), during which `activePortOrNull` was null so every Tor-routed
dial fell back to 127.0.0.1:9050, the Orbot default, where nothing listens.
`create_unbootstrapped_async` + `BootstrapBehavior::OnDemand` returns in 124ms
and lets each stream wait for its own circuit. It does not make first paint
faster — the download is the real gate — but it removes the dead-port window
and the up-to-60s lock hold that also made "turn Tor off" appear frozen.
That forced a state split, and it is the load-bearing part. `Active` was
carrying two facts that used to coincide: "proxy routable" and "circuits
buildable". Android's `TorServiceStatus` gains `Bootstrapping(port)` plus
`socksPort` / `isFullyBootstrapped`, so callers state which they mean instead of
matching a variant that looks right for both. Commons gets the accessors only —
the desktop backend drives an external Tor and never sees the window, and a
variant nothing emits is dead weight.
Watchdogs are judged on forward progress, not elapsed time. Measured cold
downloads ran 12.6, 13.4, 14.0, 15.6, 17.9, 19.7, 19.8, 20.0, 34.4 and 51.7s on
one device and network, so no fixed patience separates slow from stalled: short
enough kills healthy downloads — and a reset discards the partial consensus, so
firing early can stop one ever finishing — while long enough sits uselessly on a
hang. A new `bootstrapProgressPermille()` exports `as_frac()`, and a download is
reset only after 60s with no movement at all, never with a state wipe. Device
run: a 51.7s download completed untouched where the previous code would have
reset and wiped its cache at 45s. `blocked()` is deliberately unused; Arti
documents it as best-effort and warns it misreports in both directions.
Readiness is read live (`bootstrap_status().ready_for_traffic()`) rather than
latching the one background `bootstrap()` result, which would report "not
bootstrapped" forever against a Tor that a later stream had already recovered.
`canDial` and `TorCircuitHealthTracker.isTorActive` gate on readiness, not
routability. Dialling on routability alone put ~190 relays into a backoff that
is never forgiven — the port is identical either side of Bootstrapping -> Active
so the transport never "changes" and `resetBackoff()` never runs — and it cost
nothing to wait: time-to-first-socket was unchanged by dialling early (n=3).
Both jniLibs ABIs rebuilt and verified reproducible from an upstream clone
(arm64 b53d20d2..., x86_64 36d41793...). `build-arti.sh`'s JNI symbol check
gained the new exports; it is a hardcoded list, and without them it silently
passed a stale .so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
|
||
|
|
1de9c242e9 |
fix(embed): don't let a readonly field's mirror be typed into
Audit of the branch, two findings. `readonly` stops the *user* editing a field, not scripts: the shim writes through the native value setter, so any text that reaches the host mirror is applied to the page and fires an `input` event no native browser would. Cut and Paste were refused at their call sites, but that misses a hardware keyboard (tablets, DeX, Chromebooks) — whose Ctrl+V goes straight to `onTextContextMenuItem`, bypassing the wrapper — and autofill. Configure the mirror as TYPE_NULL for a readonly field instead: `onCheckIsTextEditor()` is then false, so there is no InputConnection to type through at all, while selection and Copy — the half native does offer on a readonly field — keep working. The selection toolbar's item list was rebuilt on every recomposition of the tab layer, which recomposes on every IME inset change, bounds report and console line, for a toolbar only shown during a selection. Remembered on the readonly flag, so it allocates once and keeps a stable identity the overlay can skip on. Adds tools/ime-test/shim-events.mjs, a regression test that drives the shipped shim in headless Chromium and asserts the page→host envelopes. It fails on main (7 cases, including "no ime.wantkb — the keyboard could never come back") and passes here. A JVM unit test cannot cover this: the host parser runs on Android's org.json, which the unit tests stub out, so it would pass without parsing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS |
||
|
|
37f9c5ad85 |
fix(embed): restore the keyboard on tab return, and none for readonly
Device testing on a tablet (SM-T220, Android 14) walked every text-field focus
path in the embedded tab. Two of them were wrong.
**The tab-return restore never fired.** `noteKeyboardOnLeave` sampled
`WindowInsets.imeAnimationTarget > 0 && isMirroringPageField()` inside
`onDispose`, on the assumption that the dispose runs before anything hides the
IME. It does not: by the time it runs, the nav transition has already snapped
the animation target to 0 *and* taken focus off the view, so both halves read
false and every tab was recorded as "left without a keyboard". Instrumented on
device, the leave was `keyboardUp=false mirroring=false imeBottomPx=0` for all
three nav-rail routes, so `pendingRestore` was false on every return and a tab
left mid-typing always came back with the keyboard down.
Ask the mirror what it *intends* instead of sampling the window at teardown:
`RemoteImeView.keyboardWanted` is set when we raise the keyboard and cleared
when the field blurs or the user puts the keyboard away, so it still reads true
while the view is being torn down.
Telling "user dismissed it" apart from "the tab went away" is what that clearing
needs, and there is no key hook for it — Android 13+ routes the IME's back
dismissal through OnBackInvokedCallback, so `onKeyPreIme` is never called (tried
first; it silently never fired and the tab over-restored). The two cases are
distinguishable by what else is true when the insets collapse, measured on
device:
dismiss: imeBottomPx=0 hasFocus=true mirrors=true
tab switch: imeBottomPx=0 hasFocus=false mirrors=false
so a collapse while we still mirror the field is the dismissal, and a switch
never looks like one — the focus loss lands in the same frame as the insets.
**A readonly field raised a keyboard that cannot type.** `isEditable` in the
shim never looked at `readOnly`, so the host took the field and showed a
keyboard whose keystrokes the page discards. Native, checked side by side in the
full-screen WebView on the same page, focuses a readonly field without a
keyboard. The field stays "editable" for selection (native offers handles and
Copy there); only the raise is suppressed, via one guard in `raiseKeyboard` so
the fresh-focus, tap-doorbell and tab-restore paths are all covered.
Verified on device, 27/27 checks: fresh focus raises for text/textarea/
contenteditable/email/number/password/search/tel and not for disabled or
readonly; BACK-dismiss then re-tap restores; re-tapping a field whose keyboard
is up keeps it; leaving mid-typing restores on return (~1s, 5/5 runs) while a
dismissed tab stays down; typing after either restore lands in the right field
at the right caret; page-background tap blurs; address-bar keyboard never arms
an embed restore; and the full-screen round trip leaves the embed IME working.
`tools/ime-test/keyboard.html` is the page those checks drive: every field type
plus a live focus readout and an event log that marks taps on an already-focused
field, which is the case with no DOM event of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b7f55d8697 |
chore: add a runtime perf probe for the embedded vs full-screen WebView
The embedded tab and the full-screen browser are the same WebView in the same `:napplet` process with byte-identical WebSettings, so a site whose JS feels slower in the embed is being slowed by the host, not by its configuration. `perf.html` measures which host effect it is: page visibility (a page Chromium treats as hidden gets ~1Hz timers and no rAF), raw CPU throughput (the renderer inherits its scheduling class from whichever process hosts the WebView — the embed's is a plain bound service, the full-screen one is top-app), forced-layout cost, rAF rate, long tasks, and input-delivery latency measured from the platform's own event timestamp. Open the same URL in both hosts and compare the summary line; the README says what each divergence points at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS |
||
|
|
592fd8f542 |
test: pin the amy argument surface the buzz-agent path depends on
Flag names and error strings are the contract between amy and the wrapper scripts (and their operators), but nothing type-checks them: renaming --base-ref or rewording an error breaks callers while compiling clean. This harness pins the ones the buzz-agent path drives. 14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM tests use: the shared `repo-naddr-or-coordinates` positional on git browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted while --base-reff is rejected on both agent up and agent serve, and the no_relays detail. Confirmed to discriminate: 14/14 on this branch, 10/14 when built against main-upstream's BuzzCommands, with the four failures printing the old misleading "no relays: pass --relays ws://…" for input the user did pass. Worth recording from writing it: a bare word like `garbage` is *accepted* as a relay (the normalizer prepends wss://), so the realistic trigger for the empty set is a pasted http:// url — which is what the cases use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL |
||
|
|
fcbd9d727b | test: add a regression harness for workflow-ship.sh | ||
|
|
30c7dbc2f3 |
Code review:
1. `title="$(git log -1 … | cut …)"` aborts the whole script under `set -euo pipefail` when the worktree has no commits 2. `base_branch` only fell back to `main` |
||
|
|
4700e51d82 | fix: add explicit default case to the workflow-ship branch guard | ||
|
|
8e79abaa82 |
feat(cli): fewer steps to connect a Buzz agent — accept-from-channel, agent up, doctor
Collapses the operator setup from an 8-flag command + two hand-written scripts to essentially two commands, without removing any of the safety. - `buzz workflow run` gains `--accept-from-channel` (parity with the job scheduler): scope intake to the channel's kind-39002 roster instead of pasting every teammate key. `--worktree` now defaults to the current directory. - Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh → agent+commit; workflow-ship.sh → push+PR after the gate), split around the approval gate the way agent-exec.sh is the one-shot ungated version. - `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the channel (the relay's only one, or --channel), defaults worktree/intake, extracts the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The only thing it can't default is the human approver. - `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist into a green/red report: gh authenticated, token can write to the repo, default branch protected against force-push, worktree clean. Exits non-zero if not. - cli build: set duplicatesStrategy on processResources (the explicit resources.srcDir re-adds the default root, which now doubles the bundled scripts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6 |
||
|
|
a7d5fbfa8a |
feat(buzz): reference --exec wrapper that turns jobs into PRs
tools/buzz-agent/agent-exec.sh — the last mile from the scheduler to a live channel. Honors the `amy buzz agent serve --exec` contract: reads the task on stdin, runs a coding agent (Claude Code by default, or any $AGENT_CMD) inside the job's git worktree, verifies a diff exists, commits, pushes the `claude/job-*` feature branch, opens (or reuses) a PR, and prints the PR URL as the job result (kind-43004); any failure exits non-zero → job error (kind-43006). It never touches the default branch and never force-pushes — merge stays a human action on GitHub. README documents the load-bearing guardrails, since Buzz enforces none of them: a PR-only fine-grained token (Contents + Pull requests write, nothing else), branch protection on the default branch (require PR + review + CI, block force-push/deletion), and scoped intake (--accept-from) + agent tool allowlist. Verified end-to-end against a throwaway repo with a stubbed gh + agent (happy path pushes the branch and prints the PR URL; no-change path errors cleanly). Plan doc follow-up #3 marked done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6 |
||
|
|
5001d4a27a |
test(notifications): cover Buzz reactions in the e2e harness + document Buzz DM
- run.sh: add a Buzz-style bare reaction check (kind-7 with an `e` tag but no `p` tag) asserting it still lands on the Reactions channel, and a note that Buzz DM isn't auto-triggered. - README: coverage row for the bare reaction, plus a "Buzz DM (manual)" section with the channel-setup steps (kind-39000 `t=dm` + participant, then post kind-9/40002) since participant-routing needs cached channel metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r |
||
|
|
853229a2c6 |
test(notifications): add Layer-3 end-to-end harness (amy + adb)
An agent-runnable script that drives the whole push-notification pipeline on a
real device/emulator: a second identity publishes each notification kind through
`amy` to the account on the phone, and the script reads back the notification
shade over `adb` to assert the right notification on the right channel — then
exercises cold-push metadata enrichment (title flips from raw pubkey to display
name in place) and dismiss-on-read (opening the note clears the tray entry).
Covers mention, reply, reaction, repost, picture, and DM; prints PASS/WARN/FAIL
and exits non-zero on any hard failure. tools/notification-e2e/{run.sh,README.md}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
|
||
|
|
dd6e7e2852 |
fix(tor): map Arti errors to accurate SOCKS reply codes
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.
This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.
socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.
RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.
Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
500de62841 |
fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger
Tapping the pending OTS pill now shows a toast explaining that the attestation is waiting to be stamped into the Bitcoin blockchain (new ots_info_pending_description). The OpenTimestamps glyph gets a near-full-em content box — its fine outline read much lighter than Material's solid shapes at the standard 80..880 bounds — and HeaderPill icons go from 11dp to 13dp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 |
||
|
|
41ef1d89b3 |
feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill
Bake the official OpenTimestamps stamp logo (traced from opentimestamps/logo vector.svg; monochrome outline, tinted at render time like every other glyph) into the Material Symbols subset font at U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts the traced SVGs in custom/ into TrueType glyphs and is invoked by subset.sh after pyftsubset, so font regenerations keep them. With the logo identifying the pill, drop the verbose "OTS:" prefix: the pill now reads icon + "2y" (or icon + "Pending", new R.string.pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 |
||
|
|
6bb2f8045d |
build: pre-merge audit fixes for reproducibility work
Bugs / inconsistencies found while reviewing the branch for merge: - dependenciesInfo comment falsely claimed Play "still derives this data server-side, nothing is lost." Not true: includeInBundle=false means the .aab carries no dependency metadata, so Play Console's dependency-insights / SDK-vulnerability alerts go unpopulated (uploads still succeed). Corrected the comment and the BUILDING.md framing (it called the blob "the one remaining blocker" when the Arti .so was the bigger one). - Version-bump workflow was broken: the README told you to run `build-arti.sh --clean` to refresh Cargo.lock, but the build is now --locked (fails on a stale lock) and the clone moved to the canonical /tmp path. Added a dedicated `--regen-lock` mode (clone + cargo generate-lockfile, no NDK needed) and pointed the docs at it. Verified it reproduces the committed lock byte-for-byte. - verify-reproducible.sh: new helper that builds twice and diffs to prove byte-for-byte reproducibility; uses portable sha256 (sha256sum/shasum) and plain `sort` so it runs on macOS too. - README verify recipe referenced paths that only resolved from the repo root while telling you to cd into tools/arti-build — replaced with the helper. - rust-toolchain.toml listed four Android targets but only two ABIs ship a .so; trimmed to match (check_prerequisites adds any other on the fly). - BUILDING.md: documented that the bundled Arti .so is reproducible-from-source and that secp256k1/webrtc are version-pinned Maven prebuilts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU |
||
|
|
ed3a893d9d |
build: build Arti at a canonical path for cross-environment reproducibility
Empirical finding: with the toolchain pin, locked deps, and --remap-path-prefix all in place, two host builds of libarti_android.so at the *same* path are byte-for-byte identical, but two builds at *different* paths still differ — not in any embedded string (no path leaks into the binary) but in the order rustc lays out functions/data, which it derives from the real on-disk artifact paths. --remap-path-prefix only rewrites embedded strings, not that internal ordering. So compile in a fixed location (/tmp/amethyst-arti-build, overridable via ARTI_REPRO_DIR) in both build-arti.sh and build-arti-host.sh. Any checkout then produces matching bytes, which is what lets F-Droid / a verifier build at the same canonical path and reproduce the shipped .so. This mirrors how Rust libraries are reproduced elsewhere (F-Droid builds Rust at a fixed path too). Corrects the README, which previously implied path remapping alone gave path-independent output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU |
||
|
|
f7a15a8407 |
build: make the Arti (Tor) native build reproducible
The libarti_android.so shipped in the APK is the one binary we compile ourselves, and it was the remaining blocker to a verifiable build: a Rust cdylib is only reproducible when the compiler, the dependency graph, and the embedded build paths are all pinned. None were. Pin all three: - rust-toolchain.toml pins rustc (rustup auto-installs it + the Android targets), so codegen is stable across machines. - Cargo.lock is now generated and committed (501 packages); both build scripts run `cargo --locked` so transitive versions can't drift. - repro-env.sh (sourced by build-arti.sh and build-arti-host.sh) rewrites host-specific absolute paths with --remap-path-prefix, disables incremental compilation, and sets a fixed SOURCE_DATE_EPOCH derived from the Arti tag. With these, an independent rebuild of the pinned tag reproduces the committed .so bit-for-bit, which is what lets F-Droid / Zapstore verify it from source instead of trusting a prebuilt blob. README documents the pins and a two-path build-and-diff verification recipe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU |
||
|
|
dec1e0bec1 |
feat(embed): selection loupe + native-parity fixes for embedded text selection
Builds out host-drawn text selection for embedded napplet/nsite/browser surfaces toward native parity, and fixes the bugs found while exercising it. - Magnifier loupe (#4): EmbeddedMagnifier + provider-side pixel capture (EmbeddedMagnifierProbe) shipped over IPC for both embed paths; the caret/selection handles drive it via OnMagnify. - SelectionUiState: single source of truth for the overlay show/hide rules (insertion caret / in-field range / page-text range + dragging/scrolling). - EmbeddedSelectionDrag: suspends the nav drawer's edge swipe while a handle is dragged (auto-scroll #9). Bug fixes: - No more overlay blink on word-select: the shim's selection-reveal scrolls (a textarea auto-scrolling to show a forming/re-asserted range) no longer trip the hide-on-scroll path, and the hide self-heals instead of being re-armed indefinitely. - RemoteImeView debounces the range-lost signal so a transient collapse that gets re-asserted doesn't flicker the handles/toolbar. - Focusing a field clears any page-text selection (shim + host), so the stale page handles/Copy bar no longer linger above — and stop stealing drags from — the field overlays; also cancels any in-flight scroll-hide on focus. - Caret insertion-handle drag now actually moves the caret: read the pointer delta with positionChangeIgnoreConsumed() before consuming, so the value isn't zeroed by our own consume (or the sandbox surface consuming the move). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8232cd50a3 |
test(embed): add IME/selection test harness + document how to run it
tools/ime-test/index.html is a single-page input+textarea harness with an on-page log timestamping focus/selection/input/composition events, paint latency, long-tasks, and main-thread blocks — the instrumentation used to pin the erase / caret-jump / first-letter-freeze bugs and what we'll use to profile the magnifier. README documents serving it (python http.server on 8765, 10.0.2.2 for emulator / adb reverse for USB) and opening it as an embedded tab via the in-app browser address bar. Plan gets a matching "How to test" section. Dev tool only — nothing under tools/ ships, so the [ImeDiag] strings stay out of src/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a2eae42c07 |
feat(napplets): app-store-style card + icon manifest tag; demote capabilities
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse screen) to look like an app entry instead of a manifest dump: square app icon (with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short description, and an Open button. The technical details users don't care about — declared capabilities, Blossom servers, source URL — move behind a tap-to-expand "What it can access" disclosure; capabilities are still re-confirmed at the consent prompt when actually used and remain fully manageable in the permissions screen. Add an `icon` tag (NIP-5A/5D) end-to-end: - quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an icon param on all four site/napplet build() factories (+ round-trip test). - amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output. - card: renders the icon via Coil, monogram fallback when absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde |
||
|
|
d60a618653 |
feat(amy): list an author's napplets / nsites
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the author's root + named manifests (15129/35129 for napplets, 15128/35128 for nsites), keep the latest per identifier, and emit a summary of each (kind, d, title, description, path count, servers, requires/aggregate, event id, created_at). Thin assembly over ctx.drain + the quartz manifest accessors. Harness README notes `amy napplet list` for enumerating what you've published. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde |
||
|
|
7bc95d836c |
feat(amy): nsite/napplet serve (local preview); drop standalone publish.sh
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`: fetch the manifest and serve its content over a local HTTP server, resolving each request through quartz StaticSiteResolver (blob downloaded from Blossom and sha256-verified per request, same as the device host) with SPA fallback to index.html. Lets you open a published site/napplet in a browser to confirm it loads and routes. (Static content only — a napplet's window.napplet.* runtime still needs the Amethyst host; documented in the command + harness README.) Implemented as thin cli glue (StaticSiteServe) over the resolver + commons BlossomClient + the JDK HTTP server. Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the single source of truth; the harness README documents publish + serve via amy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde |
||
|
|
ece1f43975 |
feat(amy): publish napplets and nsites (ship a directory)
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site or napplet directory can be shipped to Nostr in one command, building on the new CLI/Blossom infrastructure. - commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half — walks a directory (or single file), content-addresses each file, BUD-02 signed-uploads it via BlossomClient, and maps it to an absolute web path (/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags. - cli StaticSitePublish: thin shared flow — uploads via the commons publisher, hands the path tags to a kind-specific builder, signs with the account key, and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds 15129/35129 (aggregate + requires already added by the quartz builder). - nsite/napplet `publish` verbs wired into their routers. Test harness README now recommends `amy napplet publish tools/napplet-test`, keeping publish.sh as a no-amy fallback. Unit test covers the path mapping; cli + commons build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde |
||
|
|
b0332c9ff3 |
test(napplet): add on-device test harness (napplet + publish script)
A self-contained napplet (tools/napplet-test/index.html) that calls every window.napplet.* API and renders each result on screen, for verifying the NIP-5D host end to end on a real device — including the new identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and resource.bytes nostr: paths. publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D named-napplet event (kind 35129) via nak; README documents the flow and a per-feature verification checklist. Tooling only — no app code or deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde |
||
|
|
d9066572cd |
test(tor): add repeatable on-device networking scenario suite
The 60s bootstrap timeout this branch adds is invisible to every JVM test — it only manifests against a real radio. Add a device-driven harness that drives the network transitions that have historically wedged Tor and asserts the lifecycle invariants from logcat, so the behavior can be re-verified whenever Arti is bumped or the Tor management path changes. tools/tor-network-tests/run.sh — adb-driven runner, one function per scenario, PASS/FAIL per check, restores a clean network state on exit: - cold_start Active reached + no pre-ready dial storm (the #3223 gate) - offline_bootstrap empty cache + airplane: asserts the bootstrap is bounded ('bootstrap timed out' logged), then recovers on restore. This is the regression test for this PR — it FAILS on a build without the timeout (create_bootstrapped blocks 95s+). - wifi_cellular WiFi->Cellular handover recovers to Active - airplane offline pauses relays cleanly; restore recovers - pause_resume backgrounding winds relays down (~30s); resume reconnects README.md documents each scenario's rationale, device setup, when to re-run (new Arti version, TorService/TorManager/dial-gate changes), and how the suite relates to TorManagerTest / TorCircuitHealthTrackerTest / the instrumented test. Verified on a Pixel emulator (WiFi + Cellular): all scenarios pass on this branch; offline_bootstrap fails on main (no timeout), confirming the suite discriminates fixed from broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
69b8ba9f23 |
fix: bound Arti bootstrap with a 60s timeout so a hostile network can't wedge Tor
A self-heal reset()/resetWithCleanState() (and the lifecycle serialization added in the previous commit) can only recover Tor if initialize() actually returns. ArtiNative.initialize() calls TorClient::create_bootstrapped, which on a hostile network (unreachable guards, wiped consensus) retries internally for many minutes. While it blocks it holds lifecycleMutex, so the watchdog's reset can never run — Tor stays wedged at Connecting. Wrap create_bootstrapped in a 60s tokio::time::timeout. On timeout the future is dropped (tearing down the half-built client) and initialize() returns -4; the JNI ABI is unchanged (still one String arg), so the checked-in CI host .so and TorArtiNativeIntegrationTest keep working without a rebuild. TorService treats -4 specially: drop the init flag and leave status Connecting (don't wipe+retry inline under the lock, don't go Off) so TorManager's self-heal watchdog resets and re-inits on its own cadence, and connectionFailure can still surface the "use regular connection" dialog. Rebuilt libarti_android.so for arm64-v8a + x86_64. Verified on device: a no-network cold-start bootstrap timed out at exactly 60s (previously hung 7+ min), released the lock, and on network restore the watchdog re-init'd and Tor reached Active. Addresses #3225. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e39ea55fd6 |
test(tor): tier-3 integration — JVM host build of Arti, smoke + bootstrap tests
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.
Three tests in TorArtiNativeIntegrationTest:
1. `library loads and reports a version` — always-on smoke check. Loads
libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
bump, a missing JNI symbol export, a forgotten rebuild on this path).
Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
build-arti-host.sh rebuild step.
2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
-Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
→ OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
Regression net for the rustls CryptoProvider install we added after the
v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.
3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
the SAME data dir with initialize again, verify it succeeds without a
"state file already locked" error and that traffic still flows.
Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
Cargo-builds the wrapper crate for the host target (x86_64-linux on most
dev machines, but the script maps macOS / arm64-linux too) and copies to
amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
-Djava.library.path so System.loadLibrary("arti_android") finds the
checked-in host .so. Also forwards -Pamethyst.arti.integration so the
opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
most common dev/CI host (~6 MB).
Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
builder.storage().permissions().dangerously_trust_everyone() so Arti's
fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
directories have unusual UIDs (typical in containers). Android keeps its
strict default — the app's private filesDir is already sandboxed by the OS.
Compiled-out on Android, so the shipped Android .so is functionally
unchanged.
Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
on actual relay I/O in this container because outbound TCP egress is
restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
with unrestricted outbound — see the test kdoc.
|
||
|
|
9a2adf091d |
chore(tor): bump Arti to v2.3.0
Wins: reduced GeoIP memory usage (moved off heap), CircuitClosed→NotConnected error change (affects our handler error paths), DATA-cells-on-closed-streams fix, and a flow-control sidechannel mitigation bug fix. Nothing here directly addresses the stuck-Tor recovery work in the prior commits, but it's a clean overdue bump while we're in this code. Wrapper changes required by the bump: - arti-client + tor-rtcompat: 0.41 → 0.42 to match the new crate versions shipped with arti-v2.3.0. - arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider implicitly (changelog: "if the application fails to install a rustls CryptoProvider, tor-rtcompat no longer installs one itself"). Add a direct `rustls = "0.23"` dep with the `ring` feature and `install_default()` it inside INIT_ONCE before runtime creation — otherwise create_bootstrapped panics on the first TLS handshake. Keeping `ring` (same as 2.2.0 effectively used) rather than 2.3.0's new default `aws-lc-rs`, which is heavier on Android and has known build.rs pain on aarch64-linux-android. Heads-up for the next bump: arti-v2.4.0 will explicitly wrap TorClient in Arc rather than implicitly having Arc-like semantics. We already wrap explicitly so the migration is a no-op aside from potential Arc<Arc<...>> cleanup. Rebuilds: libarti_android.so for arm64-v8a + x86_64. |
||
|
|
c3ddd4e7be |
fix(tor): audit fixes — first-bootstrap grace + tighten destroy() race
Audit of
|
||
|
|
db378a105c |
feat(tor): self-heal — drop & rebuild Arti on network change and stuck Connecting
When Arti's in-memory TorClient gets into a broken state (bad guards from a previous network, dead circuits, expired consensus held in memory), nothing short of a process restart used to recover it: the JNI exposed initialize / startSocksProxy / stopSocksProxy but no way to drop the TorClient, and the Kotlin side gated initialize behind a one-shot AtomicBoolean. force-stop preserved the on-disk arti/state/, toggle-off-then-on only re-bound the SOCKS listener on the same broken client, and wiping app data was the only way out. Rust side - New JNI Java_..._ArtiNative_destroy: aborts the SOCKS listener task, aborts all in-flight per-connection handlers (each holds an Arc<TorClient> clone that would otherwise pin the state file lock), waits 500ms, drops the static ARTI_CLIENT. Next initialize() call creates a fresh client and re-bootstraps. - Track handler JoinHandles in HANDLER_TASKS so destroy can abort them; cull finished ones on each accept to keep the Vec bounded. Kotlin side - TorService.reset() / resetWithCleanState() — drop the native client, flip initialized=false. The second variant also wipes arti/state/ on disk to rebuild guard selection from scratch. - TorManager.resetEpoch StateFlow is now part of the status combine; bumping it re-fires the INTERNAL branch which calls service.start() and runs full Arti re-init. - onNetworkChange (wired from ConnectivityManager.networkId distinctUntilChanged) now calls service.reset() + clears the persisted bypass approval + bumps the epoch. Replaces the previous clearSessionBypass() which only touched the in-memory bypass half. - Self-heal watchdog: when status sits at Connecting for >45s (before the 60s connectionFailure dialog), calls resetWithCleanState. Rate-limited to one per 5 minutes so a permanently broken network doesn't loop us. onNetworkChange primes lastSelfHealAtMs so a slow legitimate post-network-change bootstrap doesn't get a second reset on top of itself. Rebuilds: libarti_android.so for arm64-v8a + x86_64 (NDK 27, 16KB-page aligned). |
||
|
|
19588be84b |
build(perf): trim build outputs for hooks, CI, and the shipped font
Headline numbers from a fresh `./gradlew assemble`:
- :commons material_symbols_outlined.ttf 11M -> 409K (subset to the 210
codepoints actually referenced from MaterialSymbols.kt)
- :amethyst per-ABI APKs no longer built in CI (-PdisableAbiSplits=true);
~600M of stripped_native_libs intermediates skipped per run
Changes:
- .git-hooks/pre-push runs only :amethyst:testPlayDebugUnitTest plus jvmTest
on the KMP modules instead of `./gradlew test`, which was compiling all six
amethyst variants (play/fdroid x debug/release/benchmark)
- amethyst/build.gradle adds three opt-in fast-build flags:
-PdisableAbiSplits=true skip per-ABI APK splits
-PdisableUniversalApk=true skip the universal APK output
-Pamethyst.skipMapping=true disable R8 (release+benchmark)
Defaults are unchanged; release pipelines must not set skipMapping.
- .github/workflows/build.yml uses gradle/actions/setup-gradle@v4 with
cache-read-only on PRs / cache-write on main, drops --no-daemon, collapses
the Android job to a single Gradle invocation (lint + focused debug unit
tests + assembleBenchmark with -PdisableAbiSplits=true), and globs both
APK naming patterns for the upload step.
- tools/material-symbols-subset/{subset.sh,README.md} regenerates the font
from upstream + MaterialSymbols.kt; run after adding/removing icons.
Verified: ./gradlew :amethyst:help with all three new -P flags parses cleanly,
and ./gradlew :commons:jvmJar --rerun-tasks succeeds with the subsetted font
(commons-jvm jar shrinks from ~13M to 2.9M).
https://claude.ai/code/session_01YSmkagXXN5AwGcY4upiUh1
|
||
|
|
b0698e0a66 |
test(cli): move tests/ out of tools/, separate marmot vs dm
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:
cli/tests/
├── lib.sh # shared logging + result tracking
├── headless/helpers.sh # shared amy_a / amy_json / assertions
├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
│ ├── marmot-interop.sh
│ ├── marmot-interop-headless.sh
│ ├── setup.sh
│ ├── tests-create.sh
│ ├── tests-manage.sh
│ ├── tests-extras.sh
│ └── patches/
└── dm/ # NIP-17 DM interop (amy ↔ amy)
├── dm-interop-headless.sh
├── setup.sh
└── tests-dm.sh
Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
headless/setup.sh and the DM setup at headless/setup-dm.sh, which
implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
`stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
two when the harness lived under `tools/`); patches/ is now a sibling
of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.
cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
|
||
|
|
112801ba82 |
test(cli): amy↔amy NIP-17 DM interop harness
Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.
Six scenarios:
dm-01 text round-trip (kind:14) in both directions
dm-02 `dm list` surfaces prior exchange with `type:text` discriminator
dm-03 strict kind:10050 refuses sends to an inboxless recipient
(surfaces the `no_dm_relays` error JSON)
dm-04 `--allow-fallback` opts into NIP-65 read / bootstrap chain
(asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
dm-05 file message reference mode (kind:15) — send-file with
--key / --nonce, verify recipient decodes url + key + nonce + mime
dm-06 no-flag `dm list` advances `state.giftWrapSince`; second call
returns an empty `messages` array
Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.
No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.
Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
|
||
|
|
a4e031353a | Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs | ||
|
|
3279c2463a |
fix(mls): use filtered direct path in UpdatePath per RFC 9420 §7.9
Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:
GroupEventHandler.add: ERROR Failed to apply commit:
UpdatePath node count (1) doesn't match direct path length (2)
Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).
Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.
Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.
Fix it throughout:
- RatchetTree.filteredDirectPath: new helper returning parallel
(filteredDirectPath, filteredCopath) with the empty-resolution
entries dropped. Uses the existing `resolution()` helper, which
already handles unmerged_leaves per §4.1.2.
- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.
- MlsGroup.commit (sender): emit one staged path node per filtered
level; encrypt path secrets using the filtered copath; patch parent
hashes into the filtered positions only.
- MlsGroup.processCommitInner (receiver): the UpdatePath node index
aligns to the filtered direct path, so the common-ancestor lookup
must find the filtered index to pick the right ciphertext + copath
resolution. Use the unfiltered index only for counting KDF steps
to the root (path_secret chain advances one step per unfiltered
level regardless of filtering — the chain is continuous; filtering
only decides which levels emit a UpdatePathNode, not how many KDF
steps separate them).
- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
Map each filtered node back to its unfiltered level so the
preUpdateSiblingHashes lookup (indexed by unfiltered level) still
resolves. This makes the parent_hash chain agree with §7.9.2 and
with what openmls computes on the other side.
- MlsGroup.verifyParentHash: short-circuit on empty filtered path
rather than empty unfiltered path.
New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.
Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.
|
||
|
|
c4e7e0d9b4 |
fix: don't render Marmot reactions/deletions as chat bubbles
WhiteNoise threads its kind:445 payloads across three inner kinds: kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The Amethyst ingest pipeline was routing every inner event into the group chatroom feed, so a kind:7 reaction rendered as a chat bubble whose only content was the emoji, quoting the liked message via the target `e` tag. That looked identical to a threaded reply. The actual kind:9 reply from `wn messages send --reply-to` never arrived, which made the two look swapped in the UI. Three changes to untangle this: - MarmotGroupList.addMessage/restoreMessage now skip inner events with kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`. The reaction is still consumed by LocalCache so it attaches to the target note's reaction row, and the deletion still revokes that reaction — they just don't appear as standalone bubbles. - LocalCache.computeReplyTo learned to derive thread parents for `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies; without this the reply bubble had no quote context in the feed. - tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes its `reply_to` field through clap v4, which renames snake_case to kebab-case by default. The script was passing `--reply_to`, which clap rejected; the `|| true` + redirected stderr hid the error and no reply was ever published. Use `--reply-to`. https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32 |
||
|
|
6f3abac3b2 |
fix(marmot-interop): peel .result wrapper when reporting Test 10's B view
Post-v0.2 wn wraps `--json groups show` output in {"result": {...}},
so `jq '{name, epoch}'` was reading the top level where those keys
don't exist — Test 10 always logged "B state: {name: null, epoch: null}"
and the operator couldn't tell whether the race produced the expected
converged state on wn's side.
Peel the .result wrapper before extracting the fields; same pattern
already used everywhere else in the script that parses wn JSON.
|