Skip to content

Commit 4281255

Browse files
Add connect exports/requests + translator type, and a media-source synthetic flag (#142)
* feat: Add exports/requests source-name lists and translator type to connect Extend the colibri2 <connect> element with optional <exports> and <requests> containers, each holding <export>/<request> items that reference a source by its 'name' attribute (the source-name idiom used by Capability/SourcePacketExtension). Also add a "translator" connect type alongside recorder/transcriber. Wire both through the XML provider and the Jackson-based JSON serializer/deserializer (exports/requests as JSON string arrays). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Add a synthetic boolean flag to media-source Add an optional 'synthetic' attribute to the colibri2 <media-source> element, marking sources generated by the bridge rather than sent by an endpoint. The attribute is only emitted when true (defaulting to false), and is wired through the builder and the JSON serializer/deserializer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe4b514 commit 4281255

7 files changed

Lines changed: 385 additions & 2 deletions

File tree

src/main/java/org/jitsi/xmpp/extensions/colibri2/MediaSource.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ public class MediaSource
5959
*/
6060
public static final String TYPE_ATTR_NAME = "type";
6161

62+
/**
63+
* The name of the <tt>synthetic</tt> attribute.
64+
*/
65+
public static final String SYNTHETIC_ATTR_NAME = "synthetic";
66+
6267
/**
6368
* Construct a MediaSource. Needs to be public for DefaultPacketExtensionProvider to work.
6469
*/
@@ -86,6 +91,11 @@ private MediaSource(Builder b)
8691
}
8792
setAttribute(ID_NAME, b.id);
8893

94+
if (b.synthetic)
95+
{
96+
setAttribute(SYNTHETIC_ATTR_NAME, true);
97+
}
98+
8999
for (SourcePacketExtension s: b.sources)
90100
{
91101
addChildExtension(s);
@@ -113,6 +123,14 @@ private MediaSource(Builder b)
113123
return MediaType.parseString(getAttributeAsString(TYPE_ATTR_NAME));
114124
}
115125

126+
/**
127+
* Whether this is a synthetic source (i.e. one generated by the bridge rather than sent by an endpoint).
128+
*/
129+
public boolean isSynthetic()
130+
{
131+
return Boolean.parseBoolean(getAttributeAsString(SYNTHETIC_ATTR_NAME));
132+
}
133+
116134
/**
117135
* Get the sources of this media source.
118136
*/
@@ -153,6 +171,11 @@ public static final class Builder
153171
*/
154172
String id = null;
155173

174+
/**
175+
* Whether the media source being built is synthetic.
176+
*/
177+
boolean synthetic = false;
178+
156179
/**
157180
* The <tt>source</tt> elements defined by XEP-0339: Source-Specific
158181
* Media Attributes associated with this <tt>media-source</tt>.
@@ -183,6 +206,15 @@ public Builder setId(@NotNull String id)
183206
return this;
184207
}
185208

209+
/**
210+
* Sets whether the media source being built is synthetic.
211+
*/
212+
public Builder setSynthetic(boolean synthetic)
213+
{
214+
this.synthetic = synthetic;
215+
return this;
216+
}
217+
186218
/**
187219
* Adds a payload type to the media being built.
188220
*/

src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/Connect.kt

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ class Connect(
6565
}
6666
fun removePing() = getPing()?.let { removeChildExtension(it) }
6767

