RtpContentMap.java

  1package eu.siacs.conversations.xmpp.jingle;
  2
  3import com.google.common.base.MoreObjects;
  4import com.google.common.base.Objects;
  5import com.google.common.base.Preconditions;
  6import com.google.common.base.Predicates;
  7import com.google.common.base.Strings;
  8import com.google.common.collect.Collections2;
  9import com.google.common.collect.ImmutableList;
 10import com.google.common.collect.ImmutableMap;
 11import com.google.common.collect.ImmutableSet;
 12import com.google.common.collect.Iterables;
 13import com.google.common.collect.Maps;
 14import com.google.common.collect.Sets;
 15
 16import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 17import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
 18import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
 19import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 20import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 21import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 22import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
 23import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 24
 25import java.util.Collection;
 26import java.util.LinkedHashMap;
 27import java.util.List;
 28import java.util.Map;
 29import java.util.Set;
 30
 31import javax.annotation.Nonnull;
 32
 33public class RtpContentMap {
 34
 35    public final Group group;
 36    public final Map<String, DescriptionTransport> contents;
 37
 38    public RtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
 39        this.group = group;
 40        this.contents = contents;
 41    }
 42
 43    public static RtpContentMap of(final JinglePacket jinglePacket) {
 44        final Map<String, DescriptionTransport> contents =
 45                DescriptionTransport.of(jinglePacket.getJingleContents());
 46        if (isOmemoVerified(contents)) {
 47            return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents);
 48        } else {
 49            return new RtpContentMap(jinglePacket.getGroup(), contents);
 50        }
 51    }
 52
 53    private static boolean isOmemoVerified(Map<String, DescriptionTransport> contents) {
 54        final Collection<DescriptionTransport> values = contents.values();
 55        if (values.size() == 0) {
 56            return false;
 57        }
 58        for (final DescriptionTransport descriptionTransport : values) {
 59            if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
 60                continue;
 61            }
 62            return false;
 63        }
 64        return true;
 65    }
 66
 67    public static RtpContentMap of(
 68            final SessionDescription sessionDescription, final boolean isInitiator) {
 69        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
 70                new ImmutableMap.Builder<>();
 71        for (SessionDescription.Media media : sessionDescription.media) {
 72            final String id = Iterables.getFirst(media.attributes.get("mid"), null);
 73            Preconditions.checkNotNull(id, "media has no mid");
 74            contentMapBuilder.put(
 75                    id, DescriptionTransport.of(sessionDescription, isInitiator, media));
 76        }
 77        final String groupAttribute =
 78                Iterables.getFirst(sessionDescription.attributes.get("group"), null);
 79        final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute);
 80        return new RtpContentMap(group, contentMapBuilder.build());
 81    }
 82
 83    public Set<Media> getMedia() {
 84        return Sets.newHashSet(
 85                Collections2.transform(
 86                        contents.values(),
 87                        input -> {
 88                            final RtpDescription rtpDescription =
 89                                    input == null ? null : input.description;
 90                            return rtpDescription == null
 91                                    ? Media.UNKNOWN
 92                                    : input.description.getMedia();
 93                        }));
 94    }
 95
 96    public Set<Content.Senders> getSenders() {
 97        return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders));
 98    }
 99
