Merge pull request #2494 from vitorpamplona/claude/clubhouse-event-listing-Kta46

Add MoQ-transport client and audio rooms support
This commit is contained in:
Vitor Pamplona
2026-04-22 09:50:02 -04:00
committed by GitHub
59 changed files with 6802 additions and 1 deletions
+1
View File
@@ -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
@@ -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,
@@ -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"
@@ -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<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioRooms> { AudioRoomsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -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<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
NavBarItem.AUDIO_ROOMS,
NavBarItem.LONGS,
NavBarItem.POLLS,
NavBarItem.BADGES,
@@ -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(
@@ -89,6 +89,8 @@ sealed class Route {
@Serializable object LiveStreams : Route()
@Serializable object AudioRooms : Route()
@Serializable object Longs : Route()
@Serializable object Articles : Route()
@@ -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)
@@ -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,
)
}
}
}
@@ -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()
}
}
@@ -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,
)
}
@@ -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<Note>() {
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<Note> {
val allRoomNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) }
return sort(innerApplyFilter(allRoomNotes))
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> {
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
}
}
}
}
@@ -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<AudioRoomsQueryState>() {
val group =
listOf(
AudioRoomsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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<AudioRoomsQueryState>,
) : PerUserAndFollowListEoseManager<AudioRoomsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: AudioRoomsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -0,0 +1,243 @@
/*
* 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.PanTool
import androidx.compose.material.icons.outlined.PanTool
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
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.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].
*
* 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(
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) }
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) {
publishPresence(account, event, handRaised)
while (isActive) {
delay(PRESENCE_REFRESH_MS)
publishPresence(account, event, handRaised)
}
}
// 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) }
}
}
}
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,
),
)
}
}
}
}
}
@Composable
private fun StagePeopleRow(
label: String,
people: List<ParticipantTag>,
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,
) {
runCatching {
account.signAndComputeBroadcast(
MeetingRoomPresenceEvent.build(
root = event,
handRaised = handRaised,
// 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,
),
)
}
}
@@ -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(
+5
View File
@@ -488,6 +488,11 @@
<string name="public_chats">Public Chats</string>
<string name="follow_packs">Follow Packs</string>
<string name="live_streams">Live Streams</string>
<string name="audio_rooms">Audio Rooms</string>
<string name="audio_room_stage">Stage</string>
<string name="audio_room_audience">Audience</string>
<string name="audio_room_raise_hand">Raise hand</string>
<string name="audio_room_lower_hand">Lower hand</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>
@@ -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 <base64-event>` 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", <pubkey>, <relay-hint>?, <role>?, <proof>?]` — 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 <service>/<room-d-tag> — room-info / join
```
where `<room-d-tag>` 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.
`<service>/` for server metadata, `<service>/.well-known/nostr-audio-rooms`
for discovery). This NIP only specifies `<service>/<room-d-tag>`.
### Authentication
Every request MUST carry a NIP-98 `Authorization` header:
```
Authorization: Nostr <base64(kind-27235-event)>
```
The kind `27235` event MUST have:
- `["u", "<fully-qualified-URL-being-requested>"]`
- `["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 <service>/<room-d-tag>` 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": "<short-code>", "reason": "<human-readable>"}`:
| 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: <host[:port] from endpoint>
:path: <path from endpoint>
Authorization: Bearer <token from room-info response>
```
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 = [ <room-d-tag> ]
track_name = <speaker-pubkey-hex> (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 `[ <room-d-tag> ]` and publishes objects on track
name `<speaker-pubkey-hex>`. 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 `<service>/<d-tag>` 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
`[<d-tag>] / <speaker-pubkey-hex>` 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", <d-tag> ]` (two elements, `"nests"` prefix).
This NIP specifies `[ <d-tag> ]` (one element). Existing nests servers
SHOULD accept both for a transition period; new deployments SHOULD use the
one-element form.
- A `/api/v1/nests/<d-tag>` 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.
@@ -0,0 +1,384 @@
# 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 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
- 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
**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'
```
**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)
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)
- 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 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)
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).
- 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 |
|---|---|---|
| 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
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 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 |
**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
**None.** Every cryptographic primitive lives in Quartz already:
| 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` |
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).
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
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.
+87
View File
@@ -0,0 +1,87 @@
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)
// 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 {
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)
}
}
}
}
@@ -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() }
}
}
@@ -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
}
@@ -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<Short>(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()
}
}
@@ -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,
)
}
}
}
@@ -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 <base64>` 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 <base64-json>"`).
*
* 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()
}
}
@@ -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)
@@ -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 `<serviceBase>/<roomId>` 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<Long> = listOf(MoqVersion.DRAFT_17),
): NestsListener {
val state =
MutableStateFlow<NestsListenerState>(
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<NestsListenerState>): 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<String, String> {
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
}
@@ -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<NestsListenerState>
/**
* Subscribe to one speaker's audio track. nests publishes each speaker's
* Opus stream under the namespace `["nests", <roomId>]` 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 `<service>/<roomId>` 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<NestsListenerState>,
) : NestsListener {
override val state: StateFlow<NestsListenerState> = 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
}
}
@@ -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 `<service>/api/v1/nests/<roomId>`.
*
* 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<String, JsonElement> = 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"
}
@@ -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
/**
* 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/<room>` 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()
}
/**
* 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.
*/
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,
}
}
@@ -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<MoqObject>` (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<MoqObject>,
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() }
}
}
@@ -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)
@@ -0,0 +1,342 @@
/*
* 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)
MoqMessageType.Subscribe -> decodeSubscribe(reader)
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(
"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)
is Subscribe -> encodeSubscribe(message)
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 {
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<Long>(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<SetupParameter>,
) {
w.writeVarint(params.size.toLong())
for (p in params) {
w.writeVarint(p.key)
w.writeLengthPrefixedBytes(p.value)
}
}
private fun decodeParameters(r: MoqReader): List<SetupParameter> {
val n = r.readVarint().toInt()
if (n < 0) throw MoqCodecException("negative parameter count: $n")
val out = ArrayList<SetupParameter>(n)
repeat(n) {
val key = r.readVarint()
val value = r.readLengthPrefixedBytes()
out.add(SetupParameter(key, value))
}
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<ByteArray>(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())
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,
)
}
@@ -0,0 +1,311 @@
/*
* 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,
) {
Subscribe(0x03),
SubscribeOk(0x04),
SubscribeError(0x05),
Announce(0x06),
AnnounceOk(0x07),
AnnounceError(0x08),
Unannounce(0x09),
Unsubscribe(0x0A),
SubscribeDone(0x0B),
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<Long>,
val parameters: List<SetupParameter> = 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<SetupParameter> = emptyList(),
) : 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<ByteArray>` and provide a [text] helper for
* the common case where every element is UTF-8.
*/
data class TrackNamespace(
val tuple: List<ByteArray>,
) {
fun text(): List<String> = 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<SetupParameter> = 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<SetupParameter> = 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
}
/**
* 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<SetupParameter> = 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
}
@@ -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,
)
}
}
@@ -0,0 +1,448 @@
/*
* 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.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
/**
* 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.
*
* 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 }
/** 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<SetupParameter> = 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<Long, CompletableDeferred<SubscribeOk>>()
/** 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<Long, Channel<MoqObject>>()
/** subscribe_id → track_alias, so [unsubscribe] can clean up the matching sink. */
private val aliasBySubscribeId = HashMap<Long, Long>()
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 starts the
* background pumps.
* Server side: reads exactly one CLIENT_SETUP, selects the first mutually
* 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).
*/
suspend fun setup(
supportedVersions: List<Long>,
clientParameters: List<SetupParameter> = emptyList(),
handshakeTimeoutMs: Long = 10_000,
) {
withTimeout(handshakeTimeoutMs) {
when (role) {
Role.Client -> runClientSetup(supportedVersions, clientParameters)
Role.Server -> runServerSetup(supportedVersions, clientParameters)
}
}
startPumps()
}
private suspend fun runClientSetup(
supportedVersions: List<Long>,
clientParameters: List<SetupParameter>,
) {
controlStream.write(MoqCodec.encode(ClientSetup(supportedVersions, clientParameters)))
val reply = readOneSetupMessage()
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<Long>,
serverParameters: List<SetupParameter>,
) {
val incoming = readOneSetupMessage()
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
}
/**
* 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 readOneSetupMessage(): 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
}
// ---------------------------------------------------------------- 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<SubscribeOk>()
val sink =
Channel<MoqObject>(
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 = "",
) {
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 {
/**
* 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.
*/
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, pumpScope)
}
/**
* Attach to a [WebTransportSession] in the server role over an
* already-accepted control stream. Used in tests — a real server
* accepts the client's first bidi.
*/
fun server(
transport: WebTransportSession,
control: WebTransportBidiStream,
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<MoqObject>,
private val unsubscribeAction: suspend () -> Unit,
) {
suspend fun unsubscribe() = unsubscribeAction()
}
/** Thrown when the peer violates the MoQ-transport state machine. */
class MoqProtocolException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -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,
)
}
@@ -0,0 +1,150 @@
/*
* 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.receiveAsFlow
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 [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<ByteArray>,
private val inboundDatagrams: Channel<ByteArray>,
private val outboundBidiStreams: Channel<FakeBidiStream>,
private val inboundBidiStreams: Channel<FakeBidiStream>,
private val inboundUniStreams: Channel<WebTransportReadStream>,
) : 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<ByteArray>(Channel.BUFFERED)
val peerToLocal = Channel<ByteArray>(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<WebTransportReadStream> = inboundUniStreams.receiveAsFlow()
override suspend fun sendDatagram(payload: ByteArray): Boolean {
if (!open) return false
outboundDatagrams.send(payload)
return true
}
override fun incomingDatagrams(): Flow<ByteArray> = inboundDatagrams.receiveAsFlow()
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<FakeBidiStream> = inboundBidiStreams.receiveAsFlow()
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<FakeWebTransport, FakeWebTransport> {
val aToBDatagrams = Channel<ByteArray>(Channel.BUFFERED)
val bToADatagrams = Channel<ByteArray>(Channel.BUFFERED)
val aToBBidi = Channel<FakeBidiStream>(Channel.BUFFERED)
val bToABidi = Channel<FakeBidiStream>(Channel.BUFFERED)
val aToBUni = Channel<WebTransportReadStream>(Channel.BUFFERED)
val bToAUni = Channel<WebTransportReadStream>(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<ByteArray>,
private val read: Channel<ByteArray>,
) : WebTransportBidiStream {
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
override suspend fun write(chunk: ByteArray) {
write.send(chunk)
}
override suspend fun finish() {
write.close()
}
}
class FakeReadStream internal constructor(
private val read: Channel<ByteArray>,
) : WebTransportReadStream {
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
}
@@ -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<WebTransportReadStream>
/** 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<ByteArray>
/** 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<ByteArray>
}
/** 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/<roomId>` 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,
}
}
@@ -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<NestsListenerState.Connected>(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<NestsListenerState.Closed>(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<NestsListenerState.Failed>(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<NestsListenerState.Failed>(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<NestsListenerState.Failed>(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")
}
}
}
@@ -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<Exception> {
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 "),
)
}
}
@@ -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<AudioException>()
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<IllegalStateException> { sut.play(flowOf()) }
sut.stop()
}
@Test
fun play_after_stop_is_rejected() =
runTest {
val sut =
AudioRoomPlayer(
FakeOpusDecoder { byteToShorts(it) },
FakeAudioPlayer(),
this,
)
sut.stop()
assertFailsWith<IllegalStateException> { 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<MoqObject>(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<ShortArray>()
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<ShortArray>.flatten(): List<Short> = flatMap { sa -> sa.toList() }
@@ -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<Announce>(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<AnnounceOk>(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<AnnounceError>(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<Unannounce>(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<SubscribeDone>(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<Announce>(first.message)
assertIs<AnnounceOk>(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)
}
}
@@ -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<ClientSetup>(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<ClientSetup>(first.message)
val second = MoqCodec.decode(concatenated, offset = first.bytesConsumed)!!
assertEquals(b.size, second.bytesConsumed)
assertIs<ServerSetup>(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<MoqCodecException> { 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<MoqCodecException> { 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)
}
}
@@ -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<MoqCodecException> { MoqObjectDatagram.decode(truncated) }
}
@Test
fun priority_out_of_range_is_rejected_at_construction() {
assertFailsWith<IllegalArgumentException> {
MoqObject(
trackAlias = 1,
groupId = 0,
objectId = 0,
publisherPriority = 256,
payload = ByteArray(0),
)
}
}
}
@@ -0,0 +1,274 @@
/*
* 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.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
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, backgroundScope)
session.setup(listOf(MoqVersion.DRAFT_17))
session.selectedVersion
}
val serverJob =
async {
val control = serverSide.peerOpenedBidiStreams().first()
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))),
)
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, backgroundScope)
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, backgroundScope)
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, backgroundScope)
runCatching { session.setup(listOf(MoqVersion.DRAFT_17)) }
}
val serverJob =
async {
val control = serverSide.peerOpenedBidiStreams().first()
val session = MoqSession.server(serverSide, control, backgroundScope)
assertFailsWith<MoqProtocolException> {
session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11))
}
}
serverJob.await()
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<MoqProtocolException> {
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())
}
}
@@ -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<Subscribe>(decoded))
}
@Test
fun subscribe_rejects_unsupported_filter_at_construction() {
assertFailsWith<IllegalArgumentException> {
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<SubscribeOk>(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<SubscribeOk>(decoded))
}
@Test
fun subscribe_ok_construction_requires_matching_content_exists_and_largest_ids() {
assertFailsWith<IllegalArgumentException> {
SubscribeOk(1, 0, 0, contentExists = true)
}
assertFailsWith<IllegalArgumentException> {
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<SubscribeError>(decoded))
}
@Test
fun unsubscribe_round_trip() {
val msg = Unsubscribe(subscribeId = 9_001)
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!.message
assertEquals(msg, assertIs<Unsubscribe>(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<Subscribe>(first.message)
assertIs<SubscribeOk>(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<MoqCodecException> { MoqCodec.decode(frame.toByteArray()) }
}
}
@@ -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<IllegalArgumentException> { Varint.encode(-1L) }
}
@Test
fun overflow_value_is_rejected() {
assertFailsWith<IllegalArgumentException> { 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)
}
}
@@ -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)))
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
}
@@ -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.nestsclient.transport
/**
* 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].
*
* 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.
*
* ## 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 = <host>` (or `<host>:<port>` if non-default)
* - `:path = <path>` (defaults to `/`, nests uses `/moq`)
* - `Authorization: Bearer <bearerToken>` 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(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession =
throw WebTransportException(
kind = WebTransportException.Kind.NotImplemented,
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).",
)
}
@@ -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")
}
}
@@ -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<WebTransportException> {
factory.connect(
authority = "nostrnests.com",
path = "/moq",
bearerToken = "ignored",
)
}
assertEquals(WebTransportException.Kind.NotImplemented, ex.kind)
}
}
@@ -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<MeetingRoomPresenceEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(root.room() ?: ALT)
roomMeeting(MeetingSpaceTag(root.address(), root.relays().firstOrNull()))
handRaised?.let { handRaised(it) }
muted?.let { muted(it) }
initializer()
}
}
}
@@ -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<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised))
fun TagArrayBuilder<MeetingRoomPresenceEvent>.muted(muted: Boolean) = addUnique(MutedTag.assemble(muted))
@@ -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<String>): 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")
}
}
+1
View File
@@ -36,5 +36,6 @@ include ':benchmark'
include ':quartz'
include ':commons'
include ':ammolite'
include ':nestsClient'
include ':desktopApp'
include ':cli'