RtpContentMap.java

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