SessionDescription.java

  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}