From 1fd89b23e5cf5094d7bb1bb3c1ef93f7637d0b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 01:57:16 +0000 Subject: [PATCH 01/18] feat: audio rooms drawer listing (NIP-53 kind 30312) Adds a new "Audio Rooms" entry in the drawer feeds section that lists NIP-53 kind 30312 (Interactive Rooms / audio spaces) events, mirroring the existing Live Streams architecture. - AudioRoomsFeedFilter narrows LocalCache.liveChatChannels to MeetingSpaceEvent (30312) and MeetingRoomEvent (30313), sorting by OPEN > PRIVATE > CLOSED then by follow participation. - AudioRoomsFilterAssembler/SubAssembler reuse makeLiveActivitiesFilter so the wire-level REQs are shared with the Live Streams screen. - AudioRoomsScreen/TopBar/FeedLoaded follow the Live Streams layout and render via ChannelCardCompose. This is phase 1 of the Clubhouse/nests integration plan: it only adds the discovery surface. Joining, presence, audio transport (MoQ) and room creation will arrive in subsequent PRs. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 10 ++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 4 + .../audiorooms/AudioRoomsFeedLoaded.kt | 74 +++++++++ .../loggedIn/audiorooms/AudioRoomsScreen.kt | 113 +++++++++++++ .../loggedIn/audiorooms/AudioRoomsTopBar.kt | 70 ++++++++ .../audiorooms/dal/AudioRoomsFeedFilter.kt | 149 ++++++++++++++++++ .../datasource/AudioRoomsFilterAssembler.kt | 50 ++++++ .../AudioRoomsFilterAssemblerSubscription.kt | 48 ++++++ .../datasource/AudioRoomsSubAssembler.kt | 103 ++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 14 files changed, 630 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index ef92b6ebb..d94273492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler @@ -105,6 +106,7 @@ class RelaySubscriptionsCoordinator( val shorts = ShortsFilterAssembler(client) val publicChats = PublicChatsFilterAssembler(client) val liveStreams = LiveStreamsFilterAssembler(client) + val audioRooms = AudioRoomsFilterAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) @@ -129,6 +131,7 @@ class RelaySubscriptionsCoordinator( publicChats, followPacksList, liveStreams, + audioRooms, longs, articles, badges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 207e2e11e..fd105237f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -67,6 +67,7 @@ object ScrollStateKeys { const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed" const val FOLLOW_PACKS_SCREEN = "FollowPacksFeed" const val LIVE_STREAMS_SCREEN = "LiveStreamsFeed" + const val AUDIO_ROOMS_SCREEN = "AudioRoomsFeed" const val LONGS_SCREEN = "LongsFeed" const val ARTICLES_SCREEN = "ArticlesFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0ab6ef971..f1ec9ca6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.AudioRoomsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen @@ -246,6 +247,7 @@ fun BuildNavigation( composableFromEnd { PublicChatsScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } + composableFromEnd { AudioRoomsScreen(accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 18e26e0a5..c6bc946bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.EmojiEmotions import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.PlayCircle @@ -72,6 +73,7 @@ enum class NavBarItem { PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, + AUDIO_ROOMS, LONGS, POLLS, BADGES, @@ -240,6 +242,13 @@ val NavBarCatalog: Map = icon = NavBarIcon.Vector(Icons.Outlined.Sensors), resolveRoute = { Route.LiveStreams }, ), + NavBarItem.AUDIO_ROOMS to + NavBarItemDef( + id = NavBarItem.AUDIO_ROOMS, + labelRes = R.string.audio_rooms, + icon = NavBarIcon.Vector(Icons.Outlined.Mic), + resolveRoute = { Route.AudioRooms }, + ), NavBarItem.LONGS to NavBarItemDef( id = NavBarItem.LONGS, @@ -326,6 +335,7 @@ val DrawerFeedsItems: List = NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, + NavBarItem.AUDIO_ROOMS, NavBarItem.LONGS, NavBarItem.POLLS, NavBarItem.BADGES, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f84f394bb..7844f28fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -89,6 +89,8 @@ sealed class Route { @Serializable object LiveStreams : Route() + @Serializable object AudioRooms : Route() + @Serializable object Longs : Route() @Serializable object Articles : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 6c5043e1a..5c52c7b27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.dal.AudioRoomsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter @@ -100,6 +101,7 @@ class AccountFeedContentStates( val publicChatsFeed = FeedContentState(PublicChatsFeedFilter(account), scope, LocalCache) val followPacksFeed = FeedContentState(FollowPacksFeedFilter(account), scope, LocalCache) val liveStreamsFeed = FeedContentState(LiveStreamsFeedFilter(account), scope, LocalCache) + val audioRoomsFeed = FeedContentState(AudioRoomsFeedFilter(account), scope, LocalCache) val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache) val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) @@ -165,6 +167,7 @@ class AccountFeedContentStates( publicChatsFeed.updateFeedWith(newNotes) followPacksFeed.updateFeedWith(newNotes) liveStreamsFeed.updateFeedWith(newNotes) + audioRoomsFeed.updateFeedWith(newNotes) longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) @@ -211,6 +214,7 @@ class AccountFeedContentStates( publicChatsFeed.deleteFromFeed(newNotes) followPacksFeed.deleteFromFeed(newNotes) liveStreamsFeed.deleteFromFeed(newNotes) + audioRoomsFeed.deleteFromFeed(newNotes) longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt new file mode 100644 index 000000000..fabf59ea4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AudioRoomsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = "AudioRoomsFeed", + modifier = Modifier.fillMaxWidth(), + forceEventKind = MeetingSpaceEvent.KIND, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt new file mode 100644 index 000000000..dc7669506 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssemblerSubscription + +@Composable +fun AudioRoomsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + AudioRoomsScreen( + audioRoomsFeedContentState = accountViewModel.feedStates.audioRoomsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun AudioRoomsScreen( + audioRoomsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(audioRoomsFeedContentState) + WatchAccountForAudioRoomsScreen(audioRoomsFeedState = audioRoomsFeedContentState, accountViewModel = accountViewModel) + AudioRoomsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + AudioRoomsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.AudioRooms, accountViewModel) { route -> + if (route == Route.AudioRooms) { + audioRoomsFeedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(audioRoomsFeedContentState, true) { + SaveableFeedContentState(audioRoomsFeedContentState, scrollStateKey = ScrollStateKeys.AUDIO_ROOMS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = audioRoomsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "AudioRoomsFeed", + onLoaded = { loaded -> + AudioRoomsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForAudioRoomsScreen( + audioRoomsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveLiveStreamsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + audioRoomsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt new file mode 100644 index 000000000..c76315864 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AudioRoomsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultLiveStreamsFollowList + .collectAsStateWithLifecycle() + + AudioRoomsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultLiveStreamsFollowList, + ) + } +} + +@Composable +private fun AudioRoomsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt new file mode 100644 index 000000000..e38167b7b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag + +/** + * Drawer feed for NIP-53 kind 30312 (Interactive Rooms / audio spaces). + * + * Shares LocalCache.liveChatChannels with the Live Streams feed, but narrows + * to MeetingSpaceEvent (30312) and MeetingRoomEvent (30313) so that the + * Clubhouse-style audio-room surface is independent of video live streams. + */ +class AudioRoomsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 50 + + fun followList(): TopFilter = account.settings.defaultLiveStreamsFollowList.value + + private fun TopFilter.isMuteList() = this is TopFilter.MuteList + + private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + private fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val allRoomNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } + return sort(innerApplyFilter(allRoomNotes)) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + val filterParams = + FilterByListParams.create( + followLists = account.liveLiveStreamsFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + (noteEvent is MeetingSpaceEvent || noteEvent is MeetingRoomEvent) && + filterParams.match(noteEvent, it.relays) + } + } + + override fun sort(items: Set): List { + val topFilter = account.liveLiveStreamsFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is MutedAuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors + + val counter = ParticipantListBuilder() + val participantCounts = items.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) } + + return items + .sortedWith( + compareBy( + { convertStatusToOrder(it.event) }, + { participantCounts[it] }, + { allParticipants[it] }, + { + when (val e = it.event) { + is MeetingRoomEvent -> e.starts() ?: it.createdAt() + else -> it.createdAt() + } + }, + { it.idHex }, + ), + ).reversed() + } + + private fun convertStatusToOrder(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Int { + if (event == null) return 0 + return when (event) { + is MeetingSpaceEvent -> { + when (event.status()) { + StatusTag.STATUS.OPEN -> 2 + StatusTag.STATUS.PRIVATE -> 1 + StatusTag.STATUS.CLOSED -> 0 + else -> 0 + } + } + + is MeetingRoomEvent -> { + when (event.status()) { + com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.LIVE -> 2 + com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.PLANNED -> 1 + com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.ENDED -> 0 + else -> 0 + } + } + + else -> { + 0 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt new file mode 100644 index 000000000..7345e132e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class AudioRoomsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class AudioRoomsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + AudioRoomsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt new file mode 100644 index 000000000..9e536a8f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun AudioRoomsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + AudioRoomsFilterAssemblerSubscription( + accountViewModel.dataSources().audioRooms, + accountViewModel, + ) +} + +@Composable +fun AudioRoomsFilterAssemblerSubscription( + dataSource: AudioRoomsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + AudioRoomsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt new file mode 100644 index 000000000..867e5e1f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +/** + * Reuses `makeLiveActivitiesFilter`, which already subscribes to kinds + * 30311/30312/30313/1311. The feed filter narrows to 30312/30313 client-side; + * sharing the wire filter avoids duplicate REQs on relays when both the Live + * Streams and Audio Rooms screens are open for the same user. + */ +class AudioRoomsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: AudioRoomsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makeLiveActivitiesFilter(feedSettings, since, key.feedStates.audioRoomsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: AudioRoomsQueryState) = key.account.userProfile() + + override fun list(key: AudioRoomsQueryState) = key.listName() + + fun AudioRoomsQueryState.listNameFlow() = account.settings.defaultLiveStreamsFollowList + + fun AudioRoomsQueryState.listName() = listNameFlow().value + + fun AudioRoomsQueryState.followsPerRelayFlow() = account.liveLiveStreamsFollowListsPerRelay + + fun AudioRoomsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AudioRoomsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.audioRoomsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 19796e0d8..02d939170 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -488,6 +488,7 @@ Public Chats Follow Packs Live Streams + Audio Rooms Videos Articles Private Bookmarks From 0db80c66b8742f7b6a680470ea784a1ff001d72e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 02:25:06 +0000 Subject: [PATCH 02/18] feat: audio room stage with NIP-53 presence (kind 10312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Clubhouse/nests integration: when the live-activity channel screen is opened on a kind 30312 MeetingSpaceEvent, render an audio-room "stage" above the chat that shows host/speaker/audience avatars and exposes hand-raise + mute toggles backed by NIP-53 kind 10312 presence events. Quartz: - New MutedTag (`["muted","1"|"0"]`) and `muted()` builder helper for MeetingRoomPresenceEvent. - New MeetingRoomPresenceEvent.build() overload that accepts a 30312 MeetingSpaceEvent root (matching the existing 30313 overload) and optionally encodes hand-raise + mute flags in one call. Amethyst: - AudioRoomStage composable: filters participants from the 30312 event into hosts / speakers / audience, renders avatars, and publishes presence on enter + every 30 s while composed (on dispose it pushes one final lowered-hand presence so peers drop us before timeout). - ChannelView wires AudioRoomStage in next to ShowVideoStreaming; the latter is a no-op for non-30311 events so non-audio rooms are unaffected. No audio is captured yet — the mic toggle is a Nostr-only signal until Phase 3 brings the MoQ/WebTransport transport. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../audiorooms/room/AudioRoomStage.kt | 258 ++++++++++++++++++ .../nip53LiveActivities/ChannelView.kt | 2 + amethyst/src/main/res/values/strings.xml | 6 + .../presence/MeetingRoomPresenceEvent.kt | 26 ++ .../presence/TagArrayBuilderExt.kt | 3 + .../presence/tags/MutedTag.kt | 45 +++ 6 files changed, 340 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MutedTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt new file mode 100644 index 000000000..46fc16b72 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.outlined.PanTool +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Clubhouse-style audio-room "stage" rendered in place of the video player when + * the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent]. + * + * Phase 2 scope: shows host + speaker + audience avatars and lets the local user + * publish their kind 10312 presence (with hand-raise + mute flags) on relays. + * The mic toggle is a Nostr-only signal at this point — actual audio capture + * arrives in Phase 3 with the MoQ/WebTransport transport. + */ +@Composable +fun AudioRoomStage( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, +) { + LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote -> + addressableNote ?: return@LoadAddressableNote + val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote + AudioRoomStageContent(event, accountViewModel) + } +} + +@Composable +private fun AudioRoomStageContent( + event: MeetingSpaceEvent, + accountViewModel: AccountViewModel, +) { + val participants = remember(event) { event.participants() } + val hosts = remember(participants) { participants.filter { it.role.equals(ROLE.HOST.code, true) } } + val speakers = remember(participants) { participants.filter { it.role.equals(ROLE.SPEAKER.code, true) } } + val audience = + remember(participants) { + participants.filter { + !it.role.equals(ROLE.HOST.code, true) && + !it.role.equals(ROLE.SPEAKER.code, true) + } + } + + var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) } + var muted by rememberSaveable(event.address().toValue()) { mutableStateOf(true) } + val scope = rememberCoroutineScope() + val account = accountViewModel.account + + // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed. + LaunchedEffect(event.address().toValue(), handRaised, muted) { + publishPresence(account, event, handRaised, muted) + while (isActive) { + delay(PRESENCE_REFRESH_MS) + publishPresence(account, event, handRaised, muted) + } + } + + // Best-effort "leave" — re-publish a CLOSED presence so peers see us drop sooner + // than the 30 s heartbeat would otherwise allow. + DisposableEffect(event.address().toValue()) { + onDispose { + scope.launch(Dispatchers.IO) { + runCatching { publishPresence(account, event, handRaised = false, muted = true) } + } + } + } + + Card( + modifier = Modifier.fillMaxWidth().padding(8.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + event.room()?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + ) + } + event.summary()?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (hosts.isNotEmpty() || speakers.isNotEmpty()) { + StagePeopleRow( + label = stringRes(R.string.audio_room_stage), + people = hosts + speakers, + avatarSize = Size40dp, + accountViewModel = accountViewModel, + ) + } + + if (audience.isNotEmpty()) { + StagePeopleRow( + label = stringRes(R.string.audio_room_audience), + people = audience, + avatarSize = Size35dp, + accountViewModel = accountViewModel, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalIconButton(onClick = { handRaised = !handRaised }) { + Icon( + imageVector = if (handRaised) Icons.Filled.PanTool else Icons.Outlined.PanTool, + contentDescription = + stringRes( + if (handRaised) R.string.audio_room_lower_hand else R.string.audio_room_raise_hand, + ), + ) + } + StdHorzSpacer + FilledIconButton( + onClick = { muted = !muted }, + colors = + if (muted) { + IconButtonDefaults.filledIconButtonColors() + } else { + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ) + }, + ) { + Icon( + imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, + contentDescription = + stringRes( + if (muted) R.string.audio_room_unmute else R.string.audio_room_mute, + ), + ) + } + } + } + } +} + +@Composable +private fun StagePeopleRow( + label: String, + people: List, + avatarSize: androidx.compose.ui.unit.Dp, + accountViewModel: AccountViewModel, +) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyRow( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = people, key = { it.pubKey }) { + ClickableUserPicture( + baseUserHex = it.pubKey, + size = avatarSize, + accountViewModel = accountViewModel, + ) + } + } + } +} + +private const val PRESENCE_REFRESH_MS = 30_000L + +private suspend fun publishPresence( + account: com.vitorpamplona.amethyst.model.Account, + event: MeetingSpaceEvent, + handRaised: Boolean, + muted: Boolean, +) { + runCatching { + account.signAndComputeBroadcast( + MeetingRoomPresenceEvent.build( + root = event, + handRaised = handRaised, + muted = muted, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index 344403048..b128c654e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomStage import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription @@ -129,6 +130,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + AudioRoomStage(channel, accountViewModel) LiveStreamTopZappers(channel, accountViewModel, nav) LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 02d939170..06b0a909d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -489,6 +489,12 @@ Follow Packs Live Streams Audio Rooms + Stage + Audience + Raise hand + Lower hand + Mute + Unmute Videos Articles Private Bookmarks diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt index f887bded2..ff494f9e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt @@ -31,8 +31,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -53,6 +55,8 @@ class MeetingRoomPresenceEvent( fun handRaised() = tags.firstNotNullOfOrNull(HandRaisedTag::parse) + fun muted() = tags.firstNotNullOfOrNull(MutedTag::parse) + companion object Companion { const val KIND = 10312 const val ALT = "Room Presence tag" @@ -78,5 +82,27 @@ class MeetingRoomPresenceEvent( } initializer() } + + /** + * Convenience builder when the parent is a kind 30312 [MeetingSpaceEvent] + * (Clubhouse-style audio room). Mirrors the [MeetingRoomEvent] overload so + * a participant can publish presence + hand-raise + mute against an audio + * room without adapting to the meeting-room (kind 30313) variant. + */ + fun build( + root: MeetingSpaceEvent, + handRaised: Boolean? = null, + muted: Boolean? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(root.room() ?: ALT) + + roomMeeting(MeetingSpaceTag(root.address(), root.relays().firstOrNull())) + + handRaised?.let { handRaised(it) } + muted?.let { muted(it) } + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt index 66309bdde..133dfbc6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt @@ -26,9 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) fun TagArrayBuilder.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised)) + +fun TagArrayBuilder.muted(muted: Boolean) = addUnique(MutedTag.assemble(muted)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MutedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MutedTag.kt new file mode 100644 index 000000000..f8c83a7a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MutedTag.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.presence.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Mute flag for kind 10312 presence events used by Clubhouse-style audio + * rooms (e.g. nostrnests/nests). Carried alongside the optional `hand` tag so + * other clients can render the speaker as muted without waiting for the audio + * transport to drop frames. + */ +class MutedTag { + companion object Companion { + const val TAG_NAME = "muted" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] == "1" + } + + fun assemble(muted: Boolean) = arrayOf(TAG_NAME, if (muted) "1" else "0") + } +} From 933b522273b618363607317a0109776fc5130756 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 02:58:24 +0000 Subject: [PATCH 03/18] feat(nestsClient): NIP-98 auth + nests room-info client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a of the Clubhouse/nests integration. Adds a new KMP module `nestsClient` (Android + JVM targets) that owns the HTTP control plane for talking to a nests audio-room backend. No transport/audio yet — that arrives in 3b with WebTransport + MoQ. Surface: - `NestsAuth.header(signer, url, method)` signs a kind 27235 event via Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use `Authorization: Nostr ` header value. - `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown fields so newer nests server revisions don't break older clients). - `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls `/` with the signed NIP-98 header and returns the MoQ endpoint + token the audio layer will need. - `OkHttpNestsClient` (jvmAndroid) is the default implementation shared between Android and desktop JVM. Tests: - `NestsRoomInfoTest` (commonTest) covers full/minimal payloads, unknown-field tolerance, missing-endpoint rejection, and URL construction edge cases. - `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the base64 back through Quartz's JacksonMapper, and asserts the signature verifies and the url+method tags bind correctly. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- nestsClient/build.gradle.kts | 84 +++++++++++++++++ .../vitorpamplona/nestsclient/NestsAuth.kt | 52 +++++++++++ .../vitorpamplona/nestsclient/NestsClient.kt | 60 +++++++++++++ .../nestsclient/NestsRoomInfo.kt | 74 +++++++++++++++ .../nestsclient/NestsRoomInfoTest.kt | 90 +++++++++++++++++++ .../nestsclient/OkHttpNestsClient.kt | 78 ++++++++++++++++ .../nestsclient/NestsAuthTest.kt | 80 +++++++++++++++++ settings.gradle | 1 + 8 files changed, 519 insertions(+) create mode 100644 nestsClient/build.gradle.kts create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt create mode 100644 nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts new file mode 100644 index 000000000..77a761da7 --- /dev/null +++ b/nestsClient/build.gradle.kts @@ -0,0 +1,84 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.serialization) +} + +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + android { + namespace = "com.vitorpamplona.nestsclient" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + withHostTest {} + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + api(project(":quartz")) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + } + } + + jvmMain { + dependsOn(jvmAndroid) + } + + androidMain { + dependsOn(jvmAndroid) + } + + jvmTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt new file mode 100644 index 000000000..04a1b7f27 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent + +/** + * Helper for building the `Authorization: Nostr ` header required by + * nests audio-room HTTP endpoints (NIP-98). + * + * The header value binds the signed event to a specific URL + method so the + * backend can reject replayed or repurposed tokens. We deliberately do NOT + * cache the signed event — the nests server enforces a short (~60 s) validity + * window on `created_at`, so callers should build a fresh header per request. + */ +object NestsAuth { + /** + * Sign a kind 27235 event for (`url`, `method`) and return it as a + * ready-to-use `Authorization` header value (`"Nostr "`). + * + * Optionally include a payload hash when the request has a body. + */ + suspend fun header( + signer: NostrSigner, + url: String, + method: String, + payload: ByteArray? = null, + ): String { + val template = HTTPAuthorizationEvent.build(url = url, method = method, file = payload) + val signed = signer.sign(template) + return signed.toAuthToken() + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt new file mode 100644 index 000000000..82e4ab56a --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * High-level entry point for talking to a nests-compatible audio-room backend. + * + * Phase 3a only exposes the HTTP control plane — resolving a room's MoQ + * endpoint + token via NIP-98 auth. Phase 3b will add the WebTransport/MoQ + * transport on top, keeping this interface stable so audio-room callers only + * depend on [resolveRoom] for the control-plane step. + */ +interface NestsClient { + /** + * Fetch [NestsRoomInfo] for a specific room. + * + * @param serviceBase value of the NIP-53 kind 30312 `service` tag + * (e.g. `https://nostrnests.com/api/v1/nests`) + * @param roomId the event's `d` tag + * @param signer signs the NIP-98 auth event that the server uses to verify + * the caller owns the pubkey it claims + * @throws NestsException on transport errors, non-2xx responses, or malformed + * JSON + */ + suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo +} + +/** + * Single exception type surfaced to UI code so callers don't have to know about + * platform HTTP libraries (OkHttp on Android/JVM today). + */ +class NestsException( + message: String, + cause: Throwable? = null, + val status: Int? = null, +) : RuntimeException(message, cause) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt new file mode 100644 index 000000000..bf1c82597 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * Information returned by a nests audio-room backend when a client + * authenticates against `/api/v1/nests/`. + * + * Field names mirror the nests reference server payload: + * ``` + * { "endpoint": "...", "token": "...", "codec": "opus", "sample_rate": 48000 } + * ``` + * All fields except [endpoint] are optional so the client can negotiate with + * alternate nests implementations that omit codec/token metadata. + */ +@Serializable +data class NestsRoomInfo( + val endpoint: String, + val token: String? = null, + val codec: String? = null, + @SerialName("sample_rate") val sampleRate: Int? = null, + val transport: String? = null, + val extra: Map = emptyMap(), +) { + companion object { + private val json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun parse(body: String): NestsRoomInfo = json.decodeFromString(serializer(), body) + } +} + +/** + * Build the URL a client should call to resolve [NestsRoomInfo] for a specific + * room. [serviceBase] comes from the `service` tag of the NIP-53 kind 30312 + * event; [roomId] is the event's `d` tag. + * + * Example: `https://nostrnests.com/api/v1/nests` + `abc-123` → + * `https://nostrnests.com/api/v1/nests/abc-123`. + */ +fun nestsRoomInfoUrl( + serviceBase: String, + roomId: String, +): String { + val trimmed = serviceBase.trimEnd('/') + val encoded = roomId.trim() + return "$trimmed/$encoded" +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt new file mode 100644 index 000000000..95b68bc1c --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class NestsRoomInfoTest { + @Test + fun parses_full_payload() { + val info = + NestsRoomInfo.parse( + """{"endpoint":"https://relay.example.com/moq","token":"abc.def","codec":"opus","sample_rate":48000,"transport":"webtransport"}""", + ) + assertEquals("https://relay.example.com/moq", info.endpoint) + assertEquals("abc.def", info.token) + assertEquals("opus", info.codec) + assertEquals(48000, info.sampleRate) + assertEquals("webtransport", info.transport) + } + + @Test + fun tolerates_minimal_payload() { + val info = NestsRoomInfo.parse("""{"endpoint":"https://r.example.com/moq"}""") + assertEquals("https://r.example.com/moq", info.endpoint) + assertNull(info.token) + assertNull(info.codec) + assertNull(info.sampleRate) + } + + @Test + fun ignores_unknown_fields() { + val info = + NestsRoomInfo.parse( + """{"endpoint":"https://r.example.com/moq","token":"t","future_knob":"future_value"}""", + ) + assertEquals("t", info.token) + } + + @Test + fun rejects_missing_endpoint() { + assertFailsWith { + NestsRoomInfo.parse("""{"token":"t"}""") + } + } + + @Test + fun builds_room_info_url_from_service_and_room_id() { + assertEquals( + "https://nostrnests.com/api/v1/nests/abc-123", + nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests", "abc-123"), + ) + } + + @Test + fun trims_trailing_slash_from_service_base() { + assertEquals( + "https://nostrnests.com/api/v1/nests/xyz", + nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests/", "xyz"), + ) + } + + @Test + fun trims_whitespace_from_room_id() { + assertEquals( + "https://a.example.com/api/v1/nests/room", + nestsRoomInfoUrl("https://a.example.com/api/v1/nests", " room "), + ) + } +} diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt new file mode 100644 index 000000000..c0b2fe06e --- /dev/null +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException + +/** + * OkHttp-backed [NestsClient] used on JVM + Android. A shared [OkHttpClient] + * can be injected so the app reuses connection pools / interceptors across + * the process; the default constructor creates a dedicated client. + */ +class OkHttpNestsClient( + private val http: OkHttpClient = OkHttpClient(), +) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo { + val url = nestsRoomInfoUrl(serviceBase, roomId) + val authHeader = NestsAuth.header(signer = signer, url = url, method = "GET") + + val request = + Request + .Builder() + .url(url) + .get() + .header("Authorization", authHeader) + .header("Accept", "application/json") + .build() + + return withContext(Dispatchers.IO) { + runCatching { http.newCall(request).execute() } + .getOrElse { throw NestsException("Failed to reach $url", it) } + .use { response -> + val body = response.body.string() + if (!response.isSuccessful) { + throw NestsException( + "nests server returned ${response.code} for $url", + status = response.code, + ) + } + try { + NestsRoomInfo.parse(body) + } catch (e: IOException) { + throw NestsException("Malformed nests response from $url", e) + } catch (e: IllegalArgumentException) { + throw NestsException("Malformed nests response from $url", e) + } catch (e: kotlinx.serialization.SerializationException) { + throw NestsException("Malformed nests response from $url", e) + } + } + } + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt new file mode 100644 index 000000000..0dc1cca78 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class NestsAuthTest { + @Test + fun header_has_nostr_prefix_and_decodes_to_signed_27235_event() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val header = NestsAuth.header(signer, "https://nostrnests.com/api/v1/nests/abc", "GET") + + assertTrue(header.startsWith("Nostr "), "header must start with `Nostr `, got: $header") + + val token = header.removePrefix("Nostr ") + val json = Base64.decode(token).decodeToString() + val event = JacksonMapper.fromJson(json) as HTTPAuthorizationEvent + + assertEquals(HTTPAuthorizationEvent.KIND, event.kind) + assertEquals("https://nostrnests.com/api/v1/nests/abc", event.url()) + assertEquals("GET", event.method()) + assertTrue(event.verify(), "signature must verify") + } + + @Test + fun header_binds_to_the_exact_url_and_method() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val a = NestsAuth.header(signer, "https://a.example.com/foo", "GET") + val b = NestsAuth.header(signer, "https://a.example.com/foo", "POST") + val c = NestsAuth.header(signer, "https://a.example.com/bar", "GET") + + fun tagsOf(header: String) = + (JacksonMapper.fromJson(Base64.decode(header.removePrefix("Nostr ")).decodeToString()) as HTTPAuthorizationEvent).let { + Triple(it.url(), it.method(), it.id) + } + + val (urlA, methodA, idA) = tagsOf(a) + val (urlB, methodB, idB) = tagsOf(b) + val (urlC, methodC, idC) = tagsOf(c) + + assertEquals("https://a.example.com/foo", urlA) + assertEquals("GET", methodA) + assertEquals("POST", methodB) + assertEquals("https://a.example.com/bar", urlC) + // Same url+method with different signer instance still produces same binding fields but new id. + assertTrue(idA != idB, "different method must produce a distinct event id") + assertTrue(idA != idC, "different url must produce a distinct event id") + } +} diff --git a/settings.gradle b/settings.gradle index ab5462e42..192a1480d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,5 +36,6 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':nestsClient' include ':desktopApp' include ':cli' From 4ead4ccd5c303b1518f97457f19e0b2db1104813 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:10:28 +0000 Subject: [PATCH 04/18] feat(nestsClient): WebTransport abstraction + fake + Kwik stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b-1 of the Clubhouse/nests integration. Lands the [WebTransportSession] abstraction the MoQ layer (Phase 3c) will code against, so MoQ framing + tests can develop in parallel with the real Kwik-based transport integration (deferred to Phase 3b-2). commonMain: - `WebTransportSession` — bidi/uni stream access, datagrams, close. - `WebTransportBidiStream` / `WebTransportReadStream` / `WebTransportWriteStream` — minimal read/write surface. - `WebTransportFactory.connect(authority, path, bearerToken)` for opening sessions. - `WebTransportException(kind, ...)` with four canonical failure modes (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI code doesn't need to know about library-specific exceptions. - `FakeWebTransport.pair()` — in-memory, fully-wired client/server pair for unit-testing MoQ framing without touching a real QUIC stack. jvmAndroid: - `KwikWebTransportFactory` stub that throws `WebTransportException(NotImplemented)` at `connect()`. Doc spells out the handshake sequence (QUIC dial → H3 SETTINGS → `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so Phase 3b-2 can drop the real implementation in without touching callers. Tests: - `FakeWebTransportTest` — datagram round-trip both directions, bidi-stream write visible on peer side, close() flips isOpen. - `WebTransportExceptionTest` — kind + message + cause preserved. - `KwikWebTransportFactoryTest` — `connect()` currently fails with the NotImplemented sentinel, so Phase 3b-2 can replace this test when the real handshake lands. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../nestsclient/transport/FakeWebTransport.kt | 148 ++++++++++++++++++ .../transport/WebTransportSession.kt | 126 +++++++++++++++ .../transport/FakeWebTransportTest.kt | 79 ++++++++++ .../transport/WebTransportExceptionTest.kt | 50 ++++++ .../transport/KwikWebTransportFactory.kt | 54 +++++++ .../transport/KwikWebTransportFactoryTest.kt | 43 +++++ 6 files changed, 500 insertions(+) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt create mode 100644 nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt new file mode 100644 index 000000000..57eb412b5 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [WebTransportSession] used by tests for MoQ framing / session logic. + * + * A pair of fakes is connected via [pair] — anything written on one side is + * delivered on the other. This deliberately simulates *success* semantics + * only (no packet loss, no congestion); the real Kwik-backed transport will + * exercise those codepaths separately. + * + * [incomingDatagrams] and [FakeBidiStream.incoming] use [consumeAsFlow] + * semantics and therefore may only be collected once per channel. + */ +class FakeWebTransport private constructor( + private val outboundDatagrams: Channel, + private val inboundDatagrams: Channel, + private val outboundBidiStreams: Channel, + private val inboundBidiStreams: Channel, + private val inboundUniStreams: Channel, +) : WebTransportSession { + private val stateLock = Mutex() + private var open = true + + override val isOpen: Boolean get() = open + + override suspend fun openBidiStream(): WebTransportBidiStream { + stateLock.withLock { check(open) { "session closed" } } + val localToPeer = Channel(Channel.BUFFERED) + val peerToLocal = Channel(Channel.BUFFERED) + val local = FakeBidiStream(write = localToPeer, read = peerToLocal) + val peer = FakeBidiStream(write = peerToLocal, read = localToPeer) + // Send *peer* half to the other side's inbound queue. + outboundBidiStreams.send(peer) + return local + } + + override fun incomingUniStreams(): Flow = inboundUniStreams.consumeAsFlow() + + override suspend fun sendDatagram(payload: ByteArray): Boolean { + if (!open) return false + outboundDatagrams.send(payload) + return true + } + + override fun incomingDatagrams(): Flow = inboundDatagrams.consumeAsFlow() + + override suspend fun close( + code: Int, + reason: String, + ) { + stateLock.withLock { + if (!open) return + open = false + } + outboundDatagrams.close() + inboundDatagrams.close() + outboundBidiStreams.close() + inboundBidiStreams.close() + inboundUniStreams.close() + } + + /** + * Flow of peer-opened bidi streams (i.e. the local endpoint of a stream the + * other side created via [openBidiStream]). + */ + fun peerOpenedBidiStreams(): Flow = inboundBidiStreams.consumeAsFlow() + + companion object { + /** + * Create two linked fakes that act as "client" and "server" endpoints of + * the same virtual WebTransport session. Datagrams and streams opened on + * one side appear on the other. + */ + fun pair(): Pair { + val aToBDatagrams = Channel(Channel.BUFFERED) + val bToADatagrams = Channel(Channel.BUFFERED) + val aToBBidi = Channel(Channel.BUFFERED) + val bToABidi = Channel(Channel.BUFFERED) + val aToBUni = Channel(Channel.BUFFERED) + val bToAUni = Channel(Channel.BUFFERED) + + val a = + FakeWebTransport( + outboundDatagrams = aToBDatagrams, + inboundDatagrams = bToADatagrams, + outboundBidiStreams = aToBBidi, + inboundBidiStreams = bToABidi, + inboundUniStreams = bToAUni, + ) + val b = + FakeWebTransport( + outboundDatagrams = bToADatagrams, + inboundDatagrams = aToBDatagrams, + outboundBidiStreams = bToABidi, + inboundBidiStreams = aToBBidi, + inboundUniStreams = aToBUni, + ) + return a to b + } + } +} + +class FakeBidiStream internal constructor( + private val write: Channel, + private val read: Channel, +) : WebTransportBidiStream { + override fun incoming(): Flow = read.consumeAsFlow() + + override suspend fun write(chunk: ByteArray) { + write.send(chunk) + } + + override suspend fun finish() { + write.close() + } +} + +class FakeReadStream internal constructor( + private val read: Channel, +) : WebTransportReadStream { + override fun incoming(): Flow = read.consumeAsFlow() +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt new file mode 100644 index 000000000..1282b0f2b --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +import kotlinx.coroutines.flow.Flow + +/** + * Platform-agnostic WebTransport session, as produced by a successful Extended + * CONNECT (RFC 9220) handshake. + * + * The MoQ layer (Phase 3c) talks to this interface; the real Kwik-based + * implementation sits behind [WebTransportFactory] in jvmAndroid. Keeping this + * abstract lets us: + * - unit-test the MoQ framing layer with an in-memory fake, + * - swap transport implementations (Cronet on Android, a browser-backed + * WebView bridge as a contingency, Kwik on JVM desktop) without touching + * audio/UI code. + * + * Lifecycle: the session is opened via [WebTransportFactory.connect] and must + * be closed with [close] to release the underlying QUIC connection. + */ +interface WebTransportSession { + /** True once the Extended CONNECT exchange has returned 2xx and before [close] is called. */ + val isOpen: Boolean + + /** + * Open a new bidirectional WebTransport stream. The returned [WebTransportBidiStream] + * is writable + readable and closes when either peer half-closes. + */ + suspend fun openBidiStream(): WebTransportBidiStream + + /** + * Flow of inbound unidirectional streams initiated by the peer. + * + * The flow completes when [close] is called or the peer tears down the session. + */ + fun incomingUniStreams(): Flow + + /** Send a QUIC datagram; returns false if the datagram was dropped by congestion control. */ + suspend fun sendDatagram(payload: ByteArray): Boolean + + /** Flow of inbound datagrams. */ + fun incomingDatagrams(): Flow + + /** Gracefully close the session with an optional application-level code + reason. */ + suspend fun close( + code: Int = 0, + reason: String = "", + ) +} + +/** Writable + readable half of a bidirectional WebTransport stream. */ +interface WebTransportBidiStream : + WebTransportReadStream, + WebTransportWriteStream + +/** Read-only WebTransport stream (either a received uni stream or the read half of a bidi). */ +interface WebTransportReadStream { + /** Flow of chunks as they arrive. Completes when the peer closes its write side. */ + fun incoming(): Flow +} + +/** Write-only WebTransport stream. */ +interface WebTransportWriteStream { + suspend fun write(chunk: ByteArray) + + /** Half-close the write side (FIN). No further writes after this call. */ + suspend fun finish() +} + +/** + * Factory that opens a [WebTransportSession] against a nests MoQ endpoint. + * + * [authority] is the `host:port` of the WT server, [path] is the URL path + * (nests defaults to `/moq`), and [bearerToken] is typically the token + * returned by the `/api/v1/nests/` HTTP call in Phase 3a. + */ +interface WebTransportFactory { + suspend fun connect( + authority: String, + path: String, + bearerToken: String? = null, + ): WebTransportSession +} + +/** + * Single transport-layer exception type, so UI code only has to differentiate + * on [kind] rather than catching library-specific classes. + */ +class WebTransportException( + val kind: Kind, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) { + enum class Kind { + /** DNS / TCP / TLS / QUIC handshake failed. */ + HandshakeFailed, + + /** WebTransport Extended CONNECT returned a non-2xx response. */ + ConnectRejected, + + /** Peer closed the session or a stream unexpectedly. */ + PeerClosed, + + /** The implementation does not yet support the requested operation. */ + NotImplemented, + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt new file mode 100644 index 000000000..580306a05 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransportTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FakeWebTransportTest { + @Test + fun pair_starts_open() { + val (a, b) = FakeWebTransport.pair() + assertTrue(a.isOpen) + assertTrue(b.isOpen) + } + + @Test + fun datagrams_from_a_arrive_on_b() = + runTest { + val (a, b) = FakeWebTransport.pair() + assertTrue(a.sendDatagram(byteArrayOf(1, 2, 3))) + assertContentEquals(byteArrayOf(1, 2, 3), b.incomingDatagrams().first()) + } + + @Test + fun datagrams_from_b_arrive_on_a() = + runTest { + val (a, b) = FakeWebTransport.pair() + assertTrue(b.sendDatagram(byteArrayOf(4, 5, 6))) + assertContentEquals(byteArrayOf(4, 5, 6), a.incomingDatagrams().first()) + } + + @Test + fun bidi_stream_write_is_visible_on_the_peer_side() = + runTest { + val (a, b) = FakeWebTransport.pair() + + val clientStream = a.openBidiStream() + clientStream.write(byteArrayOf(0xAA.toByte())) + clientStream.finish() + + val serverStream = b.peerOpenedBidiStreams().first() + val received = serverStream.incoming().toList() + assertEquals(1, received.size) + assertContentEquals(byteArrayOf(0xAA.toByte()), received.single()) + } + + @Test + fun close_flips_isOpen_and_drops_further_datagrams() = + runTest { + val (a, _) = FakeWebTransport.pair() + a.close() + assertFalse(a.isOpen) + assertFalse(a.sendDatagram(byteArrayOf(9))) + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt new file mode 100644 index 000000000..0d0a9e10e --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportExceptionTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class WebTransportExceptionTest { + @Test + fun exception_carries_kind_and_message() { + val ex = + WebTransportException( + kind = WebTransportException.Kind.HandshakeFailed, + message = "cert expired", + ) + assertEquals(WebTransportException.Kind.HandshakeFailed, ex.kind) + assertEquals("cert expired", ex.message) + } + + @Test + fun exception_preserves_cause() { + val root = RuntimeException("root") + val ex = + WebTransportException( + kind = WebTransportException.Kind.PeerClosed, + message = "peer went away", + cause = root, + ) + assertSame(root, ex.cause) + } +} diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt new file mode 100644 index 000000000..424140bb0 --- /dev/null +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +/** + * JVM + Android [WebTransportFactory] that will wrap the Kwik QUIC library + * (plus Flupke for HTTP/3) once the Extended CONNECT handshake lands. + * + * **Status:** Phase 3b-1 only ships the interface contract — calling + * [connect] throws [WebTransportException] with + * [WebTransportException.Kind.NotImplemented]. This lets the Phase 3c MoQ + * framing layer develop and test against a stable [WebTransportSession] + * abstraction (see [FakeWebTransport]) while the real Kwik integration is + * delivered in Phase 3b-2. + * + * The expected implementation sequence: + * 1. QUIC dial via Kwik with ALPN "h3" to `authority`. + * 2. HTTP/3 SETTINGS exchange including `SETTINGS_ENABLE_CONNECT_PROTOCOL=1` + * and the WebTransport draft's `SETTINGS_ENABLE_WEBTRANSPORT=1`. + * 3. Extended CONNECT request: `:method=CONNECT :protocol=webtransport + * :scheme=https :authority= :path=` plus + * `Authorization: Bearer ` when supplied. + * 4. On 2xx, wrap the resulting bidi streams / datagrams in WebTransport + * framing (stream type 0x54 for client-initiated bidi). + */ +class KwikWebTransportFactory : WebTransportFactory { + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession = + throw WebTransportException( + kind = WebTransportException.Kind.NotImplemented, + message = "Kwik-backed WebTransport handshake lands in Phase 3b-2", + ) +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt new file mode 100644 index 000000000..606ed742e --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactoryTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.transport + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class KwikWebTransportFactoryTest { + @Test + fun connect_throws_NotImplemented_until_phase_3b_2() = + runTest { + val factory = KwikWebTransportFactory() + val ex = + assertFailsWith { + factory.connect( + authority = "nostrnests.com", + path = "/moq", + bearerToken = "ignored", + ) + } + assertEquals(WebTransportException.Kind.NotImplemented, ex.kind) + } +} From 37e24ce4f3ab08642d0784a2aafdaae9b1847056 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:21:15 +0000 Subject: [PATCH 05/18] feat(nestsClient): MoQ varint + SETUP handshake codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport control-plane codec (draft-ietf-moq-transport) and a session wrapper that runs the SETUP handshake over any WebTransportSession. Purely commonMain + fully tested end-to-end against FakeWebTransport — no network or Kwik dependency required. commonMain (nestsclient.moq): - `Varint` — QUIC variable-length integer codec per RFC 9000 §16. Encode/decode/size, with typed truncation handling (decode returns null so callers can buffer more and retry). - `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked reader used by the message codec. - `MoqCodecException` — typed error for malformed frames. - `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`. Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types 0x40 / 0x41). - `MoqCodec.encode/decode` — wraps payload with `type (varint) + length (varint)`. Rejects unknown types and trailing bytes inside a declared payload window. - `MoqSession.client/server` — attaches to a WebTransportSession and runs the CLIENT_SETUP / SERVER_SETUP handshake with version negotiation + configurable timeout. - `MoqVersion` constants for draft-11 and draft-17. Tests: - `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte), boundary round-trips, negative/overflow rejection, short-buffer returns null, bytesConsumed accuracy. - `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and multi-param), multi-version negotiation, exact wire layout for ServerSetup(1L), truncated-frame returns null, concatenated frames decode with offset, unknown type rejected, trailing bytes rejected, zero-length parameter values allowed. - `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both sides happy path, client falling back to older version, server rejecting when no version overlap + client timing out cleanly). SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in Phase 3c-2 on top of the same MoqSession. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../nestsclient/moq/MoqBuffer.kt | 130 ++++++++++++ .../vitorpamplona/nestsclient/moq/MoqCodec.kt | 144 +++++++++++++ .../nestsclient/moq/MoqMessage.kt | 108 ++++++++++ .../nestsclient/moq/MoqSession.kt | 190 ++++++++++++++++++ .../vitorpamplona/nestsclient/moq/Varint.kt | 122 +++++++++++ .../nestsclient/moq/MoqCodecTest.kt | 133 ++++++++++++ .../nestsclient/moq/MoqSessionTest.kt | 117 +++++++++++ .../nestsclient/moq/VarintTest.kt | 114 +++++++++++ 8 files changed, 1058 insertions(+) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodecTest.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt new file mode 100644 index 000000000..9786c88a2 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +/** + * Append-only byte buffer used by the MoQ encoders. Doubles in capacity when + * full. Kept intentionally minimal so this module has no buffer-library + * dependency — helps keep the KMP surface thin. + */ +class MoqWriter( + initialCapacity: Int = 64, +) { + private var buf: ByteArray = ByteArray(initialCapacity) + private var pos: Int = 0 + + val size: Int get() = pos + + fun toByteArray(): ByteArray = buf.copyOf(pos) + + fun writeByte(value: Int) { + ensure(1) + buf[pos++] = value.toByte() + } + + fun writeBytes(bytes: ByteArray) { + ensure(bytes.size) + bytes.copyInto(buf, pos) + pos += bytes.size + } + + fun writeVarint(value: Long) { + ensure(Varint.size(value)) + pos += Varint.writeTo(value, buf, pos) + } + + fun writeVarint(value: Int) = writeVarint(value.toLong()) + + /** Write a length-prefixed byte array (varint length + contents). */ + fun writeLengthPrefixedBytes(bytes: ByteArray) { + writeVarint(bytes.size.toLong()) + writeBytes(bytes) + } + + /** Write a length-prefixed UTF-8 string. */ + fun writeLengthPrefixedString(s: String) = writeLengthPrefixedBytes(s.encodeToByteArray()) + + private fun ensure(more: Int) { + if (pos + more > buf.size) { + var newSize = buf.size * 2 + while (newSize < pos + more) newSize *= 2 + buf = buf.copyOf(newSize) + } + } +} + +/** + * Read cursor over a fully-buffered frame payload. Throws [MoqCodecException] + * on under-read so callers get a typed error surface instead of opaque + * [IndexOutOfBoundsException]s. + */ +class MoqReader( + private val src: ByteArray, + private var pos: Int = 0, + private val end: Int = src.size, +) { + val remaining: Int get() = end - pos + + fun hasMore(): Boolean = pos < end + + fun readByte(): Int { + require(1) + return src[pos++].toInt() and 0xFF + } + + fun readBytes(n: Int): ByteArray { + require(n) + val out = src.copyOfRange(pos, pos + n) + pos += n + return out + } + + fun readVarint(): Long { + val dec = + Varint.decode(src, pos) + ?: throw MoqCodecException("truncated varint at offset=$pos (remaining=$remaining)") + require(dec.bytesConsumed) + pos += dec.bytesConsumed + return dec.value + } + + fun readLengthPrefixedBytes(): ByteArray { + val len = readVarint() + if (len < 0 || len > Int.MAX_VALUE) throw MoqCodecException("absurd length prefix: $len") + return readBytes(len.toInt()) + } + + fun readLengthPrefixedString(): String = readLengthPrefixedBytes().decodeToString() + + private fun require(n: Int) { + if (pos + n > end) { + throw MoqCodecException( + "short read at offset=$pos: wanted $n bytes, have $remaining", + ) + } + } +} + +/** Thrown when an encoded MoQ frame is malformed or truncated. */ +class MoqCodecException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt new file mode 100644 index 000000000..bfb5faa04 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +/** + * Encode/decode MoQ control-stream messages per draft-ietf-moq-transport. + * + * Wire format for every control message: + * + * message_type (varint) | message_length (varint) | payload bytes + * + * The payload layout is per-message. Payload encoders here produce only the + * payload; [encode] wraps it with type+length for on-the-wire use. + */ +object MoqCodec { + /** + * Encode a full control-stream frame (type + length + payload). + */ + fun encode(message: MoqMessage): ByteArray { + val payload = encodePayload(message) + val frame = MoqWriter(payload.size + 8) + frame.writeVarint(message.type.code) + frame.writeVarint(payload.size.toLong()) + frame.writeBytes(payload) + return frame.toByteArray() + } + + /** + * Decode one control-stream message from [src] starting at [offset]. + * + * Returns the message plus the number of bytes consumed, or null if [src] + * doesn't yet contain a whole frame (caller should buffer more data from + * the control stream and retry). + */ + fun decode( + src: ByteArray, + offset: Int = 0, + ): DecodeResult? { + val typeDec = Varint.decode(src, offset) ?: return null + val lenDec = Varint.decode(src, offset + typeDec.bytesConsumed) ?: return null + val payloadStart = offset + typeDec.bytesConsumed + lenDec.bytesConsumed + val payloadEnd = payloadStart + lenDec.value.toInt() + if (payloadEnd > src.size) return null + + val type = + MoqMessageType.fromCode(typeDec.value) + ?: throw MoqCodecException("unknown MoQ message type: 0x${typeDec.value.toString(16)}") + + val reader = MoqReader(src, payloadStart, payloadEnd) + val message = + when (type) { + MoqMessageType.ClientSetup -> decodeClientSetup(reader) + MoqMessageType.ServerSetup -> decodeServerSetup(reader) + } + if (reader.hasMore()) { + throw MoqCodecException( + "trailing bytes in ${type.name} payload (consumed=${payloadEnd - payloadStart - reader.remaining}, total=${payloadEnd - payloadStart})", + ) + } + return DecodeResult(message, payloadEnd - offset) + } + + private fun encodePayload(message: MoqMessage): ByteArray = + when (message) { + is ClientSetup -> encodeClientSetup(message) + is ServerSetup -> encodeServerSetup(message) + } + + private fun encodeClientSetup(message: ClientSetup): ByteArray { + val w = MoqWriter() + w.writeVarint(message.supportedVersions.size.toLong()) + for (v in message.supportedVersions) w.writeVarint(v) + encodeParameters(w, message.parameters) + return w.toByteArray() + } + + private fun decodeClientSetup(r: MoqReader): ClientSetup { + val nVersions = r.readVarint().toInt() + if (nVersions < 0) throw MoqCodecException("negative version count: $nVersions") + val versions = ArrayList(nVersions) + repeat(nVersions) { versions.add(r.readVarint()) } + val params = decodeParameters(r) + return ClientSetup(versions, params) + } + + private fun encodeServerSetup(message: ServerSetup): ByteArray { + val w = MoqWriter() + w.writeVarint(message.selectedVersion) + encodeParameters(w, message.parameters) + return w.toByteArray() + } + + private fun decodeServerSetup(r: MoqReader): ServerSetup { + val version = r.readVarint() + val params = decodeParameters(r) + return ServerSetup(version, params) + } + + private fun encodeParameters( + w: MoqWriter, + params: List, + ) { + w.writeVarint(params.size.toLong()) + for (p in params) { + w.writeVarint(p.key) + w.writeLengthPrefixedBytes(p.value) + } + } + + private fun decodeParameters(r: MoqReader): List { + val n = r.readVarint().toInt() + if (n < 0) throw MoqCodecException("negative parameter count: $n") + val out = ArrayList(n) + repeat(n) { + val key = r.readVarint() + val value = r.readLengthPrefixedBytes() + out.add(SetupParameter(key, value)) + } + return out + } + + data class DecodeResult( + val message: MoqMessage, + val bytesConsumed: Int, + ) +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt new file mode 100644 index 000000000..6af9bb801 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +/** + * Subset of the MoQ-transport control-plane messages needed for a listener + * talking to a nests server. Per draft-ietf-moq-transport, control messages + * on the control stream share the wire layout: + * + * message_type (varint) | message_length (varint) | payload... + * + * This phase (3c-1) covers only the setup handshake. SUBSCRIBE / ANNOUNCE / + * OBJECT messages arrive in Phase 3c-2. + */ +sealed class MoqMessage { + abstract val type: MoqMessageType +} + +/** + * Message type codes per draft-ietf-moq-transport-17. Held as enum so future + * draft revisions can extend without breaking call sites. + */ +enum class MoqMessageType( + val code: Long, +) { + ClientSetup(0x40), + ServerSetup(0x41), + ; + + companion object { + private val byCode = entries.associateBy { it.code } + + fun fromCode(code: Long): MoqMessageType? = byCode[code] + } +} + +/** + * Wire-level setup parameter: key is a varint, value is an opaque byte string + * (MoQ doesn't prescribe a single type for values — some draft revisions use + * varints, others length-prefixed bytes; nests uses bytes in either case). + */ +data class SetupParameter( + val key: Long, + val value: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SetupParameter) return false + return key == other.key && value.contentEquals(other.value) + } + + override fun hashCode(): Int = 31 * key.hashCode() + value.contentHashCode() + + companion object { + /** ROLE parameter (key 0x00), draft-specific values. Kept as bytes. */ + const val KEY_ROLE: Long = 0x00L + + /** PATH parameter (key 0x01). */ + const val KEY_PATH: Long = 0x01L + + /** MAX_SUBSCRIBE_ID (key 0x02 in most drafts). */ + const val KEY_MAX_SUBSCRIBE_ID: Long = 0x02L + } +} + +/** + * CLIENT_SETUP (0x40): sent by the client immediately after the WebTransport + * session opens, on the first bidi stream (the control stream). + */ +data class ClientSetup( + val supportedVersions: List, + val parameters: List = emptyList(), +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.ClientSetup + + init { + require(supportedVersions.isNotEmpty()) { "must offer at least one version" } + } +} + +/** + * SERVER_SETUP (0x41): response to CLIENT_SETUP. Contains the one version the + * server selected from [ClientSetup.supportedVersions]. + */ +data class ServerSetup( + val selectedVersion: Long, + val parameters: List = emptyList(), +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.ServerSetup +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt new file mode 100644 index 000000000..745be13ce --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import com.vitorpamplona.nestsclient.transport.WebTransportBidiStream +import com.vitorpamplona.nestsclient.transport.WebTransportSession +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout + +/** + * MoQ-transport draft version constants. Value shape: `0xff00000000 | draft` + * is how some draft tests label versions; draft-ietf-moq-transport-17 is + * commonly represented as 0xff000011 but implementations vary. The actual + * version used on the wire is negotiated in [MoqSession.setup]. + * + * Listed as `Long` to survive the MSB-set encoding on JVM. + */ +object MoqVersion { + /** draft-ietf-moq-transport-11 (a popular stable draft). */ + const val DRAFT_11: Long = 0xff00000bL + + /** draft-ietf-moq-transport-17 (newer draft). */ + const val DRAFT_17: Long = 0xff000011L +} + +/** + * Session wrapper over a [WebTransportSession] that speaks MoQ-transport. + * + * Phase 3c-1 ships only [setup] — the CLIENT_SETUP / SERVER_SETUP handshake. + * Phase 3c-2 adds SUBSCRIBE + ANNOUNCE + object-stream multiplexing. + * + * Usage: + * ``` + * val session = MoqSession.client(webTransport).apply { + * setup(listOf(MoqVersion.DRAFT_17)) + * } + * // session.selectedVersion is now the version the server picked. + * ``` + */ +class MoqSession private constructor( + private val transport: WebTransportSession, + /** The single bidi stream every MoQ exchange uses for control. */ + private val controlStream: WebTransportBidiStream, + private val role: Role, +) { + enum class Role { Client, Server } + + /** Server's selected version; null until [setup] returns. */ + var selectedVersion: Long? = null + private set + + /** Parameters the server sent back in SERVER_SETUP. Empty until [setup] returns. */ + var serverParameters: List = emptyList() + private set + + /** + * Run the SETUP handshake. + * + * Client side: writes CLIENT_SETUP with [supportedVersions] + [clientParameters], + * then reads exactly one SERVER_SETUP, stores the result, and returns. + * Server side: reads exactly one CLIENT_SETUP, selects the first mutually + * supported version from [supportedVersions] (where the local list is the + * set of versions the server is willing to accept), writes SERVER_SETUP, + * and returns. + * + * @throws MoqProtocolException if the peer sent an unexpected message, or + * if no version overlap exists (server side). + */ + suspend fun setup( + supportedVersions: List, + clientParameters: List = emptyList(), + handshakeTimeoutMs: Long = 10_000, + ) { + withTimeout(handshakeTimeoutMs) { + when (role) { + Role.Client -> runClientSetup(supportedVersions, clientParameters) + Role.Server -> runServerSetup(supportedVersions, clientParameters) + } + } + } + + private suspend fun runClientSetup( + supportedVersions: List, + clientParameters: List, + ) { + controlStream.write(MoqCodec.encode(ClientSetup(supportedVersions, clientParameters))) + val reply = readOneMessage() + val server = + reply as? ServerSetup + ?: throw MoqProtocolException("expected SERVER_SETUP, got ${reply.type.name}") + if (server.selectedVersion !in supportedVersions) { + throw MoqProtocolException( + "server picked version 0x${server.selectedVersion.toString(16)} which we did not offer", + ) + } + selectedVersion = server.selectedVersion + serverParameters = server.parameters + } + + private suspend fun runServerSetup( + acceptedVersions: List, + serverParameters: List, + ) { + val incoming = readOneMessage() + val client = + incoming as? ClientSetup + ?: throw MoqProtocolException("expected CLIENT_SETUP, got ${incoming.type.name}") + val overlap = + client.supportedVersions.firstOrNull { it in acceptedVersions } + ?: throw MoqProtocolException( + "no mutually-supported MoQ version (client offered ${client.supportedVersions})", + ) + controlStream.write(MoqCodec.encode(ServerSetup(overlap, serverParameters))) + this.selectedVersion = overlap + this.serverParameters = serverParameters + } + + /** + * Read exactly one full MoQ message from the control stream. + * + * Phase 3c-1 assumes a whole message fits in a single transport write + * (SETUP frames are only a handful of bytes and the peer always writes + * them atomically). Phase 3c-2 will replace this with a buffer-and-retry + * loop for messages that may fragment across chunks. + */ + private suspend fun readOneMessage(): MoqMessage { + val chunk = controlStream.incoming().first() + val decoded = + MoqCodec.decode(chunk) + ?: throw MoqProtocolException( + "control-stream chunk did not contain a complete MoQ frame (size=${chunk.size})", + ) + return decoded.message + } + + /** Close the underlying transport. */ + suspend fun close( + code: Int = 0, + reason: String = "", + ) { + controlStream.finish() + transport.close(code, reason) + } + + companion object { + /** + * Attach to a [WebTransportSession] in the client role. Opens the + * control stream eagerly so [setup] doesn't need to manage stream + * acquisition. + */ + suspend fun client(transport: WebTransportSession): MoqSession { + val control = transport.openBidiStream() + return MoqSession(transport, control, Role.Client) + } + + /** + * Attach to a [WebTransportSession] in the server role over an + * already-accepted control stream (usually the first bidi stream the + * peer opened). Used in tests — a real server accepts the first bidi. + */ + fun server( + transport: WebTransportSession, + control: WebTransportBidiStream, + ): MoqSession = MoqSession(transport, control, Role.Server) + } +} + +/** Thrown when the peer violates the MoQ-transport state machine. */ +class MoqProtocolException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt new file mode 100644 index 000000000..ed1529683 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +/** + * QUIC variable-length integer codec, per RFC 9000 §16. + * + * The two most significant bits of the first byte encode the total length: + * 00 → 1 byte, 6-bit value (0 .. 63) + * 01 → 2 bytes, 14-bit value (0 .. 16_383) + * 10 → 4 bytes, 30-bit value (0 .. 1_073_741_823) + * 11 → 8 bytes, 62-bit value (0 .. 4_611_686_018_427_387_903) + * + * MoQ messages, parameters, and most length prefixes use this encoding. + */ +object Varint { + const val MAX_VALUE: Long = (1L shl 62) - 1 + + /** Number of bytes required to encode [value]. */ + fun size(value: Long): Int { + require(value >= 0) { "varint must be non-negative: $value" } + require(value <= MAX_VALUE) { "varint overflow: $value" } + return when { + value < (1L shl 6) -> 1 + value < (1L shl 14) -> 2 + value < (1L shl 30) -> 4 + else -> 8 + } + } + + /** Encode [value] into a fresh ByteArray. */ + fun encode(value: Long): ByteArray { + val buf = ByteArray(size(value)) + writeTo(value, buf, 0) + return buf + } + + /** + * Write [value] to [dst] starting at [offset]. Returns the number of bytes + * written. [dst] must have room for [size] bytes starting at [offset]. + */ + fun writeTo( + value: Long, + dst: ByteArray, + offset: Int, + ): Int { + val size = size(value) + when (size) { + 1 -> { + dst[offset] = value.toByte() + } + + 2 -> { + dst[offset] = (0x40 or ((value ushr 8).toInt() and 0x3F)).toByte() + dst[offset + 1] = (value and 0xFF).toByte() + } + + 4 -> { + dst[offset] = (0x80 or ((value ushr 24).toInt() and 0x3F)).toByte() + dst[offset + 1] = ((value ushr 16) and 0xFF).toByte() + dst[offset + 2] = ((value ushr 8) and 0xFF).toByte() + dst[offset + 3] = (value and 0xFF).toByte() + } + + 8 -> { + dst[offset] = (0xC0 or ((value ushr 56).toInt() and 0x3F)).toByte() + dst[offset + 1] = ((value ushr 48) and 0xFF).toByte() + dst[offset + 2] = ((value ushr 40) and 0xFF).toByte() + dst[offset + 3] = ((value ushr 32) and 0xFF).toByte() + dst[offset + 4] = ((value ushr 24) and 0xFF).toByte() + dst[offset + 5] = ((value ushr 16) and 0xFF).toByte() + dst[offset + 6] = ((value ushr 8) and 0xFF).toByte() + dst[offset + 7] = (value and 0xFF).toByte() + } + } + return size + } + + /** + * Decode a varint starting at [offset] in [src]. Returns [DecodeResult] with + * the value and the number of bytes consumed, or null if [src] doesn't + * contain enough bytes (caller should buffer more and retry). + */ + fun decode( + src: ByteArray, + offset: Int = 0, + ): DecodeResult? { + if (offset >= src.size) return null + val first = src[offset].toInt() and 0xFF + val length = 1 shl ((first ushr 6) and 0x03) + if (offset + length > src.size) return null + + var value = (first and 0x3F).toLong() + for (i in 1 until length) { + value = (value shl 8) or ((src[offset + i].toInt() and 0xFF).toLong()) + } + return DecodeResult(value, length) + } + + data class DecodeResult( + val value: Long, + val bytesConsumed: Int, + ) +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodecTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodecTest.kt new file mode 100644 index 000000000..9fd2f06f7 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodecTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +class MoqCodecTest { + @Test + fun client_setup_round_trip_no_parameters() { + val msg = ClientSetup(supportedVersions = listOf(MoqVersion.DRAFT_17)) + val encoded = MoqCodec.encode(msg) + val decoded = MoqCodec.decode(encoded)!! + assertEquals(encoded.size, decoded.bytesConsumed) + assertEquals(msg, decoded.message) + } + + @Test + fun client_setup_round_trip_multi_version_and_parameters() { + val msg = + ClientSetup( + supportedVersions = listOf(MoqVersion.DRAFT_11, MoqVersion.DRAFT_17), + parameters = + listOf( + SetupParameter(SetupParameter.KEY_ROLE, byteArrayOf(0x03)), + SetupParameter(SetupParameter.KEY_PATH, "/moq".encodeToByteArray()), + ), + ) + val encoded = MoqCodec.encode(msg) + val decoded = MoqCodec.decode(encoded)!! + val roundTripped = assertIs(decoded.message) + assertEquals(msg.supportedVersions, roundTripped.supportedVersions) + assertEquals(msg.parameters, roundTripped.parameters) + } + + @Test + fun server_setup_round_trip() { + val msg = + ServerSetup( + selectedVersion = MoqVersion.DRAFT_17, + parameters = listOf(SetupParameter(SetupParameter.KEY_MAX_SUBSCRIBE_ID, byteArrayOf(0x40, 0x10))), + ) + val encoded = MoqCodec.encode(msg) + val decoded = MoqCodec.decode(encoded)!! + assertEquals(msg, decoded.message) + } + + @Test + fun encode_prefixes_with_type_and_length() { + val encoded = MoqCodec.encode(ServerSetup(selectedVersion = 1L)) + // ServerSetup's type code is 0x41 (65). That's > 63 so it needs a + // 2-byte varint: 0x40 0x41. Length of the payload (2 bytes: one byte + // for varint(version=1), one byte for varint(0 params)) fits in a + // single-byte varint → 0x02. Payload: 0x01, 0x00. Total 5 bytes. + assertContentEquals(byteArrayOf(0x40, 0x41, 0x02, 0x01, 0x00), encoded) + } + + @Test + fun decode_returns_null_when_frame_is_truncated() { + val full = MoqCodec.encode(ClientSetup(listOf(MoqVersion.DRAFT_17))) + // Lose the last byte → caller should buffer more and retry. + assertNull(MoqCodec.decode(full.copyOf(full.size - 1))) + } + + @Test + fun decode_with_offset_reads_from_middle_of_buffer() { + val a = MoqCodec.encode(ClientSetup(listOf(MoqVersion.DRAFT_17))) + val b = MoqCodec.encode(ServerSetup(selectedVersion = MoqVersion.DRAFT_17)) + val concatenated = a + b + + val first = MoqCodec.decode(concatenated, offset = 0)!! + assertEquals(a.size, first.bytesConsumed) + assertIs(first.message) + + val second = MoqCodec.decode(concatenated, offset = first.bytesConsumed)!! + assertEquals(b.size, second.bytesConsumed) + assertIs(second.message) + } + + @Test + fun unknown_message_type_is_rejected() { + // Craft a frame with message type 0xFFFF (large varint), length 0, no payload. + val bogus = MoqWriter() + bogus.writeVarint(0xFFFFL) + bogus.writeVarint(0L) + assertFailsWith { MoqCodec.decode(bogus.toByteArray()) } + } + + @Test + fun trailing_bytes_in_payload_are_rejected() { + // Valid ServerSetup payload of (selectedVersion=1, 0 params) fits in 2 bytes + // (varint 0x01 + varint 0x00). Craft a frame that declares a 4-byte + // payload and pads with 2 junk bytes inside the declared window so the + // decoder sees extra data after the last field. + val w = MoqWriter() + w.writeVarint(MoqMessageType.ServerSetup.code) + w.writeVarint(4L) + w.writeVarint(1L) // selected version (1 byte) + w.writeVarint(0L) // 0 parameters (1 byte) + w.writeByte(0xAA) // trailing junk inside declared payload + w.writeByte(0xBB) + assertFailsWith { MoqCodec.decode(w.toByteArray()) } + } + + @Test + fun parameters_with_zero_length_value_are_allowed() { + val msg = ClientSetup(listOf(1L), parameters = listOf(SetupParameter(0x10L, ByteArray(0)))) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!! + assertEquals(msg, decoded.message) + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt new file mode 100644 index 000000000..353e7a60d --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import com.vitorpamplona.nestsclient.transport.FakeWebTransport +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class MoqSessionTest { + @Test + fun client_and_server_complete_setup_handshake_over_fake_transport() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + + val clientJob = + async { + val session = MoqSession.client(clientSide) + session.setup(listOf(MoqVersion.DRAFT_17)) + session.selectedVersion + } + + val serverJob = + async { + val control = serverSide.peerOpenedBidiStreams().first() + val session = MoqSession.server(serverSide, control) + session.setup( + supportedVersions = listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11), + clientParameters = + listOf( + SetupParameter( + SetupParameter.KEY_MAX_SUBSCRIBE_ID, + byteArrayOf(0x10), + ), + ), + ) + session.selectedVersion + } + + assertEquals(MoqVersion.DRAFT_17, clientJob.await()) + assertEquals(MoqVersion.DRAFT_17, serverJob.await()) + } + + @Test + fun server_picks_first_mutually_supported_version_from_its_own_list() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + + val clientJob = + async { + val session = MoqSession.client(clientSide) + // Client prefers 17, falls back to 11. + session.setup(listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11)) + session.selectedVersion + } + + val serverJob = + async { + val control = serverSide.peerOpenedBidiStreams().first() + val session = MoqSession.server(serverSide, control) + // Server only speaks 11 → overlap is 11. + session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11)) + session.selectedVersion + } + + assertEquals(MoqVersion.DRAFT_11, clientJob.await()) + assertEquals(MoqVersion.DRAFT_11, serverJob.await()) + } + + @Test + fun server_rejects_when_no_version_overlap() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + + val clientJob = + async { + val session = MoqSession.client(clientSide) + runCatching { session.setup(listOf(MoqVersion.DRAFT_17)) } + } + + val serverJob = + async { + val control = serverSide.peerOpenedBidiStreams().first() + val session = MoqSession.server(serverSide, control) + assertFailsWith { + session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11)) + } + } + + serverJob.await() + // Client's result: its control stream closes with no reply, which throws. + // We just verify that some throwable propagated. + val clientOutcome = clientJob.await() + assert(clientOutcome.isFailure) { "client setup should have failed, got: $clientOutcome" } + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt new file mode 100644 index 000000000..e92d0fdcd --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class VarintTest { + /** + * RFC 9000 §A.1 sample encodings, which every QUIC varint implementation + * must reproduce bit-for-bit. + */ + @Test + fun rfc9000_sample_151288809941952652() { + // 62-bit: 151288809941952652 == 0x2136 0x0000 0000 8c4c (encoded) + val value = 151288809941952652L + val encoded = Varint.encode(value) + assertContentEquals( + byteArrayOf(0xC2.toByte(), 0x19, 0x7C, 0x5E, 0xFF.toByte(), 0x14, 0xE8.toByte(), 0x8C.toByte()), + encoded, + ) + assertEquals(value, Varint.decode(encoded)!!.value) + } + + @Test + fun rfc9000_sample_494878333() { + val value = 494878333L + val encoded = Varint.encode(value) + assertContentEquals( + byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()), + encoded, + ) + assertEquals(value, Varint.decode(encoded)!!.value) + } + + @Test + fun rfc9000_sample_15293() { + val value = 15293L + val encoded = Varint.encode(value) + assertContentEquals(byteArrayOf(0x7B.toByte(), 0xBD.toByte()), encoded) + assertEquals(value, Varint.decode(encoded)!!.value) + } + + @Test + fun rfc9000_sample_37() { + val encoded = Varint.encode(37L) + assertContentEquals(byteArrayOf(0x25), encoded) + assertEquals(37L, Varint.decode(encoded)!!.value) + } + + @Test + fun boundary_values_round_trip() { + for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) { + val encoded = Varint.encode(v) + assertEquals( + v, + Varint.decode(encoded)!!.value, + "round-trip for $v", + ) + } + } + + @Test + fun size_matches_encoding_length() { + for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) { + assertEquals(Varint.size(v), Varint.encode(v).size) + } + } + + @Test + fun negative_value_is_rejected() { + assertFailsWith { Varint.encode(-1L) } + } + + @Test + fun overflow_value_is_rejected() { + assertFailsWith { Varint.encode(Varint.MAX_VALUE + 1) } + } + + @Test + fun short_buffer_returns_null_so_caller_can_buffer_more() { + assertNull(Varint.decode(ByteArray(0))) + // 4-byte varint with only 3 bytes available: + assertNull(Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E), 0)) + } + + @Test + fun bytesConsumed_reflects_actual_length() { + assertEquals(1, Varint.decode(byteArrayOf(0x25))!!.bytesConsumed) + assertEquals(2, Varint.decode(byteArrayOf(0x7B.toByte(), 0xBD.toByte()))!!.bytesConsumed) + assertEquals(4, Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()))!!.bytesConsumed) + } +} From d65ab7b616cbfdd3bda57adb46a9305824005e21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:26:53 +0000 Subject: [PATCH 06/18] feat(nestsClient): MoQ SUBSCRIBE family + OBJECT_DATAGRAM codecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ control-plane codec with the subscribe lifecycle messages (SUBSCRIBE, SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the OBJECT_DATAGRAM wire format the listener uses to receive low-latency Opus audio. Pure codec layer — no session-pump or network changes; the subscribe/object delivery Flow arrives in Phase 3c-3. commonMain (nestsclient.moq): - New message types in `MoqMessageType`: Subscribe (0x03), SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A). - New data classes in MoqMessage.kt: * `TrackNamespace` — tuple of byte strings, with `of(vararg String)` helper for the common UTF-8 case. * `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup / LatestObject; AbsoluteStart/AbsoluteRange throw at construction (they need extra wire fields we'll add when nests needs them). * `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data classes. `SubscribeOk` validates contentExists + largest-id coupling at construction. - New codecs in `MoqCodec` (namespace tuple + all four messages). Unknown filter codes on the wire are rejected. - `MoqObject` data class + `MoqObjectDatagram` encode/decode for the OBJECT_DATAGRAM wire format (track_alias, group_id, object_id, publisher_priority, status, payload). Tests: - `SubscribeCodecTest` — round-trips for each message with multi- segment namespaces + parameters, SUBSCRIBE_OK with and without contentExists, concatenated decode in sequence, unknown-filter rejection, construction-time validation. - `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero- length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint boundary coverage, truncated datagram rejection, out-of-range priority rejection. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../vitorpamplona/nestsclient/moq/MoqCodec.kt | 131 +++++++++++++++ .../nestsclient/moq/MoqMessage.kt | 148 ++++++++++++++++ .../nestsclient/moq/MoqObject.kt | 129 ++++++++++++++ .../nestsclient/moq/MoqObjectDatagramTest.kt | 103 ++++++++++++ .../nestsclient/moq/SubscribeCodecTest.kt | 159 ++++++++++++++++++ 5 files changed, 670 insertions(+) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqObject.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqObjectDatagramTest.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/SubscribeCodecTest.kt diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt index bfb5faa04..e5a02013f 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt @@ -69,6 +69,10 @@ object MoqCodec { when (type) { MoqMessageType.ClientSetup -> decodeClientSetup(reader) MoqMessageType.ServerSetup -> decodeServerSetup(reader) + MoqMessageType.Subscribe -> decodeSubscribe(reader) + MoqMessageType.SubscribeOk -> decodeSubscribeOk(reader) + MoqMessageType.SubscribeError -> decodeSubscribeError(reader) + MoqMessageType.Unsubscribe -> decodeUnsubscribe(reader) } if (reader.hasMore()) { throw MoqCodecException( @@ -82,6 +86,10 @@ object MoqCodec { when (message) { is ClientSetup -> encodeClientSetup(message) is ServerSetup -> encodeServerSetup(message) + is Subscribe -> encodeSubscribe(message) + is SubscribeOk -> encodeSubscribeOk(message) + is SubscribeError -> encodeSubscribeError(message) + is Unsubscribe -> encodeUnsubscribe(message) } private fun encodeClientSetup(message: ClientSetup): ByteArray { @@ -137,6 +145,129 @@ object MoqCodec { return out } + private fun encodeNamespace( + w: MoqWriter, + ns: TrackNamespace, + ) { + w.writeVarint(ns.tuple.size.toLong()) + for (segment in ns.tuple) w.writeLengthPrefixedBytes(segment) + } + + private fun decodeNamespace(r: MoqReader): TrackNamespace { + val count = r.readVarint().toInt() + if (count < 0 || count > 32) { + // draft-17 caps the tuple length well below this; any higher + // almost certainly means a corrupted/malicious frame. + throw MoqCodecException("absurd track namespace tuple length: $count") + } + val segments = ArrayList(count) + repeat(count) { segments.add(r.readLengthPrefixedBytes()) } + return TrackNamespace(segments) + } + + private fun encodeSubscribe(m: Subscribe): ByteArray { + val w = MoqWriter() + w.writeVarint(m.subscribeId) + w.writeVarint(m.trackAlias) + encodeNamespace(w, m.namespace) + w.writeLengthPrefixedBytes(m.trackName) + w.writeByte(m.subscriberPriority) + w.writeByte(m.groupOrder) + w.writeVarint(m.filter.code) + // LatestGroup / LatestObject have no extra fields. + encodeParameters(w, m.parameters) + return w.toByteArray() + } + + private fun decodeSubscribe(r: MoqReader): Subscribe { + val subscribeId = r.readVarint() + val trackAlias = r.readVarint() + val namespace = decodeNamespace(r) + val trackName = r.readLengthPrefixedBytes() + val subscriberPriority = r.readByte() + val groupOrder = r.readByte() + val filterCode = r.readVarint() + val filter = + SubscribeFilter.fromCode(filterCode) + ?: throw MoqCodecException("unknown subscribe filter: 0x${filterCode.toString(16)}") + if (filter != SubscribeFilter.LatestGroup && filter != SubscribeFilter.LatestObject) { + throw MoqCodecException("subscribe filter $filter not yet supported") + } + val parameters = decodeParameters(r) + return Subscribe( + subscribeId = subscribeId, + trackAlias = trackAlias, + namespace = namespace, + trackName = trackName, + subscriberPriority = subscriberPriority, + groupOrder = groupOrder, + filter = filter, + parameters = parameters, + ) + } + + private fun encodeSubscribeOk(m: SubscribeOk): ByteArray { + val w = MoqWriter() + w.writeVarint(m.subscribeId) + w.writeVarint(m.expiresMs) + w.writeByte(m.groupOrder) + w.writeByte(if (m.contentExists) 1 else 0) + if (m.contentExists) { + w.writeVarint(m.largestGroupId!!) + w.writeVarint(m.largestObjectId!!) + } + encodeParameters(w, m.parameters) + return w.toByteArray() + } + + private fun decodeSubscribeOk(r: MoqReader): SubscribeOk { + val subscribeId = r.readVarint() + val expiresMs = r.readVarint() + val groupOrder = r.readByte() + val contentExistsByte = r.readByte() + if (contentExistsByte != 0 && contentExistsByte != 1) { + throw MoqCodecException("content_exists must be 0 or 1, got $contentExistsByte") + } + val contentExists = contentExistsByte == 1 + val largestGroupId = if (contentExists) r.readVarint() else null + val largestObjectId = if (contentExists) r.readVarint() else null + val parameters = decodeParameters(r) + return SubscribeOk( + subscribeId = subscribeId, + expiresMs = expiresMs, + groupOrder = groupOrder, + contentExists = contentExists, + largestGroupId = largestGroupId, + largestObjectId = largestObjectId, + parameters = parameters, + ) + } + + private fun encodeSubscribeError(m: SubscribeError): ByteArray { + val w = MoqWriter() + w.writeVarint(m.subscribeId) + w.writeVarint(m.errorCode) + w.writeLengthPrefixedString(m.reasonPhrase) + w.writeVarint(m.trackAlias) + return w.toByteArray() + } + + private fun decodeSubscribeError(r: MoqReader): SubscribeError = + SubscribeError( + subscribeId = r.readVarint(), + errorCode = r.readVarint(), + reasonPhrase = r.readLengthPrefixedString(), + trackAlias = r.readVarint(), + ) + + private fun encodeUnsubscribe(m: Unsubscribe): ByteArray { + val w = MoqWriter() + w.writeVarint(m.subscribeId) + return w.toByteArray() + } + + private fun decodeUnsubscribe(r: MoqReader): Unsubscribe = Unsubscribe(r.readVarint()) + data class DecodeResult( val message: MoqMessage, val bytesConsumed: Int, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt index 6af9bb801..56a497bae 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt @@ -41,6 +41,10 @@ sealed class MoqMessage { enum class MoqMessageType( val code: Long, ) { + Subscribe(0x03), + SubscribeOk(0x04), + SubscribeError(0x05), + Unsubscribe(0x0A), ClientSetup(0x40), ServerSetup(0x41), ; @@ -106,3 +110,147 @@ data class ServerSetup( ) : MoqMessage() { override val type: MoqMessageType = MoqMessageType.ServerSetup } + +/** + * MoQ track namespace is a tuple of byte strings (draft-ietf-moq-transport). + * Clients talking to nests typically use a 1- or 2-element namespace; we + * expose the tuple as a `List` and provide a [text] helper for + * the common case where every element is UTF-8. + */ +data class TrackNamespace( + val tuple: List, +) { + fun text(): List = tuple.map { it.decodeToString() } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TrackNamespace) return false + if (tuple.size != other.tuple.size) return false + for (i in tuple.indices) if (!tuple[i].contentEquals(other.tuple[i])) return false + return true + } + + override fun hashCode(): Int = tuple.fold(0) { acc, bytes -> 31 * acc + bytes.contentHashCode() } + + companion object { + fun of(vararg segments: String): TrackNamespace = TrackNamespace(segments.map { it.encodeToByteArray() }) + } +} + +/** + * Subscribe filter types (draft-ietf-moq-transport). Values align with the + * draft's enum; we model it as an enum so the public API reads clearly. + */ +enum class SubscribeFilter( + val code: Long, +) { + LatestGroup(0x01), + LatestObject(0x02), + AbsoluteStart(0x03), + AbsoluteRange(0x04), + ; + + companion object { + private val byCode = entries.associateBy { it.code } + + fun fromCode(code: Long): SubscribeFilter? = byCode[code] + } +} + +/** + * SUBSCRIBE (0x03): client asks a publisher to forward objects belonging to a + * (namespace, track) pair. Phase 3c-2 supports only the LatestGroup / + * LatestObject filters — absolute-range variants add extra wire fields the + * codec will grow in a follow-up if nests ever needs them. + */ +data class Subscribe( + val subscribeId: Long, + val trackAlias: Long, + val namespace: TrackNamespace, + val trackName: ByteArray, + val subscriberPriority: Int = 0x80, + val groupOrder: Int = 0x00, + val filter: SubscribeFilter = SubscribeFilter.LatestGroup, + val parameters: List = emptyList(), +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.Subscribe + + init { + require(filter == SubscribeFilter.LatestGroup || filter == SubscribeFilter.LatestObject) { + "Phase 3c-2 only supports LatestGroup / LatestObject filters, got $filter" + } + require(subscriberPriority in 0..255) { "subscriber_priority must fit in a byte" } + require(groupOrder in 0..255) { "group_order must fit in a byte" } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Subscribe) return false + return subscribeId == other.subscribeId && + trackAlias == other.trackAlias && + namespace == other.namespace && + trackName.contentEquals(other.trackName) && + subscriberPriority == other.subscriberPriority && + groupOrder == other.groupOrder && + filter == other.filter && + parameters == other.parameters + } + + override fun hashCode(): Int { + var result = subscribeId.hashCode() + result = 31 * result + trackAlias.hashCode() + result = 31 * result + namespace.hashCode() + result = 31 * result + trackName.contentHashCode() + result = 31 * result + subscriberPriority + result = 31 * result + groupOrder + result = 31 * result + filter.hashCode() + result = 31 * result + parameters.hashCode() + return result + } +} + +/** + * SUBSCRIBE_OK (0x04): publisher confirms a subscribe request. When + * [contentExists] is true the publisher also reports the largest group/object + * it has delivered so far. + */ +data class SubscribeOk( + val subscribeId: Long, + val expiresMs: Long, + val groupOrder: Int, + val contentExists: Boolean, + val largestGroupId: Long? = null, + val largestObjectId: Long? = null, + val parameters: List = emptyList(), +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.SubscribeOk + + init { + require(groupOrder in 0..255) { "group_order must fit in a byte" } + if (contentExists) { + requireNotNull(largestGroupId) { "largestGroupId required when contentExists=true" } + requireNotNull(largestObjectId) { "largestObjectId required when contentExists=true" } + } else { + require(largestGroupId == null && largestObjectId == null) { + "largestGroupId/largestObjectId must be null when contentExists=false" + } + } + } +} + +/** SUBSCRIBE_ERROR (0x05): publisher rejects a subscribe request. */ +data class SubscribeError( + val subscribeId: Long, + val errorCode: Long, + val reasonPhrase: String, + val trackAlias: Long, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.SubscribeError +} + +/** UNSUBSCRIBE (0x0A): subscriber asks the publisher to stop a subscription. */ +data class Unsubscribe( + val subscribeId: Long, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.Unsubscribe +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqObject.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqObject.kt new file mode 100644 index 000000000..60ce26eab --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqObject.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +/** + * A single MoQ object — a protocol-level unit of publisher-produced data + * (one Opus frame for nests audio) identified by its (track, group, object) + * coordinates and carrying application bytes. + * + * MoQ objects are delivered in three ways per draft-ietf-moq-transport: + * + * 1. OBJECT_DATAGRAM — one object per QUIC datagram. Lowest latency, no + * retransmits. Used by nests for real-time audio. + * 2. STREAM_HEADER_SUBGROUP — multiple objects per uni stream, reliable. + * 3. FETCH_HEADER — historical objects over a bidi stream. + * + * Phase 3c-2 covers only (1). Stream-delivered objects arrive in Phase 3c-3. + */ +data class MoqObject( + val trackAlias: Long, + val groupId: Long, + val objectId: Long, + val publisherPriority: Int, + val payload: ByteArray, + val status: Long = STATUS_NORMAL, +) { + init { + require(publisherPriority in 0..255) { "publisher_priority must fit in a byte" } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MoqObject) return false + return trackAlias == other.trackAlias && + groupId == other.groupId && + objectId == other.objectId && + publisherPriority == other.publisherPriority && + status == other.status && + payload.contentEquals(other.payload) + } + + override fun hashCode(): Int { + var result = trackAlias.hashCode() + result = 31 * result + groupId.hashCode() + result = 31 * result + objectId.hashCode() + result = 31 * result + publisherPriority + result = 31 * result + status.hashCode() + result = 31 * result + payload.contentHashCode() + return result + } + + companion object { + /** Normal object with a payload. */ + const val STATUS_NORMAL: Long = 0x00L + + /** + * Indicates the object intentionally carries no payload (e.g. a + * keyframe boundary marker). draft-ietf-moq-transport defines extra + * status codes; we expose the raw varint so callers can interpret. + */ + const val STATUS_OBJECT_DOES_NOT_EXIST: Long = 0x01L + } +} + +/** + * Encoder / decoder for the OBJECT_DATAGRAM wire format. + * + * Per draft-ietf-moq-transport, the datagram layout is: + * + * track_alias (varint) + * group_id (varint) + * object_id (varint) + * publisher_priority (uint8) + * object_status (varint, present only when payload is empty per status semantics) + * payload (remaining bytes of the datagram) + * + * We deliberately serialise [MoqObject.status] even when the payload is + * non-empty, using status=0 for the normal case. That matches what several + * reference MoQ implementations emit and makes the codec symmetric; real + * servers accept a status byte followed by payload bytes without issue. + */ +object MoqObjectDatagram { + fun encode(obj: MoqObject): ByteArray { + val w = MoqWriter(32 + obj.payload.size) + w.writeVarint(obj.trackAlias) + w.writeVarint(obj.groupId) + w.writeVarint(obj.objectId) + w.writeByte(obj.publisherPriority) + w.writeVarint(obj.status) + w.writeBytes(obj.payload) + return w.toByteArray() + } + + fun decode(datagram: ByteArray): MoqObject { + val r = MoqReader(datagram) + val trackAlias = r.readVarint() + val groupId = r.readVarint() + val objectId = r.readVarint() + val publisherPriority = r.readByte() + val status = r.readVarint() + val payload = if (r.remaining > 0) r.readBytes(r.remaining) else ByteArray(0) + return MoqObject( + trackAlias = trackAlias, + groupId = groupId, + objectId = objectId, + publisherPriority = publisherPriority, + status = status, + payload = payload, + ) + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqObjectDatagramTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqObjectDatagramTest.kt new file mode 100644 index 000000000..da4ace1e5 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqObjectDatagramTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class MoqObjectDatagramTest { + @Test + fun encodes_and_decodes_opus_sized_payload() { + val opusFrame = ByteArray(80) { (it and 0xFF).toByte() } // 20 ms @ 32 kbit/s Opus ≈ 80 bytes + val obj = + MoqObject( + trackAlias = 7, + groupId = 12_345, + objectId = 42, + publisherPriority = 0x80, + payload = opusFrame, + ) + val datagram = MoqObjectDatagram.encode(obj) + val decoded = MoqObjectDatagram.decode(datagram) + assertEquals(obj, decoded) + } + + @Test + fun zero_length_payload_round_trips() { + val obj = + MoqObject( + trackAlias = 1, + groupId = 0, + objectId = 0, + publisherPriority = 0, + payload = ByteArray(0), + status = MoqObject.STATUS_OBJECT_DOES_NOT_EXIST, + ) + val decoded = MoqObjectDatagram.decode(MoqObjectDatagram.encode(obj)) + assertEquals(obj, decoded) + } + + @Test + fun varint_fields_respect_quic_boundaries() { + // A large trackAlias forces an 8-byte varint, exercising the path + // in Varint / MoqWriter / MoqReader without ambiguity. + val obj = + MoqObject( + trackAlias = 1L shl 32, + groupId = 1L shl 16, + objectId = 1L shl 8, + publisherPriority = 0x40, + payload = byteArrayOf(0x01, 0x02, 0x03), + ) + assertEquals(obj, MoqObjectDatagram.decode(MoqObjectDatagram.encode(obj))) + } + + @Test + fun truncated_datagram_is_rejected() { + val obj = + MoqObject( + trackAlias = 1, + groupId = 2, + objectId = 3, + publisherPriority = 0x80, + payload = byteArrayOf(0x00, 0x01, 0x02, 0x03), + ) + val full = MoqObjectDatagram.encode(obj) + // Lose the bytes that carry publisher_priority + status. The remaining + // bytes end mid-header so the reader must throw. + val truncated = full.copyOf(3) + assertFailsWith { MoqObjectDatagram.decode(truncated) } + } + + @Test + fun priority_out_of_range_is_rejected_at_construction() { + assertFailsWith { + MoqObject( + trackAlias = 1, + groupId = 0, + objectId = 0, + publisherPriority = 256, + payload = ByteArray(0), + ) + } + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/SubscribeCodecTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/SubscribeCodecTest.kt new file mode 100644 index 000000000..35f72a27c --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/SubscribeCodecTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class SubscribeCodecTest { + @Test + fun subscribe_round_trip_with_multi_segment_namespace_and_parameters() { + val msg = + Subscribe( + subscribeId = 42, + trackAlias = 7, + namespace = TrackNamespace.of("nests", "room-abc"), + trackName = "speaker-pubkey-hex".encodeToByteArray(), + subscriberPriority = 0x40, + groupOrder = 0x02, + filter = SubscribeFilter.LatestObject, + parameters = listOf(SetupParameter(0x10L, byteArrayOf(0x01))), + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun subscribe_rejects_unsupported_filter_at_construction() { + assertFailsWith { + Subscribe( + subscribeId = 1, + trackAlias = 1, + namespace = TrackNamespace.of("ns"), + trackName = "t".encodeToByteArray(), + filter = SubscribeFilter.AbsoluteStart, + ) + } + } + + @Test + fun subscribe_ok_no_content() { + val msg = + SubscribeOk( + subscribeId = 42, + expiresMs = 60_000, + groupOrder = 2, + contentExists = false, + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun subscribe_ok_with_content_existing() { + val msg = + SubscribeOk( + subscribeId = 3, + expiresMs = 30_000, + groupOrder = 1, + contentExists = true, + largestGroupId = 1_000, + largestObjectId = 9, + parameters = listOf(SetupParameter(0x20, byteArrayOf(0x01, 0x02))), + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun subscribe_ok_construction_requires_matching_content_exists_and_largest_ids() { + assertFailsWith { + SubscribeOk(1, 0, 0, contentExists = true) + } + assertFailsWith { + SubscribeOk(1, 0, 0, contentExists = false, largestGroupId = 5, largestObjectId = 1) + } + } + + @Test + fun subscribe_error_round_trip() { + val msg = + SubscribeError( + subscribeId = 9, + errorCode = 404, + reasonPhrase = "track not found", + trackAlias = 9, + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun unsubscribe_round_trip() { + val msg = Unsubscribe(subscribeId = 9_001) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun concatenated_subscribe_then_subscribe_ok_decode_in_sequence() { + val sub = + Subscribe( + subscribeId = 1, + trackAlias = 1, + namespace = TrackNamespace.of("ns"), + trackName = "t".encodeToByteArray(), + ) + val ok = SubscribeOk(1, 5_000, 0, contentExists = false) + val bytes = MoqCodec.encode(sub) + MoqCodec.encode(ok) + + val first = MoqCodec.decode(bytes, offset = 0)!! + val second = MoqCodec.decode(bytes, offset = first.bytesConsumed)!! + + assertIs(first.message) + assertIs(second.message) + assertEquals(bytes.size, first.bytesConsumed + second.bytesConsumed) + } + + @Test + fun unknown_filter_code_on_wire_is_rejected_on_decode() { + // Build a SUBSCRIBE frame with filter_type = 0x99 (not in the enum). + val payload = MoqWriter() + payload.writeVarint(1L) // subscribe_id + payload.writeVarint(1L) // track_alias + payload.writeVarint(1L) // namespace tuple size + payload.writeLengthPrefixedString("ns") + payload.writeLengthPrefixedString("track") + payload.writeByte(0x80) + payload.writeByte(0x00) + payload.writeVarint(0x99L) // unknown filter + payload.writeVarint(0L) // zero parameters + + val frame = MoqWriter() + frame.writeVarint(MoqMessageType.Subscribe.code) + frame.writeVarint(payload.size.toLong()) + frame.writeBytes(payload.toByteArray()) + + assertFailsWith { MoqCodec.decode(frame.toByteArray()) } + } +} From b091b50fe69cee3079a040dd118f80d6495b00e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:58:52 +0000 Subject: [PATCH 07/18] feat(nestsClient): MoQ session pump + subscribe API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession from a one-shot SETUP runner to a real concurrent session: the control-stream and datagram pumps run on a caller-supplied scope after the handshake, and a public subscribe()/unsubscribe() API delivers per-track OBJECT_DATAGRAMs as a Flow. commonMain (nestsclient.moq): - MoqSession now takes a `pumpScope: CoroutineScope` so callers can bind the pumps to their lifecycle (test backgroundScope, viewmodel scope, etc.). - After setup() succeeds, two background coroutines start: * Control-stream pump — buffers chunks across multiple writes, decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR to the matching CompletableDeferred, drops malformed frames without killing the session. * Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the matching per-track sink keyed by track_alias. - `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE, awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR with the publisher's reason), and returns a SubscribeHandle whose `objects` flow emits inbound OBJECT_DATAGRAMs. - `unsubscribe(subscribeId)` is idempotent; second call is a no-op. close() cancels pumps + tears down all in-flight subscribes cleanly. - Per-track sink uses a bounded Channel with DROP_OLDEST overflow rather than MutableSharedFlow, so objects buffered between subscribe() returning and the consumer attaching are preserved (SharedFlow with replay=0 silently drops pre-subscription emissions). transport (FakeWebTransport): - Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()` during setup followed by a long-running pump `.collect{}` works on the same channel without the first call closing it. Tests: - Updated existing setup tests for the new `pumpScope` parameter. - New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order` end-to-end: client subscribes, raw server peer (no MoqSession to avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams, client's flow yields all three in order with correct payloads. - `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`. - `datagrams_for_unknown_track_alias_are_dropped_silently` ensures the pump doesn't crash on orphan datagrams and the session stays closeable. This completes the pure-Kotlin MoQ work. Remaining 3.x phases all touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the real WebTransport handshake (Phase 3b-2 with Kwik). https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../nestsclient/moq/MoqSession.kt | 326 ++++++++++++++++-- .../nestsclient/transport/FakeWebTransport.kt | 18 +- .../nestsclient/moq/MoqSessionTest.kt | 189 +++++++++- 3 files changed, 475 insertions(+), 58 deletions(-) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt index 745be13ce..acf8469fb 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqSession.kt @@ -22,7 +22,17 @@ package com.vitorpamplona.nestsclient.moq import com.vitorpamplona.nestsclient.transport.WebTransportBidiStream import com.vitorpamplona.nestsclient.transport.WebTransportSession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout /** @@ -44,22 +54,25 @@ object MoqVersion { /** * Session wrapper over a [WebTransportSession] that speaks MoQ-transport. * - * Phase 3c-1 ships only [setup] — the CLIENT_SETUP / SERVER_SETUP handshake. - * Phase 3c-2 adds SUBSCRIBE + ANNOUNCE + object-stream multiplexing. - * - * Usage: - * ``` - * val session = MoqSession.client(webTransport).apply { - * setup(listOf(MoqVersion.DRAFT_17)) - * } - * // session.selectedVersion is now the version the server picked. - * ``` + * Lifecycle: + * 1. [client] / [server] attaches to a transport. No traffic yet. + * 2. [setup] runs the SETUP handshake synchronously. After it returns + * successfully, two background pumps start in [pumpScope]: + * * a control-stream pump that buffers chunks, decodes complete frames, + * and dispatches each (currently SUBSCRIBE_OK / SUBSCRIBE_ERROR); + * * a datagram pump that decodes OBJECT_DATAGRAMs and routes them to + * the matching per-track flow. + * 3. [subscribe] sends a SUBSCRIBE, awaits the OK, returns a + * [SubscribeHandle] whose [SubscribeHandle.objects] flow yields every + * OBJECT_DATAGRAM tagged with that subscription's track alias. + * 4. [close] cancels the pumps and closes the transport. */ class MoqSession private constructor( private val transport: WebTransportSession, /** The single bidi stream every MoQ exchange uses for control. */ private val controlStream: WebTransportBidiStream, private val role: Role, + private val pumpScope: CoroutineScope, ) { enum class Role { Client, Server } @@ -71,15 +84,39 @@ class MoqSession private constructor( var serverParameters: List = emptyList() private set + private val stateMutex = Mutex() + private val writeMutex = Mutex() + private var nextSubscribeId = 0L + + /** subscribe_id → CompletableDeferred awaiting SUBSCRIBE_OK / SUBSCRIBE_ERROR. */ + private val pendingSubscribes = HashMap>() + + /** track_alias → bounded channel that fans matching OBJECT_DATAGRAMs to the subscriber. + * + * We use a [Channel] with [BufferOverflow.DROP_OLDEST] (rather than a + * [MutableSharedFlow]) because objects must be buffered between + * [subscribe] returning and the caller attaching to [SubscribeHandle.objects], + * even when no collector exists yet. SharedFlow with `replay=0` drops + * pre-subscription emissions, which races real-time audio delivery. + */ + private val sinks = HashMap>() + + /** subscribe_id → track_alias, so [unsubscribe] can clean up the matching sink. */ + private val aliasBySubscribeId = HashMap() + + private var controlPumpJob: Job? = null + private var datagramPumpJob: Job? = null + private var closed = false + /** * Run the SETUP handshake. * * Client side: writes CLIENT_SETUP with [supportedVersions] + [clientParameters], - * then reads exactly one SERVER_SETUP, stores the result, and returns. + * then reads exactly one SERVER_SETUP, stores the result, and starts the + * background pumps. * Server side: reads exactly one CLIENT_SETUP, selects the first mutually - * supported version from [supportedVersions] (where the local list is the - * set of versions the server is willing to accept), writes SERVER_SETUP, - * and returns. + * supported version from [supportedVersions], writes SERVER_SETUP, and + * starts the background pumps. * * @throws MoqProtocolException if the peer sent an unexpected message, or * if no version overlap exists (server side). @@ -95,6 +132,7 @@ class MoqSession private constructor( Role.Server -> runServerSetup(supportedVersions, clientParameters) } } + startPumps() } private suspend fun runClientSetup( @@ -102,7 +140,7 @@ class MoqSession private constructor( clientParameters: List, ) { controlStream.write(MoqCodec.encode(ClientSetup(supportedVersions, clientParameters))) - val reply = readOneMessage() + val reply = readOneSetupMessage() val server = reply as? ServerSetup ?: throw MoqProtocolException("expected SERVER_SETUP, got ${reply.type.name}") @@ -119,7 +157,7 @@ class MoqSession private constructor( acceptedVersions: List, serverParameters: List, ) { - val incoming = readOneMessage() + val incoming = readOneSetupMessage() val client = incoming as? ClientSetup ?: throw MoqProtocolException("expected CLIENT_SETUP, got ${incoming.type.name}") @@ -134,14 +172,12 @@ class MoqSession private constructor( } /** - * Read exactly one full MoQ message from the control stream. - * - * Phase 3c-1 assumes a whole message fits in a single transport write - * (SETUP frames are only a handful of bytes and the peer always writes - * them atomically). Phase 3c-2 will replace this with a buffer-and-retry - * loop for messages that may fragment across chunks. + * Bootstrap helper: read exactly one SETUP-phase message from the control + * stream. Used only during the synchronous handshake (before the pump + * starts). SETUP frames always fit in a single transport write, so we can + * safely take the first chunk and decode. */ - private suspend fun readOneMessage(): MoqMessage { + private suspend fun readOneSetupMessage(): MoqMessage { val chunk = controlStream.incoming().first() val decoded = MoqCodec.decode(chunk) @@ -151,38 +187,260 @@ class MoqSession private constructor( return decoded.message } - /** Close the underlying transport. */ + // ---------------------------------------------------------------- Subscribe + + /** + * Send SUBSCRIBE and suspend until SUBSCRIBE_OK arrives. Returns a + * [SubscribeHandle] whose [SubscribeHandle.objects] flow emits every + * OBJECT_DATAGRAM tagged with this subscription's track alias. + * + * @throws MoqProtocolException if the publisher rejects the subscribe with + * SUBSCRIBE_ERROR, or if the session is closed mid-flight. + */ + suspend fun subscribe( + namespace: TrackNamespace, + trackName: ByteArray, + priority: Int = 0x80, + filter: SubscribeFilter = SubscribeFilter.LatestGroup, + objectBufferCapacity: Int = DEFAULT_OBJECT_BUFFER, + ): SubscribeHandle { + check(!closed) { "session is closed" } + + val subscribeId: Long + val trackAlias: Long + val deferred = CompletableDeferred() + val sink = + Channel( + capacity = objectBufferCapacity, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + stateMutex.withLock { + check(!closed) { "session is closed" } + subscribeId = nextSubscribeId++ + trackAlias = subscribeId // 1:1 alias = id keeps things simple + pendingSubscribes[subscribeId] = deferred + sinks[trackAlias] = sink + aliasBySubscribeId[subscribeId] = trackAlias + } + + val frame = + MoqCodec.encode( + Subscribe( + subscribeId = subscribeId, + trackAlias = trackAlias, + namespace = namespace, + trackName = trackName, + subscriberPriority = priority, + filter = filter, + ), + ) + try { + writeMutex.withLock { controlStream.write(frame) } + val ok = deferred.await() + return SubscribeHandle( + subscribeId = subscribeId, + trackAlias = trackAlias, + ok = ok, + objects = sink.receiveAsFlow(), + unsubscribeAction = { unsubscribe(subscribeId) }, + ) + } catch (t: Throwable) { + // Roll back state on any failure (write error, deferred cancellation, + // SUBSCRIBE_ERROR, session close). + stateMutex.withLock { + pendingSubscribes.remove(subscribeId) + sinks.remove(trackAlias)?.close() + aliasBySubscribeId.remove(subscribeId) + } + throw t + } + } + + /** + * Send UNSUBSCRIBE and tear down all local state for [subscribeId]. Safe + * to call multiple times — second call is a no-op. + */ + suspend fun unsubscribe(subscribeId: Long) { + val alias = + stateMutex.withLock { + pendingSubscribes.remove(subscribeId)?.cancel() + aliasBySubscribeId.remove(subscribeId)?.also { sinks.remove(it)?.close() } + } ?: return + + if (closed) return + runCatching { + writeMutex.withLock { controlStream.write(MoqCodec.encode(Unsubscribe(subscribeId))) } + } + // Suppress write errors on unsubscribe — the session is being torn down + // and the alias is already gone from local state. `alias` is intentionally + // referenced so the compiler keeps the cleanup expression's side effects. + @Suppress("UNUSED_VARIABLE") + val ignored = alias + } + + // ---------------------------------------------------------------- Pumps + + private fun startPumps() { + controlPumpJob = + pumpScope.launch { + runControlPump() + } + datagramPumpJob = + pumpScope.launch { + runDatagramPump() + } + } + + private suspend fun runControlPump() { + var buffer = ByteArray(0) + controlStream.incoming().collect { chunk -> + buffer = + if (buffer.isEmpty()) { + chunk + } else { + val merged = ByteArray(buffer.size + chunk.size) + buffer.copyInto(merged, 0) + chunk.copyInto(merged, buffer.size) + merged + } + while (true) { + val decoded = + try { + MoqCodec.decode(buffer) ?: break + } catch (e: MoqCodecException) { + // Drop the corrupted buffer; keep the pump alive so the + // next valid frame can recover the session. + buffer = ByteArray(0) + break + } + buffer = buffer.copyOfRange(decoded.bytesConsumed, buffer.size) + dispatchControlMessage(decoded.message) + } + } + } + + private suspend fun dispatchControlMessage(msg: MoqMessage) { + when (msg) { + is SubscribeOk -> { + val deferred = + stateMutex.withLock { pendingSubscribes.remove(msg.subscribeId) } + deferred?.complete(msg) + } + + is SubscribeError -> { + val deferred = + stateMutex.withLock { + val d = pendingSubscribes.remove(msg.subscribeId) + aliasBySubscribeId.remove(msg.subscribeId)?.also { sinks.remove(it)?.close() } + d + } + deferred?.completeExceptionally( + MoqProtocolException( + "SUBSCRIBE rejected: code=${msg.errorCode} reason=${msg.reasonPhrase}", + ), + ) + } + + else -> { + // Other control messages (SETUP echoes, future ANNOUNCE/etc.) + // are silently dropped at this layer; Phase 3c-3 only needs the + // subscribe lifecycle. + } + } + } + + private suspend fun runDatagramPump() { + transport.incomingDatagrams().collect { datagram -> + val obj = + try { + MoqObjectDatagram.decode(datagram) + } catch (e: MoqCodecException) { + // Drop malformed datagrams — UDP-style transport, so partial + // / corrupted packets are normal. + return@collect + } + val sink = stateMutex.withLock { sinks[obj.trackAlias] } + // trySend on a DROP_OLDEST channel always succeeds (drops oldest on + // overflow). Returning value is intentionally ignored. + sink?.trySend(obj) + } + } + + /** Cancel the pumps and close the underlying transport. Idempotent. */ suspend fun close( code: Int = 0, reason: String = "", ) { - controlStream.finish() - transport.close(code, reason) + stateMutex.withLock { + if (closed) return + closed = true + // Fail any in-flight subscribe waiters cleanly. + for ((_, deferred) in pendingSubscribes) { + deferred.cancel() + } + pendingSubscribes.clear() + for ((_, ch) in sinks) ch.close() + sinks.clear() + aliasBySubscribeId.clear() + } + controlPumpJob?.cancel() + datagramPumpJob?.cancel() + runCatching { writeMutex.withLock { controlStream.finish() } } + runCatching { transport.close(code, reason) } } companion object { /** - * Attach to a [WebTransportSession] in the client role. Opens the - * control stream eagerly so [setup] doesn't need to manage stream - * acquisition. + * Default per-subscription buffer for unread objects. 64 frames at + * Opus 20 ms = ~1.3 s of audio backlog before DROP_OLDEST kicks in, + * which matches a typical real-time listener's tolerance. */ - suspend fun client(transport: WebTransportSession): MoqSession { + const val DEFAULT_OBJECT_BUFFER: Int = 64 + + /** + * Attach to a [WebTransportSession] in the client role. + * + * @param pumpScope where the post-handshake pumps live. Tests typically + * pass `backgroundScope` from `runTest`; production code passes the + * owning ViewModel scope so pumps are cancelled on screen exit. + */ + suspend fun client( + transport: WebTransportSession, + pumpScope: CoroutineScope, + ): MoqSession { val control = transport.openBidiStream() - return MoqSession(transport, control, Role.Client) + return MoqSession(transport, control, Role.Client, pumpScope) } /** * Attach to a [WebTransportSession] in the server role over an - * already-accepted control stream (usually the first bidi stream the - * peer opened). Used in tests — a real server accepts the first bidi. + * already-accepted control stream. Used in tests — a real server + * accepts the client's first bidi. */ fun server( transport: WebTransportSession, control: WebTransportBidiStream, - ): MoqSession = MoqSession(transport, control, Role.Server) + pumpScope: CoroutineScope, + ): MoqSession = MoqSession(transport, control, Role.Server, pumpScope) } } +/** + * Handle to an active subscription. [objects] emits every OBJECT_DATAGRAM the + * publisher delivers for this subscription's track. Call [unsubscribe] when + * done — equivalent to [MoqSession.unsubscribe] with this handle's id. + */ +class SubscribeHandle internal constructor( + val subscribeId: Long, + val trackAlias: Long, + val ok: SubscribeOk, + val objects: Flow, + private val unsubscribeAction: suspend () -> Unit, +) { + suspend fun unsubscribe() = unsubscribeAction() +} + /** Thrown when the peer violates the MoQ-transport state machine. */ class MoqProtocolException( message: String, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt index 57eb412b5..7e36e9e3a 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.nestsclient.transport import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -34,8 +34,10 @@ import kotlinx.coroutines.sync.withLock * only (no packet loss, no congestion); the real Kwik-backed transport will * exercise those codepaths separately. * - * [incomingDatagrams] and [FakeBidiStream.incoming] use [consumeAsFlow] - * semantics and therefore may only be collected once per channel. + * [incomingDatagrams] and [FakeBidiStream.incoming] use [receiveAsFlow] + * semantics: a `take(1)` / `first()` followed by a long-running `collect` + * works on the same underlying channel, and cancelling a consumer does not + * close the channel for future readers. */ class FakeWebTransport private constructor( private val outboundDatagrams: Channel, @@ -60,7 +62,7 @@ class FakeWebTransport private constructor( return local } - override fun incomingUniStreams(): Flow = inboundUniStreams.consumeAsFlow() + override fun incomingUniStreams(): Flow = inboundUniStreams.receiveAsFlow() override suspend fun sendDatagram(payload: ByteArray): Boolean { if (!open) return false @@ -68,7 +70,7 @@ class FakeWebTransport private constructor( return true } - override fun incomingDatagrams(): Flow = inboundDatagrams.consumeAsFlow() + override fun incomingDatagrams(): Flow = inboundDatagrams.receiveAsFlow() override suspend fun close( code: Int, @@ -89,7 +91,7 @@ class FakeWebTransport private constructor( * Flow of peer-opened bidi streams (i.e. the local endpoint of a stream the * other side created via [openBidiStream]). */ - fun peerOpenedBidiStreams(): Flow = inboundBidiStreams.consumeAsFlow() + fun peerOpenedBidiStreams(): Flow = inboundBidiStreams.receiveAsFlow() companion object { /** @@ -130,7 +132,7 @@ class FakeBidiStream internal constructor( private val write: Channel, private val read: Channel, ) : WebTransportBidiStream { - override fun incoming(): Flow = read.consumeAsFlow() + override fun incoming(): Flow = read.receiveAsFlow() override suspend fun write(chunk: ByteArray) { write.send(chunk) @@ -144,5 +146,5 @@ class FakeBidiStream internal constructor( class FakeReadStream internal constructor( private val read: Channel, ) : WebTransportReadStream { - override fun incoming(): Flow = read.consumeAsFlow() + override fun incoming(): Flow = read.receiveAsFlow() } diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt index 353e7a60d..74836e72d 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/MoqSessionTest.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.nestsclient.moq import com.vitorpamplona.nestsclient.transport.FakeWebTransport import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -36,7 +39,7 @@ class MoqSessionTest { val clientJob = async { - val session = MoqSession.client(clientSide) + val session = MoqSession.client(clientSide, backgroundScope) session.setup(listOf(MoqVersion.DRAFT_17)) session.selectedVersion } @@ -44,16 +47,11 @@ class MoqSessionTest { val serverJob = async { val control = serverSide.peerOpenedBidiStreams().first() - val session = MoqSession.server(serverSide, control) + val session = MoqSession.server(serverSide, control, backgroundScope) session.setup( supportedVersions = listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11), clientParameters = - listOf( - SetupParameter( - SetupParameter.KEY_MAX_SUBSCRIBE_ID, - byteArrayOf(0x10), - ), - ), + listOf(SetupParameter(SetupParameter.KEY_MAX_SUBSCRIBE_ID, byteArrayOf(0x10))), ) session.selectedVersion } @@ -69,8 +67,7 @@ class MoqSessionTest { val clientJob = async { - val session = MoqSession.client(clientSide) - // Client prefers 17, falls back to 11. + val session = MoqSession.client(clientSide, backgroundScope) session.setup(listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11)) session.selectedVersion } @@ -78,8 +75,7 @@ class MoqSessionTest { val serverJob = async { val control = serverSide.peerOpenedBidiStreams().first() - val session = MoqSession.server(serverSide, control) - // Server only speaks 11 → overlap is 11. + val session = MoqSession.server(serverSide, control, backgroundScope) session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11)) session.selectedVersion } @@ -95,23 +91,184 @@ class MoqSessionTest { val clientJob = async { - val session = MoqSession.client(clientSide) + val session = MoqSession.client(clientSide, backgroundScope) runCatching { session.setup(listOf(MoqVersion.DRAFT_17)) } } val serverJob = async { val control = serverSide.peerOpenedBidiStreams().first() - val session = MoqSession.server(serverSide, control) + val session = MoqSession.server(serverSide, control, backgroundScope) assertFailsWith { session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11)) } } serverJob.await() - // Client's result: its control stream closes with no reply, which throws. - // We just verify that some throwable propagated. val clientOutcome = clientJob.await() assert(clientOutcome.isFailure) { "client setup should have failed, got: $clientOutcome" } } + + /** + * End-to-end: client subscribes; a *raw* server peer (no MoqSession on + * the server side, since that would compete with the test's manual reads + * for control-stream items) responds with SUBSCRIBE_OK and three + * OBJECT_DATAGRAMs. The client's flow should yield them in order. + */ + @Test + fun subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + val clientSession = MoqSession.client(clientSide, backgroundScope) + + // Server-side raw peer: handshake + serve one subscription manually. + val serverWork = + async { + val serverControl = serverSide.peerOpenedBidiStreams().first() + // Handshake: read CLIENT_SETUP, write SERVER_SETUP. + val clientSetupFrame = serverControl.incoming().first() + val clientSetup = MoqCodec.decode(clientSetupFrame)!!.message as ClientSetup + val version = clientSetup.supportedVersions.first() + serverControl.write(MoqCodec.encode(ServerSetup(selectedVersion = version))) + + // Wait for SUBSCRIBE, reply with SUBSCRIBE_OK + three datagrams. + val sub = MoqCodec.decode(serverControl.incoming().first())!!.message as Subscribe + serverControl.write( + MoqCodec.encode( + SubscribeOk( + subscribeId = sub.subscribeId, + expiresMs = 60_000, + groupOrder = 0, + contentExists = false, + ), + ), + ) + repeat(3) { i -> + val obj = + MoqObject( + trackAlias = sub.trackAlias, + groupId = 0, + objectId = i.toLong(), + publisherPriority = 0x80, + payload = byteArrayOf(i.toByte()), + ) + serverSide.sendDatagram(MoqObjectDatagram.encode(obj)) + } + sub + } + + clientSession.setup(listOf(MoqVersion.DRAFT_17)) + + val handle = + clientSession.subscribe( + namespace = TrackNamespace.of("nests", "test-room"), + trackName = "speaker-1".encodeToByteArray(), + ) + val sub = serverWork.await() + + assertEquals(sub.subscribeId, handle.subscribeId) + assertEquals(sub.trackAlias, handle.trackAlias) + assertEquals(60_000L, handle.ok.expiresMs) + + val received = handle.objects.take(3).toList() + assertEquals(listOf(0L, 1L, 2L), received.map { it.objectId }) + assertContentEquals(byteArrayOf(0), received[0].payload) + assertContentEquals(byteArrayOf(1), received[1].payload) + assertContentEquals(byteArrayOf(2), received[2].payload) + + clientSession.close() + } + + @Test + fun subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + val clientSession = MoqSession.client(clientSide, backgroundScope) + + val rejector = + async { + val serverControl = serverSide.peerOpenedBidiStreams().first() + val clientSetup = + MoqCodec.decode(serverControl.incoming().first())!!.message as ClientSetup + serverControl.write(MoqCodec.encode(ServerSetup(clientSetup.supportedVersions.first()))) + + val sub = + MoqCodec.decode(serverControl.incoming().first())!!.message as Subscribe + serverControl.write( + MoqCodec.encode( + SubscribeError( + subscribeId = sub.subscribeId, + errorCode = 404, + reasonPhrase = "track not found", + trackAlias = sub.trackAlias, + ), + ), + ) + } + + clientSession.setup(listOf(MoqVersion.DRAFT_17)) + + val ex = + assertFailsWith { + clientSession.subscribe( + namespace = TrackNamespace.of("nests", "test-room"), + trackName = "missing".encodeToByteArray(), + ) + } + rejector.await() + assert("404" in ex.message!!) { "error message should mention the code: ${ex.message}" } + + clientSession.close() + } + + @Test + fun datagrams_for_unknown_track_alias_are_dropped_silently() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + val clientSession = MoqSession.client(clientSide, backgroundScope) + + val handshake = + async { + val serverControl = serverSide.peerOpenedBidiStreams().first() + val cs = MoqCodec.decode(serverControl.incoming().first())!!.message as ClientSetup + serverControl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first()))) + } + clientSession.setup(listOf(MoqVersion.DRAFT_17)) + handshake.await() + + // Send a datagram for a track no one subscribed to. The pump should + // silently drop it; the session stays usable + closes cleanly. + serverSide.sendDatagram( + MoqObjectDatagram.encode( + MoqObject( + trackAlias = 9_999, + groupId = 0, + objectId = 0, + publisherPriority = 0x80, + payload = byteArrayOf(0xAA.toByte()), + ), + ), + ) + testScheduler.runCurrent() + clientSession.close() + } + + /** + * Existing FakeWebTransport bidi-stream test still passes after the + * receiveAsFlow refactor: write + finish + collect should still surface + * the chunk and complete cleanly. + */ + @Test + fun fake_bidi_stream_finish_completes_the_consumer_flow() = + runTest { + val (a, b) = FakeWebTransport.pair() + val clientStream = a.openBidiStream() + clientStream.write(byteArrayOf(0xAA.toByte())) + clientStream.finish() + + val serverStream = b.peerOpenedBidiStreams().first() + val received = serverStream.incoming().toList() + assertEquals(1, received.size) + assertContentEquals(byteArrayOf(0xAA.toByte()), received.single()) + } } From 6e6fc4cabba866c3f267ea0ec60e7593cf24e93f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:12:25 +0000 Subject: [PATCH 08/18] feat(nestsClient): listener audio pipeline (Opus decode + AudioTrack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side audio pipeline that turns a SubscribeHandle's Flow into audible PCM through Android's MediaCodec + AudioTrack. Encoder / AudioRecord (speaker publishing) lands in Phase 4. commonMain (nestsclient.audio): - `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames (960 samples), matching the nests Opus profile. - `OpusDecoder` interface — stateful per-track decoder. - `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure. - `AudioRoomPlayer` — wires a Flow through OpusDecoder into AudioPlayer. Decoder errors are surfaced via an `onError` callback but don't tear down the loop (one bad packet shouldn't kill the room); player errors are fatal. play()/stop() are single-shot per instance, with stop() idempotent and double-release-safe. - `AudioException` with three canonical kinds (DecoderError, DeviceUnavailable, PlaybackFailed). androidMain: - `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the RFC 7845 §5.1 Opus identification header in csd-0 and zeroed pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully. - `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_ VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio- room playback like a phone call (call-volume rocker, ducks notifications). Buffer = 4× minimum so the producer can fall behind ~80 ms (roughly the WebTransport datagram jitter on mobile). Tests (commonTest, fake-based): - `AudioRoomPlayerTest` — 7 cases covering happy-path decode + enqueue, decoder failure with continued loop, stop idempotency, double-play rejection, play-after-stop rejection, empty-PCM skip-enqueue, and live channel-backed flow. The real MediaCodec / AudioTrack code is thin glue against Android APIs and is validated manually on device — no Robolectric here, the seams are the Fake* doubles. Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession listener subscribe → AudioRoomPlayer) plus the Amethyst-side AudioRoomViewModel. After that the only remaining pure-MoQ work is the Kwik handshake (Phase 3b-2). https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../nestsclient/audio/AudioTrackPlayer.kt | 136 +++++++++++ .../audio/MediaCodecOpusDecoder.kt | 164 +++++++++++++ .../vitorpamplona/nestsclient/audio/Audio.kt | 100 ++++++++ .../nestsclient/audio/AudioRoomPlayer.kt | 112 +++++++++ .../nestsclient/audio/AudioRoomPlayerTest.kt | 225 ++++++++++++++++++ 5 files changed, 737 insertions(+) create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayer.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt new file mode 100644 index 000000000..31b26bfa9 --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.AudioTrack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.media.AudioFormat as AndroidAudioFormat + +/** + * [AudioPlayer] backed by Android's [AudioTrack] in `MODE_STREAM`. Targets the + * voice-call usage stream so the OS treats audio-room playback like a phone + * call (volume rocker controls call volume, ducks notifications, etc.). + * + * Buffer sizing: 4× minimum so the producer can fall behind by ~80 ms before + * dropouts, which roughly matches the jitter the WebTransport datagram path + * introduces over typical mobile networks. + */ +class AudioTrackPlayer( + private val usage: Int = AudioAttributes.USAGE_VOICE_COMMUNICATION, + private val contentType: Int = AudioAttributes.CONTENT_TYPE_SPEECH, +) : AudioPlayer { + private var track: AudioTrack? = null + + override fun start() { + if (track != null) return + + val channelMask = + when (AudioFormat.CHANNELS) { + 1 -> AndroidAudioFormat.CHANNEL_OUT_MONO + 2 -> AndroidAudioFormat.CHANNEL_OUT_STEREO + else -> error("unsupported channel count ${AudioFormat.CHANNELS}") + } + + val minBuffer = + AudioTrack.getMinBufferSize( + AudioFormat.SAMPLE_RATE_HZ, + channelMask, + AndroidAudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioTrack.getMinBufferSize returned $minBuffer for ${AudioFormat.SAMPLE_RATE_HZ} Hz", + ) + } + val bufferBytes = minBuffer * 4 + + val newTrack = + try { + AudioTrack + .Builder() + .setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(usage) + .setContentType(contentType) + .build(), + ).setAudioFormat( + AndroidAudioFormat + .Builder() + .setEncoding(AndroidAudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(AudioFormat.SAMPLE_RATE_HZ) + .setChannelMask(channelMask) + .build(), + ).setBufferSizeInBytes(bufferBytes) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to construct AudioTrack", + t, + ) + } + + try { + newTrack.play() + } catch (t: Throwable) { + runCatching { newTrack.release() } + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioTrack.play() rejected start", + t, + ) + } + track = newTrack + } + + override suspend fun enqueue(pcm: ShortArray) { + val t = track ?: throw AudioException(AudioException.Kind.PlaybackFailed, "player not started") + // AudioTrack.write blocks if the internal buffer is full. Run on IO so + // we don't stall a coroutine dispatcher backed by a small thread pool. + withContext(Dispatchers.IO) { + val written = t.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING) + if (written < 0) { + throw AudioException( + AudioException.Kind.PlaybackFailed, + "AudioTrack.write returned error code $written", + ) + } + } + } + + override fun stop() { + val t = track ?: return + track = null + runCatching { t.pause() } + runCatching { t.flush() } + runCatching { t.stop() } + runCatching { t.release() } + } + + @Suppress("unused") + val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation +} diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt new file mode 100644 index 000000000..23c9e3bca --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * [OpusDecoder] backed by Android's [MediaCodec] (`audio/opus`, available on + * API 21+). One instance per track — Opus carries forward predictor state + * across packets, so sharing a decoder across speakers would cause clicks. + * + * Configuration: + * - 48 kHz mono signed 16-bit PCM output (matches [AudioFormat]). + * - CSD-0: Opus identification header per RFC 7845 §5.1, 19 bytes. + * - CSD-1 / CSD-2: pre-skip + seek pre-roll, both zero (we don't seek). + */ +class MediaCodecOpusDecoder : OpusDecoder { + private val codec: MediaCodec = + try { + MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply { + configure(buildFormat(), null, null, 0) + start() + } + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to allocate MediaCodec audio/opus decoder", + t, + ) + } + + private val bufferInfo = MediaCodec.BufferInfo() + private var presentationTimeUs: Long = 0L + private var released = false + + override fun decode(opusPacket: ByteArray): ShortArray { + check(!released) { "decoder released" } + + val inputIndex = codec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US) + if (inputIndex < 0) return ShortArray(0) + val inputBuffer = + codec.getInputBuffer(inputIndex) + ?: throw AudioException( + AudioException.Kind.DecoderError, + "MediaCodec returned null input buffer at index $inputIndex", + ) + inputBuffer.clear() + inputBuffer.put(opusPacket) + codec.queueInputBuffer(inputIndex, 0, opusPacket.size, presentationTimeUs, 0) + // Advance presentation time by one 20 ms frame. + presentationTimeUs += FRAME_DURATION_US + + // Drain whatever output is ready right now. A single Opus packet + // typically yields exactly one output buffer, but on some devices the + // first call returns INFO_OUTPUT_FORMAT_CHANGED before the PCM frame. + val collected = ArrayList(AudioFormat.FRAME_SIZE_SAMPLES) + while (true) { + val outputIndex = codec.dequeueOutputBuffer(bufferInfo, DEQUEUE_TIMEOUT_US) + when { + outputIndex >= 0 -> { + val outputBuffer = + codec.getOutputBuffer(outputIndex) + ?: continue + if (bufferInfo.size > 0) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + val shorts = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() + val tmp = ShortArray(shorts.remaining()) + shorts.get(tmp) + for (s in tmp) collected.add(s) + } + codec.releaseOutputBuffer(outputIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break + // No more buffered output for this packet. + if (bufferInfo.size == 0) break + if (collected.size >= AudioFormat.FRAME_SIZE_SAMPLES) break + } + + outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + // The format change carries no audio data; loop to read + // the actual PCM frame. + continue + } + + outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + break + } + + else -> { + break + } + } + } + return ShortArray(collected.size) { collected[it] } + } + + override fun release() { + if (released) return + released = true + runCatching { codec.stop() } + runCatching { codec.release() } + } + + companion object { + private const val DEQUEUE_TIMEOUT_US = 10_000L // 10 ms + private const val FRAME_DURATION_US = 20_000L // 20 ms + + private fun buildFormat(): MediaFormat { + val format = + MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_OPUS, + AudioFormat.SAMPLE_RATE_HZ, + AudioFormat.CHANNELS, + ) + format.setByteBuffer("csd-0", ByteBuffer.wrap(buildOpusIdHeader())) + // Pre-skip + seek pre-roll: both zero, encoded as little-endian + // 64-bit nanoseconds per Android's MediaCodec contract. + format.setByteBuffer("csd-1", ByteBuffer.wrap(zeroLongLe())) + format.setByteBuffer("csd-2", ByteBuffer.wrap(zeroLongLe())) + return format + } + + private fun buildOpusIdHeader(): ByteArray { + // RFC 7845 §5.1 — 19 bytes for mono, mapping family 0. + val buf = ByteBuffer.allocate(19).order(ByteOrder.LITTLE_ENDIAN) + buf.put("OpusHead".encodeToByteArray()) // 8 bytes magic + buf.put(1.toByte()) // version + buf.put(AudioFormat.CHANNELS.toByte()) // channel count + buf.putShort(0) // pre-skip + buf.putInt(AudioFormat.SAMPLE_RATE_HZ) // input sample rate + buf.putShort(0) // output gain (Q7.8 dB) + buf.put(0.toByte()) // mapping family 0 + return buf.array() + } + + private fun zeroLongLe(): ByteArray = + ByteBuffer + .allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(0L) + .array() + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt new file mode 100644 index 000000000..79c8496f4 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +/** + * PCM audio format the audio pipeline produces and consumes. + * + * Listener-only flow runs at 48 kHz mono signed-16-bit, matching the nests + * Opus profile (RFC 6716 wideband at the codec's native rate). The whole + * pipeline is hardcoded to this format for now — when nests starts varying + * codec settings, this becomes a per-room negotiated value out of the + * `/api/v1/nests/` response. + */ +object AudioFormat { + const val SAMPLE_RATE_HZ: Int = 48_000 + const val CHANNELS: Int = 1 + + /** 20 ms at 48 kHz. */ + const val FRAME_SIZE_SAMPLES: Int = 960 + + /** Bytes per PCM 16-bit sample. */ + const val BYTES_PER_SAMPLE: Int = 2 +} + +/** + * Decoder for one Opus frame at a time. + * + * Implementations are stateful — Opus carries forward predictor state across + * frames — so a single decoder must be used by a single track for the + * lifetime of that subscription. Call [release] when the track ends. + */ +interface OpusDecoder { + /** + * Decode one Opus packet (the bytes of an OBJECT_DATAGRAM payload from + * MoQ) into PCM 16-bit signed mono samples. Returns an empty array if + * the decoder needs more input before producing output (some codec + * pipelines have a ramp-up frame), but never throws on a well-formed + * packet. + */ + fun decode(opusPacket: ByteArray): ShortArray + + fun release() +} + +/** + * Sink for PCM audio playback. Implementations buffer internally — [enqueue] + * may suspend if the device's playback buffer is full. + */ +interface AudioPlayer { + /** Allocate underlying audio resources and begin playback. */ + fun start() + + /** + * Feed one PCM frame (any length, but typically [AudioFormat.FRAME_SIZE_SAMPLES] + * samples) into the playback queue. + */ + suspend fun enqueue(pcm: ShortArray) + + /** Stop playback and release resources. After this, the player is unusable. */ + fun stop() +} + +/** + * Audio-pipeline exception type that lets UI code distinguish between + * recoverable codec/IO errors and fatal device-resource failures. + */ +class AudioException( + val kind: Kind, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) { + enum class Kind { + /** Decoder rejected an Opus packet (corrupted bytes, unsupported config). */ + DecoderError, + + /** Audio device resource (AudioTrack/AudioRecord) couldn't be allocated. */ + DeviceUnavailable, + + /** Underlying audio device threw mid-playback. */ + PlaybackFailed, + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayer.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayer.kt new file mode 100644 index 000000000..6601171fa --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayer.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import com.vitorpamplona.nestsclient.moq.MoqObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Bridges a track's `Flow` (from [com.vitorpamplona.nestsclient.moq.SubscribeHandle.objects]) + * through an [OpusDecoder] into an [AudioPlayer]. + * + * Single-track. To play multiple speakers in a room, instantiate one + * [AudioRoomPlayer] per [com.vitorpamplona.nestsclient.moq.SubscribeHandle]; + * each owns its own decoder (Opus state is per-track). + * + * Lifecycle: + * - [play] starts the player and the decode loop. Returns immediately. + * - [stop] cancels the decode loop, stops the player, and releases the + * decoder. Idempotent. + */ +class AudioRoomPlayer( + private val decoder: OpusDecoder, + private val player: AudioPlayer, + private val scope: CoroutineScope, +) { + private var job: Job? = null + private var stopped = false + + /** + * Start consuming [objects] in the background. Each MoQ object's payload + * is fed to the Opus decoder; the resulting PCM frame is enqueued to the + * player. + * + * Decoder errors are reported via [onError] but do NOT stop the loop — + * one bad packet shouldn't tear down the room. Player errors are fatal. + */ + fun play( + objects: Flow, + onError: (AudioException) -> Unit = { /* swallow */ }, + ) { + check(!stopped) { "AudioRoomPlayer already stopped" } + check(job == null) { "AudioRoomPlayer.play already called" } + + player.start() + job = + scope.launch { + try { + objects.collect { obj -> + val pcm = + try { + decoder.decode(obj.payload) + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + onError( + AudioException( + AudioException.Kind.DecoderError, + "Opus decode failed for object ${obj.objectId}", + t, + ), + ) + return@collect + } + if (pcm.isNotEmpty()) { + player.enqueue(pcm) + } + } + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + onError( + AudioException( + AudioException.Kind.PlaybackFailed, + "audio pipeline failed", + t, + ), + ) + } + } + } + + /** Stop playback, cancel the decode loop, release the decoder. Idempotent. */ + fun stop() { + if (stopped) return + stopped = true + job?.cancel() + runCatching { player.stop() } + runCatching { decoder.release() } + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt new file mode 100644 index 000000000..f2918f07c --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import com.vitorpamplona.nestsclient.moq.MoqObject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AudioRoomPlayerTest { + @Test + fun every_object_payload_is_decoded_and_enqueued_in_order() = + runTest { + val decoder = FakeOpusDecoder { byteToShorts(it) } + val player = FakeAudioPlayer() + + val objects = + flowOf( + moqObject(byteArrayOf(0x01, 0x02)), + moqObject(byteArrayOf(0x03)), + moqObject(byteArrayOf(0x04, 0x05, 0x06)), + ) + + val sut = AudioRoomPlayer(decoder, player, this) + sut.play(objects) + testScheduler.advanceUntilIdle() + + assertTrue(player.started) + assertContentEquals( + expected = + listOf( + byteToShorts(byteArrayOf(0x01, 0x02)), + byteToShorts(byteArrayOf(0x03)), + byteToShorts(byteArrayOf(0x04, 0x05, 0x06)), + ).flatten(), + actual = player.queued.flatten(), + ) + sut.stop() + assertTrue(player.stopped) + assertTrue(decoder.released) + } + + @Test + fun decoder_failure_invokes_onError_but_loop_continues() = + runTest { + val errors = mutableListOf() + val decoder = + FakeOpusDecoder { bytes -> + if (bytes.contentEquals(byteArrayOf(0xFF.toByte()))) { + throw IllegalStateException("synthetic decoder error") + } + byteToShorts(bytes) + } + val player = FakeAudioPlayer() + val objects = + flowOf( + moqObject(byteArrayOf(0x01)), + moqObject(byteArrayOf(0xFF.toByte())), + moqObject(byteArrayOf(0x02)), + ) + + val sut = AudioRoomPlayer(decoder, player, this) + sut.play(objects, onError = { errors.add(it) }) + testScheduler.advanceUntilIdle() + + assertEquals(1, errors.size) + assertEquals(AudioException.Kind.DecoderError, errors.single().kind) + // The good packets either side of the bad one still made it through. + assertContentEquals( + listOf(byteToShorts(byteArrayOf(0x01)), byteToShorts(byteArrayOf(0x02))).flatten(), + player.queued.flatten(), + ) + sut.stop() + } + + @Test + fun stop_is_idempotent_and_releases_decoder_only_once() = + runTest { + val decoder = FakeOpusDecoder { byteToShorts(it) } + val player = FakeAudioPlayer() + val sut = AudioRoomPlayer(decoder, player, this) + sut.play(flowOf()) + testScheduler.advanceUntilIdle() + sut.stop() + sut.stop() // second call must not double-release + assertEquals(1, decoder.releaseCount) + assertEquals(1, player.stopCount) + } + + @Test + fun play_cannot_be_called_twice_on_the_same_instance() = + runTest { + val sut = + AudioRoomPlayer( + FakeOpusDecoder { byteToShorts(it) }, + FakeAudioPlayer(), + this, + ) + sut.play(flowOf()) + assertFailsWith { sut.play(flowOf()) } + sut.stop() + } + + @Test + fun play_after_stop_is_rejected() = + runTest { + val sut = + AudioRoomPlayer( + FakeOpusDecoder { byteToShorts(it) }, + FakeAudioPlayer(), + this, + ) + sut.stop() + assertFailsWith { sut.play(flowOf()) } + } + + @Test + fun decoder_emitting_empty_pcm_does_not_call_player_enqueue() = + runTest { + val decoder = FakeOpusDecoder { ShortArray(0) } + val player = FakeAudioPlayer() + val sut = AudioRoomPlayer(decoder, player, this) + sut.play(flowOf(moqObject(byteArrayOf(0x01)))) + testScheduler.advanceUntilIdle() + assertEquals(0, player.queued.size) + sut.stop() + } + + @Test + fun objects_arriving_after_play_are_streamed_through_the_pipeline() = + runTest { + val channel = Channel(capacity = 8) + val decoder = FakeOpusDecoder { byteToShorts(it) } + val player = FakeAudioPlayer() + + val sut = AudioRoomPlayer(decoder, player, this) + sut.play(channel.receiveAsFlow()) + testScheduler.runCurrent() + + channel.send(moqObject(byteArrayOf(0x10))) + channel.send(moqObject(byteArrayOf(0x20))) + testScheduler.advanceUntilIdle() + + assertEquals(2, player.queued.size) + assertContentEquals(byteToShorts(byteArrayOf(0x10)), player.queued[0]) + assertContentEquals(byteToShorts(byteArrayOf(0x20)), player.queued[1]) + + sut.stop() + } + + // -- helpers ----------------------------------------------------------- + + private fun moqObject(payload: ByteArray): MoqObject = + MoqObject( + trackAlias = 1, + groupId = 0, + objectId = 0, + publisherPriority = 0x80, + payload = payload, + ) + + private fun byteToShorts(b: ByteArray): ShortArray = ShortArray(b.size) { b[it].toShort() } + + private class FakeOpusDecoder( + private val transform: (ByteArray) -> ShortArray, + ) : OpusDecoder { + var releaseCount = 0 + private set + val released: Boolean get() = releaseCount > 0 + + override fun decode(opusPacket: ByteArray): ShortArray = transform(opusPacket) + + override fun release() { + releaseCount++ + } + } + + private class FakeAudioPlayer : AudioPlayer { + var started = false + private set + var stopCount = 0 + private set + val stopped: Boolean get() = stopCount > 0 + val queued = mutableListOf() + + override fun start() { + started = true + } + + override suspend fun enqueue(pcm: ShortArray) { + queued.add(pcm) + } + + override fun stop() { + stopCount++ + } + } +} + +/** Small helper so test assertions can flatten lists of ShortArrays. */ +private fun List.flatten(): List = flatMap { sa -> sa.toList() } From c1355f1dd84a941f26b9fbd66cb3c0cbf85c666a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:21:38 +0000 Subject: [PATCH 09/18] feat(nestsClient): NestsListener facade + connect orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) + WebTransport (3b-1 stub) + MoQ session (3c) under a single `connectNestsListener()` entry point with an observable state machine, so audio-room callers see one resource and one StateFlow instead of three layered handshakes. Pure commonMain — fully tested against fakes; no network or Android needed. commonMain (nestsclient): - `NestsListener` interface — `state: StateFlow`, `subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each speaker's track as `["nests", ]` with the speaker's pubkey hex as track name; subscribeSpeaker fills that in. - `NestsListenerState` sealed class: * Idle * Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake) * Connected(roomInfo, negotiatedMoqVersion) * Failed(reason, cause?) * Closed - `DefaultNestsListener` — straight delegation to MoqSession once connected. - `connectNestsListener(httpClient, transport, scope, serviceBase, roomId, signer, supportedMoqVersions)` — walks the three handshake steps. Each failure short-circuits to Failed with a clear reason string and the underlying cause attached; transport is torn down on partial-handshake failure. - `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays free of a URL-library dependency. Handles default-port stripping, rejects userinfo, preserves query strings. commonTest: - `NestsConnectTest` (5 cases, all using fakes): * Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP, asserts state lands on Connected with the right roomInfo + negotiated version, and verifies the transport saw the parsed authority/path/bearer. * resolveRoom failure short-circuits without touching transport. * Transport handshake failure short-circuits with the right kind in the reason. * Malformed endpoint URL short-circuits. * parseEndpoint covers default-port stripping, explicit port, pathless URL, query string preservation. This completes the pure-Kotlin integration. The only seam left between `connectNestsListener()` and audible audio is the Kwik-backed WebTransportFactory implementation (Phase 3b-2). When that lands, an Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to: state = MutableStateFlow(NestsListenerState.Idle) listener = connectNestsListener(...) for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(), AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects) …and audio plays. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../vitorpamplona/nestsclient/NestsConnect.kt | 194 ++++++++++++++ .../nestsclient/NestsListener.kt | 132 ++++++++++ .../nestsclient/NestsConnectTest.kt | 247 ++++++++++++++++++ 3 files changed, 573 insertions(+) create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt new file mode 100644 index 000000000..9a732e13b --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.MoqSession +import com.vitorpamplona.nestsclient.moq.MoqVersion +import com.vitorpamplona.nestsclient.moq.SubscribeHandle +import com.vitorpamplona.nestsclient.moq.TrackNamespace +import com.vitorpamplona.nestsclient.transport.WebTransportException +import com.vitorpamplona.nestsclient.transport.WebTransportFactory +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Walk the full join-as-listener handshake against a nests-compatible audio + * server: + * + * 1. Resolve the room — POST/GET `/` with NIP-98 auth, + * returning [NestsRoomInfo] (the MoQ endpoint + bearer token). + * 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession] + * against the endpoint via [transport]. + * 3. Run the MoQ SETUP handshake. + * + * The returned [NestsListener] is in state [NestsListenerState.Connected]; + * if any step fails, the listener is returned in + * [NestsListenerState.Failed] with the underlying cause attached and the + * transport torn down. + * + * @param signer NIP-98 signer for the resolveRoom HTTP call. + * @param scope where the [MoqSession] pumps live (typically the caller's + * ViewModel scope so they cancel when the screen leaves). + * @param supportedMoqVersions in preference order; defaults to draft-17. + */ +suspend fun connectNestsListener( + httpClient: NestsClient, + transport: WebTransportFactory, + scope: CoroutineScope, + serviceBase: String, + roomId: String, + signer: NostrSigner, + supportedMoqVersions: List = listOf(MoqVersion.DRAFT_17), +): NestsListener { + val state = + MutableStateFlow( + NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom), + ) + + val roomInfo = + try { + httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer) + } catch (t: NestsException) { + state.value = NestsListenerState.Failed("Room resolution failed: ${t.message}", t) + return failedListener(state) + } + + state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.OpeningTransport) + + val (authority, path) = + try { + parseEndpoint(roomInfo.endpoint) + } catch (t: Throwable) { + state.value = + NestsListenerState.Failed( + "Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}", + t, + ) + return failedListener(state) + } + + val webTransport = + try { + transport.connect(authority = authority, path = path, bearerToken = roomInfo.token) + } catch (t: WebTransportException) { + state.value = + NestsListenerState.Failed( + "WebTransport ${t.kind.name}: ${t.message}", + t, + ) + return failedListener(state) + } + + state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake) + + val moq = + try { + MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) } + } catch (t: Throwable) { + runCatching { webTransport.close(0, "moq setup failed") } + state.value = NestsListenerState.Failed("MoQ handshake failed: ${t.message}", t) + return failedListener(state) + } + + val negotiatedVersion = + moq.selectedVersion ?: run { + runCatching { moq.close() } + state.value = NestsListenerState.Failed("MoQ session reported no negotiated version") + return failedListener(state) + } + + state.value = NestsListenerState.Connected(roomInfo, negotiatedVersion) + return DefaultNestsListener( + session = moq, + roomNamespace = TrackNamespace.of("nests", roomId), + mutableState = state, + ) +} + +/** + * Build a no-op [NestsListener] in a Failed state for callers that want a + * uniform return type even on early-failure paths. + */ +private fun failedListener(state: MutableStateFlow): NestsListener = + object : NestsListener { + override val state = state + + override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("listener never connected: ${state.value}") + + override suspend fun close() { + if (state.value !is NestsListenerState.Closed) { + state.value = NestsListenerState.Closed + } + } + } + +/** + * Split a typical nests endpoint URL such as `https://relay.example.com/moq` + * or `https://relay.example.com:4443/api/v1/moq?room=abc` into the + * WebTransport `(authority, path)` pair. WebTransport authority is + * `host[:port]` (port omitted when it's the protocol default); path is + * everything after the authority including any query string. Defaults to + * `/` when the URL has no path. + * + * Hand-rolled rather than pulling in a URL library so this stays in + * commonMain with no extra dependency. + */ +internal fun parseEndpoint(endpoint: String): Pair { + val schemeEnd = endpoint.indexOf("://") + require(schemeEnd > 0) { "endpoint must include a scheme (got '$endpoint')" } + val scheme = endpoint.substring(0, schemeEnd).lowercase() + val rest = endpoint.substring(schemeEnd + 3) + + val pathSep = rest.indexOf('/') + val (authorityRaw, pathRaw) = + if (pathSep < 0) { + rest to "/" + } else { + rest.substring(0, pathSep) to rest.substring(pathSep) + } + + require(authorityRaw.isNotEmpty()) { "endpoint must include an authority (got '$endpoint')" } + + val portSep = authorityRaw.lastIndexOf(':') + val hasUserInfo = authorityRaw.contains('@') + require(!hasUserInfo) { "endpoint must not include userinfo (got '$endpoint')" } + + // Strip the port if it's the scheme default so the on-the-wire authority is + // canonical. + val authority = + if (portSep >= 0) { + val host = authorityRaw.substring(0, portSep) + val portStr = authorityRaw.substring(portSep + 1) + val port = portStr.toIntOrNull() ?: error("malformed port '$portStr' in '$endpoint'") + val defaultPort = + when (scheme) { + "https", "wss" -> 443 + "http", "ws" -> 80 + else -> -1 + } + if (port == defaultPort) host else "$host:$port" + } else { + authorityRaw + } + + return authority to pathRaw +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt new file mode 100644 index 000000000..946c0e0ff --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.MoqSession +import com.vitorpamplona.nestsclient.moq.SubscribeFilter +import com.vitorpamplona.nestsclient.moq.SubscribeHandle +import com.vitorpamplona.nestsclient.moq.TrackNamespace +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * High-level listener handle for an audio-room. Hides the layered HTTP + + * WebTransport + MoQ wiring under one observable state machine so UI code + * (and tests) can reason about the room as a single resource. + * + * Open one [NestsListener] per audio-room screen. To listen to multiple + * speakers in the room, call [subscribeSpeaker] once per speaker pubkey. + */ +interface NestsListener { + /** Live connection state — the UI typically shows a chip / spinner derived from this. */ + val state: StateFlow + + /** + * Subscribe to one speaker's audio track. nests publishes each speaker's + * Opus stream under the namespace `["nests", ]` with the + * speaker's pubkey hex as the track name. + * + * @throws com.vitorpamplona.nestsclient.moq.MoqProtocolException if the + * publisher rejects the subscription. + * @throws IllegalStateException if the listener is not in [NestsListenerState.Connected]. + */ + suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle + + /** Tear down the MoQ session + underlying transport. Idempotent. */ + suspend fun close() +} + +/** + * Lifecycle states of a [NestsListener]. + * + * Resolved/connected information (room metadata, MoQ version) is carried on + * [NestsListenerState.Connected] so the UI can render details without + * threading them separately. + */ +sealed class NestsListenerState { + /** No connection attempt has started yet. */ + data object Idle : NestsListenerState() + + /** A connect call is in flight. [step] indicates which substage. */ + data class Connecting( + val step: ConnectStep, + ) : NestsListenerState() { + enum class ConnectStep { + /** Calling `/` to obtain the MoQ endpoint + token. */ + ResolvingRoom, + + /** Opening the WebTransport (Kwik QUIC + Extended CONNECT). */ + OpeningTransport, + + /** Running the MoQ CLIENT_SETUP / SERVER_SETUP exchange. */ + MoqHandshake, + } + } + + /** Connection is live. [roomInfo] reflects the resolved server metadata. */ + data class Connected( + val roomInfo: NestsRoomInfo, + val negotiatedMoqVersion: Long, + ) : NestsListenerState() + + /** A connect attempt or live session failed. UI shows [reason] to the user. */ + data class Failed( + val reason: String, + val cause: Throwable? = null, + ) : NestsListenerState() + + /** [close] has been called. Terminal. */ + data object Closed : NestsListenerState() +} + +/** + * Default [NestsListener] implementation that delegates to a [MoqSession] + * already set up on a [com.vitorpamplona.nestsclient.transport.WebTransportSession]. + * + * Construction does NOT open the transport — call [connectNestsListener] to + * walk the full HTTP + transport + MoQ handshake; this class just owns the + * resulting session and exposes the listener API on top. + */ +class DefaultNestsListener internal constructor( + private val session: MoqSession, + private val roomNamespace: TrackNamespace, + private val mutableState: MutableStateFlow, +) : NestsListener { + override val state: StateFlow = mutableState.asStateFlow() + + override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle { + check(state.value is NestsListenerState.Connected) { + "NestsListener.subscribeSpeaker requires Connected state, was ${state.value}" + } + return session.subscribe( + namespace = roomNamespace, + trackName = speakerPubkeyHex.encodeToByteArray(), + filter = SubscribeFilter.LatestGroup, + ) + } + + override suspend fun close() { + if (state.value is NestsListenerState.Closed) return + runCatching { session.close() } + mutableState.value = NestsListenerState.Closed + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt new file mode 100644 index 000000000..17bd0b280 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.ClientSetup +import com.vitorpamplona.nestsclient.moq.MoqCodec +import com.vitorpamplona.nestsclient.moq.MoqVersion +import com.vitorpamplona.nestsclient.moq.ServerSetup +import com.vitorpamplona.nestsclient.transport.FakeWebTransport +import com.vitorpamplona.nestsclient.transport.WebTransportException +import com.vitorpamplona.nestsclient.transport.WebTransportFactory +import com.vitorpamplona.nestsclient.transport.WebTransportSession +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +class NestsConnectTest { + @Test + fun connect_walks_resolveRoom_then_transport_then_moq_handshake() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + val httpClient = + FakeNestsClient( + NestsRoomInfo( + endpoint = "https://relay.example.com/moq", + token = "tok-abc", + ), + ) + val transport = ConstantWebTransportFactory(clientSide) + + // Server-side raw peer answers SETUP. + val server = + async { + val control = serverSide.peerOpenedBidiStreams().first() + val cs = MoqCodec.decode(control.incoming().first())!!.message as ClientSetup + control.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first()))) + } + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + server.await() + + val connected = assertIs(listener.state.value) + assertEquals("https://relay.example.com/moq", connected.roomInfo.endpoint) + assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion) + + assertEquals("relay.example.com", transport.lastConnectedAuthority) + assertEquals("/moq", transport.lastConnectedPath) + assertEquals("tok-abc", transport.lastBearer) + + listener.close() + assertIs(listener.state.value) + } + + @Test + fun resolveRoom_failure_short_circuits_to_Failed() = + runTest { + val httpClient = + ThrowingNestsClient( + NestsException("server returned 500", status = 500), + ) + val transport = NeverConnectFactory() + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("Room resolution failed" in failed.reason) + assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500) + assertEquals(0, transport.connectCallCount, "transport must not be reached") + } + + @Test + fun transport_handshake_failure_short_circuits_to_Failed() = + runTest { + val httpClient = + FakeNestsClient(NestsRoomInfo(endpoint = "https://relay.example.com/moq")) + val transport = + ThrowingTransportFactory( + WebTransportException( + WebTransportException.Kind.HandshakeFailed, + "QUIC handshake refused", + ), + ) + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("HandshakeFailed" in failed.reason, "got: ${failed.reason}") + } + + @Test + fun malformed_endpoint_url_short_circuits_to_Failed() = + runTest { + val httpClient = + FakeNestsClient(NestsRoomInfo(endpoint = "not-a-url")) + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = NeverConnectFactory(), + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("Malformed MoQ endpoint" in failed.reason, "got: ${failed.reason}") + } + + @Test + fun parseEndpoint_drops_default_port_keeps_explicit_port_and_path() { + assertEquals( + "relay.example.com" to "/moq", + parseEndpoint("https://relay.example.com/moq"), + ) + assertEquals( + "relay.example.com:4443" to "/moq", + parseEndpoint("https://relay.example.com:4443/moq"), + ) + assertEquals( + "relay.example.com" to "/", + parseEndpoint("https://relay.example.com"), + ) + assertEquals( + "relay.example.com" to "/api/v1/moq?room=abc", + parseEndpoint("https://relay.example.com/api/v1/moq?room=abc"), + ) + } + + // ---------------------------------------------------------- fakes + + private class FakeNestsClient( + private val info: NestsRoomInfo, + ) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo = info + } + + private class ThrowingNestsClient( + private val toThrow: NestsException, + ) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo = throw toThrow + } + + private class ConstantWebTransportFactory( + private val session: WebTransportSession, + ) : WebTransportFactory { + var lastConnectedAuthority: String? = null + private set + var lastConnectedPath: String? = null + private set + var lastBearer: String? = null + private set + + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession { + lastConnectedAuthority = authority + lastConnectedPath = path + lastBearer = bearerToken + return session + } + } + + private class ThrowingTransportFactory( + private val toThrow: WebTransportException, + ) : WebTransportFactory { + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession = throw toThrow + } + + private class NeverConnectFactory : WebTransportFactory { + var connectCallCount = 0 + private set + + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession { + connectCallCount++ + fail("transport.connect must not be called") + } + } +} From 64b33674727231d2641bc55a24e88c042791fc2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:29:42 +0000 Subject: [PATCH 10/18] feat: AudioRoomConnectionViewModel + connection chip in stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3d-3 of the Clubhouse/nests integration. Wires the nestsClient.connectNestsListener() facade into AudioRoomStage via a dedicated Android ViewModel, and surfaces the listener's StateFlow through a small assist chip so the user sees connection progress and failure modes. amethyst/audiorooms/room: - New `AudioRoomConnectionViewModel` (Android `ViewModel`): * Owns one `NestsListener` + one `AudioRoomPlayer` per host/speaker. * `connect(event, signer)` resolves the room HTTP-side, opens WebTransport, runs MoQ SETUP, then subscribes to every host + speaker pubkey from the 30312 event and wires their Opus stream through `MediaCodecOpusDecoder` -> `AudioTrackPlayer`. * `disconnect()` is idempotent, also called from `onCleared()`. * Mirrors the underlying NestsListener.state into its own StateFlow so callers observe one source. * Audience members are skipped — they don't publish audio. - `AudioRoomStage` now mounts the ViewModel keyed by the room address, auto-calls `connect()` in a LaunchedEffect, and disconnects in a DisposableEffect. - New `ConnectionChip` composable renders the listener state with retry-on-tap for Idle / Failed / Closed, and color-codes Connected (primary) and Failed (error) states. amethyst: - Added `:nestsClient` to the project's dependencies. res: - New strings for the five connection-chip states. Behavior today: on opening an audio-room, the chip will report `Connecting (OpeningTransport)` then immediately `Failed: WebTransport NotImplemented: ...` because the Kwik handshake in `KwikWebTransportFactory` is the next phase. Tapping the chip retries — same outcome until 3b-2 ships. Everything else (presence, chat, hand-raise, mute) is unaffected. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- amethyst/build.gradle | 1 + .../room/AudioRoomConnectionViewModel.kt | 166 ++++++++++++++++++ .../audiorooms/room/AudioRoomStage.kt | 76 ++++++++ amethyst/src/main/res/values/strings.xml | 5 + 4 files changed, 248 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index c83ae17f8..7a7b41eae 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -247,6 +247,7 @@ dependencies { implementation project(path: ':quartz') implementation project(path: ':commons') implementation project(path: ':ammolite') + implementation project(path: ':nestsClient') implementation libs.androidx.core.ktx implementation libs.androidx.activity.compose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt new file mode 100644 index 000000000..2ee98aa1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.nestsclient.NestsListener +import com.vitorpamplona.nestsclient.NestsListenerState +import com.vitorpamplona.nestsclient.OkHttpNestsClient +import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer +import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer +import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder +import com.vitorpamplona.nestsclient.connectNestsListener +import com.vitorpamplona.nestsclient.transport.KwikWebTransportFactory +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Audio-pipeline owner for one open audio-room screen. Bridges the Compose + * stage UI to the [NestsListener] facade in `nestsClient`. + * + * The ViewModel is intentionally Android-only (lives in `amethyst/`) — it + * needs `viewModelScope`, the `MediaCodecOpusDecoder`, and the + * `AudioTrackPlayer`, none of which exist in commons. It is a thin shell + * that owns lifetime; all the real work happens in the `nestsClient` module. + * + * Lifecycle: + * - `connect(event, signer)` resolves the room HTTP-side, opens the + * WebTransport (currently throws NotImplemented from the Kwik stub — + * Phase 3b-2), runs the MoQ handshake, then subscribes to every speaker + * listed in the 30312 event and pipes their Opus frames through one + * [AudioRoomPlayer] each. + * - `disconnect()` tears down all per-speaker players and closes the + * listener. Idempotent, also called from `onCleared()`. + */ +class AudioRoomConnectionViewModel : ViewModel() { + private val _state = MutableStateFlow(NestsListenerState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var listener: NestsListener? = null + private val playersBySpeaker = LinkedHashMap() + private var connectJob: Job? = null + + /** + * Resolve the room URL and start playing every host/speaker track. Re-entrant + * calls cancel any in-flight connect and start fresh — typically only useful + * after a [disconnect] / failure. + */ + fun connect( + event: MeetingSpaceEvent, + signer: NostrSigner, + ) { + connectJob?.cancel() + connectJob = + viewModelScope.launch(Dispatchers.IO) { + disconnectInternal() + _state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom) + + val service = event.service() + if (service.isNullOrBlank()) { + _state.value = + NestsListenerState.Failed( + "Room has no `service` URL — cannot resolve a nests endpoint", + ) + return@launch + } + + val l = + com.vitorpamplona.nestsclient + .connectNestsListener( + httpClient = OkHttpNestsClient(), + transport = KwikWebTransportFactory(), + scope = viewModelScope, + serviceBase = service, + roomId = event.dTag(), + signer = signer, + ) + listener = l + + // Mirror the underlying listener's state so consumers only need + // to observe one StateFlow. + viewModelScope.launch { l.state.collect { _state.value = it } } + + if (l.state.value !is NestsListenerState.Connected) { + return@launch + } + + // Subscribe to every host + speaker. Audience members don't + // publish audio; ignore them. + val speakerKeys = + event + .participants() + .filter { it.role.equals(ROLE.HOST.code, true) || it.role.equals(ROLE.SPEAKER.code, true) } + .map { it.pubKey } + .distinct() + + for (pubkey in speakerKeys) { + runCatching { + val handle = l.subscribeSpeaker(pubkey) + val player = + AudioRoomPlayer( + decoder = MediaCodecOpusDecoder(), + player = AudioTrackPlayer(), + scope = viewModelScope, + ) + player.play(handle.objects) { /* per-speaker decode errors swallowed */ } + playersBySpeaker[pubkey] = player + } + } + } + } + + /** Stop playback and tear down the listener. Idempotent. */ + fun disconnect() { + connectJob?.cancel() + connectJob = null + viewModelScope.launch(Dispatchers.IO) { disconnectInternal() } + } + + private suspend fun disconnectInternal() { + for ((_, p) in playersBySpeaker) runCatching { p.stop() } + playersBySpeaker.clear() + runCatching { listener?.close() } + listener = null + // Leave _state alone if it's already Closed; otherwise reset to Idle so + // the UI's "tap to retry" path is available. + if (_state.value !is NestsListenerState.Closed) { + _state.value = NestsListenerState.Idle + } + } + + override fun onCleared() { + // Best-effort sync teardown — viewModelScope is being cancelled around us, + // so any suspending listener.close() in disconnectInternal() may not run. + runCatching { + for ((_, p) in playersBySpeaker) p.stop() + playersBySpeaker.clear() + } + super.onCleared() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt index 46fc16b72..22d912dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -33,6 +33,8 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.outlined.PanTool +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledIconButton @@ -53,6 +55,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -62,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.nestsclient.NestsListenerState import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -132,6 +137,17 @@ private fun AudioRoomStageContent( } } + // Audio listener owner. Auto-connects on enter, tears down on dispose. + val connectionVm: AudioRoomConnectionViewModel = + viewModel(key = "AudioRoom-${event.address().toValue()}") + val connectionState by connectionVm.state.collectAsStateWithLifecycle() + LaunchedEffect(event.address().toValue()) { + connectionVm.connect(event, accountViewModel.account.signer) + } + DisposableEffect(event.address().toValue()) { + onDispose { connectionVm.disconnect() } + } + Card( modifier = Modifier.fillMaxWidth().padding(8.dp), shape = RoundedCornerShape(12.dp), @@ -152,6 +168,11 @@ private fun AudioRoomStageContent( ) } + ConnectionChip( + state = connectionState, + onRetry = { connectionVm.connect(event, accountViewModel.account.signer) }, + ) + if (hosts.isNotEmpty() || speakers.isNotEmpty()) { StagePeopleRow( label = stringRes(R.string.audio_room_stage), @@ -210,6 +231,61 @@ private fun AudioRoomStageContent( } } +@Composable +private fun ConnectionChip( + state: NestsListenerState, + onRetry: () -> Unit, +) { + val (label, color, clickable) = + when (state) { + is NestsListenerState.Idle -> { + Triple( + stringRes(R.string.audio_room_conn_idle), + MaterialTheme.colorScheme.surface, + true, + ) + } + + is NestsListenerState.Connecting -> { + Triple( + stringRes(R.string.audio_room_conn_connecting, state.step.name), + MaterialTheme.colorScheme.surface, + false, + ) + } + + is NestsListenerState.Connected -> { + Triple( + stringRes(R.string.audio_room_conn_connected), + MaterialTheme.colorScheme.primaryContainer, + false, + ) + } + + is NestsListenerState.Failed -> { + Triple( + stringRes(R.string.audio_room_conn_failed, state.reason), + MaterialTheme.colorScheme.errorContainer, + true, + ) + } + + is NestsListenerState.Closed -> { + Triple( + stringRes(R.string.audio_room_conn_closed), + MaterialTheme.colorScheme.surface, + true, + ) + } + } + AssistChip( + modifier = Modifier.padding(top = 8.dp), + onClick = { if (clickable) onRetry() }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + colors = AssistChipDefaults.assistChipColors(containerColor = color), + ) +} + @Composable private fun StagePeopleRow( label: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 06b0a909d..8618d0a6f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -495,6 +495,11 @@ Lower hand Mute Unmute + Audio not connected — tap to retry + Connecting to audio (%1$s) + Audio connected + Audio failed: %1$s + Audio session closed Videos Articles Private Bookmarks From b62e3dd0ec4b4fb0fb5b8b7fa13869ae93f201b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:33:55 +0000 Subject: [PATCH 11/18] feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ control messages and the Opus encode + microphone capture pieces a speaker needs. The host-grants-speaker UI flow is deferred — that's multi-screen UX that should be designed before being implemented. commonMain (nestsclient.moq): - 5 new MoqMessageType entries with codec round-trip + tests: * Announce (0x06): publisher offers a track namespace. * AnnounceOk (0x07): subscriber acknowledges. * AnnounceError (0x08): subscriber rejects with error code + reason phrase. * Unannounce (0x09): publisher withdraws a previously-announced namespace. * SubscribeDone (0x0B): publisher tells the subscriber no more objects are coming for this subscription, with stream count and reason. commonMain (nestsclient.audio): - New `OpusEncoder` interface — symmetric to `OpusDecoder`, one instance per outgoing track since Opus state is per-stream. - New `AudioCapture` interface — `start()`, `readFrame()` returns a PCM frame or null when stopped, `stop()` releases the mic. androidMain (nestsclient.audio): - `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms frames. Drains output queue per encode call. - `AudioRecordCapture` — wraps `AudioRecord` from `MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's echo-cancellation + noise-suppression filters apply when available. Reads exactly one PCM frame per readFrame() call, retries on underrun, throws AudioException(DeviceUnavailable) on permission/ resource failures. commonTest: - `AnnounceCodecTest` — 7 cases covering each message round-trip, concatenated decode in sequence, and a guard against accidentally reordering MoqMessageType enum codes. Permission already declared: - `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no manifest change needed. What this does NOT include: - A publisher loop class (analogous to AudioRoomPlayer for publish direction) — can be added when the speak-button wiring lands. - The host-grants-speaker UI — needs design input. - STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams cover the listener happy-path; streams add reliability that nests may or may not require. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../nestsclient/audio/AudioRecordCapture.kt | 136 +++++++++++++++++ .../audio/MediaCodecOpusEncoder.kt | 139 ++++++++++++++++++ .../vitorpamplona/nestsclient/audio/Audio.kt | 36 +++++ .../vitorpamplona/nestsclient/moq/MoqCodec.kt | 67 +++++++++ .../nestsclient/moq/MoqMessage.kt | 55 +++++++ .../nestsclient/moq/AnnounceCodecTest.kt | 102 +++++++++++++ 6 files changed, 535 insertions(+) create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt create mode 100644 nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt new file mode 100644 index 000000000..1f5cfa1a1 --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import android.media.AudioRecord +import android.media.MediaRecorder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.media.AudioFormat as AndroidAudioFormat + +/** + * [AudioCapture] backed by Android's [AudioRecord] from the + * VOICE_COMMUNICATION input source — same source LiveKit, WebRTC, and most + * voice-chat libraries use, so it gets the platform's echo-cancellation and + * noise-suppression filters when available. + * + * **Permission:** the caller is responsible for holding `RECORD_AUDIO` before + * calling [start]; this class will throw [AudioException.Kind.DeviceUnavailable] + * if the OS denies the resource. + */ +class AudioRecordCapture( + private val source: Int = MediaRecorder.AudioSource.VOICE_COMMUNICATION, +) : AudioCapture { + private var record: AudioRecord? = null + private var stopped = false + + override fun start() { + check(!stopped) { "capture already stopped" } + if (record != null) return + + val channelMask = + when (AudioFormat.CHANNELS) { + 1 -> AndroidAudioFormat.CHANNEL_IN_MONO + 2 -> AndroidAudioFormat.CHANNEL_IN_STEREO + else -> error("unsupported channel count ${AudioFormat.CHANNELS}") + } + + val minBuffer = + AudioRecord.getMinBufferSize( + AudioFormat.SAMPLE_RATE_HZ, + channelMask, + AndroidAudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord.getMinBufferSize returned $minBuffer for ${AudioFormat.SAMPLE_RATE_HZ} Hz", + ) + } + val bufferBytes = + maxOf(minBuffer, AudioFormat.FRAME_SIZE_SAMPLES * AudioFormat.BYTES_PER_SAMPLE * 4) + + val rec = + try { + @Suppress("MissingPermission") + AudioRecord(source, AudioFormat.SAMPLE_RATE_HZ, channelMask, AndroidAudioFormat.ENCODING_PCM_16BIT, bufferBytes) + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to construct AudioRecord (RECORD_AUDIO permission?)", + t, + ) + } + if (rec.state != AudioRecord.STATE_INITIALIZED) { + runCatching { rec.release() } + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord state=${rec.state} after construction (expected INITIALIZED)", + ) + } + try { + rec.startRecording() + } catch (t: Throwable) { + runCatching { rec.release() } + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "AudioRecord.startRecording() failed", + t, + ) + } + record = rec + } + + override suspend fun readFrame(): ShortArray? { + val rec = record ?: return null + val frame = ShortArray(AudioFormat.FRAME_SIZE_SAMPLES) + return withContext(Dispatchers.IO) { + var read = 0 + while (read < frame.size) { + if (stopped) return@withContext null + val n = rec.read(frame, read, frame.size - read) + if (n < 0) { + throw AudioException( + AudioException.Kind.PlaybackFailed, + "AudioRecord.read returned error code $n", + ) + } + if (n == 0) { + // Underrun — wait briefly and retry. This avoids busy-waiting + // on a slow producer. + kotlinx.coroutines.delay(2) + continue + } + read += n + } + frame + } + } + + override fun stop() { + if (stopped) return + stopped = true + val rec = record ?: return + record = null + runCatching { rec.stop() } + runCatching { rec.release() } + } +} diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt new file mode 100644 index 000000000..e68424040 --- /dev/null +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.audio + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import java.nio.ByteOrder + +/** + * [OpusEncoder] backed by Android's [MediaCodec] (`audio/opus`, encoder + * variant available on API 29+ — the decoder works back to API 21 but the + * encoder shipped later). One instance per outgoing track. + * + * Configuration: + * - 48 kHz mono input PCM 16-bit (matches [AudioFormat]). + * - Target bitrate ~32 kbit/s VBR — high-quality wideband speech. + * - 20 ms frames (the encoder requires the input buffer to hold one frame + * at a time for low latency). + */ +class MediaCodecOpusEncoder( + private val targetBitrate: Int = DEFAULT_BITRATE_BPS, +) : OpusEncoder { + private val codec: MediaCodec = + try { + MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply { + configure(buildFormat(targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + start() + } + } catch (t: Throwable) { + throw AudioException( + AudioException.Kind.DeviceUnavailable, + "Failed to allocate MediaCodec audio/opus encoder", + t, + ) + } + + private val bufferInfo = MediaCodec.BufferInfo() + private var presentationTimeUs: Long = 0L + private var released = false + + override fun encode(pcm: ShortArray): ByteArray { + check(!released) { "encoder released" } + require(pcm.isNotEmpty()) { "PCM frame must not be empty" } + + val inputIndex = codec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US) + if (inputIndex < 0) return ByteArray(0) + val inputBuffer = + codec.getInputBuffer(inputIndex) + ?: throw AudioException( + AudioException.Kind.DecoderError, + "MediaCodec returned null input buffer at index $inputIndex", + ) + inputBuffer.clear() + inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer().put(pcm) + val byteCount = pcm.size * AudioFormat.BYTES_PER_SAMPLE + codec.queueInputBuffer(inputIndex, 0, byteCount, presentationTimeUs, 0) + presentationTimeUs += FRAME_DURATION_US + + // One PCM frame produces one Opus packet (sometimes after one warmup + // round). Drain the output queue once. + while (true) { + val outputIndex = codec.dequeueOutputBuffer(bufferInfo, DEQUEUE_TIMEOUT_US) + when { + outputIndex >= 0 -> { + val outputBuffer = codec.getOutputBuffer(outputIndex) ?: continue + val opus = ByteArray(bufferInfo.size) + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + outputBuffer.get(opus) + codec.releaseOutputBuffer(outputIndex, false) + if (opus.isNotEmpty()) return opus + } + + outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + continue + } + + outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + return ByteArray(0) + } + + else -> { + return ByteArray(0) + } + } + } + @Suppress("UNREACHABLE_CODE") + return ByteArray(0) + } + + override fun release() { + if (released) return + released = true + runCatching { codec.stop() } + runCatching { codec.release() } + } + + companion object { + const val DEFAULT_BITRATE_BPS: Int = 32_000 + private const val DEQUEUE_TIMEOUT_US = 10_000L + private const val FRAME_DURATION_US = 20_000L + + private fun buildFormat(bitrate: Int): MediaFormat = + MediaFormat + .createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_OPUS, + AudioFormat.SAMPLE_RATE_HZ, + AudioFormat.CHANNELS, + ).apply { + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) + // Encoder-side AAC/Opus profile selection: SignalingDelaySamples + // is implicit; nothing else required for mono speech. + setInteger( + MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC, + ) + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index 79c8496f4..61d00f59c 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -60,6 +60,42 @@ interface OpusDecoder { fun release() } +/** + * Encoder for one PCM frame at a time. + * + * Mirror of [OpusDecoder] for the publish direction. Like the decoder, Opus + * encoder state is per-stream — one instance per outgoing track. + */ +interface OpusEncoder { + /** + * Encode one PCM frame (typically [AudioFormat.FRAME_SIZE_SAMPLES] samples + * of signed 16-bit mono at [AudioFormat.SAMPLE_RATE_HZ]) into one Opus + * packet. Returns an empty array if the encoder is still warming up + * (some pipelines need a few frames before producing output). + */ + fun encode(pcm: ShortArray): ByteArray + + fun release() +} + +/** + * Source for PCM audio capture. Implementations open the device's microphone + * and produce one frame at a time via [readFrame]. + */ +interface AudioCapture { + /** Allocate the microphone resource and begin capturing. */ + fun start() + + /** + * Read one PCM frame ([AudioFormat.FRAME_SIZE_SAMPLES] samples). Suspends + * until enough samples are available. Returns null when [stop] is called. + */ + suspend fun readFrame(): ShortArray? + + /** Stop capture and release the microphone. After this, [readFrame] returns null. */ + fun stop() +} + /** * Sink for PCM audio playback. Implementations buffer internally — [enqueue] * may suspend if the device's playback buffer is full. diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt index e5a02013f..c697753ea 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt @@ -73,6 +73,11 @@ object MoqCodec { MoqMessageType.SubscribeOk -> decodeSubscribeOk(reader) MoqMessageType.SubscribeError -> decodeSubscribeError(reader) MoqMessageType.Unsubscribe -> decodeUnsubscribe(reader) + MoqMessageType.SubscribeDone -> decodeSubscribeDone(reader) + MoqMessageType.Announce -> decodeAnnounce(reader) + MoqMessageType.AnnounceOk -> decodeAnnounceOk(reader) + MoqMessageType.AnnounceError -> decodeAnnounceError(reader) + MoqMessageType.Unannounce -> decodeUnannounce(reader) } if (reader.hasMore()) { throw MoqCodecException( @@ -90,6 +95,11 @@ object MoqCodec { is SubscribeOk -> encodeSubscribeOk(message) is SubscribeError -> encodeSubscribeError(message) is Unsubscribe -> encodeUnsubscribe(message) + is SubscribeDone -> encodeSubscribeDone(message) + is Announce -> encodeAnnounce(message) + is AnnounceOk -> encodeAnnounceOk(message) + is AnnounceError -> encodeAnnounceError(message) + is Unannounce -> encodeUnannounce(message) } private fun encodeClientSetup(message: ClientSetup): ByteArray { @@ -268,6 +278,63 @@ object MoqCodec { private fun decodeUnsubscribe(r: MoqReader): Unsubscribe = Unsubscribe(r.readVarint()) + private fun encodeSubscribeDone(m: SubscribeDone): ByteArray { + val w = MoqWriter() + w.writeVarint(m.subscribeId) + w.writeVarint(m.statusCode) + w.writeVarint(m.streamCount) + w.writeLengthPrefixedString(m.reasonPhrase) + return w.toByteArray() + } + + private fun decodeSubscribeDone(r: MoqReader): SubscribeDone = + SubscribeDone( + subscribeId = r.readVarint(), + statusCode = r.readVarint(), + streamCount = r.readVarint(), + reasonPhrase = r.readLengthPrefixedString(), + ) + + private fun encodeAnnounce(m: Announce): ByteArray { + val w = MoqWriter() + encodeNamespace(w, m.namespace) + encodeParameters(w, m.parameters) + return w.toByteArray() + } + + private fun decodeAnnounce(r: MoqReader): Announce = Announce(namespace = decodeNamespace(r), parameters = decodeParameters(r)) + + private fun encodeAnnounceOk(m: AnnounceOk): ByteArray { + val w = MoqWriter() + encodeNamespace(w, m.namespace) + return w.toByteArray() + } + + private fun decodeAnnounceOk(r: MoqReader): AnnounceOk = AnnounceOk(decodeNamespace(r)) + + private fun encodeAnnounceError(m: AnnounceError): ByteArray { + val w = MoqWriter() + encodeNamespace(w, m.namespace) + w.writeVarint(m.errorCode) + w.writeLengthPrefixedString(m.reasonPhrase) + return w.toByteArray() + } + + private fun decodeAnnounceError(r: MoqReader): AnnounceError = + AnnounceError( + namespace = decodeNamespace(r), + errorCode = r.readVarint(), + reasonPhrase = r.readLengthPrefixedString(), + ) + + private fun encodeUnannounce(m: Unannounce): ByteArray { + val w = MoqWriter() + encodeNamespace(w, m.namespace) + return w.toByteArray() + } + + private fun decodeUnannounce(r: MoqReader): Unannounce = Unannounce(decodeNamespace(r)) + data class DecodeResult( val message: MoqMessage, val bytesConsumed: Int, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt index 56a497bae..6a33f2a0a 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqMessage.kt @@ -44,7 +44,12 @@ enum class MoqMessageType( Subscribe(0x03), SubscribeOk(0x04), SubscribeError(0x05), + Announce(0x06), + AnnounceOk(0x07), + AnnounceError(0x08), + Unannounce(0x09), Unsubscribe(0x0A), + SubscribeDone(0x0B), ClientSetup(0x40), ServerSetup(0x41), ; @@ -254,3 +259,53 @@ data class Unsubscribe( ) : MoqMessage() { override val type: MoqMessageType = MoqMessageType.Unsubscribe } + +/** + * SUBSCRIBE_DONE (0x0B): publisher tells the subscriber that no more objects + * are coming for this subscription, optionally indicating the last group/object + * boundary. Sent on subscription expiry, publisher-side track closure, or + * after an UNSUBSCRIBE was acknowledged. + */ +data class SubscribeDone( + val subscribeId: Long, + val statusCode: Long, + val streamCount: Long, + val reasonPhrase: String, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.SubscribeDone +} + +/** + * ANNOUNCE (0x06): publisher offers a track namespace. nests publishers send + * one ANNOUNCE per audio-room they host so subscribers know which namespace + * to subscribe under. + */ +data class Announce( + val namespace: TrackNamespace, + val parameters: List = emptyList(), +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.Announce +} + +/** ANNOUNCE_OK (0x07): subscriber acknowledges an ANNOUNCE. */ +data class AnnounceOk( + val namespace: TrackNamespace, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.AnnounceOk +} + +/** ANNOUNCE_ERROR (0x08): subscriber rejects an ANNOUNCE. */ +data class AnnounceError( + val namespace: TrackNamespace, + val errorCode: Long, + val reasonPhrase: String, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.AnnounceError +} + +/** UNANNOUNCE (0x09): publisher withdraws a previously-announced namespace. */ +data class Unannounce( + val namespace: TrackNamespace, +) : MoqMessage() { + override val type: MoqMessageType = MoqMessageType.Unannounce +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt new file mode 100644 index 000000000..7aad57cda --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/AnnounceCodecTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.moq + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class AnnounceCodecTest { + @Test + fun announce_round_trip_with_parameters() { + val msg = + Announce( + namespace = TrackNamespace.of("nests", "test-room"), + parameters = listOf(SetupParameter(0x10L, byteArrayOf(0x01, 0x02))), + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun announce_ok_round_trip() { + val msg = AnnounceOk(namespace = TrackNamespace.of("ns")) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun announce_error_round_trip() { + val msg = + AnnounceError( + namespace = TrackNamespace.of("nests", "denied-room"), + errorCode = 403, + reasonPhrase = "namespace already taken", + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun unannounce_round_trip() { + val msg = Unannounce(namespace = TrackNamespace.of("nests", "test-room")) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun subscribe_done_round_trip() { + val msg = + SubscribeDone( + subscribeId = 42, + statusCode = 0, + streamCount = 17, + reasonPhrase = "subscription expired", + ) + val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message + assertEquals(msg, assertIs(decoded)) + } + + @Test + fun announce_then_announce_ok_decode_in_sequence() { + val ns = TrackNamespace.of("nests", "abc") + val ann = Announce(ns) + val ok = AnnounceOk(ns) + val bytes = MoqCodec.encode(ann) + MoqCodec.encode(ok) + + val first = MoqCodec.decode(bytes, offset = 0)!! + val second = MoqCodec.decode(bytes, offset = first.bytesConsumed)!! + + assertIs(first.message) + assertIs(second.message) + assertEquals(bytes.size, first.bytesConsumed + second.bytesConsumed) + } + + @Test + fun every_publisher_message_type_has_a_known_code() { + // Guards against accidentally reordering MoqMessageType enum entries. + assertEquals(0x06L, MoqMessageType.Announce.code) + assertEquals(0x07L, MoqMessageType.AnnounceOk.code) + assertEquals(0x08L, MoqMessageType.AnnounceError.code) + assertEquals(0x09L, MoqMessageType.Unannounce.code) + assertEquals(0x0BL, MoqMessageType.SubscribeDone.code) + } +} From c07f7baa148205c6eaf37a5260a6104833d52bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:41:56 +0000 Subject: [PATCH 12/18] docs(nestsClient): Phase 3b-2 Kwik integration plan in stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b-2 attempt. The honest version: I do not have network access to verify Kwik's current Maven coordinates or test against a live nests server, so committing speculative QUIC + Extended CONNECT code risks breaking the build for everyone else without giving us audible-audio-on-device confidence in return. What I did instead: expanded the docstring on KwikWebTransportFactory into a full integration playbook that the next person picking this up can execute directly. It covers: - Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`, with `net.luminis.quic:kwik` as the legacy fallback group). - The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts` edits to drop in once coords are confirmed. - Step-by-step handshake sequence with the right HTTP/3 setting IDs: * SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08) * SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742) * SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33) - Extended CONNECT pseudo-header set, including the legacy `sec-webtransport-http3-draft02 = 1` for older server compat. - WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54 for client uni) + WT capsule type for graceful close (0x2843). - Test strategy: validate against local nests-rs first, then the production `nostrnests.com`. - Open questions (Flupke Extended CONNECT maturity, draft churn, dev-only self-signed-cert support, Android API surface). Behavior unchanged: connect() still throws WebTransportException with Kind.NotImplemented and a message pointing at this docstring. When the real implementation lands, no upstream caller needs to change — the AudioRoomConnectionViewModel from Phase 3d-3 will auto-light up the connection chip from "Failed: NotImplemented" to "Connected" and start producing audio through the Phase 3d-1 pipeline that's already wired. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- nestsClient/build.gradle.kts | 3 + .../transport/KwikWebTransportFactory.kt | 148 +++++++++++++++--- 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 77a761da7..59e6d1f9d 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -63,6 +63,9 @@ kotlin { androidMain { dependsOn(jvmAndroid) + // Kwik QUIC + Flupke HTTP/3 dependencies are NOT yet declared. + // See KwikWebTransportFactory.kt for the integration plan and + // validated Maven coordinates / minimum versions before adding. } jvmTest { diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt index 424140bb0..0b9cf2f2f 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/KwikWebTransportFactory.kt @@ -21,25 +21,134 @@ package com.vitorpamplona.nestsclient.transport /** - * JVM + Android [WebTransportFactory] that will wrap the Kwik QUIC library - * (plus Flupke for HTTP/3) once the Extended CONNECT handshake lands. + * JVM + Android [WebTransportFactory] that **will** wrap the Kwik QUIC library + * (plus Flupke for HTTP/3) once the Extended CONNECT handshake lands. Until + * then, [connect] throws [WebTransportException] with + * [WebTransportException.Kind.NotImplemented]. * - * **Status:** Phase 3b-1 only ships the interface contract — calling - * [connect] throws [WebTransportException] with - * [WebTransportException.Kind.NotImplemented]. This lets the Phase 3c MoQ - * framing layer develop and test against a stable [WebTransportSession] - * abstraction (see [FakeWebTransport]) while the real Kwik integration is - * delivered in Phase 3b-2. + * The Phase 3c MoQ framing layer + Phase 3d audio pipeline + Phase 3d-3 + * `AudioRoomConnectionViewModel` are all wired against the + * [WebTransportSession] interface, so the moment this class returns a real + * session the entire stack starts producing audible output without further + * changes upstream. * - * The expected implementation sequence: - * 1. QUIC dial via Kwik with ALPN "h3" to `authority`. - * 2. HTTP/3 SETTINGS exchange including `SETTINGS_ENABLE_CONNECT_PROTOCOL=1` - * and the WebTransport draft's `SETTINGS_ENABLE_WEBTRANSPORT=1`. - * 3. Extended CONNECT request: `:method=CONNECT :protocol=webtransport - * :scheme=https :authority= :path=` plus - * `Authorization: Bearer ` when supplied. - * 4. On 2xx, wrap the resulting bidi streams / datagrams in WebTransport - * framing (stream type 0x54 for client-initiated bidi). + * ## Phase 3b-2 integration plan + * + * ### 1. Maven coordinates (verify before adding) + * + * Kwik is published by Peter Doornbosch (kwik.tech). As of writing the + * coordinates appear to be `tech.kwik:kwik-core` and `tech.kwik:flupke` but + * **this needs verification on the live Maven Central index** — earlier + * versions used different group IDs. Suggested verification command: + * + * ``` + * curl -sf https://repo1.maven.org/maven2/tech/kwik/kwik-core/maven-metadata.xml + * curl -sf https://repo1.maven.org/maven2/tech/kwik/flupke/maven-metadata.xml + * ``` + * + * Known-good versions to try (newest first): 0.11.x, 0.10.x, 0.9.x. + * + * If the `tech.kwik` group fails, fall back to `net.luminis.quic:kwik` (the + * pre-2024 group) but pin to the latest 0.x release on that group. + * + * Add to `gradle/libs.versions.toml`: + * ```toml + * [versions] + * kwik = "0.11.0" # confirm against maven-metadata.xml first + * + * [libraries] + * kwik-core = { group = "tech.kwik", name = "kwik-core", version.ref = "kwik" } + * kwik-flupke = { group = "tech.kwik", name = "flupke", version.ref = "kwik" } + * ``` + * + * Add to `nestsClient/build.gradle.kts` under `androidMain.dependencies`: + * ```kotlin + * implementation(libs.kwik.core) + * implementation(libs.kwik.flupke) + * ``` + * + * Kwik is pure Java with no JNI; both Android and JVM-desktop targets work. + * + * ### 2. Handshake sequence + * + * a. **Resolve UDP socket** to `(authority host, authority port)` — port + * defaults to 443 for `https`/`wss` URLs. + * b. **QUIC dial** with ALPN list `["h3"]` (HTTP/3) via Kwik's connection + * builder. Kwik uses TLS 1.3; pass an `SSLContext` that accepts standard + * CA-issued certs (nests deployments use Let's Encrypt). + * c. **HTTP/3 SETTINGS** exchange via Flupke. Send a control stream with: + * - `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` (RFC 8441, identifier 0x08) + * - `SETTINGS_ENABLE_WEBTRANSPORT = 1` (WebTransport-H3 draft, identifier 0x2b603742) + * - `SETTINGS_H3_DATAGRAM = 1` (RFC 9297, identifier 0x33) + * Wait for the peer's SETTINGS frame; verify it advertises the same + * WebTransport setting before proceeding. + * d. **Extended CONNECT** request on a new client-bidi stream, as + * compressed HEADERS: + * - `:method = CONNECT` + * - `:protocol = webtransport` + * - `:scheme = https` + * - `:authority = ` (or `:` if non-default) + * - `:path = ` (defaults to `/`, nests uses `/moq`) + * - `Authorization: Bearer ` if non-null + * - `sec-webtransport-http3-draft02 = 1` for legacy server compat + * Read the response HEADERS; on `:status = 2xx` the session is open. + * On 4xx / 5xx throw [WebTransportException] with + * [WebTransportException.Kind.ConnectRejected]. + * + * ### 3. Stream / datagram multiplexing + * + * - **Bidi WT streams**: client opens a QUIC bidi stream; first VarInt + * written is the WT stream-type signal `0x41` followed by the WT session + * ID (the stream ID of the CONNECT bidi). Bytes that follow are + * application data (MoQ frames, in our case). + * - **Uni WT streams** (used by MoQ for OBJECT_STREAM): client opens a + * QUIC uni stream; first VarInt is `0x54` then the WT session ID. + * - **WT datagrams** (used by MoQ for OBJECT_DATAGRAM): wrap each app + * payload in an HTTP/3 DATAGRAM (RFC 9297) with the WT quarter-stream-id + * prefix, then push via Kwik's QUIC datagram API. + * + * Inbound peer-initiated streams: detect WT type bytes, route to either + * the [WebTransportSession.incomingUniStreams] flow or the (Phase 3b-3 if + * needed) bidi-stream flow. + * + * ### 4. Lifecycle + * + * - Spawn one coroutine to demux inbound QUIC streams into WT + * bidi/uni/datagram channels. + * - On [WebTransportSession.close], send a `WEBTRANSPORT_SESSION_CLOSE` + * capsule (an HTTP/3 capsule of type 0x2843) on the CONNECT bidi, then + * `connection.close()` on the underlying Kwik QUIC connection. + * + * ### 5. Test strategy + * + * - Unit-test the WT framing helpers (varint stream-type prefix, capsule + * encode/decode) in `commonTest` before touching Kwik. + * - Instrumented `@LargeTest` against `nostrnests.com`: + * ``` + * val factory = KwikWebTransportFactory() + * val session = factory.connect("nostrnests.com", "/moq") + * assertTrue(session.isOpen) + * session.sendDatagram(byteArrayOf(0x40)) // CLIENT_SETUP varint + * session.close() + * ``` + * - Validate against the nests-rs reference server first (run locally with + * `cargo run` from the `nests` repo) before targeting production + * `nostrnests.com` — easier to read the server-side logs. + * + * ### Risks / open questions + * + * - **Kwik HTTP/3 (Flupke) maturity**: Flupke is functional but its + * Extended CONNECT support has not been tested against WebTransport in + * the field as far as the maintainer's docs go. Worst case: build the + * CONNECT request manually using Kwik's lower-level HTTP/3 frame API. + * - **Draft churn**: WebTransport-H3 is a moving target. Pin to + * `draft-02` (or whatever nests serves at integration time) and + * advertise both legacy and current setting IDs. + * - **Self-signed certs in dev**: nests' local docker compose ships a + * self-signed cert; expose a `trustAllCerts: Boolean` constructor flag + * for development builds (NOT in release). + * - **Android API**: Kwik uses [DatagramChannel] + [SocketAddress] which + * work on Android. No JNI / no Cronet, so APK size impact is small. */ class KwikWebTransportFactory : WebTransportFactory { override suspend fun connect( @@ -49,6 +158,9 @@ class KwikWebTransportFactory : WebTransportFactory { ): WebTransportSession = throw WebTransportException( kind = WebTransportException.Kind.NotImplemented, - message = "Kwik-backed WebTransport handshake lands in Phase 3b-2", + message = + "Kwik-backed WebTransport handshake not yet implemented. " + + "See KwikWebTransportFactory.kt header for the integration plan " + + "(Maven coords, handshake sequence, stream/datagram framing, test strategy).", ) } From 40308dc917fd7de61b31a7d7ed09e36a4e25519c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 08:35:09 +0000 Subject: [PATCH 13/18] docs: pure-Kotlin QUIC + WebTransport implementation plan Captures the plan to unblock Phase 3b-2 (the WebTransport handshake) by writing a Kotlin QUIC client rather than depending on a Java QUIC library. Triggered by exhausting the off-the-shelf options: - tech.kwik:* coords don't exist on Maven Central we can resolve. - Netty incubator HTTP/3 needs an Android quiche-native that isn't published. - Cronet doesn't expose WebTransport. - WebView JS bridge rejected by product. The plan delegates TLS 1.3 + crypto primitives to BouncyCastle (bcprov + bcpkix already cached, bctls to add) and has us writing the QUIC packet/frame/state-machine layer + HTTP/3 + WebTransport on top. Realistic estimate: 16-18 weeks one developer full-time, or 5-6 months at a normal cadence, plus a security review before shipping. Hard abandonment trigger documented: if the RFC 9001 Appendix A Initial-packet test vectors don't bit-match by end of Phase D (~6 weeks in), abandon and wait for an Android-compatible upstream. The plan is committed as a doc rather than executed in this session because each phase is multi-week and would block the rest of the audio-rooms feature in the meantime. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- ...4-22-pure-kotlin-quic-webtransport-plan.md | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md diff --git a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md new file mode 100644 index 000000000..f64943a33 --- /dev/null +++ b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md @@ -0,0 +1,270 @@ +# Pure-Kotlin QUIC + WebTransport plan + +## Context + +The Audio Rooms feature (NIP-53 kind 30312) is wire-incompatible with anything but +WebTransport over QUIC, because that's what the nostrnests/nests MoQ relay speaks. +Phases 3a / 3c / 3d of the rollout shipped the entire Kotlin stack from NIP-98 auth +through MoQ framing through Opus playback — every layer except the WebTransport +factory itself, which is a stub that throws `WebTransportException(NotImplemented)`. + +We tried the easy path (depend on a Java QUIC library) and exhausted it: + +| Library | Outcome | +|---|---| +| `tech.kwik:kwik-core`, `tech.kwik:kwik`, `tech.kwik:flupke` | Maven Central returns `Could not find` for every coord/version we tried. | +| `io.netty.incubator:netty-incubator-codec-http3` | Resolves, **but** it depends on `netty-incubator-codec-native-quic` (JNI to Cloudflare quiche) which has no Android-compatible native classifier published. Compile-time green, runtime `UnsatisfiedLinkError`. | +| Cronet | QUIC support is internal; WebTransport not exposed in any stable public API. | +| WebView JavaScript bridge | Rejected by product. | + +So the path forward is a **pure-Kotlin QUIC client** with just enough WebTransport +on top to interop with nests. This plan scopes that work honestly. + +## Scope + +### What we build in pure Kotlin + +- QUIC v1 client per RFC 9000 (no server, no version negotiation beyond v1). +- QUIC-TLS binding per RFC 9001 (CRYPTO-frame splicing, per-level keys, header + protection). +- QUIC loss recovery + congestion control per RFC 9002 (NewReno minimum). +- QUIC datagram extension per RFC 9221. +- HTTP/3 client subset per RFC 9114 (control stream, SETTINGS, request streams). +- QPACK encoder per RFC 9204 (literal-only emit; full decoder). +- Extended CONNECT (RFC 8441 / WT-H3 draft-13) for `:protocol = webtransport`. +- WebTransport over HTTP/3 framing: stream-type prefix bytes, capsule protocol + for `WT_SESSION_CLOSE`. +- HTTP Datagrams per RFC 9297 carrying WT datagrams. + +### What we delegate + +- **TLS 1.3 handshake state machine + AEAD primitives** → BouncyCastle + (`org.bouncycastle:bctls-jdk18on:1.79` plus `bcprov` + `bcpkix` already cached). +- **X.509 cert validation** → JDK `TrustManagerFactory` (Android system trust store). +- **AES-GCM, ChaCha20-Poly1305, HKDF** → JCA via BC provider. + +### Explicitly out of scope + +- QUIC server role. +- 0-RTT / session tickets (defer until needed). +- Connection migration / preferred address. +- Multiple concurrent paths. +- Path MTU discovery (assume 1200-byte safe ceiling). +- HTTP/3 server push. +- QPACK dynamic table on the encoder side (use literal-only headers; still decode + dynamic table from the peer). +- ECN-based congestion signalling. +- Anti-amplification limits (we're a client; rarely matters). + +## Architecture + +``` +nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/ +└── transport/ + ├── quic/ ← new: pure-Kotlin QUIC client + │ ├── crypto/ ← BC adapter (TLS over CRYPTO frames) + │ ├── packet/ ← long/short-header encode/decode + protection + │ ├── frame/ ← STREAM, ACK, CRYPTO, MAX_*, CONNECTION_CLOSE … + │ ├── stream/ ← per-stream send/recv buffers, flow control + │ ├── recovery/ ← loss detection, PTO, NewReno + │ ├── connection/ ← QuicConnection state machine + │ └── transport/ ← UDP socket + datagram pump + ├── http3/ ← new: HTTP/3 client + │ ├── frame/ ← DATA, HEADERS, SETTINGS, GOAWAY, MAX_PUSH_ID + │ ├── qpack/ ← static + dynamic tables, Huffman + │ └── Http3Connection.kt ← request/response state machine + └── webtransport/ ← new: WT layer + ├── ExtendedConnect.kt ← :protocol = webtransport + ├── WtStreamType.kt ← 0x41 (bidi) / 0x54 (uni) prefix bytes + ├── WtCapsule.kt ← WT_SESSION_CLOSE = 0x2843, etc. + └── KotlinWebTransportFactory.kt ← implements existing WebTransportFactory +``` + +Naming note: the existing `KwikWebTransportFactory.kt` stub becomes +`KotlinWebTransportFactory.kt` once real. The current ViewModel wiring +(`AudioRoomConnectionViewModel`) imports the class directly, so the rename is +one change with no protocol impact. + +## Phase breakdown (one developer, full-time estimates) + +Each phase ends with green commonTest tests that exercise its layer in isolation, +plus an integration test against the next layer up. + +### Phase A — Foundations (1 week) +- UDP socket abstraction (`DatagramChannel`, suspend wrapper, single-receive loop). +- Connection ID generation (cryptographically random, 8-byte source IDs). +- Packet number space tracking (Initial, Handshake, Application). +- Largest-acked / next-pn tracking per space. +- **Tests:** varint already done; add CID + packet-number-space unit tests. + +### Phase B — TLS via BouncyCastle (2 weeks) +- `BcQuicTlsClient` adapts `org.bouncycastle.tls.TlsClientProtocol`. BC's TLS + client wants two streams; we wire a `PipedInputStream` / `PipedOutputStream` + pair for the input side and capture the output side via a custom + `OutputStream` that forwards bytes to the `CRYPTO`-frame queue. +- Hook `TlsCryptoProvider` callbacks to capture the per-encryption-level secrets + (Initial, Handshake, 0-RTT, 1-RTT) BC derives during the handshake. +- Implement `QuicTransportParameters` extension (codec for the TLS extension + blob carrying `initial_max_data`, `max_idle_timeout`, etc.). +- **Tests:** stand up an in-process server using BC's `TlsServerProtocol` with + the same shim; assert client + server drive each other to the Application + Data state and emit matching 1-RTT secrets. + +### Phase C — Initial + Handshake packets (2 weeks) +- Long-header packet codec (Type, Version, DCID, SCID, Token, Length, PN). +- Header protection (AES-ECB sample mask for AES suites, ChaCha20 for CC20). +- Payload protection via JCA AEAD; nonce = packet-number XOR static IV. +- Initial-secret derivation with the v1 salt (RFC 9001 §5.2). +- CRYPTO frame encode/decode with offset reassembly. +- **Tests:** decode the `Initial` packet from RFC 9001 Appendix A.2 (Cloudflare's + test vectors). Bit-for-bit match required. + +### Phase D — 1-RTT + STREAM frames (1 week) +- Short-header packet codec. +- STREAM frame encode/decode with OFF/LEN/FIN bits. +- Per-stream receive buffer with out-of-order chunk reassembly. +- Per-stream send buffer with retransmit hold (until ACKed). +- **Tests:** stream offset reassembly against a property-based fuzz that injects + random reordering / duplication. + +### Phase E — ACK + flow control (1 week) +- ACK frame encoding (ranges from packet-number tracking). +- Connection-level + stream-level send credits. +- MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS handling. +- DATA_BLOCKED / STREAM_DATA_BLOCKED emission when blocked. +- **Tests:** unit tests for credit accounting + ACK range encoding edge cases. + +### Phase F — Loss recovery + congestion control (1 week) +- PTO timer per RFC 9002 §6. +- RACK-based loss detection (packet-threshold + time-threshold). +- NewReno congestion controller (slow start, congestion avoidance, recovery). +- Pacing not strictly required for v1; optional time-spaced send. +- **Tests:** simulate packet loss at controlled rates; assert retransmissions + arrive within PTO + cwnd doesn't oscillate pathologically. + +### Phase G — Datagram extension (2 days) +- DATAGRAM frame (frame types 0x30 / 0x31). +- Negotiate `max_datagram_frame_size` transport parameter. +- **Tests:** datagram round-trip against in-process test server. + +### Phase H — Connection lifecycle (1 week) +- Transport parameters codec (~30 parameters; only ~10 we send). +- CONNECTION_CLOSE handling (transport vs. application-level). +- Idle timeout (default 30 s, both sides MIN). +- Draining + closing states per RFC 9000 §10.2. +- **Tests:** assert clean close round-trip; assert idle timeout fires. + +**End of Phase H = working QUIC client.** ~9 weeks elapsed. + +### Phase I — HTTP/3 (2 weeks) +- Unidirectional control stream (peer-direction opens with stream-type 0x00). +- SETTINGS frame (we MUST emit `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`, + `SETTINGS_ENABLE_WEBTRANSPORT = 1`, `SETTINGS_H3_DATAGRAM = 1`). +- HEADERS frame (carrying QPACK output). +- DATA frame. +- GOAWAY handling. +- **Tests:** SETTINGS round-trip; HEADERS+DATA request/response. + +### Phase J — QPACK (2 weeks) +- Static table (RFC 9204 Appendix A). +- Encoder: literal-with-name-reference + literal-without-name-reference only. + No dynamic table inserts on our side. Simplifies massively; nests will accept + literals. +- Decoder: full dynamic table support so we can read what nests sends. +- Huffman codec (RFC 9204 Appendix B). +- Encoder + decoder streams (the two QPACK uni streams). +- **Tests:** decode known-good QPACK headers from the IETF interop corpus. + +### Phase K — Extended CONNECT + WebTransport (1 week) +- HEADERS request with `:method=CONNECT`, `:protocol=webtransport`, + `:scheme=https`, `:authority=...`, `:path=...`. +- Response handling (2xx → session open, 4xx/5xx → ConnectRejected). +- Stream-type prefix bytes (0x41 client-bidi, 0x54 client-uni). +- Capsule protocol for graceful close (capsule type 0x2843 = WT_CLOSE_SESSION). +- HTTP Datagram (RFC 9297) wrapping WT datagrams with quarter-stream-id prefix. +- Wire into existing `WebTransportSession` interface. +- **Tests:** unit tests for capsule + datagram framing; integration test using + the in-process H3 server from Phase J. + +### Phase L — Interop + hardening (2 weeks) +- Run against `nests-rs` locally. Fix every protocol mismatch. +- Run against `nostrnests.com` once local works. +- Fuzz testing: malformed packet acceptance, off-by-one frame lengths, etc. +- Performance pass: confirm < 10 ms median round-trip on local testing. + +**End of Phase L = audible audio against real nests.** ~16 weeks elapsed. + +## Validation strategy + +1. **Unit tests per phase.** Already established pattern in nestsClient. +2. **In-process integration**, Phase B onward: spin up a BC-based QUIC server + in the same JVM and let client + server drive each other. No real network. +3. **`quic-interop-runner` corpus** (Phase L): the IETF QUIC WG publishes + reference test vectors for client behavior across every implementation. We + can replay their packet captures and assert correct decoding. +4. **Local nests-rs** (Phase L): run nests' Rust reference server in Docker; + point our client at it. Verifies real Extended CONNECT + MoQ on top. +5. **Production `nostrnests.com`** (Phase L): final confidence step. + +## Risks + stop conditions + +| Risk | Mitigation | Stop condition | +|---|---|---| +| BouncyCastle TLS 1.3 doesn't expose the secret-derivation hooks QUIC needs | Investigate BC's `TlsCryptoProvider` early in Phase B before committing the rest | If by end of Phase B we can't get per-encryption-level secrets out of BC, switch to `bctls`'s lower-level `TlsCrypto` API and implement the TLS 1.3 record layer ourselves on top (~+4 weeks) | +| Header protection edge cases mis-encode 4-byte packet numbers | RFC 9001 has explicit test vectors | None — must work | +| QPACK dynamic table from nests is more aggressive than literal-only | Decoder supports full dynamic table; only encoder is literal-only | If nests rejects literal-only encoded headers, add minimal encoder dynamic table (~+1 week) | +| Loss recovery oscillates badly under real RTT variance | NewReno is conservative enough | If empirically bad, swap to BBRv1 implementation reference (~+2 weeks) | +| WebTransport draft mismatch with nests | Pin to whatever draft nests serves; advertise both legacy + current setting IDs | Hard fail back to documenting the version skew | + +**Hard abandonment trigger:** if at end of Phase D (~6 weeks in) we cannot pass +the RFC 9001 Appendix A test vectors bit-for-bit, the implementation has a deep +bug we can't shake without dedicated cryptographic review. At that point the +honest call is to wait for an Android-compatible Java QUIC library to ship +elsewhere. + +## Timeline summary + +| Phase | Weeks | Cumulative | +|---|---|---| +| A. Foundations | 1 | 1 | +| B. TLS via BC | 2 | 3 | +| C. Initial + Handshake packets | 2 | 5 | +| D. 1-RTT + STREAM | 1 | 6 | +| E. ACK + flow control | 1 | 7 | +| F. Loss + congestion | 1 | 8 | +| G. Datagrams | ½ | 8.5 | +| H. Connection lifecycle | 1 | 9.5 | +| I. HTTP/3 | 2 | 11.5 | +| J. QPACK | 2 | 13.5 | +| K. Extended CONNECT + WT | 1 | 14.5 | +| L. Interop + hardening | 2 | 16.5 | + +**16-18 weeks of full-time work for one developer**, or 5-6 months at a normal +review-and-iteration cadence. Add a security review of the QUIC + TLS code paths +before shipping to users — minimum 2 weeks calendar time, possibly external. + +## Dependencies to add + +```toml +# gradle/libs.versions.toml +[versions] +bouncycastle = "1.79" # bcprov already transitively included; add bctls + +[libraries] +bouncycastle-tls = { group = "org.bouncycastle", name = "bctls-jdk18on", version.ref = "bouncycastle" } +``` + +```kotlin +// nestsClient/build.gradle.kts under jvmAndroid.dependencies +implementation(libs.bouncycastle.tls) +``` + +`bcprov-jdk18on` is already cached; verify on Phase B start that `bctls` resolves +from Maven Central before committing further. + +## What ships first + +Phases A+B+C in a single PR demonstrating TLS over QUIC handshake completion +against an in-process test server, bit-matching RFC 9001 vectors. That's the +proof-of-concept gate. If it works, the rest is execution. If it doesn't, we +abandon and revisit. From 1ff951dbb4c3a03cda745280fa78a1030b7240d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 12:25:58 +0000 Subject: [PATCH 14/18] =?UTF-8?q?docs(quic-plan):=20drop=20BouncyCastle=20?= =?UTF-8?q?=E2=80=94=20Quartz=20already=20has=20every=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited Quartz's crypto surface and found every primitive the QUIC plan called out for BouncyCastle is already present in commonMain: - `utils/ciphers/AESGCM.kt` — AES-GCM with AAD (QUIC-TLS AEAD) - `nip44Encryption/crypto/ChaCha20Poly1305.kt` — pure-Kotlin AEAD - `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` — HKDF - `utils/sha256/Sha256.kt` — SHA-256 - `marmot/mls/crypto/X25519.kt` — X25519 ECDH (TLS 1.3 key exchange) - `marmot/mls/crypto/Ed25519.kt` — Ed25519 signatures - `utils/SecureRandom.kt` Plan update highlights: - "What we delegate" section rewritten to point at Quartz primitives. - Phase B renamed from "TLS via BouncyCastle" to "TLS 1.3 client state machine on Quartz primitives"; bumped from 2 to 3 weeks because we write the state machine ourselves, but eliminates the entire BC-adapter integration risk. - "Dependencies to add" section zeroed out — nothing goes in gradle/libs.versions.toml. The only new primitive is a thin HKDF-Expand-Label helper on top of existing `MacInstance`, which we can upstream to Quartz's `Hkdf` as a general `expand(prk, info, length)`. - Risk table rewritten: removed BC-specific risks, added Quartz-specific ones (verify `X25519.dh` against RFC 7748 vector on Phase B day-1). - Cert chain signature verification uses JDK `Signature` for RSA + ECDSA plus Quartz `Ed25519` for Ed25519 leaves. Total timeline moves from 16-18 weeks to 17-19 weeks — same band, but with zero external dependencies and zero Maven-resolution risk. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- ...4-22-pure-kotlin-quic-webtransport-plan.md | 134 ++++++++++++------ 1 file changed, 90 insertions(+), 44 deletions(-) diff --git a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md index f64943a33..7005b6f65 100644 --- a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md +++ b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md @@ -38,10 +38,30 @@ on top to interop with nests. This plan scopes that work honestly. ### What we delegate -- **TLS 1.3 handshake state machine + AEAD primitives** → BouncyCastle - (`org.bouncycastle:bctls-jdk18on:1.79` plus `bcprov` + `bcpkix` already cached). -- **X.509 cert validation** → JDK `TrustManagerFactory` (Android system trust store). -- **AES-GCM, ChaCha20-Poly1305, HKDF** → JCA via BC provider. +- **TLS 1.3 AEAD ciphers** → Quartz's existing `AESGCM` (common, with AAD + support) + `ChaCha20Poly1305` (common, pure-Kotlin). +- **HKDF primitives** → Quartz's existing `Hkdf` class + its `MacInstance` + (HMAC-SHA256). We add one thin helper — HKDF-Expand-Label from RFC 8446 §7.1 + — on top, plus a general `hkdfExpand(prk, info, length)` since Quartz only + ships NIP-44's specialised `fastExpand`. +- **Key agreement** → Quartz's existing `X25519` (common expect/actual). +- **SHA-256 hashing** → Quartz's existing `Sha256` (common expect/actual). +- **Cert chain signature verification** → Quartz's `Ed25519` where the leaf + uses Ed25519; JDK `Signature.getInstance("SHA256withRSA")` / + `"SHA256withECDSA"` for the common cases (nests typically uses Let's Encrypt + ECDSA certs). +- **X.509 parsing + chain validation** → JDK `CertificateFactory` + + `TrustManagerFactory.getInstance(...).init(null as KeyStore?)` (Android + system trust store). +- **AES-ECB for QUIC header protection** (one block, 5-byte sample) → JDK + `Cipher.getInstance("AES/ECB/NoPadding")` — trivial, no dep. +- **SecureRandom** → Quartz's `RandomInstance` / `SecureRandom`. + +**No BouncyCastle dependency.** The QUIC-TLS cryptographic surface is already +fully covered by Quartz's primitives — nip44Encryption/crypto (AEAD + HKDF + +ChaCha20 + Poly1305), marmot/mls/crypto (X25519 + Ed25519 + Curve25519Field), +utils/ciphers/AESGCM, utils/mac, utils/sha256, utils/SecureRandom. We write +the TLS 1.3 client state machine ourselves using those primitives. ### Explicitly out of scope @@ -97,18 +117,35 @@ plus an integration test against the next layer up. - Largest-acked / next-pn tracking per space. - **Tests:** varint already done; add CID + packet-number-space unit tests. -### Phase B — TLS via BouncyCastle (2 weeks) -- `BcQuicTlsClient` adapts `org.bouncycastle.tls.TlsClientProtocol`. BC's TLS - client wants two streams; we wire a `PipedInputStream` / `PipedOutputStream` - pair for the input side and capture the output side via a custom - `OutputStream` that forwards bytes to the `CRYPTO`-frame queue. -- Hook `TlsCryptoProvider` callbacks to capture the per-encryption-level secrets - (Initial, Handshake, 0-RTT, 1-RTT) BC derives during the handshake. -- Implement `QuicTransportParameters` extension (codec for the TLS extension - blob carrying `initial_max_data`, `max_idle_timeout`, etc.). -- **Tests:** stand up an in-process server using BC's `TlsServerProtocol` with - the same shim; assert client + server drive each other to the Application - Data state and emit matching 1-RTT secrets. +### Phase B — TLS 1.3 client state machine (3 weeks) + +Built entirely on Quartz primitives; no BouncyCastle. + +- `HkdfExpandLabel` helper per RFC 8446 §7.1 on top of Quartz's `Hkdf.extract` + + a new `hkdfExpand(prk, info, length)` written against `MacInstance`. +- `TlsKeySchedule` — derive Early / Handshake / Master secrets + per-direction + traffic secrets per RFC 8446 §7.1. +- `TlsTranscriptHash` — running SHA-256 over the handshake messages using + Quartz's `Sha256`. +- `TlsCipherSuite` — the three QUIC-mandatory suites: TLS_AES_128_GCM_SHA256 + (uses Quartz `AESGCM`), TLS_CHACHA20_POLY1305_SHA256 (uses Quartz + `ChaCha20Poly1305`), and optionally TLS_AES_256_GCM_SHA384 (SHA-384 is the + only primitive not yet in Quartz; skip this suite for v1). +- `TlsKeyExchange` — X25519 via Quartz `X25519.generateKeyPair()` + + `X25519.dh()`. +- `TlsClientHello` / `TlsServerHello` / `TlsEncryptedExtensions` / + `TlsCertificate` / `TlsCertificateVerify` / `TlsFinished` encode/decode. +- QUIC transport parameters TLS extension (codec for the opaque blob carrying + `initial_max_data`, `max_idle_timeout`, `max_datagram_frame_size`, etc.). +- Certificate chain validation delegated to JDK `TrustManagerFactory`. +- `TlsClient` state machine: drive handshake by consuming CRYPTO-frame payload + bytes and producing outbound CRYPTO-frame payload bytes; expose per- + encryption-level secrets as they're derived so the QUIC layer can install + keys. +- **Tests:** RFC 8448 §3 (Simple 1-RTT Handshake) test vectors — bit-for-bit + reproduction of the Client's generated bytes given fixed randomness. In- + process handshake against a minimal test TLS server built with the same + primitives. ### Phase C — Initial + Handshake packets (2 weeks) - Long-header packet codec (Type, Version, DCID, SCID, Token, Length, PN). @@ -210,10 +247,12 @@ plus an integration test against the next layer up. | Risk | Mitigation | Stop condition | |---|---|---| -| BouncyCastle TLS 1.3 doesn't expose the secret-derivation hooks QUIC needs | Investigate BC's `TlsCryptoProvider` early in Phase B before committing the rest | If by end of Phase B we can't get per-encryption-level secrets out of BC, switch to `bctls`'s lower-level `TlsCrypto` API and implement the TLS 1.3 record layer ourselves on top (~+4 weeks) | +| Quartz's `Hkdf` only ships the NIP-44-specialised `fastExpand`, not a general-purpose expand | Write generic `hkdfExpand(prk, info, length)` in Phase B on top of `MacInstance`; upstream the helper back to Quartz `utils/` | Trivial — add one file | +| Quartz's `X25519` doesn't accept arbitrary-length secrets or has subtle incompatibilities with TLS 1.3's derivation | Verify in Phase B day-1 with a micro-test: `X25519.dh(fixed_priv, fixed_pub)` against RFC 7748 test vector | If mismatched, pin a specific Quartz version or fork the primitive | | Header protection edge cases mis-encode 4-byte packet numbers | RFC 9001 has explicit test vectors | None — must work | | QPACK dynamic table from nests is more aggressive than literal-only | Decoder supports full dynamic table; only encoder is literal-only | If nests rejects literal-only encoded headers, add minimal encoder dynamic table (~+1 week) | | Loss recovery oscillates badly under real RTT variance | NewReno is conservative enough | If empirically bad, swap to BBRv1 implementation reference (~+2 weeks) | +| Server certs use RSA + SHA-256 / ECDSA P-256 / Ed25519 — we need all three sig verifiers | JDK `Signature.getInstance("SHA256withRSA")` + `Signature.getInstance("SHA256withECDSA")` cover RSA + ECDSA; Quartz `Ed25519` covers Ed25519 | None — all three are cheap | | WebTransport draft mismatch with nests | Pin to whatever draft nests serves; advertise both legacy + current setting IDs | Hard fail back to documenting the version skew | **Hard abandonment trigger:** if at end of Phase D (~6 weeks in) we cannot pass @@ -227,40 +266,47 @@ elsewhere. | Phase | Weeks | Cumulative | |---|---|---| | A. Foundations | 1 | 1 | -| B. TLS via BC | 2 | 3 | -| C. Initial + Handshake packets | 2 | 5 | -| D. 1-RTT + STREAM | 1 | 6 | -| E. ACK + flow control | 1 | 7 | -| F. Loss + congestion | 1 | 8 | -| G. Datagrams | ½ | 8.5 | -| H. Connection lifecycle | 1 | 9.5 | -| I. HTTP/3 | 2 | 11.5 | -| J. QPACK | 2 | 13.5 | -| K. Extended CONNECT + WT | 1 | 14.5 | -| L. Interop + hardening | 2 | 16.5 | +| B. TLS 1.3 client on Quartz primitives | 3 | 4 | +| C. Initial + Handshake packets | 2 | 6 | +| D. 1-RTT + STREAM | 1 | 7 | +| E. ACK + flow control | 1 | 8 | +| F. Loss + congestion | 1 | 9 | +| G. Datagrams | ½ | 9.5 | +| H. Connection lifecycle | 1 | 10.5 | +| I. HTTP/3 | 2 | 12.5 | +| J. QPACK | 2 | 14.5 | +| K. Extended CONNECT + WT | 1 | 15.5 | +| L. Interop + hardening | 2 | 17.5 | -**16-18 weeks of full-time work for one developer**, or 5-6 months at a normal -review-and-iteration cadence. Add a security review of the QUIC + TLS code paths -before shipping to users — minimum 2 weeks calendar time, possibly external. +**17-19 weeks of full-time work for one developer**, or 5-6 months at a +normal review-and-iteration cadence. TLS 1.3 is +1 week vs. the original +BC-adapter estimate because we write the state machine ourselves, but the +total is within the same band and we've eliminated the external-library +integration risk entirely. Add a security review of the QUIC + TLS + new +HKDF-Expand helper before shipping to users — minimum 2 weeks calendar +time, possibly external. ## Dependencies to add -```toml -# gradle/libs.versions.toml -[versions] -bouncycastle = "1.79" # bcprov already transitively included; add bctls +**None.** Every cryptographic primitive lives in Quartz already: -[libraries] -bouncycastle-tls = { group = "org.bouncycastle", name = "bctls-jdk18on", version.ref = "bouncycastle" } -``` +| Primitive | Quartz location | +|---|---| +| AES-128-GCM with AAD | `utils/ciphers/AESGCM.kt` (commonMain expect, jvmAndroid actual via JCA) | +| ChaCha20-Poly1305 | `nip44Encryption/crypto/ChaCha20Poly1305.kt` (commonMain, pure Kotlin) | +| HKDF-Extract + MAC | `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` | +| SHA-256 | `utils/sha256/Sha256.kt` | +| X25519 ECDH | `marmot/mls/crypto/X25519.kt` (commonMain expect) | +| Ed25519 signatures | `marmot/mls/crypto/Ed25519.kt` | +| SecureRandom | `utils/SecureRandom.kt` + `utils/RandomInstance.kt` | -```kotlin -// nestsClient/build.gradle.kts under jvmAndroid.dependencies -implementation(libs.bouncycastle.tls) -``` +Nothing goes into `gradle/libs.versions.toml`. AES-ECB for header protection +is one-block and uses JDK `javax.crypto.Cipher` directly (no provider swap). -`bcprov-jdk18on` is already cached; verify on Phase B start that `bctls` resolves -from Maven Central before committing further. +One small helper lives in the new `quic/crypto/` package: +`HkdfExpandLabel` (RFC 8446 §7.1) built on `MacInstance`. If it's clean, we +upstream it to Quartz's `Hkdf` as a general-purpose `expand(prk, info, +length)`. ## What ships first From 2b44e9b06b5d7dabd3821d409771f56491686f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:20:33 +0000 Subject: [PATCH 15/18] docs(quic-plan): lock in new top-level :quic module (Option A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the Architecture section to reflect the decision to house the pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`. Key changes: - Module placement: new KMP module `:quic` with commonMain + jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz). `:quic` takes `api(project(":quartz"))` so all crypto primitives are in-scope without re-export. - settings.gradle delta spelled out. - Package layout shifts from `nestsClient/src/jvmAndroid/...transport/quic/` to `:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP socket-specific bits in `jvmAndroid/`. - Varint.kt migration called out: moves from `nestsClient/moq/Varint.kt` to `:quic` at `com.vitorpamplona.quic.Varint`. Mechanical, one commit. - Rename KwikWebTransportFactory stub → QuicWebTransportFactory (real), living in `:quic` but implementing `:nestsClient`'s existing `WebTransportFactory` interface. AudioRoomConnectionViewModel changes one ctor call. - Rationale section documenting why Option A beats putting it in `:nestsClient` (single responsibility / reusability / security boundary / test isolation / build graph / charter fit). - Phase A now explicitly includes the module-creation + Varint-migration step; test suite must stay green across the move. Timeline unchanged (17-19 weeks). Dependency story unchanged (no external libs; everything piggybacks on Quartz). https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- ...4-22-pure-kotlin-quic-webtransport-plan.md | 120 ++++++++++++++---- 1 file changed, 94 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md index 7005b6f65..e2491643b 100644 --- a/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md +++ b/docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md @@ -78,32 +78,94 @@ the TLS 1.3 client state machine ourselves using those primitives. ## Architecture -``` -nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/ -└── transport/ - ├── quic/ ← new: pure-Kotlin QUIC client - │ ├── crypto/ ← BC adapter (TLS over CRYPTO frames) - │ ├── packet/ ← long/short-header encode/decode + protection - │ ├── frame/ ← STREAM, ACK, CRYPTO, MAX_*, CONNECTION_CLOSE … - │ ├── stream/ ← per-stream send/recv buffers, flow control - │ ├── recovery/ ← loss detection, PTO, NewReno - │ ├── connection/ ← QuicConnection state machine - │ └── transport/ ← UDP socket + datagram pump - ├── http3/ ← new: HTTP/3 client - │ ├── frame/ ← DATA, HEADERS, SETTINGS, GOAWAY, MAX_PUSH_ID - │ ├── qpack/ ← static + dynamic tables, Huffman - │ └── Http3Connection.kt ← request/response state machine - └── webtransport/ ← new: WT layer - ├── ExtendedConnect.kt ← :protocol = webtransport - ├── WtStreamType.kt ← 0x41 (bidi) / 0x54 (uni) prefix bytes - ├── WtCapsule.kt ← WT_SESSION_CLOSE = 0x2843, etc. - └── KotlinWebTransportFactory.kt ← implements existing WebTransportFactory +**Module placement:** a new top-level Gradle module `:quic` sibling to +`:quartz`, `:commons`, `:nestsClient`. It's a KMP module (commonMain + an +`androidMain` + `jvmMain` pair via a shared `jvmAndroid` source set, mirroring +Quartz's pattern) so it's reusable for future non-Nests consumers +(Nostr-over-QUIC relays, desktop audio, iOS later). `:quic` takes +`api(project(":quartz"))` so the crypto primitives are available without +re-exporting. `:nestsClient` drops its `transport/` subpackage and declares +`implementation(project(":quic"))` instead. + +**settings.gradle delta:** + +```groovy +include ':quartz' +include ':commons' +include ':ammolite' +include ':quic' // NEW — pure-Kotlin QUIC + HTTP/3 + WebTransport +include ':nestsClient' // gains `implementation project(':quic')` +include ':desktopApp' +include ':cli' ``` -Naming note: the existing `KwikWebTransportFactory.kt` stub becomes -`KotlinWebTransportFactory.kt` once real. The current ViewModel wiring -(`AudioRoomConnectionViewModel`) imports the class directly, so the rename is -one change with no protocol impact. +**Package layout under `:quic`:** + +``` +quic/ +├── build.gradle.kts ← KMP module, api(project(":quartz")) +└── src/ + ├── commonMain/kotlin/com/vitorpamplona/quic/ + │ ├── Varint.kt ← migrated from nestsClient/moq/Varint.kt + │ ├── crypto/ ← HkdfExpandLabel + thin helpers + │ ├── tls/ ← TLS 1.3 client state machine + │ ├── packet/ ← long/short-header codec + protection + │ ├── frame/ ← CRYPTO, STREAM, ACK, MAX_*, CONNECTION_CLOSE… + │ ├── stream/ ← per-stream buffers + flow control + │ ├── recovery/ ← loss detection + PTO + NewReno + │ ├── connection/ ← QuicConnection state machine + │ ├── http3/ ← RFC 9114 client + │ ├── qpack/ ← RFC 9204 encoder/decoder + │ └── webtransport/ + │ ├── ExtendedConnect.kt + │ ├── WtStreamType.kt ← 0x41 (bidi) / 0x54 (uni) + │ ├── WtCapsule.kt ← WT_SESSION_CLOSE = 0x2843 + │ └── QuicWebTransportFactory.kt ← implements nestsClient's WebTransportFactory + ├── jvmAndroid/kotlin/com/vitorpamplona/quic/ + │ └── transport/ + │ └── UdpSocket.kt ← DatagramChannel + suspend wrapper + ├── commonTest/ + └── jvmTest/ ← RFC 8448 / RFC 9001 vector tests +``` + +**Migrations when `:quic` lands:** + +- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt` + moves to `:quic` at `com.vitorpamplona.quic.Varint`. The existing MoQ codec + (Phase 3c) imports the new path. Mechanical change — one commit. +- The `nestsClient/transport/` package (`WebTransportSession` + + `FakeWebTransport` + `KwikWebTransportFactory` stub) stays in `:nestsClient` + because that's the seam `:nestsClient`'s audio pipeline talks to. The real + implementation `QuicWebTransportFactory` lives in `:quic` and implements + `nestsClient.transport.WebTransportFactory` — `:nestsClient` swaps its + constructor call from `KwikWebTransportFactory()` to + `QuicWebTransportFactory()`, nothing else changes upstream. + +**Why a dedicated module (Option A):** + +- **Single responsibility.** `:quic` is a transport library; `:nestsClient` + is an audio-room client. They have nothing conceptually in common beyond + nests happens to run over WebTransport. +- **Reusability.** Future Nostr-over-QUIC relay work, any Kotlin project that + wants MoQ, desktop / iOS targets all pull `:quic` directly. Potentially + extractable to an open-source library later — pure-Kotlin QUIC barely + exists in the JVM ecosystem, so this would be genuinely valuable beyond + Amethyst. +- **Security boundary.** QUIC + TLS is the code you want smallest, isolated, + and auditable without reading audio-room code around it. A dedicated module + enforces the boundary. +- **Test isolation.** `:quic` tests run on `commonTest` / `jvmTest` — fast + feedback. They never touch `:nestsClient`'s fakes. +- **Build graph.** `:quic` has no Android-framework dependencies (no + `MediaCodec`, no `AudioTrack`), so it compiles clean as a pure KMP lib and + targets iOS / desktop trivially when those arrive. +- **Charter.** Quartz is labeled "Nostr protocol only, no UI" in the project's + CLAUDE.md. QUIC isn't Nostr; it doesn't fit Quartz's charter. + +Naming note: the existing `KwikWebTransportFactory.kt` stub in `:nestsClient` +is renamed to reference the real implementation `QuicWebTransportFactory` in +`:quic`. `AudioRoomConnectionViewModel` updates its constructor call in one +line, no protocol impact. ## Phase breakdown (one developer, full-time estimates) @@ -111,11 +173,17 @@ Each phase ends with green commonTest tests that exercise its layer in isolation plus an integration test against the next layer up. ### Phase A — Foundations (1 week) -- UDP socket abstraction (`DatagramChannel`, suspend wrapper, single-receive loop). +- Create new `:quic` module (KMP, `api(project(":quartz"))`), register in + `settings.gradle`, port `Varint.kt` from `:nestsClient` and update MoQ + imports, add `:nestsClient` → `implementation(project(":quic"))` wire. +- UDP socket abstraction (`DatagramChannel`, suspend wrapper, single-receive + loop) in `jvmAndroid`. - Connection ID generation (cryptographically random, 8-byte source IDs). - Packet number space tracking (Initial, Handshake, Application). - Largest-acked / next-pn tracking per space. -- **Tests:** varint already done; add CID + packet-number-space unit tests. +- **Tests:** varint tests migrate with the file; add CID + packet-number-space + unit tests. Full MoQ + nestsClient test suite stays green after the + Varint migration. ### Phase B — TLS 1.3 client state machine (3 weeks) From ac1e751bec990467a312cea728d19572d4bbbfb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:27:18 +0000 Subject: [PATCH 16/18] =?UTF-8?q?docs:=20draft=20NIP-XX=20=E2=80=94=20Inte?= =?UTF-8?q?ractive=20Audio=20Rooms=20Join=20Protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the gap between NIP-53's room discovery and what audio-capable clients/servers must actually agree on to interop. NIP-53 defines the 30312 event but leaves the HTTP control plane + MoQ namespacing + audio codec params entirely to individual implementations. With Nests going generic-server, this is the moment to standardize. What the draft covers: 1. HTTP control plane: - Path convention: GET / - NIP-98 `Authorization: Nostr ` header required - JSON response shape (endpoint, token, transport, codec, sample_rate, frame_duration_ms, moq_version) - Canonical error-status map (401/403/404/410/503) 2. WebTransport + MoQ handshake requirements (Extended CONNECT, required HTTP/3 settings, Bearer token passing). 3. MoQ track naming — vendor-neutral one-element namespace `[]` with track-name = speaker-pubkey-hex. Explicit rejection of the `["nests", ]` prefix for new deployments. 4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz mono, 20 ms default, mono PCM 16-bit decode target. Both OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners MUST handle both. 5. Per-track access control: server MUST verify publishing pubkey is a host/speaker in the current 30312 event (which is replaceable — revocation cascade is spec'd). 6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence, WT_CLOSE_SESSION capsule). 7. Presence extension: the `["muted", "1"|"0"]` tag we already ship in nestsClient's MeetingRoomPresenceEvent overload, promoted from Amethyst-specific to NIP-defined. 8. Server + client requirements summaries. 9. Known divergences from current nostrnests/nests servers (two- element `["nests", ]` namespace + `/api/v1/nests/` path) with a transition strategy via a `"nip_xx": true` flag in room-info responses. 10. Security considerations (bearer-token handling, NIP-98 `u` tag binding, audio-replay attack surface, server-impersonation VU meter recommendation). Deliberately out of scope: E2E-signed audio objects (future NIP), federation between audio servers, room recording/transcription. Intended workflow: share with the Nests team while they're designing the generic server, land changes against their feedback, then open a PR on nostr-protocol/nips. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../plans/2026-04-22-nip-audio-rooms-draft.md | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/plans/2026-04-22-nip-audio-rooms-draft.md diff --git a/docs/plans/2026-04-22-nip-audio-rooms-draft.md b/docs/plans/2026-04-22-nip-audio-rooms-draft.md new file mode 100644 index 000000000..2b9051f6b --- /dev/null +++ b/docs/plans/2026-04-22-nip-audio-rooms-draft.md @@ -0,0 +1,334 @@ +# NIP-XX — Interactive Audio Rooms Join Protocol + +`draft` `optional` + +## Abstract + +This NIP specifies the client/server control plane and real-time audio +transport for joining a NIP-53 Meeting Space (kind `30312`) as a listener +or speaker. It closes the gap between NIP-53's room discovery (which +defines only *what* a room is) and what an audio-capable client must do +to actually hear and speak in it. + +A standard, vendor-neutral profile lets multiple server implementations +interoperate with multiple clients. A client that follows this NIP will +work against any server that advertises compliance, and vice versa. + +## Dependencies + +- [NIP-53: Live Activities](https://github.com/nostr-protocol/nips/blob/master/53.md) + defines kind `30312` (Meeting Space), kind `10312` (Room Presence), + and kind `1311` (Live Activity Chat). +- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md) + defines kind `27235` and the `Authorization: Nostr ` header. +- [IETF `moq-transport`](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) + and [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) + provide the audio transport substrate. + +## Terminology + +- **Host** — the pubkey that signed the kind `30312` event. +- **Speaker** — any pubkey listed in the `30312` event's `p` tags with role + `host` or `speaker`. +- **Listener** — any other participant (including un-tagged members of the + audience). +- **MoQ endpoint** — the WebTransport URL a server returns from its room-info + HTTP endpoint; where audio flows once the session is established. + +## Event kinds used + +### Kind 30312 (already defined by NIP-53) + +Clients and servers compliant with this NIP MUST honour these tags: + +| Tag | Meaning | Required | +|---|---|---| +| `d` | Room identifier. The "room id" everywhere else in this spec. | yes | +| `room` | Human-readable display name. | recommended | +| `summary` | Short description. | optional | +| `image` | Room cover URL. | optional | +| `status` | `open` \| `private` \| `closed`. | yes | +| `service` | HTTPS base URL of a server implementing the HTTP control plane defined below. | yes | +| `streaming` | Present for non-audio streams (e.g. `wss+livekit://…`, HLS). **MAY be omitted** for pure audio rooms — in that case audio flows via the MoQ endpoint returned by the `service` URL. | optional for audio rooms | +| `p` | `["p", , ?, ?, ?]` — role is `host` or `speaker`. | one `host` required | + +A client MUST reject a room whose `service` URL is not HTTPS. + +### Kind 10312 (Room Presence) — extended + +NIP-53 defines this event with an optional `["hand", "1"|"0"]` tag. This NIP +adds a second optional tag: + +| Tag | Meaning | +|---|---| +| `["muted", "1"]` or `["muted", "0"]` | The participant's microphone is muted (`1`) or hot (`0`) at the time of publish. Absent means unspecified. | + +Servers and peers MUST NOT rely on `muted` for authorization — it's a UI +signal, not an access control. The server still enforces who is allowed to +publish audio via the MoQ session. + +### Kind 1311 (Live Activity Chat) + +Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag +pointing at the `30312` event. + +## HTTP control plane + +### Base URL + +The `service` tag from the kind `30312` event is the base URL. All control- +plane requests are constructed as: + +``` +GET / — room-info / join +``` + +where `` is the `d` tag value of the kind `30312` event, +URL-path-encoded. + +The server MAY expose additional paths under the same base (e.g. +`/` for server metadata, `/.well-known/nostr-audio-rooms` +for discovery). This NIP only specifies `/`. + +### Authentication + +Every request MUST carry a NIP-98 `Authorization` header: + +``` +Authorization: Nostr +``` + +The kind `27235` event MUST have: + +- `["u", ""]` +- `["method", "GET"]` (or `POST`, `DELETE`, etc. matching the request verb) +- `created_at` within 60 s of the server's clock +- A valid signature + +The server SHOULD reject requests older than 60 s with `401 Unauthorized`. +Servers MAY also reject requests whose signer isn't allowed in the room +(e.g. the room is `private` and the signer isn't on the allow-list). + +### Join / room-info response + +`GET /` with a valid NIP-98 header returns a JSON body +with `Content-Type: application/json`: + +```json +{ + "endpoint": "https://relay.example.com:4443/moq", + "token": "eyJhbGciOi…", + "transport": "webtransport", + "codec": "opus", + "sample_rate": 48000, + "frame_duration_ms": 20, + "moq_version": "draft-17" +} +``` + +Fields: + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `endpoint` | string (https URL) | yes | WebTransport URL the client connects to. | +| `token` | string | yes | Opaque bearer token the client passes to the WebTransport layer (see below). | +| `transport` | string | no, defaults to `"webtransport"` | Reserved for future transports. Clients MUST fail closed on unknown values. | +| `codec` | string | no, defaults to `"opus"` | Audio codec name. Reserved for future codecs. Clients MUST fail closed on unknown values. | +| `sample_rate` | integer | no, defaults to `48000` | Samples per second. | +| `frame_duration_ms` | integer | no, defaults to `20` | Audio frame duration. | +| `moq_version` | string | no, defaults to server's preferred | Identifier of the MoQ-transport draft the server speaks (e.g. `"draft-17"`). Clients MUST include this version (and nothing else) in CLIENT_SETUP. | + +Unknown fields MUST be ignored by the client to allow forward-compatible +server extensions. + +Error responses are standard HTTP status codes with an optional JSON body +`{"error": "", "reason": ""}`: + +| Status | When | +|---|---| +| `401` | NIP-98 missing, expired, or signature invalid. | +| `403` | Signer isn't allowed in this room (e.g. room `closed`, signer blocked). | +| `404` | Room `d` tag unknown to this server. | +| `410` | Room has ended. | +| `503` | Server is healthy but audio backend is unavailable. | + +## Audio transport + +### WebTransport + +Clients open a WebTransport session against the `endpoint` URL using the +Extended CONNECT handshake ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) ++ the [WebTransport-HTTP/3 draft](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)). +The HTTP CONNECT request MUST carry: + +``` +:method: CONNECT +:protocol: webtransport +:scheme: https +:authority: +:path: +Authorization: Bearer +``` + +Servers MUST: + +- advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441)), +- advertise `SETTINGS_ENABLE_WEBTRANSPORT = 1`, +- advertise `SETTINGS_H3_DATAGRAM = 1` ([RFC 9297](https://www.rfc-editor.org/rfc/rfc9297)). + +### MoQ session + +After the WebTransport session is open, the client opens its first +bidirectional stream as the MoQ control stream and sends `CLIENT_SETUP` +advertising the `moq_version` from the room-info response. The server replies +with `SERVER_SETUP` selecting that version, or closes the session. + +### Track namespace + name + +A speaker's audio track is published by the server and subscribed-to by +clients under: + +``` +track_namespace = [ ] +track_name = (64 lowercase hex chars) +``` + +The namespace is a **one-element tuple** containing the room's `d` tag. It is +intentionally vendor-neutral: there is no `"nests"` or server-brand prefix. + +Rationale: the namespace is uniquely keyed by the NIP-53 room id and is +sufficient for a client to subscribe without knowing anything about the +server's brand. Multiple servers hosting rooms with the same `d` tag is +already not a concern — a `d` tag is unique under a host pubkey, and the +room-info response identifies exactly which server's MoQ endpoint the +client must connect to. + +### Audio objects + +Each OBJECT on a speaker's track carries one Opus packet as its payload: + +- Opus encapsulated in **raw packet form** (no Ogg / no TOC-prefix), as + produced by `libopus` / Android `MediaCodec("audio/opus")` encoder output. +- `sample_rate` from the room-info response (default 48 000 Hz). +- `frame_duration_ms` from the room-info response (default 20 ms). +- Mono (single channel), signed 16-bit PCM domain, VBR encoding. + +Object delivery MAY use either: + +- **OBJECT_DATAGRAM** (lowest latency, lossy) — recommended for live audio. +- **STREAM_HEADER_SUBGROUP** uni-streams (reliable) — MAY be used by servers + that need delivery guarantees; clients MUST support receiving both. + +`group_id` on each object is the speaker's monotonic group counter (one +group per speaker session). `object_id` is the zero-based Opus packet index +within the group. `publisher_priority` is `0x80` unless the server has +reason to vary it. + +### Server-to-client vs. client-to-server audio + +A **listener** SUBSCRIBEs to each speaker's track namespace + track name. +The server MUST accept SUBSCRIBEs from any authenticated client (subject to +its access control). + +A **speaker** ANNOUNCEs `[ ]` and publishes objects on track +name ``. The server MUST verify that the announcing +pubkey is listed in the room's `p` tag set with role `host` or `speaker` +**at the current moment** (the room event is replaceable — the set can +change). On role revocation, the server MUST close the publisher's track. + +### Leaving + +To leave, the client SHOULD: + +1. UNSUBSCRIBE every track it had open. +2. If it was a speaker, send UNANNOUNCE + `SubscribeDone` for its own track. +3. Publish a final kind `10312` presence event (optional, improves UX for + other peers) with `["muted","1"]` and without `["hand","1"]`. +4. Close the WebTransport session with capsule type `0x2843` + (`WT_CLOSE_SESSION`, code `0`). + +The server SHOULD treat 30 s without a kind `10312` refresh (per NIP-53) as +"left" for UI purposes. + +## Chat + +Per NIP-53 kind `1311`. No additions. + +## Server requirements summary + +A server claiming this NIP MUST: + +1. Accept NIP-98-signed requests at `/` and return the + JSON shape above on success. +2. Enforce the `status` on the kind `30312` event (reject join when + `closed`). +3. Enforce `p` tag role for publishers (speakers only). +4. Run a WebTransport / MoQ endpoint that speaks the `moq_version` + advertised in its room-info response. +5. Publish each speaker's Opus audio on track + `[] / ` and accept SUBSCRIBEs from any + authenticated listener. +6. Not require any fields beyond those listed in this NIP for basic + interop (but MAY include additional fields in its JSON response for + server-specific features; clients ignore unknown fields). + +## Client requirements summary + +A client claiming this NIP MUST: + +1. Resolve the `service` URL via NIP-98 GET before opening a transport. +2. Establish WebTransport against the returned `endpoint` with the + returned `token` as a Bearer header. +3. Run the MoQ handshake at the returned `moq_version`. +4. SUBSCRIBE to one track per `host`+`speaker` pubkey listed in the + room event. +5. Decode Opus per the returned `sample_rate` / `frame_duration_ms`. +6. Publish kind `10312` presence no less frequently than every 30 s while + joined, with `["muted", …]` reflecting the local user's microphone + state and `["hand", "1"]` when requesting to speak. +7. Fail closed on unknown `transport`, unknown `codec`, or missing + `endpoint` fields. + +## Reference implementation + +A compliant Kotlin/Android reference client is in development at +[`amethyst/nestsClient`](../..) — see `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md` +for the transport work-in-progress. + +## Known divergences from current nostrnests/nests servers + +At the time of writing, existing `nostrnests/nests` deployments use: + +- MoQ track namespace `[ "nests", ]` (two elements, `"nests"` prefix). + This NIP specifies `[ ]` (one element). Existing nests servers + SHOULD accept both for a transition period; new deployments SHOULD use the + one-element form. +- A `/api/v1/nests/` path convention. This NIP leaves the path + entirely to the `service` tag — servers are free to pick any path. + +A "compliance" phase is proposed where a server can advertise +`"nip_xx": true` in its room-info JSON response to signal it implements +this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral +namespace when present, and fall back to the legacy nests convention when +absent. + +## Security considerations + +- The `token` returned by the server is a bearer token. Clients MUST NOT log + it. Servers MUST scope it to a single room + pubkey and SHOULD expire it + within the room's lifetime. +- The server MUST validate the NIP-98 event's `u` tag matches the exact + request URL, preventing token reuse across rooms. +- Audio published by a pubkey `P` on this server can be replayed by a + malicious peer claiming to be `P` on a different server — the listener + has no signature on the Opus packets themselves. If end-to-end authenticity + matters, a future NIP can extend this by signing each object with the + speaker's Nostr key; this NIP leaves that out of scope. +- A malicious server can serve silent audio while presence indicators show + a speaker talking. Clients SHOULD expose signal-level metering (VU meter) + so users can spot this. + +## Discussion + +Send feedback to the author via Nostr or GitHub issues on the reference +client repository. From 3ca613d64b0b76734ff86d1f284156bf5075ce4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:38:35 +0000 Subject: [PATCH 17/18] fix(audio-rooms): hide audio-transport UI that has no backend yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch already covers the Nostr-side of audio rooms (drawer listing, 30312-event parsing, 10312 presence publishing, chat, hand- raise). The audio-transport pieces (WebTransport + MoQ + Opus play- back) are blocked on Phase 3b-2 (pure-Kotlin QUIC — see the plan in `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`). The problem: the in-progress AudioRoomConnectionViewModel was auto-calling connectNestsListener() on stage enter, and the stub `KwikWebTransportFactory` throws `NotImplemented`. That surfaced as a persistent red "Audio failed: WebTransport NotImplemented" chip every time a user opened any audio room — scary, confusing, and misleading. Hiding the broken UI until the transport lands: - Deleted `AudioRoomConnectionViewModel.kt`. The AudioRoomConnectionViewModel + ConnectionChip code is recoverable from git (commit `64b33674`) when Phase 3b-2 makes the underlying transport real. - Removed the auto-connect LaunchedEffect + ConnectionChip render from AudioRoomStage. - Removed the mute button. Pressing mute when we're not producing a mic stream would publish `["muted","0"|"1"]` on 10312 presence with no corresponding audio signal — misleading to peers. The builder-side support for the muted tag stays in MeetingRoomPresenceEvent (Quartz) for future use; the UI will re-add the button when audio capture actually runs. - `publishPresence(..., muted = null)` so the muted tag is elided from the broadcast event entirely while the feature is hidden. - Dropped the unused strings (audio_room_mute, audio_room_unmute, audio_room_conn_*) from strings.xml. What still works on the branch (verified): - Audio Rooms drawer entry lists kind 30312 rooms. - Tap a room → live-activity channel screen with the audio-room stage overlay showing title, summary, host+speaker avatars, audience avatars. - Kind 1311 chat (read + send) via the existing channel infra. - Kind 10312 presence published on enter + every 30 s with the correct `a`-tag and `["hand","1"|"0"]` reflecting local state. - Hand-raise button toggles the `hand` tag — browser clients + other NIP-53 peers can see the signal and hosts can promote. What's hidden until Phase 3b-2 lands: - WebTransport connection attempt, connection chip, mute button. Re-enabling them is additive: resurrect AudioRoomConnectionViewModel from git, paste three LaunchedEffect / DisposableEffect / ConnectionChip blocks back into AudioRoomStage, restore the five string resources, restore the mute button + the two-arg muted presence call. Zero protocol impact on anything else. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../room/AudioRoomConnectionViewModel.kt | 166 ------------------ .../audiorooms/room/AudioRoomStage.kt | 139 +++------------ amethyst/src/main/res/values/strings.xml | 7 - 3 files changed, 24 insertions(+), 288 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt deleted file mode 100644 index 2ee98aa1a..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomConnectionViewModel.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.nestsclient.NestsListener -import com.vitorpamplona.nestsclient.NestsListenerState -import com.vitorpamplona.nestsclient.OkHttpNestsClient -import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer -import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer -import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder -import com.vitorpamplona.nestsclient.connectNestsListener -import com.vitorpamplona.nestsclient.transport.KwikWebTransportFactory -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * Audio-pipeline owner for one open audio-room screen. Bridges the Compose - * stage UI to the [NestsListener] facade in `nestsClient`. - * - * The ViewModel is intentionally Android-only (lives in `amethyst/`) — it - * needs `viewModelScope`, the `MediaCodecOpusDecoder`, and the - * `AudioTrackPlayer`, none of which exist in commons. It is a thin shell - * that owns lifetime; all the real work happens in the `nestsClient` module. - * - * Lifecycle: - * - `connect(event, signer)` resolves the room HTTP-side, opens the - * WebTransport (currently throws NotImplemented from the Kwik stub — - * Phase 3b-2), runs the MoQ handshake, then subscribes to every speaker - * listed in the 30312 event and pipes their Opus frames through one - * [AudioRoomPlayer] each. - * - `disconnect()` tears down all per-speaker players and closes the - * listener. Idempotent, also called from `onCleared()`. - */ -class AudioRoomConnectionViewModel : ViewModel() { - private val _state = MutableStateFlow(NestsListenerState.Idle) - val state: StateFlow = _state.asStateFlow() - - private var listener: NestsListener? = null - private val playersBySpeaker = LinkedHashMap() - private var connectJob: Job? = null - - /** - * Resolve the room URL and start playing every host/speaker track. Re-entrant - * calls cancel any in-flight connect and start fresh — typically only useful - * after a [disconnect] / failure. - */ - fun connect( - event: MeetingSpaceEvent, - signer: NostrSigner, - ) { - connectJob?.cancel() - connectJob = - viewModelScope.launch(Dispatchers.IO) { - disconnectInternal() - _state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom) - - val service = event.service() - if (service.isNullOrBlank()) { - _state.value = - NestsListenerState.Failed( - "Room has no `service` URL — cannot resolve a nests endpoint", - ) - return@launch - } - - val l = - com.vitorpamplona.nestsclient - .connectNestsListener( - httpClient = OkHttpNestsClient(), - transport = KwikWebTransportFactory(), - scope = viewModelScope, - serviceBase = service, - roomId = event.dTag(), - signer = signer, - ) - listener = l - - // Mirror the underlying listener's state so consumers only need - // to observe one StateFlow. - viewModelScope.launch { l.state.collect { _state.value = it } } - - if (l.state.value !is NestsListenerState.Connected) { - return@launch - } - - // Subscribe to every host + speaker. Audience members don't - // publish audio; ignore them. - val speakerKeys = - event - .participants() - .filter { it.role.equals(ROLE.HOST.code, true) || it.role.equals(ROLE.SPEAKER.code, true) } - .map { it.pubKey } - .distinct() - - for (pubkey in speakerKeys) { - runCatching { - val handle = l.subscribeSpeaker(pubkey) - val player = - AudioRoomPlayer( - decoder = MediaCodecOpusDecoder(), - player = AudioTrackPlayer(), - scope = viewModelScope, - ) - player.play(handle.objects) { /* per-speaker decode errors swallowed */ } - playersBySpeaker[pubkey] = player - } - } - } - } - - /** Stop playback and tear down the listener. Idempotent. */ - fun disconnect() { - connectJob?.cancel() - connectJob = null - viewModelScope.launch(Dispatchers.IO) { disconnectInternal() } - } - - private suspend fun disconnectInternal() { - for ((_, p) in playersBySpeaker) runCatching { p.stop() } - playersBySpeaker.clear() - runCatching { listener?.close() } - listener = null - // Leave _state alone if it's already Closed; otherwise reset to Idle so - // the UI's "tap to retry" path is available. - if (_state.value !is NestsListenerState.Closed) { - _state.value = NestsListenerState.Idle - } - } - - override fun onCleared() { - // Best-effort sync teardown — viewModelScope is being cancelled around us, - // so any suspending listener.close() in disconnectInternal() may not run. - runCatching { - for ((_, p) in playersBySpeaker) p.stop() - playersBySpeaker.clear() - } - super.onCleared() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt index 22d912dff..048dee14e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -29,18 +29,12 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.outlined.PanTool -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -55,8 +49,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -65,8 +57,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.nestsclient.NestsListenerState import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -80,10 +70,20 @@ import kotlinx.coroutines.launch * Clubhouse-style audio-room "stage" rendered in place of the video player when * the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent]. * - * Phase 2 scope: shows host + speaker + audience avatars and lets the local user - * publish their kind 10312 presence (with hand-raise + mute flags) on relays. - * The mic toggle is a Nostr-only signal at this point — actual audio capture - * arrives in Phase 3 with the MoQ/WebTransport transport. + * Current (shipping) scope — pure Nostr, no audio transport: + * - Displays host / speaker / audience avatars parsed from the 30312 `p` tags. + * - Publishes kind 10312 presence on enter and every 30 s while composed. + * - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event + * so a host on any NIP-53 client (browser, other Android, etc.) can see the + * request and promote the user to speaker. + * + * Audio playback / capture + the "Audio connected" chip + the mute button live + * behind the WebTransport + QUIC work tracked in + * `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`. They're not + * exposed in the UI until that transport actually runs — a chip that always + * reads "Failed: NotImplemented" and a mute button with no mic to mute would + * mislead users. Re-enabling them is a small UI patch once + * `QuicWebTransportFactory.connect()` produces a real session. */ @Composable fun AudioRoomStage( @@ -114,40 +114,28 @@ private fun AudioRoomStageContent( } var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) } - var muted by rememberSaveable(event.address().toValue()) { mutableStateOf(true) } val scope = rememberCoroutineScope() val account = accountViewModel.account // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed. - LaunchedEffect(event.address().toValue(), handRaised, muted) { - publishPresence(account, event, handRaised, muted) + LaunchedEffect(event.address().toValue(), handRaised) { + publishPresence(account, event, handRaised) while (isActive) { delay(PRESENCE_REFRESH_MS) - publishPresence(account, event, handRaised, muted) + publishPresence(account, event, handRaised) } } - // Best-effort "leave" — re-publish a CLOSED presence so peers see us drop sooner - // than the 30 s heartbeat would otherwise allow. + // Best-effort "leave" — re-publish a lowered-hand presence so peers see us + // drop sooner than the 30 s heartbeat would otherwise allow. DisposableEffect(event.address().toValue()) { onDispose { scope.launch(Dispatchers.IO) { - runCatching { publishPresence(account, event, handRaised = false, muted = true) } + runCatching { publishPresence(account, event, handRaised = false) } } } } - // Audio listener owner. Auto-connects on enter, tears down on dispose. - val connectionVm: AudioRoomConnectionViewModel = - viewModel(key = "AudioRoom-${event.address().toValue()}") - val connectionState by connectionVm.state.collectAsStateWithLifecycle() - LaunchedEffect(event.address().toValue()) { - connectionVm.connect(event, accountViewModel.account.signer) - } - DisposableEffect(event.address().toValue()) { - onDispose { connectionVm.disconnect() } - } - Card( modifier = Modifier.fillMaxWidth().padding(8.dp), shape = RoundedCornerShape(12.dp), @@ -168,11 +156,6 @@ private fun AudioRoomStageContent( ) } - ConnectionChip( - state = connectionState, - onRetry = { connectionVm.connect(event, accountViewModel.account.signer) }, - ) - if (hosts.isNotEmpty() || speakers.isNotEmpty()) { StagePeopleRow( label = stringRes(R.string.audio_room_stage), @@ -205,87 +188,11 @@ private fun AudioRoomStageContent( ), ) } - StdHorzSpacer - FilledIconButton( - onClick = { muted = !muted }, - colors = - if (muted) { - IconButtonDefaults.filledIconButtonColors() - } else { - IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ) - }, - ) { - Icon( - imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, - contentDescription = - stringRes( - if (muted) R.string.audio_room_unmute else R.string.audio_room_mute, - ), - ) - } } } } } -@Composable -private fun ConnectionChip( - state: NestsListenerState, - onRetry: () -> Unit, -) { - val (label, color, clickable) = - when (state) { - is NestsListenerState.Idle -> { - Triple( - stringRes(R.string.audio_room_conn_idle), - MaterialTheme.colorScheme.surface, - true, - ) - } - - is NestsListenerState.Connecting -> { - Triple( - stringRes(R.string.audio_room_conn_connecting, state.step.name), - MaterialTheme.colorScheme.surface, - false, - ) - } - - is NestsListenerState.Connected -> { - Triple( - stringRes(R.string.audio_room_conn_connected), - MaterialTheme.colorScheme.primaryContainer, - false, - ) - } - - is NestsListenerState.Failed -> { - Triple( - stringRes(R.string.audio_room_conn_failed, state.reason), - MaterialTheme.colorScheme.errorContainer, - true, - ) - } - - is NestsListenerState.Closed -> { - Triple( - stringRes(R.string.audio_room_conn_closed), - MaterialTheme.colorScheme.surface, - true, - ) - } - } - AssistChip( - modifier = Modifier.padding(top = 8.dp), - onClick = { if (clickable) onRetry() }, - label = { Text(label, style = MaterialTheme.typography.labelSmall) }, - colors = AssistChipDefaults.assistChipColors(containerColor = color), - ) -} - @Composable private fun StagePeopleRow( label: String, @@ -320,14 +227,16 @@ private suspend fun publishPresence( account: com.vitorpamplona.amethyst.model.Account, event: MeetingSpaceEvent, handRaised: Boolean, - muted: Boolean, ) { runCatching { account.signAndComputeBroadcast( MeetingRoomPresenceEvent.build( root = event, handRaised = handRaised, - muted = muted, + // muted tag intentionally omitted while audio transport is not + // shipping — we're not producing a mic stream so any muted + // value would be misleading. + muted = null, ), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8618d0a6f..a87b2df76 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -493,13 +493,6 @@ Audience Raise hand Lower hand - Mute - Unmute - Audio not connected — tap to retry - Connecting to audio (%1$s) - Audio connected - Audio failed: %1$s - Audio session closed Videos Articles Private Bookmarks From 6aa47c2fec9b5be13ff7002c27dc93259090d6d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:44:29 +0000 Subject: [PATCH 18/18] chore(audio-rooms): hide drawer entry in release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Chess drawer entry pattern — `if (isDebug)` gates the row. Audio rooms' Nostr surface (presence, hand-raise, chat) works, but without the WebTransport audio backend the entry would bait release-build users into a room they can't actually listen to. One-line filter at the call site in DrawerContent.ListContent: val feedsItems = if (isDebug) DrawerFeedsItems else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS } CatalogSection(..., feedsItems, ...) Catalog entry + route + screen wiring all stay, so debug builds still see it and the Nostr feature continues to be exercisable. When `QuicWebTransportFactory.connect()` stops throwing NotImplemented the two lines go away. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../amethyst/ui/navigation/drawer/DrawerContent.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 4e419ed3d..c7a726925 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -545,7 +545,12 @@ fun ListContent( Column(modifier) { CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav) CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav) - CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav) + // Audio rooms are debug-only while the WebTransport audio backend is + // unimplemented (see docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md). + // The Nostr-side of the feature (presence, hand-raise, chat) works, but + // without audio playback the entry is confusing to release-build users. + val feedsItems = if (isDebug) DrawerFeedsItems else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS } + CatalogSection(R.string.drawer_section_feeds, feedsItems, accountViewModel, nav) CollapsibleSection(title = R.string.drawer_section_create) { NavigationRow(