Previously the graphicsLayer transform wrapped the whole dialog Surface,
so at progress=0 the dialog's top edge (with the back button) aligned
with the source thumbnail's top edge and the actual image appeared
below it.
Move the transform down to a Box that wraps just the image/video
container inside DialogContent, and use the image's natural layout
bounds (captured via a new onContentBoundsChanged callback on
RenderImageOrVideo's Row) as the animation target. This makes the
image's own borders align with the tapped thumbnail's borders on
enter and exit.
The controls row (back/share/download) now lives outside the transform
with alpha tied to the progress value so it fades in alongside the
grow animation and fades out with the shrink animation.
The fullscreen image/video dialog previously popped in without any
transition. Capture the tapped thumbnail's window bounds via
onGloballyPositioned and pass them to ZoomableImageDialog, which now
animates a graphicsLayer scale/translate from the source rect to
fullscreen on enter and back to the source rect on dismiss. The system
window dim is disabled and a Surface background fades in alongside the
growing content so the thumbnail remains visible during the animation.
Applies to images and videos in feeds (via ZoomableContentView) as well
as profile/banner/app-definition callers.
Three issues in the Marmot "new group → add members" flow:
1. "Marmot not initialized" error after creating a group.
AccountCacheState silently swallowed any exception from constructing
AndroidMlsGroupStateStore and set the store to null, which propagated
to Account.marmotManager being null. createMarmotGroup then silently
returned, leaving the UI in a half-created state where AddMemberScreen
would always error out. Now we log the exception and fall back to an
in-memory MlsGroupStateStore so the group operations at least work
within the current session.
2. Hex pubkey shown under selected user's name. Replaced with the
NIP-05 identifier when available, or the shortened npub otherwise,
matching how ShowUserSuggestionList renders users.
3. Could only select one user at a time. AddMemberScreen now keeps a
list of selected users, shows each with its own Remove button, and
the Add action adds them sequentially, reporting per-user successes
and failures. Failed users stay selected so the user can retry.
Replaces deprecated BluetoothGattCharacteristic.value, writeCharacteristic,
notifyCharacteristicChanged, writeDescriptor and connectGatt overloads with
their Tiramisu (API 33+) replacements, falling back to the legacy API with
@Suppress("DEPRECATION") when running on older devices. Adds the new
onCharacteristicChanged(gatt, characteristic, value) overload and caches the
last notified payload so GATT read requests no longer read from the
deprecated characteristic.value field.
The legacy Bluetooth SCO APIs (EXTRA_SCO_AUDIO_STATE, SCO_AUDIO_STATE_*,
isBluetoothScoOn, ACTION_SCO_AUDIO_STATE_UPDATED) were deprecated in
API 31+ in favor of AudioManager.setCommunicationDevice(), which this
class already uses on S+. Gate the legacy SCO broadcast receiver to
pre-S builds and suppress the deprecation warnings on the function,
matching the pattern already used on startBluetoothSco/stopBluetoothSco.
MarmotInboundProcessor and CommitOrdering.EpochCommitTracker used
`synchronized(lock) { ... }`, which is a JVM-only intrinsic. Compiling
the quartz KMP module for iosSimulatorArm64 (and other non-JVM targets)
failed with "Unresolved reference 'synchronized'".
Switch to kotlinx.coroutines.sync.Mutex + withLock, matching the pattern
already used in MlsGroupManager. EpochCommitTracker's public API becomes
suspend — update the MarmotInboundProcessor delegates (pendingCommitGroupEpochs,
clearPendingCommits) and wrap the commonTest cases in runTest. The
MarmotPipelineTest jvmAndroid tests already run inside runBlocking, so
no changes needed there.
Also reshape the processGroupEvent dedup check to hoist the "already
processed?" read out of the lock block so the early return isn't a
non-local return from the withLock lambda.
Marmot MLS groups were being wiped on every app restart. The encrypted
state was written with a 12-byte GCM IV (from cipher.iv after ENCRYPT_MODE
init), but decrypt was reading cipher.blockSize (= 16, the AES block size)
bytes as the IV. Every decrypt therefore failed, MlsGroupManager.restoreAll
caught the exception and deleted the "corrupt" group directory, and the
Marmot group list came up empty after each restart.
Use a 12-byte IV constant in both encrypt and decrypt so the round-trip
works. This matches the sibling KeyStoreEncryption in commons/androidMain
(which already hardcodes 12).
New "Enable voice and video calls" switch at the top of the Call
Settings screen. Default is ON (no behavior change for existing users).
When a user turns it OFF:
- Call buttons in the chat room top bar are hidden. ChatroomScreen
observes account.settings.callsEnabled as a StateFlow and flips
onCallClick / onVideoCallClick to null, which RenderRoomTopBar
already treats as "no buttons".
- Incoming CallOfferEvents are silently dropped in CallManager.
A new isCallsEnabled: () -> Boolean hook on the CallManager
constructor gates onIncomingCallEvent *before* the followed-user
check, so the device never rings and no state transition occurs.
Existing in-flight call signaling (answers/hangups/ICE) still flows
through so a call that was active when the user flipped the toggle
continues to clean up normally.
- The rest of the call-related settings (video quality, max bitrate,
TURN servers) are hidden on the settings screen since they have no
effect while calls are disabled — the screen becomes just the single
meaningful toggle.
The setting is persisted per account via SharedPreferences
(PrefKeys.CALLS_ENABLED = "calls_enabled"), loaded in
LocalPreferences.loadFromEncryptedStorageSync, and exposed as a
MutableStateFlow<Boolean> on AccountSettings so both the chat top bar
and the settings screen react to changes without needing to navigate
away and back.
AccountViewModel wires
isCallsEnabled = { account.settings.callsEnabled.value }
into the CallManager constructor so the flag is read lazily on every
incoming event.
Tests in CallManagerTest:
- incomingOfferIgnoredWhenCallsDisabledInSettings — disabled flag
drops the offer, no state change, no published events.
- disablingCallsAfterStartDoesNotTearDownInProgressCall — an active
call keeps working after the toggle flips; only new offers are
ignored.
- incomingOfferProcessedWhenCallsEnabled — regression guard for the
default-enabled path.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Two changes that tighten how the caller-side UX handles slow or
unresponsive callees in a group call:
1. Per-peer 30-second invite timeout
Previously the caller used a single 60-second call-wide timeout: if
*any* peer answered within the window the timer was cancelled and
slow peers could remain "ringing" indefinitely. For a mid-call
invite there was no timeout at all — the invitee stayed in
pendingPeerPubKeys forever, burning a PeerConnection on the caller
side and keeping the invitee's device ringing.
CallManager now schedules an independent 30-second timer for every
peer in pendingPeerPubKeys (or Offering.peerPubKeys in the initial
offer phase). The timer is started when the peer is added to
pending — in beginOffering, initiateCall and invitePeer — and is
cancelled when the peer answers (onCallAnswered), rejects
(onCallRejected) or hangs up (onPeerHangup). On expiry the peer is
dropped from the group, a CallHangup is published to them so their
device stops ringing, and onPeerLeft fires so CallController can
dispose the per-peer PeerConnection. If the drop leaves the caller
with zero connected and zero pending peers, the call ends with
EndReason.TIMEOUT.
Callee ringing still uses the 60-second CALL_TIMEOUT_MS in
onIncomingCallEvent; the two timers now serve distinct roles.
New constant CallManager.PEER_INVITE_TIMEOUT_MS = 30_000L.
NIP-AC.md "Event Lifecycle" documents both timers.
2. Per-peer "Calling..." status in the video grid
The shared "Waiting for others to join…" banner across the top of
ConnectedCallUI is removed. PeerVideoGrid now takes a
pendingPeerPubKeys set and, for each peer still pending, routes
rendering through PeerAvatarCell with a "Calling…" status line
under the username — so it's obvious *which* participants the call
is waiting on, not just *that* it's waiting. A peer in pending
never shows as a video cell even if a stale track is still in the
map, because they haven't actually answered yet.
The voice-only path in ConnectedCallUI now also uses PeerVideoGrid
(with empty tracks/active-video) instead of the single
GroupCallPictures + GroupCallNames stack, so the per-peer status
behavior is consistent for audio calls.
Tests added in CallManagerTest:
- perPeerTimeoutEndsP2PCallWhenBobNeverAnswers
- perPeerTimeoutDropsSlowCalleeFromGroupCall
- perPeerTimeoutIsCancelledOnAnswer
- perPeerTimeoutDropsMidCallInviteeWhenNoAnswer
- perPeerTimeoutIsCancelledOnReject
- perPeerTimeoutEndsCallWhenAllCalleesIgnore
All existing CallManagerTest cases still pass unchanged.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
When an existing group call participant invites a new peer via
CallController.invitePeer(), the caller connects to the invitee via the
normal offer/answer flow, but the other existing callees were never
establishing their own PeerConnections to the new peer — breaking the
full-mesh invariant.
Root cause: the NIP-AC "callee-to-callee mesh setup" mechanism relies on
each callee observing the others' CallAnswers during its own ringing
window and applying a symmetric "lower pubkey initiates" tiebreaker.
A mid-call invitee's ringing window happens after all existing callees
have already answered, so the invitee's discoveredCalleePeers set is
empty. Worse, if the invitee's pubkey is higher than an existing
callee's, that callee also stays passive (waiting for the invitee to
initiate), and no mesh connection is ever attempted between them.
Fix — break the symmetry on mid-call invites:
- CallManager.onCallAnswered now expands peerPubKeys when an answer
arrives in Connected (or Connecting) state from a peer that is not
yet in the tracked group membership. This keeps the UI and state
consistent with the expanded group and gives CallController a clear
hook via onAnswerReceived.
- CallController.onCallAnswerReceived splits the NO_SESSION case:
* Connected state → mid-call invite. Unconditionally initiate a
mesh CallOffer to the new peer. The invitee stays passive, so
exactly one side initiates per pair and glare is structurally
impossible.
* Connecting state → initial-call mesh observation. Keep the
existing lower-pubkey tiebreaker via onNewPeerInGroupCall to
avoid glare with the symmetric peer.
This fix uses the existing event kinds (CallOffer 25050 and CallAnswer
25051) — no new kinds or tags are required. NIP-AC.md is updated with
a new "Mid-Call Mesh Expansion" subsection under "Inviting New Peers"
documenting the asymmetric rule and a full-flow diagram.
Tests in CallManagerTest cover:
- Mid-call invitee's broadcast answer expands existing callee's
peerPubKeys in Connected state.
- Same expansion works in Connecting state (existing callee still
handshaking with caller when the invitee joins).
- Initial-call answers from already-tracked peers do NOT trigger the
expansion branch (regression guard).
- Full end-to-end 3-way flow exercising Alice, Bob, Carol managers.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Measurement via bench_vs_acinq (native C-to-C comparison against ACINQ's
libsecp256k1) revealed that fe_sqr_inline's 10-mul __int128 path regressed
verify by ~10% and batch verify by ~25% on x86_64 GCC+BMI2+ADX. The ASM
fe_mul_asm uses MULX plus dual ADCX/ADOX carry chains that the compiler
cannot reproduce from __int128 arithmetic, so the extra 6 multiplications
are cheaper than losing the ASM's scheduling wins.
Restore fe_sqr to fe_mul_asm(r, a, a) whenever FE_MUL_ASM is set, and
document the measurement in the fe_sqr_inline comment block. The 10-mul
inline still wins for portable builds (clang without the GCC asm blocks,
MSVC, non-x86_64/ARM64 targets), where fe_mul falls back to the generic
__int128 row-schoolbook path and dedicated squaring genuinely cuts muls.
Baseline (HEAD~1) vs branch with this fix, averaged over 3 runs of
`bench_vs_acinq` on x86_64 (µs/op, lower is better):
Operation ACINQ baseline with fix
pubkeyCreate 17.4 14.5 14.4
sign (derive pk) 35.7 28.8 28.2
sign (cached pk) 18.7 14.3 14.3
verify (BIP340) 35.0 36.4 36.2
verifyFast 35.0 33.0 32.4
ECDH 35.8 31.6 31.5
batch(32) 35.4 5.6 5.6
batch(200) 36.5 4.9 4.7
Every measurement is within run-to-run noise of baseline, and our custom
C is ~1.2-1.3x faster than ACINQ on sign/pubkey and ~6-8x faster on batch
verify (which ACINQ has no public API for at all). Correctness verified:
`Cross-verification: ACINQ verifies ours=1, We verify ACINQ=1`, and all
188 Kotlin secp256k1 tests still pass.
https://claude.ai/code/session_01KExJURZATpL59ZKXP6AVP6
The normal cleanup path routes through CallManager -> state=Ended -> the
state-collector in viewModelScope -> CallController.cleanup(). If the
Activity is destroyed before the collector finishes (task removal, PiP
dismissal, system kill), the async path can leave PeerConnections, the
camera capturer, the proximity wake lock, and the audio-mode change
alive until AccountViewModel.onCleared runs later.
CallActivity.onDestroy / onStop now call CallController.cleanup()
directly as a synchronous safety net, in addition to publishing the
hangup/reject signaling on a detached scope.
To make that safe, CallController.cleanup() is now truly idempotent
within a call session: the cleanedUp flag stays sticky once set and is
re-armed only when a new call starts via initiateGroupCall() or
acceptIncomingCall(). This prevents a second sequential cleanup() call
from double-disposing WebRTC objects when both the state-collector path
and the Activity safety net run.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Close the listener race window by re-snapshotting tracks inside the
DisposableEffect, hoist the openDialog remember above the early returns
so the hook count stays stable when the player loses its video group,
return ImmutableList from buildQualityChoices for Compose stability,
and remember the TextButton ButtonColors copy.
Consolidate VideoQualitySheet into VideoQualityButton using the existing
PlaybackSpeedPopUpButton Popup pattern. Derives selection state from
tracks instead of duplicating it, drops the dead QualityOption.Auto
case, gates listener-driven UI behind early returns, and reuses the
existing call_settings_video_quality string. Net -168 lines.
Adds a manual override for HLS/DASH adaptive streams via a gear icon
that opens a ModalBottomSheet with Auto + per-rendition options. The
button is hidden for single-rendition videos. Overrides reset when
players return to the pool so each video starts in Auto.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Migrates imports from com.abedelazizshe.lightcompressorlibrary to
com.davotoula.lightcompressor due to package rename in the fork.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Complete Tor toggle UX:
- key(appRestartKey) wraps App — full Compose tree rebuild on toggle
- TorManager at Window level — survives rebuild, no stop/start collision
- Tor bootstrap gate at top of App — blocks ALL creation (clients, relays,
Coil, subscriptions) until Tor proxy is ready. Zero clearnet during bootstrap.
- Coil ImageLoader reinitialized after setInstance on each rebuild
- Connection pool evicted on rebuild
- DesktopTorManager retries start up to 3x (handles previous daemon stopping)
- Settings reload from prefs on rebuild (fixes stale toggle display)
- Confirmation dialog on mode change in both TorSettingsSection and Dialog
Known: 2-4 stale Coil CDN connections may persist briefly after toggle
(image loads, not relay traffic — close after OkHttp 5min keep-alive).
Relay WebSocket connections are fully through Tor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When user changes Tor settings, shows "Restart Required" confirmation
dialog. On confirm: saves prefs, triggers appRestartKey++ which forces
Compose to unmount/remount entire App tree.
Key behaviors:
- Window stays open, user stays logged in (AccountManager outside App)
- Column layout preserved (DeckState outside App)
- DisposableEffect stops old Tor daemon before new one starts
- TorSettingsSection mode change → confirmation dialog
- TorSettingsDialog Save → confirmation dialog
- Relays, subscriptions, caches all recreated fresh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the LaunchedEffect/scope.launch reconnect code that didn't
work (relay subscriptions lost after disconnect+connect cycle).
Tor toggle will use app rebuild via key() instead (next commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay reconnect was only triggered when Tor transitioned to Active.
When toggling OFF, relays stayed on the dead SOCKS proxy and never
reconnected over clearnet — feed went blank.
Now triggers reconnect on both Active and Off transitions, so relays
switch between proxy and direct connections when toggling Tor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port Android's OkHttpFactory pattern to desktop — creates a custom
Fetcher.Factory<Uri> that calls DesktopHttpClient.currentClient() per
image request, routing all image loads through the SOCKS proxy when
Tor is active.
Avoids the OkHttpNetworkFetcher.factory() import issue by directly
constructing NetworkFetcher with the Tor-aware Call.Factory, matching
the proven Android implementation.
This closes the last network leak — ALL egress paths now route through
Tor when enabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SECURITY: With Tor ON, all HTTP traffic now routes through SOCKS proxy.
Previously only relay WebSocket connections used Tor; 8 other egress
paths created bare OkHttpClient() instances bypassing Tor entirely.
Fail-closed behavior:
- When Tor expected but bootstrapping → dead SOCKS proxy on port 1
(requests fail instead of leaking IP)
- lateinit var crashes on misconfiguration (loud failure vs silent leak)
Leak sites fixed (all use DesktopHttpClient.currentClient()):
- AnimatedGifImage: GIF fetch
- SaveMediaAction: media downloads
- EncryptedMediaService: NIP-17 DM media
- ServerHealthCheck: Blossom server probes
- NoteActions: zap/lightning LNURL resolution
- DesktopBlossomClient: media uploads
- AccountManager: NIP-46 bunker relay connections
- Coil image loader: TODO (OkHttpNetworkFetcher import issue)
Also:
- Relay reconnect on Tor Active (prevents stale clearnet connections)
- isTorExpected() for fail-closed logic
- Tests updated for new torTypeProvider parameter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shield was getting pushed off screen when RelayHealthIndicator ("x s ago")
appeared above it. Moved to very last position (after BunkerHeartbeat)
so it's always visible at the bottom edge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>