100    public List<String> getNames() {
101        return ImmutableList.copyOf(contents.keySet());
102    }
103
104    void requireContentDescriptions() {
105        if (this.contents.size() == 0) {
106            throw new IllegalStateException("No contents available");
107        }
108        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
109            if (entry.getValue().description == null) {
110                throw new IllegalStateException(
111                        String.format("%s is lacking content description", entry.getKey()));
112            }
113        }
114    }
115
116    void requireDTLSFingerprint() {
117        requireDTLSFingerprint(false);
118    }
119
120    void requireDTLSFingerprint(final boolean requireActPass) {
121        if (this.contents.size() == 0) {
122            throw new IllegalStateException("No contents available");
123        }
124        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
125            final IceUdpTransportInfo transport = entry.getValue().transport;
126            final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
127            if (fingerprint == null
128                    || Strings.isNullOrEmpty(fingerprint.getContent())
129                    || Strings.isNullOrEmpty(fingerprint.getHash())) {
130                throw new SecurityException(
131                        String.format(
132                                "Use of DTLS-SRTP (XEP-0320) is required for content %s",
133                                entry.getKey()));
134            }
135            final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
136            if (setup == null) {
137                throw new SecurityException(
138                        String.format(
139                                "Use of DTLS-SRTP (XEP-0320) is required for content %s but missing setup attribute",
140                                entry.getKey()));
141            }
142            if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
143                throw new SecurityException(
144                        "Initiator needs to offer ACTPASS as setup for DTLS-SRTP (XEP-0320)");
145            }
146        }
147    }
148
149    JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) {
150        final JinglePacket jinglePacket = new JinglePacket(action, sessionId);
151        if (this.group != null) {
152            jinglePacket.addGroup(this.group);
153        }
154        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
155            final DescriptionTransport descriptionTransport = entry.getValue();
156            final Content content =
157                    new Content(
158                            Content.Creator.INITIATOR,
159                            descriptionTransport.senders,
160                            entry.getKey());
161            if (descriptionTransport.description != null) {
162                content.addChild(descriptionTransport.description);
163            }
164            content.addChild(descriptionTransport.transport);
165            jinglePacket.addJingleContent(content);
166        }
167        return jinglePacket;
168    }
169
170    RtpContentMap transportInfo(
171            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
172        final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName);
173        final IceUdpTransportInfo transportInfo =
174                descriptionTransport == null ? null : descriptionTransport.transport;
175        if (transportInfo == null) {
176            throw new IllegalArgumentException(
177                    "Unable to find transport info for content name " + contentName);
178        }
179        final IceUdpTransportInfo newTransportInfo = transportInfo.cloneWrapper();
180        newTransportInfo.addChild(candidate);
181        return new RtpContentMap(
182                null,
183                ImmutableMap.of(
184                        contentName,
185                        new DescriptionTransport(
186                                descriptionTransport.senders, null, newTransportInfo)));
187    }
188
189    RtpContentMap transportInfo() {
190        return new RtpContentMap(
191                null,
192                Maps.transformValues(
193                        contents,
194                        dt ->
195                                new DescriptionTransport(
196                                        dt.senders, null, dt.transport.cloneWrapper())));
197    }
198
199    public IceUdpTransportInfo.Credentials getDistinctCredentials() {
200        final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
201        final IceUdpTransportInfo.Credentials credentials =
202                Iterables.getFirst(allCredentials, null);
203        if (allCredentials.size() == 1 && credentials != null) {
204            if (Strings.isNullOrEmpty(credentials.password)
205                    || Strings.isNullOrEmpty(credentials.ufrag)) {
206                throw new IllegalStateException("Credentials are missing password or ufrag");
207            }
208            return credentials;
209        }
210        throw new IllegalStateException("Content map does not have distinct credentials");
211    }
212
213    private Set<String> getCombinedIceOptions() {
214        final Collection<List<String>> combinedIceOptions =
215                Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions());
216        return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions));
217    }
218
219    public Set<IceUdpTransportInfo.Credentials> getCredentials() {
220        final Set<IceUdpTransportInfo.Credentials> credentials =
221                ImmutableSet.copyOf(
222                        Collections2.transform(
223                                contents.values(), dt -> dt.transport.getCredentials()));
224        if (credentials.isEmpty()) {
225            throw new IllegalStateException("Content map does not have any credentials");
226        }
227        return credentials;
228    }
229
230    public IceUdpTransportInfo.Credentials getCredentials(final String contentName) {
231        final DescriptionTransport descriptionTransport = this.contents.get(contentName);
232        if (descriptionTransport == null) {
233            throw new IllegalArgumentException(
234                    String.format(
235                            "Unable to find transport info for content name %s", contentName));
236        }
237        return descriptionTransport.transport.getCredentials();
238    }
239
240    public IceUdpTransportInfo.Setup getDtlsSetup() {
241        final Set<IceUdpTransportInfo.Setup> setups =
242                ImmutableSet.copyOf(
243                        Collections2.transform(
244                                contents.values(), dt -> dt.transport.getFingerprint().getSetup()));
245        final IceUdpTransportInfo.Setup setup = Iterables.getFirst(setups, null);
246        if (setups.size() == 1 && setup != null) {
247            return setup;
248        }
249        throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
250    }
251
252    private DTLS getDistinctDtls() {
253        final Set<DTLS> dtlsSet =
254                ImmutableSet.copyOf(
255                        Collections2.transform(
256                                contents.values(),
257                                dt -> {
258                                    final IceUdpTransportInfo.Fingerprint fp =
259                                            dt.transport.getFingerprint();
260                                    return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
261                                }));
262        final DTLS dtls = Iterables.getFirst(dtlsSet, null);
263        if (dtlsSet.size() == 1 && dtls != null) {
264            return dtls;
265        }
266        throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
267    }
268
269    public boolean emptyCandidates() {
270        int count = 0;
271        for (DescriptionTransport descriptionTransport : contents.values()) {
272            count += descriptionTransport.transport.getCandidates().size();
273        }
274        return count == 0;
275    }
276
277    public boolean hasFullTransportInfo() {
278        return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub())
279                .contains(false);
280    }
281
282    public RtpContentMap modifiedCredentials(
283            IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
284        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
285                new ImmutableMap.Builder<>();
286        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
287            final DescriptionTransport descriptionTransport = content.getValue();
288            final RtpDescription rtpDescription = descriptionTransport.description;
289            final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
290            final IceUdpTransportInfo modifiedTransportInfo =
291                    transportInfo.modifyCredentials(credentials, setup);
292            contentMapBuilder.put(
293                    content.getKey(),
294                    new DescriptionTransport(
295                            descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
296        }
297        return new RtpContentMap(this.group, contentMapBuilder.build());
298    }
299
300    public RtpContentMap modifiedSenders(final Content.Senders senders) {
301        return new RtpContentMap(
302                this.group,
303                Maps.transformValues(
304                        contents,
305                        dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
306    }
307
308    public RtpContentMap modifiedSendersChecked(
309            final boolean isInitiator, final Map<String, Content.Senders> modification) {
310        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
311                new ImmutableMap.Builder<>();
312        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
313            final String id = content.getKey();
314            final DescriptionTransport descriptionTransport = content.getValue();
315            final Content.Senders currentSenders = descriptionTransport.senders;
316            final Content.Senders targetSenders = modification.get(id);
317            if (targetSenders == null || currentSenders == targetSenders) {
318                contentMapBuilder.put(id, descriptionTransport);
319            } else {
320                checkSenderModification(isInitiator, currentSenders, targetSenders);
321                contentMapBuilder.put(
322                        id,
323                        new DescriptionTransport(
324                                targetSenders,
325                                descriptionTransport.description,
326                                descriptionTransport.transport));
327            }
328        }
329        return new RtpContentMap(this.group, contentMapBuilder.build());
330    }
331
332    private static void checkSenderModification(
333            final boolean isInitiator,
334            final Content.Senders current,
335            final Content.Senders target) {
336        if (isInitiator) {
337            // we were both sending and now other party only wants to receive
338            if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) {
339                return;
340            }
341            // only we were sending but now other party wants to send too
342            if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) {
343                return;
344            }
345        } else {
346            // we were both sending and now other party only wants to receive
347            if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) {
348                return;
349            }
350            // only we were sending but now other party wants to send too
351            if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) {
352                return;
353            }
354        }
355        throw new IllegalArgumentException(
356                String.format("Unsupported senders modification %s -> %s", current, target));
357    }
358
359    public RtpContentMap toContentModification(final Collection<String> modifications) {
360        return new RtpContentMap(
361                this.group, Maps.filterKeys(contents, Predicates.in(modifications)));
362    }
363
364    public RtpContentMap toStub() {
365        return new RtpContentMap(
366                null,
367                Maps.transformValues(
368                        this.contents,
369                        dt ->
370                                new DescriptionTransport(
371                                        dt.senders,
372                                        RtpDescription.stub(dt.description.getMedia()),
373                                        IceUdpTransportInfo.STUB)));
374    }
375
376    public RtpContentMap activeContents() {
377        return new RtpContentMap(
378                group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
379    }
380
381    public Diff diff(final RtpContentMap rtpContentMap) {
382        final Set<String> existingContentIds = this.contents.keySet();
383        final Set<String> newContentIds = rtpContentMap.contents.keySet();
384        return new Diff(
385                ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
386                ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
387    }
388
389    public boolean iceRestart(final RtpContentMap rtpContentMap) {
390        try {
391            return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
392        } catch (final IllegalStateException e) {
393            return false;
394        }
395    }
396
397    public RtpContentMap addContent(
398            final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) {
399        final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
400        final Map<String, DescriptionTransport> combinedFixedTransport =
401                Maps.transformValues(
402                        combined,
403                        dt -> {
404                            final IceUdpTransportInfo iceUdpTransportInfo;
405                            if (dt.transport.isStub()) {
406                                final IceUdpTransportInfo.Credentials credentials =
407                                        getDistinctCredentials();
408                                final Collection<String> iceOptions = getCombinedIceOptions();
409                                final DTLS dtls = getDistinctDtls();
410                                iceUdpTransportInfo =
411                                        IceUdpTransportInfo.of(
412                                                credentials,
413                                                iceOptions,
414                                                setupOverwrite,
415                                                dtls.hash,
416                                                dtls.fingerprint);
417                            } else {
418                                final IceUdpTransportInfo.Fingerprint fp =
419                                        dt.transport.getFingerprint();
420                                final IceUdpTransportInfo.Setup setup = fp.getSetup();
421                                iceUdpTransportInfo =
422                                        IceUdpTransportInfo.of(
423                                                dt.transport.getCredentials(),
424                                                dt.transport.getIceOptions(),
425                                                setup == IceUdpTransportInfo.Setup.ACTPASS
426                                                        ? setupOverwrite
427                                                        : setup,
428                                                fp.getHash(),
429                                                fp.getContent());
430                            }
431                            return new DescriptionTransport(
432                                    dt.senders, dt.description, iceUdpTransportInfo);
433                        });
434        return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport));
435    }
436
437    private static Map<String, DescriptionTransport> merge(
438            final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
439        final Map<String, DescriptionTransport> combined = new LinkedHashMap<>();
440        combined.putAll(a);
441        combined.putAll(b);
442        return ImmutableMap.copyOf(combined);
443    }
444
445    public static class DescriptionTransport {
446        public final Content.Senders senders;
447        public final RtpDescription description;
448        public final IceUdpTransportInfo transport;
449
450        public DescriptionTransport(
451                final Content.Senders senders,
452                final RtpDescription description,
453                final IceUdpTransportInfo transport) {
454            this.senders = senders;
455            this.description = description;
456            this.transport = transport;
457        }
458
459        public static DescriptionTransport of(final Content content) {
460            final GenericDescription description = content.getDescription();
461            final GenericTransportInfo transportInfo = content.getTransport();
462            final Content.Senders senders = content.getSenders();
463            final RtpDescription rtpDescription;
464            final IceUdpTransportInfo iceUdpTransportInfo;
465            if (description == null) {
466                rtpDescription = null;
467            } else if (description instanceof RtpDescription) {
468                rtpDescription = (RtpDescription) description;
469            } else {
470                throw new UnsupportedApplicationException(
471                        "Content does not contain rtp description");
472            }
473            if (transportInfo instanceof IceUdpTransportInfo) {
474                iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
475            } else {
476                throw new UnsupportedTransportException(
477                        "Content does not contain ICE-UDP transport");
478            }
479            return new DescriptionTransport(
480                    senders,
481                    rtpDescription,
482                    OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
483        }
484
485        private static DescriptionTransport of(
486                final SessionDescription sessionDescription,
487                final boolean isInitiator,
488                final SessionDescription.Media media) {
489            final Content.Senders senders = Content.Senders.of(media, isInitiator);
490            final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
491            final IceUdpTransportInfo transportInfo =
492                    IceUdpTransportInfo.of(sessionDescription, media);
493            return new DescriptionTransport(senders, rtpDescription, transportInfo);
494        }
495
496        public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
497            return ImmutableMap.copyOf(
498                    Maps.transformValues(
499                            contents, content -> content == null ? null : of(content)));
500        }
501    }
502
503    public static class UnsupportedApplicationException extends IllegalArgumentException {
504        UnsupportedApplicationException(String message) {
505            super(message);
506        }
507    }
508
509    public static class UnsupportedTransportException extends IllegalArgumentException {
510        UnsupportedTransportException(String message) {
511            super(message);
512        }
513    }
514
515    public static final class Diff {
516        public final Set<String> added;
517        public final Set<String> removed;
518
519        private Diff(final Set<String> added, final Set<String> removed) {
520            this.added = added;
521            this.removed = removed;
522        }
523
524        public boolean hasModifications() {
525            return !this.added.isEmpty() || !this.removed.isEmpty();
526        }
527
528        public boolean isEmpty() {
529            return this.added.isEmpty() && this.removed.isEmpty();
530        }
531
532        @Override
533        @Nonnull
534        public String toString() {
535            return MoreObjects.toStringHelper(this)
536                    .add("added", added)
537                    .add("removed", removed)
538                    .toString();
539        }
540    }
541
542    public static final class DTLS {
543        public final String hash;
544        public final IceUdpTransportInfo.Setup setup;
545        public final String fingerprint;
546
547        private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
548            this.hash = hash;
549            this.setup = setup;
550            this.fingerprint = fingerprint;
551        }
552
553        @Override
554        public boolean equals(Object o) {
555            if (this == o) return true;
556            if (o == null || getClass() != o.getClass()) return false;
557            DTLS dtls = (DTLS) o;
558            return Objects.equal(hash, dtls.hash)
559                    && setup == dtls.setup
560                    && Objects.equal(fingerprint, dtls.fingerprint);
561        }
562
563        @Override
564        public int hashCode() {
565            return Objects.hashCode(hash, setup, fingerprint);
566        }
567    }
568}