The smoke test discovered that ProGuard's method/marking/static pass
converts kmp-tor's AsyncFs.of() from instance to static. The JVM
then throws IncompatibleClassChangeError at launch because callers
still use invokevirtual.
Also updates smoke-test-desktop.yml trigger: runs on any PR touching
desktopApp/ (not just build config files).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the intentional breakage from d0a6bbc96. The smoke test
proved it catches the crash: removing java.management from jlink
modules causes a fatal IncompatibleClassChangeError in kmp-tor
at launch (the module is needed for ManagementFactory + tor runtime).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit should FAIL the release-deb-launch CI job, proving the
smoke test works. Will be reverted in the next commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dpkg -i exits non-zero when the post-install script fails even with
--force-all. Allow the error with || true, then verify the binary
was actually extracted to /opt before proceeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jpackage's post-install script calls xdg-desktop-menu which fails on
GitHub Actions runners with "No writable system menu directory found".
Menu registration is irrelevant for smoke testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes#2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.
- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
under xvfb, verifies process stays alive 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two improvements to onchain history attribution:
1. Coalesce UI updates. The OnchainZapEvent observation in the
ViewModel now passes through .sample(500), so a relay flooding
a backlog of historical zaps in quick succession produces at
most one map update per 500ms instead of one per event. The
downstream combine + StateFlow conflation handles the rest.
2. Bound relay queries to the visible window. New
OnchainZapsFilterAssembler (SingleSubEoseManager-based) wakes
only while the screen is on top and asks the user's inbox/
outbox relays for kind-8333 events with since = the oldest
visible blockTime — incoming (p-tag = user) and outgoing
(authors = user) — so we don't drag the whole NIP-BC history
when the user only scrolled through last week. As the user
pages back, oldestBlockTime drops, the assembler re-subscribes
with a wider window.
OnchainTransactionsScreen now wires OnchainZapsFilterAssemblerSubscription
with the StateFlow-derived window, and the coordinator owns the
parent assembler so the lifecycle matches other screen subs.
OnchainTransactionsViewModel now subscribes to LocalCache via
observeEvents for kind-8333 zaps that either p-tag the user
(incoming) or are signed by the user (outgoing), merging both into
a txid -> OnchainZapEvent map. The FilterIndex inside LocalCache
narrows the fanout so we only wake on relevant events.
The list of chain rows and the zap map are now independent
StateFlows combined into the displayed views, so a zap event
arriving after the page has loaded immediately attributes the
existing row to its Nostr counterparty without a refresh.
Replaces the per-row LocalCache.notes scan from the previous commit.
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.
The dedicated ConcurrentHashMap<txid, OnchainZapEvent> in LocalCache
didn't pay for itself at current NIP-BC volumes. The on-chain
transactions view goes back to scanning LocalCache.notes for the
matching OnchainZapEvent per row.
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).
Without busy_timeout SQLite returns SQLITE_BUSY immediately when
BEGIN IMMEDIATE can't acquire a lock — e.g. during a WAL
auto-checkpoint or a reader briefly upgrading its snapshot — instead
of retrying. ParallelInsertTest's reader+writer test hit this ~10%
of runs. 5s matches Room's default and adds no overhead in the
uncontended case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Voice notes go through GetVideoController, so the 30s background
release timer + warm-pool reattach already applied to them. What was
missing was the immediate ON_PAUSE handler: voice didn't use
ControlWhenPlayerIsActive, so playback kept running for the full 30s
before the release timer hit and cut it off via the warm-pool pause.
Extract a small PauseControllerWhenInBackground composable from the
ON_PAUSE arm of ControlWhenPlayerIsActive and call it from VoiceTrack.
Result matches video: pause on ON_PAUSE, release at 30s, reassemble
the controller (paused, position preserved) when the user returns —
no auto-resume since voice has no autoplay setting.
PiP exemption (BackgroundMedia.isMutex) is preserved.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
- LocalCache now keeps a ConcurrentHashMap<txid, OnchainZapEvent>
populated in consume(OnchainZapEvent). First sender to claim a
txid wins. The on-chain transactions ViewModel switches to
LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan
of notes.
- Each row in OnchainTransactionsScreen is now clickable: rows with
a matched zap event navigate to the zap's note thread; the rest
open the transaction on mempool.space in the system browser.
The 30s release timer fires on ON_PAUSE, but PipVideoActivity enters
PiP mode (which dispatches ON_PAUSE) immediately after onCreate. The
MediaController is built asynchronously and RegisterBackgroundMedia
runs only after inner() mounts, so there's a window where the timer's
BackgroundMedia.isMutex check sees a stale (or null) bgInstance and
releases a controller that was about to start background playback —
blanking the PiP window.
Add an opt-out parameter on GetVideoController and disable the timer
from PipVideoActivity. PiP IS the explicit background-playback opt-in,
so the timer doesn't apply by design.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
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.
- Replace `remember { side effect }` with lazy `entry.ensure(context)` in
the row's onClick; the system per-channel page only needs the channel
to exist at open time, not before, and the ensure call is idempotent.
- Replace the `refreshKey++` invalidation trick with a real
`Map<channelId, ChannelStatus>` state holder updated inside
`LifecycleResumeEffect`; downstream reads are direct map lookups.
- Replace `Triple`-with-destructuring in `ChannelStatusBadge` with three
direct `when` branches calling `StatusChip` — no tuples, no
temporaries.
- Drop the unused `notification_settings_open_system` string.
Verify video/x-m4v maps to video/mp4 (the failing case), normalization
is case-insensitive, and supported image/video/audio types pass through
unchanged. Required making normalizeMimeTypeForMediaStore internal.
Reorganize Notification settings into three sections that reflect what
each control actually does:
- Delivery: how notifications reach the device — push provider (fdroid)
and the always-on relay service live together here.
- In-app display: how the notifications screen renders incoming activity —
currently the Split-by-Follows toggle.
- Categories: one row per user-facing Android NotificationChannel (DMs,
Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls),
showing the current importance (On / Silent / Off) and opening the
system per-channel settings page on tap. Foreground-service channels
are intentionally omitted — disabling them breaks the service contract.
The screen ensures every listed channel exists on first open and
re-reads importance via LifecycleResumeEffect so the badge reflects
changes made in system settings.
API surface bumped for the channel registry:
- CallNotifier.CALL_CHANNEL_ID is now public.
- ScheduledPostNotifier.ensureChannel is now public.
Replaces the previous narrow try/catch fix. The earlier patch silently
swallowed SignerExceptions in NewMediaModel / EditPostViewModel — no
toast, no error dialog — so the user would tap Post, see the dialog
close, and never learn that the signer prompt timed out or was
rejected. It also bypassed the project's standard signer-error
pipeline.
This version routes the sign+publish phase through
AccountViewModel.launchSigner, which is the same path every other
signing entry point uses:
- ReadOnly / SignerNotFound / UnauthorizedDecryption / IllegalState →
toastManager surfaces a localized alert.
- TimedOut / ManuallyUnauthorized / etc. → logged silently (no crash),
matching the rest of the app's behavior when a user dismisses the
external signer.
NewMediaModel.upload now takes an AccountViewModel parameter; the three
inner viewModelScope.launch(Dispatchers.IO) blocks become
accountViewModel.launchSigner { ... }. The joinAll wait is preserved
(launchSigner returns Job).
EditPostViewModel.uploadUnsafe routes its single launch the same way
and wraps the body in try/finally so mediaUploadTracker.finishUpload()
still runs if a signer throws mid-flow.
launchSigner is changed from Unit to Job (= viewModelScope.launch ...)
so callers can join — non-breaking for the ~190 existing callsites that
ignore the return value.
- Promote the duplicate `SwitchTile` from SecurityFiltersScreen and
NotificationSettingsScreen into a shared `SettingsSwitchTile` in
SettingsSectionCard so all settings switches share one implementation.
- Render BatteryOptimizationBanner outside the in-app section card
instead of nesting a Card inside a Card and splitting the section's
divider away from the row it explains.
- Refresh the battery-optimization exemption on LifecycleResumeEffect
so the banner disappears after the user returns from the system
settings page; drop the racy post-button re-read.
- Demote `HasPushNotificationProvider` to a plain `hasPushNotificationProvider`
since it returns a per-flavor constant and reads no Compose state.
#2946 fixed the ClassCastException with a bespoke AtomicReference<Map>
+ CAS copy-on-write helper. Quartz already has a concurrent-map
abstraction for exactly this purpose — LargeCache — with platform-tuned
actuals (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple, custom
on Linux). Swap to it.
Removes the bespoke putAuthStatus/removeAuthStatus helpers, the
ExperimentalAtomicApi opt-in, and the AtomicReference imports.
The RelayAuthenticatorConcurrencyTest from #2946 still passes against
the new implementation.
Reuses the NamecoinResolutionRow composable already shipping for the
onchain-zap recipient field, promoting it from
ui/screen/loggedIn/wallet/ to a generic ui/components/namecoin/
location so it can be mounted anywhere a .bit-shaped search input may
race the local-cache prefix search.
In the global search bar, typing a bare ".bit" host (e.g.
"testls.bit") used to surface a cached sibling profile like
"m@testls.bit" first (LocalCache.findUsersStartingWith hits the
prefix) and only several seconds later be corrected by the slower
on-chain ElectrumX resolution from
SearchBarViewModel.directNip05Resolver. No in-flight indicator and no
feedback on hard failures (timeout, malformed record, etc.).
Changes:
- git-rename NamecoinResolutionRow.kt and its test from
ui/screen/loggedIn/wallet/ to ui/components/namecoin/, updating
the package declaration only.
- Add an optional `modifier: Modifier = Modifier` parameter to the
composable (standard Compose convention) and wrap the spinner /
result / error rows in a Column taking the caller-provided
modifier. No visual change in OnchainZapSendDialog.
- Update OnchainZapSendDialog import to the new package location.
- Mount NamecoinResolutionRow in SearchScreen.SearchBar between
SearchTextField and SearchFilterRow, with horizontal padding to
match the rest of the bar. onUserResolved navigates to the user
and clears the field, matching the bech32 auto-resolve path in
SearchBarViewModel.directRouteResolver.
State is held in the shared
commons.NamecoinResolveState (no new state class introduced) and
diagnostic wording comes from the existing mapOutcomeToResolveState
helper, so every Namecoin surface continues to produce the same
message for the same outcome.
The local-cache suggestion dropdown can momentarily show a stale match
when the user types a .bit identifier. For example, after resolving
"m@testls.bit" earlier in the session, typing the bare host
"testls.bit" causes findUsersStartingWith() to return the cached
m@... profile first; the correct _@testls.bit profile only appears a
few seconds later when ElectrumX resolution finishes. There is no
visual hint that a Namecoin lookup is in flight, and on hard failures
(timeout, malformed record, no nostr field, etc.) the user gets no
feedback at all.
This adds a dedicated NamecoinResolutionRow composable mounted between
the recipient field and the local-cache dropdown:
- "Resolving <name> on Namecoin…" spinner while the on-chain
lookup is in flight (after a 300 ms debounce that matches the
dropdown's own debounce).
- On success, a tappable row showing the resolved profile with a
distinct "Namecoin" badge (MaterialSymbols.Link), so the user
can pick the on-chain-verified profile unambiguously.
- On failure, a single explanatory error line covering all
NamecoinResolveOutcome variants the resolver already produces:
NameNotFound, NoNostrField, MalformedRecord (with the underlying
parser error verbatim), ServersUnreachable, InvalidIdentifier and
Timeout.
State is held in the shared NamecoinResolveState sealed class already
used by NamecoinNameService and the desktop SearchScreen — no new
state model is introduced. A small mapOutcomeToResolveState() helper
mirrors the same wording desktop ships, so all Namecoin surfaces
produce the same diagnostic string for the same outcome.
The row owns its own LaunchedEffect keyed on the typed query, so it
cancels in-flight lookups whenever the user keeps typing, and it
unmounts cleanly once a recipient is selected. It uses the existing
Amethyst.instance.namecoinResolver instance, so no DI plumbing or new
network calls beyond what was already wired up for .bit resolution.
The dropdown is left untouched: local-cache results are still valid
hits (just not necessarily the *intended* on-chain identity), so they
remain available below the new row.
Pure helpers (looksLikeNamecoinIdentifier + the outcome-to-state
mapper) are covered by unit tests in NamecoinResolutionRowTest.
The Overview tab is a plain vertical-scroll Column inside DisappearingScaffold,
which lays content under the top app bar + tab row and bottom nav. The Issues
and Patches tabs go through RefresheableFeedView and already consume
LocalDisappearingScaffoldPadding internally, but the Overview tab did not, so
its top and bottom were hidden behind the surrounding UI.
OkHttp dispatches WebSocket callbacks on one thread per relay socket,
so RelayAuthenticator's plain LinkedHashMap was mutated concurrently
from many threads during connection storms. When a bucket crossed
HashMap's TREEIFY_THRESHOLD the racing treeify corrupted internal
state and threw ClassCastException: LinkedHashMap$Entry cannot be
cast to HashMap$TreeNode from onDisconnected.