Three callers were still bypassing PrivacyRouter and would route Tor-only or
direct-only regardless of the picker:
- Coil ImageLoader (AppModules): called
okHttpClients.getHttpClient(shouldUseTorForImageDownload(it)), which
collapses to a boolean and can't pick I2P. Now uses
roleBasedHttpClientBuilder.okHttpClientForImage(it) so images route through
the user's preferred clearnet transport and hidden-service hostnames
hard-pin.
- ExoPlayer pool (PlaybackService): had separate poolNoProxy / poolWithProxy
named fields and bound them to getDynamicCallFactory(useProxy: Boolean) —
i.e. with-proxy was always the Tor-proxied client. Reshaped to a
poolsByPort: Map<Int, MediaSessionPool> keyed by the SOCKS port returned
by proxyPortForVideo(url). DualHttpClientManager gains getHttpClientForPort
and PortBasedCallFactory to resolve the right OkHttpClient (direct / Tor /
I2P) live based on port.
- Nostr relay websocket builder (AppModules): only consulted torEvaluator,
which has no notion of .i2p. Now hard-pins .onion to Tor and .i2p to I2P
(throwing BlockedRouteException via the fail-closed contract when the
matching daemon isn't ready) and falls through to torEvaluator for
clearnet relays — the existing TorRelayEvaluation per-relay-class booleans
stay as-is for clearnet routing.
Replaces the inline IOException thrown by DualHttpClientManager.getHttpClient
when a hidden-service URL is requested while its matching daemon is off.
The new exception extends IOException so existing HTTP error paths (Coil,
OkHttp call factories, etc.) propagate it unchanged, but carries the
BlockReason so future UI work can surface a clear "Enable Tor / I2P to view
this content" hint instead of just a broken-image placeholder.
DualHttpClientManager.blockedException(...) now returns BlockedRouteException
rather than a bare IOException; the messages move into the typed exception's
companion.
Tests cover the message wording (mentions Tor/.onion and I2P/.i2p) and pin
the factory's return type so the typed information can't be silently widened
back to a bare IOException without breaking the test.
Surfacing this to the UI (snackbar on image-load fail, etc.) is left to a
follow-up — that work is cross-cutting (Coil event listeners, NIP-05 error
paths, etc.) and orthogonal to the routing decision itself.
All ad-hoc HTTP traffic now consults PrivacyRoutingFlow → PrivacyRouter:
images, videos, URL previews, NIP-05 lookups, Money operations, uploads, push
registration. Each call computes a PrivacyRoute (Direct / Tor / I2p /
Blocked) and asks DualHttpClientManager for the matching OkHttpClient,
which in turn picks the right SOCKS-attached client or — for Blocked
hidden-service routes — throws IOException so the request fails closed
instead of silently leaking to the clearnet.
Constructor switched from TorSettingsFlow to PrivacyRoutingFlow. AppModules
swaps the wiring; the flow construction order now puts privacyRouting
before the builder.
Legacy shouldUseTorFor* booleans are kept but now return true iff the route
would actually end up on Tor — they no longer drive routing themselves.
External method-references in AppModules (OtsResolver, etc.) keep working
with the same semantics they had before for clearnet calls.
Push registration still routes through the URL_PREVIEW role (closest analogue
to the old trustedRelaysViaTor global flag, which had no URL to consult).
That's fine for now — trusted-relay push services live on the clearnet and
hard-pin behavior is unchanged for hidden-service push URLs.
I2pManager: EXTERNAL-only mirror of TorManager. When the persisted I2pType is
EXTERNAL it emits Active(externalSocksPort); OFF and INTERNAL both surface as
Off (no embedded daemon in this branch). Exposes activePortOrNull as a
StateFlow<Int?> with the same shape Tor uses, so the http client managers can
keep the proxy-port wiring symmetric.
DualHttpClientManager + DualHttpClientManagerForRelays:
- New constructor param i2pProxyPortProvider: StateFlow<Int?> (defaults to a
closed null flow so existing tests / callers compile unchanged).
- Third StateFlow<OkHttpClient> built against the I2P SOCKS port.
- New route-aware methods:
getHttpClient(route: PrivacyRoute): OkHttpClient
getCurrentProxyPort(route: PrivacyRoute): Int?
Direct → no-proxy client, Tor → Tor-proxy client, I2p → I2P-proxy client,
Blocked → throws IOException with a user-readable reason so the call fails
closed instead of silently leaking through the clearnet.
- Existing boolean-based methods (getHttpClient/useProxy) untouched.
AppModules wires i2pManager.activePortOrNull into both http managers. No
caller switched onto the new methods yet — that's the next chunk
(RoleBasedHttpClientBuilder reshape).
Same Privacy Options screen as before, now also surfaces:
- A three-way "Privacy Network" picker (Direct / Tor / I2P) for clearnet
traffic. Hidden services aren't affected — .onion / .i2p still hard-pin to
their matching daemon (and fail closed via PrivacyRouter when it's off).
- An I2P engine + per-feature toggle section mirroring the existing Tor
section. Only OFF and EXTERNAL are offered in the picker until an embedded
daemon ships; persisted INTERNAL is treated as OFF in the UI but kept in the
enum so a future commit can light it up without a migration.
- Save action posts all three settings — Tor, I2P, preferred transport — to
their separate DataStore-backed writers in one click.
New UI pieces:
- I2pDialogViewModel — mirror of TorDialogViewModel, no preset DSL since
there's no canonical I2P configuration yet
- I2pSettingsBody — composable mirror of PrivacySettingsBody, shares
SwitchSettingsRow with the Tor body
- PrivacyTransportPickerViewModel + PreferredTransportPicker — global picker
- I2pType.resourceId — Android string-id extension matching TorType.resourceId
Strings: i2p_* per-feature toggles, i2p_off/internal/external, i2p_socks_port,
privacy_clearnet_transport (+ direct / tor / i2p labels).
UI is wired against the live flows from AppModules (torPrefs / i2pPrefs /
privacyPrefs from the previous commits). No HTTP routing change — those flows
still go through TorManager only; the new toggles only have routing impact
once DualHttpClientManager grows tri-state in the daemon chunk.
Read-side seam that snapshots TorSettingsFlow + I2pSettingsFlow + the
preferredClearnetTransport StateFlow into a PrivacySettings, then delegates
to PrivacyRouter.route. Gives the I2P daemon manager and the eventual HTTP
routing rewrite a single stable API to consult instead of poking every flow
themselves.
DualHttpClientManager is not touched in this commit — that grows to tri-state
in the chunk where the I2P daemon lands a real proxy port.
AppModules:
- Constructs `privacyRouting` alongside roleBasedHttpClientBuilder
- Uses torPrefs.value + i2pPrefs.value + privacyPrefs (added last commit)
No callers wired yet — this is the seam those callers will move onto.
Mirrors the existing Tor persistence stack so I2P settings + the global
clearnet-transport preference survive process restart.
New on Android:
- I2pSettingsFlow — StateFlow-per-field shape matching TorSettingsFlow,
including the propertyWatchFlow that the prefs class listens to.
- I2pSharedPreferences — DataStore-backed load/save with `i2p.*` pref keys,
debounced-save wired to propertyWatchFlow.
- PrivacySharedPreferences — single MutableStateFlow<PrivacyTransport> for
preferredClearnetTransport, persisted under `privacy.preferredClearnetTransport`.
Kept separate from Tor/I2p prefs because the decision spans both transports.
AppModules:
- Parallel-load i2pPrefs and privacyPrefs alongside torPrefs in async/await pairs
so cold-start blocking time stays at ~max() not sum().
- Lazy accessors mirror torPrefs/uiPrefs.
No routing wired yet — RoleBasedHttpClientBuilder still consumes torPrefs only.
That swap is the next chunk.
Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor
and I2P at the same time. Both daemons can still run side-by-side so .onion
and .i2p hidden services stay reachable independently, but only one transport
carries clearnet traffic at a time.
Routing model:
- .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback)
- .i2p → I2P required; Blocked when I2P is OFF (no clearnet fallback)
- clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active
transport; that transport's own per-feature toggle decides this request;
otherwise Direct
Commons:
- Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so
fail-closed has somewhere to land — callers must surface Blocked instead of
silently leaking over clearnet
- PrivacySettings drops `features`, adds preferredClearnetTransport
- I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p /
profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p /
mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the
preferred clearnet transport
- PrivacyRouter rewritten to the new model
- Delete FeatureTransportChoices and TransportChoice
- Keep FeatureRole as the per-request hint that selects which toggle to read
Tests: PrivacyRouterTest rewritten to cover the new outcomes, including
both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and
that the non-preferred transport's toggles have no effect on clearnet.
GitHub's macos-13 runner image is being retired. Drop the x64 macOS
legs from both the desktop and CLI release matrices; macos-14 arm64
continues to ship the macOS builds.
- ktlint 1.8.0 changed how rule providers are loaded, and spotless 8.4.0 ends up handing the engine an empty ruleProviders set — so every file fails with IllegalArgumentException: A non-empty set of 'ruleProviders' need to be provided.
Measured the actual Kotlin daemon peak RSS during a full cold compile
of :amethyst:compilePlayDebugKotlin (which transitively compiles
:quartz, :commons, :quic, :nestsClient as well, in parallel after the
earlier parallel-mode change). Peak RSS was ~6.3 GB at the previous
12g/4g setting, meaning heap usage peaked around 4-5 GB plus
metaspace and native memory.
Drops to -Xmx8g -XX:MaxMetaspaceSize=2g, which:
- Cuts committed-virtual-memory ask from 18 GB (Gradle 6g + Kotlin 12g)
to 14 GB, comfortably under typical CI runner RAM and well under the
15 GB available in this dev environment.
- Frees ~4 GB of headroom for the OS file cache, which speeds up
classpath snapshot I/O on incremental builds.
- Leaves clear headroom over peak usage so GC pressure stays low; we
re-measured cold compile after the change at 3m30s vs 3m34s before,
well within run-to-run noise.
- Peak Kotlin daemon RSS at the new setting: 4.96 GB, confirming the
daemon right-sizes itself rather than pinning the ceiling.
If a much larger codebase target lands later (e.g. iOS framework
compilation in this same daemon), bump back up — the value is not
sacred, it just shouldn't request more than the host can afford.
The :ammolite module contained no production Kotlin/Java sources (just
a manifest, build.gradle, and proguard stubs) and no module in the
codebase imports com.vitorpamplona.ammolite.*.
Removes:
- ammolite/ directory (5 files)
- :ammolite project include in settings.gradle
- implementation project(':ammolite') from :amethyst
- androidTestImplementation project(':ammolite') from :benchmark
- :ammolite:testDebugUnitTest from CI workflow and pre-push hook
- -keep class com.vitorpamplona.ammolite.** rules from
:amethyst, :commons, and :desktopApp proguard files
- Stale references in CONTRIBUTING.md, CLAUDE.md, and the
gradle-expert skill dependency-graph doc
Small build-graph win: one fewer module to configure, compile, lint,
and spotless-check on every build, and one fewer unit-test target in
both CI and the local pre-push hook.
Auto-generated equals/hashCode would compare the ByteArray-list of
addresses by reference identity — a footgun no caller needs. No code
uses ==, copy, componentN, or hashing on records, so the class is now
plain with no synthesized methods.
The legacy `.json` reclaim was running in the constructor, which fires
on Application#onCreate (main thread) — a strict-mode regression vs the
prior no-op constructor. Moved into load(), which is documented as
background-only.
Also wraps the tmp-file write/rename in a try/finally so a writeRecords
crash partway (corrupt record, disk-full mid-write) or a copyTo
fallback can't leave an orphaned `.tmp` sibling behind.
Replaces Jackson-based JSON serialization with a compact big-endian
binary format (magic + version + per-record host/ip bytes) so cold-start
load/save is ~5-10x faster and the blob shrinks from ~55 KB to ~25 KB
for the ~700-host workload.
DnsCacheRecord now carries raw `ByteArray` addresses so the persistence
boundary uses `InetAddress.address` / `InetAddress.getByAddress(byte[])`
on both sides — no string formatting or literal re-parsing on the hot
path. `SurgeDnsStore` validates magic, version, and length-bounded
counters; corrupt or truncated blobs are deleted and ignored. The
constructor reclaims the legacy `dns_cache_v1.json` sibling on first
run.
Kotlin 2.x removed the classpath-snapshot incremental-compilation
strategy in favor of ABI snapshots, which are always on. The flag is
now a deprecation warning at configuration time. Drop it.
Parallel + build-cache + bumped Kotlin daemon metaspace remain.
Halves cold compile time for compilePlayDebugKotlin on this machine
(6m25s -> 3m26s, 47% reduction):
- org.gradle.parallel=true lets quic/nestsClient/commons compile
concurrently once quartz is done, instead of serially.
- org.gradle.caching=true lets warm cross-clean builds reuse task
outputs.
- kotlin.incremental.useClasspathSnapshot=true narrows the incremental
recompile blast radius after dependency-jar changes.
- Bumps Kotlin daemon MaxMetaspaceSize 3g -> 4g for headroom under
the Compose IR phase across ~2.8K @Composable functions.
Up-to-date and same-file incremental times are unchanged (~3s and ~4s)
since those weren't bottlenecked by these settings.
Configuration cache is NOT enabled: amethyst/build.gradle:12 runs
'git rev-parse --abbrev-ref HEAD' at configuration time, which is
incompatible with the configuration cache. Migrating that to a
ValueSource is a separate change.
RFC 1034: `localhost.` and `localhost` are the same name — the trailing
dot just marks the FQDN form. Without this, an upstream answer of
127.0.0.1 for `localhost.` (or `relay.localhost.`) would get filtered as
poison, breaking user-configured local relays addressed in FQDN form.
SurgeDns was faithfully caching whatever the system resolver returned —
including 127.0.0.1 / ::1 / 0.0.0.0 — for 24-48h plus an on-disk
snapshot. A single bad answer (captive portal, ad-blocker DNS, transient
VPN hiccup) could leave the user unable to reach any non-loopback relay
for days: connection attempts go to their own loopback, fail, and the
next lookup re-resolves through the same source.
- Filter loopback/any-local addresses out of lookupAndCache and restore,
unless the hostname is itself a loopback name (localhost, .localhost
subdomains, or 127.0.0.1 / ::1 literals) so user-configured local
relays keep working.
- Mark cache dirty when restore drops poisoned on-disk entries so the
next save rewrites the snapshot without them — otherwise the bad
entries would be re-restored on every cold start.
Moves the SurgeDns persisted snapshot from a SharedPreferences blob
under /data/data/.../shared_prefs to a plain JSON file under cacheDir.
The snapshot is pure perf data — if the OS evicts it under storage
pressure, the resolver just falls back to sync getaddrinfo and rebuilds
as lookups happen, which is the correct trade-off for a cache.
Writes go through a sibling .tmp + rename so a crash mid-write can't
leave a half-written blob behind. AppModules also deletes the legacy
amethyst_dns_cache SharedPreferences on next launch so the old store
doesn't linger.
The join/leave-request buttons launched a raw scope.launch(Dispatchers.IO)
and called account.signer.sign() directly. When the signer threw —
TimedOutException from a Samsung-A53 / Android-16 Amber prompt the user
didn't respond to in 30s, or any other SignerException — the failure
escaped the composable's scope and was reported as an uncaught crash
instead of being toasted/logged like every other signing operation.
Route both buttons through accountViewModel.launchSigner so the same
SignerExceptions handling that every other signing entry point uses
applies here too.
ChatroomListKnownFeedFilter.feed() iterated account.publicChatList.flow,
a Set<ChannelTag>. ChannelTag uses identity equality (it's not a data
class) and the set is built from raw tag parsing, so a channel list
that names the same channel id twice (e.g., the same channel appearing
in the public section and the encrypted private section, or repeated
with different relay hints) produces multiple ChannelTag entries for
the same channel. Each one resolved to the same newest Note via
getOrCreatePublicChatChannel(it.eventId), so the feed contained the
same Note twice and the chat list LazyColumn crashed with "Key
PublicChannelLazyKey(channelId=...) was already used".
Use the sibling flowSet (Set<HexKey>, already deduped by event id),
which is the same source filterRelevantPublicMessages reads from.
rememberForeverPagerState persists currentPage across tab-count changes.
When the user toggles off one of the conditional Home tabs (New Threads,
Conversations, Everything), tabs.size shrinks but pagerState.currentPage
is still pointing at the removed slot, so Material3's
TabIndicatorOffsetNode reads tabPositions[currentPage] out of bounds and
crashes with IndexOutOfBoundsException.
Clamp the index for the TabRow and use getOrNull for the bottom-bar
re-tap callback.
ChatroomListKnownFeedFilter.updateListWith only recognized
ChannelMessageEvent when checking the old list for an existing row to
replace. When a public channel's row was first populated from a
ChannelCreateEvent or ChannelMetadataEvent (no message had arrived
yet), the next incoming ChannelMessageEvent failed to match, so the
filter appended a second note for the same channel. Both rows then
produced the same PublicChannelLazyKey, crashing the LazyColumn with
"Key was already used".
Resolve the channel id from any IsInPublicChatChannel event (covers
ChannelMessageEvent and ChannelMetadataEvent) and fall back to the
event id for ChannelCreateEvent, mirroring how the lazy key is built
in ChatroomListFeedView.