1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4import android.util.Pair;
5import androidx.annotation.NonNull;
6import com.google.common.base.CharMatcher;
7import com.google.common.base.Joiner;
8import com.google.common.base.Strings;
9import com.google.common.collect.ArrayListMultimap;
10import com.google.common.collect.ImmutableList;
11import com.google.common.collect.ImmutableMultimap;
12import com.google.common.collect.Multimap;
13import eu.siacs.conversations.Config;
14import eu.siacs.conversations.xml.Namespace;
15import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
16import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
17import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
18import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
19import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
20import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
21import java.util.Collection;
22import java.util.Collections;
23import java.util.List;
24import java.util.Locale;
25import java.util.Map;
26
27public class SessionDescription {
28
29 public static final String LINE_DIVIDER = "\r\n";
30 private static final String HARDCODED_MEDIA_PROTOCOL =
31 "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
32 private static final String HARDCODED_APPLICATION_PROTOCOL = "UDP/DTLS/SCTP";
33 private static final String FORMAT_WEBRTC_DATA_CHANNEL = "webrtc-datachannel";
34 private static final int HARDCODED_MEDIA_PORT = 9;
35 private static final Collection<String> HARDCODED_ICE_OPTIONS =
36 Collections.singleton("trickle");
37 private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
38
39 public final int version;
40 public final String name;
41 public final String connectionData;
42 public final ArrayListMultimap<String, String> attributes;
43 public final List<Media> media;
44
45 public SessionDescription(
46 int version,
47 String name,
48 String connectionData,
49 ArrayListMultimap<String, String> attributes,
50 List<Media> media) {
51 this.version = version;
52 this.name = name;
53 this.connectionData = connectionData;
54 this.attributes = attributes;
55 this.media = media;
56 }
57
58 private static void appendAttributes(StringBuilder s, Multimap<String, String> attributes) {
59 for (final Map.Entry<String, String> attribute : attributes.entries()) {
60 final String key = attribute.getKey();
61 final String value = attribute.getValue();
62 s.append("a=").append(key);
63 if (!Strings.isNullOrEmpty(value)) {
64 s.append(':').append(value);
65 }
66 s.append(LINE_DIVIDER);
67 }
68 }
69
70 public static SessionDescription parse(final String input) {
71 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
72 MediaBuilder currentMediaBuilder = null;
73 ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
74 ImmutableList.Builder<Media> mediaBuilder = new ImmutableList.Builder<>();
75 for (final String line : input.split(LINE_DIVIDER)) {
76 final String[] pair = line.trim().split("=", 2);
77 if (pair.length < 2 || pair[0].length() != 1) {
78 Log.d(Config.LOGTAG, "skipping sdp parsing on line " + line);
79 continue;
80 }
81 final char key = pair[0].charAt(0);
82 final String value = pair[1];
83 switch (key) {
84 case 'v' -> sessionDescriptionBuilder.setVersion(ignorantIntParser(value));
85 case 'c' -> {
86 if (currentMediaBuilder != null) {
87 currentMediaBuilder.setConnectionData(value);
88 } else {
89 sessionDescriptionBuilder.setConnectionData(value);
90 }
91 }
92 case 's' -> sessionDescriptionBuilder.setName(value);
93 case 'a' -> {
94 final Pair<String, String> attribute = parseAttribute(value);
95 attributeMap.put(attribute.first, attribute.second);
96 }
97 case 'm' -> {
98 if (currentMediaBuilder == null) {
99 sessionDescriptionBuilder.setAttributes(attributeMap);
100 } else {
101 currentMediaBuilder.setAttributes(attributeMap);
102 mediaBuilder.add(currentMediaBuilder.createMedia());
103 }
104 attributeMap = ArrayListMultimap.create();
105 currentMediaBuilder = new MediaBuilder();
106 final String[] parts = value.split(" ");
107 if (parts.length >= 3) {
108 currentMediaBuilder.setMedia(parts[0]);
109 currentMediaBuilder.setPort(ignorantIntParser(parts[1]));
110 currentMediaBuilder.setProtocol(parts[2]);
111 ImmutableList.Builder<Integer> formats = new ImmutableList.Builder<>();
112 for (int i = 3; i < parts.length; ++i) {
113 formats.add(ignorantIntParser(parts[i]));
114 }
115 currentMediaBuilder.setFormats(formats.build());
116 } else {
117 Log.d(Config.LOGTAG, "skipping media line " + line);
118 }
119 }
120 }
121 }
122 if (currentMediaBuilder != null) {
123 currentMediaBuilder.setAttributes(attributeMap);
124 mediaBuilder.add(currentMediaBuilder.createMedia());
125 } else {
126 sessionDescriptionBuilder.setAttributes(attributeMap);
127 }
128 sessionDescriptionBuilder.setMedia(mediaBuilder.build());
129 return sessionDescriptionBuilder.createSessionDescription();
130 }
131
132 public static SessionDescription of(final FileTransferContentMap contentMap) {
133 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
134 final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
135 final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
136
137 final Group group = contentMap.group;
138 if (group != null) {
139 final String semantics = group.getSemantics();
140 checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
141 final var idTags = group.getIdentificationTags();
142 for (final String content : idTags) {
143 checkNoWhitespace(content, "group content names must not contain any whitespace");
144 }
145 attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
146 }
147
148 // TODO my-media-stream can be removed I think
149 attributeMap.put("msid-semantic", " WMS my-media-stream");
150
151 for (final Map.Entry<
152 String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
153 entry : contentMap.contents.entrySet()) {
154 final var dt = entry.getValue();
155 final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo;
156 if (dt.transport instanceof WebRTCDataChannelTransportInfo transportInfo) {
157 webRTCDataChannelTransportInfo = transportInfo;
158 } else {
159 throw new IllegalArgumentException("Transport is not of type WebRTCDataChannel");
160 }
161 final String name = entry.getKey();
162 checkNoWhitespace(name, "content name must not contain any whitespace");
163
164 final MediaBuilder mediaBuilder = new MediaBuilder();
165 mediaBuilder.setMedia("application");
166 mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
167 mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
168 mediaBuilder.setProtocol(HARDCODED_APPLICATION_PROTOCOL);
169 mediaBuilder.setAttributes(
170 transportInfoMediaAttributes(webRTCDataChannelTransportInfo));
171 mediaBuilder.setFormat(FORMAT_WEBRTC_DATA_CHANNEL);
172 mediaListBuilder.add(mediaBuilder.createMedia());
173 }
174
175 sessionDescriptionBuilder.setVersion(0);
176 sessionDescriptionBuilder.setName("-");
177 sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
178 sessionDescriptionBuilder.setAttributes(attributeMap);
179 return sessionDescriptionBuilder.createSessionDescription();
180 }
181
182 public static SessionDescription of(
183 final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
184 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
185 final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
186 final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
187 final Group group = contentMap.group;
188 if (group != null) {
189 final String semantics = group.getSemantics();
190 checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
191 final var idTags = group.getIdentificationTags();
192 for (final String content : idTags) {
193 checkNoWhitespace(content, "group content names must not contain any whitespace");
194 }
195 attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
196 }
197
198 // TODO my-media-stream can be removed I think
199 attributeMap.put("msid-semantic", " WMS my-media-stream");
200
201 for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
202 entry : contentMap.contents.entrySet()) {
203 final String name = entry.getKey();
204 checkNoWhitespace(name, "content name must not contain any whitespace");
205 // https://groups.google.com/g/discuss-webrtc/c/VG406JMTBI4/m/MrSex_q7AgAJ
206 if (name.length() > 16) {
207 throw new IllegalArgumentException("mid should not be longer than 16 chars");
208 }
209 final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =
210 entry.getValue();
211 final RtpDescription description = descriptionTransport.description;
212 final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
213 mediaAttributes.putAll(transportInfoMediaAttributes(descriptionTransport.transport));
214 final ImmutableList.Builder<Integer> formatBuilder = new ImmutableList.Builder<>();
215 for (final RtpDescription.PayloadType payloadType : description.getPayloadTypes()) {
216 final String id = payloadType.getId();
217 if (Strings.isNullOrEmpty(id)) {
218 throw new IllegalArgumentException("Payload type is missing id");
219 }
220 if (!isInt(id)) {
221 throw new IllegalArgumentException("Payload id is not numeric");
222 }
223 formatBuilder.add(payloadType.getIntId());
224 mediaAttributes.put("rtpmap", payloadType.toSdpAttribute());
225 final List<RtpDescription.Parameter> parameters = payloadType.getParameters();
226 if (parameters.size() == 1) {
227 mediaAttributes.put(
228 "fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
229 } else if (!parameters.isEmpty()) {
230 mediaAttributes.put(
231 "fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
232 }
233 for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
234 payloadType.getFeedbackNegotiations()) {
235 final String type = feedbackNegotiation.getType();
236 final String subtype = feedbackNegotiation.getSubType();
237 if (Strings.isNullOrEmpty(type)) {
238 throw new IllegalArgumentException(
239 "a feedback for payload-type "
240 + id
241 + " negotiation is missing type");
242 }
243 checkNoWhitespace(
244 type, "feedback negotiation type must not contain whitespace");
245 if (Strings.isNullOrEmpty(subtype)) {
246 mediaAttributes.put("rtcp-fb", id + " " + type);
247 } else {
248 checkNoWhitespace(
249 subtype,
250 "feedback negotiation subtype must not contain whitespace");
251 mediaAttributes.put("rtcp-fb", id + " " + type + " " + subtype);
252 }
253 }
254 for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
255 payloadType.feedbackNegotiationTrrInts()) {
256 mediaAttributes.put(
257 "rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
258 }
259 }
260
261 for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
262 description.getFeedbackNegotiations()) {
263 final String type = feedbackNegotiation.getType();
264 final String subtype = feedbackNegotiation.getSubType();
265 if (Strings.isNullOrEmpty(type)) {
266 throw new IllegalArgumentException("a feedback negotiation is missing type");
267 }
268 checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
269 if (Strings.isNullOrEmpty(subtype)) {
270 mediaAttributes.put("rtcp-fb", "* " + type);
271 } else {
272 checkNoWhitespace(
273 subtype, "feedback negotiation subtype must not contain whitespace");
274 mediaAttributes.put("rtcp-fb", "* " + type + " " + subtype); /**/
275 }
276 }
277 for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
278 description.feedbackNegotiationTrrInts()) {
279 mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
280 }
281 for (final RtpDescription.RtpHeaderExtension extension :
282 description.getHeaderExtensions()) {
283 final String id = extension.getId();
284 final String uri = extension.getUri();
285 if (Strings.isNullOrEmpty(id)) {
286 throw new IllegalArgumentException("A header extension is missing id");
287 }
288 checkNoWhitespace(id, "header extension id must not contain whitespace");
289 if (Strings.isNullOrEmpty(uri)) {
290 throw new IllegalArgumentException("A header extension is missing uri");
291 }
292 checkNoWhitespace(uri, "feedback negotiation uri must not contain whitespace");
293 mediaAttributes.put("extmap", id + " " + uri);
294 }
295
296 if (description.hasChild(
297 "extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
298 mediaAttributes.put("extmap-allow-mixed", "");
299 }
300
301 for (final RtpDescription.SourceGroup sourceGroup : description.getSourceGroups()) {
302 final String semantics = sourceGroup.getSemantics();
303 final List<String> groups = sourceGroup.getSsrcs();
304 if (Strings.isNullOrEmpty(semantics)) {
305 throw new IllegalArgumentException(
306 "A SSRC group is missing semantics attribute");
307 }
308 checkNoWhitespace(semantics, "source group semantics must not contain whitespace");
309 if (groups.isEmpty()) {
310 throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
311 }
312 for (final String source : groups) {
313 checkNoWhitespace(source, "Sources must not contain whitespace");
314 }
315 mediaAttributes.put(
316 "ssrc-group",
317 String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
318 }
319 for (final RtpDescription.Source source : description.getSources()) {
320 for (final RtpDescription.Source.Parameter parameter : source.getParameters()) {
321 final String id = source.getSsrcId();
322 final String parameterName = parameter.getParameterName();
323 final String parameterValue = parameter.getParameterValue();
324 if (Strings.isNullOrEmpty(id)) {
325 throw new IllegalArgumentException(
326 "A source specific media attribute is missing the id");
327 }
328 checkNoWhitespace(
329 id, "A source specific media attributes must not contain whitespaces");
330 if (Strings.isNullOrEmpty(parameterName)) {
331 throw new IllegalArgumentException(
332 "A source specific media attribute is missing its name");
333 }
334 if (Strings.isNullOrEmpty(parameterValue)) {
335 throw new IllegalArgumentException(
336 "A source specific media attribute is missing its value");
337 }
338 checkNoWhitespace(
339 parameterName,
340 "A source specific media attribute name not not contain whitespace");
341 checkNoNewline(
342 parameterValue,
343 "A source specific media attribute value must not contain new lines");
344 mediaAttributes.put(
345 "ssrc", id + " " + parameterName + ":" + parameterValue.trim());
346 }
347 }
348
349 mediaAttributes.put("mid", name);
350
351 mediaAttributes.put(
352 descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
353 if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
354 mediaAttributes.put("rtcp-mux", "");
355 }
356
357 // random additional attributes
358 mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
359
360 final MediaBuilder mediaBuilder = new MediaBuilder();
361 mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
362 mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
363 mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
364 mediaBuilder.setProtocol(HARDCODED_MEDIA_PROTOCOL);
365 mediaBuilder.setAttributes(mediaAttributes);
366 mediaBuilder.setFormats(formatBuilder.build());
367 mediaListBuilder.add(mediaBuilder.createMedia());
368 }
369 sessionDescriptionBuilder.setVersion(0);
370 sessionDescriptionBuilder.setName("-");
371 sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
372 sessionDescriptionBuilder.setAttributes(attributeMap);
373
374 return sessionDescriptionBuilder.createSessionDescription();
375 }
376
377 private static Multimap<String, String> transportInfoMediaAttributes(
378 final IceUdpTransportInfo transport) {
379 final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
380 final String ufrag = transport.getAttribute("ufrag");
381 final String pwd = transport.getAttribute("pwd");
382 if (Strings.isNullOrEmpty(ufrag)) {
383 throw new IllegalArgumentException(
384 "Transport element is missing required ufrag attribute");
385 }
386 checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
387 mediaAttributes.put("ice-ufrag", ufrag);
388 if (Strings.isNullOrEmpty(pwd)) {
389 throw new IllegalArgumentException(
390 "Transport element is missing required pwd attribute");
391 }
392 checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
393 mediaAttributes.put("ice-pwd", pwd);
394 final List<String> negotiatedIceOptions = transport.getIceOptions();
395 final Collection<String> iceOptions =
396 negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
397 mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
398 final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
399 if (fingerprint != null) {
400 final String hashFunction = fingerprint.getHash();
401 final String hash = fingerprint.getContent();
402 if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) {
403 throw new IllegalArgumentException("DTLS-SRTP missing hash");
404 }
405 checkNoWhitespace(hashFunction, "DTLS-SRTP hash function must not contain whitespace");
406 checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace");
407 mediaAttributes.put("fingerprint", hashFunction + " " + hash);
408 final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
409 if (setup != null) {
410 mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
411 }
412 }
413 return ImmutableMultimap.copyOf(mediaAttributes);
414 }
415
416 private static Multimap<String, String> transportInfoMediaAttributes(
417 final WebRTCDataChannelTransportInfo transport) {
418 final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
419 final var iceUdpTransportInfo = transport.innerIceUdpTransportInfo();
420 if (iceUdpTransportInfo == null) {
421 throw new IllegalArgumentException(
422 "Transport element is missing inner ice-udp transport");
423 }
424 mediaAttributes.putAll(transportInfoMediaAttributes(iceUdpTransportInfo));
425 final Integer sctpPort = transport.getSctpPort();
426 if (sctpPort == null) {
427 throw new IllegalArgumentException(
428 "Transport element is missing required sctp-port attribute");
429 }
430 mediaAttributes.put("sctp-port", String.valueOf(sctpPort));
431 final Integer maxMessageSize = transport.getMaxMessageSize();
432 if (maxMessageSize == null) {
433 throw new IllegalArgumentException(
434 "Transport element is missing required max-message-size");
435 }
436 mediaAttributes.put("max-message-size", String.valueOf(maxMessageSize));
437 return ImmutableMultimap.copyOf(mediaAttributes);
438 }
439
440 public static String checkNoWhitespace(final String input, final String message) {
441 if (CharMatcher.whitespace().matchesAnyOf(input)) {
442 throw new IllegalArgumentException(message);
443 }
444 return input;
445 }
446
447 public static String checkNoNewline(final String input, final String message) {
448 if (CharMatcher.anyOf("\r\n").matchesAnyOf(message)) {
449 throw new IllegalArgumentException(message);
450 }
451 return input;
452 }
453
454 public static int ignorantIntParser(final String input) {
455 try {
456 return Integer.parseInt(input);
457 } catch (NumberFormatException e) {
458 return 0;
459 }
460 }
461
462 public static boolean isInt(final String input) {
463 if (input == null) {
464 return false;
465 }
466 try {
467 Integer.parseInt(input);
468 return true;
469 } catch (NumberFormatException e) {
470 return false;
471 }
472 }
473
474 public static Pair<String, String> parseAttribute(final String input) {
475 final String[] pair = input.split(":", 2);
476 if (pair.length == 2) {
477 return new Pair<>(pair[0], pair[1]);
478 } else {
479 return new Pair<>(pair[0], "");
480 }
481 }
482
483 @NonNull
484 @Override
485 public String toString() {
486 final StringBuilder s =
487 new StringBuilder()
488 .append("v=")
489 .append(version)
490 .append(LINE_DIVIDER)
491 // TODO randomize or static
492 .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1")
493 .append(LINE_DIVIDER) // what ever that means
494 .append("s=")
495 .append(name)
496 .append(LINE_DIVIDER)
497 .append("t=0 0")
498 .append(LINE_DIVIDER);
499 appendAttributes(s, attributes);
500 for (Media media : this.media) {
501 s.append("m=")
502 .append(media.media)
503 .append(' ')
504 .append(media.port)
505 .append(' ')
506 .append(media.protocol)
507 .append(' ')
508 .append(media.format)
509 .append(LINE_DIVIDER);
510 s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
511 appendAttributes(s, media.attributes);
512 }
513 return s.toString();
514 }
515
516 public static class Media {
517 public final String media;
518 public final int port;
519 public final String protocol;
520 public final String format;
521 public final String connectionData;
522 public final Multimap<String, String> attributes;
523
524 public Media(
525 String media,
526 int port,
527 String protocol,
528 String format,
529 String connectionData,
530 Multimap<String, String> attributes) {
531 this.media = media;
532 this.port = port;
533 this.protocol = protocol;
534 this.format = format;
535 this.connectionData = connectionData;
536 this.attributes = attributes;
537 }
538 }
539}