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(
302 "ssrc", id + " " + parameterName + ":" + parameterValue.trim());
303 }
304 }
305
306 mediaAttributes.put("mid", name);
307
308 mediaAttributes.put(
309 descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
310 if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
311 mediaAttributes.put("rtcp-mux", "");
312 }
313
314 // random additional attributes
315 mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
316
317 final MediaBuilder mediaBuilder = new MediaBuilder();
318 mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
319 mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
320 mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
321 mediaBuilder.setProtocol(HARDCODED_MEDIA_PROTOCOL);
322 mediaBuilder.setAttributes(mediaAttributes);
323 mediaBuilder.setFormats(formatBuilder.build());
324 mediaListBuilder.add(mediaBuilder.createMedia());
325 }
326 sessionDescriptionBuilder.setVersion(0);
327 sessionDescriptionBuilder.setName("-");
328 sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
329 sessionDescriptionBuilder.setAttributes(attributeMap);
330
331 return sessionDescriptionBuilder.createSessionDescription();
332 }
333
334 public static String checkNoWhitespace(final String input, final String message) {
335 if (CharMatcher.whitespace().matchesAnyOf(input)) {
336 throw new IllegalArgumentException(message);
337 }
338 return input;
339 }
340
341 public static int ignorantIntParser(final String input) {
342 try {
343 return Integer.parseInt(input);
344 } catch (NumberFormatException e) {
345 return 0;
346 }
347 }
348
349 public static boolean isInt(final String input) {
350 if (input == null) {
351 return false;
352 }
353 try {
354 Integer.parseInt(input);
355 return true;
356 } catch (NumberFormatException e) {
357 return false;
358 }
359 }
360
361 public static Pair<String, String> parseAttribute(final String input) {
362 final String[] pair = input.split(":", 2);
363 if (pair.length == 2) {
364 return new Pair<>(pair[0], pair[1]);
365 } else {
366 return new Pair<>(pair[0], "");
367 }
368 }
369
370 @NonNull
371 @Override
372 public String toString() {
373 final StringBuilder s =
374 new StringBuilder()
375 .append("v=")
376 .append(version)
377 .append(LINE_DIVIDER)
378 // TODO randomize or static
379 .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1")
380 .append(LINE_DIVIDER) // what ever that means
381 .append("s=")
382 .append(name)
383 .append(LINE_DIVIDER)
384 .append("t=0 0")
385 .append(LINE_DIVIDER);
386 appendAttributes(s, attributes);
387 for (Media media : this.media) {
388 s.append("m=")
389 .append(media.media)
390 .append(' ')
391 .append(media.port)
392 .append(' ')
393 .append(media.protocol)
394 .append(' ')
395 .append(Joiner.on(' ').join(media.formats))
396 .append(LINE_DIVIDER);
397 s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
398 appendAttributes(s, media.attributes);
399 }
400 return s.toString();
401 }
402
403 public static class Media {
404 public final String media;
405 public final int port;
406 public final String protocol;
407 public final List<Integer> formats;
408 public final String connectionData;
409 public final ArrayListMultimap<String, String> attributes;
410
411 public Media(
412 String media,
413 int port,
414 String protocol,
415 List<Integer> formats,
416 String connectionData,
417 ArrayListMultimap<String, String> attributes) {
418 this.media = media;
419 this.port = port;
420 this.protocol = protocol;
421 this.formats = formats;
422 this.connectionData = connectionData;
423 this.attributes = attributes;
424 }
425 }
426}