8d228db440bb564afef4e7d5e0a67b6848812afa
12501 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bc43168032 |
chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:
- Drop the no-op `supportedMoqVersions: List<Long>` parameter from
`connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
via ALPN, no caller passed a value, and the project rule forbids
no-op back-compat shims.
- `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
moq-lite framing path (one Subscribe bidi for `audio/data`, group
uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
- `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
as IETF MoQ-transport reference impls — production uses
`MoqLiteNests*`. They stay around for the IETF unit-test suite.
- `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
the plan was written before the moq-lite gap was discovered, so
it described the work as IETF-MoQ-publisher additions; the
moq-lite path lands the same outcome via a different protocol.
|
||
|
|
71cf99dc22 |
feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.
Transport layer:
- WebTransportSession.incomingBidiStreams() — peer-initiated bidi
flow. moq-lite publishers receive Announce + Subscribe bidis from
the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
Stream::accept(session)), so the abstraction grew the
accept-bidi-from-peer surface.
- WebTransportSession.openUniStream() — locally-opened uni stream
for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
session.open_uni()).
- :quic WtPeerStreamDemux StrippedWtStream now carries optional
send/finish closures. The demux takes the QuicConnectionDriver
so wakeups fire after each app-level write on a peer-initiated
bidi.
- FakeWebTransport now exposes incomingBidiStreams + openUniStream
directly; the openPeerUniStream test helper went away (production
flow covers it).
Session layer:
- MoqLiteSession.publish(suffix) — claims a broadcast suffix and
lazily launches a relay→us bidi pump. ControlType=Announce reads
AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
body, replies SubscribeOk, registers the inbound subscription.
- MoqLitePublisherHandle — startGroup / send / endGroup / close
semantics. send opens a uni stream per group with DataType=0 +
GroupHeader and pushes varint(size)+payload frames. close emits
Announce(Ended) on every active announce bidi, FINs the uni.
Application layer:
- AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
its unit tests).
- MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
MoqLiteNestsListener on the publish side.
- connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
returns MoqLiteNestsSpeaker.
Tests:
- 4 new MoqLiteSessionTest cases:
publisher_replies_to_announcePlease_with_active_announce,
publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
publisher_send_returns_false_when_no_inbound_subscriber,
publisher_close_emits_ended_announce.
Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.
Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
|
||
|
|
49f769f92a |
Merge pull request #2590 from vitorpamplona/claude/fix-fdroid-lint-errors-i6aEY
fix(playback): hoist DataSourceBitmapLoader build into a function |
||
|
|
6540b81512 |
Merge pull request #2589 from vitorpamplona/claude/audit-remember-functions-6lpHA
perf(ui): drop remember wrappers where overhead exceeds savings |
||
|
|
5914e9e9fc |
docs(audio-rooms): moq-lite listener landed — refresh status callouts
Phase 5d wrapped, so the doc set now reflects "listener path done,
speaker pending":
- nestsClient/plans/2026-04-26-moq-lite-gap.md — new "Implementation
status" section maps phases 5a → 5d to commits, calls out the
speaker side as blocked on a small `WebTransportSession.acceptBidi`
extension (since publisher.rs:40 uses Stream::accept), and points
at the existing :quic primitive (QuicConnection.awaitIncomingPeerStream)
that the bridge can lean on.
- nestsClient/plans/2026-04-26-audio-rooms-completion.md — Phase M1
is no longer "on hold for moq-lite"; manual nostrnests.com
validation should now work end-to-end on the listener path.
- .claude/CLAUDE.md — :nestsClient now hosts both IETF MoQ-transport
and moq-lite Lite-03; production listener path uses moq-lite.
|
||
|
|
41f4dcd9ac |
feat(audio-rooms): connectNestsListener uses moq-lite (phase 5d)
Production wiring switch — `connectNestsListener` now opens a
moq-lite (Lite-03) session against the WebTransport peer instead of
running the IETF MoQ-transport SETUP handshake. The downstream
NestsListener interface is unchanged; consumer code in commons /
amethyst keeps working.
Listener flow (per the audio-rooms NIP draft + nostrnests JS reference):
- subscribeSpeaker(pubkey) → MoqLiteSession.subscribe(broadcast =
pubkey, track = "audio/data")
- frames map to MoqObject so AudioRoomPlayer / AudioRoomViewModel
keep working unchanged. Per-frame `objectId` is synthesised as a
monotonic counter (moq-lite has no per-frame ID); `groupId` =
moq-lite group sequence; `trackAlias` = subscribe id.
NestsListenerState.Connected.negotiatedMoqVersion now reports
MOQ_LITE_03_VERSION (a synthetic constant carrying the ALPN suffix
in the low bytes) since moq-lite has no in-band SETUP message.
NestsConnectTest:
- Drop the SETUP-faking peer side — moq-lite has no SETUP, so
connectNestsListener returns Connected immediately after the WT
handshake.
- Assert MOQ_LITE_03_VERSION on Connected.
Speaker path (`connectNestsSpeaker`) is untouched — it still uses
the IETF DefaultNestsSpeaker. Speaker-side moq-lite needs
`acceptBidiStream` on the WebTransportSession interface so the relay
can open Announce/Subscribe bidis to the publisher; tracked in
plans/2026-04-26-moq-lite-gap.md phase-5c-speaker.
Verified `:commons:compileKotlinJvm` + `:amethyst:compilePlayDebugKotlin`
both still compile against the swap.
|
||
|
|
f96b42f6b9 |
fix(lint): add @file:OptIn(UnstableApi::class) to MediaSessionPool
Property-level @OptIn doesn't propagate through the lazy{} delegate body,
so lint flags the DataSourceBitmapLoader.Builder chain (lines 87-90) with
UnsafeOptInUsageError. File-level annotation is a one-line fix that lets
:amethyst:lintPlayDebug pass without changing runtime semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4e136cacf4 |
feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)
Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:
- announce(prefix) — opens an Announce bidi with
ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
of relay Announce updates (Active/Ended)
- subscribe(broadcast, track, ...) — opens a Subscribe bidi with
ControlType=Subscribe + body, reads the size-prefixed response,
returns a MoqLiteSubscribeHandle whose frames flow yields each
frame the publisher pushes
- close() — cancels pumps, FINs every active subscribe bidi
(moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport
Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.
Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.
FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.
Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
- subscribe_writes_request_and_returns_handle_on_ok
- subscribe_throws_on_drop_response
- announce_streams_relay_updates
- groups_are_demuxed_by_subscribeId (no cross-talk)
- unsubscribe_FINs_the_subscribe_bidi
|
||
|
|
6f04edd995 |
perf(ui): drop remember wrappers where overhead exceeds savings
Cases removed: - Trivial Modifier allocations (Modifier.weight/padding/size) — slot table cost dominates the cost of building a fresh Modifier each recomposition. - Map.keys views over relayStatuses on desktop screens — .keys is a property read on the same map, no need to memoize. - coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares are cheaper than the slot table read+compare. - fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition allocations that don't justify the slot table overhead. Audit-only changes; no behavior changes. https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4 |
||
|
|
179642d78b |
fix(playback): hoist DataSourceBitmapLoader build into a function
Android Lint's UnsafeOptInUsageError doesn't recognize @OptIn placed on a `by lazy` property as covering the lambda body, so each Media3 unstable-API call inside the initializer (Builder, setExecutorService, setDataSourceFactory, build) was flagged. Move the construction into a real function carrying the @OptIn annotation; the lazy delegate just calls it. No behavior change. |
||
|
|
fb47a4cf75 |
feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.
What landed:
- MoqLitePath — mandatory wire-boundary normalisation (strip
leading/trailing/duplicate `/`), join, path-component-aware
stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
- MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
Group uni-stream type byte, announce status, subscribe
response type.
- Message data classes — AnnouncePlease, Announce, Subscribe,
SubscribeOk, SubscribeDrop, GroupHeader, Probe.
- MoqLiteCodec — encode/decode for each message, with the
size-prefix envelope baked in. Handles the off-by-one
`0 = None, n = Some(n−1)` trick for startGroup/endGroup,
coerces booleans to 0/1, validates priority fits in u8,
rejects unknown status/response bytes. Path normalisation
applied at every encode + decode boundary.
- MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
every codec entry point + negative paths (oversized priority,
invalid ordered byte, unknown status, trailing garbage).
All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
|
||
|
|
7f48e52541 |
docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:
- ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
handshake IS the handshake)
- Per-request bidi streams keyed by ControlType varint (Announce=1,
Subscribe=2, Fetch=3, Probe=4)
- AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
- Subscribe with priority (raw u8), ordered, maxLatency (ms),
startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
- Group = uni stream with (DataType=0, subscribeId, sequence)
header followed by varint-length frames until QUIC FIN
- No datagrams; no per-frame envelope beyond size
- Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
for errors
Phase-5a..e implementation plan included (~1 week scope).
Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:
- .claude/CLAUDE.md project description and architecture diagram
- nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
- MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
pointers to the gap doc
- NestsConnect.kt — note that step 3 of the listener handshake
(SETUP) does NOT match nostrnests's relay framing
|
||
|
|
d589fc4614 |
Merge pull request #2588 from vitorpamplona/claude/optimize-composables-performance-bpO05
perf(note types): cache event-derived values and hoist static modifiers |
||
|
|
1887bd1fa7 |
test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:
1. WebTransport CONNECT path is now /<moqNamespace> (matches the
relay's claims.root prefix check). Previously hardcoded "/anon".
2. JWT travels in the ?jwt=<token> query parameter, not the
Authorization header — moq-rs only reads the query param. The
bearer-token path on QuicWebTransportFactory is now unused for
nests; left in place for non-nests WebTransport servers.
3. Harness `moqEndpoint` is the relay base URL only; the connect
helpers append /<namespace>?jwt=<token> themselves.
Interop test additions (all -DnestsInterop=true gated, default-skipped):
- NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
for the wrong URL, malformed namespace per the strict regex,
publish=true grant for any caller — sidecar does NOT gate by NIP-53
hostlist).
- NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
shape (must contain ES256/P-256), 404 on unknown route.
- NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
speaker isolation, subscribe-before-announce. Code is wired through
production connectNestsSpeaker / connectNestsListener; will pass
once the moq-lite gap (below) is resolved.
Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
|
||
|
|
46f305fe1a |
perf(chats): typed sealed key instead of concatenated string
The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"`
etc., which allocates a StringBuilder + char[] + new String per call —
worst case for the DM branch which also allocates a sorted List on top.
Replace with a sealed `ChatroomLazyKey` and per-type data classes that
just wrap the existing String / RoomId / ChatroomKey. Equality and
hashCode are auto-generated, so Compose still moves rows correctly on
reorder, and we drop most of the per-key allocations:
ch:abc -> PublicChannelLazyKey(abc) # 1 wrapper, reused String
dm:userA,userB -> PrivateChatLazyKey(chatroomKey) # 1 wrapper, reused ChatroomKey
eph:roomId -> EphemeralChannelLazyKey(roomId) # 1 wrapper, reused RoomId
|
||
|
|
06340dbdf9 |
fix(chats): stable per-chatroom LazyColumn key for messages list
The chatroom list keyed each row by `if (index == 0) index else item.idHex`. Two problems: 1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to the top, the existing composition slot was reused with state from the previous chatroom — observed as "the row updated and reordered but still shows the old result" right at the top. 2. For other positions, the key was the latest message's `idHex`. When a new message arrived in any chatroom the chatroom's representative Note got replaced (different idHex), so Compose threw away the row and rebuilt it from scratch — wasted work. Fix: derive a stable key from chatroom identity instead of message id — nostr group id for marmot rooms, channel id for public/ephemeral channels, sorted user set for DMs. Falls back to `item.idHex` for unrecognized event types (drafts etc.). Reorders now move the row; new-message updates re-use the slot. |
||
|
|
6aecfe016b |
perf(note types): second pass — fix more missing remember keys
Follow-up to the first perf pass. Same goal: cut allocation cost for
note rows that scroll inside LazyColumn feeds.
- AppDefinition: key the `remember { tags.toImmutableListOfLists() }`
block by `note` so it actually invalidates when the note changes
- NIP90ContentDiscoveryResponse: drop the `remember(note) {
Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a
constant call
- PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and
switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the
participants reload when the underlying event changes
- PinList: replace `val pins by remember { mutableStateOf(noteEvent
.pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` —
the `mutableStateOf` wrapper was unnecessary and the missing key
meant `pins` could go stale on event updates
- LongForm: return the `topics` list as `ImmutableList` so Compose
treats it as a stable parameter to the consuming `forEach`
- RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside
4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/
read, DisplayDMRelayList) — the value never changes after creation;
also key by `noteEvent` rather than `baseNote`, and cache
`noteEvent.description()`
- Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content
comparison and `files().toImmutableList()` in `remember(noteEvent)`
so they don't recompute and reallocate on every recomposition
|
||
|
|
bf540db557 |
perf(note types): cache event-derived values and hoist static modifiers
Reduce per-recomposition allocation cost for note rows that render inside
LazyColumn feeds:
- AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/
participants/waveform/content so the cached values invalidate when the
underlying event changes
- Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title,
summary, price and location in `remember(noteEvent)` so they aren't
recomputed on every recomposition; hoist the static price-tag modifier
to a top-level `val`
- Report: collapse the per-recomposition `map { stringRes(...) }` chain
into a single `remember(reportTypes, noteEvent)` over a deduplicated
set of report types, and key the `base` collection by `noteEvent`
- Highlight: key the URL-parse `remember` by `url` so it actually
re-validates when the parameter changes
- PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`,
drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and
key `isLoggedUser` by `note.author` instead of `note.event?.id`
- PictureDisplay / FileHeader / Video: drop the unnecessary
`mutableStateOf(...)` wrap inside `remember` blocks that produce
immutable `BaseMediaContent` values; cache `images.map { it.url }`
preload list, and key `title`/`summary`/`image`/`isYouTube` by event
- Poll: add the missing `it.label` and `card` keys to `remember` blocks
that derive booleans from those parameters
- MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to
top-level `val`s instead of allocating them each composition
|
||
|
|
0ac8c0f791 |
test(audio-rooms): production round-trip via real MoQ relay (phase 3/3)
Drives connectNestsSpeaker + connectNestsListener end-to-end against the
real nostrnests Docker stack. Speaker announces a track, listener
subscribes by pubkey, speaker pushes deterministic frames through
AudioRoomBroadcaster → MoQ → relay → listener.objects flow, and the
test asserts payload integrity + monotonic object ids.
Validates the wire shapes the Phase-2 refactor committed to:
- QuicWebTransportFactory + PermissiveCertificateValidator can
handshake against the relay's self-signed dev cert
- JWT minting + WebTransport CONNECT + MoQ SETUP all succeed
- The single-segment TrackNamespace `nests/<kind>:<host>:<room>`
matches the relay's `root` JWT claim
Single-keypair design sidesteps host-vs-audience auth policy so the
test stays focused on transport + protocol; a future dual-keypair
test can layer permissions on top.
Skipped by default — set -DnestsInterop=true to enable.
|
||
|
|
beec8204e5 |
refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.
| Aspect | Before | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method | GET | POST |
| URL | `<base>/<roomId>` | `<base>/auth` |
| Body | none | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response | `{endpoint, token, codec, sample_rate}` | `{token}` only |
| Endpoint | from response | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98 | bound to GET URL | bound to POST URL + body hash |
Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
→ `NestsClient.mintToken(room, publish, signer): String`. The
publish flag drives the JWT claims (`get` for listeners, `put`
for speakers).
Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
NestsRoomConfig` instead of split (serviceBase, roomId), pass
`publish=false` / `publish=true` respectively, and use the room's
`endpoint` (not a server-returned one) for the WebTransport
connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
`Broadcasting` carry the `room: NestsRoomConfig` instead of the old
`roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
bytes are `room.moqNamespace()` — the simplest mapping to the
relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
test will confirm and adjust if the relay expects a multi-segment
tuple.
Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
with `(room: NestsRoomConfig)`. Connector seam interfaces
(NestsListenerConnector, NestsSpeakerConnector) follow the same
shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
`EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
+ roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
`EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
`event.kind` in addition to `event.service()`; rooms missing any
of those are silently un-joinable (the event author didn't host
on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
of (serviceBase, roomId) and threads it down.
Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.
Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.
Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
|
||
|
|
864d14379a |
Merge pull request #2587 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
5c9bb64ab6 | New Crowdin translations by GitHub Action | ||
|
|
5be38ef3ac |
Merge pull request #2586 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
2bd655e31d |
Merge pull request #2584 from vitorpamplona/claude/optimize-video-loading-5opqr
Optimize video playback with warm player pool and buffer tuning |
||
|
|
a8e7c6c598 |
revert(video): drop the videoPlayerButtonItemsFlow remember
The remember(accountViewModel) { ... } I added was cargo-cult. The
getter is just a chain of val property accesses
(account.settings.syncedSettings.videoPlayer.buttonItems) returning the
same StateFlow instance every call. collectAsStateWithLifecycle keys on
that flow reference, which is identity-stable, so re-calling the getter
on every recompose costs nothing meaningful and doesn't cause a
re-subscription. Inline back to the original one-liner.
|
||
|
|
45fb5119e8 |
revert(video): two cleanups from the round-4 pass that didn't earn their keep
- GifVideoView: revert the dimensions remember() to the original one-line expression. Unlike VideoView's equivalent block, GifVideoView only *reads* — there's no MediaAspectRatioCache.add() side effect to gate. The replaced code spent three slot reads + three equality checks per recompose to skip an int division and an LruCache.get(), neither of which allocates. It was a wash at best, a small loss at worst. The original is simpler and roughly the same cost. - PlaybackServiceClient: bump the executor from newSingleThreadExecutor() back up to newFixedThreadPool(4). The work per listener is genuinely trivial in the steady state, but a single thread leaves us exposed to one stuck listener (e.g. the defensive 5s controllerFuture.get() timeout actually firing) stalling every other video on screen behind it. With a feed often holding several visible videos at once, that's a real regression risk. A fixed pool of 4 keeps us bounded against churn while letting independent listeners proceed in parallel. |
||
|
|
3283d302fa |
test(audio-rooms): nostrnests interop harness + /auth ping (phase 1/3)
Brings up the nostrnests reference server (https://github.com/nostrnests/nests) locally via Docker Compose so we can drive `:nestsClient`'s production code against the real MoQ relay + NIP-98 auth sidecar. Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via `-DinteropHost=…`): - Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>). - Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`, pinned to the `DEFAULT_REVISION` (currently `main`; override via `-DnestsInteropRev=<sha>` to lock in for reproducibility). - `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay (host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes 4443 + 8090 with a 90 s timeout. - `close()` runs `docker compose down -v --remove-orphans`. Tests use `@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across all cases in one class. Phase-1 ping test (NostrNestsAuthInteropTest): - Generates an ephemeral KeyPair / NostrSignerInternal via Quartz. - POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>", "publish":true}`, NIP-98 Authorization header signed for that exact (url, method, payload) tuple. - Asserts 200 + a `"token":"…"` JWT in the response body. Doesn't yet route through `OkHttpNestsClient` because the production client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token, codec, sample_rate}`) does not match nostrnests' actual API (POST `<base>/auth` with `{namespace, publish}` body, returning just `{token}` — endpoint comes from the NIP-53 event's `endpoint` tag instead of the HTTP response). Phase 2 of this audit refactors production to match; this test documents the divergence on the wire so the refactor has a clear target. Verified: harness compiles clean; default `:nestsClient:jvmTest` shows the test as <skipped> (not <failure>) when `nestsInterop` property is unset. Files: - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt |
||
|
|
294ac3e74e | New Crowdin translations by GitHub Action | ||
|
|
d9b0f034f6 |
Merge pull request #2582 from vitorpamplona/claude/fix-translation-rendering-9fgk4
fix(translation): bug, perf and jitter overhaul of rich-text translation |
||
|
|
5ee09dfc04 |
Merge pull request #2583 from vitorpamplona/claude/fix-sqlite-parallel-inserts-Zx0Eu
Make event store operations async with coroutine support |
||
|
|
d7bd78cc32 |
chore(video): correctness and hygiene cleanups in playback layer
Round-up of the small leftovers from the audit. None move the needle on their own; together they remove a real cancellation bug and tighten the playback types. - PlaybackServiceClient.executorService: Executors.newCachedThreadPool() → Executors.newSingleThreadExecutor(). The work per callback is Future.get() on an already-completed future plus a non-blocking trySend; a single thread is plenty. The previous unbounded pool could spin up a thread per concurrent video, each lingering for the 60 s keep-alive afterwards. - MediaControllerState.controller: var → val. The field was never reassigned anywhere (grep confirms), and a non-observable var on a @Stable class is a footgun — Compose can't see writes to a plain var, so any future write would silently miss recomposition. - MediaControllerState.currrentMedia() → currentMedia(). Typo. Updated the single caller in PipVideoView. - LoadThumbAndThenVideoView: real cancellation bug fix. The Coil fetch was launched into AccountViewModel.viewModelScope via a side helper (loadThumb), so a scroll-away didn't cancel the in-flight image request — wasted bandwidth and a late callback writing into stale state. Inline the Coil call into the LaunchedEffect's own scope so cancellation propagates, and key the effect on thumbUri so a recycled audio-track slot with a new cover doesn't stall on the prior Pair(true, ...) gate. Drop the now-unused AccountViewModel.loadThumb and its only-here imports. |
||
|
|
63b20b88d0 |
refactor(translation): split UI from orchestration, dedupe boilerplate
Pure-readability refactor — no behaviour change, all 410 unit tests still pass.
TranslatableRichTextViewer.kt (358 → 192 lines)
- Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation
bridge + result validation + caching) into a private `suspend translateAndCache`
function. The effect body is now four lines: try/catch around one call.
- Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a
TranslationConfig only when an actual translation took place, replacing the
five-condition inline if/else inside the effect.
- Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling
file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer
and don't belong in the orchestrator file.
TranslationStatusBar.kt (new)
- Renamed the public composable to `TranslationStatusBar` to make its role obvious.
- Split the status text and the dropdown into separate private composables so each
fits on screen at a glance.
- Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four
`DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks.
- Hoist `rememberDeviceLocales()` out of the dropdown body for clarity.
- Cache `settings.preferenceBetween(source, target)` once per dropdown render
instead of calling it twice with identical args.
LanguageTranslatorService.kt
- Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)`
helper. `autoTranslate` is now three lines that read top-to-bottom:
pre-filter, dedupe, identifyLanguage → translateOrSkip.
- Promote the inline `when` deciding whether to translate (matches translateTo,
is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so
the policy is greppable.
TranslationDictionary.kt
- Add a `private inline fun Pattern.forEachMatch(text, block)` extension.
The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())`
loops collapse to three one-liners; the URL detector loop stays explicit because
it has its own filter.
`inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is
a zero-cost refactor at runtime.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
|
||
|
|
3bf1448d63 |
docs(quartz/store): explain the connection pool and the suspend API
- Add a Concurrency section to the SQLite store README covering the Room-style 1-writer + N-reader pool, the in-memory degradation, and the non-reentrant Mutex contract. - Refresh the SQLite "How to Use" examples to call out the suspend context and recommend transaction-batching for hot inserts. - Switch the ExpirationWorker example from Worker to CoroutineWorker now that deleteExpiredEvents is suspend. - Note in the FS README that the IEventStore API is suspend even though the FS layer keeps a synchronous flock manager (the withWriteLock helper is inline so suspend bodies pass through). - Update the FsMaintenanceTest description to match the coroutine-based concurrency test. - Document the Mutex non-reentrancy footgun in SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter the pool from inside useWriter. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 |
||
|
|
9fcf85bed0 |
fix(quartz/sqlite): serialise writes via a Room-style connection pool
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore shared a single lazy connection across all callers, so two coroutines calling insertEvent() at the same time would race on BEGIN IMMEDIATE and the modules' prepared statements, surfacing as "cannot start a transaction within a transaction" or SQLITE_MISUSE. Mirror Room's design: introduce SQLiteConnectionPool with one writer connection guarded by a coroutine Mutex and N reader connections handed out via a Channel-as-semaphore (file-backed DBs only; in-memory DBs share the writer because each ":memory:" connection is a separate DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore + LiveEventStore to suspend, route writes through useWriter and reads through useReader. RelaySession now launches handleEvent / handleCount on its scope. CLI Context helpers and StoreCommands.sweepExpired pick up suspend. Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200 inserts, parallel reads alongside writes, transaction batches across coroutines, and a reopen smoke test all pass against a file-backed DB. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 |
||
|
|
2a932dc974 |
fix(audio-rooms): clean up deferred audit items (VM + Android + MoQ comment)
Lands the audit follow-ups that didn't require external input. Only the wire-format draft pinning (MoQ #1, #8) remains deferred — that's gated on the M4 manual interop pass, and the same-room-PIP-re-entry corner case (Android #5) — Android has no programmatic PIP-exit API. ViewModel: - VM #10: serialize disconnect→connect via a tracked `pendingCloseJob`. teardown() records the listener.close() launch (when not finalCleanup); the next launchConnect() awaits it before opening a fresh transport. Eliminates the brief two-QUIC-session overlap that some MoQ relays reject by deduping on client pubkey. - VM #4: extracted shared connect-launch body into `launchConnect(triggerRetryOnFailure)` so connect() / connectInternal() share one implementation. Removed the near-duplicate viewModelScope.launch block. - VM #8b: setMicMuted no longer silently swallows handle failures. BroadcastUiState.Broadcasting gains a `muteError: String?` field that the UI can surface as an inline message; cleared on the next successful toggle. The broadcast itself stays running with its previous mute state — only the mute toggle failed. - VM #6: documented the dispatcher-confinement contract in the class kdoc. Audit was theoretical — every map mutation already runs on viewModelScope (Dispatchers.Main.immediate on Android, same dispatcher the MoQ flow's onEach callback uses because the player launch lives in viewModelScope). Future cross-thread callers must marshal explicitly. Android: - Android #2: AudioRoomActivity.toggleMuteSignal type tightened from `MutableSharedFlow<Unit>` to `SharedFlow<Unit>` so external code can't tryEmit into it. Internal emit uses the private `_toggleMuteSignal`. - Android #10: presence debounce-publisher (the LaunchedEffect keyed on micMutedTag) now skips entirely when micMutedTag is null. Stops the duplicate first-frame publish where heartbeat fires immediately AND debounce-publisher fires 500 ms later, both with muted=null. Once the user goes live the debounce-publisher kicks in for state changes. MoQ session: - MoQ #11: send() rollback comment rewritten to say "monotonic; gaps acceptable per spec, this just minimises them on full-fanout failures" instead of the misleading "strictly contiguous" claim. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. Still deferred: - MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs M4 interop input from `nostrnests.com` to know what the relay actually speaks. - Android #5: same-room re-entry from MainActivity while in PIP doesn't auto-exit PIP. Android has no programmatic PIP-exit API; user must tap the expand button. Corner case. - Test coverage gaps (round-1 VM #10, round-2 VM #13): retry-counter + broadcast state + setMicMuted-no-handle + server-Closed cleanup + double-connect-while-Failed. Each is a small dedicated test using the existing connector-seam pattern; landing as a separate test-only commit. |
||
|
|
ba1a1bfc12 |
perf(video): shorten VideoCache warmup delay from 10s to 1.5s
The existing background warmup in Amethyst.initiate() deferred the lazy
videoCache touch by 10 seconds. SimpleCache's constructor opens a SQLite
index via StandaloneDatabaseProvider and walks every cached span on disk
— a few hundred ms on a populated 4 GB cache — so we really do not want
that running on the main thread.
But 10 s is long enough that a fast user (or a deep link / push notification
that lands directly on a video-bearing screen) can win the `lazy { }` race
and trigger init on the main thread inside PlaybackService.onGetSession,
which is exactly the hitch the warmup was meant to prevent.
Drop to 1.5 s — long enough to let the urgent first-paint work above
(account load, image loader, ui state, robohash) breathe, short enough
that a typical user can't scroll and tap a video before the warmup wins.
Document the trade-off in a comment so the timing isn't a magic number.
|
||
|
|
c6b275e7fe |
perf(video): tighten remember keys and stabilize controller-overlay tree
Round-4 audit cleanups. Each item is small but each runs on the hot path
that recomposes during every active video, so they add up while scrolling.
P1 — DimensionTag identity invalidating remember:
- DimensionTag (in quartz) is a regular class with no equals override, so
reference equality means a freshly parsed tag for the same event is !=
to the previous one. The remember(videoUri, dimensions) blocks added in
the earlier perf commits were re-running their lambda on every recompose.
Switch to primitive (width, height) keys in VideoView and GifVideoView so
the cache lookups + MediaAspectRatioCache writes only fire when the
dimensions actually change.
P1 — Static gradient brushes:
- TopGradientOverlay / BottomGradientOverlay were calling
Brush.verticalGradient(colors = colors) inside the modifier chain, which
allocated a fresh Brush on every recomposition while the controllers
were visible (i.e. on every active video most of the time). Pre-build
both brushes as file-level vals so they're allocated exactly once per
process.
P2 — ImmutableList for action collections:
- RenderTopButtons / AnimatedOverflowMenuButton / OverflowMenuButton were
passing List<VideoPlayerAction> across composable boundaries. Plain List
is unstable in Compose, forcing the overflow tree to recompose any time
an unrelated parent state (volume, tracks, controllerVisible) ticked.
Use ImmutableList end-to-end via toImmutableList() at the producer side.
P2 — videoPlayerButtonItemsFlow remember:
- accountViewModel.videoPlayerButtonItemsFlow() was being called fresh
every recomposition, with the result handed straight to
collectAsStateWithLifecycle. Hoist the call into remember(accountViewModel)
so the flow reference is stable.
P2 — MuteButton dispatcher cleanup:
- The 2-second hold timer was using LaunchedEffect { launch(Dispatchers.IO)
{ delay(2000); holdOn.value = false } }. The wrapped launch was just
redundant dispatcher hopping — delay() doesn't hold a thread and the
Compose write is fine on Main. Inline it.
|
||
|
|
ed793e8eb3 |
fix(audio-rooms): round-2 audit — pump self-join (CRITICAL) + 5 other findings
Round 2 audit (3 parallel agents reviewing every change since the previous follow-up commit) caught one CRITICAL regression and several HIGH/MED items. Most round-1 fixes verified clean. CRITICAL fix (audit round-2 MoQ #1): - Pump exception handlers added in the previous commit call `close()` from inside the failing pump's own coroutine. `close()` now does `controlPumpJob?.join()` to drain in-flight writes — but the Job we try to join is the very Job we're inside, so `join()` suspends forever (lambda can't finish until close returns; close can't return until lambda finishes). `runCatching` doesn't help — `join()` doesn't throw, it suspends. This deadlocks the entire session whenever a pump fails. Fix: skip the join when the current coroutine IS the job we're joining. `currentCoroutineContext()[Job]` identifies the caller; we compare and bypass. HIGH fixes: - MoQ #2 (regression): concurrent unannounce() + post-OK AnnounceError handler could both write UNANNOUNCE on the wire (some relays disconnect on UNANNOUNCE for an unknown namespace). Fix: `AnnounceHandleImpl.unannounceWritten: Boolean` flag, set under stateMutex by whichever writer goes first; the other path skips. - VM #3 (new): `connect()` overwrote `listener` if invoked from a Failed-with-stale-listener state, leaking the previous MoQ session. Fix: call `teardown(targetState=Idle, finalCleanup=false)` before launching the new attempt when listener or stateObserverJob is still alive. - VM #7 (new): `openSubscription` allocated decoder + player via the factories, then attached them to the slot. If the VM scope was cancelled between `decoderFactory()` and `slot.attach(...)`, the native MediaCodec / AudioTrack leaked because nothing was tracking them yet. Fix: nest a try/catch that releases both on any throw (including `CancellationException`) before re-throwing. MED fixes: - MoQ #7 (new): `capture.start()` could throw before `job` was assigned, leaving the broadcaster in a half-started state where future `start()` calls would re-pass the guards and double-start the mic. Fix: try/catch around capture.start; on throw, set `stopped = true` + run capture.stop and propagate. - MoQ #8 (new): `stopped` was read across threads (setMuted from caller, stop from anywhere) without a `@Volatile` barrier. Visibility hazard. Fix: `@Volatile private var stopped`. - Android #12 (new): after the user granted RECORD_AUDIO via the Settings deep-link, `permissionDenied` stayed `true` because the launcher callback never fired — the warning + Open-settings button remained visible until the user tapped Talk again. Fix: derive `showDenialWarning` from `permissionDenied AND ContextCompat.checkSelfPermission(...) != GRANTED`. Re-checks every recomposition (including post-Settings return). Round-1 fixes verified clean by this audit: - pending-deferred completeExceptionally on close - SubscribeDoneStatus codes (UNSUBSCRIBED=0x00, TRACK_ENDED=0x03) - suspend `stop()` conversions on broadcaster + player - gate release before NestsSpeaker teardown chain - unannounce() ordering on thrown wire-write - SharedFlow PIP signal (rapid double-tap behavior is correct) - RECEIVER_NOT_EXPORTED gate (constant 4 doesn't collide; round-1 collision claim was incorrect) - onUserLeaveHint guards (PIP from lobby, no PIP support) - foreground service `startForeground`-always-first contract (`Result.onFailure` is `inline`, the `return` IS a non-local return from `onStartCommand` — verified) - 4-hour wake-lock cap - AudioRoomBridge.clear() in AccountViewModel.onCleared Still deferred: - VM #6 (round-1 carryover): unsynchronized speakingExpiryJobs map. Cross-thread mutation under contention. Needs ConcurrentHashMap or Dispatchers.Main.immediate marshalling. - VM #10 (round-2 new): brief two-QUIC-session overlap during rapid disconnect→connect. - Android #5 (round-2 new): same-room re-entry from MainActivity while in PIP doesn't auto-exit PIP. - MoQ #1, #8 (round-1 carryover): wire-format draft pinning. Still blocked on M4 manual interop input. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. |
||
|
|
0b1ec52f79 |
fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED findings from the protocol / ViewModel / Android lifecycle audits that weren't fixed in the previous commit. The wire-format draft pinning (audit MoQ #1, #8) is intentionally deferred until the M4 manual interop pass against `nostrnests.com` reveals which draft revision the relay is actually speaking. MoQ session fixes: - #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns], so an inbound SUBSCRIBE during the teardown window sees the namespace as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST)) instead of "namespace never existed". - #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and post-OK errors. Post-OK is a session-level kick: mark the handle closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the optimistic announces[] insert as before. - #6 close() now joins the cancelled control + datagram pumps before calling controlStream.finish(), so an in-flight SUBSCRIBE_OK / SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its writeMutex.withLock { ... } critical section. Previously cancellation could truncate a frame mid-flight and we'd send FIN over a corrupted stream. - #9 pumps wrapped in try/catch that calls close(...) on unexpected exceptions, so a transport-died-mid-session no longer leaves the session thinking it's healthy with new subscribe/announce calls hanging on a dead peer. - #10 TrackPublisher.send rolls back nextObjectId when every datagram fan-out fails (transport down). The audio-rooms NIP wants strictly contiguous object ids per group; a gap from a fully-failed send would trip strict subscribers. - #13 DefaultNestsSpeaker.close drops `gate` before calling activeHandle.close() / session.close(). The teardown chain runs cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached subscriber + joins MoQ pumps; holding the gate through all of that blocked any other concurrent API call on this speaker. Resource lifecycle (audit MoQ #11/#12): - AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before releasing the encoder + closing the publisher. The loop's last encoder.encode/publisher.send no longer races encoder.release()/publisher.close() — both produced use-after-release on native MediaCodec on Android, the latter sent orphan OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE. - AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the same reason: decoder.release() ran while the decode loop was still inside MediaCodec.decode(...), undefined behaviour. Updated VM call sites (closeSubscription, teardown) to route both player.stop() and handle.unsubscribe() through one launched coroutine via the new `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape. ViewModel fixes: - #4 auto-retry uses a single `retryPending: Boolean` flag instead of `Job.isActive`. Two scheduleAutoRetry calls could previously both pass `Job.isActive == false` (the launched body had just started) and stack a second retry on top of one already running. - #7 setMicMuted updates the UI INSIDE the launched coroutine, after the suspending broadcastHandle.setMuted() returns. Previously the indicator could claim "muted" while audio was still on the wire if the handle's setMuted suspended on a gate. - #8 connect() cancels the previous stateObserverJob before kicking off the new attempt, so a delayed Failed/Closed emission from the old listener can no longer clobber the fresh Connecting UI. - #9 disconnect() clears requestedSpeakers, so a fresh connect() to a different room (or the same room after a long pause) doesn't reuse a stale speaker snapshot. - #12 updateSpeakers filters out the user's own pubkey: subscribing to your own forwarded audio would echo through the local playback device whenever the broadcast track loops back from the relay. Android lifecycle / PIP / service: - #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9 (landscape) so the row of avatars under the title actually fits. - #7 process-death recovery: when AudioRoomBridge is empty (previous process's AccountViewModel is gone), redirect to MainActivity before finish() so the user lands somewhere meaningful instead of a black-flash. - #8 AudioRoomForegroundService.onStartCommand always calls startForeground first, on every invocation including ACTION_STOP. startForegroundService's 5-second contract requires it; previously the STOP path skipped it. startForeground itself wrapped in runCatching so a foreground-not-allowed exception bails cleanly rather than leaking the wake-lock. - #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that fail to detect a network drop no longer hold the device awake for half a day. - #11 presence-event spam fix: split the publish loop into a heartbeat keyed only on (address, handRaised) and a separate debounced state-change publisher keyed on micMutedTag. Every mute toggle previously triggered a full sign + publish + relay round trip; now we coalesce within a 500 ms window. - #12 final "leaving" presence routed through GlobalScope.launch instead of rememberCoroutineScope (which is cancelled on dispose, so the leave event almost never reached the relay). - #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't ask again", the launcher silently returns false. New "Open settings" button deep-links to the app's settings page so the user can re-grant the permission and try again. - #15 setPictureInPictureParams now updates outside PIP too, so the next entry shows the correct mute-state icon without an extra flip. - #16 onNewIntent override: a second Join tap for a different room finishes the current Activity and starts a fresh one with the new extras, instead of singleTask silently keeping the old room running. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. Audit findings still deferred (all documented inline / in this commit): - MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs the M4 manual interop pass to confirm what nests is actually speaking. - VM #6: confined map mutation under a single dispatcher. Current setup (Dispatchers.Main via setMain in tests, viewModelScope in prod) is functionally fine; full belt-and-suspenders confining is a separate concurrency review. - VM #10: test coverage gaps for retry + speaker reconcile cycle + setMicMuted no-handle case + server-initiated Closed leaves stale state. Each is a dedicated test. - Android #18: startListening / promoteToMicrophone race. Mitigation (always declaring microphone foreground type) requires unconditional RECORD_AUDIO grant which listener-only users won't have. |
||
|
|
f0b27654ba |
test(audio-rooms) + fix: round-trip test + audit pass
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.
Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
through a hand-rolled in-test relay coroutine that mirrors a real
MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
subscriber path, asserting payload bytes + monotonic group/object
ids round-trip correctly. Catches any drift between our publisher
and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
back as MoqProtocolException when the publisher hasn't openTrack'd.
MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
(peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
session close were `cancel()`-ed, which propagates as
CancellationException — caller's entire scope cancels instead of
catching a domain MoqProtocolException. Switched all sites to
`completeExceptionally(MoqProtocolException("session closed"))`
including unsubscribe-while-pending-OK.
ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
&& !closed` AFTER the suspending `subscribeSpeaker` returns; if the
user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
a discarded slot.
- Server-initiated `Closed` listener state now triggers
`teardown(targetState=Closed)` and resets the auto-retry counter,
so a transport-died-mid-handshake doesn't leave stale subscriptions
in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
through `viewModelScope` (still alive); `onCleared()` routes it
through a process-lived `cleanupScope` (default GlobalScope, tests
pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
transport drops, instead of being eaten by the cancelled
viewModelScope.
Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
to the existing CallSessionBridge.clear() — no more cross-account
AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
ui.connection == Connected AND PackageManager
FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
PIP-on-incompatible-device doesn't leave a frozen full-screen
card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
emission from a torn-down Activity can't leak into a new one.
ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.
Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.
Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
(audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
(audit Android #8).
|
||
|
|
c9a19b90f0 |
perf(video): retain warm ExoPlayers and clear up the controller hot path
Closes the remaining audit items so the eager-prepare model also pays off on scroll-back. The big change is keeping the most recent N feed players paused-with-buffer instead of stop()'ing them on release. P0 — Warm-slot ExoPlayer pool: - ExoPlayerPool now retains up to N (default 3) paused players keyed by the mediaId they last loaded. Acquire takes an optional preferredMediaId hint and returns the matching warm player intact; only the cold fallback path runs stop()/clearMediaItems(). Warm slots count against the device's MediaCodec budget (poolSize) so the cold cap is poolSize - warmSize, with warm slots themselves capped at poolSize-1 to guarantee there's always at least one cold slot for a brand-new URI. - Plumb videoUri through connection hints (PlaybackServiceClient -> PlaybackService.onGetSession -> MediaSessionPool.getSession -> ExoPlayerPool.acquirePlayer) so the service can find a warm match. Constants for the bundle keys live on PlaybackService. - GetVideoController.onEach now checks state.controller.currentMediaItem?.mediaId before calling setMediaItem. On a warm hit it leaves the player and its buffer alone — calling setMediaItem in that case would reset the player and undo the whole point of the warm pool. STATE_IDLE survivors still get a re-prepare. P1 — Stop rebuilding the MediaController on transient lifecycle dips: - GetVideoController.collectAsStateWithLifecycle was tearing down and re-binding the MediaController every time the activity lifecycle dropped below STARTED (system dialogs, briefly switching apps, notification shade). Switch to plain collectAsState — the controller now lives until the composable actually leaves composition, so a brief lifecycle dip no longer costs a full IPC rebind + buffer reload. Real backgrounding still tears down via composable disposal. P2 — Smaller fixes flushed at the same time: - GetVideoController: only write controller.volume when it differs from target. Combined with the new dedup, several feed videos preloading no longer fire one volume IPC per ready callback. - CurrentPlayPositionCacher: the resume threshold was `5 * 60`, which in milliseconds is 300 ms — i.e. "always seek". Bump to 5_000 (5 s) so trivially short clips don't pay an extra seek + buffer flush at STATE_READY just to land 100 ms away from where they started. - MediaSessionPool: stop allocating a fresh DataSourceBitmapLoader per session — the loader has no per-session state, so it's now a single lazy instance shared across all sessions in a pool. - MediaSessionPool.cleanupUnused was racy: concurrent releases all won the time check and each launched a redundant sweep coroutine. Replace with a CAS-guarded AtomicLong on a nano-precision timestamp. |
||
|
|
91d194b11e |
test(translation): JVM unit tests for placeholder dictionary round-trip
Extracts the placeholder dictionary logic out of LanguageTranslatorService into a
pure-JVM TranslationDictionary helper so the round-trip can be unit-tested
without ML Kit / Android runtime, and adds 24 tests covering it.
The existing TranslationsTest is androidTestPlay-only — it needs a real device or
emulator with Google Play services, so it can't validate a refactor in plain CI
or local dev. The new tests cover the riskiest part of this branch: that PUA
placeholders survive an arbitrary "translation" of the surrounding text and
decode back to the exact original tokens.
Test coverage
- isWorthTranslating: short text / letterless text rejected, mixed letters+emoji accepted
- placeholder: produces single PUA codepoint, rejects out-of-range index
- build: collects URLs, NIP-19 nostr refs, Lightning invoices, NIP-08 #[N] refs
- build: deduplicates repeated occurrences and skips Chinese-punctuation URL false-positives
- encode/decode round-trip on plain URLs, multi-URL strings, and mixed real-world content
- encode replaces longer values first to avoid prefix collisions
- decode preserves user text containing the OLD "B0/C0/A0" tokens (regression for the
pre-rewrite collision bug)
- case-sensitive replacement preserves user text that differs only in case from a placeholder
- decode handles null and empty-dictionary inputs
- simulated translation (rewrite English to Portuguese around the placeholders) round-trips
#[0] and nostr:nevent1... unchanged
LanguageTranslatorService now delegates to TranslationDictionary.{build, encode,
decode, isWorthTranslating} — public API (autoTranslate / translate /
identifyLanguage / clear) and behaviour are unchanged.
Result: 410 tests run, 408 passed, 2 pre-existing skips, 0 failures.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
|
||
|
|
c1c52aa022 |
fix(deb): broaden libicu Depends so .deb installs across Debian/Ubuntu
jpackage runs `dpkg-shlibdeps` against the bundled JDK runtime's native libs (libfontmanager.so etc. link libicu) and pins Depends to the build host's libicu version. CI builds on ubuntu-24.04, so the .deb requires libicu74 — uninstallable on Ubuntu 22.04 (libicu70), Debian 12 (libicu72), Debian 11 (libicu67), and Debian 13 (libicu76). Neither jpackage nor the Compose Multiplatform DSL exposes a way to override the auto-generated Depends. Add scripts/relax-deb-libicu.sh which extracts the .deb with `dpkg-deb -R`, rewrites `libicuNN` (or any alternation thereof) to `libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77`, and repacks. The script is idempotent and a no-op for .debs without a libicu Depends. Wire it into both release legs (desktopApp + amy CLI) in create-release.yml, and into the desktop test/build leg in build.yml so testers downloading the CI artifact hit the same fix. |
||
|
|
de31d37c01 |
Merge pull request #2580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
ed2419b6c7 | New Crowdin translations by GitHub Action | ||
|
|
30c2cca8a4 |
Merge pull request #2578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
24b8fa12b4 |
fix(translation): bug, perf and jitter overhaul of rich-text translation
Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService that caused stale translations, redundant ML Kit work, and visible jitter on every note that scrolls into view. Bugs fixed - Effect now actually re-runs when "Translate to" / "Don't translate from" change. Previously LaunchedEffect(Unit) snapshotted the settings once and ignored subsequent updates. - Translation cache now keys on (content, translateTo, dontTranslateFrom) instead of just content, so changing the target language no longer serves a stale translation in the wrong language. - Cancelled / "no translation needed" outcomes are now cached, so language identification no longer re-runs on every recomposition / scroll-back of text in the user's own language or in the don't-translate set. - ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with ensureActive() checks; cancelling the composable's coroutine no longer races against an in-flight callback that mutates Compose state after disposal. - Encoded placeholders no longer collide with arbitrary user text. Replaced the old "B0/C0/A0" tokens (which a user could legitimately type) with single Unicode Private Use Area codepoints, and made replacement case-sensitive so e.g. "b0" in body text is no longer rewritten on decode. - Translation pipeline propagates failures: continueWith now rethrows task.exception instead of silently calling .result on a failed sub-task. - buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder table, replacing the fragile post-translation "# [" -> "#[" string fix. Performance - LanguageTranslatorService de-duplicates concurrent translation requests for the same (text, settings) via an in-flight ConcurrentHashMap, so reposts / notifications / threads sharing the same content fire one ML Kit pipeline instead of N. - executorService is now a private bounded fixed pool sized on availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool that could spawn dozens of threads under heavy scroll. - Skip ML Kit entirely for texts shorter than 4 chars or with no letter codepoints (emoji-only, punctuation) — language identification is unreliable there anyway. - Translation cache bumped from 100 to 500 entries to cover long threads / long-form articles. - Single-call translation (one ML Kit call for the whole text) preserves sentence-level context across paragraphs that the old per-line split discarded. Jitter - Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references twice) during the ~300ms crossfade whenever a translation arrived. Body now swaps directly; only the translation toggle hint sits below. - Replaced derivedStateOf around a trivial ternary with a plain expression. - Locale.forLanguageTag(...).displayName memoized per source/target tag so the CLDR display-name lookup doesn't run on every recomposition of the "Translated from X to Y" hint. - Device-locale list lifted out of the dropdown render loop and remembered, so ConfigurationCompat.getLocales no longer fires per recomposition while the language menu is open. - Dropdown body is only composed when expanded — it was already cheap inside Material3's DropdownMenu, but skipping the wrapper composition entirely is measurably tighter. The exposed API (LanguageTranslatorService.autoTranslate / .translate / .identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's two public composables keep their signatures, so the ~30 call sites and the existing TranslationsTest don't need any updates. TranslationConfig drops the showOriginal field — that toggle is now derived live from AccountLanguagePreferences.preferenceBetween(...) so changing the user's language preference is reflected immediately without invalidating the cache. https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5 |
||
|
|
a11ba2e55d |
perf(video): warm up ExoPlayer pool, tune LoadControl, fix notification fall-through
Three small infrastructure fixes that support the eager-prepare model (every visible feed video calls setMediaItem + prepare immediately so it's ready when the user scrolls to it). - ExoPlayerPool.create(): the warmup that builds poolStartingSize players up front was already written but never invoked. Wire it from PlaybackService.lazyPool() the first time a pool is requested. The builds now run on the pool's main-looper scope with a yield() between each so they're spread across frames instead of stalling the UI in one ~150–600 ms burst. Idempotent via an AtomicBoolean. - ExoPlayerBuilder: install a feed-tuned DefaultLoadControl (10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every visible video preloads, so 5 simultaneous players were each trying to buffer 50s ahead — fighting for network and burning ~30 MB of buffer per HD player. Capping at 15s keeps the active video smooth, lets it start playing as soon as ~750 ms is buffered, and slashes peak memory on feeds with several preloads. - PlaybackService.onUpdateNotification: the third forEachIndexed loop was missing its return, so on the muted-but-playing fallback path super.onUpdateNotification was called once per playing session instead of once total. With multiple feed videos preloading simultaneously this was hammering the notification system every time a player changed state. Match the first two loops by returning after the first match. Also drop the unused `idx` from forEachIndexed. |
||
|
|
3816e471b7 |
refactor(audio-rooms): dedicated AudioRoomActivity with PIP — replaces in-channel AudioRoomStage
Moves the entire audio-room session into its own Activity with Picture-in-Picture support. The chat-room screen (ChannelView) now shows a thin lobby card (`AudioRoomJoinCard`) with a Join button; tapping it launches `AudioRoomActivity`, which owns the MoQ session, the broadcaster, presence (kind 10312), hand-raise, and the foreground service for screen-locked playback. Why: the previous design tied the audio session to the chat-room screen's Composable lifecycle, which (a) made "navigate away keeps audio playing" require a process-level workaround, (b) tied hand-raise + presence to a UI screen even when the user wasn't audio- connected, (c) had no clear home for PIP. A dedicated Activity mirrors the existing CallActivity pattern: clear lifecycle, Activity finish() = leave room, PIP = keep speakers visible while doing other things. Files split into focused units (the original single-file design hit generation limits): - `AudioRoomActivity.kt` (~200 lines): Activity class, PIP entry on onUserLeaveHint, PIP RemoteAction wiring (mute / leave) with a BroadcastReceiver that signals a shared toggle into the composable. Manifest registers it with `supportsPictureInPicture=true`, `launchMode=singleTask`, `resizeableActivity=true`. - `AudioRoomActivityContent.kt`: top-level composable. Loads the addressable note, constructs the VM via factory, auto-connects on entry, runs the presence loop (with mic-mute reflected in the kind 10312 event), drives the foreground service, flips between full-screen and PIP layouts. - `AudioRoomFullScreen.kt`: full-screen layout — title, summary, on-stage / audience rows with active-speaker rings, ConnectionRow, TalkRow (RECORD_AUDIO permission flow inside), hand-raise, Leave button. - `AudioRoomPipScreen.kt`: compact PIP layout — up to 4 on-stage avatars with speaking ring + room title. - `AudioRoomCommon.kt`: shared StagePeopleRow + connectingLabel. - `AudioRoomJoinCard.kt`: lobby card in ChannelView. Title + summary + Join button. Hidden when the event has no `service` tag. - `AudioRoomBridge.kt`: process-level singleton that hands the active AccountViewModel to the standalone Activity (mirrors CallSessionBridge). - `AudioRoomViewModelFactory.kt`: extracted from the deleted AudioRoomStage; binds the Android audio actuals (OkHttp, QUIC, MediaCodec, AudioTrack, AudioRecord). Deleted: `AudioRoomStage.kt` (replaced by the lobby card + the Activity). ChannelView wiring changed from `AudioRoomStage(channel, …)` to `AudioRoomJoinCard(channel, …)` — one-line swap. Strings: 2 new (`audio_room_join`, `audio_room_leave`). Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest :amethyst:compilePlayDebugKotlin` all green. |
||
|
|
8e60a79eaf |
perf(video): reduce jitter and per-recomposition work in note video pipeline
Tightens the hot path that runs whenever a video appears inside a note in the feed (RichText -> ZoomableContentView -> VideoView). - VideoView: resolve aspect ratio once per (uri, dim), and prime MediaAspectRatioCache from the imeta dim tag so repeat appearances (PiP, dialog, list re-enter) don't have to wait for ExoPlayer's onVideoSizeChanged before reserving layout space. - VideoView: key the manual "tap to show" toggle on videoUri so a recycled feed slot doesn't inherit stale state from the previous video. - VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) — the result was being recomputed every recomposition only to be dropped by GetMediaItem's URI-keyed remember. - RenderVideoPlayer: stop holding container size in compose state. The size is only read in onDoubleTap, so a non-state IntArray holder removes a recomposition of the whole player tree on every layout pass. Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run on every recomposition. - RenderTopButtons: same isLiveStreaming() memoization. - GetVideoController: switch the remaining non-lambda Log.d call to the lambda overload so the message string isn't formatted when the log level is filtered out. |