68+
fun getExports(): List<String> = getChildExtension(Exports::class.java)?.getNames() ?: emptyList()
69+
fun addExport(name: String) {
70+
val exports = getChildExtension(Exports::class.java) ?: Exports().also { addChildExtension(it) }
71+
exports.addExport(name)
72+
}
73+
fun setExports(names: List<String>) {
74+
getChildExtension(Exports::class.java)?.let { removeChildExtension(it) }
75+
if (names.isNotEmpty()) {
76+
addChildExtension(Exports(names))
77+
}
78+
}
79+
80+
fun getRequests(): List<String> = getChildExtension(Requests::class.java)?.getNames() ?: emptyList()
81+
fun addRequest(name: String) {
82+
val requests = getChildExtension(Requests::class.java) ?: Requests().also { addChildExtension(it) }
83+
requests.addRequest(name)
84+
}
85+
fun setRequests(names: List<String>) {
86+
getChildExtension(Requests::class.java)?.let { removeChildExtension(it) }
87+
if (names.isNotEmpty()) {
88+
addChildExtension(Requests(names))
89+
}
90+
}
91+
6892
class HttpHeader(val name: String, val value: String) : AbstractPacketExtension(NAMESPACE, ELEMENT) {
6993
init {
7094
setAttribute(NAME_ATTR_NAME, name)
@@ -91,13 +115,72 @@ class Connect(
91115
}
92116
}
93117

118+
/** A container for the source names this connection should export (send out). */
119+
class Exports() : AbstractPacketExtension(NAMESPACE, ELEMENT) {
120+
constructor(names: List<String>) : this() {
121+
names.forEach { addChildExtension(Export(it)) }
122+
}
123+
124+
fun getNames(): List<String> = getChildExtensionsOfType(Export::class.java).map { it.name }
125+
fun addExport(name: String) = addChildExtension(Export(name))
126+
127+
companion object {
128+
const val ELEMENT = "exports"
129+
}
130+
}
131+
132+
/** A single exported source, identified by its source name. */
133+
class Export(name: String) : AbstractPacketExtension(NAMESPACE, ELEMENT) {
134+
init {
135+
setAttribute(NAME_ATTR_NAME, name)
136+
}
137+
138+
val name: String
139+
get() = getAttributeAsString(NAME_ATTR_NAME)
140+
141+
companion object {
142+
const val ELEMENT = "export"
143+
const val NAME_ATTR_NAME = "name"
144+
}
145+
}
146+
147+
/** A container for the source names this connection requests (wants to receive). */
148+
class Requests() : AbstractPacketExtension(NAMESPACE, ELEMENT) {
149+
constructor(names: List<String>) : this() {
150+
names.forEach { addChildExtension(Request(it)) }
151+
}
152+
153+
fun getNames(): List<String> = getChildExtensionsOfType(Request::class.java).map { it.name }
154+
fun addRequest(name: String) = addChildExtension(Request(name))
155+
156+
companion object {
157+
const val ELEMENT = "requests"
158+
}
159+
}
160+
161+
/** A single requested source, identified by its source name. */
162+
class Request(name: String) : AbstractPacketExtension(NAMESPACE, ELEMENT) {
163+
init {
164+
setAttribute(NAME_ATTR_NAME, name)
165+
}
166+
167+
val name: String
168+
get() = getAttributeAsString(NAME_ATTR_NAME)
169+
170+
companion object {
171+
const val ELEMENT = "request"
172+
const val NAME_ATTR_NAME = "name"
173+
}
174+
}
175+
94176
enum class Protocols(val value: String) {
95177
MEDIAJSON("mediajson")
96178
}
97179

98180
enum class Types(val value: String) {
99181
RECORDER("recorder"),
100-
TRANSCRIBER("transcriber")
182+
TRANSCRIBER("transcriber"),
183+
TRANSLATOR("translator")
101184
}
102185

103186
companion object {
@@ -186,6 +269,16 @@ class ConnectProvider : DefaultPacketExtensionProvider<Connect>(Connect::class.j
186269
}
187270
connect.setPing(interval, timeout)
188271
}
272+
Connect.Exports.ELEMENT -> {
273+
connect.setExports(
274+
parseSourceNames(parser, Connect.Exports.ELEMENT, Connect.Export.ELEMENT)
275+
)
276+
}
277+
Connect.Requests.ELEMENT -> {
278+
connect.setRequests(
279+
parseSourceNames(parser, Connect.Requests.ELEMENT, Connect.Request.ELEMENT)
280+
)
281+
}
189282
}
190283
}
191284
XmlPullParser.Event.END_ELEMENT -> {
@@ -199,4 +292,34 @@ class ConnectProvider : DefaultPacketExtensionProvider<Connect>(Connect::class.j
199292

200293
return connect
201294
}
295+
296+
/**
297+
* Parses a container element (e.g. <exports>) holding a list of source-name items (e.g. <export name='...'/>),
298+
* returning the list of source names. The parser is left positioned on the container's end element.
299+
*/
300+
@Throws(XmlPullParserException::class, IOException::class, SmackParsingException::class)
301+
private fun parseSourceNames(parser: XmlPullParser, containerElement: String, itemElement: String): List<String> {
302+
val names = mutableListOf<String>()
303+
var done = false
304+
while (!done) {
305+
when (parser.next()) {
306+
XmlPullParser.Event.START_ELEMENT -> {
307+
if (parser.name == itemElement) {
308+
val name = parser.getAttributeValue("", Connect.Export.NAME_ATTR_NAME)
309+
?: throw SmackParsingException.RequiredAttributeMissingException(
310+
"Missing 'name' attribute in $itemElement element"
311+
)
312+
names.add(name)
313+
}
314+
}
315+
XmlPullParser.Event.END_ELEMENT -> {
316+
if (parser.name == containerElement) {
317+
done = true
318+
}
319+
}
320+
else -> { /* ignore */ }
321+
}
322+
}
323+
return names
324+
}
202325
}

src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONDeserializer.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ object Colibri2JSONDeserializer {
124124
setId(it.asText())
125125
}
126126

127+
mediaSource[MediaSource.SYNTHETIC_ATTR_NAME]?.let {
128+
require(it.isBoolean) { "Expected boolean for ${MediaSource.SYNTHETIC_ATTR_NAME}, got ${it.nodeType}" }
129+
setSynthetic(it.asBoolean())
130+
}
131+
127132
mediaSource[Colibri2JSONSerializer.SOURCES]?.let { sources ->
128133
require(sources is ArrayNode) { "Expected array for sources, got ${sources.nodeType}" }
129134
sources.forEach { addSource(JSONDeserializer.deserializeSource(it)) }
@@ -396,6 +401,32 @@ object Colibri2JSONDeserializer {
396401
}
397402
}
398403

