- Re-layout the screen as a centered "hero" preview on a subtle gradient
backdrop, with a soft drop shadow, instead of a top-aligned full-width
bitmap above a settings row.
- Move the primary action into a bottom bar: a clearer server picker plus
a full-width "Share" button with an icon. Top bar is now a plain back bar.
- Brand the captured card with a divider + Amethyst logo watermark and
roomier padding.
- Capture in two passes (quick first preview, then a refined snapshot after
a short settle delay) so async media has time to load into the image
instead of showing empty placeholders.
- Show real upload progress (stage + percentage) via the shared
UploadProgressIndicator instead of a bare spinner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
The macOS icon.icns shipped inconsistent artwork across its embedded
sizes — a transparent full-bleed glyph at 256/512 (shown on the DMG mount
window and Spotlight) but a white-carded glyph at 128 (shown in the Dock).
It was also missing every @2x Retina tier and its 16/32/48 entries decoded
to corrupt noise, a signature of a generic PNG->ICNS converter rather than
iconutil. Regenerate from a single transparent glyph master into a proper
iconset (all standard sizes + @2x) compositing one consistent rounded-card
look at every size, then assemble with iconutil.
The Windows icon.ico held a single 32x32 BMP, so Windows upscaled a blurry
32px everywhere it needed a larger icon. Rebuild as a multi-size .ico
(16/32/48/64/128/256, PNG-encoded) from the same glyph, full-bleed and
transparent per Windows convention (matches the Linux icon.png).
Linux icon.png is unchanged — a single transparent PNG never had the
inconsistency and Linux desktops expect transparent icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
composemediaplayer 0.10.0 publishes kotlinx-coroutines-test as a runtime
dependency in its POM. That jar ships a META-INF/services registration for
kotlinx.coroutines.CoroutineExceptionHandler -> ExceptionCollectorAsService.
The release-only ProGuard pass strips the unreferenced provider class but
keeps the services manifest, so the packaged dmg crashed at startup with a
ServiceConfigurationError the first time the coroutine exception handler
loaded (DesktopHttpClient.<init>). Dev runs were unaffected since they don't
run ProGuard.
Exclude the test-only artifact so it never lands on the production classpath.
Verified: rebuilt release dmg no longer bundles the jar and launches clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the share-as-image flow from a dialog into a proper navigation
screen (Route.ShareNoteAsImage), reached from the note 3-dot menu. The
note is rendered off-screen into a GraphicsLayer, captured to a bitmap,
and the captured bitmap is shown as the preview via an Image composable —
so the preview is exactly what gets uploaded and shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
Add a "Share as Image" action to the note 3-dot menu. It renders the
post into a framed card, captures it to a bitmap via a GraphicsLayer,
uploads the PNG to one of the user's Blossom media servers, and hands
the resulting URL to the Android share sheet.
The on-screen preview is the capture source, so what the user sees is
exactly what gets shared. A server spinner lets the user pick which
Blossom server to upload to, defaulting to their configured default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
TorService.start() drove the Connecting→Active transition off Arti's
"Sufficiently bootstrapped" log line, gated on proxyRunning.get(). That line
is emitted from a tokio task spawned inside the native startSocksProxy
(arti-android-wrapper/lib.rs), which races startSocksProxy returning and
start() setting proxyRunning = true. When the task wins, the guard is false,
the transition is dropped, status stays Connecting, and the 60s
connection-failure screen fires even though the SOCKS proxy is up and
bootstrapped.
Set _status.value = Active(socksPort) directly in start() right after the
proxy binds and proxyRunning is set — under the lifecycleMutex start()
already holds, so a concurrent reset/stop can't clobber it. Reduce the log
callback to plain log forwarding now that it no longer drives status.
Verified on emulator-5554: Active now logged from start() before the
"Sufficiently bootstrapped" callback, zero ECONNREFUSED on the 9050 fallback
port, relays connecting through Tor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expand the Tor entry into the full reliability cluster (warm-cache speedup,
Active-but-dead watchtower, wedged-guard recovery, 60s bootstrap timeout, dial
gating, lifecycle serialization) and add the other user-facing changes from
recent commits that weren't captured yet: pre-loaded reply threads, richer
workout cards, relay recovery after device sleep, and not resetting relay
backoff on momentary connections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
On-device testing caught a false positive: the original 8-failures-in-a-30s-
window detector fired ~2s after Tor flipped Active. The instant Tor goes Active
the pool dials the whole Tor-routed relay set at once, and on freshly built
circuits a burst of >=8 can fail before the first relay completes its handshake
— so the detector wiped the good, just-bootstrapped client.
Replace the count-window with a sustained-streak model: fire only when an
unbroken run of Tor-routed failures both reaches FAIL_THRESHOLD and spans at
least SUSTAINED_MS (30s), with zero successes interrupting it (any success ends
the streak). A gap longer than SUSTAINED_MS also restarts the streak. The span
floor doubles as a post-Active warmup grace, so the warmup burst — and the
identical reconnection burst on app resume — no longer trips it.
Verified on device across cold start, WiFi<->Cellular handover, airplane
on/off, and app pause/resume: 0 false self-heals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TorManager's watchdogs (selfHealSignal, connectionFailure) only arm while
status is Connecting. Once Arti logs "Sufficiently bootstrapped" off cached
consensus, status flips to Active and every watchdog disarms — even if no exit
circuit actually works (the ExitTimeout/RESOLVEFAILED barrage seen on device).
The SOCKS proxy is up, so nothing in the lifecycle looks wrong, and Tor can sit
Active-but-useless indefinitely with no recovery.
The relay layer is the only place with both halves of the signal: per-relay
success (onConnected) and failure (onCannotConnect), plus the Tor-routing of
each url. Arti emits no per-stream success log, so a Tor-internal trigger could
only count failures — and couldn't tell "all dead" from "some dead", resetting
Tor every time a few dead relays are dialed.
Add TorCircuitHealthTracker, a RelayConnectionListener that fires only when,
while Tor is Active and connectivity is up, there are >= FAIL_THRESHOLD (8)
Tor-routed failures within WINDOW_MS (30s) AND zero Tor-routed successes in that
window. The zero-success clause is the whole discriminator: one successful Tor
open means circuits work, so it suppresses. It pokes TorManager.onTorCircuitsDead(),
which mirrors the post-Active stuck-Connecting recovery — resetWithCleanState()
(wipe the suspect guard/circuit state behind a healthy-looking guards.json) plus
a resetEpoch bump to force a full re-init — and shares lastSelfHealAtMs /
SELF_HEAL_COOLDOWN_MS with the Connecting watchdog so the two can't thrash. If
circuits are still dead after the reset, the cooldown suppresses further resets
and the 60s connectionFailure dialog still offers the user the bypass.
Tests: TorManagerTest covers onTorCircuitsDead (fires + wipes + re-inits when
Active; no-op while not-Active / bypassing; shares the cooldown).
TorCircuitHealthTrackerTest covers the discriminator (threshold, single-success
disarm, clearnet ignored, connectivity/Active gating, window aging, re-arm).
Verified on device: no crash, tracker stays silent during normal operation
(168 relays opened, 0 self-heal fires).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TorService.start() called clearArtiCache() on every cold start (and every
reset, since reset flips initialized back to false), deleting the
consensus/microdescriptor cache and forcing a full re-download on the next
bootstrap. Measured on device, that turned a ~7.7s warm bootstrap into ~24.2s
— a ~3.2x slowdown paid on every launch.
The wipe was an early attempt at what we later understood to be the wedged-
guard problem (now handled by noUsableGuards()/clearAllArtiData()). It never
actually helped: guards live in state/, not cache/, so wiping the cache can't
fix a stale guard sample; and Arti already validates consensus freshness and
refetches whatever has expired, so there is no stale-consensus risk to guard
against here. The reset/clean-state self-heal paths still call
clearAllArtiData() for genuine corruption recovery.
Drop clearArtiCache() entirely and preserve the cache for warm bootstraps.
Also add bootstrap-timing instrumentation: the "SOCKS proxy active" log now
reports elapsed bootstrap ms, and a cache-size log line correlates cache
state with bootstrap time.
Verified on device: warm bootstrap 7,670ms vs 24,200ms cold, with no
regression — a .onion relay and 200 clearnet relays connected over Tor, 0
pre-ready doomed dials, guards healthy (59/60 usable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
ArtiNative is a process-global singleton over a single native Tor client, and
initialize/destroy are blocking JNI calls that ignore coroutine cancellation.
TorService guarded it with only two AtomicBooleans and no mutual exclusion
across start/stop/reset/resetWithCleanState. A self-heal reset() (stuck-
Connecting watchdog or onNetworkChange) could therefore call destroy() while a
start() was mid-initialize(): the freshly bootstrapped client was destroyed
~0.5s after coming up, leaving the SOCKS listener bound with no live client
behind it. Every Tor dial then timed out at the exit (ExitTimeout barrage) and
status was left at a stale Active.
Serialize all four native lifecycle transitions behind a single lifecycleMutex
so destroy() can never overlap initialize() — a reset now waits for an
in-flight bootstrap to finish before tearing it down cleanly. reset() and
resetWithCleanState() share a private resetLocked() helper (Mutex is not
reentrant). Also gate the "Sufficiently bootstrapped" -> Active callback on
proxyRunning so a late callback from a torn-down client can't resurrect a stale
Active status.
Verified on device: forced reset+start now fully serialize (destroy completes
before create begins), and a clean start bootstraps to Active in one cycle with
no churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recordIncoming_after_close_does_not_schedule_persist called advanceUntilIdle()
while RelayHealthStore's init ticker (`while(true){ reclassify(); delay(60s) }`)
was still live on the shared StandardTestDispatcher scheduler. advanceUntilIdle()
chases that periodic delay forever, so the test spun at 100% CPU and never
returned — wedging :commons:jvmTest at "373 tests completed" and hanging the
pre-push hook (and leaving orphaned, CPU-pegging Gradle test workers behind).
Advance just past PERSIST_DEBOUNCE_MS and runCurrent() instead, so init's
debounced save fires for the baseline while the 60s ticker stays parked. The
post-close advanceUntilIdle() calls are fine — close() cancels the ticker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass the reply's id straight to the thread subscription instead of resolving
the root in composition. ThreadFilterSubAssembler already runs findRoot in
updateFilter, so the extra compose-side findRoot was duplicate work; gate on
replyTo so only replies (not roots or quotes) pre-load.
Before Tor finishes bootstrapping, the relay pool dialed every Tor-routed
relay against the not-yet-listening SOCKS proxy. On a cold start this was
~580 doomed dials (all "SOCKS: Connection refused") concentrated in the
seconds before Tor went Active, churning sockets/CPU and inflating each
relay's backoff. The cost scaled with bootstrap latency, and the same
storm recurred on every network switch (which resets and re-bootstraps Arti).
Add an optional WebsocketBuilder.canConnect(url) gate (defaults to true,
so other implementors are untouched), checked at the top of
BasicRelayClient.connect() before the mutex/onConnecting/build — so a
gated relay opens no socket, fires no listener events, and grows no
backoff. The Android builder gates Tor-routed relays on
torManager.isSocksReady(); RelayProxyClientConnector already reconnects
them with ignoreRetryDelays=true the instant Tor flips to Active, so they
dial as soon as the transport is usable.
Measured on-device: pre-ready doomed Tor dials 581 -> 0 across cold starts
and WiFi<->Mobile switches; clearnet connections stay untouched and Tor
relays self-heal once Tor is Active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Vitor's review of #3221: wake-detection is platform-specific UX, not
NostrClient's job. Quartz already exposes `reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)` which does the full disconnect + connect — the
app layer just needs to call it when it detects a wake.
- Revert the keep-alive heuristic in NostrClient.kt; the loop is back to the
conservative `reconnectIfNeedsTo` path it had before.
- Add `runSleepResumeMonitor` (desktopApp/network/SleepResumeMonitor.kt): a
60s tick that watches for wall-clock overshoot and calls the supplied
`onWake` lambda. No native deps.
- Wire it in `Main.kt` next to the metrics LaunchedEffect: on >5x overshoot
call `relayManager.client.reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)`.
Real OS sleep events (NSWorkspace on macOS, D-Bus PrepareForSleep on Linux,
WM_POWERBROADCAST on Windows) can be layered in later as platform improvements
without touching Quartz again.
When a reply is visible in NoteCompose, eagerly subscribe to its thread
root (filter on the root's e/a tag, like the thread screen) so opening the
conversation finds it already loaded. The observer resolves the root and
keys on its id, so sibling replies share one subscription and root notes
are skipped.
Make the workout note render with more visual punch, modeled on RUNSTR's
workout cards:
- Promote the headline metric (distance for cardio, steps when there is no
distance, otherwise duration) to a large hero number, skipping it in the
grid below so it is not repeated.
- Lay out secondary metrics in a fixed 3-column grid instead of a free-flowing
row so values line up in tidy columns.
- Give the activity icon a tinted circular chip for more prominence.
Surface more of the parsed kind-1301 data in WorkoutDisplay, modeled on how
RUNSTR renders workout records:
- Source badge (GPS / RUNSTR / HEALTHKIT / MANUAL) in the header
- Average speed (km/h or mph) for cycling instead of pace
- Elevation loss alongside elevation gain
- Max heart rate alongside average heart rate
Relabels 'Elevation' to 'Elevation gain' now that loss is shown.
Switch the Workouts screen from the custom WorkoutCardCompose card to the
standard NoteCompose feed via the default FeedLoaded renderer. NoteCompose
already dispatches WorkoutRecordEvent to WorkoutDisplay, so workouts render
with the full note chrome (author header, reactions, replies, etc.).
Removes the now-unused WorkoutFeedLoaded and WorkoutCardCompose.