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