refactor Jingle File Transfer. add WebRTCDatachannel transport

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/Config.java                                             |    4 
src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java                      |   37 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java                        |    7 
src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java                         |    7 
src/main/java/eu/siacs/conversations/utils/Checksum.java                                     |   60 
src/main/java/eu/siacs/conversations/xml/Namespace.java                                      |    5 
src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java                     |   82 
src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java               |  351 
src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java                        |    8 
src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java                   |   19 
src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java                  |   32 
src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java                 |  219 
src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java                             |   98 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java                        |  152 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java                |  105 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java           | 2233 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java                  |  265 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java                    |  422 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java                  |  305 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java                        |   15 
src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java                           |   23 
src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java             |    6 
src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java                |    5 
src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java                          |  307 
src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java                     |  216 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java                          |    8 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java                        |   15 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java        |  256 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java             |    1 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java                          |    2 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java               |   13 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java            |    6 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java                   |   53 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java                        |    2 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java               |   50 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java  |  117 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java |  111 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java  |  321 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java   |  870 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java                   |   80 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java  |  617 
41 files changed, 4,762 insertions(+), 2,743 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/Config.java πŸ”—

@@ -41,7 +41,7 @@ public final class Config {
 
     public static final String LOGTAG = BuildConfig.APP_NAME.toLowerCase(Locale.US);
 
-    public static final boolean QUICK_LOG = false;
+    public static final boolean QUICK_LOG = true;
 
     public static final Jid BUG_REPORTS = Jid.of("bugs@conversations.im");
     public static final Uri HELP = Uri.parse("https://help.conversations.im");
@@ -117,7 +117,7 @@ public final class Config {
     public static final boolean OMEMO_PADDING = false;
     public static final boolean PUT_AUTH_TAG_INTO_KEY = true;
     public static final boolean AUTOMATICALLY_COMPLETE_SESSIONS = true;
-    public static final boolean DISABLE_PROXY_LOOKUP = false; //useful to debug ibb
+    public static final boolean DISABLE_PROXY_LOOKUP = false; //disables STUN/TURN and Proxy65 look up (useful to debug IBB fallback)
     public static final boolean USE_DIRECT_JINGLE_CANDIDATES = true;
     public static final boolean DISABLE_HTTP_UPLOAD = false;
     public static final boolean EXTENDED_SM_LOGGING = false; // log stanza counts

src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java πŸ”—

@@ -62,11 +62,13 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnAdvancedStreamFeaturesLoaded;
 import eu.siacs.conversations.xmpp.OnIqPacketReceived;
+import eu.siacs.conversations.xmpp.jingle.DescriptionTransport;
 import eu.siacs.conversations.xmpp.jingle.OmemoVerification;
 import eu.siacs.conversations.xmpp.jingle.OmemoVerifiedRtpContentMap;
 import eu.siacs.conversations.xmpp.jingle.RtpContentMap;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 import eu.siacs.conversations.xmpp.pep.PublishOptions;
 import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
@@ -1262,12 +1264,12 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         if (Config.REQUIRE_RTP_VERIFICATION) {
             requireVerification(session);
         }
-        final ImmutableMap.Builder<String, RtpContentMap.DescriptionTransport> descriptionTransportBuilder = new ImmutableMap.Builder<>();
+        final ImmutableMap.Builder<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> descriptionTransportBuilder = new ImmutableMap.Builder<>();
         final OmemoVerification omemoVerification = new OmemoVerification();
         omemoVerification.setDeviceId(session.getRemoteAddress().getDeviceId());
         omemoVerification.setSessionFingerprint(session.getFingerprint());
-        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : rtpContentMap.contents.entrySet()) {
-            final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue();
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> content : rtpContentMap.contents.entrySet()) {
+            final DescriptionTransport<RtpDescription,IceUdpTransportInfo> descriptionTransport = content.getValue();
             final OmemoVerifiedIceUdpTransportInfo encryptedTransportInfo;
             try {
                 encryptedTransportInfo = encrypt(descriptionTransport.transport, session);
@@ -1276,7 +1278,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             }
             descriptionTransportBuilder.put(
                     content.getKey(),
-                    new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo)
+                    new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo)
             );
         }
         return Futures.immediateFuture(
@@ -1296,11 +1298,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     }
 
     public ListenableFuture<OmemoVerifiedPayload<RtpContentMap>> decrypt(OmemoVerifiedRtpContentMap omemoVerifiedRtpContentMap, final Jid from) {
-        final ImmutableMap.Builder<String, RtpContentMap.DescriptionTransport> descriptionTransportBuilder = new ImmutableMap.Builder<>();
+        final ImmutableMap.Builder<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> descriptionTransportBuilder = new ImmutableMap.Builder<>();
         final OmemoVerification omemoVerification = new OmemoVerification();
         final ImmutableList.Builder<ListenableFuture<XmppAxolotlSession>> pepVerificationFutures = new ImmutableList.Builder<>();
-        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : omemoVerifiedRtpContentMap.contents.entrySet()) {
-            final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue();
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> content : omemoVerifiedRtpContentMap.contents.entrySet()) {
+            final DescriptionTransport<RtpDescription,IceUdpTransportInfo> descriptionTransport = content.getValue();
             final OmemoVerifiedPayload<IceUdpTransportInfo> decryptedTransport;
             try {
                 decryptedTransport = decrypt((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport, from, pepVerificationFutures);
@@ -1310,7 +1312,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             omemoVerification.setOrEnsureEqual(decryptedTransport);
             descriptionTransportBuilder.put(
                     content.getKey(),
-                    new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload)
+                    new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload)
             );
         }
         processPostponed();
@@ -1376,18 +1378,15 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         ));
     }
 
