From 7aec2bfbb905e8b86b7febd0d97304484b0ffe4d Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Sun, 22 Sep 2024 13:32:20 -0500 Subject: [PATCH] feat: Add a "connect" extension to colibri2. --- .../colibri2/ConferenceModifyIQ.java | 27 +++++ .../extensions/colibri2/IqProviderUtils.java | 2 + .../jitsi/xmpp/extensions/colibri2/Connect.kt | 100 +++++++++++++++++ .../xmpp/extensions/colibri2/Connects.kt | 32 ++++++ .../colibri2/json/Colibri2JSONDeserializer.kt | 29 +++++ .../colibri2/json/Colibri2JSONSerializer.kt | 18 ++++ .../xmpp/extensions/colibri2/ConnectTest.kt | 102 ++++++++++++++++++ .../json/Colibri2JSONSerializerTest.kt | 8 ++ 8 files changed, 318 insertions(+) create mode 100644 src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connect.kt create mode 100644 src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connects.kt create mode 100644 src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/ConnectTest.kt diff --git a/src/main/java/org/jitsi/xmpp/extensions/colibri2/ConferenceModifyIQ.java b/src/main/java/org/jitsi/xmpp/extensions/colibri2/ConferenceModifyIQ.java index 875ea3f..d40eff6 100644 --- a/src/main/java/org/jitsi/xmpp/extensions/colibri2/ConferenceModifyIQ.java +++ b/src/main/java/org/jitsi/xmpp/extensions/colibri2/ConferenceModifyIQ.java @@ -95,6 +95,10 @@ private ConferenceModifyIQ(Builder b) rtcstatsEnabled = b.rtcstatsEnabled; create = b.create; expire = b.expire; + if (b.connects != null) + { + addExtension(b.connects); + } if (b.meetingId == null) { @@ -170,6 +174,12 @@ public boolean getExpire() return expire; } + @Nullable + public Connects getConnects() + { + return getExtension(Connects.class); + } + @Contract("_ -> new") public static @NotNull Builder builder(XMPPConnection connection) { @@ -196,6 +206,7 @@ public static final class Builder private boolean expire = EXPIRE_DEFAULT; private String conferenceName; private String meetingId; + private Connects connects = null; private Builder(IqData iqCommon) { @@ -218,6 +229,22 @@ public Builder setRtcstatsEnabled(boolean rtcstatsEnabled) return this; } + public Builder setEmptyConnects() + { + connects = new Connects(); + return this; + } + + public Builder addConnect(@NotNull Connect connect) + { + if (connects == null) + { + connects = new Connects(); + } + connects.addConnect(connect); + return this; + } + public Builder setConferenceName(String name) { conferenceName = name; diff --git a/src/main/java/org/jitsi/xmpp/extensions/colibri2/IqProviderUtils.java b/src/main/java/org/jitsi/xmpp/extensions/colibri2/IqProviderUtils.java index bf98154..bbd735e 100644 --- a/src/main/java/org/jitsi/xmpp/extensions/colibri2/IqProviderUtils.java +++ b/src/main/java/org/jitsi/xmpp/extensions/colibri2/IqProviderUtils.java @@ -176,6 +176,8 @@ private static void doRegisterProviders() /* Original colibri does something weird with these elements' namespaces, so register them here. */ ProviderManager.addExtensionProvider(ForceMute.ELEMENT, ForceMute.NAMESPACE, new ForceMute.Provider()); ProviderManager.addExtensionProvider(InitialLastN.ELEMENT, InitialLastN.NAMESPACE, new InitialLastNProvider()); + ProviderManager.addExtensionProvider(Connect.ELEMENT, Connect.NAMESPACE, new ConnectProvider()); + ProviderManager.addExtensionProvider(Connects.ELEMENT, Connects.NAMESPACE, new ConnectsProvider()); ProviderManager.addExtensionProvider(Capability.ELEMENT, Capability.NAMESPACE, new Capability.Provider()); ProviderManager.addExtensionProvider(Sctp.ELEMENT, Sctp.NAMESPACE, new Sctp.Provider()); ProviderManager.addExtensionProvider(Colibri2Error.ELEMENT, diff --git a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connect.kt b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connect.kt new file mode 100644 index 0000000..34bb4f3 --- /dev/null +++ b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connect.kt @@ -0,0 +1,100 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.xmpp.extensions.colibri2 + +import org.jitsi.xmpp.extensions.AbstractPacketExtension +import org.jitsi.xmpp.extensions.DefaultPacketExtensionProvider +import org.jivesoftware.smack.packet.XmlEnvironment +import org.jivesoftware.smack.parsing.SmackParsingException +import org.jivesoftware.smack.xml.XmlPullParser +import org.jivesoftware.smack.xml.XmlPullParserException +import java.io.IOException +import java.net.URI + +class Connect( + val url: URI, + val protocol: Protocols, + val type: Types, + audio: Boolean = false, + video: Boolean = false +) : AbstractPacketExtension(NAMESPACE, ELEMENT) { + init { + setAttribute(URL_ATTR_NAME, url) + setAttribute(PROTOCOL_ATTR_NAME, protocol.toString().lowercase()) + setAttribute(TYPE_ATTR_NAME, type.toString().lowercase()) + if (audio) { + setAttribute(AUDIO_ATTR_NAME, true) + } + if (video) { + setAttribute(VIDEO_ATTR_NAME, true) + } + } + + val audio: Boolean + get() = getAttributeAsString(AUDIO_ATTR_NAME)?.toBoolean() ?: false + val video: Boolean + get() = getAttributeAsString(VIDEO_ATTR_NAME)?.toBoolean() ?: false + + enum class Protocols(val value: String) { + MEDIAJSON("mediajson") + } + + enum class Types(val value: String) { + RECORDER("recorder"), + TRANSCRIBER("transcriber") + } + + companion object { + const val ELEMENT = "connect" + const val NAMESPACE = ConferenceModifyIQ.NAMESPACE + const val URL_ATTR_NAME = "url" + const val PROTOCOL_ATTR_NAME = "protocol" + const val TYPE_ATTR_NAME = "type" + const val AUDIO_ATTR_NAME = "audio" + const val VIDEO_ATTR_NAME = "video" + } +} + +class ConnectProvider : DefaultPacketExtensionProvider(Connect::class.java) { + @Throws(XmlPullParserException::class, IOException::class, SmackParsingException::class) + override fun parse(parser: XmlPullParser, depth: Int, xml: XmlEnvironment?): Connect { + val url = parser.getAttributeValue("", Connect.URL_ATTR_NAME) + ?: throw SmackParsingException.RequiredAttributeMissingException("Missing 'url' attribute") + val uri = try { + URI(url) + } catch (e: Exception) { + throw SmackParsingException("Invalid 'url': ${e.message}") + } + val audio = parser.getAttributeValue("", Connect.AUDIO_ATTR_NAME)?.toBoolean() ?: false + val video = parser.getAttributeValue("", Connect.VIDEO_ATTR_NAME)?.toBoolean() ?: false + val protocolStr = parser.getAttributeValue("", Connect.PROTOCOL_ATTR_NAME) + ?: throw SmackParsingException.RequiredAttributeMissingException("Missing 'protocol' attribute") + val protocol = try { + Connect.Protocols.valueOf(protocolStr.uppercase()) + } catch (e: Exception) { + throw SmackParsingException("Invalid 'protocol': $protocolStr") + } + val typeStr = parser.getAttributeValue("", Connect.TYPE_ATTR_NAME) + ?: throw SmackParsingException.RequiredAttributeMissingException("Missing 'type' attribute") + val type = try { + Connect.Types.valueOf(typeStr.uppercase()) + } catch (e: Exception) { + throw SmackParsingException("Invalid 'type': $typeStr") + } + + return Connect(url = uri, protocol = protocol, type = type, audio = audio, video = video) + } +} diff --git a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connects.kt b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connects.kt new file mode 100644 index 0000000..325f1a0 --- /dev/null +++ b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connects.kt @@ -0,0 +1,32 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.xmpp.extensions.colibri2 + +import org.jitsi.xmpp.extensions.AbstractPacketExtension +import org.jitsi.xmpp.extensions.DefaultPacketExtensionProvider + +class Connects : AbstractPacketExtension(NAMESPACE, ELEMENT) { + + fun getConnects(): List = getChildExtensionsOfType(Connect::class.java) + fun addConnect(connect: Connect) = addChildExtension(connect) + + companion object { + const val ELEMENT = "connects" + const val NAMESPACE = ConferenceModifyIQ.NAMESPACE + } +} + +class ConnectsProvider : DefaultPacketExtensionProvider(Connects::class.java) diff --git a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONDeserializer.kt b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONDeserializer.kt index 41338d5..a79a284 100644 --- a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONDeserializer.kt +++ b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONDeserializer.kt @@ -23,6 +23,8 @@ import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.Connect +import org.jitsi.xmpp.extensions.colibri2.Connects import org.jitsi.xmpp.extensions.colibri2.Endpoints import org.jitsi.xmpp.extensions.colibri2.ForceMute import org.jitsi.xmpp.extensions.colibri2.InitialLastN @@ -37,6 +39,7 @@ import org.jivesoftware.smackx.muc.MUCRole import org.json.simple.JSONArray import org.json.simple.JSONObject import java.lang.IllegalArgumentException +import java.net.URI object Colibri2JSONDeserializer { private fun deserializeMedia(media: JSONObject): Media { @@ -356,6 +359,32 @@ object Colibri2JSONDeserializer { setRtcstatsEnabled(it) } } + + conferenceModify[Connects.ELEMENT]?.let { + if (it is JSONArray) { + var added = false + it.forEach { connect -> + if (connect is JSONObject) { + addConnect( + Connect( + URI(connect[Connect.URL_ATTR_NAME] as String), + protocol = Connect.Protocols.valueOf( + (connect[Connect.PROTOCOL_ATTR_NAME] as String).uppercase() + ), + type = Connect.Types.valueOf( + (connect[Connect.TYPE_ATTR_NAME] as String).uppercase() + ), + audio = connect[Connect.AUDIO_ATTR_NAME]?.toString()?.toBoolean() ?: false, + video = connect[Connect.VIDEO_ATTR_NAME]?.toString()?.toBoolean() ?: false + ) + ) + added = true + } + } + // An empty array is distinct from no value specified. + if (!added) setEmptyConnects() + } + } } } diff --git a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializer.kt b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializer.kt index 2f26168..d9e75fd 100644 --- a/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializer.kt +++ b/src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializer.kt @@ -24,6 +24,8 @@ import org.jitsi.xmpp.extensions.colibri2.Colibri2Endpoint import org.jitsi.xmpp.extensions.colibri2.Colibri2Relay import org.jitsi.xmpp.extensions.colibri2.ConferenceModifiedIQ import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ +import org.jitsi.xmpp.extensions.colibri2.Connect +import org.jitsi.xmpp.extensions.colibri2.Connects import org.jitsi.xmpp.extensions.colibri2.ForceMute import org.jitsi.xmpp.extensions.colibri2.InitialLastN import org.jitsi.xmpp.extensions.colibri2.Media @@ -231,6 +233,18 @@ object Colibri2JSONSerializer { } } + private fun serializeConnect(connect: Connect) = JSONObject().apply { + put(Connect.URL_ATTR_NAME, connect.url.toString()) + put(Connect.PROTOCOL_ATTR_NAME, connect.protocol.toString().lowercase()) + put(Connect.TYPE_ATTR_NAME, connect.type.toString().lowercase()) + if (connect.audio) put(Connect.AUDIO_ATTR_NAME, true) + if (connect.video) put(Connect.VIDEO_ATTR_NAME, true) + } + + private fun serializeConnects(connects: Connects) = JSONArray().apply { + connects.getConnects().forEach { add(serializeConnect(it)) } + } + @JvmStatic fun serializeConferenceModify(iq: ConferenceModifyIQ): JSONObject { return serializeAbstractConferenceModificationIQ(iq).apply { @@ -246,6 +260,10 @@ object Colibri2JSONSerializer { put(ConferenceModifyIQ.RTCSTATS_ENABLED_ATTR_NAME, iq.isRtcstatsEnabled) } + iq.connects?.let { + put(Connects.ELEMENT, serializeConnects(it)) + } + put(ConferenceModifyIQ.MEETING_ID_ATTR_NAME, iq.meetingId) iq.conferenceName?.let { put(ConferenceModifyIQ.NAME_ATTR_NAME, it) } diff --git a/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/ConnectTest.kt b/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/ConnectTest.kt new file mode 100644 index 0000000..735bf61 --- /dev/null +++ b/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/ConnectTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright @ 2024 - present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.xmpp.extensions.colibri2 + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.jivesoftware.smack.parsing.SmackParsingException +import org.jivesoftware.smack.util.PacketParserUtils +import java.net.URI + +class ConnectTest : ShouldSpec() { + init { + IqProviderUtils.registerProviders() + val provider = ConnectProvider() + val url = "ws://example.com" + + context("Parsing a valid extension") { + context("Without audio/video") { + val connect = provider.parse( + PacketParserUtils.getParserFor("") + ) + connect.url shouldBe URI(url) + connect.protocol shouldBe Connect.Protocols.MEDIAJSON + connect.type shouldBe Connect.Types.RECORDER + connect.audio shouldBe false + connect.video shouldBe false + } + context("With audio") { + val connect = provider.parse( + PacketParserUtils.getParserFor( + "" + ) + ) + connect.url shouldBe URI(url) + connect.protocol shouldBe Connect.Protocols.MEDIAJSON + connect.type shouldBe Connect.Types.RECORDER + connect.audio shouldBe true + connect.video shouldBe false + } + context("With video") { + val connect = provider.parse( + PacketParserUtils.getParserFor( + "" + ) + ) + connect.url shouldBe URI(url) + connect.protocol shouldBe Connect.Protocols.MEDIAJSON + connect.type shouldBe Connect.Types.TRANSCRIBER + connect.audio shouldBe false + connect.video shouldBe true + } + } + context("Parsing with missing url") { + shouldThrow { + provider.parse( + PacketParserUtils.getParserFor("") + ) + } + } + context("Parsing with invalid url") { + shouldThrow { + provider.parse( + PacketParserUtils.getParserFor("") + ) + } + } + context("Parsing with missing protocol") { + shouldThrow { + provider.parse(PacketParserUtils.getParserFor("")) + } + } + context("Parsing with invalid protocol") { + shouldThrow { + provider.parse(PacketParserUtils.getParserFor("")) + } + } + context("Parsing with missing type") { + shouldThrow { + provider.parse(PacketParserUtils.getParserFor("")) + } + } + context("Parsing with invalid type") { + shouldThrow { + provider.parse(PacketParserUtils.getParserFor("")) + } + } + } +} diff --git a/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializerTest.kt b/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializerTest.kt index 648ba60..908073d 100644 --- a/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializerTest.kt +++ b/src/test/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializerTest.kt @@ -192,6 +192,10 @@ private val expectedMappings = listOf( + + + + """, @@ -278,6 +282,10 @@ private val expectedMappings = listOf( "transport": { "ice-controlling": true }, "capabilities": [ "source-names" ] } + ], + "connects": [ + { "url": "wss://example.com/audio", "protocol": "mediajson", "type": "transcriber", "audio": true }, + { "url": "wss://example.com/video", "protocol":"mediajson", "type": "recorder", "video": true } ] } """,