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