404+
// Deserialize exports
405+
connect[Connect.Exports.ELEMENT]?.let { exports ->
406+
require(exports is ArrayNode) {
407+
"Expected array for ${Connect.Exports.ELEMENT}, got ${exports.nodeType}"
408+
}
409+
exports.forEach { export ->
410+
require(export.isTextual) {
411+
"Expected string for ${Connect.Export.ELEMENT}, got ${export.nodeType}"
412+
}
413+
connectObj.addExport(export.asText())
414+
}
415+
}
416+
417+
// Deserialize requests
418+
connect[Connect.Requests.ELEMENT]?.let { requests ->
419+
require(requests is ArrayNode) {
420+
"Expected array for ${Connect.Requests.ELEMENT}, got ${requests.nodeType}"
421+
}
422+
requests.forEach { request ->
423+
require(request.isTextual) {
424+
"Expected string for ${Connect.Request.ELEMENT}, got ${request.nodeType}"
425+
}
426+
connectObj.addRequest(request.asText())
427+
}
428+
}
429+
399430
addConnect(connectObj)
400431
added = true
401432
}

src/main/kotlin/org/jitsi/xmpp/extensions/colibri2/json/Colibri2JSONSerializer.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ object Colibri2JSONSerializer {
132132
return JsonNodeFactory.instance.objectNode().apply {
133133
put(MediaSource.TYPE_ATTR_NAME, source.type.toString())
134134
put(MediaSource.ID_NAME, source.id)
135+
if (source.isSynthetic) put(MediaSource.SYNTHETIC_ATTR_NAME, true)
135136
if (source.sources.isNotEmpty()) {
136137
set<ObjectNode>(SOURCES, JSONSerializer.serializeSources(source.sources))
137138
}
@@ -258,6 +259,22 @@ object Colibri2JSONSerializer {
258259
pingObj.put(Connect.Ping.TIMEOUT_ATTR_NAME, ping.timeout)
259260
set<ObjectNode>("ping", pingObj)
260261
}
262+
263+
// Serialize exports
264+
val exports = connect.getExports()
265+
if (exports.isNotEmpty()) {
266+
val exportsArray = JsonNodeFactory.instance.arrayNode()
267+
exports.forEach { exportsArray.add(it) }
268+
set<ArrayNode>(Connect.Exports.ELEMENT, exportsArray)
269+
}
270+
271+
// Serialize requests
272+
val requests = connect.getRequests()
273+
if (requests.isNotEmpty()) {
274+
val requestsArray = JsonNodeFactory.instance.arrayNode()
275+
requests.forEach { requestsArray.add(it) }
276+
set<ArrayNode>(Connect.Requests.ELEMENT, requestsArray)
277+
}
261278
}
262279

263280
private fun serializeConnects(connects: Connects) = JsonNodeFactory.instance.arrayNode().apply {

src/test/java/org/jitsi/xmpp/extensions/colibri2/MediaSourceTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
*/
1616
package org.jitsi.xmpp.extensions.colibri2;
1717

18+
import org.jitsi.utils.*;
1819
import org.jivesoftware.smack.util.*;
1920
import org.junit.jupiter.api.*;
2021

22+
import static org.junit.jupiter.api.Assertions.assertFalse;
2123
import static org.junit.jupiter.api.Assertions.assertNotNull;
2224
import static org.junit.jupiter.api.Assertions.assertThrows;
25+
import static org.junit.jupiter.api.Assertions.assertTrue;
2326

2427
public class MediaSourceTest
2528
{
@@ -50,4 +53,28 @@ public void parsingTest()
5053
"An invalid type must result in an exception"
5154
);
5255
}
56+
57+
@Test
58+
public void syntheticTest()
59+
throws Exception
60+
{
61+
MediaSource notSynthetic =
62+
provider.parse(PacketParserUtils.getParserFor("<media-source type='audio' id='id'/>"));
63+
assertFalse(notSynthetic.isSynthetic(), "Synthetic must default to false when the attribute is absent");
64+
65+
MediaSource synthetic =
66+
provider.parse(PacketParserUtils.getParserFor("<media-source type='audio' id='id' synthetic='true'/>"));
67+
assertTrue(synthetic.isSynthetic());
68+
69+
MediaSource built = MediaSource.getBuilder()
70+
.setType(MediaType.AUDIO)
71+
.setId("id")
72+
.setSynthetic(true)
73+
.build();
74+
assertTrue(built.isSynthetic());
75+
76+
// The attribute is only emitted when true, so it round-trips through XML.
77+
MediaSource reparsed = provider.parse(PacketParserUtils.getParserFor(built.toXML().toString()));
78+
assertTrue(reparsed.isSynthetic());
79+
}
5380
}

0 commit comments

Comments
 (0)