1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4import android.util.Pair;
5
6import androidx.annotation.NonNull;
7
8import com.google.common.base.CharMatcher;
9import com.google.common.base.Joiner;
10import com.google.common.base.Strings;
11import com.google.common.collect.ArrayListMultimap;
12import com.google.common.collect.ImmutableList;
13import com.google.common.collect.ImmutableMultimap;
14import com.google.common.collect.Multimap;
15
16import eu.siacs.conversations.Config;
17import eu.siacs.conversations.xml.Namespace;
18import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
19import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
20import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
21import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
22import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
23import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
24
25import java.util.Collection;
26import java.util.Collections;
27import java.util.List;
28import java.util.Locale;
29import java.util.Map;
30
31public class SessionDescription {
32
33 public static final String LINE_DIVIDER = "\r\n";
34 private static final String HARDCODED_MEDIA_PROTOCOL =
35 "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
36 private static final String HARDCODED_APPLICATION_PROTOCOL = "UDP/DTLS/SCTP";
37 private static final String FORMAT_WEBRTC_DATA_CHANNEL = "webrtc-datachannel";
38 private static final int HARDCODED_MEDIA_PORT = 9;
39 private static final Collection<String> HARDCODED_ICE_OPTIONS =
40 Collections.singleton("trickle");
41 private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
42
43 public final int version;
44 public final String name;
45 public final String connectionData;
46 public final ArrayListMultimap<String, String> attributes;
47 public final List<Media> media;
48
49 public SessionDescription(
50 int version,
51 String name,
52 String connectionData,
53 ArrayListMultimap<String, String> attributes,
54 List<Media> media) {
55 this.version = version;
56 this.name = name;
57 this.connectionData = connectionData;
58 this.attributes = attributes;
59 this.media = media;
60 }
61
62 private static void appendAttributes(StringBuilder s, Multimap<String, String> attributes) {
63 for (final Map.Entry<String, String> attribute : attributes.entries()) {
64 final String key = attribute.getKey();
65 final String value = attribute.getValue();
66 s.append("a=").append(key);
67 if (!Strings.isNullOrEmpty(value)) {
68 s.append(':').append(value);
69 }
70 s.append(LINE_DIVIDER);
71 }
72 }
73
74 public static SessionDescription parse(final String input) {
75 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
76 MediaBuilder currentMediaBuilder = null;
77 ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
78 ImmutableList.Builder<Media> mediaBuilder = new ImmutableList.Builder<>();
79 for (final String line : input.split(LINE_DIVIDER)) {
80 final String[] pair = line.trim().split("=", 2);
81 if (pair.length < 2 || pair[0].length() != 1) {
82 Log.d(Config.LOGTAG, "skipping sdp parsing on line " + line);
83 continue;
84 }
85 final char key = pair[0].charAt(0);
86 final String value = pair[1];
87 switch (key) {
88 case 'v' -> sessionDescriptionBuilder.setVersion(ignorantIntParser(value));
89 case 'c' -> {
90 if (currentMediaBuilder != null) {
91 currentMediaBuilder.setConnectionData(value);
92 } else {
93 sessionDescriptionBuilder.setConnectionData(value);
94 }
95 }
96 case 's' -> sessionDescriptionBuilder.setName(value);
97 case 'a' -> {
98 final Pair<String, String> attribute = parseAttribute(value);
99 attributeMap.put(attribute.first, attribute.second);
100 }
101 case 'm' -> {
102 if (currentMediaBuilder == null) {
103 sessionDescriptionBuilder.setAttributes(attributeMap);
104 } else {
105 currentMediaBuilder.setAttributes(attributeMap);
106 mediaBuilder.add(currentMediaBuilder.createMedia());
107 }
108 attributeMap = ArrayListMultimap.create();
109 currentMediaBuilder = new MediaBuilder();
110 final String[] parts = value.split(" ");
111 if (parts.length >= 3) {
112 currentMediaBuilder.setMedia(parts[0]);
113 currentMediaBuilder.setPort(ignorantIntParser(parts[1]));
114 currentMediaBuilder.setProtocol(parts[2]);
115 ImmutableList.Builder<Integer> formats = new ImmutableList.Builder<>();
116 for (int i = 3; i < parts.length; ++i) {
117 formats.add(ignorantIntParser(parts[i]));
118 }
119 currentMediaBuilder.setFormats(formats.build());
120 } else {
121 Log.d(Config.LOGTAG, "skipping media line " + line);
122 }
123 }
124 }
125 }
126 if (currentMediaBuilder != null) {
127 currentMediaBuilder.setAttributes(attributeMap);
128 mediaBuilder.add(currentMediaBuilder.createMedia());
129 } else {
130 sessionDescriptionBuilder.setAttributes(attributeMap);
131 }
132 sessionDescriptionBuilder.setMedia(mediaBuilder.build());
133 return sessionDescriptionBuilder.createSessionDescription();
134 }
135
136 public static SessionDescription of(final FileTransferContentMap contentMap) {
137 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
138 final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
139 final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
140
141 final Group group = contentMap.group;
142 if (group != null) {
143 final String semantics = group.getSemantics();
144 checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
145 final var idTags = group.getIdentificationTags();
146 for (final String content : idTags) {
147 checkNoWhitespace(content, "group content names must not contain any whitespace");
148 }
149 attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
150 }
151
152 // TODO my-media-stream can be removed I think
153 attributeMap.put("msid-semantic", " WMS my-media-stream");
154
155 for (final Map.Entry<
156 String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
157 entry : contentMap.contents.entrySet()) {
158 final var dt = entry.getValue();
159 final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo;
160 if (dt.transport instanceof WebRTCDataChannelTransportInfo transportInfo) {
161 webRTCDataChannelTransportInfo = transportInfo;
162 } else {
163 throw new IllegalArgumentException("Transport is not of type WebRTCDataChannel");
164 }
165 final String name = entry.getKey();
166 checkNoWhitespace(name, "content name must not contain any whitespace");
167
168 final MediaBuilder mediaBuilder = new MediaBuilder();
169 mediaBuilder.setMedia("application");
170 mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
171 mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
172 mediaBuilder.setProtocol(HARDCODED_APPLICATION_PROTOCOL);
173 mediaBuilder.setAttributes(
174 transportInfoMediaAttributes(webRTCDataChannelTransportInfo));
175 mediaBuilder.setFormat(FORMAT_WEBRTC_DATA_CHANNEL);
176 mediaListBuilder.add(mediaBuilder.createMedia());
177 }
178
179 sessionDescriptionBuilder.setVersion(0);
180 sessionDescriptionBuilder.setName("-");
181 sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
182 sessionDescriptionBuilder.setAttributes(attributeMap);
183 return sessionDescriptionBuilder.createSessionDescription();
184 }
185
186 public static SessionDescription of(
187 final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
188 final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
189 final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
190 final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
191 final Group group = contentMap.group;
192 if (group != null) {
193 final String semantics = group.getSemantics();
194 checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
195 final var idTags = group.getIdentificationTags();
196 for (final String content : idTags) {
197 checkNoWhitespace(content, "group content names must not contain any whitespace");
198 }
199 attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
200 }
201
202 // TODO my-media-stream can be removed I think
203 attributeMap.put("msid-semantic", " WMS my-media-stream");
204
205 for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
206 entry : contentMap.contents.entrySet()) {
207 final String name = entry.getKey();
208 checkNoWhitespace(name, "content name must not contain any whitespace");
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.size() > 0) {
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.size() == 0) {
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}