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 mediaAttributes.put(
180 "fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
181 final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
182 if (setup != null) {
183 mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
184 }
185 }
186 final ImmutableList.Builder<Integer> formatBuilder = new ImmutableList.Builder<>();
187 for (RtpDescription.PayloadType payloadType : description.getPayloadTypes()) {
188 final String id = payloadType.getId();
189 if (Strings.isNullOrEmpty(id)) {
190 throw new IllegalArgumentException("Payload type is missing id");
191 }
192 if (!isInt(id)) {
193 throw new IllegalArgumentException("Payload id is not numeric");
194 }
195 formatBuilder.add(payloadType.getIntId());
196 mediaAttributes.put("rtpmap", payloadType.toSdpAttribute());
197 final List<RtpDescription.Parameter> parameters = payloadType.getParameters();
198 if (parameters.size() == 1) {
199 mediaAttributes.put(
200 "fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
201 } else if (parameters.size() > 0) {
202 mediaAttributes.put(
203 "fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
204 }
205 for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
206 payloadType.getFeedbackNegotiations()) {
207 final String type = feedbackNegotiation.getType();
208 final String subtype = feedbackNegotiation.getSubType();
209 if (Strings.isNullOrEmpty(type)) {
210 throw new IllegalArgumentException(
211 "a feedback for payload-type "
212 + id
213 + " negotiation is missing type");
214 }
215 checkNoWhitespace(
216 type, "feedback negotiation type must not contain whitespace");
217 mediaAttributes.put(
218 "rtcp-fb",
219 id
220 + " "
221 + type
222 + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
223 }
224 for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
225 payloadType.feedbackNegotiationTrrInts()) {
226 mediaAttributes.put(
227 "rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
228 }
229 }
230
231 for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
232 description.getFeedbackNegotiations()) {
233 final String type = feedbackNegotiation.getType();
234 final String subtype = feedbackNegotiation.getSubType();
235 if (Strings.isNullOrEmpty(type)) {
236 throw new IllegalArgumentException("a feedback negotiation is missing type");
237 }
238 checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
239 mediaAttributes.put(
240 "rtcp-fb",
241 "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
242 }
243 for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
244 description.feedbackNegotiationTrrInts()) {
245 mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
246 }
247 for (final RtpDescription.RtpHeaderExtension extension :
248 description.getHeaderExtensions()) {
249 final String id = extension.getId();
250 final String uri = extension.getUri();
251 if (Strings.isNullOrEmpty(id)) {
252 throw new IllegalArgumentException("A header extension is missing id");
253 }
254 checkNoWhitespace(id, "header extension id must not contain whitespace");
255 if (Strings.isNullOrEmpty(uri)) {
256 throw new IllegalArgumentException("A header extension is missing uri");
257 }
258 checkNoWhitespace(uri, "feedback negotiation uri must not contain whitespace");
259 mediaAttributes.put("extmap", id + " " + uri);
260 }
261
262 if (description.hasChild(
263 "extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
264 mediaAttributes.put("extmap-allow-mixed", "");
265 }
266
267 for (final RtpDescription.SourceGroup sourceGroup : description.getSourceGroups()) {
268 final String semantics = sourceGroup.getSemantics();
269 final List<String> groups = sourceGroup.getSsrcs();
270 if (Strings.isNullOrEmpty(semantics)) {
271 throw new IllegalArgumentException(
272 "A SSRC group is missing semantics attribute");
273 }
274 checkNoWhitespace(semantics, "source group semantics must not contain whitespace");
275 if (groups.size() == 0) {
276 throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
277 }
278 mediaAttributes.put(
279 "ssrc-group",
280 String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
281 }
282 for (final RtpDescription.Source source : description.getSources()) {
283 for (final RtpDescription.Source.Parameter parameter : source.getParameters()) {
284 final String id = source.getSsrcId();
285 final String parameterName = parameter.getParameterName();
286 final String parameterValue = parameter.getParameterValue();
287 if (Strings.isNullOrEmpty(id)) {
288 throw new IllegalArgumentException(
289 "A source specific media attribute is missing the id");
290 }
291 checkNoWhitespace(
292 id, "A source specific media attributes must not contain whitespaces");
293 if (Strings.isNullOrEmpty(parameterName)) {
294 throw new IllegalArgumentException(
295 "A source specific media attribute is missing its name");
296 }
297 if (Strings.isNullOrEmpty(parameterValue)) {
298 throw new IllegalArgumentException(
299 "A source specific media attribute is missing its value");
300 }
301 mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
302 }
303 }
304
305 mediaAttributes.put("mid", name);
306
307 mediaAttributes.put(
308 descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
309 if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
310 mediaAttributes.put("rtcp-mux", "");
311 }
312
313 // random additional attributes
314 mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
315
316 final MediaBuilder mediaBuilder = new MediaBuilder();
317 mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
318 mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
319 mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
320 mediaBuilder.setProtocol(HARDCODED_MEDIA_PROTOCOL);
321 mediaBuilder.setAttributes(mediaAttributes);
322 mediaBuilder.setFormats(formatBuilder.build());
323 mediaListBuilder.add(mediaBuilder.createMedia());
324 }
325 sessionDescriptionBuilder.setVersion(0);
326 sessionDescriptionBuilder.setName("-");
327 sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
328 sessionDescriptionBuilder.setAttributes(attributeMap);
329
330 return sessionDescriptionBuilder.createSessionDescription();
331 }
332
333 public static String checkNoWhitespace(final String input, final String message) {
334 if (CharMatcher.whitespace().matchesAnyOf(input)) {
335 throw new IllegalArgumentException(message);
336 }
337 return input;
338 }
339
340 public static int ignorantIntParser(final String input) {
341 try {
342 return Integer.parseInt(input);
343 } catch (NumberFormatException e) {
344 return 0;
345 }
346 }
347
348 public static boolean isInt(final String input) {
349 if (input == null) {
350 return false;
351 }
352 try {
353 Integer.parseInt(input);
354 return true;
355 } catch (NumberFormatException e) {
356 return false;
357 }
358 }
359
360 public static Pair<String, String> parseAttribute(final String input) {
361 final String[] pair = input.split(":", 2);
362 if (pair.length == 2) {
363 return new Pair<>(pair[0], pair[1]);
364 } else {
365 return new Pair<>(pair[0], "");
366 }
367 }
368
369 @NonNull
370 @Override
371 public String toString() {
372 final StringBuilder s =
373 new StringBuilder()
374 .append("v=")
375 .append(version)
376 .append(LINE_DIVIDER)
377 // TODO randomize or static
378 .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1")
379 .append(LINE_DIVIDER) // what ever that means
380 .append("s=")
381 .append(name)
382 .append(LINE_DIVIDER)
383 .append("t=0 0")
384 .append(LINE_DIVIDER);
385 appendAttributes(s, attributes);
386 for (Media media : this.media) {
387 s.append("m=")
388 .append(media.media)
389 .append(' ')
390 .append(media.port)
391 .append(' ')
392 .append(media.protocol)
393 .append(' ')
394 .append(Joiner.on(' ').join(media.formats))
395 .append(LINE_DIVIDER);
396 s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
397 appendAttributes(s, media.attributes);
398 }
399 return s.toString();
400 }
401
402 public static class Media {
403 public final String media;
404 public final int port;
405 public final String protocol;
406 public final List<Integer> formats;
407 public final String connectionData;
408 public final ArrayListMultimap<String, String> attributes;
409
410 public Media(
411 String media,
412 int port,
413 String protocol,
414 List<Integer> formats,
415 String connectionData,
416 ArrayListMultimap<String, String> attributes) {
417 this.media = media;
418 this.port = port;
419 this.protocol = protocol;
420 this.formats = formats;
421 this.connectionData = connectionData;
422 this.attributes = attributes;
423 }
424 }
425}