-    public void prepareKeyTransportMessage(final Conversation conversation, final OnMessageCreatedCallback onMessageCreatedCallback) {
-        executor.execute(new Runnable() {
-            @Override
-            public void run() {
-                final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId());
-                if (buildHeader(axolotlMessage, conversation)) {
-                    onMessageCreatedCallback.run(axolotlMessage);
-                } else {
-                    onMessageCreatedCallback.run(null);
-                }
+    public ListenableFuture<XmppAxolotlMessage> prepareKeyTransportMessage(final Conversation conversation) {
+        return Futures.submit(()->{
+            final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId());
+            if (buildHeader(axolotlMessage, conversation)) {
+                return axolotlMessage;
+            } else {
+                throw new IllegalStateException("No session to decrypt to");
             }
-        });
+        },executor);
     }
 
     public XmppAxolotlMessage fetchAxolotlMessageFromCache(Message message) {

src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java πŸ”—

@@ -27,11 +27,7 @@ public abstract class AbstractGenerator {
     private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
     private final String[] FEATURES = {
             Namespace.JINGLE,
-
-            //Jingle File Transfer
-            FileTransferDescription.Version.FT_3.getNamespace(),
-            FileTransferDescription.Version.FT_4.getNamespace(),
-            FileTransferDescription.Version.FT_5.getNamespace(),
+            Namespace.JINGLE_APPS_FILE_TRANSFER,
             Namespace.JINGLE_TRANSPORTS_S5B,
             Namespace.JINGLE_TRANSPORTS_IBB,
             Namespace.JINGLE_ENCRYPTED_TRANSPORT,
@@ -124,6 +120,7 @@ public abstract class AbstractGenerator {
         if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) {
             features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
             features.addAll(Arrays.asList(VOIP_NAMESPACES));
+            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
         }
         if (mXmppConnectionService.broadcastLastActivity()) {
             features.add(Namespace.IDLE);

src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java πŸ”—

@@ -403,7 +403,12 @@ public class UnifiedPushBroker {
         updateIntent.putExtra("token", target.instance);
         updateIntent.putExtra("bytesMessage", payload);
         updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8));
-        // TODO add distributor verification?
+        final var distributorVerificationIntent = new Intent();
+        distributorVerificationIntent.setPackage(service.getPackageName());
+        final var pendingIntent =
+                PendingIntent.getBroadcast(
+                        service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE);
+        updateIntent.putExtra("distributor", pendingIntent);
         service.sendBroadcast(updateIntent);
     }
 

src/main/java/eu/siacs/conversations/utils/Checksum.java πŸ”—

@@ -1,60 +0,0 @@
-/*
- * Copyright (c) 2018, Daniel Gultsch All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without modification,
- * are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright notice, this
- * list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation and/or
- * other materials provided with the distribution.
- *
- * 3. Neither the name of the copyright holder nor the names of its contributors
- * may be used to endorse or promote products derived from this software without
- * specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
- * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
- * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
- * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
- * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package eu.siacs.conversations.utils;
-
-import android.util.Base64;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-public class Checksum {
-
-	public static String md5(InputStream inputStream) throws IOException {
-		byte[] buffer = new byte[4096];
-		MessageDigest messageDigest;
-		try {
-			messageDigest = MessageDigest.getInstance("MD5");
-		} catch (NoSuchAlgorithmException e) {
-			throw new AssertionError(e);
-		}
-
-		int count;
-		do {
-			count = inputStream.read(buffer);
-			if (count > 0) {
-				messageDigest.update(buffer, 0, count);
-			}
-		} while (count != -1);
-		inputStream.close();
-		return Base64.encodeToString(messageDigest.digest(), Base64.NO_WRAP);
-	}
-}

src/main/java/eu/siacs/conversations/xml/Namespace.java πŸ”—

@@ -47,7 +47,11 @@ public final class Namespace {
     public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1";
     public static final String JINGLE_TRANSPORTS_IBB = "urn:xmpp:jingle:transports:ibb:1";
     public static final String JINGLE_TRANSPORT_ICE_UDP = "urn:xmpp:jingle:transports:ice-udp:1";
+    public static final String JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL = "urn:xmpp:jingle:transports:webrtc-datachannel:1";
+    public static final String JINGLE_TRANSPORT = "urn:xmpp:jingle:transports:dtls-sctp:1";
     public static final String JINGLE_APPS_RTP = "urn:xmpp:jingle:apps:rtp:1";
+
+    public static final String JINGLE_APPS_FILE_TRANSFER = "urn:xmpp:jingle:apps:file-transfer:5";
     public static final String JINGLE_APPS_DTLS = "urn:xmpp:jingle:apps:dtls:0";
     public static final String JINGLE_APPS_GROUPING = "urn:xmpp:jingle:apps:grouping:0";
     public static final String JINGLE_FEATURE_AUDIO = "urn:xmpp:jingle:apps:rtp:audio";
@@ -71,4 +75,5 @@ public final class Namespace {
     public static final String REPORTING = "urn:xmpp:reporting:1";
     public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam";
     public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264";
+    public static final String HASHES = "urn:xmpp:hashes:2";
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java πŸ”—

@@ -0,0 +1,82 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
+import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public abstract class AbstractContentMap<
+        D extends GenericDescription, T extends GenericTransportInfo> {
+
+    public final Group group;
+
+    public final Map<String, DescriptionTransport<D, T>> contents;
+
+    protected AbstractContentMap(
+            final Group group, final Map<String, DescriptionTransport<D, T>> contents) {
+        this.group = group;
+        this.contents = contents;
+    }
+
+    public static class UnsupportedApplicationException extends IllegalArgumentException {
+        UnsupportedApplicationException(String message) {
+            super(message);
+        }
+    }
+
+    public static class UnsupportedTransportException extends IllegalArgumentException {
+        UnsupportedTransportException(String message) {
+            super(message);
+        }
+    }
+
+    public Set<Content.Senders> getSenders() {
+        return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders));
+    }
+
+    public List<String> getNames() {
+        return ImmutableList.copyOf(contents.keySet());
+    }
+
+    JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) {
+        final JinglePacket jinglePacket = new JinglePacket(action, sessionId);
+        for (final Map.Entry<String, DescriptionTransport<D, T>> entry : this.contents.entrySet()) {
+            final DescriptionTransport<D, T> descriptionTransport = entry.getValue();
+            final Content content =
+                    new Content(
+                            Content.Creator.INITIATOR,
+                            descriptionTransport.senders,
+                            entry.getKey());
+            if (descriptionTransport.description != null) {
+                content.addChild(descriptionTransport.description);
+            }
+            content.addChild(descriptionTransport.transport);
+            jinglePacket.addJingleContent(content);
+        }
+        if (this.group != null) {
+            jinglePacket.addGroup(this.group);
+        }
+        return jinglePacket;
+    }
+
+    void requireContentDescriptions() {
+        if (this.contents.size() == 0) {
+            throw new IllegalStateException("No contents available");
+        }
+        for (final Map.Entry<String, DescriptionTransport<D, T>> entry : this.contents.entrySet()) {
+            if (entry.getValue().description == null) {
+                throw new IllegalStateException(
+                        String.format("%s is lacking content description", entry.getKey()));
+            }
+        }
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java πŸ”—

@@ -1,47 +1,352 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import android.util.Log;
+
 import androidx.annotation.NonNull;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.Presence;
+import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
 
 public abstract class AbstractJingleConnection {
 
     public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
     public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
 
+    protected static final List<State> TERMINATED =
+            Arrays.asList(
+                    State.ACCEPTED,
+                    State.REJECTED,
+                    State.REJECTED_RACED,
+                    State.RETRACTED,
+                    State.RETRACTED_RACED,
+                    State.TERMINATED_SUCCESS,
+                    State.TERMINATED_DECLINED_OR_BUSY,
+                    State.TERMINATED_CONNECTIVITY_ERROR,
+                    State.TERMINATED_CANCEL_OR_TIMEOUT,
+                    State.TERMINATED_APPLICATION_FAILURE,
+                    State.TERMINATED_SECURITY_ERROR);
+
+    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
+
+    static {
+        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder =
+                new ImmutableMap.Builder<>();
+        transitionBuilder.put(
+                State.NULL,
+                ImmutableList.of(
+                        State.PROPOSED,
+                        State.SESSION_INITIALIZED,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR));
+        transitionBuilder.put(
+                State.PROPOSED,
+                ImmutableList.of(
+                        State.ACCEPTED,
+                        State.PROCEED,
+                        State.REJECTED,
+                        State.RETRACTED,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR,
+                        State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection
+                        // rebinds
+                        ));
+        transitionBuilder.put(
+                State.PROCEED,
+                ImmutableList.of(
+                        State.REJECTED_RACED,
+                        State.RETRACTED_RACED,
+                        State.SESSION_INITIALIZED_PRE_APPROVED,
+                        State.TERMINATED_SUCCESS,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR,
+                        State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error
+                        // bounces of the proceed message
+                        ));
+        transitionBuilder.put(
+                State.SESSION_INITIALIZED,
+                ImmutableList.of(
+                        State.SESSION_ACCEPTED,
+                        State.TERMINATED_SUCCESS,
+                        State.TERMINATED_DECLINED_OR_BUSY,
+                        State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
+                        // and IQ timeouts
+                        State.TERMINATED_CANCEL_OR_TIMEOUT,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR));
+        transitionBuilder.put(
+                State.SESSION_INITIALIZED_PRE_APPROVED,
+                ImmutableList.of(
+                        State.SESSION_ACCEPTED,
+                        State.TERMINATED_SUCCESS,
+                        State.TERMINATED_DECLINED_OR_BUSY,
+                        State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
+                        // and IQ timeouts
+                        State.TERMINATED_CANCEL_OR_TIMEOUT,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR));
+        transitionBuilder.put(
+                State.SESSION_ACCEPTED,
+                ImmutableList.of(
+                        State.TERMINATED_SUCCESS,
+                        State.TERMINATED_DECLINED_OR_BUSY,
+                        State.TERMINATED_CONNECTIVITY_ERROR,
+                        State.TERMINATED_CANCEL_OR_TIMEOUT,
+                        State.TERMINATED_APPLICATION_FAILURE,
+                        State.TERMINATED_SECURITY_ERROR));
+        VALID_TRANSITIONS = transitionBuilder.build();
+    }
+
     final JingleConnectionManager jingleConnectionManager;
     protected final XmppConnectionService xmppConnectionService;
     protected final Id id;
     private final Jid initiator;
 
-    AbstractJingleConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator) {
+    protected State state = State.NULL;
+
+    AbstractJingleConnection(
+            final JingleConnectionManager jingleConnectionManager,
+            final Id id,
+            final Jid initiator) {
         this.jingleConnectionManager = jingleConnectionManager;
         this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService();
         this.id = id;
         this.initiator = initiator;
     }
 
+    public Id getId() {
+        return id;
+    }
+
     boolean isInitiator() {
         return initiator.equals(id.account.getJid());
     }
 
+    boolean isResponder() {
+        return !initiator.equals(id.account.getJid());
+    }
+
+    public State getState() {
+        return this.state;
+    }
+
+    protected synchronized boolean isInState(State... state) {
+        return Arrays.asList(state).contains(this.state);
+    }
+
+    protected boolean transition(final State target) {
+        return transition(target, null);
+    }
+
+    protected synchronized boolean transition(final State target, final Runnable runnable) {
+        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
+        if (validTransitions != null && validTransitions.contains(target)) {
+            this.state = target;
+            if (runnable != null) {
+                runnable.run();
+            }
+            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    protected void transitionOrThrow(final State target) {
+        if (!transition(target)) {
+            throw new IllegalStateException(
+                    String.format("Unable to transition from %s to %s", this.state, target));
+        }
+    }
+
+    boolean isTerminated() {
+        return TERMINATED.contains(this.state);
+    }
+
     abstract void deliverPacket(JinglePacket jinglePacket);
 
-    public Id getId() {
-        return id;
+    protected void receiveOutOfOrderAction(
+            final JinglePacket jinglePacket, final JinglePacket.Action action) {
+        Log.d(
+                Config.LOGTAG,
+                String.format(
+                        "%s: received %s even though we are in state %s",
+                        id.account.getJid().asBareJid(), action, getState()));
+        if (isTerminated()) {
+            Log.d(
+                    Config.LOGTAG,
+                    String.format(
+                            "%s: got a reason to terminate with out-of-order. but already in state %s",
+                            id.account.getJid().asBareJid(), getState()));
+            respondWithOutOfOrder(jinglePacket);
+        } else {
+            terminateWithOutOfOrder(jinglePacket);
+        }
+    }
+
+    protected void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
+        Log.d(
+                Config.LOGTAG,
+                id.account.getJid().asBareJid() + ": terminating session with out-of-order");
+        terminateTransport();
+        transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
+        respondWithOutOfOrder(jinglePacket);
+        this.finish();
     }
 
+    protected void finish() {
+        if (isTerminated()) {
+            this.jingleConnectionManager.finishConnectionOrThrow(this);
+        } else {
+            throw new AssertionError(
+                    String.format("Unable to call finish from %s", this.state));
+        }
+    }
+
+    protected abstract void terminateTransport();
+
     abstract void notifyRebound();
 
+    protected void sendSessionTerminate(
+            final Reason reason, final String text, final Consumer<State> trigger) {
+        final State previous = this.state;
+        final State target = reasonToState(reason);
+        transitionOrThrow(target);
+        if (previous != State.NULL && trigger != null) {
+            trigger.accept(target);
+        }
+        final JinglePacket jinglePacket =
+                new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
+        jinglePacket.setReason(reason, text);
+        send(jinglePacket);
+        finish();
+    }
+
+    protected void send(final JinglePacket jinglePacket) {
+        jinglePacket.setTo(id.with);
+        xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
+    }
+
+    protected void respondOk(final JinglePacket jinglePacket) {
+        xmppConnectionService.sendIqPacket(
+                id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
+    }
+
+    protected void respondWithTieBreak(final JinglePacket jinglePacket) {
+        respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
+    }
+
+    protected void respondWithOutOfOrder(final JinglePacket jinglePacket) {
+        respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
+    }
+
+    protected void respondWithItemNotFound(final JinglePacket jinglePacket) {
+        respondWithJingleError(jinglePacket, null, "item-not-found", "cancel");
+    }
+
+    private void respondWithJingleError(
+            final IqPacket original,
+            String jingleCondition,
+            String condition,
+            String conditionType) {
+        jingleConnectionManager.respondWithJingleError(
+                id.account, original, jingleCondition, condition, conditionType);
+    }
+
+    private synchronized void handleIqResponse(final Account account, final IqPacket response) {
+        if (response.getType() == IqPacket.TYPE.ERROR) {
+            handleIqErrorResponse(response);
+            return;
+        }
+        if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+            handleIqTimeoutResponse(response);
+        }
+    }
+
+    protected void handleIqErrorResponse(final IqPacket response) {
+        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR);
+        final String errorCondition = response.getErrorCondition();
+        Log.d(
+                Config.LOGTAG,
+                id.account.getJid().asBareJid()
+                        + ": received IQ-error from "
+                        + response.getFrom()
+                        + " in RTP session. "
+                        + errorCondition);
+        if (isTerminated()) {
+            Log.i(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid()
+                            + ": ignoring error because session was already terminated");
+            return;
+        }
+        this.terminateTransport();
+        final State target;
+        if (Arrays.asList(
+                        "service-unavailable",
+                        "recipient-unavailable",
+                        "remote-server-not-found",
+                        "remote-server-timeout")
+                .contains(errorCondition)) {
+            target = State.TERMINATED_CONNECTIVITY_ERROR;
+        } else {
+            target = State.TERMINATED_APPLICATION_FAILURE;
+        }
+        transitionOrThrow(target);
+        this.finish();
+    }
+
+    protected void handleIqTimeoutResponse(final IqPacket response) {
+        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT);
+        Log.d(
+                Config.LOGTAG,
+                id.account.getJid().asBareJid()
+                        + ": received IQ timeout in RTP session with "
+                        + id.with
+                        + ". terminating with connectivity error");
+        if (isTerminated()) {
+            Log.i(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid()
+                            + ": ignoring error because session was already terminated");
+            return;
+        }
+        this.terminateTransport();
+        transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
+        this.finish();
+    }
+
+    protected boolean remoteHasFeature(final String feature) {
+        final Contact contact = id.getContact();
+        final Presence presence =
+                contact.getPresences().get(Strings.nullToEmpty(id.with.getResource()));
+        final ServiceDiscoveryResult serviceDiscoveryResult =
+                presence == null ? null : presence.getServiceDiscoveryResult();
+        final List<String> features =
+                serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures();
+        return features != null && features.contains(feature);
+    }
 
     public static class Id implements OngoingRtpSession {
         public final Account account;
@@ -73,8 +378,7 @@ public abstract class AbstractJingleConnection {
             return new Id(
                     message.getConversation().getAccount(),
                     message.getCounterpart(),
-                    JingleConnectionManager.nextRandomId()
-            );
+                    JingleConnectionManager.nextRandomId());
         }
 
         public Contact getContact() {
@@ -86,9 +390,9 @@ public abstract class AbstractJingleConnection {
             if (this == o) return true;
             if (o == null || getClass() != o.getClass()) return false;
             Id id = (Id) o;
-            return Objects.equal(account.getUuid(), id.account.getUuid()) &&
-                    Objects.equal(with, id.with) &&
-                    Objects.equal(sessionId, id.sessionId);
+            return Objects.equal(account.getUuid(), id.account.getUuid())
+                    && Objects.equal(with, id.with)
+                    && Objects.equal(sessionId, id.sessionId);
         }
 
         @Override
@@ -122,23 +426,36 @@ public abstract class AbstractJingleConnection {
         }
     }
 
+    protected static State reasonToState(Reason reason) {
+        return switch (reason) {
+            case SUCCESS -> State.TERMINATED_SUCCESS;
+            case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY;
+            case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT;
+            case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR;
+            case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State
+                    .TERMINATED_APPLICATION_FAILURE;
+            default -> State.TERMINATED_CONNECTIVITY_ERROR;
+        };
+    }
 
     public enum State {
-        NULL, //default value; nothing has been sent or received yet
+        NULL, // default value; nothing has been sent or received yet
         PROPOSED,
         ACCEPTED,
         PROCEED,
         REJECTED,
-        REJECTED_RACED, //used when we want to reject but haven’t received session init yet
+        REJECTED_RACED, // used when we want to reject but haven’t received session init yet
         RETRACTED,
-        RETRACTED_RACED, //used when receiving a retract after we already asked to proceed
-        SESSION_INITIALIZED, //equal to 'PENDING'
+        RETRACTED_RACED, // used when receiving a retract after we already asked to proceed
+        SESSION_INITIALIZED, // equal to 'PENDING'
         SESSION_INITIALIZED_PRE_APPROVED,
-        SESSION_ACCEPTED, //equal to 'ACTIVE'
-        TERMINATED_SUCCESS, //equal to 'ENDED' (after successful call) ui will just close
-        TERMINATED_DECLINED_OR_BUSY, //equal to 'ENDED' (after other party declined the call)
-        TERMINATED_CONNECTIVITY_ERROR, //equal to 'ENDED' (but after network failures; ui will display retry button)
-        TERMINATED_CANCEL_OR_TIMEOUT, //more or less the same as retracted; caller pressed end call before session was accepted
+        SESSION_ACCEPTED, // equal to 'ACTIVE'
+        TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close
+        TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call)
+        TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will
+        // display retry button)
+        TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call
+        // before session was accepted
         TERMINATED_APPLICATION_FAILURE,
         TERMINATED_SECURITY_ERROR
     }

src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java πŸ”—

@@ -1,5 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import androidx.annotation.NonNull;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.collect.Collections2;
@@ -8,6 +10,8 @@ import com.google.common.collect.ImmutableSet;
 import java.util.Set;
 
 import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 
 public final class ContentAddition {
 
@@ -32,12 +36,13 @@ public final class ContentAddition {
                 Collections2.transform(
                         rtpContentMap.contents.entrySet(),
                         e -> {
-                            final RtpContentMap.DescriptionTransport dt = e.getValue();
+                            final DescriptionTransport<RtpDescription, IceUdpTransportInfo> dt = e.getValue();
                             return new Summary(e.getKey(), dt.description.getMedia(), dt.senders);
                         }));
     }
 
     @Override
+    @NonNull
     public String toString() {
         return MoreObjects.toStringHelper(this)
                 .add("direction", direction)
@@ -77,6 +82,7 @@ public final class ContentAddition {
         }
 
         @Override
+        @NonNull
         public String toString() {
             return MoreObjects.toStringHelper(this)
                     .add("name", name)

src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java πŸ”—

@@ -0,0 +1,19 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
+
+public class DescriptionTransport<D extends GenericDescription, T extends GenericTransportInfo> {
+
+    public final Content.Senders senders;
+    public final D description;
+    public final T transport;
+
+    public DescriptionTransport(
+            final Content.Senders senders, final D description, final T transport) {
+        this.senders = senders;
+        this.description = description;
+        this.transport = transport;
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java πŸ”—

@@ -1,5 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import com.google.common.collect.ImmutableList;
+
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.NetworkInterface;
@@ -15,13 +17,13 @@ import eu.siacs.conversations.xmpp.Jid;
 
 public class DirectConnectionUtils {
 
-    private static List<InetAddress> getLocalAddresses() {
-        final List<InetAddress> addresses = new ArrayList<>();
+    public static List<InetAddress> getLocalAddresses() {
+        final ImmutableList.Builder<InetAddress> inetAddresses = new ImmutableList.Builder<>();
         final Enumeration<NetworkInterface> interfaces;
         try {
             interfaces = NetworkInterface.getNetworkInterfaces();
-        } catch (SocketException e) {
-            return addresses;
+        } catch (final SocketException e) {
+            return inetAddresses.build();
         }
         while (interfaces.hasMoreElements()) {
             NetworkInterface networkInterface = interfaces.nextElement();
@@ -34,31 +36,15 @@ public class DirectConnectionUtils {
                 if (inetAddress instanceof Inet6Address) {
                     //let's get rid of scope
                     try {
-                        addresses.add(Inet6Address.getByAddress(inetAddress.getAddress()));
+                        inetAddresses.add(Inet6Address.getByAddress(inetAddress.getAddress()));
                     } catch (UnknownHostException e) {
                         //ignored
                     }
                 } else {
-                    addresses.add(inetAddress);
+                    inetAddresses.add(inetAddress);
                 }
             }
         }
-        return addresses;
+        return inetAddresses.build();
     }
-
-    public static List<JingleCandidate> getLocalCandidates(Jid jid) {
-        SecureRandom random = new SecureRandom();
-        ArrayList<JingleCandidate> candidates = new ArrayList<>();
-        for (InetAddress inetAddress : getLocalAddresses()) {
-            final JingleCandidate candidate = new JingleCandidate(UUID.randomUUID().toString(), true);
-            candidate.setHost(inetAddress.getHostAddress());
-            candidate.setPort(random.nextInt(60000) + 1024);
-            candidate.setType(JingleCandidate.TYPE_DIRECT);
-            candidate.setJid(jid);
-            candidate.setPriority(8257536 + candidates.size());
-            candidates.add(candidate);
-        }
-        return candidates;
-    }
-
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java πŸ”—

@@ -0,0 +1,219 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
+import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.transports.Transport;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class FileTransferContentMap
+        extends AbstractContentMap<FileTransferDescription, GenericTransportInfo> {
+
+    private static final List<Class<? extends GenericTransportInfo>> SUPPORTED_TRANSPORTS =
+            Arrays.asList(
+                    SocksByteStreamsTransportInfo.class,
+                    IbbTransportInfo.class,
+                    WebRTCDataChannelTransportInfo.class);
+
+    protected FileTransferContentMap(
+            final Group group, final Map<String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
+                    contents) {
+        super(group, contents);
+    }
+
+    public static FileTransferContentMap of(final JinglePacket jinglePacket) {
+        final Map<String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
+                contents = of(jinglePacket.getJingleContents());
+        return new FileTransferContentMap(jinglePacket.getGroup(), contents);
+    }
+
+    public static DescriptionTransport<FileTransferDescription, GenericTransportInfo> of(
+            final Content content) {
+        final GenericDescription description = content.getDescription();
+        final GenericTransportInfo transportInfo = content.getTransport();
+        final Content.Senders senders = content.getSenders();
+        final FileTransferDescription fileTransferDescription;
+        if (description == null) {
+            fileTransferDescription = null;
+        } else if (description instanceof FileTransferDescription ftDescription) {
+            fileTransferDescription = ftDescription;
+        } else {
+            throw new UnsupportedApplicationException(
+                    "Content does not contain file transfer description");
+        }
+        if (!SUPPORTED_TRANSPORTS.contains(transportInfo.getClass())) {
+            throw new UnsupportedTransportException("Content does not have supported transport");
+        }
+        return new DescriptionTransport<>(senders, fileTransferDescription, transportInfo);
+    }
+
+    private static Map<String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
+            of(final Map<String, Content> contents) {
+        return ImmutableMap.copyOf(
+                Maps.transformValues(contents, content -> content == null ? null : of(content)));
+    }
+
+    public static FileTransferContentMap of(
+            final FileTransferDescription.File file, final Transport.InitialTransportInfo initialTransportInfo) {
+        // TODO copy groups
+        final var transportInfo = initialTransportInfo.transportInfo;
+        return new FileTransferContentMap(initialTransportInfo.group,
+                Map.of(
+                        initialTransportInfo.contentName,
+                        new DescriptionTransport<>(
+                                Content.Senders.INITIATOR,
+                                FileTransferDescription.of(file),
+                                transportInfo)));
+    }
+
+    public FileTransferDescription.File requireOnlyFile() {
+        if (this.contents.size() != 1) {
+            throw new IllegalStateException("Only one file at a time is supported");
+        }
+        final var dt = Iterables.getOnlyElement(this.contents.values());
+        return dt.description.getFile();
+    }
+
+    public FileTransferDescription requireOnlyFileTransferDescription() {
+        if (this.contents.size() != 1) {
+            throw new IllegalStateException("Only one file at a time is supported");
+        }
+        final var dt = Iterables.getOnlyElement(this.contents.values());
+        return dt.description;
+    }
+
+    public GenericTransportInfo requireOnlyTransportInfo() {
+        if (this.contents.size() != 1) {
+            throw new IllegalStateException(
+                    "We expect exactly one content with one transport info");
+        }
+        final var dt = Iterables.getOnlyElement(this.contents.values());
+        return dt.transport;
+    }
+
+    public FileTransferContentMap withTransport(final Transport.TransportInfo transportWrapper) {
+        final var transportInfo = transportWrapper.transportInfo;
+        return new FileTransferContentMap(transportWrapper.group,
+                ImmutableMap.copyOf(
+                        Maps.transformValues(
+                                contents,
+                                content -> {
+                                    if (content == null) {
+                                        return null;
+                                    }
+                                    return new DescriptionTransport<>(
+                                            content.senders, content.description, transportInfo);
+                                })));
+    }
+
+    public FileTransferContentMap candidateUsed(final String streamId, final String cid) {
+        return new FileTransferContentMap(null,
+                ImmutableMap.copyOf(
+                        Maps.transformValues(
+                                contents,
+                                content -> {
+                                    if (content == null) {
+                                        return null;
+                                    }
+                                    final var transportInfo =
+                                            new SocksByteStreamsTransportInfo(
+                                                    streamId, Collections.emptyList());
+                                    final Element candidateUsed =
+                                            transportInfo.addChild(
+                                                    "candidate-used",
+                                                    Namespace.JINGLE_TRANSPORTS_S5B);
+                                    candidateUsed.setAttribute("cid", cid);
+                                    return new DescriptionTransport<>(
+                                            content.senders, null, transportInfo);
+                                })));
+    }
+
+    public FileTransferContentMap candidateError(final String streamId) {
+        return new FileTransferContentMap(null,
+                ImmutableMap.copyOf(
+                        Maps.transformValues(
+                                contents,
+                                content -> {
+                                    if (content == null) {
+                                        return null;
+                                    }
+                                    final var transportInfo =
+                                            new SocksByteStreamsTransportInfo(
+                                                    streamId, Collections.emptyList());
+                                    transportInfo.addChild(
+                                            "candidate-error", Namespace.JINGLE_TRANSPORTS_S5B);
+                                    return new DescriptionTransport<>(
+                                            content.senders, null, transportInfo);
+                                })));
+    }
+
+    public FileTransferContentMap proxyActivated(final String streamId, final String cid) {
+        return new FileTransferContentMap(null,
+                ImmutableMap.copyOf(
+                        Maps.transformValues(
+                                contents,
+                                content -> {
+                                    if (content == null) {
+                                        return null;
+                                    }
+                                    final var transportInfo =
+                                            new SocksByteStreamsTransportInfo(
+                                                    streamId, Collections.emptyList());
+                                    final Element candidateUsed =
+                                            transportInfo.addChild(
+                                                    "activated", Namespace.JINGLE_TRANSPORTS_S5B);
+                                    candidateUsed.setAttribute("cid", cid);
+                                    return new DescriptionTransport<>(
+                                            content.senders, null, transportInfo);
+                                })));
+    }
+
+    FileTransferContentMap transportInfo() {
+        return new FileTransferContentMap(this.group,
+                Maps.transformValues(
+                        contents,
+                        dt -> new DescriptionTransport<>(dt.senders, null, dt.transport)));
+    }
+
+    FileTransferContentMap transportInfo(
+            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
+        final DescriptionTransport<FileTransferDescription, GenericTransportInfo> descriptionTransport =
+                contents.get(contentName);
+        if (descriptionTransport == null) {
+            throw new IllegalArgumentException(
+                    "Unable to find transport info for content name " + contentName);
+        }
+        final WebRTCDataChannelTransportInfo transportInfo;
+        if (descriptionTransport.transport instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+            transportInfo = webRTCDataChannelTransportInfo;
+        } else {
+            throw new IllegalStateException("TransportInfo is not WebRTCDataChannel");
+        }
+        final WebRTCDataChannelTransportInfo newTransportInfo = transportInfo.cloneWrapper();
+        newTransportInfo.addCandidate(candidate);
+        return new FileTransferContentMap(
+                null,
+                ImmutableMap.of(
+                        contentName,
+                        new DescriptionTransport<>(
+                                descriptionTransport.senders, null, newTransportInfo)));
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java πŸ”—

@@ -0,0 +1,98 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.util.Log;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.IP;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+import org.webrtc.PeerConnection;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public final class IceServers {
+
+    public static List<PeerConnection.IceServer> parse(final IqPacket response) {
+        ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
+        if (response.getType() == IqPacket.TYPE.RESULT) {
+            final Element services =
+                    response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+            final List<Element> children =
+                    services == null ? Collections.emptyList() : services.getChildren();
+            for (final Element child : children) {
+                if ("service".equals(child.getName())) {
+                    final String type = child.getAttribute("type");
+                    final String host = child.getAttribute("host");
+                    final String sport = child.getAttribute("port");
+                    final Integer port = sport == null ? null : Ints.tryParse(sport);
+                    final String transport = child.getAttribute("transport");
+                    final String username = child.getAttribute("username");
+                    final String password = child.getAttribute("password");
+                    if (Strings.isNullOrEmpty(host) || port == null) {
+                        continue;
+                    }
+                    if (port < 0 || port > 65535) {
+                        continue;
+                    }
+
+                    if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type)
+                            && Arrays.asList("udp", "tcp").contains(transport)) {
+                        if (Arrays.asList("stuns", "turns").contains(type)
+                                && "udp".equals(transport)) {
+                            Log.w(
+                                    Config.LOGTAG,
+                                    "skipping invalid combination of udp/tls in external services");
+                            continue;
+                        }
+
+                        // STUN URLs do not support a query section since M110
+                        final String uri;
+                        if (Arrays.asList("stun", "stuns").contains(type)) {
+                            uri = String.format("%s:%s:%s", type, IP.wrapIPv6(host), port);
+                        } else {
+                            uri =
+                                    String.format(
+                                            "%s:%s:%s?transport=%s",
+                                            type, IP.wrapIPv6(host), port, transport);
+                        }
+
+                        final PeerConnection.IceServer.Builder iceServerBuilder =
+                                PeerConnection.IceServer.builder(uri);
+                        iceServerBuilder.setTlsCertPolicy(
+                                PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
+                        if (username != null && password != null) {
+                            iceServerBuilder.setUsername(username);
+                            iceServerBuilder.setPassword(password);
+                        } else if (Arrays.asList("turn", "turns").contains(type)) {
+                            // The WebRTC spec requires throwing an
+                            // InvalidAccessError when username (from libwebrtc
+                            // source coder)
+                            // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
+                            Log.w(
+                                    Config.LOGTAG,
+                                    "skipping "
+                                            + type
+                                            + "/"
+                                            + transport
+                                            + " without username and password");
+                            continue;
+                        }
+                        final PeerConnection.IceServer iceServer =
+                                iceServerBuilder.createIceServer();
+                        Log.w(Config.LOGTAG, "discovered ICE Server: " + iceServer);
+                        listBuilder.add(iceServer);
+                    }
+                }
+            }
+        }
+        return listBuilder.build();
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java πŸ”—

@@ -1,152 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xmpp.InvalidJid;
-import eu.siacs.conversations.xmpp.Jid;
-
-public class JingleCandidate {
-
-	public static int TYPE_UNKNOWN;
-	public static int TYPE_DIRECT = 0;
-	public static int TYPE_PROXY = 1;
-
-	private final boolean ours;
-	private boolean usedByCounterpart = false;
-	private final String cid;
-	private String host;
-	private int port;
-	private int type;
-	private Jid jid;
-	private int priority;
-
-	public JingleCandidate(String cid, boolean ours) {
-		this.ours = ours;
-		this.cid = cid;
-	}
-
-	public String getCid() {
-		return cid;
-	}
-
-	public void setHost(String host) {
-		this.host = host;
-	}
-
-	public String getHost() {
-		return this.host;
-	}
-
-	public void setJid(final Jid jid) {
-		this.jid = jid;
-	}
-
-	public Jid getJid() {
-		return this.jid;
-	}
-
-	public void setPort(int port) {
-		this.port = port;
-	}
-
-	public int getPort() {
-		return this.port;
-	}
-
-	public void setType(int type) {
-		this.type = type;
-	}
-
-	public void setType(String type) {
-		if (type == null) {
-			this.type = TYPE_UNKNOWN;
-			return;
-		}
-        switch (type) {
-            case "proxy":
-                this.type = TYPE_PROXY;
-                break;
-            case "direct":
-                this.type = TYPE_DIRECT;
-                break;
-            default:
-                this.type = TYPE_UNKNOWN;
-                break;
-        }
-	}
-
-	public void setPriority(int i) {
-		this.priority = i;
-	}
-
-	public int getPriority() {
-		return this.priority;
-	}
-
-	public boolean equals(JingleCandidate other) {
-		return this.getCid().equals(other.getCid());
-	}
-
-	public boolean equalValues(JingleCandidate other) {
-		return other != null && other.getHost().equals(this.getHost()) && (other.getPort() == this.getPort());
-	}
-
-	public boolean isOurs() {
-		return ours;
-	}
-
-	public int getType() {
-		return this.type;
-	}
-
-	public static List<JingleCandidate> parse(final List<Element> elements) {
-		final List<JingleCandidate> candidates = new ArrayList<>();
-		for (final Element element : elements) {
-			if ("candidate".equals(element.getName())) {
-				candidates.add(JingleCandidate.parse(element));
-			}
-		}
-		return candidates;
-	}
-
-	public static JingleCandidate parse(Element element) {
-		final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false);
-		candidate.setHost(element.getAttribute("host"));
-		candidate.setJid(InvalidJid.getNullForInvalid(element.getAttributeAsJid("jid")));
-		candidate.setType(element.getAttribute("type"));
-		candidate.setPriority(Integer.parseInt(element.getAttribute("priority")));
-		candidate.setPort(Integer.parseInt(element.getAttribute("port")));
-		return candidate;
-	}
-
-	public Element toElement() {
-		Element element = new Element("candidate");
-		element.setAttribute("cid", this.getCid());
-		element.setAttribute("host", this.getHost());
-		element.setAttribute("port", Integer.toString(this.getPort()));
-		if (jid != null) {
-			element.setAttribute("jid", jid);
-		}
-		element.setAttribute("priority", Integer.toString(this.getPriority()));
-		if (this.getType() == TYPE_DIRECT) {
-			element.setAttribute("type", "direct");
-		} else if (this.getType() == TYPE_PROXY) {
-			element.setAttribute("type", "proxy");
-		}
-		return element;
-	}
-
-	public void flagAsUsedByCounterpart() {
-		this.usedByCounterpart = true;
-	}
-
-	public boolean isUsedByCounterpart() {
-		return this.usedByCounterpart;
-	}
-
-	public String toString() {
-		return String.format("%s:%s (priority=%s,ours=%s)", getHost(), getPort(), getPriority(), isOurs());
-	}
-}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java πŸ”—

@@ -29,10 +29,13 @@ import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
+import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport;
+import eu.siacs.conversations.xmpp.jingle.transports.Transport;
 import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 
@@ -61,8 +64,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
     private final Cache<PersistableSessionId, TerminatedRtpSession> terminatedSessions =
             CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();
 
-    private final HashMap<Jid, JingleCandidate> primaryCandidates = new HashMap<>();
-
     public JingleConnectionManager(XmppConnectionService service) {
         super(service);
         this.toneManager = new ToneManager(service);
@@ -90,7 +91,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
             final String descriptionNamespace =
                     content == null ? null : content.getDescriptionNamespace();
             final AbstractJingleConnection connection;
-            if (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) {
+            if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(descriptionNamespace)) {
                 connection = new JingleFileTransferConnection(this, id, from);
             } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace)
                     && isUsingClearNet(account)) {
@@ -593,13 +594,10 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         if (old != null) {
             old.cancel();
         }
-        final Account account = message.getConversation().getAccount();
-        final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message);
         final JingleFileTransferConnection connection =
-                new JingleFileTransferConnection(this, id, account.getJid());
-        mXmppConnectionService.markMessage(message, Message.STATUS_WAITING);
-        this.connections.put(id, connection);
-        connection.init(message);
+                new JingleFileTransferConnection(this, message);
+        this.connections.put(connection.getId(), connection);
+        connection.sendSessionInitialize();
     }
 
     public Optional<OngoingRtpSession> getOngoingRtpConnection(final Contact contact) {
@@ -658,60 +656,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return firedUpdates;
     }
 
-    void getPrimaryCandidate(
-            final Account account,
-            final boolean initiator,
-            final OnPrimaryCandidateFound listener) {
-        if (Config.DISABLE_PROXY_LOOKUP) {
-            listener.onPrimaryCandidateFound(false, null);
-            return;
-        }
-        if (this.primaryCandidates.containsKey(account.getJid().asBareJid())) {
-            listener.onPrimaryCandidateFound(
-                    true, this.primaryCandidates.get(account.getJid().asBareJid()));
-            return;
-        }
-
-        final Jid proxy =
-                account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS);
-        if (proxy == null) {
-            listener.onPrimaryCandidateFound(false, null);
-            return;
-        }
-        final IqPacket iq = new IqPacket(IqPacket.TYPE.GET);
-        iq.setTo(proxy);
-        iq.query(Namespace.BYTE_STREAMS);
-        account.getXmppConnection()
-                .sendIqPacket(
-                        iq,
-                        (a, response) -> {
-                            final Element streamhost =
-                                    response.query()
-                                            .findChild("streamhost", Namespace.BYTE_STREAMS);
-                            final String host =
-                                    streamhost == null ? null : streamhost.getAttribute("host");
-                            final String port =
-                                    streamhost == null ? null : streamhost.getAttribute("port");
-                            if (host != null && port != null) {
-                                try {
-                                    JingleCandidate candidate =
-                                            new JingleCandidate(nextRandomId(), true);
-                                    candidate.setHost(host);
-                                    candidate.setPort(Integer.parseInt(port));
-                                    candidate.setType(JingleCandidate.TYPE_PROXY);
-                                    candidate.setJid(proxy);
-                                    candidate.setPriority(655360 + (initiator ? 30 : 0));
-                                    primaryCandidates.put(a.getJid().asBareJid(), candidate);
-                                    listener.onPrimaryCandidateFound(true, candidate);
-                                } catch (final NumberFormatException e) {
-                                    listener.onPrimaryCandidateFound(false, null);
-                                }
-                            } else {
-                                listener.onPrimaryCandidateFound(false, null);
-                            }
-                        });
-    }
-
     public void retractSessionProposal(final Account account, final Jid with) {
         synchronized (this.rtpSessionProposals) {
             RtpSessionProposal matchingProposal = null;
@@ -810,36 +754,53 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return false;
     }
 
-    public void deliverIbbPacket(Account account, IqPacket packet) {
+    public void deliverIbbPacket(final Account account, final IqPacket packet) {
         final String sid;
         final Element payload;
+        final InbandBytestreamsTransport.PacketType packetType;
         if (packet.hasChild("open", Namespace.IBB)) {
+            packetType = InbandBytestreamsTransport.PacketType.OPEN;
             payload = packet.findChild("open", Namespace.IBB);
             sid = payload.getAttribute("sid");
         } else if (packet.hasChild("data", Namespace.IBB)) {
+            packetType = InbandBytestreamsTransport.PacketType.DATA;
             payload = packet.findChild("data", Namespace.IBB);
             sid = payload.getAttribute("sid");
         } else if (packet.hasChild("close", Namespace.IBB)) {
+            packetType = InbandBytestreamsTransport.PacketType.CLOSE;
             payload = packet.findChild("close", Namespace.IBB);
             sid = payload.getAttribute("sid");
         } else {
+            packetType = null;
             payload = null;
             sid = null;
         }
-        if (sid != null) {
-            for (final AbstractJingleConnection connection : this.connections.values()) {
-                if (connection instanceof JingleFileTransferConnection fileTransfer) {
-                    final JingleTransport transport = fileTransfer.getTransport();
-                    if (transport instanceof JingleInBandTransport inBandTransport) {
-                        if (inBandTransport.matches(account, sid)) {
-                            inBandTransport.deliverPayload(packet, payload);
+        if (sid == null) {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet. missing sid");
+            account.getXmppConnection()
+                    .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
+            return;
+        }
+        for (final AbstractJingleConnection connection : this.connections.values()) {
+            if (connection instanceof JingleFileTransferConnection fileTransfer) {
+                final Transport transport = fileTransfer.getTransport();
+                if (transport instanceof InbandBytestreamsTransport inBandTransport) {
+                    if (sid.equals(inBandTransport.getStreamId())) {
+                        if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) {
+                            account.getXmppConnection()
+                                    .sendIqPacket(
+                                            packet.generateResponse(IqPacket.TYPE.RESULT), null);
+                        } else {
+                            account.getXmppConnection()
+                                    .sendIqPacket(
+                                            packet.generateResponse(IqPacket.TYPE.ERROR), null);
                         }
                         return;
                     }
                 }
             }
         }
-        Log.d(Config.LOGTAG, "unable to deliver ibb packet: " + packet);
+        Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet with sid="+sid);
         account.getXmppConnection()
                 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
     }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java πŸ”—

@@ -1,1255 +1,1454 @@
 package eu.siacs.conversations.xmpp.jingle;
 
-import android.util.Base64;
 import android.util.Log;
 
+import androidx.annotation.NonNull;
+
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.concurrent.ConcurrentHashMap;
+import com.google.common.hash.Hashing;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
 
 import eu.siacs.conversations.Config;
-import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
-import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Conversation;
-import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.entities.Presence;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.entities.TransferablePlaceholder;
-import eu.siacs.conversations.parser.IqParser;
-import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.AbstractConnectionManager;
-import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
-import eu.siacs.conversations.xmpp.OnIqPacketReceived;
-import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
-import eu.siacs.conversations.xmpp.jingle.stanzas.S5BTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport;
+import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport;
+import eu.siacs.conversations.xmpp.jingle.transports.Transport;
+import eu.siacs.conversations.xmpp.jingle.transports.WebRTCDataChannelTransport;
 import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 
-public class JingleFileTransferConnection extends AbstractJingleConnection implements Transferable {
-
-    private static final int JINGLE_STATUS_TRANSMITTING = 5;
-    private static final String JET_OMEMO_CIPHER = "urn:xmpp:ciphers:aes-128-gcm-nopadding";
-    private static final int JINGLE_STATUS_INITIATED = 0;
-    private static final int JINGLE_STATUS_ACCEPTED = 1;
-    private static final int JINGLE_STATUS_FINISHED = 4;
-    private static final int JINGLE_STATUS_FAILED = 99;
-    private static final int JINGLE_STATUS_OFFERED = -1;
-
-    private static final int MAX_IBB_BLOCK_SIZE = 8192;
-
-    private int ibbBlockSize = MAX_IBB_BLOCK_SIZE;
-
-    private int mJingleStatus = JINGLE_STATUS_OFFERED; //migrate to enum
-    private int mStatus = Transferable.STATUS_UNKNOWN;
-    private Message message;
-    private Jid responder;
-    private final List<JingleCandidate> candidates = new ArrayList<>();
-    private final ConcurrentHashMap<String, JingleSocks5Transport> connections = new ConcurrentHashMap<>();
-
-    private String transportId;
-    private FileTransferDescription description;
-    private DownloadableFile file = null;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.io.CipherOutputStream;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.webrtc.IceCandidate;
+
+import java.io.Closeable;
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Queue;
+import java.util.concurrent.CountDownLatch;
 
-    private boolean proxyActivationFailed = false;
+public class JingleFileTransferConnection extends AbstractJingleConnection
+        implements Transport.Callback, Transferable {
 
-    private String contentName;
-    private Content.Creator contentCreator;
-    private Content.Senders contentSenders;
-    private Class<? extends GenericTransportInfo> initialTransport;
-    private boolean remoteSupportsOmemoJet;
+    private final Message message;
 
-    private int mProgress = 0;
+    private FileTransferContentMap initiatorFileTransferContentMap;
+    private FileTransferContentMap responderFileTransferContentMap;
 
-    private boolean receivedCandidate = false;
-    private boolean sentCandidate = false;
+    private Transport transport;
+    private TransportSecurity transportSecurity;
+    private AbstractFileTransceiver fileTransceiver;
 
+    private final Queue<IceCandidate> pendingIncomingIceCandidates = new LinkedList<>();
     private boolean acceptedAutomatically = false;
-    private boolean cancelled = false;
-
-    private XmppAxolotlMessage mXmppAxolotlMessage;
 
-    private JingleTransport transport = null;
+    public JingleFileTransferConnection(
+            final JingleConnectionManager jingleConnectionManager, final Message message) {
+        super(
+                jingleConnectionManager,
+                AbstractJingleConnection.Id.of(message),
+                message.getConversation().getAccount().getJid());
+        Preconditions.checkArgument(
+                message.isFileOrImage(),
+                "only file or images messages can be transported via jingle");
+        this.message = message;
+        this.message.setTransferable(this);
+        xmppConnectionService.markMessage(message, Message.STATUS_WAITING);
+    }
 
-    private OutputStream mFileOutputStream;
-    private InputStream mFileInputStream;
+    public JingleFileTransferConnection(
+            final JingleConnectionManager jingleConnectionManager,
+            final Id id,
+            final Jid initiator) {
+        super(jingleConnectionManager, id, initiator);
+        final Conversation conversation =
+                this.xmppConnectionService.findOrCreateConversation(
+                        id.account, id.with.asBareJid(), false, false);
+        this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
+        this.message.setStatus(Message.STATUS_RECEIVED);
+        this.message.setErrorMessage(null);
+        this.message.setTransferable(this);
+    }
 
-    private final OnIqPacketReceived responseListener = (account, packet) -> {
-        if (packet.getType() != IqPacket.TYPE.RESULT) {
-            if (mJingleStatus != JINGLE_STATUS_FAILED && mJingleStatus != JINGLE_STATUS_FINISHED) {
-                fail(IqParser.extractErrorMessage(packet));
-            } else {
-                Log.d(Config.LOGTAG, "ignoring late delivery of jingle packet to jingle session with status=" + mJingleStatus + ": " + packet.toString());
+    @Override
+    void deliverPacket(final JinglePacket jinglePacket) {
+        switch (jinglePacket.getAction()) {
+            case SESSION_ACCEPT -> receiveSessionAccept(jinglePacket);
+            case SESSION_INITIATE -> receiveSessionInitiate(jinglePacket);
+            case SESSION_INFO -> receiveSessionInfo(jinglePacket);
+            case SESSION_TERMINATE -> receiveSessionTerminate(jinglePacket);
+            case TRANSPORT_ACCEPT -> receiveTransportAccept(jinglePacket);
+            case TRANSPORT_INFO -> receiveTransportInfo(jinglePacket);
+            case TRANSPORT_REPLACE -> receiveTransportReplace(jinglePacket);
+            default -> {
+                respondOk(jinglePacket);
+                Log.d(
+                        Config.LOGTAG,
+                        String.format(
+                                "%s: received unhandled jingle action %s",
+                                id.account.getJid().asBareJid(), jinglePacket.getAction()));
             }
         }
-    };
-    private byte[] expectedHash = new byte[0];
-    private final OnFileTransmissionStatusChanged onFileTransmissionStatusChanged = new OnFileTransmissionStatusChanged() {
+    }
 
-        @Override
-        public void onFileTransmitted(DownloadableFile file) {
-            if (responding()) {
-                if (expectedHash.length > 0) {
-                    if (Arrays.equals(expectedHash, file.getSha1Sum())) {
-                        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received file matched the expected hash");
-                    } else {
-                        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": hashes did not match");
+    public void sendSessionInitialize() {
+        final ListenableFuture<Optional<XmppAxolotlMessage>> keyTransportMessage;
+        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
+            keyTransportMessage =
+                    Futures.transform(
+                            id.account
+                                    .getAxolotlService()
+                                    .prepareKeyTransportMessage(requireConversation()),
+                            Optional::of,
+                            MoreExecutors.directExecutor());
+        } else {
+            keyTransportMessage = Futures.immediateFuture(Optional.empty());
+        }
+        Futures.addCallback(
+                keyTransportMessage,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Optional<XmppAxolotlMessage> xmppAxolotlMessage) {
+                        sendSessionInitialize(xmppAxolotlMessage.orElse(null));
                     }
-                } else {
-                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer");
-                }
-                sendSuccess();
-                xmppConnectionService.getFileBackend().updateFileParams(message);
-                xmppConnectionService.databaseBackend.createMessage(message);
-                xmppConnectionService.markMessage(message, Message.STATUS_RECEIVED);
-                if (acceptedAutomatically) {
-                    message.markUnread();
-                    if (message.getEncryption() == Message.ENCRYPTION_PGP) {
-                        id.account.getPgpDecryptionService().decrypt(message, true);
-                    } else {
-                        xmppConnectionService.getFileBackend().updateMediaScanner(file, () -> JingleFileTransferConnection.this.xmppConnectionService.getNotificationService().push(message));
 
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        Log.d(Config.LOGTAG, "can not send message");
                     }
-                    Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
-                    return;
-                } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
-                    id.account.getPgpDecryptionService().decrypt(message, true);
-                }
-            } else {
-                if (description.getVersion() == FileTransferDescription.Version.FT_5) { //older Conversations will break when receiving a session-info
-                    sendHash();
-                }
-                if (message.getEncryption() == Message.ENCRYPTION_PGP) {
-                    id.account.getPgpDecryptionService().decrypt(message, false);
-                }
-                if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
-                    file.delete();
-                }
-            }
-            Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
-            if (message.getEncryption() != Message.ENCRYPTION_PGP) {
-                xmppConnectionService.getFileBackend().updateMediaScanner(file);
-            }
-        }
+                },
+                MoreExecutors.directExecutor());
+    }
 
-        @Override
-        public void onFileTransferAborted() {
-            JingleFileTransferConnection.this.sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
-            JingleFileTransferConnection.this.fail();
-        }
-    };
-    private final OnTransportConnected onIbbTransportConnected = new OnTransportConnected() {
-        @Override
-        public void failed() {
-            Log.d(Config.LOGTAG, "ibb open failed");
-        }
+    private void sendSessionInitialize(final XmppAxolotlMessage xmppAxolotlMessage) {
+        this.transport = setupTransport();
+        this.transport.setTransportCallback(this);
+        final File file = xmppConnectionService.getFileBackend().getFile(message);
+        final var fileDescription =
+                new FileTransferDescription.File(
+                        file.length(),
+                        file.getName(),
+                        message.getMimeType(),
+                        Collections.emptyList());
+        final var transportInfoFuture = this.transport.asInitialTransportInfo();
+        Futures.addCallback(
+                transportInfoFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(
+                            final Transport.InitialTransportInfo initialTransportInfo) {
+                        final FileTransferContentMap contentMap =
+                                FileTransferContentMap.of(fileDescription, initialTransportInfo);
+                        sendSessionInitialize(xmppAxolotlMessage, contentMap);
+                    }
 
-        @Override
-        public void established() {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ibb transport connected. sending file");
-            mJingleStatus = JINGLE_STATUS_TRANSMITTING;
-            JingleFileTransferConnection.this.transport.send(file, onFileTransmissionStatusChanged);
-        }
-    };
-    private final OnProxyActivated onProxyActivated = new OnProxyActivated() {
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {}
+                },
+                MoreExecutors.directExecutor());
+    }
 
-        @Override
-        public void success() {
-            if (isInitiator()) {
-                Log.d(Config.LOGTAG, "we were initiating. sending file");
-                transport.send(file, onFileTransmissionStatusChanged);
-            } else {
-                transport.receive(file, onFileTransmissionStatusChanged);
-                Log.d(Config.LOGTAG, "we were responding. receiving file");
-            }
+    private Conversation requireConversation() {
+        final var conversational = message.getConversation();
+        if (conversational instanceof Conversation c) {
+            return c;
+        } else {
+            throw new IllegalStateException("Message had no proper conversation attached");
         }
+    }
 
-        @Override
-        public void failed() {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": proxy activation failed");
-            proxyActivationFailed = true;
-            if (isInitiator()) {
-                sendFallbackToIbb();
+    private void sendSessionInitialize(
+            final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) {
+        if (transition(
+                State.SESSION_INITIALIZED,
+                () -> this.initiatorFileTransferContentMap = contentMap)) {
+            final var jinglePacket =
+                    contentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
+            if (xmppAxolotlMessage != null) {
+                this.transportSecurity =
+                        new TransportSecurity(
+                                xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV());
+                jinglePacket.setSecurity(
+                        Iterables.getOnlyElement(contentMap.contents.keySet()), xmppAxolotlMessage);
             }
+            Log.d(Config.LOGTAG, "--> " + jinglePacket.toString());
+            jinglePacket.setTo(id.with);
+            xmppConnectionService.sendIqPacket(
+                    id.account,
+                    jinglePacket,
+                    (a, response) -> {
+                        if (response.getType() == IqPacket.TYPE.RESULT) {
+                            xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
+                            return;
+                        }
+                        if (response.getType() == IqPacket.TYPE.ERROR) {
+                            handleIqErrorResponse(response);
+                            return;
+                        }
+                        if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+                            handleIqTimeoutResponse(response);
+                        }
+                    });
+            this.transport.readyToSentAdditionalCandidates();
         }
-    };
-
-    JingleFileTransferConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
-        super(jingleConnectionManager, id, initiator);
     }
 
-    private static long parseLong(final Element element, final long l) {
-        final String input = element == null ? null : element.getContent();
-        if (input == null) {
-            return l;
+    private void receiveSessionAccept(final JinglePacket jinglePacket) {
+        Log.d(Config.LOGTAG, "receive session accept " + jinglePacket);
+        if (isResponder()) {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT);
+            return;
         }
+        final FileTransferContentMap contentMap;
         try {
-            return Long.parseLong(input);
-        } catch (Exception e) {
-            return l;
+            contentMap = FileTransferContentMap.of(jinglePacket);
+            contentMap.requireOnlyFileTransferDescription();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
         }
+        receiveSessionAccept(jinglePacket, contentMap);
     }
 
-    //TODO get rid and use isInitiator() instead
-    private boolean responding() {
-        return responder != null && responder.equals(id.account.getJid());
+    private void receiveSessionAccept(
+            final JinglePacket jinglePacket, final FileTransferContentMap contentMap) {
+        if (transition(State.SESSION_ACCEPTED, () -> setRemoteContentMap(contentMap))) {
+            respondOk(jinglePacket);
+            final var transport = this.transport;
+            if (configureTransportWithPeerInfo(transport, contentMap)) {
+                transport.connect();
+            } else {
+                Log.e(
+                        Config.LOGTAG,
+                        "Transport in session accept did not match our session-initialize");
+                terminateTransport();
+                sendSessionTerminate(
+                        Reason.FAILED_APPLICATION,
+                        "Transport in session accept did not match our session-initialize");
+            }
+        } else {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": receive out of order session-accept");
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT);
+        }
     }
 
-
-    InputStream getFileInputStream() {
-        return this.mFileInputStream;
+    private static boolean configureTransportWithPeerInfo(
+            final Transport transport, final FileTransferContentMap contentMap) {
+        final GenericTransportInfo transportInfo = contentMap.requireOnlyTransportInfo();
+        if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
+                && transportInfo instanceof WebRTCDataChannelTransportInfo) {
+            webRTCDataChannelTransport.setResponderDescription(SessionDescription.of(contentMap));
+            return true;
+        } else if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
+                && transportInfo
+                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
+            socksBytestreamsTransport.setTheirCandidates(
+                    socksBytestreamsTransportInfo.getCandidates());
+            return true;
+        } else if (transport instanceof InbandBytestreamsTransport inbandBytestreamsTransport
+                && transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
+            final var peerBlockSize = ibbTransportInfo.getBlockSize();
+            if (peerBlockSize != null) {
+                inbandBytestreamsTransport.setPeerBlockSize(peerBlockSize);
+            }
+            return true;
+        } else {
+            return false;
+        }
     }
 
-    OutputStream getFileOutputStream() throws IOException {
-        if (this.file == null) {
-            Log.d(Config.LOGTAG, "file object was not assigned");
-            return null;
+    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
+        if (isInitiator()) {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE);
+            return;
         }
-        final File parent = this.file.getParentFile();
-        if (parent != null && parent.mkdirs()) {
-            Log.d(Config.LOGTAG, "created parent directories for file " + file.getAbsolutePath());
+        Log.d(Config.LOGTAG, "receive session initiate " + jinglePacket);
+        final FileTransferContentMap contentMap;
+        final FileTransferDescription.File file;
+        try {
+            contentMap = FileTransferContentMap.of(jinglePacket);
+            contentMap.requireContentDescriptions();
+            file = contentMap.requireOnlyFile();
+            // TODO check is offer
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
         }
-        if (this.file.createNewFile()) {
-            Log.d(Config.LOGTAG, "created output file " + file.getAbsolutePath());
+        final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage;
+        final var security =
+                jinglePacket.getSecurity(Iterables.getOnlyElement(contentMap.contents.keySet()));
+        if (security != null) {
+            Log.d(Config.LOGTAG, "found security element!");
+            keyTransportMessage =
+                    id.account
+                            .getAxolotlService()
+                            .processReceivingKeyTransportMessage(security, false);
+        } else {
+            keyTransportMessage = null;
         }
-        this.mFileOutputStream = AbstractConnectionManager.createOutputStream(this.file, false, true);
-        return this.mFileOutputStream;
+        receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage);
     }
 
-    @Override
-    void deliverPacket(final JinglePacket packet) {
-        final JinglePacket.Action action = packet.getAction();
-        //TODO switch case
-        if (action == JinglePacket.Action.SESSION_INITIATE) {
-            init(packet);
-        } else if (action == JinglePacket.Action.SESSION_TERMINATE) {
-            final Reason reason = packet.getReason().reason;
-            switch (reason) {
-                case CANCEL:
-                    this.cancelled = true;
-                    this.fail();
-                    break;
-                case SUCCESS:
-                    this.receiveSuccess();
-                    break;
-                default:
-                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate with reason " + reason);
-                    this.fail();
-                    break;
-
-            }
-        } else if (action == JinglePacket.Action.SESSION_ACCEPT) {
-            receiveAccept(packet);
-        } else if (action == JinglePacket.Action.SESSION_INFO) {
-            final Element checksum = packet.getJingleChild("checksum");
-            final Element file = checksum == null ? null : checksum.findChild("file");
-            final Element hash = file == null ? null : file.findChild("hash", "urn:xmpp:hashes:2");
-            if (hash != null && "sha-1".equalsIgnoreCase(hash.getAttribute("algo"))) {
-                try {
-                    this.expectedHash = Base64.decode(hash.getContent(), Base64.DEFAULT);
-                } catch (Exception e) {
-                    this.expectedHash = new byte[0];
-                }
+    private void receiveSessionInitiate(
+            final JinglePacket jinglePacket,
+            final FileTransferContentMap contentMap,
+            final FileTransferDescription.File file,
+            final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage) {
+
+        if (transition(State.SESSION_INITIALIZED, () -> setRemoteContentMap(contentMap))) {
+            respondOk(jinglePacket);
+            Log.d(Config.LOGTAG, jinglePacket.toString());
+            Log.d(
+                    Config.LOGTAG,
+                    "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage));
+            setFileOffer(file);
+            if (keyTransportMessage != null) {
+                this.transportSecurity =
+                        new TransportSecurity(
+                                keyTransportMessage.getKey(), keyTransportMessage.getIv());
+                this.message.setFingerprint(keyTransportMessage.getFingerprint());
+                this.message.setEncryption(Message.ENCRYPTION_AXOLOTL);
+            } else {
+                this.transportSecurity = null;
+                this.message.setFingerprint(null);
             }
-            respondToIq(packet, true);
-        } else if (action == JinglePacket.Action.TRANSPORT_INFO) {
-            receiveTransportInfo(packet);
-        } else if (action == JinglePacket.Action.TRANSPORT_REPLACE) {
-            final Content content = packet.getJingleContent();
-            final GenericTransportInfo transportInfo = content == null ? null : content.getTransport();
-            if (transportInfo instanceof IbbTransportInfo) {
-                receiveFallbackToIbb(packet, (IbbTransportInfo) transportInfo);
+            final var conversation = (Conversation) message.getConversation();
+            conversation.add(message);
+
+            // make auto accept decision
+            if (id.account.getRoster().getContact(id.with).showInContactList()
+                    && jingleConnectionManager.hasStoragePermission()
+                    && file.size <= this.jingleConnectionManager.getAutoAcceptFileSize()
+                    && xmppConnectionService.isDataSaverDisabled()) {
+                Log.d(Config.LOGTAG, "auto accepting file from " + id.with);
+                this.acceptedAutomatically = true;
+                this.sendSessionAccept();
             } else {
-                Log.d(Config.LOGTAG, "trying to fallback to something unknown" + packet.toString());
-                respondToIq(packet, false);
+                Log.d(
+                        Config.LOGTAG,
+                        "not auto accepting new file offer with size: "
+                                + file.size
+                                + " allowed size:"
+                                + this.jingleConnectionManager.getAutoAcceptFileSize());
+                message.markUnread();
+                this.xmppConnectionService.updateConversationUi();
+                this.xmppConnectionService.getNotificationService().push(message);
             }
-        } else if (action == JinglePacket.Action.TRANSPORT_ACCEPT) {
-            receiveTransportAccept(packet);
         } else {
-            Log.d(Config.LOGTAG, "packet arrived in connection. action was " + packet.getAction());
-            respondToIq(packet, false);
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": receive out of order session-initiate");
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE);
         }
     }
 
-    @Override
-    void notifyRebound() {
-        if (getJingleStatus() == JINGLE_STATUS_TRANSMITTING) {
-            abort(Reason.CONNECTIVITY_ERROR);
+    private void setFileOffer(final FileTransferDescription.File file) {
+        final AbstractConnectionManager.Extension extension =
+                AbstractConnectionManager.Extension.of(file.name);
+        if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
+            this.message.setEncryption(Message.ENCRYPTION_PGP);
+        } else {
+            this.message.setEncryption(Message.ENCRYPTION_NONE);
         }
+        final String ext = extension.getExtension();
+        final String filename =
+                Strings.isNullOrEmpty(ext)
+                        ? message.getUuid()
+                        : String.format("%s.%s", message.getUuid(), ext);
+        xmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
     }
 
-    private void respondToIq(final IqPacket packet, final boolean result) {
-        final IqPacket response;
-        if (result) {
-            response = packet.generateResponse(IqPacket.TYPE.RESULT);
-        } else {
-            response = packet.generateResponse(IqPacket.TYPE.ERROR);
-            final Element error = response.addChild("error").setAttribute("type", "cancel");
-            error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas");
+    public void sendSessionAccept() {
+        final FileTransferContentMap contentMap = this.initiatorFileTransferContentMap;
+        final Transport transport;
+        try {
+            transport = setupTransport(contentMap.requireOnlyTransportInfo());
+        } catch (final RuntimeException e) {
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
         }
-        xmppConnectionService.sendIqPacket(id.account, response, null);
-    }
+        transitionOrThrow(State.SESSION_ACCEPTED);
+        this.transport = transport;
+        this.transport.setTransportCallback(this);
+        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
+            final var sessionDescription = SessionDescription.of(contentMap);
+            webRTCDataChannelTransport.setInitiatorDescription(sessionDescription);
+        }
+        final var transportInfoFuture = transport.asTransportInfo();
+        Futures.addCallback(
+                transportInfoFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Transport.TransportInfo transportInfo) {
+                        final FileTransferContentMap responderContentMap =
+                                contentMap.withTransport(transportInfo);
+                        sendSessionAccept(responderContentMap);
+                    }
 
-    private void respondToIqWithOutOfOrder(final IqPacket packet) {
-        final IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR);
-        final Element error = response.addChild("error").setAttribute("type", "wait");
-        error.addChild("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas");
-        error.addChild("out-of-order", "urn:xmpp:jingle:errors:1");
-        xmppConnectionService.sendIqPacket(id.account, response, null);
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        failureToAcceptSession(throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
-    public void init(final Message message) {
-        Preconditions.checkArgument(message.isFileOrImage());
-        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
-            Conversation conversation = (Conversation) message.getConversation();
-            conversation.getAccount().getAxolotlService().prepareKeyTransportMessage(conversation, xmppAxolotlMessage -> {
-                if (xmppAxolotlMessage != null) {
-                    init(message, xmppAxolotlMessage);
-                } else {
-                    fail();
-                }
-            });
-        } else {
-            init(message, null);
+    private void sendSessionAccept(final FileTransferContentMap contentMap) {
+        setLocalContentMap(contentMap);
+        final var jinglePacket =
+                contentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
+        Log.d(Config.LOGTAG, "--> " + jinglePacket.toString());
+        send(jinglePacket);
+        // this needs to come after session-accept or else our candidate-error might arrive first
+        this.transport.connect();
+        this.transport.readyToSentAdditionalCandidates();
+        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
+            drainPendingIncomingIceCandidates(webRTCDataChannelTransport);
         }
     }
 
-    private void init(final Message message, final XmppAxolotlMessage xmppAxolotlMessage) {
-        this.mXmppAxolotlMessage = xmppAxolotlMessage;
-        this.contentCreator = Content.Creator.INITIATOR;
-        this.contentSenders = Content.Senders.INITIATOR;
-        this.contentName = JingleConnectionManager.nextRandomId();
-        this.message = message;
-        final List<String> remoteFeatures = getRemoteFeatures();
-        final FileTransferDescription.Version remoteVersion = getAvailableFileTransferVersion(remoteFeatures);
-        this.initialTransport = remoteFeatures.contains(Namespace.JINGLE_TRANSPORTS_S5B) ? S5BTransportInfo.class : IbbTransportInfo.class;
-        this.remoteSupportsOmemoJet = remoteFeatures.contains(Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO);
-        this.message.setTransferable(this);
-        this.mStatus = Transferable.STATUS_UPLOADING;
-        this.responder = this.id.with;
-        this.transportId = JingleConnectionManager.nextRandomId();
-        this.setupDescription(remoteVersion);
-        if (this.initialTransport == IbbTransportInfo.class) {
-            this.sendInitRequest();
-        } else {
-            gatherAndConnectDirectCandidates();
-            this.jingleConnectionManager.getPrimaryCandidate(id.account, isInitiator(), (success, candidate) -> {
-                if (success) {
-                    final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate);
-                    connections.put(candidate.getCid(), socksConnection);
-                    socksConnection.connect(new OnTransportConnected() {
-
-                        @Override
-                        public void failed() {
-                            Log.d(Config.LOGTAG, String.format("connection to our own proxy65 candidate failed (%s:%d)", candidate.getHost(), candidate.getPort()));
-                            sendInitRequest();
-                        }
-
-                        @Override
-                        public void established() {
-                            Log.d(Config.LOGTAG, "successfully connected to our own proxy65 candidate");
-                            mergeCandidate(candidate);
-                            sendInitRequest();
-                        }
-                    });
-                    mergeCandidate(candidate);
-                } else {
-                    Log.d(Config.LOGTAG, "no proxy65 candidate of our own was found");
-                    sendInitRequest();
-                }
-            });
+    private void drainPendingIncomingIceCandidates(
+            final WebRTCDataChannelTransport webRTCDataChannelTransport) {
+        while (this.pendingIncomingIceCandidates.peek() != null) {
+            final var candidate = this.pendingIncomingIceCandidates.poll();
+            if (candidate == null) {
+                continue;
+            }
+            webRTCDataChannelTransport.addIceCandidates(ImmutableList.of(candidate));
         }
-
     }
 
-    private void gatherAndConnectDirectCandidates() {
-        final List<JingleCandidate> directCandidates;
-        if (Config.USE_DIRECT_JINGLE_CANDIDATES) {
-            if (id.account.isOnion() || xmppConnectionService.useTorToConnect()) {
-                directCandidates = Collections.emptyList();
-            } else {
-                directCandidates = DirectConnectionUtils.getLocalCandidates(id.account.getJid());
+    private Transport setupTransport(final GenericTransportInfo transportInfo) {
+        final XmppConnection xmppConnection = id.account.getXmppConnection();
+        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
+        if (transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
+            final String streamId = ibbTransportInfo.getTransportId();
+            final Long blockSize = ibbTransportInfo.getBlockSize();
+            if (streamId == null || blockSize == null) {
+                throw new IllegalStateException("ibb transport is missing sid and/or block-size");
             }
+            return new InbandBytestreamsTransport(
+                    xmppConnection,
+                    id.with,
+                    isInitiator(),
+                    streamId,
+                    Ints.saturatedCast(blockSize));
+        } else if (transportInfo
+                instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
+            final String streamId = socksBytestreamsTransportInfo.getTransportId();
+            final String destination = socksBytestreamsTransportInfo.getDestinationAddress();
+            final List<SocksByteStreamsTransport.Candidate> candidates =
+                    socksBytestreamsTransportInfo.getCandidates();
+            Log.d(Config.LOGTAG, "received socks candidates " + candidates);
+            return new SocksByteStreamsTransport(
+                    xmppConnection, id, isInitiator(), useTor, streamId, candidates);
+        } else if (!useTor && transportInfo instanceof WebRTCDataChannelTransportInfo) {
+            return new WebRTCDataChannelTransport(
+                    xmppConnectionService.getApplicationContext(),
+                    xmppConnection,
+                    id.account,
+                    isInitiator());
         } else {
-            directCandidates = Collections.emptyList();
-        }
-        for (JingleCandidate directCandidate : directCandidates) {
-            final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, directCandidate);
-            connections.put(directCandidate.getCid(), socksConnection);
-            candidates.add(directCandidate);
+            throw new IllegalArgumentException("Do not know how to create transport");
         }
     }
 
-    private FileTransferDescription.Version getAvailableFileTransferVersion(List<String> remoteFeatures) {
-        if (remoteFeatures.contains(FileTransferDescription.Version.FT_5.getNamespace())) {
-            return FileTransferDescription.Version.FT_5;
-        } else if (remoteFeatures.contains(FileTransferDescription.Version.FT_4.getNamespace())) {
-            return FileTransferDescription.Version.FT_4;
-        } else {
-            return FileTransferDescription.Version.FT_3;
+    private Transport setupTransport() {
+        final XmppConnection xmppConnection = id.account.getXmppConnection();
+        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
+        if (!useTor && remoteHasFeature(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)) {
+            return new WebRTCDataChannelTransport(
+                    xmppConnectionService.getApplicationContext(),
+                    xmppConnection,
+                    id.account,
+                    isInitiator());
         }
+        if (remoteHasFeature(Namespace.JINGLE_TRANSPORTS_S5B)) {
+            return new SocksByteStreamsTransport(xmppConnection, id, isInitiator(), useTor);
+        }
+        return setupLastResortTransport();
     }
 
-    private List<String> getRemoteFeatures() {
-        final String resource = Strings.nullToEmpty(this.id.with.getResource());
-        final Presence presence = this.id.account.getRoster().getContact(id.with).getPresences().get(resource);
-        final ServiceDiscoveryResult result = presence != null ? presence.getServiceDiscoveryResult() : null;
-        return result == null ? Collections.emptyList() : result.getFeatures();
+    private Transport setupLastResortTransport() {
+        final XmppConnection xmppConnection = id.account.getXmppConnection();
+        return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator());
     }
 
-    private void init(JinglePacket packet) { //should move to deliverPacket
-        //TODO if not 'OFFERED' reply with out-of-order
-        this.mJingleStatus = JINGLE_STATUS_INITIATED;
-        final Conversation conversation = this.xmppConnectionService.findOrCreateConversation(id.account, id.with.asBareJid(), false, false);
-        this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
-        this.message.setStatus(Message.STATUS_RECEIVED);
-        this.mStatus = Transferable.STATUS_OFFER;
-        this.message.setTransferable(this);
-        this.message.setCounterpart(this.id.with);
-        this.responder = this.id.account.getJid();
-        final Content content = packet.getJingleContent();
-        final GenericTransportInfo transportInfo = content.getTransport();
-        this.contentCreator = content.getCreator();
-        Content.Senders senders;
-        try {
-            senders = content.getSenders();
-        } catch (final Exception e) {
-            senders = Content.Senders.INITIATOR;
-        }
-        this.contentSenders = senders;
-        this.contentName = content.getAttribute("name");
-
-        if (transportInfo instanceof S5BTransportInfo) {
-            final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
-            this.transportId = s5BTransportInfo.getTransportId();
-            this.initialTransport = s5BTransportInfo.getClass();
-            this.mergeCandidates(s5BTransportInfo.getCandidates());
-        } else if (transportInfo instanceof IbbTransportInfo) {
-            final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
-            this.initialTransport = ibbTransportInfo.getClass();
-            this.transportId = ibbTransportInfo.getTransportId();
-            final int remoteBlockSize = ibbTransportInfo.getBlockSize();
-            if (remoteBlockSize <= 0) {
-                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote party requested invalid ibb block size");
-                respondToIq(packet, false);
-                this.fail();
-            }
-            this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, ibbTransportInfo.getBlockSize());
-        } else {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote tried to use unknown transport " + transportInfo.getNamespace());
-            respondToIq(packet, false);
-            this.fail();
+    private void failureToAcceptSession(final Throwable throwable) {
+        if (isTerminated()) {
             return;
         }
+        final Throwable rootCause = Throwables.getRootCause(throwable);
+        Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
+        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
+    }
+
+    private void receiveSessionInfo(final JinglePacket jinglePacket) {
+        Log.d(Config.LOGTAG, "<-- " + jinglePacket);
+        respondOk(jinglePacket);
+        final var sessionInfo = FileTransferDescription.getSessionInfo(jinglePacket);
+        if (sessionInfo instanceof FileTransferDescription.Checksum checksum) {
+            receiveSessionInfoChecksum(checksum);
+        } else if (sessionInfo instanceof FileTransferDescription.Received received) {
+            receiveSessionInfoReceived(received);
+        }
+    }
 
-        this.description = (FileTransferDescription) content.getDescription();
+    private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
+        Log.d(Config.LOGTAG, "received checksum " + checksum);
+    }
 
-        final Element fileOffer = this.description.getFileOffer();
+    private void receiveSessionInfoReceived(final FileTransferDescription.Received received) {
+        Log.d(Config.LOGTAG, "peer confirmed received " + received);
+    }
 
-        if (fileOffer != null) {
-            boolean remoteIsUsingJet = false;
-            Element encrypted = fileOffer.findChild("encrypted", AxolotlService.PEP_PREFIX);
-            if (encrypted == null) {
-                final Element security = content.findChild("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
-                if (security != null && AxolotlService.PEP_PREFIX.equals(security.getAttribute("type"))) {
-                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received jingle file offer with JET");
-                    encrypted = security.findChild("encrypted", AxolotlService.PEP_PREFIX);
-                    remoteIsUsingJet = true;
-                }
-            }
-            if (encrypted != null) {
-                this.mXmppAxolotlMessage = XmppAxolotlMessage.fromElement(encrypted, packet.getFrom().asBareJid());
-            }
-            Element fileSize = fileOffer.findChild("size");
-            final String path = fileOffer.findChildContent("name");
-            if (path != null) {
-                AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(path);
-                if (VALID_IMAGE_EXTENSIONS.contains(extension.main)) {
-                    message.setType(Message.TYPE_IMAGE);
-                    xmppConnectionService.getFileBackend().setupRelativeFilePath(message, message.getUuid() + "." + extension.main);
-                } else if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
-                    if (VALID_IMAGE_EXTENSIONS.contains(extension.secondary)) {
-                        message.setType(Message.TYPE_IMAGE);
-                        xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + "." + extension.secondary);
-                    } else {
-                        message.setType(Message.TYPE_FILE);
-                        xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.secondary != null ? ("." + extension.secondary) : ""));
-                    }
-                    message.setEncryption(Message.ENCRYPTION_PGP);
-                } else {
-                    message.setType(Message.TYPE_FILE);
-                    xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.main != null ? ("." + extension.main) : ""));
-                }
-                long size = parseLong(fileSize, 0);
-                message.setBody(Long.toString(size));
-                conversation.add(message);
-                jingleConnectionManager.updateConversationUi(true);
-                this.file = this.xmppConnectionService.getFileBackend().getFile(message, false);
-                if (mXmppAxolotlMessage != null) {
-                    XmppAxolotlMessage.XmppAxolotlKeyTransportMessage transportMessage = id.account.getAxolotlService().processReceivingKeyTransportMessage(mXmppAxolotlMessage, false);
-                    if (transportMessage != null) {
-                        message.setEncryption(Message.ENCRYPTION_AXOLOTL);
-                        this.file.setKey(transportMessage.getKey());
-                        this.file.setIv(transportMessage.getIv());
-                        message.setFingerprint(transportMessage.getFingerprint());
-                    } else {
-                        Log.d(Config.LOGTAG, "could not process KeyTransportMessage");
-                    }
-                }
-                message.resetFileParams();
-                //legacy OMEMO encrypted file transfers reported the file size after encryption
-                //JET reports the plain text size. however lower levels of our receiving code still
-                //expect the cipher text size. so we just + 16 bytes (auth tag size) here
-                this.file.setExpectedSize(size + (remoteIsUsingJet ? 16 : 0));
-
-                respondToIq(packet, true);
-
-                if (id.account.getRoster().getContact(id.with).showInContactList()
-                        && jingleConnectionManager.hasStoragePermission()
-                        && size < this.jingleConnectionManager.getAutoAcceptFileSize()
-                        && xmppConnectionService.isDataSaverDisabled()) {
-                    Log.d(Config.LOGTAG, "auto accepting file from " + id.with);
-                    this.acceptedAutomatically = true;
-                    this.sendAccept();
-                } else {
-                    message.markUnread();
-                    Log.d(Config.LOGTAG,
-                            "not auto accepting new file offer with size: "
-                                    + size
-                                    + " allowed size:"
-                                    + this.jingleConnectionManager
-                                    .getAutoAcceptFileSize());
-                    this.xmppConnectionService.getNotificationService().push(message);
-                }
-                Log.d(Config.LOGTAG, "receiving file: expecting size of " + this.file.getExpectedSize());
-                return;
-            }
-            respondToIq(packet, false);
+    private void receiveSessionTerminate(final JinglePacket jinglePacket) {
+        final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
+        final State previous = this.state;
+        Log.d(
+                Config.LOGTAG,
+                id.account.getJid().asBareJid()
+                        + ": received session terminate reason="
+                        + wrapper.reason
+                        + "("
+                        + Strings.nullToEmpty(wrapper.text)
+                        + ") while in state "
+                        + previous);
+        if (TERMINATED.contains(previous)) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid()
+                            + ": ignoring session terminate because already in "
+                            + previous);
+            return;
         }
+        if (isInitiator()) {
+            this.message.setErrorMessage(
+                    Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text);
+        }
+        terminateTransport();
+        final State target = reasonToState(wrapper.reason);
+        transitionOrThrow(target);
+        finish();
     }
 
-    private void setupDescription(final FileTransferDescription.Version version) {
-        this.file = this.xmppConnectionService.getFileBackend().getFile(message, false);
-        final FileTransferDescription description;
-        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
-            this.file.setKey(mXmppAxolotlMessage.getInnerKey());
-            this.file.setIv(mXmppAxolotlMessage.getIV());
-            //legacy OMEMO encrypted file transfer reported file size of the encrypted file
-            //JET uses the file size of the plain text file. The difference is only 16 bytes (auth tag)
-            this.file.setExpectedSize(file.getSize() + (this.remoteSupportsOmemoJet ? 0 : 16));
-            if (remoteSupportsOmemoJet) {
-                description = FileTransferDescription.of(this.file, version, null);
-            } else {
-                description = FileTransferDescription.of(this.file, version, this.mXmppAxolotlMessage);
-            }
-        } else {
-            this.file.setExpectedSize(file.getSize());
-            description = FileTransferDescription.of(this.file, version, null);
-        }
-        this.description = description;
-    }
-
-    private void sendInitRequest() {
-        final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE);
-        final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && remoteSupportsOmemoJet) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote announced support for JET");
-            final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
-            security.setAttribute("name", this.contentName);
-            security.setAttribute("cipher", JET_OMEMO_CIPHER);
-            security.setAttribute("type", AxolotlService.PEP_PREFIX);
-            security.addChild(mXmppAxolotlMessage.toElement());
-            content.addChild(security);
-        }
-        content.setDescription(this.description);
-        message.resetFileParams();
+    private void receiveTransportAccept(final JinglePacket jinglePacket) {
+        if (isResponder()) {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT);
+            return;
+        }
+        Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket);
+        final GenericTransportInfo transportInfo;
         try {
-            this.mFileInputStream = new FileInputStream(file);
-        } catch (FileNotFoundException e) {
-            fail(e.getMessage());
+            transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            sendSessionTerminate(Reason.of(e), e.getMessage());
             return;
         }
-        if (this.initialTransport == IbbTransportInfo.class) {
-            content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending IBB offer");
+        if (isInState(State.SESSION_ACCEPTED)) {
+            final var group = jinglePacket.getGroup();
+            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
         } else {
-            final Collection<JingleCandidate> candidates = getOurCandidates();
-            content.setTransport(new S5BTransportInfo(this.transportId, candidates));
-            Log.d(Config.LOGTAG, String.format("%s: sending S5B offer with %d candidates", id.account.getJid().asBareJid(), candidates.size()));
-        }
-        packet.addJingleContent(content);
-        this.sendJinglePacket(packet, (account, response) -> {
-            if (response.getType() == IqPacket.TYPE.RESULT) {
-                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party received offer");
-                if (mJingleStatus == JINGLE_STATUS_OFFERED) {
-                    mJingleStatus = JINGLE_STATUS_INITIATED;
-                    xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
-                } else {
-                    Log.d(Config.LOGTAG, "received ack for offer when status was " + mJingleStatus);
-                }
-            } else {
-                fail(IqParser.extractErrorMessage(response));
-            }
-        });
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT);
+        }
+    }
 
+    private void receiveTransportAccept(
+            final JinglePacket jinglePacket, final Transport.TransportInfo transportInfo) {
+        final FileTransferContentMap remoteContentMap =
+                getRemoteContentMap().withTransport(transportInfo);
+        setRemoteContentMap(remoteContentMap);
+        respondOk(jinglePacket);
+        final var transport = this.transport;
+        if (configureTransportWithPeerInfo(transport, remoteContentMap)) {
+            transport.connect();
+        } else {
+            Log.e(
+                    Config.LOGTAG,
+                    "Transport in transport-accept did not match our transport-replace");
+            terminateTransport();
+            sendSessionTerminate(
+                    Reason.FAILED_APPLICATION,
+                    "Transport in transport-accept did not match our transport-replace");
+        }
     }
 
-    private void sendHash() {
-        final Element checksum = new Element("checksum", description.getVersion().getNamespace());
-        checksum.setAttribute("creator", "initiator");
-        checksum.setAttribute("name", "a-file-offer");
-        Element hash = checksum.addChild("file").addChild("hash", "urn:xmpp:hashes:2");
-        hash.setAttribute("algo", "sha-1").setContent(Base64.encodeToString(file.getSha1Sum(), Base64.NO_WRAP));
+    private void receiveTransportInfo(final JinglePacket jinglePacket) {
+        final FileTransferContentMap contentMap;
+        final GenericTransportInfo transportInfo;
+        try {
+            contentMap = FileTransferContentMap.of(jinglePacket);
+            transportInfo = contentMap.requireOnlyTransportInfo();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+        respondOk(jinglePacket);
+        final var transport = this.transport;
+        if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
+                && transportInfo
+                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
+            receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo);
+        } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
+                && transportInfo
+                        instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+            receiveTransportInfo(
+                    Iterables.getOnlyElement(contentMap.contents.keySet()),
+                    webRTCDataChannelTransport,
+                    webRTCDataChannelTransportInfo);
+        } else if (transportInfo
+                instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+            receiveTransportInfo(
+                    Iterables.getOnlyElement(contentMap.contents.keySet()),
+                    webRTCDataChannelTransportInfo);
+        } else {
+            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
+        }
+    }
 
-        final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INFO);
-        packet.addJingleChild(checksum);
-        xmppConnectionService.sendIqPacket(id.account, packet, (account, response) -> {
-            if (response.getType() == IqPacket.TYPE.ERROR) {
-                Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignoring error response to our session-info (hash transmission)");
-            }
-        });
+    private void receiveTransportInfo(
+            final String contentName,
+            final WebRTCDataChannelTransport webRTCDataChannelTransport,
+            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
+        final var iceCandidates =
+                WebRTCDataChannelTransport.iceCandidatesOf(
+                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
+        final var localContentMap = getLocalContentMap();
+        if (localContentMap == null) {
+            Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate");
+            this.pendingIncomingIceCandidates.addAll(iceCandidates);
+        } else {
+            webRTCDataChannelTransport.addIceCandidates(iceCandidates);
+        }
     }
 
-    private Collection<JingleCandidate> getOurCandidates() {
-        return Collections2.filter(this.candidates, c -> c != null && c.isOurs());
+    private void receiveTransportInfo(
+            final String contentName,
+            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
+        final var iceCandidates =
+                WebRTCDataChannelTransport.iceCandidatesOf(
+                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
+        this.pendingIncomingIceCandidates.addAll(iceCandidates);
     }
 
-    private void sendAccept() {
-        mJingleStatus = JINGLE_STATUS_ACCEPTED;
-        this.mStatus = Transferable.STATUS_DOWNLOADING;
-        this.jingleConnectionManager.updateConversationUi(true);
-        if (initialTransport == S5BTransportInfo.class) {
-            sendAcceptSocks();
-        } else {
-            sendAcceptIbb();
+    private void receiveTransportInfo(
+            final SocksByteStreamsTransport socksBytestreamsTransport,
+            final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
+        final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo();
+        if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) {
+            socksBytestreamsTransport.setCandidateError();
+        } else if (transportInfo
+                instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) {
+            if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) {
+                sendSessionTerminate(
+                        Reason.FAILED_TRANSPORT,
+                        String.format(
+                                "Peer is not connected to our candidate %s", candidateUsed.cid));
+            }
+        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) {
+            socksBytestreamsTransport.setProxyActivated(activated.cid);
+        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) {
+            socksBytestreamsTransport.setProxyError();
         }
     }
 
-    private void sendAcceptSocks() {
-        gatherAndConnectDirectCandidates();
-        this.jingleConnectionManager.getPrimaryCandidate(this.id.account, isInitiator(), (success, candidate) -> {
-            final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
-            final Content content = new Content(contentCreator, contentSenders, contentName);
-            content.setDescription(this.description);
-            if (success && candidate != null && !equalCandidateExists(candidate)) {
-                final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate);
-                connections.put(candidate.getCid(), socksConnection);
-                socksConnection.connect(new OnTransportConnected() {
+    private void receiveTransportReplace(final JinglePacket jinglePacket) {
+        if (isInitiator()) {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE);
+            return;
+        }
+        Log.d(Config.LOGTAG, "receive transport replace " + jinglePacket);
+        final GenericTransportInfo transportInfo;
+        try {
+            transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+        if (isInState(State.SESSION_ACCEPTED)) {
+            receiveTransportReplace(jinglePacket, transportInfo);
+        } else {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE);
+        }
+    }
 
+    private void receiveTransportReplace(
+            final JinglePacket jinglePacket, final GenericTransportInfo transportInfo) {
+        respondOk(jinglePacket);
+        final Transport transport;
+        try {
+            transport = setupTransport(transportInfo);
+        } catch (final RuntimeException e) {
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+        this.transport = transport;
+        this.transport.setTransportCallback(this);
+        final var transportInfoFuture = transport.asTransportInfo();
+        Futures.addCallback(
+                transportInfoFuture,
+                new FutureCallback<>() {
                     @Override
-                    public void failed() {
-                        Log.d(Config.LOGTAG, "connection to our own proxy65 candidate failed");
-                        content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
-                        packet.addJingleContent(content);
-                        sendJinglePacket(packet);
-                        connectNextCandidate();
+                    public void onSuccess(final Transport.TransportInfo transportWrapper) {
+                        final FileTransferContentMap contentMap =
+                                getLocalContentMap().withTransport(transportWrapper);
+                        sendTransportAccept(contentMap);
                     }
 
                     @Override
-                    public void established() {
-                        Log.d(Config.LOGTAG, "connected to proxy65 candidate");
-                        mergeCandidate(candidate);
-                        content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
-                        packet.addJingleContent(content);
-                        sendJinglePacket(packet);
-                        connectNextCandidate();
+                    public void onFailure(@NonNull Throwable throwable) {
+                        // transition into application failed (analogues to failureToAccept
                     }
-                });
-            } else {
-                Log.d(Config.LOGTAG, "did not find a proxy65 candidate for ourselves");
-                content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
-                packet.addJingleContent(content);
-                sendJinglePacket(packet);
-                connectNextCandidate();
-            }
-        });
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void sendTransportAccept(final FileTransferContentMap contentMap) {
+        setLocalContentMap(contentMap);
+        final var jinglePacket =
+                contentMap
+                        .transportInfo()
+                        .toJinglePacket(JinglePacket.Action.TRANSPORT_ACCEPT, id.sessionId);
+        Log.d(Config.LOGTAG, "sending transport accept " + jinglePacket);
+        send(jinglePacket);
+        transport.connect();
+    }
+
+    protected void sendSessionTerminate(final Reason reason, final String text) {
+        if (isInitiator()) {
+            this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text);
+        }
+        sendSessionTerminate(reason, text, null);
+    }
+
+    private FileTransferContentMap getLocalContentMap() {
+        return isInitiator()
+                ? this.initiatorFileTransferContentMap
+                : this.responderFileTransferContentMap;
     }
 
-    private void sendAcceptIbb() {
-        this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
-        final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
-        final Content content = new Content(contentCreator, contentSenders, contentName);
-        content.setDescription(this.description);
-        content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
-        packet.addJingleContent(content);
-        this.transport.receive(file, onFileTransmissionStatusChanged);
-        this.sendJinglePacket(packet);
+    private FileTransferContentMap getRemoteContentMap() {
+        return isInitiator()
+                ? this.responderFileTransferContentMap
+                : this.initiatorFileTransferContentMap;
     }
 
-    private JinglePacket bootstrapPacket(JinglePacket.Action action) {
-        final JinglePacket packet = new JinglePacket(action, this.id.sessionId);
-        packet.setTo(id.with);
-        return packet;
+    private void setLocalContentMap(final FileTransferContentMap contentMap) {
+        if (isInitiator()) {
+            this.initiatorFileTransferContentMap = contentMap;
+        } else {
+            this.responderFileTransferContentMap = contentMap;
+        }
     }
 
-    private void sendJinglePacket(JinglePacket packet) {
-        xmppConnectionService.sendIqPacket(id.account, packet, responseListener);
+    private void setRemoteContentMap(final FileTransferContentMap contentMap) {
+        if (isInitiator()) {
+            this.responderFileTransferContentMap = contentMap;
+        } else {
+            this.initiatorFileTransferContentMap = contentMap;
+        }
     }
 
-    private void sendJinglePacket(JinglePacket packet, OnIqPacketReceived callback) {
-        xmppConnectionService.sendIqPacket(id.account, packet, callback);
+    public Transport getTransport() {
+        return this.transport;
     }
 
-    private void receiveAccept(JinglePacket packet) {
-        if (responding()) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept (we were responding)");
-            respondToIqWithOutOfOrder(packet);
+    @Override
+    protected void terminateTransport() {
+        final var transport = this.transport;
+        if (transport == null) {
             return;
         }
-        if (this.mJingleStatus != JINGLE_STATUS_INITIATED) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept");
-            respondToIqWithOutOfOrder(packet);
+        transport.terminate();
+        this.transport = null;
+    }
+
+    @Override
+    void notifyRebound() {}
+
+    @Override
+    public void onTransportEstablished() {
+        Log.d(Config.LOGTAG, "on transport established");
+        final AbstractFileTransceiver fileTransceiver;
+        try {
+            fileTransceiver = setupTransceiver(isResponder());
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
+            sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
             return;
         }
-        this.mJingleStatus = JINGLE_STATUS_ACCEPTED;
-        xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
-        final Content content = packet.getJingleContent();
-        final GenericTransportInfo transportInfo = content.getTransport();
-        //TODO we want to fail if transportInfo doesn’t match our intialTransport and/or our id
-        if (transportInfo instanceof S5BTransportInfo) {
-            final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
-            respondToIq(packet, true);
-            //TODO calling merge is probably a bug because that might eliminate candidates of the other party and lead to us not sending accept/deny
-            //TODO: we probably just want to call add
-            mergeCandidates(s5BTransportInfo.getCandidates());
-            this.connectNextCandidate();
-        } else if (transportInfo instanceof IbbTransportInfo) {
-            final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
-            final int remoteBlockSize = ibbTransportInfo.getBlockSize();
-            if (remoteBlockSize > 0) {
-                this.ibbBlockSize = Math.min(ibbBlockSize, remoteBlockSize);
-            }
-            respondToIq(packet, true);
-            this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
-            this.transport.connect(onIbbTransportConnected);
+        this.fileTransceiver = fileTransceiver;
+        final var fileTransceiverThread = new Thread(fileTransceiver);
+        fileTransceiverThread.start();
+        Futures.addCallback(
+                fileTransceiver.complete,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
+                        onFileTransmissionComplete(hashes);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        onFileTransmissionFailed(throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
+        // TODO if we ever support receiving files this should become isSending(); isReceiving()
+        if (isInitiator()) {
+            sendSessionInfoChecksum(hashes);
         } else {
-            respondToIq(packet, false);
+            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
+            sendFileSessionInfoReceived();
+            terminateTransport();
+            messageReceivedSuccess();
+            sendSessionTerminate(Reason.SUCCESS, null);
         }
     }
 
-    private void receiveTransportInfo(JinglePacket packet) {
-        final Content content = packet.getJingleContent();
-        final GenericTransportInfo transportInfo = content.getTransport();
-        if (transportInfo instanceof S5BTransportInfo) {
-            final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
-            if (s5BTransportInfo.hasChild("activated")) {
-                respondToIq(packet, true);
-                if ((this.transport != null) && (this.transport instanceof JingleSocks5Transport)) {
-                    onProxyActivated.success();
-                } else {
-                    String cid = s5BTransportInfo.findChild("activated").getAttribute("cid");
-                    Log.d(Config.LOGTAG, "received proxy activated (" + cid
-                            + ")prior to choosing our own transport");
-                    JingleSocks5Transport connection = this.connections.get(cid);
-                    if (connection != null) {
-                        connection.setActivated(true);
-                    } else {
-                        Log.d(Config.LOGTAG, "activated connection not found");
-                        sendSessionTerminate(Reason.FAILED_TRANSPORT);
-                        this.fail();
-                    }
-                }
-            } else if (s5BTransportInfo.hasChild("proxy-error")) {
-                respondToIq(packet, true);
-                onProxyActivated.failed();
-            } else if (s5BTransportInfo.hasChild("candidate-error")) {
-                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received candidate error");
-                respondToIq(packet, true);
-                this.receivedCandidate = true;
-                if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) {
-                    this.connect();
-                }
-            } else if (s5BTransportInfo.hasChild("candidate-used")) {
-                String cid = s5BTransportInfo.findChild("candidate-used").getAttribute("cid");
-                if (cid != null) {
-                    Log.d(Config.LOGTAG, "candidate used by counterpart:" + cid);
-                    JingleCandidate candidate = getCandidate(cid);
-                    if (candidate == null) {
-                        Log.d(Config.LOGTAG, "could not find candidate with cid=" + cid);
-                        respondToIq(packet, false);
-                        return;
-                    }
-                    respondToIq(packet, true);
-                    candidate.flagAsUsedByCounterpart();
-                    this.receivedCandidate = true;
-                    if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) {
-                        this.connect();
-                    } else {
-                        Log.d(Config.LOGTAG, "ignoring because file is already in transmission or we haven't sent our candidate yet status=" + mJingleStatus + " sentCandidate=" + sentCandidate);
-                    }
-                } else {
-                    respondToIq(packet, false);
-                }
+    private void messageReceivedSuccess() {
+        this.message.setTransferable(null);
+        xmppConnectionService.getFileBackend().updateFileParams(message);
+        xmppConnectionService.databaseBackend.createMessage(message);
+        final File file = xmppConnectionService.getFileBackend().getFile(message);
+        if (acceptedAutomatically) {
+            message.markUnread();
+            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
+                id.account.getPgpDecryptionService().decrypt(message, true);
             } else {
-                respondToIq(packet, false);
+                xmppConnectionService
+                        .getFileBackend()
+                        .updateMediaScanner(
+                                file,
+                                () ->
+                                        JingleFileTransferConnection.this
+                                                .xmppConnectionService
+                                                .getNotificationService()
+                                                .push(message));
             }
+        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
+            id.account.getPgpDecryptionService().decrypt(message, false);
         } else {
-            respondToIq(packet, true);
+            xmppConnectionService.getFileBackend().updateMediaScanner(file);
         }
     }
 
-    private void connect() {
-        final JingleSocks5Transport connection = chooseConnection();
-        this.transport = connection;
-        if (connection == null) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": could not find suitable candidate");
-            this.disconnectSocks5Connections();
-            if (isInitiator()) {
-                this.sendFallbackToIbb();
-            }
+    private void onFileTransmissionFailed(final Throwable throwable) {
+        if (isTerminated()) {
+            Log.d(
+                    Config.LOGTAG,
+                    "file transfer failed but session is already terminated",
+                    throwable);
         } else {
-            //TODO at this point we can already close other connections to free some resources
-            final JingleCandidate candidate = connection.getCandidate();
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": elected candidate " + candidate.toString());
-            this.mJingleStatus = JINGLE_STATUS_TRANSMITTING;
-            if (connection.needsActivation()) {
-                if (connection.getCandidate().isOurs()) {
-                    final String sid;
-                    if (description.getVersion() == FileTransferDescription.Version.FT_3) {
-                        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": use session ID instead of transport ID to activate proxy");
-                        sid = id.sessionId;
-                    } else {
-                        sid = getTransportId();
-                    }
-                    Log.d(Config.LOGTAG, "candidate "
-                            + connection.getCandidate().getCid()
-                            + " was our proxy. going to activate");
-                    IqPacket activation = new IqPacket(IqPacket.TYPE.SET);
-                    activation.setTo(connection.getCandidate().getJid());
-                    activation.query("http://jabber.org/protocol/bytestreams")
-                            .setAttribute("sid", sid);
-                    activation.query().addChild("activate")
-                            .setContent(this.id.with.toEscapedString());
-                    xmppConnectionService.sendIqPacket(this.id.account, activation, (account, response) -> {
-                        if (response.getType() != IqPacket.TYPE.RESULT) {
-                            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": " + response.toString());
-                            sendProxyError();
-                            onProxyActivated.failed();
-                        } else {
-                            sendProxyActivated(connection.getCandidate().getCid());
-                            onProxyActivated.success();
-                        }
-                    });
-                } else {
-                    Log.d(Config.LOGTAG,
-                            "candidate "
-                                    + connection.getCandidate().getCid()
-                                    + " was a proxy. waiting for other party to activate");
-                }
-            } else {
-                if (isInitiator()) {
-                    Log.d(Config.LOGTAG, "we were initiating. sending file");
-                    connection.send(file, onFileTransmissionStatusChanged);
-                } else {
-                    Log.d(Config.LOGTAG, "we were responding. receiving file");
-                    connection.receive(file, onFileTransmissionStatusChanged);
-                }
-            }
+            terminateTransport();
+            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
+            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
         }
     }
 
-    private JingleSocks5Transport chooseConnection() {
-        final List<JingleSocks5Transport> establishedConnections = FluentIterable.from(connections.entrySet())
-                .transform(Entry::getValue)
-                .filter(c -> (c != null && c.isEstablished() && (c.getCandidate().isUsedByCounterpart() || !c.getCandidate().isOurs())))
-                .toSortedList((a, b) -> {
-                    final int compare = Integer.compare(b.getCandidate().getPriority(), a.getCandidate().getPriority());
-                    if (compare == 0) {
-                        if (isInitiator()) {
-                            //pick the one we sent a candidate-used for (meaning not ours)
-                            return a.getCandidate().isOurs() ? 1 : -1;
-                        } else {
-                            //pick the one they sent a candidate-used for (meaning ours)
-                            return a.getCandidate().isOurs() ? -1 : 1;
-                        }
-                    }
-                    return compare;
-                });
-        return Iterables.getFirst(establishedConnections, null);
+    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
+        final var fileDescription = getLocalContentMap().requireOnlyFile();
+        final File file = xmppConnectionService.getFileBackend().getFile(message);
+        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
+        if (receiving) {
+            return new FileReceiver(
+                    file,
+                    this.transportSecurity,
+                    transport.getInputStream(),
+                    transport.getTerminationLatch(),
+                    fileDescription.size,
+                    updateRunnable);
+        } else {
+            return new FileTransmitter(
+                    file,
+                    this.transportSecurity,
+                    transport.getOutputStream(),
+                    transport.getTerminationLatch(),
+                    fileDescription.size,
+                    updateRunnable);
+        }
     }
 
-    private void sendSuccess() {
-        sendSessionTerminate(Reason.SUCCESS);
-        this.disconnectSocks5Connections();
-        this.mJingleStatus = JINGLE_STATUS_FINISHED;
-        this.message.setStatus(Message.STATUS_RECEIVED);
-        this.message.setTransferable(null);
-        this.xmppConnectionService.updateMessage(message, false);
-        this.jingleConnectionManager.finishConnection(this);
+    private void sendFileSessionInfoReceived() {
+        final var contentMap = getLocalContentMap();
+        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
+        sendSessionInfo(new FileTransferDescription.Received(name));
     }
 
-    private void sendFallbackToIbb() {
-        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending fallback to ibb");
-        final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.TRANSPORT_REPLACE);
-        final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        this.transportId = JingleConnectionManager.nextRandomId();
-        content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
-        packet.addJingleContent(content);
-        this.sendJinglePacket(packet);
+    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
+        final var contentMap = getLocalContentMap();
+        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
+        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
     }
 
+    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
+        final var jinglePacket =
+                new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId);
+        jinglePacket.addJingleChild(sessionInfo.asElement());
+        jinglePacket.setTo(this.id.with);
+        Log.d(Config.LOGTAG, "--> " + jinglePacket);
+        send(jinglePacket);
+    }
 
-    private void receiveFallbackToIbb(final JinglePacket packet, final IbbTransportInfo transportInfo) {
-        if (isInitiator()) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace (we were initiating)");
-            respondToIqWithOutOfOrder(packet);
+    @Override
+    public void onTransportSetupFailed() {
+        final var transport = this.transport;
+        if (transport == null) {
+            // this really is not supposed to happen
+            sendSessionTerminate(Reason.FAILED_APPLICATION, null);
             return;
         }
-        final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING);
-        if (!validState) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace");
-            respondToIqWithOutOfOrder(packet);
+        Log.d(Config.LOGTAG, "onTransportSetupFailed");
+        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
+        if (isTransportInBand) {
+            terminateTransport();
+            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
             return;
         }
-        this.proxyActivationFailed = false; //fallback received; now we no longer need to accept another one;
-        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receiving fallback to ibb");
-        final int remoteBlockSize = transportInfo.getBlockSize();
-        if (remoteBlockSize > 0) {
-            this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize);
+        // terminate the current transport
+        transport.terminate();
+        if (isInitiator()) {
+            this.transport = setupLastResortTransport();
+            this.transport.setTransportCallback(this);
+            final var transportInfoFuture = this.transport.asTransportInfo();
+            Futures.addCallback(
+                    transportInfoFuture,
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
+                            final FileTransferContentMap contentMap = getLocalContentMap();
+                            sendTransportReplace(contentMap.withTransport(transportWrapper));
+                        }
+
+                        @Override
+                        public void onFailure(@NonNull Throwable throwable) {
+                            // TODO send application failure;
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+
         } else {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to parse block size in transport-replace");
+            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
         }
-        this.transportId = transportInfo.getTransportId(); //TODO: handle the case where this is null by the remote party
-        this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
-
-        final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT);
+    }
 
-        final Content content = new Content(contentCreator, contentSenders, contentName);
-        content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
-        answer.addJingleContent(content);
+    private void sendTransportReplace(final FileTransferContentMap contentMap) {
+        setLocalContentMap(contentMap);
+        final var jinglePacket =
+                contentMap
+                        .transportInfo()
+                        .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId);
+        Log.d(Config.LOGTAG, "sending transport replace " + jinglePacket);
+        send(jinglePacket);
+    }
 
-        respondToIq(packet, true);
+    @Override
+    public void onAdditionalCandidate(
+            final String contentName, final Transport.Candidate candidate) {
+        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
+            sendTransportInfo(contentName, iceCandidate);
+        }
+    }
 
-        if (isInitiator()) {
-            this.sendJinglePacket(answer, (account, response) -> {
-                if (response.getType() == IqPacket.TYPE.RESULT) {
-                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + " recipient ACKed our transport-accept. creating ibb");
-                    transport.connect(onIbbTransportConnected);
-                }
-            });
-        } else {
-            this.transport.receive(file, onFileTransmissionStatusChanged);
-            this.sendJinglePacket(answer);
+    public void sendTransportInfo(
+            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
+        final FileTransferContentMap transportInfo;
+        try {
+            final FileTransferContentMap rtpContentMap = getLocalContentMap();
+            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
+        } catch (final Exception e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.account.getJid().asBareJid()
+                            + ": unable to prepare transport-info from candidate for content="
+                            + contentName);
+            return;
         }
+        final JinglePacket jinglePacket =
+                transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
+        Log.d(Config.LOGTAG, "--> " + jinglePacket);
+        send(jinglePacket);
     }
 
-    private void receiveTransportAccept(JinglePacket packet) {
-        if (responding()) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept (we were responding)");
-            respondToIqWithOutOfOrder(packet);
+    @Override
+    public void onCandidateUsed(
+            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
+        final FileTransferContentMap contentMap = getLocalContentMap();
+        if (contentMap == null) {
+            Log.e(Config.LOGTAG, "local content map is null on candidate used");
             return;
         }
-        final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING);
-        if (!validState) {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept");
-            respondToIqWithOutOfOrder(packet);
+        final var jinglePacket =
+                contentMap
+                        .candidateUsed(streamId, candidate.cid)
+                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
+        Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket);
+        send(jinglePacket);
+    }
+
+    @Override
+    public void onCandidateError(final String streamId) {
+        final FileTransferContentMap contentMap = getLocalContentMap();
+        if (contentMap == null) {
+            Log.e(Config.LOGTAG, "local content map is null on candidate used");
             return;
         }
-        this.proxyActivationFailed = false; //fallback accepted; now we no longer need to accept another one;
-        final Content content = packet.getJingleContent();
-        final GenericTransportInfo transportInfo = content == null ? null : content.getTransport();
-        if (transportInfo instanceof IbbTransportInfo) {
-            final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
-            final int remoteBlockSize = ibbTransportInfo.getBlockSize();
-            if (remoteBlockSize > 0) {
-                this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize);
-            }
-            final String sid = ibbTransportInfo.getTransportId();
-            this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
+        final var jinglePacket =
+                contentMap
+                        .candidateError(streamId)
+                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
+        Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket);
+        send(jinglePacket);
+    }
 
-            if (sid == null || !sid.equals(this.transportId)) {
-                Log.w(Config.LOGTAG, String.format("%s: sid in transport-accept (%s) did not match our sid (%s) ", id.account.getJid().asBareJid(), sid, transportId));
-            }
-            respondToIq(packet, true);
-            //might be receive instead if we are not initiating
-            if (isInitiator()) {
-                this.transport.connect(onIbbTransportConnected);
-            }
-        } else {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received invalid transport-accept");
-            respondToIq(packet, false);
+    @Override
+    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
+        final FileTransferContentMap contentMap = getLocalContentMap();
+        if (contentMap == null) {
+            Log.e(Config.LOGTAG, "local content map is null on candidate used");
+            return;
         }
+        final var jinglePacket =
+                contentMap
+                        .proxyActivated(streamId, candidate.cid)
+                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
+        send(jinglePacket);
     }
 
-    private void receiveSuccess() {
-        if (isInitiator()) {
-            this.mJingleStatus = JINGLE_STATUS_FINISHED;
-            this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_RECEIVED);
-            this.disconnectSocks5Connections();
-            if (this.transport instanceof JingleInBandTransport) {
-                this.transport.disconnect();
+    @Override
+    protected boolean transition(final State target, final Runnable runnable) {
+        final boolean transitioned = super.transition(target, runnable);
+        if (transitioned && isInitiator()) {
+            Log.d(Config.LOGTAG, "running mark message hooks");
+            if (target == State.SESSION_ACCEPTED) {
+                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
+            } else if (target == State.TERMINATED_SUCCESS) {
+                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
+            } else if (TERMINATED.contains(target)) {
+                xmppConnectionService.markMessage(
+                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
+            } else {
+                xmppConnectionService.updateConversationUi();
             }
-            this.message.setTransferable(null);
-            this.jingleConnectionManager.finishConnection(this);
         } else {
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate/success while responding");
+            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
+                    .contains(target)) {
+                this.message.setTransferable(
+                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
+            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
+                this.message.setTransferable(
+                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
+            }
+            xmppConnectionService.updateConversationUi();
         }
+        return transitioned;
     }
 
     @Override
-    public void cancel() {
-        this.cancelled = true;
-        abort(Reason.CANCEL);
-    }
-
-    private void abort(final Reason reason) {
-        this.disconnectSocks5Connections();
-        if (this.transport instanceof JingleInBandTransport) {
-            this.transport.disconnect();
+    protected void finish() {
+        if (transport != null) {
+            throw new AssertionError(
+                    "finish MUST not be called without terminating the transport first");
         }
-        sendSessionTerminate(reason);
-        this.jingleConnectionManager.finishConnection(this);
-        if (responding()) {
-            this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED));
-            if (this.file != null) {
-                file.delete();
-            }
-            this.jingleConnectionManager.updateConversationUi(true);
-        } else {
-            this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null);
+        // we don't want to remove TransferablePlaceholder
+        if (message.getTransferable() instanceof JingleFileTransferConnection) {
+            Log.d(Config.LOGTAG, "nulling transferable on message");
             this.message.setTransferable(null);
         }
+        super.finish();
     }
 
-    private void fail() {
-        fail(null);
-    }
-
-    private void fail(String errorMessage) {
-        this.mJingleStatus = JINGLE_STATUS_FAILED;
-        this.disconnectSocks5Connections();
-        if (this.transport instanceof JingleInBandTransport) {
-            this.transport.disconnect();
-        }
-        FileBackend.close(mFileInputStream);
-        FileBackend.close(mFileOutputStream);
-        if (this.message != null) {
-            if (responding()) {
-                this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED));
-                if (this.file != null) {
-                    file.delete();
-                }
-                this.jingleConnectionManager.updateConversationUi(true);
-            } else {
-                this.xmppConnectionService.markMessage(this.message,
-                        Message.STATUS_SEND_FAILED,
-                        cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage);
-                this.message.setTransferable(null);
-            }
+    private int getTransferableStatus() {
+        // status in file transfer is a bit weird. for sending it is mostly handled via
+        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
+        // uploading
+        // for receiving the message status remains at 'received' but Transferable goes through
+        // various status
+        if (isInitiator()) {
+            return Transferable.STATUS_UPLOADING;
         }
-        this.jingleConnectionManager.finishConnection(this);
+        final var state = getState();
+        return switch (state) {
+            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
+                    .STATUS_OFFER;
+            case TERMINATED_APPLICATION_FAILURE,
+                    TERMINATED_CONNECTIVITY_ERROR,
+                    TERMINATED_DECLINED_OR_BUSY,
+                    TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
+            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
+            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
+            default -> Transferable.STATUS_UNKNOWN;
+        };
     }
 
-    private void sendSessionTerminate(Reason reason) {
-        final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_TERMINATE);
-        packet.setReason(reason, null);
-        this.sendJinglePacket(packet);
-    }
+    // these methods are for interacting with 'Transferable' - we might want to remove the concept
+    // at some point
 
-    private void connectNextCandidate() {
-        for (JingleCandidate candidate : this.candidates) {
-            if ((!connections.containsKey(candidate.getCid()) && (!candidate
-                    .isOurs()))) {
-                this.connectWithCandidate(candidate);
-                return;
-            }
+    @Override
+    public boolean start() {
+        Log.d(Config.LOGTAG, "user pressed start()");
+        // TODO there is a 'connected' check apparently?
+        if (isInState(State.SESSION_INITIALIZED)) {
+            sendSessionAccept();
         }
-        this.sendCandidateError();
+        return true;
     }
 
-    private void connectWithCandidate(final JingleCandidate candidate) {
-        final JingleSocks5Transport socksConnection = new JingleSocks5Transport(
-                this, candidate);
-        connections.put(candidate.getCid(), socksConnection);
-        socksConnection.connect(new OnTransportConnected() {
-
-            @Override
-            public void failed() {
-                Log.d(Config.LOGTAG,
-                        "connection failed with " + candidate.getHost() + ":"
-                                + candidate.getPort());
-                connectNextCandidate();
-            }
-
-            @Override
-            public void established() {
-                Log.d(Config.LOGTAG,
-                        "established connection with " + candidate.getHost()
-                                + ":" + candidate.getPort());
-                sendCandidateUsed(candidate.getCid());
-            }
-        });
+    @Override
+    public int getStatus() {
+        return getTransferableStatus();
     }
 
-    private void disconnectSocks5Connections() {
-        Iterator<Entry<String, JingleSocks5Transport>> it = this.connections
-                .entrySet().iterator();
-        while (it.hasNext()) {
-            Entry<String, JingleSocks5Transport> pairs = it.next();
-            pairs.getValue().disconnect();
-            it.remove();
+    @Override
+    public Long getFileSize() {
+        final var transceiver = this.fileTransceiver;
+        if (transceiver != null) {
+            return transceiver.total;
+        }
+        final var contentMap = this.initiatorFileTransferContentMap;
+        if (contentMap != null) {
+            return contentMap.requireOnlyFile().size;
         }
+        return null;
     }
 
-    private void sendProxyActivated(String cid) {
-        final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        content.setTransport(new S5BTransportInfo(this.transportId, new Element("activated").setAttribute("cid", cid)));
-        packet.addJingleContent(content);
-        this.sendJinglePacket(packet);
+    @Override
+    public int getProgress() {
+        final var transceiver = this.fileTransceiver;
+        return transceiver != null ? transceiver.getProgress() : 0;
     }
 
-    private void sendProxyError() {
-        final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        content.setTransport(new S5BTransportInfo(this.transportId, new Element("proxy-error")));
-        packet.addJingleContent(content);
-        this.sendJinglePacket(packet);
+    @Override
+    public void cancel() {
+        if (stopFileTransfer()) {
+            Log.d(Config.LOGTAG, "user has stopped file transfer");
+        } else {
+            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
+        }
     }
 
-    private void sendCandidateUsed(final String cid) {
-        JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-used").setAttribute("cid", cid)));
-        packet.addJingleContent(content);
-        this.sentCandidate = true;
-        if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) {
-            connect();
+    private boolean stopFileTransfer() {
+        if (isInitiator()) {
+            return stopFileTransfer(Reason.CANCEL);
+        } else {
+            return stopFileTransfer(Reason.DECLINE);
         }
-        this.sendJinglePacket(packet);
     }
 
-    private void sendCandidateError() {
-        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending candidate error");
-        JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
-        content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-error")));
-        packet.addJingleContent(content);
-        this.sentCandidate = true;
-        this.sendJinglePacket(packet);
-        if (receivedCandidate && mJingleStatus == JINGLE_STATUS_ACCEPTED) {
-            connect();
+    private boolean stopFileTransfer(final Reason reason) {
+        final State target = reasonToState(reason);
+        if (transition(target)) {
+            // we change state before terminating transport so we don't consume the following
+            // IOException and turn it into a connectivity error
+            terminateTransport();
+            final JinglePacket jinglePacket =
+                    new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
+            jinglePacket.setReason(reason, "User requested to stop file transfer");
+            send(jinglePacket);
+            finish();
+            return true;
+        } else {
+            return false;
         }
     }
 
-    private int getJingleStatus() {
-        return this.mJingleStatus;
-    }
+    private abstract static class AbstractFileTransceiver implements Runnable {
+
+        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
+                SettableFuture.create();
+
+        protected final File file;
+        protected final TransportSecurity transportSecurity;
+
+        protected final CountDownLatch transportTerminationLatch;
+        protected final long total;
+        protected long transmitted = 0;
+        private int progress = Integer.MIN_VALUE;
+        private final Runnable updateRunnable;
+
+        private AbstractFileTransceiver(
+                final File file,
+                final TransportSecurity transportSecurity,
+                final CountDownLatch transportTerminationLatch,
+                final long total,
+                final Runnable updateRunnable) {
+            this.file = file;
+            this.transportSecurity = transportSecurity;
+            this.transportTerminationLatch = transportTerminationLatch;
+            this.total = transportSecurity == null ? total : (total + 16);
+            this.updateRunnable = updateRunnable;
+        }
 
-    private boolean equalCandidateExists(JingleCandidate candidate) {
-        for (JingleCandidate c : this.candidates) {
-            if (c.equalValues(candidate)) {
-                return true;
+        static void closeTransport(final Closeable stream) {
+            try {
+                stream.close();
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "transport has already been closed. good");
             }
         }
-        return false;
-    }
 
-    private void mergeCandidate(JingleCandidate candidate) {
-        for (JingleCandidate c : this.candidates) {
-            if (c.equals(candidate)) {
-                return;
-            }
+        public int getProgress() {
+            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
         }
-        this.candidates.add(candidate);
-    }
 
-    private void mergeCandidates(List<JingleCandidate> candidates) {
-        Collections.sort(candidates, (a, b) -> Integer.compare(b.getPriority(), a.getPriority()));
-        for (JingleCandidate c : candidates) {
-            mergeCandidate(c);
+        public void updateProgress() {
+            final int current = getProgress();
+            final boolean update;
+            synchronized (this) {
+                if (this.progress != current) {
+                    this.progress = current;
+                    update = true;
+                } else {
+                    update = false;
+                }
+                if (update) {
+                    this.updateRunnable.run();
+                }
+            }
         }
-    }
 
-    private JingleCandidate getCandidate(String cid) {
-        for (JingleCandidate c : this.candidates) {
-            if (c.getCid().equals(cid)) {
-                return c;
+        protected void awaitTransportTermination() {
+            try {
+                this.transportTerminationLatch.await();
+            } catch (final InterruptedException ignored) {
+                return;
             }
+            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
         }
-        return null;
     }
 
-    void updateProgress(int i) {
-        this.mProgress = i;
-        jingleConnectionManager.updateConversationUi(false);
-    }
+    private static class FileTransmitter extends AbstractFileTransceiver {
 
-    String getTransportId() {
-        return this.transportId;
-    }
+        private final OutputStream outputStream;
 
-    FileTransferDescription.Version getFtVersion() {
-        return this.description.getVersion();
-    }
+        private FileTransmitter(
+                final File file,
+                final TransportSecurity transportSecurity,
+                final OutputStream outputStream,
+                final CountDownLatch transportTerminationLatch,
+                final long total,
+                final Runnable updateRunnable) {
+            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
+            this.outputStream = outputStream;
+        }
 
-    public JingleTransport getTransport() {
-        return this.transport;
-    }
+        private InputStream openFileInputStream() throws FileNotFoundException {
+            final var fileInputStream = new FileInputStream(this.file);
+            if (this.transportSecurity == null) {
+                return fileInputStream;
+            } else {
+                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
+                cipher.init(
+                        true,
+                        new AEADParameters(
+                                new KeyParameter(transportSecurity.key),
+                                128,
+                                transportSecurity.iv));
+                Log.d(Config.LOGTAG, "setting up CipherInputStream");
+                return new CipherInputStream(fileInputStream, cipher);
+            }
+        }
 
-    public boolean start() {
-        if (id.account.getStatus() == Account.State.ONLINE) {
-            if (mJingleStatus == JINGLE_STATUS_INITIATED) {
-                new Thread(this::sendAccept).start();
+        @Override
+        public void run() {
+            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
+            final var sha1Hasher = Hashing.sha1().newHasher();
+            final var sha256Hasher = Hashing.sha256().newHasher();
+            try (final var fileInputStream = openFileInputStream()) {
+                final var buffer = new byte[4096];
+                while (total - transmitted > 0) {
+                    final int count = fileInputStream.read(buffer);
+                    if (count == -1) {
+                        throw new EOFException(
+                                String.format("reached EOF after %d/%d", transmitted, total));
+                    }
+                    outputStream.write(buffer, 0, count);
+                    sha1Hasher.putBytes(buffer, 0, count);
+                    sha256Hasher.putBytes(buffer, 0, count);
+                    transmitted += count;
+                    updateProgress();
+                }
+                outputStream.flush();
+                Log.d(
+                        Config.LOGTAG,
+                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
+                final List<FileTransferDescription.Hash> hashes =
+                        ImmutableList.of(
+                                new FileTransferDescription.Hash(
+                                        sha1Hasher.hash().asBytes(),
+                                        FileTransferDescription.Algorithm.SHA_1),
+                                new FileTransferDescription.Hash(
+                                        sha256Hasher.hash().asBytes(),
+                                        FileTransferDescription.Algorithm.SHA_256));
+                complete.set(hashes);
+            } catch (final Exception e) {
+                complete.setException(e);
             }
-            return true;
-        } else {
-            return false;
+            // the transport implementations backed by PipedOutputStreams do not like it when
+            // the writing Thread (this thread) goes away. so we just wait until the other peer
+            // has received our file and we are shutting down the transport
+            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
+            awaitTransportTermination();
+            closeTransport(outputStream);
         }
     }
 
-    @Override
-    public int getStatus() {
-        return this.mStatus;
-    }
+    private static class FileReceiver extends AbstractFileTransceiver {
 
-    @Override
-    public Long getFileSize() {
-        if (this.file != null) {
-            return this.file.getExpectedSize();
-        } else {
-            return null;
+        private final InputStream inputStream;
+
+        private FileReceiver(
+                final File file,
+                final TransportSecurity transportSecurity,
+                final InputStream inputStream,
+                final CountDownLatch transportTerminationLatch,
+                final long total,
+                final Runnable updateRunnable) {
+            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
+            this.inputStream = inputStream;
         }
-    }
 
-    @Override
-    public int getProgress() {
-        return this.mProgress;
-    }
+        private OutputStream openFileOutputStream() throws FileNotFoundException {
+            final var directory = this.file.getParentFile();
+            if (directory != null && directory.mkdirs()) {
+                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
+            }
+            final var fileOutputStream = new FileOutputStream(this.file);
+            if (this.transportSecurity == null) {
+                return fileOutputStream;
+            } else {
+                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
+                cipher.init(
+                        false,
+                        new AEADParameters(
+                                new KeyParameter(transportSecurity.key),
+                                128,
+                                transportSecurity.iv));
+                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
+                return new CipherOutputStream(fileOutputStream, cipher);
+            }
+        }
 
-    AbstractConnectionManager getConnectionManager() {
-        return this.jingleConnectionManager;
+        @Override
+        public void run() {
+            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
+            final var sha1Hasher = Hashing.sha1().newHasher();
+            final var sha256Hasher = Hashing.sha256().newHasher();
+            try (final var fileOutputStream = openFileOutputStream()) {
+                final var buffer = new byte[4096];
+                while (total - transmitted > 0) {
+                    final int count = inputStream.read(buffer);
+                    if (count == -1) {
+                        throw new EOFException(
+                                String.format("reached EOF after %d/%d", transmitted, total));
+                    }
+                    fileOutputStream.write(buffer, 0, count);
+                    sha1Hasher.putBytes(buffer, 0, count);
+                    sha256Hasher.putBytes(buffer, 0, count);
+                    transmitted += count;
+                    updateProgress();
+                }
+                Log.d(
+                        Config.LOGTAG,
+                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
+                final List<FileTransferDescription.Hash> hashes =
+                        ImmutableList.of(
+                                new FileTransferDescription.Hash(
+                                        sha1Hasher.hash().asBytes(),
+                                        FileTransferDescription.Algorithm.SHA_1),
+                                new FileTransferDescription.Hash(
+                                        sha256Hasher.hash().asBytes(),
+                                        FileTransferDescription.Algorithm.SHA_256));
+                complete.set(hashes);
+            } catch (final Exception e) {
+                complete.setException(e);
+            }
+            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
+            awaitTransportTermination();
+            closeTransport(inputStream);
+        }
     }
 
-    interface OnProxyActivated {
-        void success();
+    private static final class TransportSecurity {
+        final byte[] key;
+        final byte[] iv;
 
-        void failed();
+        private TransportSecurity(byte[] key, byte[] iv) {
+            this.key = key;
+            this.iv = iv;
+        }
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java πŸ”—

@@ -1,265 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle;
-
-import android.util.Base64;
-import android.util.Log;
-
-import com.google.common.base.Preconditions;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.DownloadableFile;
-import eu.siacs.conversations.persistance.FileBackend;
-import eu.siacs.conversations.services.AbstractConnectionManager;
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xmpp.Jid;
-import eu.siacs.conversations.xmpp.OnIqPacketReceived;
-import eu.siacs.conversations.xmpp.stanzas.IqPacket;
-
-public class JingleInBandTransport extends JingleTransport {
-
-    private final Account account;
-    private final Jid counterpart;
-    private final int blockSize;
-    private int seq = 0;
-    private final String sessionId;
-
-    private boolean established = false;
-
-    private boolean connected = true;
-
-    private DownloadableFile file;
-    private final JingleFileTransferConnection connection;
-
-    private InputStream fileInputStream = null;
-    private InputStream innerInputStream = null;
-    private OutputStream fileOutputStream = null;
-    private long remainingSize = 0;
-    private long fileSize = 0;
-    private MessageDigest digest;
-
-    private OnFileTransmissionStatusChanged onFileTransmissionStatusChanged;
-
-    private final OnIqPacketReceived onAckReceived = new OnIqPacketReceived() {
-        @Override
-        public void onIqPacketReceived(Account account, IqPacket packet) {
-            if (!connected) {
-                return;
-            }
-            if (packet.getType() == IqPacket.TYPE.RESULT) {
-                if (remainingSize > 0) {
-                    sendNextBlock();
-                }
-            } else if (packet.getType() == IqPacket.TYPE.ERROR) {
-                onFileTransmissionStatusChanged.onFileTransferAborted();
-            }
-        }
-    };
-
-    JingleInBandTransport(final JingleFileTransferConnection connection, final String sid, final int blockSize) {
-        this.connection = connection;
-        this.account = connection.getId().account;
-        this.counterpart = connection.getId().with;
-        this.blockSize = blockSize;
-        this.sessionId = sid;
-    }
-
-    private void sendClose() {
-        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending ibb close");
-        IqPacket iq = new IqPacket(IqPacket.TYPE.SET);
-        iq.setTo(this.counterpart);
-        Element close = iq.addChild("close", "http://jabber.org/protocol/ibb");
-        close.setAttribute("sid", this.sessionId);
-        this.account.getXmppConnection().sendIqPacket(iq, null);
-    }
-
-    public boolean matches(final Account account, final String sessionId) {
-        return this.account == account && this.sessionId.equals(sessionId);
-    }
-
-    public void connect(final OnTransportConnected callback) {
-        IqPacket iq = new IqPacket(IqPacket.TYPE.SET);
-        iq.setTo(this.counterpart);
-        Element open = iq.addChild("open", "http://jabber.org/protocol/ibb");
-        open.setAttribute("sid", this.sessionId);
-        open.setAttribute("stanza", "iq");
-        open.setAttribute("block-size", Integer.toString(this.blockSize));
-        this.connected = true;
-        this.account.getXmppConnection().sendIqPacket(iq, (account, packet) -> {
-            if (packet.getType() != IqPacket.TYPE.RESULT) {
-                callback.failed();
-            } else {
-                callback.established();
-            }
-        });
-    }
-
-    @Override
-    public void receive(DownloadableFile file, OnFileTransmissionStatusChanged callback) {
-        this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback);
-        this.file = file;
-        try {
-            this.digest = MessageDigest.getInstance("SHA-1");
-            digest.reset();
-            this.fileOutputStream = connection.getFileOutputStream();
-            if (this.fileOutputStream == null) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not create output stream");
-                callback.onFileTransferAborted();
-                return;
-            }
-            this.remainingSize = this.fileSize = file.getExpectedSize();
-        } catch (final NoSuchAlgorithmException | IOException e) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + " " + e.getMessage());
-            callback.onFileTransferAborted();
-        }
-    }
-
-    @Override
-    public void send(DownloadableFile file, OnFileTransmissionStatusChanged callback) {
-        this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback);
-        this.file = file;
-        try {
-            this.remainingSize = this.file.getExpectedSize();
-            this.fileSize = this.remainingSize;
-            this.digest = MessageDigest.getInstance("SHA-1");
-            this.digest.reset();
-            fileInputStream = connection.getFileInputStream();
-            if (fileInputStream == null) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could no create input stream");
-                callback.onFileTransferAborted();
-                return;
-            }
-            innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream);
-            if (this.connected) {
-                this.sendNextBlock();
-            }
-        } catch (Exception e) {
-            callback.onFileTransferAborted();
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage());
-        }
-    }
-
-    @Override
-    public void disconnect() {
-        this.connected = false;
-        FileBackend.close(fileOutputStream);
-        FileBackend.close(fileInputStream);
-    }
-
-    private void sendNextBlock() {
-        byte[] buffer = new byte[this.blockSize];
-        try {
-            int count = innerInputStream.read(buffer);
-            if (count == -1) {
-                sendClose();
-                file.setSha1Sum(digest.digest());
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sendNextBlock() count was -1");
-                this.onFileTransmissionStatusChanged.onFileTransmitted(file);
-                fileInputStream.close();
-                return;
-            } else if (count != buffer.length) {
-                int rem = innerInputStream.read(buffer, count, buffer.length - count);
-                if (rem > 0) {
-                    count += rem;
-                }
-            }
-            this.remainingSize -= count;
-            this.digest.update(buffer, 0, count);
-            String base64 = Base64.encodeToString(buffer, 0, count, Base64.NO_WRAP);
-            IqPacket iq = new IqPacket(IqPacket.TYPE.SET);
-            iq.setTo(this.counterpart);
-            Element data = iq.addChild("data", "http://jabber.org/protocol/ibb");
-            data.setAttribute("seq", Integer.toString(this.seq));
-            data.setAttribute("block-size", Integer.toString(this.blockSize));
-            data.setAttribute("sid", this.sessionId);
-            data.setContent(base64);
-            this.account.getXmppConnection().sendIqPacket(iq, this.onAckReceived);
-            this.account.getXmppConnection().r(); //don't fill up stanza queue too much
-            this.seq++;
-            connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100));
-            if (this.remainingSize <= 0) {
-                file.setSha1Sum(digest.digest());
-                this.onFileTransmissionStatusChanged.onFileTransmitted(file);
-                sendClose();
-                fileInputStream.close();
-            }
-        } catch (IOException e) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": io exception during sendNextBlock() " + e.getMessage());
-            FileBackend.close(fileInputStream);
-            this.onFileTransmissionStatusChanged.onFileTransferAborted();
-        }
-    }
-
-    private void receiveNextBlock(String data) {
-        try {
-            byte[] buffer = Base64.decode(data, Base64.NO_WRAP);
-            if (this.remainingSize < buffer.length) {
-                buffer = Arrays.copyOfRange(buffer, 0, (int) this.remainingSize);
-            }
-            this.remainingSize -= buffer.length;
-            this.fileOutputStream.write(buffer);
-            this.digest.update(buffer);
-            if (this.remainingSize <= 0) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received last block. waiting for close");
-            } else {
-                connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100));
-            }
-        } catch (Exception e) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage(), e);
-            FileBackend.close(fileOutputStream);
-            this.onFileTransmissionStatusChanged.onFileTransferAborted();
-        }
-    }
-
-    private void done() {
-        try {
-            file.setSha1Sum(digest.digest());
-            fileOutputStream.flush();
-            fileOutputStream.close();
-            this.onFileTransmissionStatusChanged.onFileTransmitted(file);
-        } catch (Exception e) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage());
-            FileBackend.close(fileOutputStream);
-            this.onFileTransmissionStatusChanged.onFileTransferAborted();
-        }
-    }
-
-    void deliverPayload(IqPacket packet, Element payload) {
-        if (payload.getName().equals("open")) {
-            if (!established) {
-                established = true;
-                connected = true;
-                this.receiveNextBlock("");
-                this.account.getXmppConnection().sendIqPacket(
-                        packet.generateResponse(IqPacket.TYPE.RESULT), null);
-            } else {
-                this.account.getXmppConnection().sendIqPacket(
-                        packet.generateResponse(IqPacket.TYPE.ERROR), null);
-            }
-        } else if (connected && payload.getName().equals("data")) {
-            this.receiveNextBlock(payload.getContent());
-            this.account.getXmppConnection().sendIqPacket(
-                    packet.generateResponse(IqPacket.TYPE.RESULT), null);
-        } else if (connected && payload.getName().equals("close")) {
-            this.connected = false;
-            this.account.getXmppConnection().sendIqPacket(
-                    packet.generateResponse(IqPacket.TYPE.RESULT), null);
-            if (this.remainingSize <= 0) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close. done");
-                done();
-            } else {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close with " + this.remainingSize + " remaining");
-                FileBackend.close(fileOutputStream);
-                this.onFileTransmissionStatusChanged.onFileTransferAborted();
-            }
-        } else {
-            this.account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
-        }
-    }
-}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java πŸ”—

@@ -13,7 +13,6 @@ import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -30,14 +29,10 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.RtpSessionStatus;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.AppRTCAudioManager;
 import eu.siacs.conversations.utils.IP;
 import eu.siacs.conversations.xml.Element;
@@ -78,96 +73,13 @@ public class JingleRtpConnection extends AbstractJingleConnection
             Arrays.asList(
                     State.PROCEED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED);
     private static final long BUSY_TIME_OUT = 30;
-    private static final List<State> TERMINATED =
-            Arrays.asList(
-                    State.ACCEPTED,
-                    State.REJECTED,
-                    State.REJECTED_RACED,
-                    State.RETRACTED,
-                    State.RETRACTED_RACED,
-                    State.TERMINATED_SUCCESS,
-                    State.TERMINATED_DECLINED_OR_BUSY,
-                    State.TERMINATED_CONNECTIVITY_ERROR,
-                    State.TERMINATED_CANCEL_OR_TIMEOUT,
-                    State.TERMINATED_APPLICATION_FAILURE,
-                    State.TERMINATED_SECURITY_ERROR);
-
-    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
-
-    static {
-        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder =
-                new ImmutableMap.Builder<>();
-        transitionBuilder.put(
-                State.NULL,
-                ImmutableList.of(
-                        State.PROPOSED,
-                        State.SESSION_INITIALIZED,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR));
-        transitionBuilder.put(
-                State.PROPOSED,
-                ImmutableList.of(
-                        State.ACCEPTED,
-                        State.PROCEED,
-                        State.REJECTED,
-                        State.RETRACTED,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR,
-                        State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection
-                        // rebinds
-                        ));
-        transitionBuilder.put(
-                State.PROCEED,
-                ImmutableList.of(
-                        State.REJECTED_RACED,
-                        State.RETRACTED_RACED,
-                        State.SESSION_INITIALIZED_PRE_APPROVED,
-                        State.TERMINATED_SUCCESS,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR,
-                        State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error
-                        // bounces of the proceed message
-                        ));
-        transitionBuilder.put(
-                State.SESSION_INITIALIZED,
-                ImmutableList.of(
-                        State.SESSION_ACCEPTED,
-                        State.TERMINATED_SUCCESS,
-                        State.TERMINATED_DECLINED_OR_BUSY,
-                        State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
-                        // and IQ timeouts
-                        State.TERMINATED_CANCEL_OR_TIMEOUT,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR));
-        transitionBuilder.put(
-                State.SESSION_INITIALIZED_PRE_APPROVED,
-                ImmutableList.of(
-                        State.SESSION_ACCEPTED,
-                        State.TERMINATED_SUCCESS,
-                        State.TERMINATED_DECLINED_OR_BUSY,
-                        State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
-                        // and IQ timeouts
-                        State.TERMINATED_CANCEL_OR_TIMEOUT,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR));
-        transitionBuilder.put(
-                State.SESSION_ACCEPTED,
-                ImmutableList.of(
-                        State.TERMINATED_SUCCESS,
-                        State.TERMINATED_DECLINED_OR_BUSY,
-                        State.TERMINATED_CONNECTIVITY_ERROR,
-                        State.TERMINATED_CANCEL_OR_TIMEOUT,
-                        State.TERMINATED_APPLICATION_FAILURE,
-                        State.TERMINATED_SECURITY_ERROR));
-        VALID_TRANSITIONS = transitionBuilder.build();
-    }
 
     private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
-    private final Queue<Map.Entry<String, RtpContentMap.DescriptionTransport>>
+    private final Queue<Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>>>
             pendingIceCandidates = new LinkedList<>();
     private final OmemoVerification omemoVerification = new OmemoVerification();
     private final Message message;
-    private State state = State.NULL;
+
     private Set<Media> proposedMedia;
     private RtpContentMap initiatorRtpContentMap;
     private RtpContentMap responderRtpContentMap;
@@ -192,18 +104,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
                         id.sessionId);
     }
 
-    private static State reasonToState(Reason reason) {
-        return switch (reason) {
-            case SUCCESS -> State.TERMINATED_SUCCESS;
-            case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY;
-            case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT;
-            case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR;
-            case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State
-                    .TERMINATED_APPLICATION_FAILURE;
-            default -> State.TERMINATED_CONNECTIVITY_ERROR;
-        };
-    }
-
     @Override
     synchronized void deliverPacket(final JinglePacket jinglePacket) {
         switch (jinglePacket.getAction()) {
@@ -233,7 +133,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             return;
         }
         webRTCWrapper.close();
-        if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
+        if (isResponder() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
             xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
         }
         if (isInState(
@@ -322,7 +222,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
     private void receiveTransportInfo(
             final JinglePacket jinglePacket, final RtpContentMap contentMap) {
-        final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> candidates =
+        final Set<Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>>> candidates =
                 contentMap.contents.entrySet();
         final RtpContentMap remote = getRemoteContentMap();
         final Set<String> remoteContentIds =
@@ -522,7 +422,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
         setRemoteContentMap(modifiedContentMap);
 
-        final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator());
+        final SessionDescription answer = SessionDescription.of(modifiedContentMap, isResponder());
 
         final org.webrtc.SessionDescription sdp =
                 new org.webrtc.SessionDescription(
@@ -596,7 +496,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             }
             final SessionDescription offer;
             try {
-                offer = SessionDescription.of(modifiedRemoteContentMap, !isInitiator());
+                offer = SessionDescription.of(modifiedRemoteContentMap, isResponder());
             } catch (final IllegalArgumentException | NullPointerException e) {
                 Log.d(
                         Config.LOGTAG,
@@ -815,7 +715,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             final RtpContentMap nextRemote =
                     currentRemote.addContent(
                             patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup());
-            return SessionDescription.of(nextRemote, !isInitiator());
+            return SessionDescription.of(nextRemote, isResponder());
         }
         throw new IllegalStateException(
                 "Unexpected rollback condition. Senders were not uniformly none");
@@ -881,7 +781,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
         final SessionDescription offer;
         try {
-            offer = SessionDescription.of(modifiedContentMap, !isInitiator());
+            offer = SessionDescription.of(modifiedContentMap, isResponder());
         } catch (final IllegalArgumentException | NullPointerException e) {
             Log.d(
                     Config.LOGTAG,
@@ -1066,7 +966,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             final boolean isOffer)
             throws ExecutionException, InterruptedException {
         final SessionDescription sessionDescription =
-                SessionDescription.of(restartContentMap, !isInitiator());
+                SessionDescription.of(restartContentMap, isResponder());
         final org.webrtc.SessionDescription.Type type =
                 isOffer
                         ? org.webrtc.SessionDescription.Type.OFFER
@@ -1095,14 +995,14 @@ public class JingleRtpConnection extends AbstractJingleConnection
     }
 
     private void processCandidates(
-            final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
-        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contents) {
+            final Set<Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>>> contents) {
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> content : contents) {
             processCandidate(content);
         }
     }
 
     private void processCandidate(
-            final Map.Entry<String, RtpContentMap.DescriptionTransport> content) {
+            final Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> content) {
         final RtpContentMap rtpContentMap = getRemoteContentMap();
         final List<String> indices = toIdentificationTags(rtpContentMap);
         final String sdpMid = content.getKey(); // aka content name
@@ -1204,21 +1104,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
     private void receiveSessionInitiate(final JinglePacket jinglePacket) {
         if (isInitiator()) {
-            Log.d(
-                    Config.LOGTAG,
-                    String.format(
-                            "%s: received session-initiate even though we were initiating",
-                            id.account.getJid().asBareJid()));
-            if (isTerminated()) {
-                Log.d(
-                        Config.LOGTAG,
-                        String.format(
-                                "%s: got a reason to terminate with out-of-order. but already in state %s",
-                                id.account.getJid().asBareJid(), getState()));
-                respondWithOutOfOrder(jinglePacket);
-            } else {
-                terminateWithOutOfOrder(jinglePacket);
-            }
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE);
             return;
         }
         final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, false);
@@ -1300,13 +1186,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
     }
 
     private void receiveSessionAccept(final JinglePacket jinglePacket) {
-        if (!isInitiator()) {
-            Log.d(
-                    Config.LOGTAG,
-                    String.format(
-                            "%s: received session-accept even though we were responding",
-                            id.account.getJid().asBareJid()));
-            terminateWithOutOfOrder(jinglePacket);
+        if (isResponder()) {
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT);
             return;
         }
         final ListenableFuture<RtpContentMap> future =
@@ -1491,7 +1372,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     }
 
     private void addIceCandidatesFromBlackLog() {
-        Map.Entry<String, RtpContentMap.DescriptionTransport> foo;
+        Map.Entry<String, DescriptionTransport<RtpDescription,IceUdpTransportInfo>> foo;
         while ((foo = this.pendingIceCandidates.poll()) != null) {
             processCandidate(foo);
             Log.d(
@@ -2061,24 +1942,16 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
-    private void sendSessionTerminate(final Reason reason) {
+    protected void sendSessionTerminate(final Reason reason) {
         sendSessionTerminate(reason, null);
     }
 
-    private void sendSessionTerminate(final Reason reason, final String text) {
-        final State previous = this.state;
-        final State target = reasonToState(reason);
-        transitionOrThrow(target);
-        if (previous != State.NULL) {
-            writeLogMessage(target);
-        }
-        final JinglePacket jinglePacket =
-                new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
-        jinglePacket.setReason(reason, text);
-        send(jinglePacket);
-        finish();
+
+    protected void sendSessionTerminate(final Reason reason, final String text) {
+        sendSessionTerminate(reason,text, this::writeLogMessage);
     }
 
+
     private void sendTransportInfo(
             final String contentName, IceUdpTransportInfo.Candidate candidate) {
         final RtpContentMap transportInfo;
@@ -2099,110 +1972,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
         send(jinglePacket);
     }
 
-    private void send(final JinglePacket jinglePacket) {
-        jinglePacket.setTo(id.with);
-        xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
-    }
-
-    private synchronized void handleIqResponse(final Account account, final IqPacket response) {
-        if (response.getType() == IqPacket.TYPE.ERROR) {
-            handleIqErrorResponse(response);
-            return;
-        }
-        if (response.getType() == IqPacket.TYPE.TIMEOUT) {
-            handleIqTimeoutResponse(response);
-        }
-    }
-
-    private void handleIqErrorResponse(final IqPacket response) {
-        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR);
-        final String errorCondition = response.getErrorCondition();
-        Log.d(
-                Config.LOGTAG,
-                id.account.getJid().asBareJid()
-                        + ": received IQ-error from "
-                        + response.getFrom()
-                        + " in RTP session. "
-                        + errorCondition);
-        if (isTerminated()) {
-            Log.i(
-                    Config.LOGTAG,
-                    id.account.getJid().asBareJid()
-                            + ": ignoring error because session was already terminated");
-            return;
-        }
-        this.webRTCWrapper.close();
-        final State target;
-        if (Arrays.asList(
-                        "service-unavailable",
-                        "recipient-unavailable",
-                        "remote-server-not-found",
-                        "remote-server-timeout")
-                .contains(errorCondition)) {
-            target = State.TERMINATED_CONNECTIVITY_ERROR;
-        } else {
-            target = State.TERMINATED_APPLICATION_FAILURE;
-        }
-        transitionOrThrow(target);
-        this.finish();
-    }
-
-    private void handleIqTimeoutResponse(final IqPacket response) {
-        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT);
-        Log.d(
-                Config.LOGTAG,
-                id.account.getJid().asBareJid()
-                        + ": received IQ timeout in RTP session with "
-                        + id.with
-                        + ". terminating with connectivity error");
-        if (isTerminated()) {
-            Log.i(
-                    Config.LOGTAG,
-                    id.account.getJid().asBareJid()
-                            + ": ignoring error because session was already terminated");
-            return;
-        }
-        this.webRTCWrapper.close();
-        transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
-        this.finish();
-    }
-
-    private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
-        Log.d(
-                Config.LOGTAG,
-                id.account.getJid().asBareJid() + ": terminating session with out-of-order");
-        this.webRTCWrapper.close();
-        transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
-        respondWithOutOfOrder(jinglePacket);
-        this.finish();
-    }
-
-    private void respondWithTieBreak(final JinglePacket jinglePacket) {
-        respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
-    }
-
-    private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
-        respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
-    }
-
-    private void respondWithItemNotFound(final JinglePacket jinglePacket) {
-        respondWithJingleError(jinglePacket, null, "item-not-found", "cancel");
-    }
-
-    void respondWithJingleError(
-            final IqPacket original,
-            String jingleCondition,
-            String condition,
-            String conditionType) {
-        jingleConnectionManager.respondWithJingleError(
-                id.account, original, jingleCondition, condition, conditionType);
-    }
-
-    private void respondOk(final JinglePacket jinglePacket) {
-        xmppConnectionService.sendIqPacket(
-                id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
-    }
-
     public RtpEndUserState getEndUserState() {
         switch (this.state) {
             case NULL, PROPOSED, SESSION_INITIALIZED -> {
@@ -2398,7 +2167,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                             + ": received endCall() when session has already been terminated. nothing to do");
             return;
         }
-        if (isInState(State.PROPOSED) && !isInitiator()) {
+        if (isInState(State.PROPOSED) && isResponder()) {
             rejectCallFromProposed();
             return;
         }
@@ -2527,22 +2296,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
         sendSessionAccept();
     }
 
-    private synchronized boolean isInState(State... state) {
-        return Arrays.asList(state).contains(this.state);
-    }
-
-    private boolean transition(final State target) {
-        return transition(target, null);
-    }
 
-    private synchronized boolean transition(final State target, final Runnable runnable) {
-        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
-        if (validTransitions != null && validTransitions.contains(target)) {
-            this.state = target;
-            if (runnable != null) {
-                runnable.run();
-            }
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
+    @Override
+    protected synchronized boolean transition(final State target, final Runnable runnable) {
+        if (super.transition(target, runnable)) {
             updateEndUserState();
             updateOngoingCallNotification();
             return true;
@@ -2551,13 +2308,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
-    void transitionOrThrow(final State target) {
-        if (!transition(target)) {
-            throw new IllegalStateException(
-                    String.format("Unable to transition from %s to %s", this.state, target));
-        }
-    }
-
     @Override
     public void onIceCandidate(final IceCandidate iceCandidate) {
         final RtpContentMap rtpContentMap =
@@ -2893,98 +2643,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                     id.account,
                     request,
                     (account, response) -> {
-                        ImmutableList.Builder<PeerConnection.IceServer> listBuilder =
-                                new ImmutableList.Builder<>();
-                        if (response.getType() == IqPacket.TYPE.RESULT) {
-                            final Element services =
-                                    response.findChild(
-                                            "services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
-                            final List<Element> children =
-                                    services == null
-                                            ? Collections.emptyList()
-                                            : services.getChildren();
-                            for (final Element child : children) {
-                                if ("service".equals(child.getName())) {
-                                    final String type = child.getAttribute("type");
-                                    final String host = child.getAttribute("host");
-                                    final String sport = child.getAttribute("port");
-                                    final Integer port =
-                                            sport == null ? null : Ints.tryParse(sport);
-                                    final String transport = child.getAttribute("transport");
-                                    final String username = child.getAttribute("username");
-                                    final String password = child.getAttribute("password");
-                                    if (Strings.isNullOrEmpty(host) || port == null) {
-                                        continue;
-                                    }
-                                    if (port < 0 || port > 65535) {
-                                        continue;
-                                    }
-
-                                    if (Arrays.asList("stun", "stuns", "turn", "turns")
-                                                    .contains(type)
-                                            && Arrays.asList("udp", "tcp").contains(transport)) {
-                                        if (Arrays.asList("stuns", "turns").contains(type)
-                                                && "udp".equals(transport)) {
-                                            Log.d(
-                                                    Config.LOGTAG,
-                                                    id.account.getJid().asBareJid()
-                                                            + ": skipping invalid combination of udp/tls in external services");
-                                            continue;
-                                        }
-
-                                        // STUN URLs do not support a query section since M110
-                                        final String uri;
-                                        if (Arrays.asList("stun", "stuns").contains(type)) {
-                                            uri =
-                                                    String.format(
-                                                            "%s:%s:%s",
-                                                            type, IP.wrapIPv6(host), port);
-                                        } else {
-                                            uri =
-                                                    String.format(
-                                                            "%s:%s:%s?transport=%s",
-                                                            type,
-                                                            IP.wrapIPv6(host),
-                                                            port,
-                                                            transport);
-                                        }
-
-                                        final PeerConnection.IceServer.Builder iceServerBuilder =
-                                                PeerConnection.IceServer.builder(uri);
-                                        iceServerBuilder.setTlsCertPolicy(
-                                                PeerConnection.TlsCertPolicy
-                                                        .TLS_CERT_POLICY_INSECURE_NO_CHECK);
-                                        if (username != null && password != null) {
-                                            iceServerBuilder.setUsername(username);
-                                            iceServerBuilder.setPassword(password);
-                                        } else if (Arrays.asList("turn", "turns").contains(type)) {
-                                            // The WebRTC spec requires throwing an
-                                            // InvalidAccessError when username (from libwebrtc
-                                            // source coder)
-                                            // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
-                                            Log.d(
-                                                    Config.LOGTAG,
-                                                    id.account.getJid().asBareJid()
-                                                            + ": skipping "
-                                                            + type
-                                                            + "/"
-                                                            + transport
-                                                            + " without username and password");
-                                            continue;
-                                        }
-                                        final PeerConnection.IceServer iceServer =
-                                                iceServerBuilder.createIceServer();
-                                        Log.d(
-                                                Config.LOGTAG,
-                                                id.account.getJid().asBareJid()
-                                                        + ": discovered ICE Server: "
-                                                        + iceServer);
-                                        listBuilder.add(iceServer);
-                                    }
-                                }
-                            }
-                        }
-                        final List<PeerConnection.IceServer> iceServers = listBuilder.build();
+                        final var iceServers = IceServers.parse(response);
                         if (iceServers.size() == 0) {
                             Log.w(
                                     Config.LOGTAG,
@@ -3001,13 +2660,19 @@ public class JingleRtpConnection extends AbstractJingleConnection
             onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
         }
     }
+    
+    @Override
+    protected void terminateTransport() {
+        this.webRTCWrapper.close();
+    }
 
-    private void finish() {
+    @Override
+    protected void finish() {
         if (isTerminated()) {
             this.cancelRingingTimeout();
             this.webRTCWrapper.verifyClosed();
             this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
-            this.jingleConnectionManager.finishConnectionOrThrow(this);
+            super.finish();
         } else {
             throw new IllegalStateException(
                     String.format("Unable to call finish from %s", this.state));
@@ -3045,14 +2710,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
-    public State getState() {
-        return this.state;
-    }
-
-    boolean isTerminated() {
-        return TERMINATED.contains(this.state);
-    }
-
     public Optional<VideoTrack> getLocalVideoTrack() {
         return webRTCWrapper.getLocalVideoTrack();
     }
@@ -3091,17 +2748,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
         return remoteHasFeature(Namespace.SDP_OFFER_ANSWER);
     }
 
-    private boolean remoteHasFeature(final String feature) {
-        final Contact contact = id.getContact();
-        final Presence presence =
-                contact.getPresences().get(Strings.nullToEmpty(id.with.getResource()));
-        final ServiceDiscoveryResult serviceDiscoveryResult =
-                presence == null ? null : presence.getServiceDiscoveryResult();
-        final List<String> features =
-                serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures();
-        return features != null && features.contains(feature);
-    }
-
     private interface OnIceServersDiscovered {
         void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
     }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java πŸ”—

@@ -1,305 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle;
-
-import android.os.PowerManager;
-import android.util.Log;
-
-import com.google.common.io.ByteStreams;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.ServerSocket;
-import java.net.Socket;
-import java.net.SocketAddress;
-import java.nio.ByteBuffer;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.DownloadableFile;
-import eu.siacs.conversations.persistance.FileBackend;
-import eu.siacs.conversations.services.AbstractConnectionManager;
-import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.utils.SocksSocketFactory;
-import eu.siacs.conversations.utils.WakeLockHelper;
-import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
-
-public class JingleSocks5Transport extends JingleTransport {
-
-    private static final int SOCKET_TIMEOUT_DIRECT = 3000;
-    private static final int SOCKET_TIMEOUT_PROXY = 5000;
-
-    private final JingleCandidate candidate;
-    private final JingleFileTransferConnection connection;
-    private final String destination;
-    private final Account account;
-    private OutputStream outputStream;
-    private InputStream inputStream;
-    private boolean isEstablished = false;
-    private boolean activated = false;
-    private ServerSocket serverSocket;
-    private Socket socket;
-
-    JingleSocks5Transport(JingleFileTransferConnection jingleConnection, JingleCandidate candidate) {
-        final MessageDigest messageDigest;
-        try {
-            messageDigest = MessageDigest.getInstance("SHA-1");
-        } catch (NoSuchAlgorithmException e) {
-            throw new AssertionError(e);
-        }
-        this.candidate = candidate;
-        this.connection = jingleConnection;
-        this.account = jingleConnection.getId().account;
-        final StringBuilder destBuilder = new StringBuilder();
-        if (this.connection.getFtVersion() == FileTransferDescription.Version.FT_3) {
-            Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": using session Id instead of transport Id for proxy destination");
-            destBuilder.append(this.connection.getId().sessionId);
-        } else {
-            destBuilder.append(this.connection.getTransportId());
-        }
-        if (candidate.isOurs()) {
-            destBuilder.append(this.account.getJid());
-            destBuilder.append(this.connection.getId().with);
-        } else {
-            destBuilder.append(this.connection.getId().with);
-            destBuilder.append(this.account.getJid());
-        }
-        messageDigest.reset();
-        this.destination = CryptoHelper.bytesToHex(messageDigest.digest(destBuilder.toString().getBytes()));
-        if (candidate.isOurs() && candidate.getType() == JingleCandidate.TYPE_DIRECT) {
-            createServerSocket();
-        }
-    }
-
-    private void createServerSocket() {
-        try {
-            serverSocket = new ServerSocket();
-            serverSocket.bind(new InetSocketAddress(InetAddress.getByName(candidate.getHost()), candidate.getPort()));
-            new Thread(() -> {
-                try {
-                    final Socket socket = serverSocket.accept();
-                    new Thread(() -> {
-                        try {
-                            acceptIncomingSocketConnection(socket);
-                        } catch (IOException e) {
-                            Log.d(Config.LOGTAG, "unable to read from socket", e);
-
-                        }
-                    }).start();
-                } catch (IOException e) {
-                    if (!serverSocket.isClosed()) {
-                        Log.d(Config.LOGTAG, "unable to accept socket", e);
-                    }
-                }
-            }).start();
-        } catch (IOException e) {
-            Log.d(Config.LOGTAG, "unable to bind server socket ", e);
-        }
-    }
-
-    private void acceptIncomingSocketConnection(final Socket socket) throws IOException {
-        Log.d(Config.LOGTAG, "accepted connection from " + socket.getInetAddress().getHostAddress());
-        socket.setSoTimeout(SOCKET_TIMEOUT_DIRECT);
-        final byte[] authBegin = new byte[2];
-        final InputStream inputStream = socket.getInputStream();
-        final OutputStream outputStream = socket.getOutputStream();
-        ByteStreams.readFully(inputStream, authBegin);
-        if (authBegin[0] != 0x5) {
-            socket.close();
-        }
-        final short methodCount = authBegin[1];
-        final byte[] methods = new byte[methodCount];
-        ByteStreams.readFully(inputStream, methods);
-        if (SocksSocketFactory.contains((byte) 0x00, methods)) {
-            outputStream.write(new byte[]{0x05, 0x00});
-        } else {
-            outputStream.write(new byte[]{0x05, (byte) 0xff});
-        }
-        final byte[] connectCommand = new byte[4];
-        ByteStreams.readFully(inputStream, connectCommand);
-        if (connectCommand[0] == 0x05 && connectCommand[1] == 0x01 && connectCommand[3] == 0x03) {
-            int destinationCount = inputStream.read();
-            final byte[] destination = new byte[destinationCount];
-            ByteStreams.readFully(inputStream, destination);
-            final byte[] port = new byte[2];
-            ByteStreams.readFully(inputStream, port);
-            final String receivedDestination = new String(destination);
-            final ByteBuffer response = ByteBuffer.allocate(7 + destination.length);
-            final byte[] responseHeader;
-            final boolean success;
-            if (receivedDestination.equals(this.destination) && this.socket == null) {
-                responseHeader = new byte[]{0x05, 0x00, 0x00, 0x03};
-                success = true;
-            } else {
-                Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": destination mismatch. received " + receivedDestination + " (expected " + this.destination + ")");
-                responseHeader = new byte[]{0x05, 0x04, 0x00, 0x03};
-                success = false;
-            }
-            response.put(responseHeader);
-            response.put((byte) destination.length);
-            response.put(destination);
-            response.put(port);
-            outputStream.write(response.array());
-            outputStream.flush();
-            if (success) {
-                Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": successfully processed connection to candidate " + candidate.getHost() + ":" + candidate.getPort());
-                socket.setSoTimeout(0);
-                this.socket = socket;
-                this.inputStream = inputStream;
-                this.outputStream = outputStream;
-                this.isEstablished = true;
-                FileBackend.close(serverSocket);
-            } else {
-                FileBackend.close(socket);
-            }
-        } else {
-            socket.close();
-        }
-    }
-
-    public void connect(final OnTransportConnected callback) {
-        new Thread(() -> {
-            final int timeout = candidate.getType() == JingleCandidate.TYPE_DIRECT ? SOCKET_TIMEOUT_DIRECT : SOCKET_TIMEOUT_PROXY;
-            try {
-                final boolean useTor = this.account.isOnion() || connection.getConnectionManager().getXmppConnectionService().useTorToConnect();
-                if (useTor) {
-                    socket = SocksSocketFactory.createSocketOverTor(candidate.getHost(), candidate.getPort());
-                } else {
-                    socket = new Socket();
-                    SocketAddress address = new InetSocketAddress(candidate.getHost(), candidate.getPort());
-                    socket.connect(address, timeout);
-                }
-                inputStream = socket.getInputStream();
-                outputStream = socket.getOutputStream();
-                socket.setSoTimeout(timeout);
-                SocksSocketFactory.createSocksConnection(socket, destination, 0);
-                socket.setSoTimeout(0);
-                isEstablished = true;
-                callback.established();
-            } catch (final IOException e) {
-                callback.failed();
-            }
-        }).start();
-
-    }
-
-    public void send(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) {
-        new Thread(() -> {
-            InputStream fileInputStream = null;
-            final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_send_" + connection.getId().sessionId);
-            long transmitted = 0;
-            try {
-                wakeLock.acquire();
-                MessageDigest digest = MessageDigest.getInstance("SHA-1");
-                digest.reset();
-                fileInputStream = connection.getFileInputStream();
-                if (fileInputStream == null) {
-                    Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create input stream");
-                    callback.onFileTransferAborted();
-                    return;
-                }
-                final InputStream innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream);
-                long size = file.getExpectedSize();
-                int count;
-                byte[] buffer = new byte[8192];
-                while ((count = innerInputStream.read(buffer)) > 0) {
-                    outputStream.write(buffer, 0, count);
-                    digest.update(buffer, 0, count);
-                    transmitted += count;
-                    connection.updateProgress((int) ((((double) transmitted) / size) * 100));
-                }
-                outputStream.flush();
-                file.setSha1Sum(digest.digest());
-                if (callback != null) {
-                    callback.onFileTransmitted(file);
-                }
-            } catch (Exception e) {
-                final Account account = this.account;
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed sending file after " + transmitted + "/" + file.getExpectedSize() + " (" + socket.getInetAddress() + ":" + socket.getPort() + ")", e);
-                callback.onFileTransferAborted();
-            } finally {
-                FileBackend.close(fileInputStream);
-                WakeLockHelper.release(wakeLock);
-            }
-        }).start();
-
-    }
-
-    public void receive(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) {
-        new Thread(() -> {
-            OutputStream fileOutputStream = null;
-            final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_receive_" + connection.getId().sessionId);
-            try {
-                wakeLock.acquire();
-                MessageDigest digest = MessageDigest.getInstance("SHA-1");
-                digest.reset();
-                //inputStream.skip(45);
-                socket.setSoTimeout(30000);
-                fileOutputStream = connection.getFileOutputStream();
-                if (fileOutputStream == null) {
-                    callback.onFileTransferAborted();
-                    Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create output stream");
-                    return;
-                }
-                double size = file.getExpectedSize();
-                long remainingSize = file.getExpectedSize();
-                byte[] buffer = new byte[8192];
-                int count;
-                while (remainingSize > 0) {
-                    count = inputStream.read(buffer);
-                    if (count == -1) {
-                        callback.onFileTransferAborted();
-                        Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": file ended prematurely with " + remainingSize + " bytes remaining");
-                        return;
-                    } else {
-                        fileOutputStream.write(buffer, 0, count);
-                        digest.update(buffer, 0, count);
-                        remainingSize -= count;
-                    }
-                    connection.updateProgress((int) (((size - remainingSize) / size) * 100));
-                }
-                fileOutputStream.flush();
-                fileOutputStream.close();
-                file.setSha1Sum(digest.digest());
-                callback.onFileTransmitted(file);
-            } catch (Exception e) {
-                Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": " + e.getMessage());
-                callback.onFileTransferAborted();
-            } finally {
-                WakeLockHelper.release(wakeLock);
-                FileBackend.close(fileOutputStream);
-                FileBackend.close(inputStream);
-            }
-        }).start();
-    }
-
-    public boolean isProxy() {
-        return this.candidate.getType() == JingleCandidate.TYPE_PROXY;
-    }
-
-    public boolean needsActivation() {
-        return (this.isProxy() && !this.activated);
-    }
-
-    public void disconnect() {
-        FileBackend.close(inputStream);
-        FileBackend.close(outputStream);
-        FileBackend.close(socket);
-        FileBackend.close(serverSocket);
-    }
-
-    public boolean isEstablished() {
-        return this.isEstablished;
-    }
-
-    public JingleCandidate getCandidate() {
-        return this.candidate;
-    }
-
-    public void setActivated(boolean activated) {
-        this.activated = activated;
-    }
-}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java πŸ”—

@@ -1,15 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle;
-
-import eu.siacs.conversations.entities.DownloadableFile;
-
-public abstract class JingleTransport {
-	public abstract void connect(final OnTransportConnected callback);
-
-	public abstract void receive(final DownloadableFile file,
-			final OnFileTransmissionStatusChanged callback);
-
-	public abstract void send(final DownloadableFile file,
-			final OnFileTransmissionStatusChanged callback);
-
-	public abstract void disconnect();
-}

src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java πŸ”—

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle;
 
-import com.google.common.collect.ArrayListMultimap;
+import com.google.common.base.Joiner;
+import com.google.common.collect.Multimap;
 
 import java.util.List;
 
@@ -8,9 +9,9 @@ public class MediaBuilder {
     private String media;
     private int port;
     private String protocol;
-    private List<Integer> formats;
+    private String format;
     private String connectionData;
-    private ArrayListMultimap<String,String> attributes;
+    private Multimap<String, String> attributes;
 
     public MediaBuilder setMedia(String media) {
         this.media = media;
@@ -27,8 +28,13 @@ public class MediaBuilder {
         return this;
     }
 
-    public MediaBuilder setFormats(List<Integer> formats) {
-        this.formats = formats;
+    public MediaBuilder setFormats(final List<Integer> formats) {
+        this.format = Joiner.on(' ').join(formats);
+        return this;
+    }
+
+    public MediaBuilder setFormat(final String format) {
+        this.format = format;
         return this;
     }
 
@@ -37,12 +43,13 @@ public class MediaBuilder {
         return this;
     }
 
-    public MediaBuilder setAttributes(ArrayListMultimap<String,String> attributes) {
+    public MediaBuilder setAttributes(Multimap<String, String> attributes) {
         this.attributes = attributes;
         return this;
     }
 
     public SessionDescription.Media createMedia() {
-        return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes);
+        return new SessionDescription.Media(
+                media, port, protocol, format, connectionData, attributes);
     }
-}
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java πŸ”—

@@ -3,12 +3,14 @@ package eu.siacs.conversations.xmpp.jingle;
 import java.util.Map;
 
 import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 
 public class OmemoVerifiedRtpContentMap extends RtpContentMap {
-    public OmemoVerifiedRtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
+    public OmemoVerifiedRtpContentMap(Group group, Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> contents) {
         super(group, contents);
-        for(final DescriptionTransport descriptionTransport : contents.values()) {
+        for(final DescriptionTransport<RtpDescription,IceUdpTransportInfo> descriptionTransport : contents.values()) {
             if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
                 ((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport).ensureNoPlaintextFingerprint();
                 continue;

src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java πŸ”—

@@ -6,7 +6,6 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -31,19 +30,17 @@ import java.util.Set;
 
 import javax.annotation.Nonnull;
 
-public class RtpContentMap {
+public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTransportInfo> {
 
-    public final Group group;
-    public final Map<String, DescriptionTransport> contents;
-
-    public RtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
-        this.group = group;
-        this.contents = contents;
+    public RtpContentMap(
+            Group group,
+            Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> contents) {
+        super(group, contents);
     }
 
     public static RtpContentMap of(final JinglePacket jinglePacket) {
-        final Map<String, DescriptionTransport> contents =
-                DescriptionTransport.of(jinglePacket.getJingleContents());
+        final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> contents =
+                of(jinglePacket.getJingleContents());
         if (isOmemoVerified(contents)) {
             return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents);
         } else {
@@ -51,12 +48,15 @@ public class RtpContentMap {
         }
     }
 
-    private static boolean isOmemoVerified(Map<String, DescriptionTransport> contents) {
-        final Collection<DescriptionTransport> values = contents.values();
+    private static boolean isOmemoVerified(
+            Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> contents) {
+        final Collection<DescriptionTransport<RtpDescription, IceUdpTransportInfo>> values =
+                contents.values();
         if (values.size() == 0) {
             return false;
         }
-        for (final DescriptionTransport descriptionTransport : values) {
+        for (final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport :
+                values) {
             if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
                 continue;
             }
@@ -67,13 +67,13 @@ public class RtpContentMap {
 
     public static RtpContentMap of(
             final SessionDescription sessionDescription, final boolean isInitiator) {
-        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
-                new ImmutableMap.Builder<>();
+        final ImmutableMap.Builder<
+                        String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                contentMapBuilder = new ImmutableMap.Builder<>();
         for (SessionDescription.Media media : sessionDescription.media) {
             final String id = Iterables.getFirst(media.attributes.get("mid"), null);
             Preconditions.checkNotNull(id, "media has no mid");
-            contentMapBuilder.put(
-                    id, DescriptionTransport.of(sessionDescription, isInitiator, media));
+            contentMapBuilder.put(id, of(sessionDescription, isInitiator, media));
         }
         final String groupAttribute =
                 Iterables.getFirst(sessionDescription.attributes.get("group"), null);
@@ -94,26 +94,6 @@ public class RtpContentMap {
                         }));
     }
 
-    public Set<Content.Senders> getSenders() {
-        return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders));
-    }
-
-    public List<String> getNames() {
-        return ImmutableList.copyOf(contents.keySet());
-    }
-
-    void requireContentDescriptions() {
-        if (this.contents.size() == 0) {
-            throw new IllegalStateException("No contents available");
-        }
-        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
-            if (entry.getValue().description == null) {
-                throw new IllegalStateException(
-                        String.format("%s is lacking content description", entry.getKey()));
-            }
-        }
-    }
-
     void requireDTLSFingerprint() {
         requireDTLSFingerprint(false);
     }
@@ -122,7 +102,8 @@ public class RtpContentMap {
         if (this.contents.size() == 0) {
             throw new IllegalStateException("No contents available");
         }
-        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
+        for (Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> entry :
+                this.contents.entrySet()) {
             final IceUdpTransportInfo transport = entry.getValue().transport;
             final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
             if (fingerprint == null
@@ -146,31 +127,10 @@ public class RtpContentMap {
             }
         }
     }
-
-    JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) {
-        final JinglePacket jinglePacket = new JinglePacket(action, sessionId);
-        if (this.group != null) {
-            jinglePacket.addGroup(this.group);
-        }
-        for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
-            final DescriptionTransport descriptionTransport = entry.getValue();
-            final Content content =
-                    new Content(
-                            Content.Creator.INITIATOR,
-                            descriptionTransport.senders,
-                            entry.getKey());
-            if (descriptionTransport.description != null) {
-                content.addChild(descriptionTransport.description);
-            }
-            content.addChild(descriptionTransport.transport);
-            jinglePacket.addJingleContent(content);
-        }
-        return jinglePacket;
-    }
-
     RtpContentMap transportInfo(
             final String contentName, final IceUdpTransportInfo.Candidate candidate) {
-        final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName);
+        final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =
+                contents.get(contentName);
         final IceUdpTransportInfo transportInfo =
                 descriptionTransport == null ? null : descriptionTransport.transport;
         if (transportInfo == null) {
@@ -183,7 +143,7 @@ public class RtpContentMap {
                 null,
                 ImmutableMap.of(
                         contentName,
-                        new DescriptionTransport(
+                        new DescriptionTransport<>(
                                 descriptionTransport.senders, null, newTransportInfo)));
     }
 
@@ -193,21 +153,24 @@ public class RtpContentMap {
                 Maps.transformValues(
                         contents,
                         dt ->
-                                new DescriptionTransport(
+                                new DescriptionTransport<>(
                                         dt.senders, null, dt.transport.cloneWrapper())));
     }
 
     RtpContentMap withCandidates(
             ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates) {
-        final ImmutableMap.Builder<String, DescriptionTransport> contentBuilder =
-                new ImmutableMap.Builder<>();
-        for (final Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
+        final ImmutableMap.Builder<
+                        String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                contentBuilder = new ImmutableMap.Builder<>();
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                entry : this.contents.entrySet()) {
             final String name = entry.getKey();
-            final DescriptionTransport descriptionTransport = entry.getValue();
+            final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =
+                    entry.getValue();
             final var transport = descriptionTransport.transport;
             contentBuilder.put(
                     name,
-                    new DescriptionTransport(
+                    new DescriptionTransport<>(
                             descriptionTransport.senders,
                             descriptionTransport.description,
                             transport.withCandidates(candidates.get(name))));
@@ -247,7 +210,7 @@ public class RtpContentMap {
     }
 
     public IceUdpTransportInfo.Credentials getCredentials(final String contentName) {
-        final DescriptionTransport descriptionTransport = this.contents.get(contentName);
+        final var descriptionTransport = this.contents.get(contentName);
         if (descriptionTransport == null) {
             throw new IllegalArgumentException(
                     String.format(
@@ -287,7 +250,7 @@ public class RtpContentMap {
 
     public boolean emptyCandidates() {
         int count = 0;
-        for (DescriptionTransport descriptionTransport : contents.values()) {
+        for (final var descriptionTransport : contents.values()) {
             count += descriptionTransport.transport.getCandidates().size();
         }
         return count == 0;
@@ -300,17 +263,19 @@ public class RtpContentMap {
 
     public RtpContentMap modifiedCredentials(
             IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
-        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
-                new ImmutableMap.Builder<>();
-        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
-            final DescriptionTransport descriptionTransport = content.getValue();
+        final ImmutableMap.Builder<
+                        String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                contentMapBuilder = new ImmutableMap.Builder<>();
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                content : contents.entrySet()) {
+            final var descriptionTransport = content.getValue();
             final RtpDescription rtpDescription = descriptionTransport.description;
             final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
             final IceUdpTransportInfo modifiedTransportInfo =
                     transportInfo.modifyCredentials(credentials, setup);
             contentMapBuilder.put(
                     content.getKey(),
-                    new DescriptionTransport(
+                    new DescriptionTransport<>(
                             descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
         }
         return new RtpContentMap(this.group, contentMapBuilder.build());
@@ -321,16 +286,18 @@ public class RtpContentMap {
                 this.group,
                 Maps.transformValues(
                         contents,
-                        dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
+                        dt -> new DescriptionTransport<>(senders, dt.description, dt.transport)));
     }
 
     public RtpContentMap modifiedSendersChecked(
             final boolean isInitiator, final Map<String, Content.Senders> modification) {
-        final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
-                new ImmutableMap.Builder<>();
-        for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
+        final ImmutableMap.Builder<
+                        String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                contentMapBuilder = new ImmutableMap.Builder<>();
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                content : contents.entrySet()) {
             final String id = content.getKey();
-            final DescriptionTransport descriptionTransport = content.getValue();
+            final var descriptionTransport = content.getValue();
             final Content.Senders currentSenders = descriptionTransport.senders;
             final Content.Senders targetSenders = modification.get(id);
             if (targetSenders == null || currentSenders == targetSenders) {
@@ -339,7 +306,7 @@ public class RtpContentMap {
                 checkSenderModification(isInitiator, currentSenders, targetSenders);
                 contentMapBuilder.put(
                         id,
-                        new DescriptionTransport(
+                        new DescriptionTransport<>(
                                 targetSenders,
                                 descriptionTransport.description,
                                 descriptionTransport.transport));
@@ -386,7 +353,7 @@ public class RtpContentMap {
                 Maps.transformValues(
                         this.contents,
                         dt ->
-                                new DescriptionTransport(
+                                new DescriptionTransport<>(
                                         dt.senders,
                                         RtpDescription.stub(dt.description.getMedia()),
                                         IceUdpTransportInfo.STUB)));
@@ -415,120 +382,96 @@ public class RtpContentMap {
 
     public RtpContentMap addContent(
             final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) {
-        final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
-        final Map<String, DescriptionTransport> combinedFixedTransport =
-                Maps.transformValues(
-                        combined,
-                        dt -> {
-                            final IceUdpTransportInfo iceUdpTransportInfo;
-                            if (dt.transport.isStub()) {
-                                final IceUdpTransportInfo.Credentials credentials =
-                                        getDistinctCredentials();
-                                final Collection<String> iceOptions = getCombinedIceOptions();
-                                final DTLS dtls = getDistinctDtls();
-                                iceUdpTransportInfo =
-                                        IceUdpTransportInfo.of(
-                                                credentials,
-                                                iceOptions,
-                                                setupOverwrite,
-                                                dtls.hash,
-                                                dtls.fingerprint);
-                            } else {
-                                final IceUdpTransportInfo.Fingerprint fp =
-                                        dt.transport.getFingerprint();
-                                final IceUdpTransportInfo.Setup setup = fp.getSetup();
-                                iceUdpTransportInfo =
-                                        IceUdpTransportInfo.of(
-                                                dt.transport.getCredentials(),
-                                                dt.transport.getIceOptions(),
-                                                setup == IceUdpTransportInfo.Setup.ACTPASS
-                                                        ? setupOverwrite
-                                                        : setup,
-                                                fp.getHash(),
-                                                fp.getContent());
-                            }
-                            return new DescriptionTransport(
-                                    dt.senders, dt.description, iceUdpTransportInfo);
-                        });
+        final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> combined =
+                merge(contents, modification.contents);
+        final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                combinedFixedTransport =
+                        Maps.transformValues(
+                                combined,
+                                dt -> {
+                                    final IceUdpTransportInfo iceUdpTransportInfo;
+                                    if (dt.transport.isStub()) {
+                                        final IceUdpTransportInfo.Credentials credentials =
+                                                getDistinctCredentials();
+                                        final Collection<String> iceOptions =
+                                                getCombinedIceOptions();
+                                        final DTLS dtls = getDistinctDtls();
+                                        iceUdpTransportInfo =
+                                                IceUdpTransportInfo.of(
+                                                        credentials,
+                                                        iceOptions,
+                                                        setupOverwrite,
+                                                        dtls.hash,
+                                                        dtls.fingerprint);
+                                    } else {
+                                        final IceUdpTransportInfo.Fingerprint fp =
+                                                dt.transport.getFingerprint();
+                                        final IceUdpTransportInfo.Setup setup = fp.getSetup();
+                                        iceUdpTransportInfo =
+                                                IceUdpTransportInfo.of(
+                                                        dt.transport.getCredentials(),
+                                                        dt.transport.getIceOptions(),
+                                                        setup == IceUdpTransportInfo.Setup.ACTPASS
+                                                                ? setupOverwrite
+                                                                : setup,
+                                                        fp.getHash(),
+                                                        fp.getContent());
+                                    }
+                                    return new DescriptionTransport<>(
+                                            dt.senders, dt.description, iceUdpTransportInfo);
+                                });
         return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport));
     }
 
-    private static Map<String, DescriptionTransport> merge(
-            final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
-        final Map<String, DescriptionTransport> combined = new LinkedHashMap<>();
+    private static Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> merge(
+            final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> a,
+            final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> b) {
+        final Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> combined =
+                new LinkedHashMap<>();
         combined.putAll(a);
         combined.putAll(b);
         return ImmutableMap.copyOf(combined);
     }
 
-    public static class DescriptionTransport {
-        public final Content.Senders senders;
-        public final RtpDescription description;
-        public final IceUdpTransportInfo transport;
-
-        public DescriptionTransport(
-                final Content.Senders senders,
-                final RtpDescription description,
-                final IceUdpTransportInfo transport) {
-            this.senders = senders;
-            this.description = description;
-            this.transport = transport;
-        }
-
-        public static DescriptionTransport of(final Content content) {
-            final GenericDescription description = content.getDescription();
-            final GenericTransportInfo transportInfo = content.getTransport();
-            final Content.Senders senders = content.getSenders();
-            final RtpDescription rtpDescription;
-            final IceUdpTransportInfo iceUdpTransportInfo;
-            if (description == null) {
-                rtpDescription = null;
-            } else if (description instanceof RtpDescription) {
-                rtpDescription = (RtpDescription) description;
-            } else {
-                throw new UnsupportedApplicationException(
-                        "Content does not contain rtp description");
-            }
-            if (transportInfo instanceof IceUdpTransportInfo) {
-                iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
-            } else {
-                throw new UnsupportedTransportException(
-                        "Content does not contain ICE-UDP transport");
-            }
-            return new DescriptionTransport(
-                    senders,
-                    rtpDescription,
-                    OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
-        }
-
-        private static DescriptionTransport of(
-                final SessionDescription sessionDescription,
-                final boolean isInitiator,
-                final SessionDescription.Media media) {
-            final Content.Senders senders = Content.Senders.of(media, isInitiator);
-            final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
-            final IceUdpTransportInfo transportInfo =
-                    IceUdpTransportInfo.of(sessionDescription, media);
-            return new DescriptionTransport(senders, rtpDescription, transportInfo);
+    public static DescriptionTransport<RtpDescription, IceUdpTransportInfo> of(
+            final Content content) {
+        final GenericDescription description = content.getDescription();
+        final GenericTransportInfo transportInfo = content.getTransport();
+        final Content.Senders senders = content.getSenders();
+        final RtpDescription rtpDescription;
+        final IceUdpTransportInfo iceUdpTransportInfo;
+        if (description == null) {
+            rtpDescription = null;
+        } else if (description instanceof RtpDescription) {
+            rtpDescription = (RtpDescription) description;
+        } else {
+            throw new UnsupportedApplicationException("Content does not contain rtp description");
         }
-
-        public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
-            return ImmutableMap.copyOf(
-                    Maps.transformValues(
-                            contents, content -> content == null ? null : of(content)));
+        if (transportInfo instanceof IceUdpTransportInfo) {
+            iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
+        } else {
+            throw new UnsupportedTransportException("Content does not contain ICE-UDP transport");
         }
+        return new DescriptionTransport<>(
+                senders,
+                rtpDescription,
+                OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
     }
 
-    public static class UnsupportedApplicationException extends IllegalArgumentException {
-        UnsupportedApplicationException(String message) {
-            super(message);
-        }
+    private static DescriptionTransport<RtpDescription, IceUdpTransportInfo> of(
+            final SessionDescription sessionDescription,
+            final boolean isInitiator,
+            final SessionDescription.Media media) {
+        final Content.Senders senders = Content.Senders.of(media, isInitiator);
+        final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
+        final IceUdpTransportInfo transportInfo = IceUdpTransportInfo.of(sessionDescription, media);
+        return new DescriptionTransport<>(senders, rtpDescription, transportInfo);
     }
 
-    public static class UnsupportedTransportException extends IllegalArgumentException {
-        UnsupportedTransportException(String message) {
-            super(message);
-        }
+    private static Map<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> of(
+            final Map<String, Content> contents) {
+        return ImmutableMap.copyOf(
+                Maps.transformValues(contents, content -> content == null ? null : of(content)));
     }
 
     public static final class Diff {

src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java πŸ”—

@@ -10,12 +10,17 @@ import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
+import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -28,6 +33,8 @@ public class SessionDescription {
     public static final String LINE_DIVIDER = "\r\n";
     private static final String HARDCODED_MEDIA_PROTOCOL =
             "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
+    private static final String HARDCODED_APPLICATION_PROTOCOL = "UDP/DTLS/SCTP";
+    private static final String FORMAT_WEBRTC_DATA_CHANNEL = "webrtc-datachannel";
     private static final int HARDCODED_MEDIA_PORT = 9;
     private static final Collection<String> HARDCODED_ICE_OPTIONS =
             Collections.singleton("trickle");
@@ -52,9 +59,8 @@ public class SessionDescription {
         this.media = media;
     }
 
-    private static void appendAttributes(
-            StringBuilder s, ArrayListMultimap<String, String> attributes) {
-        for (Map.Entry<String, String> attribute : attributes.entries()) {
+    private static void appendAttributes(StringBuilder s, Multimap<String, String> attributes) {
+        for (final Map.Entry<String, String> attribute : attributes.entries()) {
             final String key = attribute.getKey();
             final String value = attribute.getValue();
             s.append("a=").append(key);
@@ -79,24 +85,20 @@ public class SessionDescription {
             final char key = pair[0].charAt(0);
             final String value = pair[1];
             switch (key) {
-                case 'v':
-                    sessionDescriptionBuilder.setVersion(ignorantIntParser(value));
-                    break;
-                case 'c':
+                case 'v' -> sessionDescriptionBuilder.setVersion(ignorantIntParser(value));
+                case 'c' -> {
                     if (currentMediaBuilder != null) {
                         currentMediaBuilder.setConnectionData(value);
                     } else {
                         sessionDescriptionBuilder.setConnectionData(value);
                     }
-                    break;
-                case 's':
-                    sessionDescriptionBuilder.setName(value);
-                    break;
-                case 'a':
+                }
+                case 's' -> sessionDescriptionBuilder.setName(value);
+                case 'a' -> {
                     final Pair<String, String> attribute = parseAttribute(value);
                     attributeMap.put(attribute.first, attribute.second);
-                    break;
-                case 'm':
+                }
+                case 'm' -> {
                     if (currentMediaBuilder == null) {
                         sessionDescriptionBuilder.setAttributes(attributeMap);
                     } else {
@@ -118,7 +120,7 @@ public class SessionDescription {
                     } else {
                         Log.d(Config.LOGTAG, "skipping media line " + line);
                     }
-                    break;
+                }
             }
         }
         if (currentMediaBuilder != null) {
@@ -131,6 +133,56 @@ public class SessionDescription {
         return sessionDescriptionBuilder.createSessionDescription();
     }
 
+    public static SessionDescription of(final FileTransferContentMap contentMap) {
+        final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
+        final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
+        final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
+
+        final Group group = contentMap.group;
+        if (group != null) {
+            final String semantics = group.getSemantics();
+            checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
+            final var idTags = group.getIdentificationTags();
+            for (final String content : idTags) {
+                checkNoWhitespace(content, "group content names must not contain any whitespace");
+            }
+            attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
+        }
+
+        // TODO my-media-stream can be removed I think
+        attributeMap.put("msid-semantic", " WMS my-media-stream");
+
+        for (final Map.Entry<
+                        String, DescriptionTransport<FileTransferDescription, GenericTransportInfo>>
+                entry : contentMap.contents.entrySet()) {
+            final var dt = entry.getValue();
+            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo;
+            if (dt.transport instanceof WebRTCDataChannelTransportInfo transportInfo) {
+                webRTCDataChannelTransportInfo = transportInfo;
+            } else {
+                throw new IllegalArgumentException("Transport is not of type WebRTCDataChannel");
+            }
+            final String name = entry.getKey();
+            checkNoWhitespace(name, "content name must not contain any whitespace");
+
+            final MediaBuilder mediaBuilder = new MediaBuilder();
+            mediaBuilder.setMedia("application");
+            mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
+            mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
+            mediaBuilder.setProtocol(HARDCODED_APPLICATION_PROTOCOL);
+            mediaBuilder.setAttributes(
+                    transportInfoMediaAttributes(webRTCDataChannelTransportInfo));
+            mediaBuilder.setFormat(FORMAT_WEBRTC_DATA_CHANNEL);
+            mediaListBuilder.add(mediaBuilder.createMedia());
+        }
+
+        sessionDescriptionBuilder.setVersion(0);
+        sessionDescriptionBuilder.setName("-");
+        sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
+        sessionDescriptionBuilder.setAttributes(attributeMap);
+        return sessionDescriptionBuilder.createSessionDescription();
+    }
+
     public static SessionDescription of(
             final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
         final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
@@ -140,58 +192,27 @@ public class SessionDescription {
         if (group != null) {
             final String semantics = group.getSemantics();
             checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
-            attributeMap.put(
-                    "group",
-                    group.getSemantics()
-                            + " "
-                            + Joiner.on(' ').join(group.getIdentificationTags()));
+            final var idTags = group.getIdentificationTags();
+            for (final String content : idTags) {
+                checkNoWhitespace(content, "group content names must not contain any whitespace");
+            }
+            attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags));
         }
 
+        // TODO my-media-stream can be removed I think
         attributeMap.put("msid-semantic", " WMS my-media-stream");
 
-        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry :
-                contentMap.contents.entrySet()) {
+        for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
+                entry : contentMap.contents.entrySet()) {
             final String name = entry.getKey();
-            RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue();
-            RtpDescription description = descriptionTransport.description;
-            IceUdpTransportInfo transport = descriptionTransport.transport;
+            checkNoWhitespace(name, "content name must not contain any whitespace");
+            final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =
+                    entry.getValue();
+            final RtpDescription description = descriptionTransport.description;
             final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
-            final String ufrag = transport.getAttribute("ufrag");
-            final String pwd = transport.getAttribute("pwd");
-            if (Strings.isNullOrEmpty(ufrag)) {
-                throw new IllegalArgumentException(
-                        "Transport element is missing required ufrag attribute");
-            }
-            checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
-            mediaAttributes.put("ice-ufrag", ufrag);
-            if (Strings.isNullOrEmpty(pwd)) {
-                throw new IllegalArgumentException(
-                        "Transport element is missing required pwd attribute");
-            }
-            checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
-            mediaAttributes.put("ice-pwd", pwd);
-            final List<String> negotiatedIceOptions = transport.getIceOptions();
-            final Collection<String> iceOptions =
-                    negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
-            mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
-            final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
-            if (fingerprint != null) {
-                final String hashFunction = fingerprint.getHash();
-                final String hash = fingerprint.getContent();
-                if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) {
-                    throw new IllegalArgumentException("DTLS-SRTP missing hash");
-                }
-                checkNoWhitespace(
-                        hashFunction, "DTLS-SRTP hash function must not contain whitespace");
-                checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace");
-                mediaAttributes.put("fingerprint", hashFunction + " " + hash);
-                final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
-                if (setup != null) {
-                    mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
-                }
-            }
+            mediaAttributes.putAll(transportInfoMediaAttributes(descriptionTransport.transport));
             final ImmutableList.Builder<Integer> formatBuilder = new ImmutableList.Builder<>();
-            for (RtpDescription.PayloadType payloadType : description.getPayloadTypes()) {
+            for (final RtpDescription.PayloadType payloadType : description.getPayloadTypes()) {
                 final String id = payloadType.getId();
                 if (Strings.isNullOrEmpty(id)) {
                     throw new IllegalArgumentException("Payload type is missing id");
@@ -353,6 +374,69 @@ public class SessionDescription {
         return sessionDescriptionBuilder.createSessionDescription();
     }
 
+    private static Multimap<String, String> transportInfoMediaAttributes(
+            final IceUdpTransportInfo transport) {
+        final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
+        final String ufrag = transport.getAttribute("ufrag");
+        final String pwd = transport.getAttribute("pwd");
+        if (Strings.isNullOrEmpty(ufrag)) {
+            throw new IllegalArgumentException(
+                    "Transport element is missing required ufrag attribute");
+        }
+        checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
+        mediaAttributes.put("ice-ufrag", ufrag);
+        if (Strings.isNullOrEmpty(pwd)) {
+            throw new IllegalArgumentException(
+                    "Transport element is missing required pwd attribute");
+        }
+        checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
+        mediaAttributes.put("ice-pwd", pwd);
+        final List<String> negotiatedIceOptions = transport.getIceOptions();
+        final Collection<String> iceOptions =
+                negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
+        mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
+        final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
+        if (fingerprint != null) {
+            final String hashFunction = fingerprint.getHash();
+            final String hash = fingerprint.getContent();
+            if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) {
+                throw new IllegalArgumentException("DTLS-SRTP missing hash");
+            }
+            checkNoWhitespace(hashFunction, "DTLS-SRTP hash function must not contain whitespace");
+            checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace");
+            mediaAttributes.put("fingerprint", hashFunction + " " + hash);
+            final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
+            if (setup != null) {
+                mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
+            }
+        }
+        return ImmutableMultimap.copyOf(mediaAttributes);
+    }
+
+    private static Multimap<String, String> transportInfoMediaAttributes(
+            final WebRTCDataChannelTransportInfo transport) {
+        final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
+        final var iceUdpTransportInfo = transport.innerIceUdpTransportInfo();
+        if (iceUdpTransportInfo == null) {
+            throw new IllegalArgumentException(
+                    "Transport element is missing inner ice-udp transport");
+        }
+        mediaAttributes.putAll(transportInfoMediaAttributes(iceUdpTransportInfo));
+        final Integer sctpPort = transport.getSctpPort();
+        if (sctpPort == null) {
+            throw new IllegalArgumentException(
+                    "Transport element is missing required sctp-port attribute");
+        }
+        mediaAttributes.put("sctp-port", String.valueOf(sctpPort));
+        final Integer maxMessageSize = transport.getMaxMessageSize();
+        if (maxMessageSize == null) {
+            throw new IllegalArgumentException(
+                    "Transport element is missing required max-message-size");
+        }
+        mediaAttributes.put("max-message-size", String.valueOf(maxMessageSize));
+        return ImmutableMultimap.copyOf(mediaAttributes);
+    }
+
     public static String checkNoWhitespace(final String input, final String message) {
         if (CharMatcher.whitespace().matchesAnyOf(input)) {
             throw new IllegalArgumentException(message);
@@ -421,7 +505,7 @@ public class SessionDescription {
                     .append(' ')
                     .append(media.protocol)
                     .append(' ')
-                    .append(Joiner.on(' ').join(media.formats))
+                    .append(media.format)
                     .append(LINE_DIVIDER);
             s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
             appendAttributes(s, media.attributes);
@@ -433,21 +517,21 @@ public class SessionDescription {
         public final String media;
         public final int port;
         public final String protocol;
-        public final List<Integer> formats;
+        public final String format;
         public final String connectionData;
-        public final ArrayListMultimap<String, String> attributes;
+        public final Multimap<String, String> attributes;
 
         public Media(
                 String media,
                 int port,
                 String protocol,
-                List<Integer> formats,
+                String format,
                 String connectionData,
-                ArrayListMultimap<String, String> attributes) {
+                Multimap<String, String> attributes) {
             this.media = media;
             this.port = port;
             this.protocol = protocol;
-            this.formats = formats;
+            this.format = format;
             this.connectionData = connectionData;
             this.attributes = attributes;
         }

src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java πŸ”—

@@ -406,7 +406,7 @@ public class WebRTCWrapper {
         }
     }
 
-    private static PeerConnection.RTCConfiguration buildConfiguration(
+    public static PeerConnection.RTCConfiguration buildConfiguration(
             final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
         final PeerConnection.RTCConfiguration rtcConfig =
                 new PeerConnection.RTCConfiguration(iceServers);
@@ -774,7 +774,7 @@ public class WebRTCWrapper {
         void onRenegotiationNeeded();
     }
 
-    private abstract static class SetSdpObserver implements SdpObserver {
+    public abstract static class SetSdpObserver implements SdpObserver {
 
         @Override
         public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) {
@@ -800,12 +800,12 @@ public class WebRTCWrapper {
 
     public static class PeerConnectionNotInitialized extends IllegalStateException {
 
-        private PeerConnectionNotInitialized() {
+        public PeerConnectionNotInitialized() {
             super("initialize PeerConnection first");
         }
     }
 
-    private static class FailureToSetDescriptionException extends IllegalArgumentException {
+    public static class FailureToSetDescriptionException extends IllegalArgumentException {
         public FailureToSetDescriptionException(String message) {
             super(message);
         }

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java πŸ”—

@@ -8,14 +8,14 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 
-import java.util.Locale;
-import java.util.Set;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.SessionDescription;
 
+import java.util.Locale;
+import java.util.Set;
+
 public class Content extends Element {
 
     public Content(final Creator creator, final Senders senders, final String name) {
@@ -65,7 +65,7 @@ public class Content extends Element {
             return null;
         }
         final String namespace = description.getNamespace();
-        if (FileTransferDescription.NAMESPACES.contains(namespace)) {
+        if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) {
             return FileTransferDescription.upgrade(description);
         } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
             return RtpDescription.upgrade(description);
@@ -90,9 +90,11 @@ public class Content extends Element {
         if (Namespace.JINGLE_TRANSPORTS_IBB.equals(namespace)) {
             return IbbTransportInfo.upgrade(transport);
         } else if (Namespace.JINGLE_TRANSPORTS_S5B.equals(namespace)) {
-            return S5BTransportInfo.upgrade(transport);
+            return SocksByteStreamsTransportInfo.upgrade(transport);
         } else if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) {
             return IceUdpTransportInfo.upgrade(transport);
+        } else if (Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(namespace)) {
+            return WebRTCDataChannelTransportInfo.upgrade(transport);
         } else if (transport != null) {
             return GenericTransportInfo.upgrade(transport);
         } else {
@@ -100,7 +102,6 @@ public class Content extends Element {
         }
     }
 
-
     public void setTransport(GenericTransportInfo transportInfo) {
         this.addChild(transportInfo);
     }
@@ -141,7 +142,7 @@ public class Content extends Element {
             } else if (attributes.contains("recvonly")) {
                 return initiator ? RESPONDER : INITIATOR;
             }
-            Log.w(Config.LOGTAG,"assuming default value for senders");
+            Log.w(Config.LOGTAG, "assuming default value for senders");
             // If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is
             // present, "sendrecv" SHOULD be assumed as the default
             // https://www.rfc-editor.org/rfc/rfc4566

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java πŸ”—

@@ -1,89 +1,233 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
-import com.google.common.base.Preconditions;
+import android.util.Log;
 
-import java.util.Arrays;
-import java.util.List;
+import androidx.annotation.NonNull;
 
-import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
-import eu.siacs.conversations.entities.DownloadableFile;
-import eu.siacs.conversations.xml.Element;
+import com.google.common.base.CaseFormat;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.BaseEncoding;
+import com.google.common.primitives.Longs;
 
-public class FileTransferDescription extends GenericDescription {
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
 
-    public static List<String> NAMESPACES = Arrays.asList(
-            Version.FT_3.namespace,
-            Version.FT_4.namespace,
-            Version.FT_5.namespace
-    );
+import java.util.List;
 
+public class FileTransferDescription extends GenericDescription {
 
-    private FileTransferDescription(String name, String namespace) {
-        super(name, namespace);
+    private FileTransferDescription() {
+        super("description", Namespace.JINGLE_APPS_FILE_TRANSFER);
     }
 
-    public Version getVersion() {
-        final String namespace = getNamespace();
-        if (namespace.equals(Version.FT_3.namespace)) {
-            return Version.FT_3;
-        } else if (namespace.equals(Version.FT_4.namespace)) {
-            return Version.FT_4;
-        } else if (namespace.equals(Version.FT_5.namespace)) {
-            return Version.FT_5;
-        } else {
-            throw new IllegalStateException("Unknown namespace");
+    public static FileTransferDescription of(final File fileDescription) {
+        final var description = new FileTransferDescription();
+        final var file = description.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER);
+        file.addChild("name").setContent(fileDescription.name);
+        file.addChild("size").setContent(Long.toString(fileDescription.size));
+        if (fileDescription.mediaType != null) {
+            file.addChild("mediaType").setContent(fileDescription.mediaType);
         }
+        return description;
     }
 
-    public Element getFileOffer() {
-        final Version version = getVersion();
-        if (version == Version.FT_3) {
-            final Element offer = this.findChild("offer");
-            return offer == null ? null : offer.findChild("file");
-        } else {
-            return this.findChild("file");
+    public File getFile() {
+        final Element fileElement = this.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER);
+        if (fileElement == null) {
+            Log.d(Config.LOGTAG,"no file? "+this);
+            throw new IllegalStateException("file transfer description has no file");
         }
+        final String name = fileElement.findChildContent("name");
+        final String sizeAsString = fileElement.findChildContent("size");
+        final String mediaType = fileElement.findChildContent("mediaType");
+        if (Strings.isNullOrEmpty(name) || Strings.isNullOrEmpty(sizeAsString)) {
+            throw new IllegalStateException("File definition is missing name and/or size");
+        }
+        final Long size = Longs.tryParse(sizeAsString);
+        if (size == null) {
+            throw new IllegalStateException("Invalid file size");
+        }
+        final List<Hash> hashes = findHashes(fileElement.getChildren());
+        return new File(size, name, mediaType, hashes);
     }
 
-    public static FileTransferDescription of(DownloadableFile file, Version version, XmppAxolotlMessage axolotlMessage) {
-        final FileTransferDescription description = new FileTransferDescription("description", version.getNamespace());
-        final Element fileElement;
-        if (version == Version.FT_3) {
-            Element offer = description.addChild("offer");
-            fileElement = offer.addChild("file");
-        } else {
-            fileElement = description.addChild("file");
+    public static SessionInfo getSessionInfo(@NonNull final JinglePacket jinglePacket) {
+        Preconditions.checkNotNull(jinglePacket);
+        Preconditions.checkArgument(
+                jinglePacket.getAction() == JinglePacket.Action.SESSION_INFO,
+                "jingle packet is not a session-info");
+        final Element jingle = jinglePacket.findChild("jingle", Namespace.JINGLE);
+        if (jingle == null) {
+            return null;
         }
-        fileElement.addChild("size").setContent(Long.toString(file.getExpectedSize()));
-        fileElement.addChild("name").setContent(file.getName());
-        if (axolotlMessage != null) {
-            fileElement.addChild(axolotlMessage.toElement());
+        final Element checksum = jingle.findChild("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER);
+        if (checksum != null) {
+            final Element file = checksum.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER);
+            final String name = checksum.getAttribute("name");
+            if (file == null || Strings.isNullOrEmpty(name)) {
+                return null;
+            }
+            return new Checksum(name, findHashes(file.getChildren()));
         }
-        return description;
+        final Element received = jingle.findChild("received", Namespace.JINGLE_APPS_FILE_TRANSFER);
+        if (received != null) {
+            final String name = received.getAttribute("name");
+            if (Strings.isNullOrEmpty(name)) {
+                return new Received(name);
+            }
+        }
+        return null;
+    }
+
+    private static List<Hash> findHashes(final List<Element> elements) {
+        final ImmutableList.Builder<Hash> hashes = new ImmutableList.Builder<>();
+        for (final Element child : elements) {
+            if ("hash".equals(child.getName()) && Namespace.HASHES.equals(child.getNamespace())) {
+                final Algorithm algorithm;
+                try {
+                    algorithm = Algorithm.of(child.getAttribute("algo"));
+                } catch (final IllegalArgumentException e) {
+                    continue;
+                }
+                final String content = child.getContent();
+                if (Strings.isNullOrEmpty(content)) {
+                    continue;
+                }
+                if (BaseEncoding.base64().canDecode(content)) {
+                    hashes.add(new Hash(BaseEncoding.base64().decode(content), algorithm));
+                }
+            }
+        }
+        return hashes.build();
     }
 
     public static FileTransferDescription upgrade(final Element element) {
-        Preconditions.checkArgument("description".equals(element.getName()), "Name of provided element is not description");
-        Preconditions.checkArgument(NAMESPACES.contains(element.getNamespace()), "Element does not match a file transfer namespace");
-        final FileTransferDescription description = new FileTransferDescription("description", element.getNamespace());
+        Preconditions.checkArgument(
+                "description".equals(element.getName()),
+                "Name of provided element is not description");
+        Preconditions.checkArgument(
+                element.getNamespace().equals(Namespace.JINGLE_APPS_FILE_TRANSFER),
+                "Element does not match a file transfer namespace");
+        final FileTransferDescription description = new FileTransferDescription();
         description.setAttributes(element.getAttributes());
         description.setChildren(element.getChildren());
         return description;
     }
 
-    public enum Version {
-        FT_3("urn:xmpp:jingle:apps:file-transfer:3"),
-        FT_4("urn:xmpp:jingle:apps:file-transfer:4"),
-        FT_5("urn:xmpp:jingle:apps:file-transfer:5");
+    public static final class Checksum extends SessionInfo {
+        public final List<Hash> hashes;
+
+        public Checksum(final String name, List<Hash> hashes) {
+            super(name);
+            this.hashes = hashes;
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return MoreObjects.toStringHelper(this).add("hashes", hashes).toString();
+        }
+
+        @Override
+        public Element asElement() {
+            final var checksum = new Element("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER);
+            checksum.setAttribute("name", name);
+            final var file = checksum.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER);
+            for (final Hash hash : hashes) {
+                final var element = file.addChild("hash", Namespace.HASHES);
+                element.setAttribute(
+                        "algo",
+                        CaseFormat.UPPER_UNDERSCORE.to(
+                                CaseFormat.LOWER_HYPHEN, hash.algorithm.toString()));
+                element.setContent(BaseEncoding.base64().encode(hash.hash));
+            }
+            return checksum;
+        }
+    }
+
+    public static final class Received extends SessionInfo {
+
+        public Received(String name) {
+            super(name);
+        }
+
+        @Override
+        public Element asElement() {
+            final var element = new Element("received", Namespace.JINGLE_APPS_FILE_TRANSFER);
+            element.setAttribute("name", name);
+            return element;
+        }
+    }
+
+    public abstract static sealed class SessionInfo permits Checksum, Received {
 
-        private final String namespace;
+        public final String name;
 
-        Version(String namespace) {
-            this.namespace = namespace;
+        protected SessionInfo(final String name) {
+            this.name = name;
         }
 
-        public String getNamespace() {
-            return namespace;
+        public abstract Element asElement();
+    }
+
+    public static class File {
+        public final long size;
+        public final String name;
+        public final String mediaType;
+
+        public final List<Hash> hashes;
+
+        public File(long size, String name, String mediaType, List<Hash> hashes) {
+            this.size = size;
+            this.name = name;
+            this.mediaType = mediaType;
+            this.hashes = hashes;
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("size", size)
+                    .add("name", name)
+                    .add("mediaType", mediaType)
+                    .add("hashes", hashes)
+                    .toString();
+        }
+    }
+
+    public static class Hash {
+        public final byte[] hash;
+        public final Algorithm algorithm;
+
+        public Hash(byte[] hash, Algorithm algorithm) {
+            this.hash = hash;
+            this.algorithm = algorithm;
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("hash", hash)
+                    .add("algorithm", algorithm)
+                    .toString();
+        }
+    }
+
+    public enum Algorithm {
+        SHA_1,
+        SHA_256;
+
+        public static Algorithm of(final String value) {
+            if (Strings.isNullOrEmpty(value)) {
+                return null;
+            }
+            return valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value));
         }
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java πŸ”—

@@ -41,7 +41,7 @@ public class Group extends Element {
     }
 
     public static Group ofSdpString(final String input) {
-        ImmutableList.Builder<String> tagBuilder = new ImmutableList.Builder<>();
+        final ImmutableList.Builder<String> tagBuilder = new ImmutableList.Builder<>();
         final String[] parts = input.split(" ");
         if (parts.length >= 2) {
             final String semantics = parts[0];

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java πŸ”—

@@ -1,6 +1,8 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
 import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.primitives.Longs;
 
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
@@ -23,16 +25,9 @@ public class IbbTransportInfo extends GenericTransportInfo {
         return this.getAttribute("sid");
     }
 
-    public int getBlockSize() {
+    public Long getBlockSize() {
         final String blockSize = this.getAttribute("block-size");
-        if (blockSize == null) {
-            return 0;
-        }
-        try {
-            return Integer.parseInt(blockSize);
-        } catch (NumberFormatException e) {
-            return 0;
-        }
+        return Strings.isNullOrEmpty(blockSize) ? null : Longs.tryParse(blockSize);
     }
 
     public static IbbTransportInfo upgrade(final Element element) {

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java πŸ”—

@@ -15,11 +15,13 @@ import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.SessionDescription;
+import eu.siacs.conversations.xmpp.jingle.transports.Transport;
 
 import java.util.Arrays;
 import java.util.Collection;
@@ -195,7 +197,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
         }
     }
 
-    public static class Candidate extends Element {
+    public static class Candidate extends Element implements Transport.Candidate {
 
         private Candidate() {
             super("candidate");
@@ -396,7 +398,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             return fingerprint;
         }
 
-        private static Fingerprint of(ArrayListMultimap<String, String> attributes) {
+        private static Fingerprint of(final Multimap<String, String> attributes) {
             final String fingerprint = Iterables.getFirst(attributes.get("fingerprint"), null);
             final String setup = Iterables.getFirst(attributes.get("setup"), null);
             if (setup != null && fingerprint != null) {

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java πŸ”—

@@ -1,5 +1,7 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
+import android.util.Log;
+
 import androidx.annotation.NonNull;
 
 import com.google.common.base.CaseFormat;
@@ -7,13 +9,16 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 
-import java.util.Map;
-
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.crypto.axolotl.AxolotlService;
+import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 
+import java.util.Map;
+
 public class JinglePacket extends IqPacket {
 
     private JinglePacket() {
@@ -36,7 +41,7 @@ public class JinglePacket extends IqPacket {
         return jinglePacket;
     }
 
-    //TODO deprecate this somehow and make file transfer fail if there are multiple (or something)
+    // TODO deprecate this somehow and make file transfer fail if there are multiple (or something)
     public Content getJingleContent() {
         final Element content = getJingleChild("content");
         return content == null ? null : Content.upgrade(content);
@@ -64,7 +69,7 @@ public class JinglePacket extends IqPacket {
         return builder.build();
     }
 
-    public void addJingleContent(final Content content) { //take content interface
+    public void addJingleContent(final Content content) { // take content interface
         addJingleChild(content);
     }
 
@@ -94,13 +99,13 @@ public class JinglePacket extends IqPacket {
         }
     }
 
-    //RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise
+    // RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise
     public void setInitiator(final Jid initiator) {
         Preconditions.checkArgument(initiator.isFullJid(), "initiator should be a full JID");
         findChild("jingle", Namespace.JINGLE).setAttribute("initiator", initiator);
     }
 
-    //RECOMMENDED for session-accept, NOT RECOMMENDED otherwise
+    // RECOMMENDED for session-accept, NOT RECOMMENDED otherwise
     public void setResponder(Jid responder) {
         Preconditions.checkArgument(responder.isFullJid(), "responder should be a full JID");
         findChild("jingle", Namespace.JINGLE).setAttribute("responder", responder);
@@ -116,6 +121,39 @@ public class JinglePacket extends IqPacket {
         jingle.addChild(child);
     }
 
+    public void setSecurity(final String name, final XmppAxolotlMessage xmppAxolotlMessage) {
+        final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
+        security.setAttribute("name", name);
+        security.setAttribute("cipher", "urn:xmpp:ciphers:aes-128-gcm-nopadding");
+        security.setAttribute("type", AxolotlService.PEP_PREFIX);
+        security.addChild(xmppAxolotlMessage.toElement());
+        addJingleChild(security);
+    }
+
+    public XmppAxolotlMessage getSecurity(final String nameNeedle) {
+        final Element jingle = findChild("jingle", Namespace.JINGLE);
+        if (jingle == null) {
+            return null;
+        }
+        for (final Element child : jingle.getChildren()) {
+            if ("security".equals(child.getName())
+                    && Namespace.JINGLE_ENCRYPTED_TRANSPORT.equals(child.getNamespace())) {
+                final String name = child.getAttribute("name");
+                final String type = child.getAttribute("type");
+                final String cipher = child.getAttribute("cipher");
+                if (nameNeedle.equals(name)
+                        && AxolotlService.PEP_PREFIX.equals(type)
+                        && "urn:xmpp:ciphers:aes-128-gcm-nopadding".equals(cipher)) {
+                    final var encrypted = child.findChild("encrypted", AxolotlService.PEP_PREFIX);
+                    if (encrypted != null) {
+                        return XmppAxolotlMessage.fromElement(encrypted, getFrom().asBareJid());
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
     public String getSessionId() {
         return findChild("jingle", Namespace.JINGLE).getAttribute("sid");
     }
@@ -142,7 +180,7 @@ public class JinglePacket extends IqPacket {
         TRANSPORT_REPLACE;
 
         public static Action of(final String value) {
-            //TODO handle invalid
+            // TODO handle invalid
             return Action.valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value));
         }
 
@@ -153,7 +191,6 @@ public class JinglePacket extends IqPacket {
         }
     }
 
-
     public static class ReasonWrapper {
         public final Reason reason;
         public final String text;

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java πŸ”—

@@ -18,7 +18,7 @@ public class Propose extends Element {
         for (final Element child : this.children) {
             if ("description".equals(child.getName())) {
                 final String namespace = child.getNamespace();
-                if (FileTransferDescription.NAMESPACES.contains(namespace)) {
+                if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) {
                     builder.add(FileTransferDescription.upgrade(child));
                 } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
                     builder.add(RtpDescription.upgrade(child));

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java πŸ”—

@@ -1,50 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle.stanzas;
-
-import com.google.common.base.Preconditions;
-
-import java.util.Collection;
-import java.util.List;
-
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.jingle.JingleCandidate;
-
-public class S5BTransportInfo extends GenericTransportInfo {
-
-    private S5BTransportInfo(final String name, final String xmlns) {
-        super(name, xmlns);
-    }
-
-    public String getTransportId() {
-        return this.getAttribute("sid");
-    }
-
-    public S5BTransportInfo(final String transportId, final Collection<JingleCandidate> candidates) {
-        super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
-        Preconditions.checkNotNull(transportId,"transport id must not be null");
-        for(JingleCandidate candidate : candidates) {
-            this.addChild(candidate.toElement());
-        }
-        this.setAttribute("sid", transportId);
-    }
-
-    public S5BTransportInfo(final String transportId, final Element child) {
-        super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
-        Preconditions.checkNotNull(transportId,"transport id must not be null");
-        this.addChild(child);
-        this.setAttribute("sid", transportId);
-    }
-
-    public List<JingleCandidate> getCandidates() {
-        return JingleCandidate.parse(this.getChildren());
-    }
-
-    public static S5BTransportInfo upgrade(final Element element) {
-        Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport");
-        Preconditions.checkArgument(Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), "Element does not match s5b transport namespace");
-        final S5BTransportInfo transportInfo = new S5BTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_S5B);
-        transportInfo.setAttributes(element.getAttributes());
-        transportInfo.setChildren(element.getChildren());
-        return transportInfo;
-    }
-}

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java πŸ”—

@@ -0,0 +1,117 @@
+package eu.siacs.conversations.xmpp.jingle.stanzas;
+
+import android.util.Log;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport;
+
+import java.util.Collection;
+import java.util.List;
+
+public class SocksByteStreamsTransportInfo extends GenericTransportInfo {
+
+    private SocksByteStreamsTransportInfo() {
+        super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
+    }
+
+    public String getTransportId() {
+        return this.getAttribute("sid");
+    }
+
+    public SocksByteStreamsTransportInfo(
+            final String transportId,
+            final Collection<SocksByteStreamsTransport.Candidate> candidates) {
+        super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
+        Preconditions.checkNotNull(transportId, "transport id must not be null");
+        for (SocksByteStreamsTransport.Candidate candidate : candidates) {
+            this.addChild(candidate.asElement());
+        }
+        this.setAttribute("sid", transportId);
+    }
+
+    public TransportInfo getTransportInfo() {
+        if (hasChild("proxy-error")) {
+            return new ProxyError();
+        } else if (hasChild("candidate-error")) {
+            return new CandidateError();
+        } else if (hasChild("candidate-used")) {
+            final Element candidateUsed = findChild("candidate-used");
+            final String cid = candidateUsed == null ? null : candidateUsed.getAttribute("cid");
+            if (Strings.isNullOrEmpty(cid)) {
+                return null;
+            } else {
+                return new CandidateUsed(cid);
+            }
+        } else if (hasChild("activated")) {
+            final Element activated = findChild("activated");
+            final String cid = activated == null ? null : activated.getAttribute("cid");
+            if (Strings.isNullOrEmpty(cid)) {
+                return null;
+            } else {
+                return new Activated(cid);
+            }
+        } else {
+            return null;
+        }
+    }
+
+    public List<SocksByteStreamsTransport.Candidate> getCandidates() {
+        final ImmutableList.Builder<SocksByteStreamsTransport.Candidate> candidateBuilder =
+                new ImmutableList.Builder<>();
+        for (final Element child : this.children) {
+            if ("candidate".equals(child.getName())
+                    && Namespace.JINGLE_TRANSPORTS_S5B.equals(child.getNamespace())) {
+                try {
+                    candidateBuilder.add(SocksByteStreamsTransport.Candidate.of(child));
+                } catch (final Exception e) {
+                    Log.d(Config.LOGTAG, "skip over broken candidate", e);
+                }
+            }
+        }
+        return candidateBuilder.build();
+    }
+
+    public static SocksByteStreamsTransportInfo upgrade(final Element element) {
+        Preconditions.checkArgument(
+                "transport".equals(element.getName()), "Name of provided element is not transport");
+        Preconditions.checkArgument(
+                Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()),
+                "Element does not match s5b transport namespace");
+        final SocksByteStreamsTransportInfo transportInfo = new SocksByteStreamsTransportInfo();
+        transportInfo.setAttributes(element.getAttributes());
+        transportInfo.setChildren(element.getChildren());
+        return transportInfo;
+    }
+
+    public String getDestinationAddress() {
+        return this.getAttribute("dstaddr");
+    }
+
+    public abstract static class TransportInfo {}
+
+    public static class CandidateUsed extends TransportInfo {
+        public final String cid;
+
+        public CandidateUsed(String cid) {
+            this.cid = cid;
+        }
+    }
+
+    public static class Activated extends TransportInfo {
+        public final String cid;
+
+        public Activated(final String cid) {
+            this.cid = cid;
+        }
+    }
+
+    public static class CandidateError extends TransportInfo {}
+
+    public static class ProxyError extends TransportInfo {}
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java πŸ”—

@@ -0,0 +1,111 @@
+package eu.siacs.conversations.xmpp.jingle.stanzas;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Ints;
+
+import java.util.Collections;
+import java.util.Hashtable;
+import java.util.List;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.jingle.SessionDescription;
+import eu.siacs.conversations.xmpp.jingle.transports.Transport;
+
+public class WebRTCDataChannelTransportInfo extends GenericTransportInfo {
+
+    public static final WebRTCDataChannelTransportInfo STUB = new WebRTCDataChannelTransportInfo();
+
+    public WebRTCDataChannelTransportInfo() {
+        super("transport", Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
+    }
+
+    public static WebRTCDataChannelTransportInfo upgrade(final Element element) {
+        Preconditions.checkArgument(
+                "transport".equals(element.getName()), "Name of provided element is not transport");
+        Preconditions.checkArgument(
+                Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(element.getNamespace()),
+                "Element does not match ice-udp transport namespace");
+        final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo();
+        transportInfo.setAttributes(element.getAttributes());
+        transportInfo.setChildren(element.getChildren());
+        return transportInfo;
+    }
+
+    public IceUdpTransportInfo innerIceUdpTransportInfo() {
+        final var iceUdpTransportInfo =
+                this.findChild("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP);
+        if (iceUdpTransportInfo != null) {
+            return IceUdpTransportInfo.upgrade(iceUdpTransportInfo);
+        }
+        return null;
+    }
+
+    public static Transport.InitialTransportInfo of(final SessionDescription sessionDescription) {
+        final SessionDescription.Media media = Iterables.getOnlyElement(sessionDescription.media);
+        final String id = Iterables.getFirst(media.attributes.get("mid"), null);
+        Preconditions.checkNotNull(id, "media has no mid");
+        final String maxMessageSize =
+                Iterables.getFirst(media.attributes.get("max-message-size"), null);
+        final Integer maxMessageSizeInt =
+                maxMessageSize == null ? null : Ints.tryParse(maxMessageSize);
+        final String sctpPort = Iterables.getFirst(media.attributes.get("sctp-port"), null);
+        final Integer sctpPortInt = sctpPort == null ? null : Ints.tryParse(sctpPort);
+        final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo =
+                new WebRTCDataChannelTransportInfo();
+        if (maxMessageSizeInt != null) {
+            webRTCDataChannelTransportInfo.setAttribute("max-message-size", maxMessageSizeInt);
+        }
+        if (sctpPortInt != null) {
+            webRTCDataChannelTransportInfo.setAttribute("sctp-port", sctpPortInt);
+        }
+        webRTCDataChannelTransportInfo.addChild(IceUdpTransportInfo.of(sessionDescription, media));
+
+        final String groupAttribute =
+                Iterables.getFirst(sessionDescription.attributes.get("group"), null);
+        final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute);
+        return new Transport.InitialTransportInfo(id, webRTCDataChannelTransportInfo, group);
+    }
+
+    public Integer getSctpPort() {
+        final var attribute = this.getAttribute("sctp-port");
+        if (attribute == null) {
+            return null;
+        }
+        return Ints.tryParse(attribute);
+    }
+
+    public Integer getMaxMessageSize() {
+        final var attribute = this.getAttribute("max-message-size");
+        if (attribute == null) {
+            return null;
+        }
+        return Ints.tryParse(attribute);
+    }
+
+    public WebRTCDataChannelTransportInfo cloneWrapper() {
+        final var iceUdpTransport = this.innerIceUdpTransportInfo();
+        final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo();
+        transportInfo.setAttributes(new Hashtable<>(getAttributes()));
+        transportInfo.addChild(iceUdpTransport.cloneWrapper());
+        return transportInfo;
+    }
+
+    public void addCandidate(final IceUdpTransportInfo.Candidate candidate) {
+        this.innerIceUdpTransportInfo().addChild(candidate);
+    }
+
+    public List<IceUdpTransportInfo.Candidate> getCandidates() {
+        final var innerTransportInfo = this.innerIceUdpTransportInfo();
+        if (innerTransportInfo == null) {
+            return Collections.emptyList();
+        }
+        return innerTransportInfo.getCandidates();
+    }
+
+    public IceUdpTransportInfo.Credentials getCredentials() {
+        final var innerTransportInfo = this.innerIceUdpTransportInfo();
+        return innerTransportInfo == null ? null : innerTransportInfo.getCredentials();
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java πŸ”—

@@ -0,0 +1,321 @@
+package eu.siacs.conversations.xmpp.jingle.transports;
+
+import android.util.Log;
+
+import com.google.common.base.Strings;
+import com.google.common.io.BaseEncoding;
+import com.google.common.io.Closeables;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class InbandBytestreamsTransport implements Transport {
+
+    private static final int DEFAULT_BLOCK_SIZE = 8192;
+
+    private final PipedInputStream pipedInputStream = new PipedInputStream(DEFAULT_BLOCK_SIZE);
+    private final PipedOutputStream pipedOutputStream = new PipedOutputStream();
+    private final CountDownLatch terminationLatch = new CountDownLatch(1);
+
+    private final XmppConnection xmppConnection;
+
+    private final Jid with;
+
+    private final boolean initiator;
+
+    private final String streamId;
+
+    private int blockSize;
+    private Callback transportCallback;
+    private final BlockSender blockSender;
+
+    private final Thread blockSenderThread;
+
+    private final AtomicBoolean isReceiving = new AtomicBoolean(false);
+
+    public InbandBytestreamsTransport(
+            final XmppConnection xmppConnection, final Jid with, final boolean initiator) {
+        this(xmppConnection, with, initiator, UUID.randomUUID().toString(), DEFAULT_BLOCK_SIZE);
+    }
+
+    public InbandBytestreamsTransport(
+            final XmppConnection xmppConnection,
+            final Jid with,
+            final boolean initiator,
+            final String streamId,
+            final int blockSize) {
+        this.xmppConnection = xmppConnection;
+        this.with = with;
+        this.initiator = initiator;
+        this.streamId = streamId;
+        this.blockSize = Math.min(DEFAULT_BLOCK_SIZE, blockSize);
+        this.blockSender =
+                new BlockSender(xmppConnection, with, streamId, this.blockSize, pipedInputStream);
+        this.blockSenderThread = new Thread(blockSender);
+    }
+
+    public void setTransportCallback(final Callback callback) {
+        this.transportCallback = callback;
+    }
+
+    public String getStreamId() {
+        return this.streamId;
+    }
+
+    public void connect() {
+        if (initiator) {
+            openInBandTransport();
+        }
+    }
+
+    @Override
+    public CountDownLatch getTerminationLatch() {
+        return this.terminationLatch;
+    }
+
+    private void openInBandTransport() {
+        final var iqPacket = new IqPacket(IqPacket.TYPE.SET);
+        iqPacket.setTo(with);
+        final var open = iqPacket.addChild("open", Namespace.IBB);
+        open.setAttribute("block-size", this.blockSize);
+        open.setAttribute("sid", this.streamId);
+        Log.d(Config.LOGTAG, "sending ibb open");
+        Log.d(Config.LOGTAG, iqPacket.toString());
+        xmppConnection.sendIqPacket(iqPacket, this::receiveResponseToOpen);
+    }
+
+    private void receiveResponseToOpen(final Account account, final IqPacket response) {
+        if (response.getType() == IqPacket.TYPE.RESULT) {
+            Log.d(Config.LOGTAG, "ibb open was accepted");
+            this.transportCallback.onTransportEstablished();
+            this.blockSenderThread.start();
+        } else {
+            this.transportCallback.onTransportSetupFailed();
+        }
+    }
+
+    public boolean deliverPacket(
+            final PacketType packetType, final Jid from, final Element payload) {
+        if (from == null || !from.equals(with)) {
+            Log.d(
+                    Config.LOGTAG,
+                    "ibb packet received from wrong address. was " + from + " expected " + with);
+            return false;
+        }
+        return switch (packetType) {
+            case OPEN -> receiveOpen();
+            case DATA -> receiveData(payload.getContent());
+            case CLOSE -> receiveClose();
+            default -> throw new IllegalArgumentException("Invalid packet type");
+        };
+    }
+
+    private boolean receiveData(final String encoded) {
+        final byte[] buffer;
+        if (Strings.isNullOrEmpty(encoded)) {
+            buffer = new byte[0];
+        } else {
+            buffer = BaseEncoding.base64().decode(encoded);
+        }
+        Log.d(Config.LOGTAG, "ibb received " + buffer.length + " bytes");
+        try {
+            pipedOutputStream.write(buffer);
+            pipedOutputStream.flush();
+            return true;
+        } catch (final IOException e) {
+            Log.d(Config.LOGTAG, "unable to receive ibb data", e);
+            return false;
+        }
+    }
+
+    private boolean receiveClose() {
+        if (this.isReceiving.compareAndSet(true, false)) {
+            try {
+                this.pipedOutputStream.close();
+                return true;
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "could not close pipedOutStream");
+                return false;
+            }
+        } else {
+            Log.d(Config.LOGTAG, "received ibb close but was not receiving");
+            return false;
+        }
+    }
+
+    private boolean receiveOpen() {
+        Log.d(Config.LOGTAG, "receiveOpen()");
+        if (this.isReceiving.get()) {
+            Log.d(Config.LOGTAG, "ibb received open even though we were already open");
+            return false;
+        }
+        this.isReceiving.set(true);
+        transportCallback.onTransportEstablished();
+        return true;
+    }
+
+    public void terminate() {
+        // TODO send close
+        Log.d(Config.LOGTAG, "IbbTransport.terminate()");
+        this.terminationLatch.countDown();
+        this.blockSender.close();
+        this.blockSenderThread.interrupt();
+        closeQuietly(this.pipedOutputStream);
+    }
+
+    private static void closeQuietly(final OutputStream outputStream) {
+        try {
+            outputStream.close();
+        } catch (final IOException ignored) {
+
+        }
+    }
+
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        final var outputStream = new PipedOutputStream();
+        this.pipedInputStream.connect(outputStream);
+        return outputStream;
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        final var inputStream = new PipedInputStream();
+        this.pipedOutputStream.connect(inputStream);
+        return inputStream;
+    }
+
+    @Override
+    public ListenableFuture<TransportInfo> asTransportInfo() {
+        return Futures.immediateFuture(
+                new TransportInfo(new IbbTransportInfo(streamId, blockSize), null));
+    }
+
+    @Override
+    public ListenableFuture<InitialTransportInfo> asInitialTransportInfo() {
+        return Futures.immediateFuture(
+                new InitialTransportInfo(
+                        UUID.randomUUID().toString(),
+                        new IbbTransportInfo(streamId, blockSize),
+                        null));
+    }
+
+    public void setPeerBlockSize(long peerBlockSize) {
+        this.blockSize = Math.min(Ints.saturatedCast(peerBlockSize), DEFAULT_BLOCK_SIZE);
+        if (this.blockSize < DEFAULT_BLOCK_SIZE) {
+            Log.d(Config.LOGTAG, "peer reconfigured IBB block size to " + this.blockSize);
+        }
+        this.blockSender.setBlockSize(this.blockSize);
+    }
+
+    private static class BlockSender implements Runnable, Closeable {
+
+        private final XmppConnection xmppConnection;
+
+        private final Jid with;
+        private final String streamId;
+
+        private int blockSize;
+        private final PipedInputStream inputStream;
+        private final Semaphore semaphore = new Semaphore(3);
+        private final AtomicInteger sequencer = new AtomicInteger();
+        private final AtomicBoolean isSending = new AtomicBoolean(true);
+
+        private BlockSender(
+                XmppConnection xmppConnection,
+                final Jid with,
+                String streamId,
+                int blockSize,
+                PipedInputStream inputStream) {
+            this.xmppConnection = xmppConnection;
+            this.with = with;
+            this.streamId = streamId;
+            this.blockSize = blockSize;
+            this.inputStream = inputStream;
+        }
+
+        @Override
+        public void run() {
+            final var buffer = new byte[blockSize];
+            try {
+                while (isSending.get()) {
+                    final int count = this.inputStream.read(buffer);
+                    if (count < 0) {
+                        Log.d(Config.LOGTAG, "block sender reached EOF");
+                        return;
+                    }
+                    this.semaphore.acquire();
+                    final var block = new byte[count];
+                    System.arraycopy(buffer, 0, block, 0, block.length);
+                    sendIbbBlock(sequencer.getAndIncrement(), block);
+                }
+            } catch (final InterruptedException | InterruptedIOException e) {
+                if (isSending.get()) {
+                    Log.w(Config.LOGTAG, "IbbBlockSender got interrupted while sending", e);
+                }
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "block sender terminated", e);
+            } finally {
+                Closeables.closeQuietly(inputStream);
+            }
+        }
+
+        private void sendIbbBlock(final int sequence, final byte[] block) {
+            Log.d(Config.LOGTAG, "sending ibb block #" + sequence + " " + block.length + " bytes");
+            final var iqPacket = new IqPacket(IqPacket.TYPE.SET);
+            iqPacket.setTo(with);
+            final var data = iqPacket.addChild("data", Namespace.IBB);
+            data.setAttribute("sid", this.streamId);
+            data.setAttribute("seq", sequence);
+            data.setContent(BaseEncoding.base64().encode(block));
+            this.xmppConnection.sendIqPacket(
+                    iqPacket,
+                    (a, response) -> {
+                        if (response.getType() != IqPacket.TYPE.RESULT) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    "received iq error in response to data block #" + sequence);
+                            isSending.set(false);
+                        }
+                        semaphore.release();
+                    });
+        }
+
+        @Override
+        public void close() {
+            this.isSending.set(false);
+        }
+
+        public void setBlockSize(final int blockSize) {
+            this.blockSize = blockSize;
+        }
+    }
+
+    public enum PacketType {
+        OPEN,
+        DATA,
+        CLOSE
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java πŸ”—

@@ -0,0 +1,870 @@
+package eu.siacs.conversations.xmpp.jingle.transports;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
+import com.google.common.hash.Hashing;
+import com.google.common.io.ByteStreams;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.SocksSocketFactory;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
+import eu.siacs.conversations.xmpp.jingle.DirectConnectionUtils;
+import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.net.SocketException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class SocksByteStreamsTransport implements Transport {
+
+    private final XmppConnection xmppConnection;
+
+    private final AbstractJingleConnection.Id id;
+
+    private final boolean initiator;
+    private final boolean useTor;
+
+    private final String streamId;
+
+    private ImmutableList<Candidate> theirCandidates;
+    private final String theirDestination;
+    private final SettableFuture<Connection> selectedByThemCandidate = SettableFuture.create();
+    private final SettableFuture<String> theirProxyActivation = SettableFuture.create();
+
+    private final CountDownLatch terminationLatch = new CountDownLatch(1);
+
+    private final ConnectionProvider connectionProvider;
+    private final ListenableFuture<Connection> ourProxyConnection;
+
+    private Connection connection;
+
+    private Callback transportCallback;
+
+    public SocksByteStreamsTransport(
+            final XmppConnection xmppConnection,
+            final AbstractJingleConnection.Id id,
+            final boolean initiator,
+            final boolean useTor,
+            final String streamId,
+            final Collection<Candidate> theirCandidates) {
+        this.xmppConnection = xmppConnection;
+        this.id = id;
+        this.initiator = initiator;
+        this.useTor = useTor;
+        this.streamId = streamId;
+        this.theirDestination =
+                Hashing.sha1()
+                        .hashString(
+                                Joiner.on("")
+                                        .join(
+                                                Arrays.asList(
+                                                        streamId,
+                                                        id.with.toEscapedString(),
+                                                        id.account.getJid().toEscapedString())),
+                                StandardCharsets.UTF_8)
+                        .toString();
+        final var ourDestination =
+                Hashing.sha1()
+                        .hashString(
+                                Joiner.on("")
+                                        .join(
+                                                Arrays.asList(
+                                                        streamId,
+                                                        id.account.getJid().toEscapedString(),
+                                                        id.with.toEscapedString())),
+                                StandardCharsets.UTF_8)
+                        .toString();
+
+        this.connectionProvider =
+                new ConnectionProvider(id.account.getJid(), ourDestination, useTor);
+        new Thread(connectionProvider).start();
+        this.ourProxyConnection = getOurProxyConnection(ourDestination);
+        setTheirCandidates(theirCandidates);
+    }
+
+    public SocksByteStreamsTransport(
+            final XmppConnection xmppConnection,
+            final AbstractJingleConnection.Id id,
+            final boolean initiator,
+            final boolean useTor) {
+        this(
+                xmppConnection,
+                id,
+                initiator,
+                useTor,
+                UUID.randomUUID().toString(),
+                Collections.emptyList());
+    }
+
+    public void connectTheirCandidates() {
+        Preconditions.checkState(
+                this.transportCallback != null, "transport callback needs to be set");
+        // TODO this needs to go into a variable so we can cancel it
+        final var connectionFinder =
+                new ConnectionFinder(theirCandidates, theirDestination, useTor);
+        new Thread(connectionFinder).start();
+        Futures.addCallback(
+                connectionFinder.connectionFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Connection connection) {
+                        final Candidate candidate = connection.candidate;
+                        transportCallback.onCandidateUsed(streamId, candidate);
+                        establishTransport(connection);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        if (throwable instanceof CandidateErrorException) {
+                            transportCallback.onCandidateError(streamId);
+                        }
+                        establishTransport(null);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void establishTransport(final Connection selectedByUs) {
+        Futures.addCallback(
+                selectedByThemCandidate,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Connection result) {
+                        establishTransport(selectedByUs, result);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        establishTransport(selectedByUs, null);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void establishTransport(
+            final Connection selectedByUs, final Connection selectedByThem) {
+        final var selection = selectConnection(selectedByUs, selectedByThem);
+        if (selection == null) {
+            transportCallback.onTransportSetupFailed();
+            return;
+        }
+        if (selection.connection.candidate.type == CandidateType.DIRECT) {
+            Log.d(Config.LOGTAG, "final selection " + selection.connection.candidate);
+            this.connection = selection.connection;
+            this.transportCallback.onTransportEstablished();
+        } else {
+            final ListenableFuture<String> proxyActivation;
+            if (selection.owner == Owner.THEIRS) {
+                proxyActivation = this.theirProxyActivation;
+            } else {
+                proxyActivation = activateProxy(selection.connection.candidate);
+            }
+            Log.d(Config.LOGTAG, "waiting for proxy activation");
+            Futures.addCallback(
+                    proxyActivation,
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(final String cid) {
+                            // TODO compare cid to selection.connection.candidate
+                            connection = selection.connection;
+                            transportCallback.onTransportEstablished();
+                        }
+
+                        @Override
+                        public void onFailure(@NonNull Throwable throwable) {
+                            Log.d(Config.LOGTAG, "failed to activate proxy");
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        }
+    }
+
+    private ConnectionWithOwner selectConnection(
+            final Connection selectedByUs, final Connection selectedByThem) {
+        if (selectedByUs != null && selectedByThem != null) {
+            if (selectedByUs.candidate.priority == selectedByThem.candidate.priority) {
+                return initiator
+                        ? new ConnectionWithOwner(selectedByUs, Owner.THEIRS)
+                        : new ConnectionWithOwner(selectedByThem, Owner.OURS);
+            } else if (selectedByUs.candidate.priority > selectedByThem.candidate.priority) {
+                return new ConnectionWithOwner(selectedByUs, Owner.THEIRS);
+            } else {
+                return new ConnectionWithOwner(selectedByThem, Owner.OURS);
+            }
+        }
+        if (selectedByUs != null) {
+            return new ConnectionWithOwner(selectedByUs, Owner.THEIRS);
+        }
+        if (selectedByThem != null) {
+            return new ConnectionWithOwner(selectedByThem, Owner.OURS);
+        }
+        return null;
+    }
+
+    private ListenableFuture<String> activateProxy(final Candidate candidate) {
+        Log.d(Config.LOGTAG, "trying to activate our proxy " + candidate);
+        final SettableFuture<String> iqFuture = SettableFuture.create();
+        final IqPacket proxyActivation = new IqPacket(IqPacket.TYPE.SET);
+        proxyActivation.setTo(candidate.jid);
+        final Element query = proxyActivation.addChild("query", Namespace.BYTE_STREAMS);
+        query.setAttribute("sid", this.streamId);
+        final Element activate = query.addChild("activate");
+        activate.setContent(id.with.toEscapedString());
+        xmppConnection.sendIqPacket(
+                proxyActivation,
+                (a, response) -> {
+                    if (response.getType() == IqPacket.TYPE.RESULT) {
+                        Log.d(Config.LOGTAG, "our proxy has been activated");
+                        transportCallback.onProxyActivated(this.streamId, candidate);
+                        iqFuture.set(candidate.cid);
+                    } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+                        iqFuture.setException(new TimeoutException());
+                    } else {
+                        Log.d(
+                                Config.LOGTAG,
+                                a.getJid().asBareJid()
+                                        + ": failed to activate proxy on "
+                                        + candidate.jid);
+                        iqFuture.setException(new IllegalStateException("Proxy activation failed"));
+                    }
+                });
+        return iqFuture;
+    }
+
+    private ListenableFuture<Connection> getOurProxyConnection(final String ourDestination) {
+        final var proxyFuture = getProxyCandidate();
+        return Futures.transformAsync(
+                proxyFuture,
+                proxy -> {
+                    final var connectionFinder =
+                            new ConnectionFinder(ImmutableList.of(proxy), ourDestination, useTor);
+                    new Thread(connectionFinder).start();
+                    return Futures.transform(
+                            connectionFinder.connectionFuture,
+                            c -> {
+                                try {
+                                    c.socket.setKeepAlive(true);
+                                    Log.d(
+                                            Config.LOGTAG,
+                                            "set keep alive on our own proxy connection");
+                                } catch (final SocketException e) {
+                                    throw new RuntimeException(e);
+                                }
+                                return c;
+                            },
+                            MoreExecutors.directExecutor());
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Candidate> getProxyCandidate() {
+        if (Config.DISABLE_PROXY_LOOKUP) {
+            return Futures.immediateFailedFuture(
+                    new IllegalStateException("Proxy look up is disabled"));
+        }
+        final Jid streamer = xmppConnection.findDiscoItemByFeature(Namespace.BYTE_STREAMS);
+        if (streamer == null) {
+            return Futures.immediateFailedFuture(
+                    new IllegalStateException("No proxy/streamer found"));
+        }
+        final IqPacket iqRequest = new IqPacket(IqPacket.TYPE.GET);
+        iqRequest.setTo(streamer);
+        iqRequest.query(Namespace.BYTE_STREAMS);
+        final SettableFuture<Candidate> candidateFuture = SettableFuture.create();
+        xmppConnection.sendIqPacket(
+                iqRequest,
+                (a, response) -> {
+                    if (response.getType() == IqPacket.TYPE.RESULT) {
+                        final Element query = response.findChild("query", Namespace.BYTE_STREAMS);
+                        final Element streamHost =
+                                query == null
+                                        ? null
+                                        : query.findChild("streamhost", Namespace.BYTE_STREAMS);
+                        final String host =
+                                streamHost == null ? null : streamHost.getAttribute("host");
+                        final Integer port =
+                                Ints.tryParse(
+                                        Strings.nullToEmpty(
+                                                streamHost == null
+                                                        ? null
+                                                        : streamHost.getAttribute("port")));
+                        if (Strings.isNullOrEmpty(host) || port == null) {
+                            candidateFuture.setException(
+                                    new IOException("Proxy response is missing attributes"));
+                            return;
+                        }
+                        candidateFuture.set(
+                                new Candidate(
+                                        UUID.randomUUID().toString(),
+                                        host,
+                                        streamer,
+                                        port,
+                                        655360 + (initiator ? 0 : 15),
+                                        CandidateType.PROXY));
+
+                    } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+                        candidateFuture.setException(new TimeoutException());
+                    } else {
+                        candidateFuture.setException(
+                                new IOException(
+                                        "received iq error in response to proxy discovery"));
+                    }
+                });
+        return candidateFuture;
+    }
+
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        final var connection = this.connection;
+        if (connection == null) {
+            throw new IOException("No candidate has been selected yet");
+        }
+        return connection.socket.getOutputStream();
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        final var connection = this.connection;
+        if (connection == null) {
+            throw new IOException("No candidate has been selected yet");
+        }
+        return connection.socket.getInputStream();
+    }
+
+    @Override
+    public ListenableFuture<TransportInfo> asTransportInfo() {
+        final ListenableFuture<Collection<Connection>> proxyConnections =
+                getOurProxyConnectionsFuture();
+        return Futures.transform(
+                proxyConnections,
+                proxies -> {
+                    final var candidateBuilder = new ImmutableList.Builder<Candidate>();
+                    candidateBuilder.addAll(this.connectionProvider.candidates);
+                    candidateBuilder.addAll(Collections2.transform(proxies, p -> p.candidate));
+                    final var transportInfo =
+                            new SocksByteStreamsTransportInfo(
+                                    this.streamId, candidateBuilder.build());
+                    return new TransportInfo(transportInfo, null);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    @Override
+    public ListenableFuture<InitialTransportInfo> asInitialTransportInfo() {
+        return Futures.transform(
+                asTransportInfo(),
+                ti ->
+                        new InitialTransportInfo(
+                                UUID.randomUUID().toString(), ti.transportInfo, ti.group),
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Collection<Connection>> getOurProxyConnectionsFuture() {
+        return Futures.catching(
+                Futures.transform(
+                        this.ourProxyConnection,
+                        Collections::singleton,
+                        MoreExecutors.directExecutor()),
+                Exception.class,
+                ex -> {
+                    Log.d(Config.LOGTAG, "could not find a proxy of our own", ex);
+                    return Collections.emptyList();
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private Collection<Connection> getOurProxyConnections() {
+        final var future = getOurProxyConnectionsFuture();
+        if (future.isDone()) {
+            try {
+                return future.get();
+            } catch (final Exception e) {
+                return Collections.emptyList();
+            }
+        } else {
+            return Collections.emptyList();
+        }
+    }
+
+    @Override
+    public void terminate() {
+        Log.d(Config.LOGTAG, "terminating socks transport");
+        this.terminationLatch.countDown();
+        final var connection = this.connection;
+        if (connection != null) {
+            closeSocket(connection.socket);
+        }
+        this.connectionProvider.close();
+    }
+
+    @Override
+    public void setTransportCallback(final Callback callback) {
+        this.transportCallback = callback;
+    }
+
+    @Override
+    public void connect() {
+        this.connectTheirCandidates();
+    }
+
+    @Override
+    public CountDownLatch getTerminationLatch() {
+        return this.terminationLatch;
+    }
+
+    public boolean setCandidateUsed(final String cid) {
+        final var ourProxyConnections = getOurProxyConnections();
+        final var proxyConnection =
+                Iterables.tryFind(ourProxyConnections, c -> c.candidate.cid.equals(cid));
+        if (proxyConnection.isPresent()) {
+            this.selectedByThemCandidate.set(proxyConnection.get());
+            return true;
+        }
+
+        // the peer selected a connection that is not our proxy. so we can close our proxies
+        closeConnections(ourProxyConnections);
+
+        final var connection = this.connectionProvider.findPeerConnection(cid);
+        if (connection.isPresent()) {
+            this.selectedByThemCandidate.set(connection.get());
+            return true;
+        } else {
+            Log.d(Config.LOGTAG, "none of the connected candidates has cid " + cid);
+            return false;
+        }
+    }
+
+    public void setCandidateError() {
+        this.selectedByThemCandidate.setException(
+                new CandidateErrorException("Remote could not connect to any of our candidates"));
+    }
+
+    public void setProxyActivated(final String cid) {
+        this.theirProxyActivation.set(cid);
+    }
+
+    public void setProxyError() {
+        this.theirProxyActivation.setException(
+                new IllegalStateException("Remote could not activate their proxy"));
+    }
+
+    public void setTheirCandidates(Collection<Candidate> candidates) {
+        this.theirCandidates =
+                Ordering.from(
+                                (Comparator<Candidate>)
+                                        (o1, o2) -> Integer.compare(o2.priority, o1.priority))
+                        .immutableSortedCopy(candidates);
+    }
+
+    private static void closeSocket(final Socket socket) {
+        try {
+            socket.close();
+        } catch (final IOException e) {
+            Log.w(Config.LOGTAG, "error closing socket", e);
+        }
+    }
+
+    private static class ConnectionProvider implements Runnable {
+
+        private final ExecutorService clientConnectionExecutorService =
+                Executors.newFixedThreadPool(4);
+
+        private final ImmutableList<Candidate> candidates;
+
+        private final int port;
+
+        private final AtomicBoolean acceptingConnections = new AtomicBoolean(true);
+
+        private ServerSocket serverSocket;
+
+        private final String destination;
+
+        private final ArrayList<Connection> peerConnections = new ArrayList<>();
+
+        private ConnectionProvider(
+                final Jid account, final String destination, final boolean useTor) {
+            final SecureRandom secureRandom = new SecureRandom();
+            this.port = secureRandom.nextInt(60_000) + 1024;
+            this.destination = destination;
+            final InetAddress[] localAddresses;
+            if (Config.USE_DIRECT_JINGLE_CANDIDATES && !useTor) {
+                localAddresses =
+                        DirectConnectionUtils.getLocalAddresses().toArray(new InetAddress[0]);
+            } else {
+                localAddresses = new InetAddress[0];
+            }
+            final var candidateBuilder = new ImmutableList.Builder<Candidate>();
+            for (int i = 0; i < localAddresses.length; ++i) {
+                final var inetAddress = localAddresses[i];
+                candidateBuilder.add(
+                        new Candidate(
+                                UUID.randomUUID().toString(),
+                                inetAddress.getHostAddress(),
+                                account,
+                                port,
+                                8257536 + i,
+                                CandidateType.DIRECT));
+            }
+            this.candidates = candidateBuilder.build();
+        }
+
+        @Override
+        public void run() {
+            if (this.candidates.isEmpty()) {
+                Log.d(Config.LOGTAG, "no direct candidates. stopping ConnectionProvider");
+                return;
+            }
+            try (final ServerSocket serverSocket = new ServerSocket(this.port)) {
+                this.serverSocket = serverSocket;
+                while (acceptingConnections.get()) {
+                    final Socket clientSocket;
+                    try {
+                        clientSocket = serverSocket.accept();
+                    } catch (final SocketException ignored) {
+                        Log.d(Config.LOGTAG, "server socket has been closed.");
+                        return;
+                    }
+                    clientConnectionExecutorService.execute(
+                            () -> acceptClientConnection(clientSocket));
+                }
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "could not create server socket", e);
+            }
+        }
+
+        private void acceptClientConnection(final Socket socket) {
+            final var localAddress = socket.getLocalAddress();
+            final var hostAddress = localAddress == null ? null : localAddress.getHostAddress();
+            final var candidate =
+                    Iterables.tryFind(this.candidates, c -> c.host.equals(hostAddress));
+            if (candidate.isPresent()) {
+                acceptingConnections(socket, candidate.get());
+
+            } else {
+                closeSocket(socket);
+                Log.d(Config.LOGTAG, "no local candidate found for connection on " + hostAddress);
+            }
+        }
+
+        private void acceptingConnections(final Socket socket, final Candidate candidate) {
+            final var remoteAddress = socket.getRemoteSocketAddress();
+            Log.d(
+                    Config.LOGTAG,
+                    "accepted client connection from " + remoteAddress + " to " + candidate);
+            try {
+                socket.setSoTimeout(3000);
+                final byte[] authBegin = new byte[2];
+                final InputStream inputStream = socket.getInputStream();
+                final OutputStream outputStream = socket.getOutputStream();
+                ByteStreams.readFully(inputStream, authBegin);
+                if (authBegin[0] != 0x5) {
+                    socket.close();
+                }
+                final short methodCount = authBegin[1];
+                final byte[] methods = new byte[methodCount];
+                ByteStreams.readFully(inputStream, methods);
+                if (SocksSocketFactory.contains((byte) 0x00, methods)) {
+                    outputStream.write(new byte[] {0x05, 0x00});
+                } else {
+                    outputStream.write(new byte[] {0x05, (byte) 0xff});
+                }
+                final byte[] connectCommand = new byte[4];
+                ByteStreams.readFully(inputStream, connectCommand);
+                if (connectCommand[0] == 0x05
+                        && connectCommand[1] == 0x01
+                        && connectCommand[3] == 0x03) {
+                    int destinationCount = inputStream.read();
+                    final byte[] destination = new byte[destinationCount];
+                    ByteStreams.readFully(inputStream, destination);
+                    final byte[] port = new byte[2];
+                    ByteStreams.readFully(inputStream, port);
+                    final String receivedDestination = new String(destination);
+                    final ByteBuffer response = ByteBuffer.allocate(7 + destination.length);
+                    final byte[] responseHeader;
+                    final boolean success;
+                    if (receivedDestination.equals(this.destination)) {
+                        responseHeader = new byte[] {0x05, 0x00, 0x00, 0x03};
+                        synchronized (this.peerConnections) {
+                            peerConnections.add(new Connection(candidate, socket));
+                        }
+                        success = true;
+                    } else {
+                        Log.d(
+                                Config.LOGTAG,
+                                "destination mismatch. received "
+                                        + receivedDestination
+                                        + " (expected "
+                                        + this.destination
+                                        + ")");
+                        responseHeader = new byte[] {0x05, 0x04, 0x00, 0x03};
+                        success = false;
+                    }
+                    response.put(responseHeader);
+                    response.put((byte) destination.length);
+                    response.put(destination);
+                    response.put(port);
+                    outputStream.write(response.array());
+                    outputStream.flush();
+                    if (success) {
+                        Log.d(
+                                Config.LOGTAG,
+                                remoteAddress + " successfully connected to " + candidate);
+                    } else {
+                        closeSocket(socket);
+                    }
+                }
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "failed to accept client connection to " + candidate, e);
+                closeSocket(socket);
+            }
+        }
+
+        private static void closeServerSocket(@Nullable final ServerSocket serverSocket) {
+            if (serverSocket == null) {
+                return;
+            }
+            try {
+                serverSocket.close();
+            } catch (final IOException ignored) {
+
+            }
+        }
+
+        public Optional<Connection> findPeerConnection(String cid) {
+            synchronized (this.peerConnections) {
+                return Iterables.tryFind(
+                        this.peerConnections, connection -> connection.candidate.cid.equals(cid));
+            }
+        }
+
+        public void close() {
+            this.acceptingConnections.set(false); // we have probably done this earlier already
+            closeServerSocket(this.serverSocket);
+            synchronized (this.peerConnections) {
+                closeConnections(this.peerConnections);
+                this.peerConnections.clear();
+            }
+        }
+    }
+
+    private static void closeConnections(final Iterable<Connection> connections) {
+        for (final var connection : connections) {
+            closeSocket(connection.socket);
+        }
+    }
+
+    private static class ConnectionFinder implements Runnable {
+
+        private final SettableFuture<Connection> connectionFuture = SettableFuture.create();
+
+        private final ImmutableList<Candidate> candidates;
+        private final String destination;
+        private final boolean useTor;
+
+        private ConnectionFinder(
+                final ImmutableList<Candidate> candidates,
+                final String destination,
+                final boolean useTor) {
+            this.candidates = candidates;
+            this.destination = destination;
+            this.useTor = useTor;
+        }
+
+        @Override
+        public void run() {
+            for (final Candidate candidate : this.candidates) {
+                // TODO we can check if there is already something in `selectedByThemCandidate` with
+                // a higher priority and abort
+                try {
+                    connectionFuture.set(connect(candidate));
+                    Log.d(Config.LOGTAG, "connected to " + candidate);
+                    return;
+                } catch (final IOException e) {
+                    Log.d(Config.LOGTAG, "could not connect to candidate " + candidate);
+                }
+            }
+            connectionFuture.setException(
+                    new CandidateErrorException(
+                            String.format(
+                                    Locale.US,
+                                    "Gave up after %d candidates",
+                                    this.candidates.size())));
+        }
+
+        private Connection connect(final Candidate candidate) throws IOException {
+            final var timeout = 3000;
+            final Socket socket;
+            if (useTor) {
+                Log.d(Config.LOGTAG, "using Tor to connect to candidate " + candidate.host);
+                socket = SocksSocketFactory.createSocketOverTor(candidate.host, candidate.port);
+            } else {
+                socket = new Socket();
+                final SocketAddress address = new InetSocketAddress(candidate.host, candidate.port);
+                socket.connect(address, timeout);
+            }
+            socket.setSoTimeout(timeout);
+            SocksSocketFactory.createSocksConnection(socket, destination, 0);
+            socket.setSoTimeout(0);
+            return new Connection(candidate, socket);
+        }
+    }
+
+    public static class CandidateErrorException extends IllegalStateException {
+        private CandidateErrorException(final String message) {
+            super(message);
+        }
+    }
+
+    private enum Owner {
+        THEIRS,
+        OURS
+    }
+
+    public static class ConnectionWithOwner {
+        public final Connection connection;
+        public final Owner owner;
+
+        public ConnectionWithOwner(Connection connection, Owner owner) {
+            this.connection = connection;
+            this.owner = owner;
+        }
+    }
+
+    public static class Connection {
+
+        public final Candidate candidate;
+        public final Socket socket;
+
+        public Connection(Candidate candidate, Socket socket) {
+            this.candidate = candidate;
+            this.socket = socket;
+        }
+    }
+
+    public static class Candidate implements Transport.Candidate {
+        public final String cid;
+        public final String host;
+        public final Jid jid;
+        public final int port;
+        public final int priority;
+        public final CandidateType type;
+
+        public Candidate(
+                final String cid,
+                final String host,
+                final Jid jid,
+                int port,
+                int priority,
+                final CandidateType type) {
+            this.cid = cid;
+            this.host = host;
+            this.jid = jid;
+            this.port = port;
+            this.priority = priority;
+            this.type = type;
+        }
+
+        public static Candidate of(final Element element) {
+            Preconditions.checkArgument(
+                    "candidate".equals(element.getName()),
+                    "trying to construct candidate from non candidate element");
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()),
+                    "candidate element is in correct namespace");
+            final String cid = element.getAttribute("cid");
+            final String host = element.getAttribute("host");
+            final String jid = element.getAttribute("jid");
+            final String port = element.getAttribute("port");
+            final String priority = element.getAttribute("priority");
+            final String type = element.getAttribute("type");
+            if (Strings.isNullOrEmpty(cid)
+                    || Strings.isNullOrEmpty(host)
+                    || Strings.isNullOrEmpty(jid)
+                    || Strings.isNullOrEmpty(port)
+                    || Strings.isNullOrEmpty(priority)
+                    || Strings.isNullOrEmpty(type)) {
+                throw new IllegalArgumentException("Candidate is missing non optional attribute");
+            }
+            return new Candidate(
+                    cid,
+                    host,
+                    Jid.ofEscaped(jid),
+                    Integer.parseInt(port),
+                    Integer.parseInt(priority),
+                    CandidateType.valueOf(type.toUpperCase(Locale.ROOT)));
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("cid", cid)
+                    .add("host", host)
+                    .add("jid", jid)
+                    .add("port", port)
+                    .add("priority", priority)
+                    .add("type", type)
+                    .toString();
+        }
+
+        public Element asElement() {
+            final var element = new Element("candidate", Namespace.JINGLE_TRANSPORTS_S5B);
+            element.setAttribute("cid", this.cid);
+            element.setAttribute("host", this.host);
+            element.setAttribute("jid", this.jid);
+            element.setAttribute("port", this.port);
+            element.setAttribute("priority", this.priority);
+            element.setAttribute("type", this.type.toString().toLowerCase(Locale.ROOT));
+            return element;
+        }
+    }
+
+    public enum CandidateType {
+        DIRECT,
+        PROXY
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java πŸ”—

@@ -0,0 +1,80 @@
+package eu.siacs.conversations.xmpp.jingle.transports;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.CountDownLatch;
+
+public interface Transport {
+
+    OutputStream getOutputStream() throws IOException;
+
+    InputStream getInputStream() throws IOException;
+
+    ListenableFuture<TransportInfo> asTransportInfo();
+
+    ListenableFuture<InitialTransportInfo> asInitialTransportInfo();
+
+    default void readyToSentAdditionalCandidates() {}
+
+    void terminate();
+
+    void setTransportCallback(final Callback callback);
+
+    void connect();
+
+    CountDownLatch getTerminationLatch();
+
+    interface Callback {
+        void onTransportEstablished();
+
+        void onTransportSetupFailed();
+
+        void onAdditionalCandidate(final String contentName, final Candidate candidate);
+
+        void onCandidateUsed(String streamId, SocksByteStreamsTransport.Candidate candidate);
+
+        void onCandidateError(String streamId);
+
+        void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate);
+    }
+
+    enum Direction {
+        SEND,
+        RECEIVE,
+        SEND_RECEIVE
+    }
+
+    class InitialTransportInfo extends TransportInfo {
+        public final String contentName;
+
+        public InitialTransportInfo(
+                String contentName, GenericTransportInfo transportInfo, Group group) {
+            super(transportInfo, group);
+            this.contentName = contentName;
+        }
+    }
+
+    class TransportInfo {
+
+        public final GenericTransportInfo transportInfo;
+        public final Group group;
+
+        public TransportInfo(final GenericTransportInfo transportInfo, final Group group) {
+            this.transportInfo = transportInfo;
+            this.group = group;
+        }
+
+        public TransportInfo(final GenericTransportInfo transportInfo) {
+            this.transportInfo = transportInfo;
+            this.group = null;
+        }
+    }
+
+    interface Candidate {}
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java πŸ”—

@@ -0,0 +1,617 @@
+package eu.siacs.conversations.xmpp.jingle.transports;
+
+import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.buildConfiguration;
+import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.logDescription;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.Closeables;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.jingle.IceServers;
+import eu.siacs.conversations.xmpp.jingle.WebRTCWrapper;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+import org.webrtc.CandidatePairChangeEvent;
+import org.webrtc.DataChannel;
+import org.webrtc.IceCandidate;
+import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.SessionDescription;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.annotation.Nonnull;
+
+public class WebRTCDataChannelTransport implements Transport {
+
+    private static final int BUFFER_SIZE = 16_384;
+    private static final int MAX_SENT_BUFFER = 256 * 1024;
+
+    private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+    private final ExecutorService localDescriptionExecutorService =
+            Executors.newSingleThreadExecutor();
+
+    private final AtomicBoolean readyToSentIceCandidates = new AtomicBoolean(false);
+    private final Queue<IceCandidate> pendingOutgoingIceCandidates = new LinkedList<>();
+
+    private final PipedOutputStream pipedOutputStream = new PipedOutputStream();
+    private final WritableByteChannel writableByteChannel = Channels.newChannel(pipedOutputStream);
+    private final PipedInputStream pipedInputStream = new PipedInputStream(BUFFER_SIZE);
+
+    private final AtomicBoolean connected = new AtomicBoolean(false);
+
+    private final CountDownLatch terminationLatch = new CountDownLatch(1);
+
+    private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
+
+    private final XmppConnection xmppConnection;
+    private final Account account;
+    private PeerConnectionFactory peerConnectionFactory;
+    private ListenableFuture<PeerConnection> peerConnectionFuture;
+
+    private ListenableFuture<SessionDescription> localDescriptionFuture;
+
+    private DataChannel dataChannel;
+
+    private Callback transportCallback;
+
+    private final PeerConnection.Observer peerConnectionObserver =
+            new PeerConnection.Observer() {
+                @Override
+                public void onSignalingChange(PeerConnection.SignalingState signalingState) {
+                    Log.d(Config.LOGTAG, "onSignalChange(" + signalingState + ")");
+                }
+
+                @Override
+                public void onConnectionChange(final PeerConnection.PeerConnectionState state) {
+                    stateHistory.add(state);
+                    Log.d(Config.LOGTAG, "onConnectionChange(" + state + ")");
+                    if (state == PeerConnection.PeerConnectionState.CONNECTED) {
+                        if (connected.compareAndSet(false, true)) {
+                            executorService.execute(() -> onIceConnectionConnected());
+                        }
+                    }
+                    if (state == PeerConnection.PeerConnectionState.FAILED) {
+                        final boolean neverConnected =
+                                !stateHistory.contains(
+                                        PeerConnection.PeerConnectionState.CONNECTED);
+                        // we want to terminate the connection a) to properly fail if a connection
+                        // drops during a transfer and b) to avoid race conditions if we find a
+                        // connection after failure while waiting for the initiator to replace
+                        // transport
+                        executorService.execute(() -> terminate());
+                        if (neverConnected) {
+                            executorService.execute(() -> onIceConnectionFailed());
+                        }
+                    }
+                }
+
+                @Override
+                public void onIceConnectionChange(
+                        final PeerConnection.IceConnectionState newState) {}
+
+                @Override
+                public void onIceConnectionReceivingChange(boolean b) {}
+
+                @Override
+                public void onIceGatheringChange(
+                        final PeerConnection.IceGatheringState iceGatheringState) {
+                    Log.d(Config.LOGTAG, "onIceGatheringChange(" + iceGatheringState + ")");
+                }
+
+                @Override
+                public void onIceCandidate(final IceCandidate iceCandidate) {
+                    if (readyToSentIceCandidates.get()) {
+                        WebRTCDataChannelTransport.this.onIceCandidate(
+                                iceCandidate.sdpMid, iceCandidate.sdp);
+                    } else {
+                        pendingOutgoingIceCandidates.add(iceCandidate);
+                    }
+                }
+
+                @Override
+                public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
+
+                @Override
+                public void onAddStream(MediaStream mediaStream) {}
+
+                @Override
+                public void onRemoveStream(MediaStream mediaStream) {}
+
+                @Override
+                public void onDataChannel(final DataChannel dataChannel) {
+                    Log.d(Config.LOGTAG, "onDataChannel()");
+                    WebRTCDataChannelTransport.this.setDataChannel(dataChannel);
+                }
+
+                @Override
+                public void onRenegotiationNeeded() {
+                    Log.d(Config.LOGTAG, "onRenegotiationNeeded");
+                }
+
+                @Override
+                public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) {
+                    Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote);
+                    Log.d(Config.LOGTAG, "local candidate selected: " + event.local);
+                }
+            };
+
+    private DataChannelWriter dataChannelWriter;
+
+    private void onIceConnectionConnected() {
+        this.transportCallback.onTransportEstablished();
+    }
+
+    private void onIceConnectionFailed() {
+        this.transportCallback.onTransportSetupFailed();
+    }
+
+    private void setDataChannel(final DataChannel dataChannel) {
+        Log.d(Config.LOGTAG, "the 'receiving' data channel has id " + dataChannel.id());
+        this.dataChannel = dataChannel;
+        this.dataChannel.registerObserver(
+                new OnMessageObserver() {
+                    @Override
+                    public void onMessage(final DataChannel.Buffer buffer) {
+                        Log.d(Config.LOGTAG, "onMessage() (the other one)");
+                        try {
+                            WebRTCDataChannelTransport.this.writableByteChannel.write(buffer.data);
+                        } catch (final IOException e) {
+                            Log.d(Config.LOGTAG, "error writing to output stream");
+                        }
+                    }
+                });
+    }
+
+    protected void onIceCandidate(final String mid, final String sdp) {
+        final var candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(sdp, null);
+        this.transportCallback.onAdditionalCandidate(mid, candidate);
+    }
+
+    public WebRTCDataChannelTransport(
+            final Context context,
+            final XmppConnection xmppConnection,
+            final Account account,
+            final boolean initiator) {
+        PeerConnectionFactory.initialize(
+                PeerConnectionFactory.InitializationOptions.builder(context)
+                        .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
+                        .createInitializationOptions());
+        this.peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory();
+        this.xmppConnection = xmppConnection;
+        this.account = account;
+        this.peerConnectionFuture =
+                Futures.transform(
+                        getIceServers(),
+                        iceServers -> createPeerConnection(iceServers, true),
+                        MoreExecutors.directExecutor());
+        if (initiator) {
+            this.localDescriptionFuture = setLocalDescription();
+        }
+    }
+
+    private ListenableFuture<List<PeerConnection.IceServer>> getIceServers() {
+        if (Config.DISABLE_PROXY_LOOKUP) {
+            return Futures.immediateFuture(Collections.emptyList());
+        }
+        if (xmppConnection.getFeatures().externalServiceDiscovery()) {
+            final SettableFuture<List<PeerConnection.IceServer>> iceServerFuture =
+                    SettableFuture.create();
+            final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
+            request.setTo(this.account.getDomain());
+            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+            xmppConnection.sendIqPacket(
+                    request,
+                    (account, response) -> {
+                        final var iceServers = IceServers.parse(response);
+                        if (iceServers.size() == 0) {
+                            Log.w(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": no ICE server found "
+                                            + response);
+                        }
+                        iceServerFuture.set(iceServers);
+                    });
+            return iceServerFuture;
+        } else {
+            return Futures.immediateFuture(Collections.emptyList());
+        }
+    }
+
+    private PeerConnection createPeerConnection(
+            final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
+        final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle);
+        final PeerConnection peerConnection =
+                requirePeerConnectionFactory()
+                        .createPeerConnection(rtcConfig, peerConnectionObserver);
+        if (peerConnection == null) {
+            throw new IllegalStateException("Unable to create PeerConnection");
+        }
+        final var dataChannelInit = new DataChannel.Init();
+        dataChannelInit.protocol = "xmpp-jingle";
+        final var dataChannel = peerConnection.createDataChannel("test", dataChannelInit);
+        this.dataChannelWriter = new DataChannelWriter(this.pipedInputStream, dataChannel);
+        Log.d(Config.LOGTAG, "the 'sending' data channel has id " + dataChannel.id());
+        new Thread(this.dataChannelWriter).start();
+        return peerConnection;
+    }
+
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        final var outputStream = new PipedOutputStream();
+        this.pipedInputStream.connect(outputStream);
+        this.dataChannelWriter.pipedInputStreamLatch.countDown();
+        return outputStream;
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        final var inputStream = new PipedInputStream(BUFFER_SIZE);
+        this.pipedOutputStream.connect(inputStream);
+        return inputStream;
+    }
+
+    @Override
+    public ListenableFuture<TransportInfo> asTransportInfo() {
+        Preconditions.checkState(
+                this.localDescriptionFuture != null,
+                "Make sure you are setting initiator description first");
+        return Futures.transform(
+                asInitialTransportInfo(), info -> info, MoreExecutors.directExecutor());
+    }
+
+    @Override
+    public ListenableFuture<InitialTransportInfo> asInitialTransportInfo() {
+        return Futures.transform(
+                localDescriptionFuture,
+                sdp ->
+                        WebRTCDataChannelTransportInfo.of(
+                                eu.siacs.conversations.xmpp.jingle.SessionDescription.parse(
+                                        sdp.description)),
+                MoreExecutors.directExecutor());
+    }
+
+    @Override
+    public void readyToSentAdditionalCandidates() {
+        readyToSentIceCandidates.set(true);
+        while (this.pendingOutgoingIceCandidates.peek() != null) {
+            final var candidate = pendingOutgoingIceCandidates.poll();
+            if (candidate == null) {
+                continue;
+            }
+            onIceCandidate(candidate.sdpMid, candidate.sdp);
+        }
+    }
+
+    @Override
+    public void terminate() {
+        terminate(this.dataChannel);
+        this.dataChannel = null;
+        final var dataChannelWriter = this.dataChannelWriter;
+        if (dataChannelWriter != null) {
+            dataChannelWriter.close();
+        }
+        this.dataChannelWriter = null;
+        final var future = this.peerConnectionFuture;
+        if (future != null) {
+            future.cancel(true);
+        }
+        try {
+            final PeerConnection peerConnection = requirePeerConnection();
+            terminate(peerConnection);
+        } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
+            Log.d(Config.LOGTAG, "peer connection was not initialized during termination");
+        }
+        this.peerConnectionFuture = null;
+        final var peerConnectionFactory = this.peerConnectionFactory;
+        if (peerConnectionFactory != null) {
+            peerConnectionFactory.dispose();
+        }
+        this.peerConnectionFactory = null;
+        closeQuietly(this.pipedOutputStream);
+        this.terminationLatch.countDown();
+        Log.d(Config.LOGTAG, WebRTCDataChannelTransport.class.getSimpleName() + " terminated");
+    }
+
+    private static void closeQuietly(final OutputStream outputStream) {
+        try {
+            outputStream.close();
+        } catch (final IOException ignored) {
+
+        }
+    }
+
+    private static void terminate(final DataChannel dataChannel) {
+        if (dataChannel == null) {
+            Log.d(Config.LOGTAG, "nothing to terminate. data channel is already null");
+            return;
+        }
+        try {
+            dataChannel.close();
+        } catch (final IllegalStateException e) {
+            Log.w(Config.LOGTAG, "could not close data channel");
+        }
+        try {
+            dataChannel.dispose();
+        } catch (final IllegalStateException e) {
+            Log.w(Config.LOGTAG, "could not dispose data channel");
+        }
+    }
+
+    private static void terminate(final PeerConnection peerConnection) {
+        if (peerConnection == null) {
+            return;
+        }
+        try {
+            peerConnection.dispose();
+            Log.d(Config.LOGTAG, "terminated peer connection!");
+        } catch (final IllegalStateException e) {
+            Log.w(Config.LOGTAG, "could not dispose of peer connection");
+        }
+    }
+
+    @Override
+    public void setTransportCallback(final Callback callback) {
+        this.transportCallback = callback;
+    }
+
+    @Override
+    public void connect() {}
+
+    @Override
+    public CountDownLatch getTerminationLatch() {
+        return this.terminationLatch;
+    }
+
+    synchronized ListenableFuture<SessionDescription> setLocalDescription() {
+        return Futures.transformAsync(
+                peerConnectionFuture,
+                peerConnection -> {
+                    if (peerConnection == null) {
+                        return Futures.immediateFailedFuture(
+                                new IllegalStateException("PeerConnection was null"));
+                    }
+                    final SettableFuture<SessionDescription> future = SettableFuture.create();
+                    peerConnection.setLocalDescription(
+                            new WebRTCWrapper.SetSdpObserver() {
+                                @Override
+                                public void onSetSuccess() {
+                                    future.setFuture(getLocalDescriptionFuture(peerConnection));
+                                }
+
+                                @Override
+                                public void onSetFailure(final String message) {
+                                    future.setException(
+                                            new WebRTCWrapper.FailureToSetDescriptionException(
+                                                    message));
+                                }
+                            });
+                    return future;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<SessionDescription> getLocalDescriptionFuture(
+            final PeerConnection peerConnection) {
+        return Futures.submit(
+                () -> {
+                    final SessionDescription description = peerConnection.getLocalDescription();
+                    WebRTCWrapper.logDescription(description);
+                    return description;
+                },
+                localDescriptionExecutorService);
+    }
+
+    @Nonnull
+    private PeerConnectionFactory requirePeerConnectionFactory() {
+        final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
+        if (peerConnectionFactory == null) {
+            throw new IllegalStateException("Make sure PeerConnectionFactory is initialized");
+        }
+        return peerConnectionFactory;
+    }
+
+    @Nonnull
+    private PeerConnection requirePeerConnection() {
+        final var future = this.peerConnectionFuture;
+        if (future != null && future.isDone()) {
+            try {
+                return future.get();
+            } catch (final InterruptedException | ExecutionException e) {
+                throw new WebRTCWrapper.PeerConnectionNotInitialized();
+            }
+        } else {
+            throw new WebRTCWrapper.PeerConnectionNotInitialized();
+        }
+    }
+
+    public static List<IceCandidate> iceCandidatesOf(
+            final String contentName,
+            final IceUdpTransportInfo.Credentials credentials,
+            final List<IceUdpTransportInfo.Candidate> candidates) {
+        final ImmutableList.Builder<IceCandidate> iceCandidateBuilder =
+                new ImmutableList.Builder<>();
+        for (final IceUdpTransportInfo.Candidate candidate : candidates) {
+            final String sdp;
+            try {
+                sdp = candidate.toSdpAttribute(credentials.ufrag);
+            } catch (final IllegalArgumentException e) {
+                continue;
+            }
+            // TODO mLneIndex should probably not be hard coded
+            iceCandidateBuilder.add(new IceCandidate(contentName, 0, sdp));
+        }
+        return iceCandidateBuilder.build();
+    }
+
+    public void addIceCandidates(final List<IceCandidate> iceCandidates) {
+        try {
+            for (final var candidate : iceCandidates) {
+                requirePeerConnection().addIceCandidate(candidate);
+            }
+        } catch (WebRTCWrapper.PeerConnectionNotInitialized e) {
+            Log.w(Config.LOGTAG, "could not add ice candidate. peer connection is not initialized");
+        }
+    }
+
+    public void setInitiatorDescription(
+            final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) {
+        final var sdp =
+                new SessionDescription(
+                        SessionDescription.Type.OFFER, sessionDescription.toString());
+        final var setFuture = setRemoteDescriptionFuture(sdp);
+        this.localDescriptionFuture =
+                Futures.transformAsync(
+                        setFuture, v -> setLocalDescription(), MoreExecutors.directExecutor());
+    }
+
+    public void setResponderDescription(
+            final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) {
+        Log.d(Config.LOGTAG, "setResponder description");
+        final var sdp =
+                new SessionDescription(
+                        SessionDescription.Type.ANSWER, sessionDescription.toString());
+        logDescription(sdp);
+        setRemoteDescriptionFuture(sdp);
+    }
+
+    synchronized ListenableFuture<Void> setRemoteDescriptionFuture(
+            final SessionDescription sessionDescription) {
+        return Futures.transformAsync(
+                this.peerConnectionFuture,
+                peerConnection -> {
+                    if (peerConnection == null) {
+                        return Futures.immediateFailedFuture(
+                                new IllegalStateException("PeerConnection was null"));
+                    }
+                    final SettableFuture<Void> future = SettableFuture.create();
+                    peerConnection.setRemoteDescription(
+                            new WebRTCWrapper.SetSdpObserver() {
+                                @Override
+                                public void onSetSuccess() {
+                                    future.set(null);
+                                }
+
+                                @Override
+                                public void onSetFailure(final String message) {
+                                    future.setException(
+                                            new WebRTCWrapper.FailureToSetDescriptionException(
+                                                    message));
+                                }
+                            },
+                            sessionDescription);
+                    return future;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private static class DataChannelWriter implements Runnable {
+
+        private final CountDownLatch pipedInputStreamLatch = new CountDownLatch(1);
+        private final CountDownLatch dataChannelLatch = new CountDownLatch(1);
+        private final AtomicBoolean isSending = new AtomicBoolean(true);
+        private final InputStream inputStream;
+        private final DataChannel dataChannel;
+
+        private DataChannelWriter(InputStream inputStream, DataChannel dataChannel) {
+            this.inputStream = inputStream;
+            this.dataChannel = dataChannel;
+            final StateChangeObserver stateChangeObserver =
+                    new StateChangeObserver() {
+
+                        @Override
+                        public void onStateChange() {
+                            if (dataChannel.state() == DataChannel.State.OPEN) {
+                                dataChannelLatch.countDown();
+                            }
+                        }
+                    };
+            this.dataChannel.registerObserver(stateChangeObserver);
+        }
+
+        public void run() {
+            try {
+                this.pipedInputStreamLatch.await();
+                this.dataChannelLatch.await();
+                final var buffer = new byte[4096];
+                while (isSending.get()) {
+                    final long bufferedAmount = dataChannel.bufferedAmount();
+                    if (bufferedAmount > MAX_SENT_BUFFER) {
+                        Thread.sleep(50);
+                        continue;
+                    }
+                    final int count = this.inputStream.read(buffer);
+                    if (count < 0) {
+                        Log.d(Config.LOGTAG, "DataChannelWriter reached EOF");
+                        return;
+                    }
+                    dataChannel.send(
+                            new DataChannel.Buffer(ByteBuffer.wrap(buffer, 0, count), true));
+                }
+            } catch (final InterruptedException | InterruptedIOException e) {
+                if (isSending.get()) {
+                    Log.w(Config.LOGTAG, "DataChannelWriter got interrupted while sending", e);
+                }
+            } catch (final IOException e) {
+                Log.d(Config.LOGTAG, "DataChannelWriter terminated", e);
+            } finally {
+                Closeables.closeQuietly(inputStream);
+            }
+        }
+
+        public void close() {
+            this.isSending.set(false);
+            terminate(this.dataChannel);
+        }
+    }
+
+    private abstract static class StateChangeObserver implements DataChannel.Observer {
+
+        @Override
+        public void onBufferedAmountChange(final long change) {}
+
+        @Override
+        public void onMessage(final DataChannel.Buffer buffer) {}
+    }
+
+    private abstract static class OnMessageObserver implements DataChannel.Observer {
+
+        @Override
+        public void onBufferedAmountChange(long l) {}
+
+        @Override
+        public void onStateChange() {}
+    }
+}