Replaces the `var publishDelegate` set-after-construction pattern with an
explicit `CashuWalletState.start(publish: suspend (Event) -> Unit)`.
Account now calls `cashuWalletState.start { event -> sendLiterallyEverywhere(event) }`
from its own init { } block, AFTER all field initializers complete.
Why this matters: the previous code launched the backfill + cache-live
collectors from inside the state's own init { } block. Those collectors
could (and would, for returning users) fire an auto-redeem during
Account's field-initializer phase — at which point `publishDelegate` was
still the no-op default AND `followPlusAllMineWithIndex` (which
sendLiterallyEverywhere depends on) wasn't initialized yet. The publish
would silently swallow or NPE. Gating all of start()'s work behind a
@Volatile started flag eliminates the window.
Also: `MintExceptionTest` (+4 tests) pins down the runtime-exception
contract of `MintHttpException` and the new `MintProtocolException` —
the latter is what callers branch on when distinguishing "mint refused"
from "HTTP failed". Kept simple so any future refactor that breaks the
hierarchy fails loudly here instead of silently in describeMintError.
24/24 NIP-60 jvm tests passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two small follow-ups to the audit refactor:
* recomputeUnspent: replace the broken `getOrPut { ... return@forEach }`
pattern (which short-circuited the outer loop on a single decryption
failure, skipping remaining tokens) with an explicit containsKey
guard. Decryption failures are now individually skipped without
affecting other tokens in the same pass.
* CashuWalletScreen: when the wallet opens and pendingQuotes (live
flow from CashuWalletState) is non-empty, automatically resume the
most recent kind:7374 by re-polling the mint and reopening the
receive dialog. Without this, a user who backgrounded the app
mid-mint would see no indication their pending invoice exists.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Addresses the critical findings from the post-implementation audit:
A1. State holder lives on Account, not the ViewModel
New CashuWalletState owns the wallet event, decrypted token contents,
history, mint-quote, and inbound-nutzap indexes. It's constructed on
Account and runs for the lifetime of the login session — so nutzaps
arriving while the user is on Home/DMs/etc. get auto-redeemed without
requiring the wallet screen to be open. ViewModel becomes a thin
presenter that forwards flows + holds per-flow UI state (mint quote
in progress, melt confirmation pending).
A2. Reactive observation via LocalCache.live.newEventBundles
The state object backfills once from cache.notes at construction time,
then receives incremental updates from the live new/deleted event
bundles for any NIP-60/61 event authored by us (or addressed to us
via #p for nutzaps). NIP-44 decryption results for kind:7375 events
are cached by event-id, so the per-refresh re-decrypt is gone (D2).
A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
redeemPendingNutzapsSerialized uses tryLock so a sweep already in
flight short-circuits any new triggers; subsequent cache updates
catch up via the next bundle.
A4. Mint-quote recovery on launch
pendingQuotes flow surfaces unfulfilled kind:7374 events whose
expiration hasn't passed and whose id isn't yet referenced with a
"destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
re-polls the mint for the original quote and rebuilds the flow.
B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
publish nutzaps where our assembler is actually listening.
B2. Subscription tracks the outboxRelaysFlow — when the relay list
changes, the assembler subscription is rebuilt with the new set.
B5. New MintProtocolException distinguishes "HTTP fine, protocol said
no" (e.g. melt state != PAID) from "HTTP error". Both surface
through describeMintError() (now top-level — C4).
B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
wallet pubkey before signing — saves a wasted mint round-trip when
the lock targets someone else.
B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
amount + fee_reserve so the UI confirms before paying; confirmMelt()
actually spends. No more silent fee acceptance.
C1. MintHttpClient + CashuMintOperations cached per mint URL via a
ConcurrentHashMap.
C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
before adding, with inline success / failure feedback.
C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.
C8. Dead .also { _ -> secretJson } removed from redeemNutzap.
D1. runCatching {}.getOrNull() callsites in the state holder now log
via Log.w("CashuWallet") so silent failures surface in logcat.
D5. CashuWalletQueryState made @Immutable + data class for Compose
stability hygiene.
Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).
All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Builds out the Cashu wallet beyond the scaffold: a complete mint
protocol layer, the four user-facing wallet operations (mint, melt,
send-as-token, redeem), and auto-redemption of inbound NIP-61
nutzaps. Wires the relay subscription so the wallet state syncs
across devices.
quartz/ — mint protocol layer (commonMain + jvmAndroid)
* nip60Cashu/mintApi/MintApiDtos.kt — Kotlinx Serialization DTOs
for NUT-00..06 (info, keys, mint/quote/bolt11, mint/bolt11,
swap, melt/quote/bolt11, melt/bolt11, checkstate). ProofDto
carries the optional NUT-11 witness.
* nip60Cashu/mintApi/MintHttpClient.kt — OkHttp + kotlinx-json
client bound to a single mint URL; surfaces MintHttpException
with the mint's detail string preserved for the UI.
* nip60Cashu/mintApi/CashuMintOperations.kt — combines BDHKE +
HTTP + amount splitting. Exposes requestMintQuote / mintProofs
/ swap / requestMeltQuote / meltProofs / redeemNutzap. Power-
of-2 amount split per NUT-00.
* nip60Cashu/mintApi/AmountSplit.kt — extracted into commonMain
for testability.
* nip60Cashu/p2pk/P2PK.kt — NUT-11 locked-secret format and
BIP-340 Schnorr witness signing.
* CashuProof gains an optional witness field.
amethyst/ — wallet ops + UI
* model/nip60Cashu/CashuWalletOps.kt — Nostr publishing layer
over CashuMintOperations:
- publishWalletEvents (kind 17375 + kind 10019 together)
- startMintFromLightning / checkMintQuote /
completeMintFromLightning (kind 7374 lifecycle + 7375 +
7376 + NIP-09 deletion of the quote)
- meltToLightning (pre-swap if needed, melt, change rollover,
delete sources, history)
- sendAsToken (swap to exact split, V4Encoder for cashuB,
rollover, history)
- redeemToken (inbound cashuA/B via swap)
- redeemNutzap (NIP-61 P2PK unlock + swap, history with
unencrypted "redeemed" marker per spec)
* service/cashu/v4/V4Encoder.kt — inverse of the existing
V4Parser; encodes proofs to cashuB strings for send.
* ui/screen/loggedIn/wallet/CashuWalletScreen.kt — adds four
action buttons (Receive / Send LN / Send Token / Redeem) with
AlertDialog-based flows that poll the mint quote, paste/copy
from clipboard, and surface mint errors.
* ui/screen/loggedIn/wallet/CashuWalletViewModel.kt — new mint
/ melt / send-token / redeem state machines, subscribes via
CashuWalletFilterAssembler on init (auto-syncs the wallet
across devices), observes the wallet note's flow for reactive
refresh, and auto-redeems any inbound kind 9321 nutzap that
isn't already marked redeemed in our kind 7376 history.
relay subscription
* commons/.../CashuWalletFilterAssembler.kt refactored into the
standard ComposeSubscriptionManager + SingleSubEoseManager
pair (matches the NWC pattern). Now driven by subscribe(query)
/ unsubscribe(query) calls from the ViewModel.
* RelaySubscriptionsCoordinator.cashuWallet exposes a singleton
assembler reachable as Amethyst.instance.sources.cashuWallet.
Tests (jvmTest)
* BdhkeTest — 7/7
* AmountSplitTest — 7/7 (NUT-00 vectors + sum invariants)
* P2PKTest — 6/6 (secret round-trip, witness verifies under
BIP-340, compressed + x-only acceptance)
Total: 20 new NIP-60 jvm tests, all passing. Both playDebug and
fdroidDebug compile clean.
Deferred (clearly bounded follow-ups):
* Sending nutzaps (kind 9321) from the zap picker UI — requires
integrating with the existing LN zap chooser surface. The
underlying P2PK locking primitives are in place.
* Recovering an interrupted kind 7374 mint quote on next launch
— current flow keeps polling while the dialog stays open.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.
UI
* AddWalletScreen is now a wallet-type chooser. The existing NWC
flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
is new: takes one or more mint URLs, auto-generates a separate
P2PK key for nutzap receiving (or accepts a pasted hex key), and
publishes a kind:17375 wallet event via the account's signer
using CashuWalletEvent.build(mints, privkey).
* CashuWalletScreen renders the wallet's mint list, total balance
in sats (summed across all unspent kind:7375 token events the
signer can decrypt, with rollover applied via the `del` field),
and a chronological history view sourced from kind:7376.
* WalletScreen surfaces the Cashu wallet as a card under "Your
Wallets" when one exists, so the Wallets entry point shows both
wallet kinds side by side.
Relay subscription
* CashuWalletFilterAssembler (commons) subscribes one filter per
relay covering kinds 17375/7375/7376/7374/10019 by author and
one targeting inbound kind:9321 via #p. Not yet wired into
Account.kt — the view path works because we feed our own writes
through cache.justConsumeMyOwnEvent. Cross-device sync requires
the assembler subscription wiring, which comes next.
Plumbing
* Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
registered in AppNavigation.
* CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
looking up the replaceable wallet event from LocalCache.
Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Implements blind Diffie-Hellman key exchange per NUT-00 — the
cryptographic core that lets a Cashu mint sign blinded messages
without seeing the underlying secret. Used by the upcoming NIP-60
wallet flows (mint, swap, melt) to issue and verify ecash proofs.
- hash_to_curve (NUT-00 try-and-increment, with Cashu domain separator)
- blind: B_ = Y + r·G
- unblind: C = C_ - r·K
- sign/verify: mint-side helpers used by tests and DLEQ-less
client-side proof validation.
All operations sit on top of the existing pure-Kotlin secp256k1
implementation in quartz/utils/secp256k1/, so they run on every KMP
target without JNI. Includes the official NUT-00 hash_to_curve test
vectors and a BDHKE round-trip with both the trivial (a=1, r=1) and
a random key.
7/7 jvm tests pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
When a note's only zap split recipient is the post author, the split is
redundant — the author already receives the zap. Skip rendering the row
in those cases by gating on a new `hasZapSplitSetupBesidesAuthor` helper.
ExoPlayer entered the ERROR state silently when a codec was missing or the
container/format wasn't supported, leaving a blank video area with no
recourse. Track the player error in MediaControllerState, render an overlay
with the error code, and offer an "Open in browser" button so the user can
fall back to the system browser for codecs the device can't decode.
style(nests): import TimeUtils in CreateNestViewModel instead of inline FQN
HIGH-1: import java.io.RandomAccessFile in MetadataStripper instead of
inline fully-qualified name
HIGH-2: catch AvifMetadataNotVerifiableException in the 6 ViewModels
that call MetadataStripper.strip directly (profile picture, emoji pack
list+display, bookmark group, nest, channel)
MEDIUM-1: tighten AvifAnimatedDecoderFactory.createAnimatedImageDecoder
annotation from @RequiresApi(P) to @RequiresApi(S); the outer guard is
already SDK_INT < S.
MEDIUM-2: replace the curried lambda DI seam in MetadataStripper with
a named fun interface (AvifExifReader).
MEDIUM-3: rename isGifUrl -> isAnimatedMediaUrl (MyAsyncImage) and
BaseMediaContent.isGif() -> isAnimatedMedia() (ZoomableContentView)
since both predicates now cover AVIF as well as GIF.
- AvifAnimatedDecoderFactory.isAvif now iterates a single brand list
with .any { rangeEquals(8, it) } instead of three || branches.
- MetadataStripper.inspectAvifMetadata dropped the outer defensive
try/catch; the inner catch already converts parse failures to
AvifMetadataNotVerifiableException and the rest of the function
cannot realistically throw.
- PreviewMetadataCalculator extracts the shared ImageDecoder allocator
+ exception path from decodeAvifBytes and decodeAvifFromUri into a
single private decodeAvif(source) helper.
- RobohashFallbackAsyncImage merges its identical Loading and Error
when branches into one via Kotlin's multi-value branch syntax.
- MediaCompressorTest drops a no-op MockKAnnotations.init(this) call
and the now-unused import; no @MockK fields exist.
fix(ui): default avatar contentScale to Crop, not Fit
fix(images): skip thumbnail cache for animated AVIF profile pictures
fix(ui): animate profile pictures regardless of URL extension
15 bite-sized tasks across 6 phases (A foundation, B upload pipeline, C animation
lifecycle audit, D test fixtures + instrumented tests, E manual on-device
verification, F ship). Each task has exact file paths, full test code, full
patch code, exact commands, expected output, and per-task commits.
Companion to amethyst/plans/2026-05-26-avif-support.md spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(amethyst): document strip-toggle-off AVIF EXIF leak as known limitation
docs(amethyst): document Desktop AVIF gaps from spot-check
docs(amethyst): record animated AVIF playback caveats from on-device testing
docs(amethyst): note API < 31 gallery-picker greys out AVIF (OS limit)
docs(amethyst): tighten API < 31 known-limitation with on-device findings
docs(amethyst): plan and design for AVIF instrumented tests
Tapping a chip silently failed if no installed app handled the
type-specific URI scheme (bitcoin:, ethereum:, monero:, etc.).
Surface that case through the existing toastManager so users know
to install a compatible wallet.
https://claude.ai/code/session_01R7kRziq14Hc22dPwAnZRAr
Closes the UX gap where a user who creates a pack via the in-app UI has no
path to add it to their NIP-51 kind-10030 selection without leaving the
pack-management screens.
- Wire DragAndDropTarget on avatar circle and banner area
- Image-only filter (jpg/png/gif/webp/avif)
- Visual drag-over feedback (primary border highlight)
- Fix avatar: only show placeholder icon when no image set
(previously overlay was visible behind the loaded avatar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both umbrelOS (via getumbrel/umbrel-apps#4962) and StartOS / Start9
(via Start9-Community/namecoin-core-startos) ship a self-hosted
Namecoin Core that this backend can target. Generalize the
help/strings so umbrel users discover the feature too.
No logic changes.
Replace the awkward small icon button with a full 100dp tappable circle.
Shows surfaceVariant background when empty, semi-transparent overlay with
centered upload icon when image is present. Spinner replaces icon during
upload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace plain text payment-target rows with a FlowRow of pill-shaped
clickable chips that carry a type-aware icon, brand color, uppercase
label, and a truncated address. Tap opens a type-specific URI scheme
(bitcoin:, lightning:, ethereum:, monero:, liquidnetwork:, dash:,
payto:// fallback) so wallets can actually pick up the intent; long-press
still copies the authority to the clipboard.
If a user's NIP-65 outbox advertises only relays that don't hold their
kind 0, profile fetching used to give up after EOSE on those relays.
filterUserMetadataForKey now widens to the account's indexer relays
once every outbox relay has either EOSE'd or is in cannotConnectRelays
and metadata is still missing. UserWatcherSubAssembler invalidates
filters on EOSE so the fallback re-evaluates without waiting for an
unrelated trigger.
Replace the up/down chevron IconButtons on each wallet card with a drag
handle, matching the pattern used across the relay-settings screens.
Reuses RelayDragState / rememberRelayDragState / draggableRelayItem /
relayDragHandle from relays/common — same gesture handling, elevation
animation, and swap-on-threshold behavior.
The handle and item modifier are only attached when there is more than
one wallet to reorder.
The wallet detail screen's Send, Receive and Transactions buttons navigated to
parameterless routes. Each destination created a fresh WalletViewModel with no
selection, so the action ran against `_defaultWalletId` (the account default)
instead of the wallet being viewed. Paying, invoicing, and listing
transactions could therefore go to the wrong wallet.
Parameterize WalletSend/WalletReceive/WalletTransactions with `walletId`,
plumb it through AppNavigation, pass it from WalletDetailScreen, and have
each screen call `selectWallet(walletId)` before operating.
Dropping both `authors` and `#p` from the kind-23195 subscription filter
fixed wallets that don't set those fields the way NIP-47 implies, but
broke purpose-built NWC relays (notably relay.getalby.com/v1) that use
`#p` as the routing key — without it the relay never delivers the
response to our subscription, so the wallet screen sits on a spinner.
Restore `#p: [client pubkey]` in the relay filter. Keep `authors` out
since that field was the one actually causing the broader interop pain.
Spec-compliant responses always carry the `p` tag, so adding it back
does not exclude any conforming wallet. End-to-end authenticity is
still enforced by NIP-04 decryption against the per-connection shared
secret and by the client-side author check in NwcPaymentTracker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug had four ingredients (per the kdoc on TorArtiNativeIntegrationTest).
We had one test for #1; now there's targeted coverage for each:
1) Native TorClient gets stuck (bad guards / dead circuits / expired
consensus) with no way to drop it in-process:
`destroy then re-initialize releases the state file lock cleanly`
(was already there — added exit-IP logging so the developer can eyeball
that the circuit actually changed across the destroy).
2) In-flight per-connection handlers holding Arc<TorClient> clones,
pinning the state file lock past destroy:
NEW `destroy aborts an in-flight SOCKS handler quickly`
Opens a SOCKS HTTPS request, lets the handler get into the data plane,
calls destroy() concurrently, asserts:
- destroy() returns within 3s (the budgeted abort+sleep window),
- the in-flight request thread dies within 5s,
- a fresh initialize on the SAME data dir succeeds afterward
(this is the actual regression net — pre-fix the orphaned handler's
Arc would keep the TorClient alive and the lock held).
3) stopSocksProxy *deliberately* preserves the running client so the
legitimate stop/start toggle is fast. We need to keep that path
working after the destroy/abort changes:
NEW `stopSocksProxy then startSocksProxy reuses the running TorClient`
Asserts the second startSocksProxy returns in < 5s — no re-bootstrap.
4) State / fd / memory leaks accumulating across many destroy/init cycles
(the watchdog can drive these forever):
NEW `survives multiple destroy then initialize cycles`
5 full cycles of initialize → startSocksProxy → fetch → destroy.
Logs per-cycle elapsed time + exit IP so degradation is observable
even when it's not yet a hard failure.
Plus two extra robustness tests:
NEW `proxies concurrent SOCKS requests in parallel`
5 in-flight HTTPS-via-SOCKS requests at once. Exercises the Rust
accept loop, HANDLER_TASKS retain-on-push, and Arc<TorClient> clone
independence under load.
NEW `destroy is idempotent`
destroy-without-init, double-destroy, init-after-double-destroy.
Cheap regression net for unwrap-on-None panics in the Rust shim.
All new tests gated by -Pamethyst.arti.integration=true alongside the
existing ones; the smoke test (`library loads and reports a version`)
still runs unconditionally on Linux x86_64. Total runtime for the slow
suite is ~10-15 minutes against Tor, depending on bootstrap luck.
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.
Zaps and DMs move real artifacts (money, private messages) to a Nostr
pubkey. Nostr has no global namespace, so "zap Alice" is ambiguous —
multiple users can publish the same display name. Four safeguards now
make it much harder for Gemini (or any agent) to misroute a write:
1. `expectedDisplayName: String?` on followUser / sendDm / zapUser.
Agent passes the name it understood; verb cross-checks that the
resolved profile's name / display name / NIP-05 contains it (or
vice-versa). Mismatch aborts with a typed error carrying the npub
and NIP-05 so the agent can re-prompt.
2. `requireFollow: Boolean = true` default on sendDm and zapUser.
Refuses to act on a pubkey the user doesn't already follow on
Nostr. Strongest guard against same-name impersonators — even if
the agent picked the wrong Alice, the user almost certainly isn't
following her. Override to false only when the user explicitly
approves acting on a stranger.
3. Updated kdocs instruct the agent to confirm with the user using
all three identity signals (display name + npub + NIP-05) before
invoking. The kdoc is what Gemini reads to learn the verb's
contract, so this is where the instruction goes.
4. searchProfiles now filters out hits whose NIP-05 claim explicitly
fails verification (the listed domain refuses to sign for that
pubkey). Network errors / no-claim profiles are kept (inconclusive,
not refutations). Verifications run in parallel with a 4s overall
budget; on timeout we surface all candidates rather than censor.
https://claude.ai/code/session_013NKVhEF2KqyCrV7ufaiQ6N
Tier 1 — 18 fast unit tests for the self-heal logic, virtual time only:
- Extracted TorBackend interface (status + start/stop/reset/resetWithCleanState),
TorService implements it. TorManager now takes a TorBackend by injection
rather than constructing a TorService itself.
- Extracted TorPreferencesPort (torType + externalSocksPort flows + load/save
bypass-approval). TorSharedPreferences implements it via forwarding properties.
- Injected ioDispatcher (default Dispatchers.IO) and nowMs clock (default
System::currentTimeMillis) so tests drive the 45s watchdog + 5-min cooldown
in milliseconds of virtual time.
- Tests cover: persisted-approval load, torType-change bypass clear,
approveBypassForOneHour, onNetworkChange (clear + reset + cooldown prime),
watchdog gentle-reset before first Active, watchdog full-reset after Active,
watchdog cancellation on Active, cooldown blocks within window + permits
outside, status routing for OFF/EXTERNAL/INTERNAL, sessionBypass forcing Off,
activePortOrNull mirroring.
- Uses UnconfinedTestDispatcher inside runTest — flowOn(ioDispatcher) +
WhileSubscribed cross-dispatcher channel needs eager dispatch for
MutableStateFlow.value updates to propagate through advanceUntilIdle.
Tier 3 — TorBootstrapInstrumentedTest scaffold (@LargeTest, @Ignore by default):
- Cold-start bootstrap: TorService.start → first { Active } within 120s.
- HTTPS round-trip: OkHttp via SOCKS to check.torproject.org, asserts IsTor:true.
This is the regression net for the rustls CryptoProvider install after the
Arti bump and for the destroy/handler abort race in the Rust shim.
- reset → re-start: verifies the state-file-lock is released so the second
TorService.start can re-create the TorClient cleanly.
- KDoc documents how to enable + run on a real device (the test needs Tor
network egress + 60–120s of wall-clock per case, hence default-Ignored).
No production behavior changes — only injection seams + interfaces.