mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
Merge pull request #2494 from vitorpamplona/claude/clubhouse-event-listing-Kta46
Add MoQ-transport client and audio rooms support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+3
@@ -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) }
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+6
-1
@@ -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()
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+149
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+103
@@ -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() }
|
||||
}
|
||||
}
|
||||
+243
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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() }
|
||||
}
|
||||
}
|
||||
+136
@@ -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
|
||||
}
|
||||
+164
@@ -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()
|
||||
}
|
||||
}
|
||||
+139
@@ -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,
|
||||
}
|
||||
}
|
||||
+112
@@ -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,
|
||||
)
|
||||
}
|
||||
+150
@@ -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()
|
||||
}
|
||||
+126
@@ -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 "),
|
||||
)
|
||||
}
|
||||
}
|
||||
+225
@@ -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() }
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -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())
|
||||
}
|
||||
}
|
||||
+159
@@ -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)
|
||||
}
|
||||
}
|
||||
+79
@@ -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)))
|
||||
}
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -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")
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+45
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,6 @@ include ':benchmark'
|
||||
include ':quartz'
|
||||
include ':commons'
|
||||
include ':ammolite'
|
||||
include ':nestsClient'
|
||||
include ':desktopApp'
|
||||
include ':cli'
|
||||
|
||||
Reference in New Issue
Block a user