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