Merge branch 'master' of codeberg.org:iNPUTmice/Conversations

Stephen Paul Weber created

* 'master' of codeberg.org:iNPUTmice/Conversations: (50 commits)
  abort socks candidate search if peer selected something with higher priority
  disable quick log
  refactor Jingle File Transfer. add WebRTCDatachannel transport
  Translated using Weblate (Galician)
  Translated using Weblate (Bulgarian)
  Added translation using Weblate (Esperanto)
  remove some outdated information from readme
  UnifiedPush: send unregistered to apps when 'none' account is selected
  ensure will tell 'messenger' when UP registration fails or is delayed
  support location attribute on enable
  bump gradle plugin version
  add quick log functionality to debug UP
  Translated using Weblate (Chinese (Simplified))
  Translated using Weblate (Chinese (Simplified))
  Translated using Weblate (Italian)
  Translated using Weblate (Chinese (Simplified))
  JingleConnectionManager: code clean up
  update call UI after RTP connection has ended
  fixup: properly detect fast
  treat carbons as enabled when requested through bind 2
  ...

Change summary

CHANGELOG.md                                                                                 |    7 
build.gradle                                                                                 |    2 
fastlane/metadata/android/en-US/changelogs/4208104.txt                                       |    4 
fastlane/metadata/android/es-ES/changelogs/4208104.txt                                       |    4 
fastlane/metadata/android/gl-ES/changelogs/4208104.txt                                       |    4 
fastlane/metadata/android/it-IT/changelogs/4208104.txt                                       |    4 
fastlane/metadata/android/uk/changelogs/4208104.txt                                          |    4 
fastlane/metadata/android/zh-CN/changelogs/398.txt                                           |    2 
fastlane/metadata/android/zh-CN/changelogs/4207704.txt                                       |    2 
fastlane/metadata/android/zh-CN/changelogs/4208104.txt                                       |    4 
src/conversations/fastlane/metadata/android/eo/short_description.txt                         |    1 
src/conversations/fastlane/metadata/android/fi-FI/full_description.txt                       |   39 
src/conversations/fastlane/metadata/android/fi-FI/short_description.txt                      |    1 
src/conversations/res/values-eo/strings.xml                                                  |   20 
src/main/java/eu/siacs/conversations/Config.java                                             |    5 
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/persistance/UnifiedPushDatabase.java                    |   18 
src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java                         |  108 
src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java                    |   78 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java                     |   25 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java                              |  170 
src/main/java/eu/siacs/conversations/utils/Checksum.java                                     |   60 
src/main/java/eu/siacs/conversations/xml/LocalizedContent.java                               |    2 
src/main/java/eu/siacs/conversations/xml/Namespace.java                                      |    6 
src/main/java/eu/siacs/conversations/xml/Tag.java                                            |    4 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                                |  168 
src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java                                    |   10 
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                |  191 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java           | 2249 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java                  |  265 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java                    |  428 
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                          |   58 
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   |  901 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java                   |   80 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java  |  617 
src/main/res/values-bg/strings.xml                                                           |    9 
src/main/res/values-de/strings.xml                                                           |   12 
src/main/res/values-eo/strings.xml                                                           |    2 
src/main/res/values-es/strings.xml                                                           |    2 
src/main/res/values-fi/strings.xml                                                           |  117 
src/main/res/values-it/strings.xml                                                           |    4 
src/main/res/values-pl/strings.xml                                                           |   11 
src/main/res/values-ru/strings.xml                                                           |    6 
src/main/res/values-sv/strings.xml                                                           |  181 
src/main/res/values-zh-rCN/strings.xml                                                       |   20 
src/quicksy/fastlane/metadata/android/eo/short_description.txt                               |    1 
src/quicksy/fastlane/metadata/android/gl-ES/short_description.txt                            |    2 
src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt                             |    6 
src/quicksy/res/values-eo/strings.xml                                                        |   12 
77 files changed, 5,613 insertions(+), 3,142 deletions(-)

Detailed changes

CHANGELOG.md 🔗

@@ -1,5 +1,12 @@
 # Changelog
 
+### Version 2.13.0
+
+* Easier access to 'Show QR code'
+* Support PEP Native Bookmarks
+* Add support for SDP Offer / Answer Model (Used by SIP gateways)
+* Raise target API to Android 14
+
 ### Version 2.12.12
 
 * Support Private DNS (DNS over TLS)

build.gradle 🔗

@@ -6,7 +6,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:8.2.0-rc01'
+        classpath 'com.android.tools.build:gradle:8.2.0-rc03'
     }
 }
 

fastlane/metadata/android/es-ES/changelogs/4208104.txt 🔗

@@ -0,0 +1,4 @@
+* Acceso más fácil a "Mostrar el código QR
+* Soporte para marcadores nativos PEP
+* Añadir soporte para SDP Oferta / Respuesta Modelo (Utilizado por pasarelas SIP)
+* Aumento de la API de destino a Android 14

fastlane/metadata/android/gl-ES/changelogs/4208104.txt 🔗

@@ -0,0 +1,4 @@
+* Acceso mais rápido á 'Mostrar código QR'
+* Soporte para a PEP Marcadores Nativos
+* Engadido soporte para SDP Offer / Answer Model (usado por pasarelas SIP)
+* Establecida a API de Android 14 como obxectivo

fastlane/metadata/android/it-IT/changelogs/4208104.txt 🔗

@@ -0,0 +1,4 @@
+* Accesso più facile a 'Mostra codice QR'
+* Supporto per PEP Native Bookmarks
+* Aggiunto supporto per il modello Offerta / Risposta SDP (usato dai gateway SIP)
+* Aumentata l'API di destinazione ad Android 14

fastlane/metadata/android/uk/changelogs/4208104.txt 🔗

@@ -0,0 +1,4 @@
+* Простіший доступ до «Показати QR-код»
+* Підтримка закладок PEP Native Bookmarks
+* Додано підтримку моделі SDP пропозиція/відповідь (Використовується шлюзами SIP)
+* Підвищено цільовий API до Android 14

fastlane/metadata/android/zh-CN/changelogs/398.txt 🔗

@@ -1,4 +1,4 @@
 * 搜索个人对话
 * 消息传递失败时通知用户
-* 重新启动时记住 Quicksy 用户的显示名称(昵称)
+* 重启时记住 Quicksy 用户的显示名称(昵称)
 * 如有必要,添加按钮以从通知中启动 Orbot(Tor)

src/conversations/fastlane/metadata/android/fi-FI/full_description.txt 🔗

@@ -0,0 +1,39 @@
+Helppokäyttöinen, luotettava ja vähän akkua käyttävä. Sisäänrakennettu tuki kuville, ryhmille ja päästä päähän -salaukselle.
+
+Periaatteet:
+
+* Ole niin kaunis ja helppokäyttöinen kuin on mahdolista turvallisuudesta ja ykstyisyydestä tinkimättä
+* Käytä valmiita ja vakiintuneita protokollia
+* Älä riipu Google-tunnuksesta äläkä Google Cloud Messaging -palvelusta (GCM)
+* Vaadi niin vähän käyttöoikeuksia kuin mahdollista
+
+Ominaisuudet:
+
+* Päästä päähän -salaus joko <a href="http://conversations.im/omemo/">OMEMO:lla</a> tai <a href="http://openpgp.org/about/">OpenPGP:llä</a>
+* Lähetä ja vastaanota kuvia
+* Salatut ääni- ja videopuhelut (DTLS-SRTP)
+* Intuitiivinen käyttöliittymä joka noudattaa Androidin muotoilukieltä
+* Profiilikuvat yhteystiedoille
+* Synkronoi työpöytäversion kanssa
+* Konferenssit (kirjanmerkkituella)
+* Osoitekirjaintegrointi
+* Useampi tili yhdessä näkymässä
+* Todella pieni akun kulutus
+
+Conversations:lla on helppo luoda tili conversations.im-palvelimella. Silti Conversations toimii myös minkä tahansa muun XMPP-palvelimen kanssa. Monia XMPP-palvelimia ylläpidetään ilmaiseksi vapaaehtoisvoimin.
+
+XMPP-ominaisuudet:
+
+Conversations toimii kaikkien XMPP-palvelinten kanssa. XMPP on kuitenkin laajennettava protokolla. Nämä laajennukset on standardoitu niin kutsuttuina XEP:inä. Conversations tukee muutamaa näistä tehdäkseen käyttäjäkokemuksesta paremman. On mahdollista että nykyinen XMPP-palvelimesi ei tue kaikkia näitä laajennoksia. Siispä saadaksesi kaiken ilon irti Conversationsista kannattaa harkita joko sellaiseen palvelimeen, joka tukee näitä, vaihtamista tai oman XMPP-palvelimen ylläpitämistä itsellesi ja kavereillesi.
+
+XEP:t ovat tällä hetkellä:
+
+* XEP-0065: SOCKS5 Bytestreams (tai mod_proxy65). Käytetään tiedostojen siirtoon jos molemmat osapuolet ovat palomuurin tai NAT:n takana.
+* XEP-0163: Personal Eventing Protocol profiilikuville
+* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster.
+* XEP-0198: Stream Management mahdollistaa XMPP:n selviämisen pienestä verkon pätkimisestä ja TCP-yhteyden muutoksista.
+* XEP-0280: Kopiot lähettämistäsi viesteistä muille laitteillesi. Mahdolistaa laitteiden vaihdon kesken keskustelun täysin saumoitta.
+* XEP-0237: Roster Versioning säästää dataa heikoila yhteyksillä
+* XEP-0313: Message Archive Management synchronize message history with the server. Catch up with messages that were sent while Conversations was offline.
+* XEP-0352: Client State Indication lets the server know whether or not Conversations is in the background. Allows the server to save bandwidth by withholding unimportant packages.
+* XEP-0363: HTTP File Upload allows you to share files in conferences and with offline contacts. Requires an additional component on your server.

src/conversations/res/values-eo/strings.xml 🔗

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="use_conversations.im">Uzi conversations.im</string>
+    <string name="easy_invite_share_text">Aliĝu al %1$s kaj babilu kun mi: %2$s</string>
+    <string name="magic_create_text_on_x">Vi estis invitita al %1$s. Ni gvidos vin tra la procezo de kreado de konto.
+\nElektante %1$s kiel provizanton vi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson.</string>
+    <string name="do_you_have_an_account">Ĉu vi jam havas XMPP-konton\? Ĉi tio povus esti la kazo se vi jam uzas alian XMPP-klienton aŭ antaŭe uzis Conversations. Se ne, vi povas krei novan XMPP-konton nun.
+\nKonsileto: Iuj retpoŝtaj provizantoj ankaŭ provizas XMPP-kontojn.</string>
+    <string name="if_contact_is_nearby_use_qr">Se via kontakto estas proksime, ili ankaŭ povas skani la suban kodon por akcepti vian inviton.</string>
+    <string name="pick_a_server">Elekti vian XMPP-provizanton</string>
+    <string name="share_invite_with">Kunhavigi inviton kun…</string>
+    <string name="server_select_text">XMPP estas provizanta sendependa tujmesaĝa reto. Vi povas uzi ĉi tiun klienton per kia ajn XMPP-servilo, kiun vi elektas.
+\nTamen por via komforto ni faciligis krei konton ĉe conversations.im; provizanto speciale taŭga por la uzo kun Conversations.</string>
+    <string name="magic_create_text_fixed">Vi estis invitita al %1$s. Uzantnomo jam estas elektita por vi. Ni gvidos vin tra la procezo de kreado de konto.
+\nVi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson.</string>
+    <string name="improperly_formatted_provisioning">Nedece formatita provizokodo</string>
+    <string name="tap_share_button_send_invite">Premu la kunhavigi butonon por sendi al via kontakto inviton al %1$s.</string>
+    <string name="your_server_invitation">Via servila invito</string>
+    <string name="create_new_account">Krei novan konton</string>
+</resources>

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -41,10 +41,11 @@ 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 Jid BUG_REPORTS = Jid.of("+14169938000@cheogram.com");
     public static final Uri HELP = Uri.parse("https://cheogram.com");
 
-
     public static final String DOMAIN_LOCK = null; //only allow account creation for this domain
     public static final String MAGIC_CREATE_DOMAIN = "chatterboxtown.us";
     public static final Jid QUICKSY_DOMAIN = Jid.of("cheogram.com");
@@ -116,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,
@@ -126,6 +122,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/persistance/UnifiedPushDatabase.java 🔗

@@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableList;
 
 import org.jetbrains.annotations.NotNull;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import eu.siacs.conversations.Config;
@@ -129,6 +130,23 @@ public class UnifiedPushDatabase extends SQLiteOpenHelper {
         return null;
     }
 
+    public List<PushTarget> deletePushTargets() {
+        final SQLiteDatabase sqLiteDatabase = getReadableDatabase();
+        final ImmutableList.Builder<PushTarget> builder = new ImmutableList.Builder<>();
+        try (final Cursor cursor = sqLiteDatabase.query("push",new String[]{"application","instance"},null,null,null,null,null)) {
+            if (cursor != null && cursor.moveToFirst()) {
+                builder.add(new PushTarget(
+                        cursor.getString(cursor.getColumnIndexOrThrow("application")),
+                        cursor.getString(cursor.getColumnIndexOrThrow("instance"))));
+            }
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG,"unable to retrieve push targets",e);
+            return builder.build();
+        }
+        sqLiteDatabase.delete("push",null,null);
+        return builder.build();
+    }
+
     public boolean hasEndpoints(final UnifiedPushBroker.Transport transport) {
         final SQLiteDatabase sqLiteDatabase = getReadableDatabase();
         try (final Cursor cursor =

src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java 🔗

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.services;
 
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.content.Intent;
 import android.content.SharedPreferences;
@@ -9,10 +10,18 @@ import android.os.Messenger;
 import android.os.RemoteException;
 import android.preference.PreferenceManager;
 import android.util.Log;
+
+import androidx.annotation.NonNull;
+
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
+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 eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
@@ -87,14 +96,32 @@ public class UnifiedPushBroker {
             if (transport.account.isEnabled()) {
                 renewUnifiedEndpoint(transportOptional.get(), pushTargetMessenger);
             } else {
+                if (pushTargetMessenger.messenger != null) {
+                    sendRegistrationDelayed(pushTargetMessenger.messenger,"account is disabled");
+                }
                 Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. Account is disabled");
             }
         } else {
+            if (pushTargetMessenger.messenger != null) {
+                sendRegistrationDelayed(pushTargetMessenger.messenger,"no transport selected");
+            }
             Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. No transport selected");
         }
         return transportOptional;
     }
 
+    private void sendRegistrationDelayed(final Messenger messenger, final String error) {
+        final Intent intent = new Intent(UnifiedPushDistributor.ACTION_REGISTRATION_DELAYED);
+        intent.putExtra(UnifiedPushDistributor.EXTRA_MESSAGE, error);
+        final var message = new Message();
+        message.obj = intent;
+        try {
+            messenger.send(message);
+        } catch (final RemoteException e) {
+            Log.d(Config.LOGTAG,"unable to tell messenger of delayed registration",e);
+        }
+    }
+
     private void renewUnifiedEndpoint(final Transport transport, final PushTargetMessenger pushTargetMessenger) {
         final Account account = transport.account;
         final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service);
@@ -112,6 +139,7 @@ public class UnifiedPushBroker {
             Log.d(
                     Config.LOGTAG,
                     account.getJid().asBareJid() + ": try to renew UnifiedPush " + renewal);
+            UnifiedPushDistributor.quickLog(service,String.format("%s: try to renew UnifiedPush %s", account.getJid(), renewal.toString()));
             final String hashedApplication =
                     UnifiedPushDistributor.hash(account.getUuid(), renewal.application);
             final String hashedInstance =
@@ -186,7 +214,16 @@ public class UnifiedPushBroker {
                             + renewal.instance
                             + " was updated to "
                             + endpoint);
-            final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint = new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint);
+            UnifiedPushDistributor.quickLog(
+                    service,
+                    "endpoint for "
+                            + renewal.application
+                            + "/"
+                            + renewal.instance
+                            + " was updated to "
+                            + endpoint);
+            final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint =
+                    new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint);
             sendEndpoint(messenger, renewal.instance, applicationEndpoint);
         }
     }
@@ -210,6 +247,9 @@ public class UnifiedPushBroker {
     public boolean reconfigurePushDistributor() {
         final boolean enabled = getTransport().isPresent();
         setUnifiedPushDistributorEnabled(enabled);
+        if (!enabled) {
+            unregisterCurrentPushTargets();
+        }
         return enabled;
     }
 
@@ -232,6 +272,43 @@ public class UnifiedPushBroker {
         }
     }
 
+    private void unregisterCurrentPushTargets() {
+        final var future = deletePushTargets();
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(
+                            final List<UnifiedPushDatabase.PushTarget> pushTargets) {
+                        broadcastUnregistered(pushTargets);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "could not delete endpoints after UnifiedPushDistributor was disabled");
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<List<UnifiedPushDatabase.PushTarget>> deletePushTargets() {
+        return Futures.submit(() -> UnifiedPushDatabase.getInstance(service).deletePushTargets(),SCHEDULER);
+    }
+
+    private void broadcastUnregistered(final List<UnifiedPushDatabase.PushTarget> pushTargets) {
+        for(final UnifiedPushDatabase.PushTarget pushTarget : pushTargets) {
+            Log.d(Config.LOGTAG,"sending unregistered to "+pushTarget);
+            broadcastUnregistered(pushTarget);
+        }
+    }
+
+    private void broadcastUnregistered(final UnifiedPushDatabase.PushTarget pushTarget) {
+        final var intent = unregisteredIntent(pushTarget);
+        service.sendBroadcast(intent);
+    }
+
     public boolean processPushMessage(
             final Account account, final Jid transport, final Element push) {
         final String instance = push.getAttribute("instance");
@@ -326,6 +403,12 @@ public class UnifiedPushBroker {
         updateIntent.putExtra("token", target.instance);
         updateIntent.putExtra("bytesMessage", payload);
         updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8));
+        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);
     }
 
@@ -336,11 +419,30 @@ public class UnifiedPushBroker {
         service.sendBroadcast(updateIntent);
     }
 
-    private static Intent endpointIntent(final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) {
+    private Intent endpointIntent(final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) {
         final Intent intent = new Intent(UnifiedPushDistributor.ACTION_NEW_ENDPOINT);
         intent.setPackage(endpoint.application);
         intent.putExtra("token", instance);
         intent.putExtra("endpoint", endpoint.endpoint);
+        final var distributorVerificationIntent = new Intent();
+        distributorVerificationIntent.setPackage(service.getPackageName());
+        final var pendingIntent =
+                PendingIntent.getBroadcast(
+                        service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE);
+        intent.putExtra("distributor", pendingIntent);
+        return intent;
+    }
+
+    private Intent unregisteredIntent(final UnifiedPushDatabase.PushTarget pushTarget) {
+        final Intent intent = new Intent(UnifiedPushDistributor.ACTION_UNREGISTERED);
+        intent.setPackage(pushTarget.application);
+        intent.putExtra("token", pushTarget.instance);
+        final var distributorVerificationIntent = new Intent();
+        distributorVerificationIntent.setPackage(service.getPackageName());
+        final var pendingIntent =
+                PendingIntent.getBroadcast(
+                        service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE);
+        intent.putExtra("distributor", pendingIntent);
         return intent;
     }
 
@@ -368,7 +470,7 @@ public class UnifiedPushBroker {
 
     public static class PushTargetMessenger {
         private final UnifiedPushDatabase.PushTarget pushTarget;
-        private final Messenger messenger;
+        public final Messenger messenger;
 
         public PushTargetMessenger(UnifiedPushDatabase.PushTarget pushTarget, Messenger messenger) {
             this.pushTarget = pushTarget;

src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java 🔗

@@ -6,8 +6,10 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.ResolveInfo;
 import android.net.Uri;
+import android.os.Message;
 import android.os.Messenger;
 import android.os.Parcelable;
+import android.os.RemoteException;
 import android.util.Log;
 
 import com.google.common.base.Charsets;
@@ -27,16 +29,30 @@ import eu.siacs.conversations.utils.Compatibility;
 
 public class UnifiedPushDistributor extends BroadcastReceiver {
 
+    // distributor actions (these are actios used for connector->distributor broadcasts)
+    // we, the distributor, have a broadcast receiver listening for those actions
+
     public static final String ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER";
     public static final String ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER";
+
+
+    // connector actions (these are actions used for distributor->connector broadcasts)
+    public static final String ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED";
     public static final String ACTION_BYTE_MESSAGE =
             "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE";
     public static final String ACTION_REGISTRATION_FAILED =
             "org.unifiedpush.android.connector.REGISTRATION_FAILED";
+
+    // this action is only used in 'messenger' communication to tell the app that a registration is
+    // probably fine but can not be processed right now; for example due to spotty internet
+    public static final String ACTION_REGISTRATION_DELAYED =
+            "org.unifiedpush.android.connector.REGISTRATION_DELAYED";
     public static final String ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE";
     public static final String ACTION_NEW_ENDPOINT =
             "org.unifiedpush.android.connector.NEW_ENDPOINT";
 
+    public static final String EXTRA_MESSAGE = "message";
+
     public static final String PREFERENCE_ACCOUNT = "up_push_account";
     public static final String PREFERENCE_PUSH_SERVER = "up_push_server";
 
@@ -50,9 +66,8 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
         }
         final String action = intent.getAction();
         final String application;
-        final Parcelable appByPendingIntent = intent.getParcelableExtra("app");
-        if (appByPendingIntent instanceof PendingIntent) {
-            final PendingIntent pendingIntent = (PendingIntent) appByPendingIntent;
+        final Parcelable appVerification = intent.getParcelableExtra("app");
+        if (appVerification instanceof PendingIntent pendingIntent) {
             application = pendingIntent.getIntentSender().getCreatorPackage();
             Log.d(Config.LOGTAG,"received application name via pending intent "+ application);
         } else {
@@ -62,18 +77,12 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
         final String instance = intent.getStringExtra("token");
         final List<String> features = intent.getStringArrayListExtra("features");
         switch (Strings.nullToEmpty(action)) {
-            case ACTION_REGISTER:
-                register(context, application, instance, features, messenger);
-                break;
-            case ACTION_UNREGISTER:
-                unregister(context, instance);
-                break;
-            case Intent.ACTION_PACKAGE_FULLY_REMOVED:
-                unregisterApplication(context, intent.getData());
-                break;
-            default:
-                Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action);
-                break;
+            case ACTION_REGISTER -> register(context, application, instance, features, messenger);
+            case ACTION_UNREGISTER -> unregister(context, instance);
+            case Intent.ACTION_PACKAGE_FULLY_REMOVED ->
+                    unregisterApplication(context, intent.getData());
+            default ->
+                    Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action);
         }
     }
 
@@ -102,6 +111,7 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
                 Log.d(
                         Config.LOGTAG,
                         "successfully created UnifiedPush entry. waking up XmppConnectionService");
+                quickLog(context, String.format("successfully registered %s (token = %s) for UnifiedPushed", application, instance));
                 final Intent serviceIntent = new Intent(context, XmppConnectionService.class);
                 serviceIntent.setAction(XmppConnectionService.ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS);
                 serviceIntent.putExtra("instance", instance);
@@ -113,11 +123,25 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
             } else {
                 Log.d(Config.LOGTAG, "not successful. sending error message back to application");
                 final Intent registrationFailed = new Intent(ACTION_REGISTRATION_FAILED);
+                registrationFailed.putExtra(EXTRA_MESSAGE, "instance already exits");
                 registrationFailed.setPackage(application);
                 registrationFailed.putExtra("token", instance);
-                context.sendBroadcast(registrationFailed);
+                if (messenger instanceof Messenger m) {
+                    final var message = new Message();
+                    message.obj = registrationFailed;
+                    try {
+                        m.send(message);
+                    } catch (final RemoteException e) {
+                        context.sendBroadcast(registrationFailed);
+                    }
+                } else {
+                    context.sendBroadcast(registrationFailed);
+                }
             }
         } else {
+            if (messenger instanceof Messenger m) {
+                sendRegistrationFailed(m,"Your application is not registered to receive messages");
+            }
             Log.d(
                     Config.LOGTAG,
                     "ignoring invalid UnifiedPush registration. Unknown application "
@@ -125,6 +149,18 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
         }
     }
 
+    private void sendRegistrationFailed(final Messenger messenger, final String error) {
+        final Intent intent = new Intent(ACTION_REGISTRATION_FAILED);
+        intent.putExtra(EXTRA_MESSAGE, error);
+        final var message = new Message();
+        message.obj = intent;
+        try {
+            messenger.send(message);
+        } catch (final RemoteException e) {
+            Log.d(Config.LOGTAG,"unable to tell messenger of failed registration",e);
+        }
+    }
+
     private List<String> getBroadcastReceivers(final Context context, final String application) {
         final Intent messageIntent = new Intent(ACTION_MESSAGE);
         messageIntent.setPackage(application);
@@ -141,7 +177,9 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
         }
         final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(context);
         if (unifiedPushDatabase.deleteInstance(instance)) {
+            quickLog(context, String.format("successfully unregistered token %s from UnifiedPushed (application requested unregister)", instance));
             Log.d(Config.LOGTAG, "successfully removed " + instance + " from UnifiedPush");
+            // TODO send UNREGISTERED broadcast back to app?!
         }
     }
 
@@ -154,6 +192,7 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
             Log.d(Config.LOGTAG, "app " + application + " has been removed from the system");
             final UnifiedPushDatabase database = UnifiedPushDatabase.getInstance(context);
             if (database.deleteApplication(application)) {
+                quickLog(context, String.format("successfully removed %s from UnifiedPushed (ACTION_PACKAGE_FULLY_REMOVED)", application));
                 Log.d(Config.LOGTAG, "successfully removed " + application + " from UnifiedPush");
             }
         }
@@ -166,4 +205,11 @@ public class UnifiedPushDistributor extends BroadcastReceiver {
                                 .hashString(Joiner.on('\0').join(components), Charsets.UTF_8)
                                 .asBytes());
     }
+
+    public static void quickLog(final Context context, final String message) {
+        final Intent intent = new Intent(context, XmppConnectionService.class);
+        intent.setAction(XmppConnectionService.ACTION_QUICK_LOG);
+        intent.putExtra("message", message);
+        context.startService(intent);
+    }
 }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -153,6 +153,7 @@ import eu.siacs.conversations.ui.UiCallback;
 import eu.siacs.conversations.ui.interfaces.OnAvatarPublication;
 import eu.siacs.conversations.ui.interfaces.OnMediaLoaded;
 import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable;
+import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.Compatibility;
 import eu.siacs.conversations.utils.ConversationsFileObserver;
 import eu.siacs.conversations.utils.CryptoHelper;
@@ -227,6 +228,7 @@ public class XmppConnectionService extends Service {
     public static final String ACTION_PROVISION_ACCOUNT = "provision_account";
     private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE";
     public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS = "eu.siacs.conversations.UNIFIED_PUSH_RENEW";
+    public static final String ACTION_QUICK_LOG = "eu.siacs.conversations.QUICK_LOG";
 
     private static final String SETTING_LAST_ACTIVITY_TS = "last_activity_timestamp";
 
@@ -1016,6 +1018,12 @@ public class XmppConnectionService extends Service {
             case ACTION_FCM_MESSAGE_RECEIVED:
                 Log.d(Config.LOGTAG, "push message arrived in service. account");
                 break;
+            case ACTION_QUICK_LOG:
+                final String message = intent == null ? null : intent.getStringExtra("message");
+                if (message != null && Config.QUICK_LOG) {
+                    quickLog(message);
+                }
+                break;
             case Intent.ACTION_SEND:
                 final Uri uri = intent == null ? null : intent.getData();
                 if (uri != null) {
@@ -1036,6 +1044,23 @@ public class XmppConnectionService extends Service {
         return START_STICKY;
     }
 
+    private void quickLog(final String message) {
+        if (Strings.isNullOrEmpty(message)) {
+            return;
+        }
+        final Account account = AccountUtils.getFirstEnabled(this);
+        if (account == null) {
+            return;
+        }
+        final Conversation conversation =
+                findOrCreateConversation(account, Config.BUG_REPORTS, false, true);
+        final Message report = new Message(conversation, message, Message.ENCRYPTION_NONE);
+        report.setStatus(Message.STATUS_RECEIVED);
+        conversation.add(report);
+        databaseBackend.createMessage(report);
+        updateConversationUi();
+    }
+
     private void manageAccountConnectionStatesInternal() {
         manageAccountConnectionStates(ACTION_INTERNAL_PING, null);
     }

src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java 🔗

@@ -1,8 +1,9 @@
 package eu.siacs.conversations.ui;
 
-import static java.util.Arrays.asList;
 import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
 
+import static java.util.Arrays.asList;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
@@ -65,7 +66,6 @@ import eu.siacs.conversations.ui.util.MainThreadExecutor;
 import eu.siacs.conversations.ui.util.Rationals;
 import eu.siacs.conversations.utils.PermissionUtils;
 import eu.siacs.conversations.utils.TimeFrameUtils;
-import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 import eu.siacs.conversations.xmpp.jingle.ContentAddition;
@@ -75,6 +75,16 @@ import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
 import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 
+import org.webrtc.RendererCommon;
+import org.webrtc.SurfaceViewRenderer;
+import org.webrtc.VideoTrack;
+
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
 public class RtpSessionActivity extends XmppActivity
         implements XmppConnectionService.OnJingleRtpConnectionUpdate,
                 eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged {
@@ -109,9 +119,7 @@ public class RtpSessionActivity extends XmppActivity
                     RtpEndUserState.RECONNECTING,
                     RtpEndUserState.INCOMING_CONTENT_ADD);
     private static final List<RtpEndUserState> STATES_CONSIDERED_CONNECTED =
-            Arrays.asList(
-                    RtpEndUserState.CONNECTED,
-                    RtpEndUserState.RECONNECTING);
+            Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING);
     private static final List<RtpEndUserState> STATES_SHOWING_PIP_PLACEHOLDER =
             Arrays.asList(
                     RtpEndUserState.ACCEPTING_CALL,
@@ -262,21 +270,22 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     public boolean onOptionsItemSelected(final MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_help:
-                launchHelpInBrowser();
-                return true;
-            case R.id.action_goto_chat:
-                switchToConversation();
-                return true;
-            case R.id.action_dialpad:
-                toggleDialpadVisibility();
-                return true;
-            case R.id.action_switch_to_video:
-                requestPermissionAndSwitchToVideo();
-                return true;
+        final var itemItem = item.getItemId();
+        if (itemItem == R.id.action_help) {
+            launchHelpInBrowser();
+            return true;
+        } else if (itemItem == R.id.action_goto_chat) {
+            switchToConversation();
+            return true;
+        } else if (itemItem == R.id.action_dialpad) {
+            toggleDialpadVisibility();
+            return true;
+        } else if (itemItem == R.id.action_switch_to_video) {
+            requestPermissionAndSwitchToVideo();
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     private void launchHelpInBrowser() {
@@ -353,8 +362,9 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     private void acceptContentAdd(final ContentAddition contentAddition) {
-        if (contentAddition == null || contentAddition.direction != ContentAddition.Direction.INCOMING) {
-            Log.d(Config.LOGTAG,"ignore press on content-accept button");
+        if (contentAddition == null
+                || contentAddition.direction != ContentAddition.Direction.INCOMING) {
+            Log.d(Config.LOGTAG, "ignore press on content-accept button");
             return;
         }
         requestPermissionAndAcceptContentAdd(contentAddition);
@@ -363,7 +373,11 @@ public class RtpSessionActivity extends XmppActivity
     private void requestPermissionAndAcceptContentAdd(final ContentAddition contentAddition) {
         final List<String> permissions = permissions(contentAddition.media());
         if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CONTENT)) {
-            requireRtpConnection().acceptContentAdd(contentAddition.summary);
+            try {
+                requireRtpConnection().acceptContentAdd(contentAddition.summary);
+            } catch (final IllegalStateException e) {
+                Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
+            }
         }
     }
 
@@ -715,8 +729,10 @@ public class RtpSessionActivity extends XmppActivity
     private boolean isConnected() {
         final JingleRtpConnection connection =
                 this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
-        final RtpEndUserState endUserState = connection == null ? null : connection.getEndUserState();
-        return STATES_CONSIDERED_CONNECTED.contains(endUserState) || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD;
+        final RtpEndUserState endUserState =
+                connection == null ? null : connection.getEndUserState();
+        return STATES_CONSIDERED_CONNECTED.contains(endUserState)
+                || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD;
     }
 
     private boolean switchToPictureInPicture() {
@@ -875,68 +891,43 @@ public class RtpSessionActivity extends XmppActivity
         updateStateDisplay(state, Collections.emptySet(), null);
     }
 
-    private void updateStateDisplay(final RtpEndUserState state, final Set<Media> media, final ContentAddition contentAddition) {
+    private void updateStateDisplay(
+            final RtpEndUserState state,
+            final Set<Media> media,
+            final ContentAddition contentAddition) {
         switch (state) {
-            case INCOMING_CALL:
+            case INCOMING_CALL -> {
                 Preconditions.checkArgument(media.size() > 0, "Media must not be empty");
                 if (media.contains(Media.VIDEO)) {
                     setTitle(R.string.rtp_state_incoming_video_call);
                 } else {
                     setTitle(R.string.rtp_state_incoming_call);
                 }
-                break;
-            case INCOMING_CONTENT_ADD:
+            }
+            case INCOMING_CONTENT_ADD -> {
                 if (contentAddition != null && contentAddition.media().contains(Media.VIDEO)) {
                     setTitle(R.string.rtp_state_content_add_video);
                 } else {
                     setTitle(R.string.rtp_state_content_add);
                 }
-                break;
-            case CONNECTING:
-                setTitle(R.string.rtp_state_connecting);
-                break;
-            case CONNECTED:
-                setTitle(R.string.rtp_state_connected);
-                break;
-            case RECONNECTING:
-                setTitle(R.string.rtp_state_reconnecting);
-                break;
-            case ACCEPTING_CALL:
-                setTitle(R.string.rtp_state_accepting_call);
-                break;
-            case ENDING_CALL:
-                setTitle(R.string.rtp_state_ending_call);
-                break;
-            case FINDING_DEVICE:
-                setTitle(R.string.rtp_state_finding_device);
-                break;
-            case RINGING:
-                setTitle(R.string.rtp_state_ringing);
-                break;
-            case DECLINED_OR_BUSY:
-                setTitle(R.string.rtp_state_declined_or_busy);
-                break;
-            case CONNECTIVITY_ERROR:
-                setTitle(R.string.rtp_state_connectivity_error);
-                break;
-            case CONNECTIVITY_LOST_ERROR:
-                setTitle(R.string.rtp_state_connectivity_lost_error);
-                break;
-            case RETRACTED:
-                setTitle(R.string.rtp_state_retracted);
-                break;
-            case APPLICATION_ERROR:
-                setTitle(R.string.rtp_state_application_failure);
-                break;
-            case SECURITY_ERROR:
-                setTitle(R.string.rtp_state_security_error);
-                break;
-            case ENDED:
-                throw new IllegalStateException(
-                        "Activity should have called finishAndReleaseWakeLock();");
-            default:
-                throw new IllegalStateException(
-                        String.format("State %s has not been handled in UI", state));
+            }
+            case CONNECTING -> setTitle(R.string.rtp_state_connecting);
+            case CONNECTED -> setTitle(R.string.rtp_state_connected);
+            case RECONNECTING -> setTitle(R.string.rtp_state_reconnecting);
+            case ACCEPTING_CALL -> setTitle(R.string.rtp_state_accepting_call);
+            case ENDING_CALL -> setTitle(R.string.rtp_state_ending_call);
+            case FINDING_DEVICE -> setTitle(R.string.rtp_state_finding_device);
+            case RINGING -> setTitle(R.string.rtp_state_ringing);
+            case DECLINED_OR_BUSY -> setTitle(R.string.rtp_state_declined_or_busy);
+            case CONNECTIVITY_ERROR -> setTitle(R.string.rtp_state_connectivity_error);
+            case CONNECTIVITY_LOST_ERROR -> setTitle(R.string.rtp_state_connectivity_lost_error);
+            case RETRACTED -> setTitle(R.string.rtp_state_retracted);
+            case APPLICATION_ERROR -> setTitle(R.string.rtp_state_application_failure);
+            case SECURITY_ERROR -> setTitle(R.string.rtp_state_security_error);
+            case ENDED -> throw new IllegalStateException(
+                    "Activity should have called finishAndReleaseWakeLock();");
+            default -> throw new IllegalStateException(
+                    String.format("State %s has not been handled in UI", state));
         }
     }
 
@@ -992,7 +983,10 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     @SuppressLint("RestrictedApi")
-    private void updateButtonConfiguration(final RtpEndUserState state, final Set<Media> media, final ContentAddition contentAddition) {
+    private void updateButtonConfiguration(
+            final RtpEndUserState state,
+            final Set<Media> media,
+            final ContentAddition contentAddition) {
         if (state == RtpEndUserState.ENDING_CALL || isPictureInPicture()) {
             this.binding.rejectCall.setVisibility(View.INVISIBLE);
             this.binding.endCall.setVisibility(View.INVISIBLE);
@@ -1008,7 +1002,8 @@ public class RtpSessionActivity extends XmppActivity
             this.binding.acceptCall.setImageResource(R.drawable.ic_call_white_48dp);
             this.binding.acceptCall.setVisibility(View.VISIBLE);
         } else if (state == RtpEndUserState.INCOMING_CONTENT_ADD) {
-            this.binding.rejectCall.setContentDescription(getString(R.string.reject_switch_to_video));
+            this.binding.rejectCall.setContentDescription(
+                    getString(R.string.reject_switch_to_video));
             this.binding.rejectCall.setOnClickListener(this::rejectContentAdd);
             this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp);
             this.binding.rejectCall.setVisibility(View.VISIBLE);
@@ -1100,7 +1095,7 @@ public class RtpSessionActivity extends XmppActivity
     private void updateInCallButtonConfigurationSpeaker(
             final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) {
         switch (selectedAudioDevice) {
-            case EARPIECE:
+            case EARPIECE -> {
                 this.binding.inCallActionRight.setImageResource(
                         R.drawable.ic_volume_off_black_24dp);
                 if (numberOfChoices >= 2) {
@@ -1109,13 +1104,13 @@ public class RtpSessionActivity extends XmppActivity
                     this.binding.inCallActionRight.setOnClickListener(null);
                     this.binding.inCallActionRight.setClickable(false);
                 }
-                break;
-            case WIRED_HEADSET:
+            }
+            case WIRED_HEADSET -> {
                 this.binding.inCallActionRight.setImageResource(R.drawable.ic_headset_black_24dp);
                 this.binding.inCallActionRight.setOnClickListener(null);
                 this.binding.inCallActionRight.setClickable(false);
-                break;
-            case SPEAKER_PHONE:
+            }
+            case SPEAKER_PHONE -> {
                 this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_up_black_24dp);
                 if (numberOfChoices >= 2) {
                     this.binding.inCallActionRight.setOnClickListener(this::switchToEarpiece);
@@ -1123,13 +1118,13 @@ public class RtpSessionActivity extends XmppActivity
                     this.binding.inCallActionRight.setOnClickListener(null);
                     this.binding.inCallActionRight.setClickable(false);
                 }
-                break;
-            case BLUETOOTH:
+            }
+            case BLUETOOTH -> {
                 this.binding.inCallActionRight.setImageResource(
                         R.drawable.ic_bluetooth_audio_black_24dp);
                 this.binding.inCallActionRight.setOnClickListener(null);
                 this.binding.inCallActionRight.setClickable(false);
-                break;
+            }
         }
         this.binding.inCallActionRight.setVisibility(View.VISIBLE);
     }
@@ -1158,10 +1153,10 @@ public class RtpSessionActivity extends XmppActivity
     private void switchCamera(final View view) {
         Futures.addCallback(
                 requireRtpConnection().switchCamera(),
-                new FutureCallback<Boolean>() {
+                new FutureCallback<>() {
                     @Override
                     public void onSuccess(@Nullable Boolean isFrontCamera) {
-                        binding.localVideo.setMirror(isFrontCamera);
+                        binding.localVideo.setMirror(Boolean.TRUE.equals(isFrontCamera));
                     }
 
                     @Override
@@ -1526,8 +1521,7 @@ public class RtpSessionActivity extends XmppActivity
             final Account account, Jid with, final RtpEndUserState state, final Set<Media> media) {
         final Intent intent = new Intent(Intent.ACTION_VIEW);
         intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString());
-        if (RtpCapability.jmiSupport(account.getRoster()
-                .getContact(with))) {
+        if (RtpCapability.jmiSupport(account.getRoster().getContact(with))) {
             intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString());
         } else {
             intent.putExtra(EXTRA_WITH, with.toEscapedString());

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/LocalizedContent.java 🔗

@@ -29,7 +29,7 @@ public class LocalizedContent {
                 final String childLanguage = child.getAttribute("xml:lang");
                 final String lang = childLanguage == null ? parentLanguage : childLanguage;
                 final String content = child.getContent();
-                if (namespace == null || "jabber:client".equals(namespace)) {
+                if (content != null && (namespace == null || Namespace.JABBER_CLIENT.equals(namespace))) {
                     if (contents.put(lang, content == null ? "" : content) != null) {
                         //anything that has multiple contents for the same language is invalid
                         return null;

src/main/java/eu/siacs/conversations/xml/Namespace.java 🔗

@@ -2,6 +2,7 @@ package eu.siacs.conversations.xml;
 
 public final class Namespace {
     public static final String STREAMS = "http://etherx.jabber.org/streams";
+    public static final String JABBER_CLIENT = "jabber:client";
     public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items";
     public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info";
     public static final String EXTERNAL_SERVICE_DISCOVERY = "urn:xmpp:extdisco:2";
@@ -46,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 +76,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/xml/Tag.java 🔗

@@ -43,6 +43,10 @@ public class Tag {
         return name;
     }
 
+    public String identifier() {
+        return String.format("%s#%s", name, this.attributes.get("xmlns"));
+    }
+
     public String getAttribute(final String attrName) {
         return this.attributes.get(attrName);
     }

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -17,8 +17,11 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.core.util.Consumer;
 
+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.ImmutableList;
 
 import org.xmlpull.v1.XmlPullParserException;
 
@@ -169,7 +172,7 @@ public class XmppConnection implements Runnable {
     private boolean isBound = false;
     private Element streamFeatures;
     private Element boundStreamFeatures;
-    private String streamId = null;
+    private StreamId streamId = null;
     private int stanzasReceived = 0;
     private int stanzasSent = 0;
     private int stanzasSentBeforeAuthentication;
@@ -192,7 +195,7 @@ public class XmppConnection implements Runnable {
     private OnStatusChanged statusListener = null;
     private OnBindListener bindListener = null;
     private OnMessageAcknowledged acknowledgedListener = null;
-    private SaslMechanism saslMechanism;
+    private LoginInfo loginInfo;
     private HashedToken.Mechanism hashTokenRequest;
     private HttpUrl redirectionUrl = null;
     private String verifiedHostname = null;
@@ -382,6 +385,12 @@ public class XmppConnection implements Runnable {
                                         + storedBackupResult);
                     }
                 }
+                final StreamId streamId = this.streamId;
+                final Resolver.Result resumeLocation = streamId == null ? null : streamId.location;
+                if (resumeLocation != null) {
+                    Log.d(Config.LOGTAG,account.getJid().asBareJid()+": injected resume location on position 0");
+                    results.add(0, resumeLocation);
+                }
                 final Resolver.Result seeOtherHost = this.seeOtherHostResolverResult;
                 if (seeOtherHost != null) {
                     Log.d(Config.LOGTAG,account.getJid().asBareJid()+": injected see-other-host on position 0");
@@ -599,7 +608,6 @@ public class XmppConnection implements Runnable {
                 if (processSuccess(success)) {
                     break;
                 }
-
             } else if (nextTag.isStart("failure", Namespace.TLS)) {
                 throw new StateChangingException(Account.State.TLS_ERROR);
             } else if (nextTag.isStart("failure")) {
@@ -609,7 +617,7 @@ public class XmppConnection implements Runnable {
                 // two step sasl2 - we don’t support this yet
                 throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT);
             } else if (nextTag.isStart("challenge")) {
-                if (isSecure() && this.saslMechanism != null) {
+                if (isSecure() && this.loginInfo != null) {
                     final Element challenge = tagReader.readElement(nextTag);
                     processChallenge(challenge);
                 } else {
@@ -622,10 +630,10 @@ public class XmppConnection implements Runnable {
             } else if (nextTag.isStart("enabled", Namespace.STREAM_MANAGEMENT)) {
                 final Element enabled = tagReader.readElement(nextTag);
                 processEnabled(enabled);
-            } else if (nextTag.isStart("resumed")) {
+            } else if (nextTag.isStart("resumed", Namespace.STREAM_MANAGEMENT)) {
                 final Element resumed = tagReader.readElement(nextTag);
                 processResumed(resumed);
-            } else if (nextTag.isStart("r")) {
+            } else if (nextTag.isStart("r", Namespace.STREAM_MANAGEMENT)) {
                 tagReader.readElement(nextTag);
                 if (Config.EXTENDED_SM_LOGGING) {
                     Log.d(
@@ -636,7 +644,7 @@ public class XmppConnection implements Runnable {
                 }
                 final AckPacket ack = new AckPacket(this.stanzasReceived);
                 tagWriter.writeStanzaAsync(ack);
-            } else if (nextTag.isStart("a")) {
+            } else if (nextTag.isStart("a", Namespace.STREAM_MANAGEMENT)) {
                 boolean accountUiNeedsRefresh = false;
                 synchronized (NotificationService.CATCHUP_LOCK) {
                     if (mWaitingForSmCatchup.compareAndSet(true, false)) {
@@ -679,15 +687,22 @@ public class XmppConnection implements Runnable {
                 if (acknowledgedMessages) {
                     mXmppConnectionService.updateConversationUi();
                 }
-            } else if (nextTag.isStart("failed")) {
+            } else if (nextTag.isStart("failed", Namespace.STREAM_MANAGEMENT)) {
                 final Element failed = tagReader.readElement(nextTag);
                 processFailed(failed, true);
-            } else if (nextTag.isStart("iq")) {
+            } else if (nextTag.isStart("iq", Namespace.JABBER_CLIENT)) {
                 processIq(nextTag);
-            } else if (nextTag.isStart("message")) {
+            } else if (nextTag.isStart("message", Namespace.JABBER_CLIENT)) {
                 processMessage(nextTag);
-            } else if (nextTag.isStart("presence")) {
+            } else if (nextTag.isStart("presence", Namespace.JABBER_CLIENT)) {
                 processPresence(nextTag);
+            } else {
+                Log.e(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": Encountered unknown stream element"
+                                + nextTag.identifier());
+                throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
             }
             nextTag = tagReader.readTag();
         }
@@ -712,7 +727,7 @@ public class XmppConnection implements Runnable {
             throw new AssertionError("Missing implementation for " + version);
         }
         try {
-            response.setContent(saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket)));
+            response.setContent(this.loginInfo.saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket)));
         } catch (final SaslMechanism.AuthenticationException e) {
             // TODO: Send auth abort tag.
             Log.e(Config.LOGTAG, e.toString());
@@ -729,8 +744,9 @@ public class XmppConnection implements Runnable {
         } catch (final IllegalArgumentException e) {
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
-        final SaslMechanism currentSaslMechanism = this.saslMechanism;
-        if (currentSaslMechanism == null) {
+        final LoginInfo currentLoginInfo = this.loginInfo;
+        final SaslMechanism currentSaslMechanism = LoginInfo.mechanism(currentLoginInfo);
+        if (currentLoginInfo == null || currentSaslMechanism == null) {
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
         final String challenge;
@@ -829,13 +845,25 @@ public class XmppConnection implements Runnable {
                     //if we did not enable stream management in bind do it now
                     waitForDisco = enableStreamManagement();
                 }
+                final boolean negotiatedCarbons;
                 if (carbonsEnabled != null) {
+                    negotiatedCarbons = true;
                     Log.d(
                             Config.LOGTAG,
-                            account.getJid().asBareJid() + ": successfully enabled carbons");
+                            account.getJid().asBareJid()
+                                    + ": successfully enabled carbons (via Bind 2.0)");
                     features.carbonsEnabled = true;
+                } else if (loginInfo.inlineBindFeatures.contains(Namespace.CARBONS)) {
+                    negotiatedCarbons = true;
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": successfully enabled carbons (via Bind 2.0/implicit)");
+                    features.carbonsEnabled = true;
+                } else {
+                    negotiatedCarbons = false;
                 }
-                sendPostBindInitialization(waitForDisco, carbonsEnabled != null);
+                sendPostBindInitialization(waitForDisco, negotiatedCarbons);
             }
             final HashedToken.Mechanism tokenMechanism;
             if (SaslMechanism.hashedToken(currentSaslMechanism)) {
@@ -939,7 +967,7 @@ public class XmppConnection implements Runnable {
         }
         Log.d(Config.LOGTAG, failure.toString());
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version);
-        if (SaslMechanism.hashedToken(this.saslMechanism)) {
+        if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token");
             account.resetFastToken();
             mXmppConnectionService.databaseBackend.updateAccount(account);
@@ -965,7 +993,7 @@ public class XmppConnection implements Runnable {
                 }
             }
         }
-        if (SaslMechanism.hashedToken(this.saslMechanism)) {
+        if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) {
             Log.d(
                     Config.LOGTAG,
                     account.getJid().asBareJid()
@@ -985,18 +1013,27 @@ public class XmppConnection implements Runnable {
     }
 
     private void processEnabled(final Element enabled) {
-        final String streamId;
+        final String id;
         if (enabled.getAttributeAsBoolean("resume")) {
-            streamId = enabled.getAttribute("id");
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid().asBareJid().toString()
-                            + ": stream management enabled (resumable)");
+            id = enabled.getAttribute("id");
+        } else {
+            id = null;
+        }
+        final String locationAttribute = enabled.getAttribute("location");
+        final Resolver.Result currentResolverResult = this.currentResolverResult;
+        final Resolver.Result location;
+        if (Strings.isNullOrEmpty(locationAttribute) || currentResolverResult == null) {
+            location = null;
+        } else {
+            location = currentResolverResult.seeOtherHost(locationAttribute);
+        }
+        final StreamId streamId = id == null ? null : new StreamId(id, location);
+        if (streamId == null) {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stream management enabled");
         } else {
             Log.d(
                     Config.LOGTAG,
-                    account.getJid().asBareJid().toString() + ": stream management enabled");
-            streamId = null;
+                    account.getJid().asBareJid() + ": stream management enabled. resume at: " + streamId.location);
         }
         this.streamId = streamId;
         this.stanzasReceived = 0;
@@ -1363,7 +1400,7 @@ public class XmppConnection implements Runnable {
                         account.getJid().asBareJid()
                                 + ": quick start in progress. ignoring features: "
                                 + XmlHelper.printElementNames(this.streamFeatures));
-                if (SaslMechanism.hashedToken(this.saslMechanism)) {
+                if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) {
                     return;
                 }
                 if (isFastTokenAvailable(
@@ -1421,7 +1458,7 @@ public class XmppConnection implements Runnable {
                                 + ": resuming after stanza #"
                                 + stanzasReceived);
             }
-            final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived);
+            final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived);
             this.mSmCatchupMessageCounter.set(0);
             this.mWaitingForSmCatchup.set(true);
             this.tagWriter.writeStanzaAsync(resume);
@@ -1448,7 +1485,8 @@ public class XmppConnection implements Runnable {
 
     private void authenticate() throws IOException {
         final boolean isSecure = isSecure();
-        if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) {authenticate(SaslMechanism.Version.SASL_2);
+        if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) {
+            authenticate(SaslMechanism.Version.SASL_2);
         } else if (isSecure && this.streamFeatures.hasChild("mechanisms", Namespace.SASL)) {
             authenticate(SaslMechanism.Version.SASL);
         } else {
@@ -1473,10 +1511,10 @@ public class XmppConnection implements Runnable {
         final Collection<ChannelBinding> channelBindings = ChannelBinding.of(cbElement);
         final SaslMechanism.Factory factory = new SaslMechanism.Factory(account);
         final SaslMechanism saslMechanism = factory.of(mechanisms, channelBindings, version, SSLSockets.version(this.socket));
-        this.saslMechanism = validate(saslMechanism, mechanisms);
+        this.validate(saslMechanism, mechanisms);
         final boolean quickStartAvailable;
-        final String firstMessage = this.saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket));
-        final boolean usingFast = SaslMechanism.hashedToken(this.saslMechanism);
+        final String firstMessage = saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket));
+        final boolean usingFast = SaslMechanism.hashedToken(saslMechanism);
         final Element authenticate;
         if (version == SaslMechanism.Version.SASL) {
             authenticate = new Element("auth", Namespace.SASL);
@@ -1484,6 +1522,7 @@ public class XmppConnection implements Runnable {
                 authenticate.setContent(firstMessage);
             }
             quickStartAvailable = false;
+            this.loginInfo = new LoginInfo(saslMechanism,version,Collections.emptyList());
         } else if (version == SaslMechanism.Version.SASL_2) {
             final Element inline = authElement.findChild("inline", Namespace.SASL_2);
             final boolean sm = inline != null && inline.hasChild("sm", Namespace.STREAM_MANAGEMENT);
@@ -1512,6 +1551,7 @@ public class XmppConnection implements Runnable {
                     return;
                 }
             }
+            this.loginInfo = new LoginInfo(saslMechanism,version,bindFeatures);
             this.hashTokenRequest = hashTokenRequest;
             authenticate = generateAuthenticationRequest(firstMessage, usingFast, hashTokenRequest, bindFeatures, sm);
         } else {
@@ -1528,8 +1568,8 @@ public class XmppConnection implements Runnable {
                         + ": Authenticating with "
                         + version
                         + "/"
-                        + this.saslMechanism.getMechanism());
-        authenticate.setAttribute("mechanism", this.saslMechanism.getMechanism());
+                        + LoginInfo.mechanism(this.loginInfo).getMechanism());
+        authenticate.setAttribute("mechanism", LoginInfo.mechanism(this.loginInfo).getMechanism());
         synchronized (this.mStanzaQueue) {
             this.stanzasSentBeforeAuthentication = this.stanzasSent;
             tagWriter.writeElement(authenticate);
@@ -1541,8 +1581,7 @@ public class XmppConnection implements Runnable {
         return inline != null && inline.hasChild("fast", Namespace.FAST);
     }
 
-    @NonNull
-    private SaslMechanism validate(final @Nullable SaslMechanism saslMechanism, Collection<String> mechanisms) throws StateChangingException {
+    private void validate(final @Nullable SaslMechanism saslMechanism, Collection<String> mechanisms) throws StateChangingException {
         if (saslMechanism == null) {
             Log.d(
                     Config.LOGTAG,
@@ -1552,7 +1591,7 @@ public class XmppConnection implements Runnable {
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
         if (SaslMechanism.hashedToken(saslMechanism)) {
-            return saslMechanism;
+            return;
         }
         final int pinnedMechanism = account.getPinnedMechanismPriority();
         if (pinnedMechanism > saslMechanism.getPriority()) {
@@ -1567,7 +1606,6 @@ public class XmppConnection implements Runnable {
                             + "). Possible downgrade attack?");
             throw new StateChangingException(Account.State.DOWNGRADE_ATTACK);
         }
-        return saslMechanism;
     }
 
     private Element generateAuthenticationRequest(final String firstMessage, final boolean usingFast) {
@@ -1594,13 +1632,14 @@ public class XmppConnection implements Runnable {
                     .addChild("device")
                     .setContent(String.format("%s %s", Build.MANUFACTURER, Build.MODEL));
         }
-        // do not include bind if 'inlinestreamManagment' is missing and we have a streamId
+        // do not include bind if 'inlineStreamManagement' is missing and we have a streamId
+        // (because we would rather just do a normal SM/resume)
         final boolean mayAttemptBind = streamId == null || inlineStreamManagement;
         if (bind != null && mayAttemptBind) {
             authenticate.addChild(generateBindRequest(bind));
         }
         if (inlineStreamManagement && streamId != null) {
-            final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived);
+            final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived);
             this.mSmCatchupMessageCounter.set(0);
             this.mWaitingForSmCatchup.set(true);
             authenticate.addChild(resume);
@@ -1772,7 +1811,7 @@ public class XmppConnection implements Runnable {
         synchronized (this.commands) {
             this.commands.clear();
         }
-        this.saslMechanism = null;
+        this.loginInfo = null;
     }
 
     private void sendBindRequest() {
@@ -2268,7 +2307,7 @@ public class XmppConnection implements Runnable {
                 && quickStartMechanism != null
                 && account.isOptionSet(Account.OPTION_QUICKSTART_AVAILABLE)) {
             mXmppConnectionService.restoredFromDatabaseLatch.await();
-            this.saslMechanism = quickStartMechanism;
+            this.loginInfo = new LoginInfo(quickStartMechanism, SaslMechanism.Version.SASL_2, Bind2.QUICKSTART_FEATURES);
             final boolean usingFast = quickStartMechanism instanceof HashedToken;
             final Element authenticate =
                     generateAuthenticationRequest(quickStartMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)), usingFast);
@@ -2298,7 +2337,7 @@ public class XmppConnection implements Runnable {
         }
         stream.setAttribute("version", "1.0");
         stream.setAttribute("xml:lang", LocalizedContent.STREAM_LANGUAGE);
-        stream.setAttribute("xmlns", "jabber:client");
+        stream.setAttribute("xmlns", Namespace.JABBER_CLIENT);
         stream.setAttribute("xmlns:stream", Namespace.STREAMS);
         tagWriter.writeTag(stream, flush);
     }
@@ -2681,6 +2720,49 @@ public class XmppConnection implements Runnable {
         }
     }
 
+    private static class LoginInfo {
+        public final SaslMechanism saslMechanism;
+        public final SaslMechanism.Version saslVersion;
+        public final List<String> inlineBindFeatures;
+
+        private LoginInfo(
+                final SaslMechanism saslMechanism,
+                final SaslMechanism.Version saslVersion,
+                final Collection<String> inlineBindFeatures) {
+            Preconditions.checkNotNull(saslMechanism, "SASL Mechanism must not be null");
+            Preconditions.checkNotNull(saslVersion, "SASL version must not be null");
+            this.saslMechanism = saslMechanism;
+            this.saslVersion = saslVersion;
+            this.inlineBindFeatures =
+                    inlineBindFeatures == null
+                            ? Collections.emptyList()
+                            : ImmutableList.copyOf(inlineBindFeatures);
+        }
+
+        public static SaslMechanism mechanism(final LoginInfo loginInfo) {
+            return loginInfo == null ? null : loginInfo.saslMechanism;
+        }
+    }
+
+    private static class StreamId {
+        public final String id;
+        public final Resolver.Result location;
+
+        private StreamId(String id, Resolver.Result location) {
+            this.id = id;
+            this.location = location;
+        }
+
+        @NonNull
+        @Override
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("id", id)
+                    .add("location", location)
+                    .toString();
+        }
+    }
+
     private static class StateChangingError extends Error {
         private final Account.State state;
 

src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java 🔗

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.xmpp.bind;
 
+import com.google.common.base.Predicates;
 import com.google.common.collect.Collections2;
 
 import java.util.Arrays;
@@ -27,7 +28,12 @@ public class Bind2 {
         if (inlineBind2Inline == null) {
             return Collections.emptyList();
         }
-        return Collections2.transform(
-                inlineBind2Inline.getChildren(), c -> c == null ? null : c.getAttribute("var"));
+        return Collections2.filter(
+                Collections2.transform(
+                        Collections2.filter(
+                                inlineBind2Inline.getChildren(),
+                                c -> "feature".equals(c.getName())),
+                        c -> c.getAttribute("var")),
+                Predicates.notNull());
     }
 }

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 🔗

@@ -12,20 +12,6 @@ import com.google.common.collect.Collections2;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableSet;
 
-import java.lang.ref.WeakReference;
-import java.security.SecureRandom;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
@@ -39,18 +25,33 @@ import eu.siacs.conversations.services.XmppConnectionService;
 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.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;
 
+import java.lang.ref.WeakReference;
+import java.security.SecureRandom;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
 public class JingleConnectionManager extends AbstractConnectionManager {
     static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE =
             Executors.newSingleThreadScheduledExecutor();
@@ -63,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);
@@ -92,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)) {
@@ -170,8 +169,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
 
     public boolean hasJingleRtpConnection(final Account account) {
         for (AbstractJingleConnection connection : this.connections.values()) {
-            if (connection instanceof JingleRtpConnection) {
-                final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
+            if (connection instanceof JingleRtpConnection rtpConnection) {
                 if (rtpConnection.isTerminated()) {
                     continue;
                 }
@@ -185,8 +183,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
 
     public void notifyPhoneCallStarted() {
         for (AbstractJingleConnection connection : connections.values()) {
-            if (connection instanceof JingleRtpConnection) {
-                final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
+            if (connection instanceof JingleRtpConnection rtpConnection) {
                 if (rtpConnection.isTerminated()) {
                     continue;
                 }
@@ -195,7 +192,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         }
     }
 
-
     private Optional<RtpSessionProposal> findMatchingSessionProposal(
             final Account account, final Jid with, final Set<Media> media) {
         synchronized (this.rtpSessionProposals) {
@@ -220,8 +216,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
 
     private String hasMatchingRtpSession(final Account account, final Jid with, final Set<Media> media) {
         for (AbstractJingleConnection connection : this.connections.values()) {
-            if (connection instanceof JingleRtpConnection) {
-                final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
+            if (connection instanceof JingleRtpConnection rtpConnection) {
                 if (rtpConnection.isTerminated()) {
                     continue;
                 }
@@ -281,8 +276,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         }
         if ("accept".equals(message.getName()) || "reject".equals(message.getName())) {
             for (AbstractJingleConnection connection : connections.values()) {
-                if (connection instanceof JingleRtpConnection) {
-                    final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
+                if (connection instanceof JingleRtpConnection rtpConnection) {
                     final AbstractJingleConnection.Id id = connection.getId();
                     if (id.account == account && id.sessionId.equals(sessionId)) {
                         rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
@@ -599,13 +593,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) {
@@ -644,15 +635,16 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         final AbstractJingleConnection.Id id = connection.getId();
         if (this.connections.remove(id) == null) {
             throw new IllegalStateException(
-                    String.format("Unable to finish connection with id=%s", id.toString()));
+                    String.format("Unable to finish connection with id=%s", id));
         }
+        // update chat UI to remove 'ongoing call' icon
+        mXmppConnectionService.updateConversationUi();
     }
 
     public boolean fireJingleRtpConnectionStateUpdates() {
         boolean firedUpdates = false;
         for (final AbstractJingleConnection connection : this.connections.values()) {
-            if (connection instanceof JingleRtpConnection) {
-                final JingleRtpConnection jingleRtpConnection = (JingleRtpConnection) connection;
+            if (connection instanceof JingleRtpConnection jingleRtpConnection) {
                 if (jingleRtpConnection.isTerminated()) {
                     continue;
                 }
@@ -663,73 +655,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())) {
-            final Jid proxy =
-                    account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS);
-            if (proxy != null) {
-                IqPacket iq = new IqPacket(IqPacket.TYPE.GET);
-                iq.setTo(proxy);
-                iq.query(Namespace.BYTE_STREAMS);
-                account.getXmppConnection()
-                        .sendIqPacket(
-                                iq,
-                                new OnIqPacketReceived() {
-
-                                    @Override
-                                    public void onIqPacketReceived(
-                                            Account account, IqPacket packet) {
-                                        final Element streamhost =
-                                                packet.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(
-                                                        account.getJid().asBareJid(), candidate);
-                                                listener.onPrimaryCandidateFound(true, candidate);
-                                            } catch (final NumberFormatException e) {
-                                                listener.onPrimaryCandidateFound(false, null);
-                                            }
-                                        } else {
-                                            listener.onPrimaryCandidateFound(false, null);
-                                        }
-                                    }
-                                });
-            } else {
-                listener.onPrimaryCandidateFound(false, null);
-            }
-
-        } else {
-            listener.onPrimaryCandidateFound(
-                    true, this.primaryCandidates.get(account.getJid().asBareJid()));
-        }
-    }
-
     public void retractSessionProposal(final Account account, final Jid with) {
         synchronized (this.rtpSessionProposals) {
             RtpSessionProposal matchingProposal = null;
@@ -831,40 +756,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) {
-                    final JingleFileTransferConnection fileTransfer =
-                            (JingleFileTransferConnection) connection;
-                    final JingleTransport transport = fileTransfer.getTransport();
-                    if (transport instanceof JingleInBandTransport) {
-                        final JingleInBandTransport inBandTransport =
-                                (JingleInBandTransport) transport;
-                        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.toString());
+        Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet with sid="+sid);
         account.getXmppConnection()
                 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
     }
@@ -970,7 +908,8 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         }
     }
 
-    public void failProceed(Account account, final Jid with, final String sessionId, final String message) {
+    public void failProceed(
+            Account account, final Jid with, final String sessionId, final String message) {
         final AbstractJingleConnection.Id id =
                 AbstractJingleConnection.Id.of(account, with, sessionId);
         final AbstractJingleConnection existingJingleConnection = connections.get(id);
@@ -1044,15 +983,11 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         FAILED;
 
         public RtpEndUserState toEndUserState() {
-            switch (this) {
-                case SEARCHING:
-                case SEARCHING_ACKNOWLEDGED:
-                    return RtpEndUserState.FINDING_DEVICE;
-                case DISCOVERED:
-                    return RtpEndUserState.RINGING;
-                default:
-                    return RtpEndUserState.CONNECTIVITY_ERROR;
-            }
+            return switch (this) {
+                case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE;
+                case DISCOVERED -> RtpEndUserState.RINGING;
+                default -> RtpEndUserState.CONNECTIVITY_ERROR;
+            };
         }
     }
 
@@ -1062,10 +997,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         public final Set<Media> media;
         private final Account account;
 
-        private RtpSessionProposal(Account account, Jid with, String sessionId) {
-            this(account, with, sessionId, Collections.emptySet());
-        }
-
         private RtpSessionProposal(Account account, Jid with, String sessionId, Set<Media> media) {
             this.account = account;
             this.with = with;

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java 🔗

@@ -1,1279 +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.services.XmppConnectionService;
-import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.utils.MimeUtils;
-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) {
-            DownloadableFile finalFile;
-
-            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();
-
-                final String extension = MimeUtils.extractRelevantExtension(file.getName());
-                try {
-                    xmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(file), extension);
-                    finalFile = xmppConnectionService.getFileBackend().getFile(message);
-                    boolean didRename = file.renameTo(finalFile);
-                    if (!didRename) throw new IOException("rename failed");
-                } catch (final IOException e) {
-                    finalFile = file;
-                    message.setRelativeFilePath(finalFile.getAbsolutePath());
-                } catch (final XmppConnectionService.BlockedMediaException e) {
-                    finalFile = file;
-                    file.delete();
-                    message.setRelativeFilePath(null);
-                    message.setDeleted(true);
-                }
-
-                xmppConnectionService.getFileBackend().updateFileParams(message, null, false);
-                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 {
-                finalFile = file;
-
-                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) {
-                    finalFile.delete();
-                }
-                disconnectSocks5Connections();
-            }
-            Log.d(Config.LOGTAG, "successfully transmitted file:" + finalFile.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
-            if (message.getEncryption() != Message.ENCRYPTION_PGP) {
-                xmppConnectionService.getFileBackend().updateMediaScanner(finalFile);
-            }
-        }
+                },
+                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());
+    }
 
-        this.description = (FileTransferDescription) content.getDescription();
+    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);
+        }
+    }
 
-        final Element fileOffer = this.description.getFileOffer();
+    private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
+        Log.d(Config.LOGTAG, "received checksum " + checksum);
+    }
 
-        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.FileParams fp = new Message.FileParams();
-                fp.size = new Long(size);
-                message.setFileParams(fp);
-                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");
-                    }
-                }
-                //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 receiveSessionInfoReceived(final FileTransferDescription.Received received) {
+        Log.d(Config.LOGTAG, "peer confirmed received " + received);
+    }
+
+    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);
-            }
+    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 {
+            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)) {
+            final var group = jinglePacket.getGroup();
+            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
         } else {
-            this.file.setExpectedSize(file.getSize());
-            description = FileTransferDescription.of(this.file, version, null);
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT);
         }
-        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);
+    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");
         }
-        content.setDescription(this.description);
+    }
+
+    private void receiveTransportInfo(final JinglePacket jinglePacket) {
+        final FileTransferContentMap contentMap;
+        final GenericTransportInfo transportInfo;
         try {
-            this.mFileInputStream = new FileInputStream(file);
-        } catch (FileNotFoundException e) {
-            fail(e.getMessage());
+            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;
         }
-        if (this.initialTransport == IbbTransportInfo.class) {
-            content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
-            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending IBB offer");
+        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 {
-            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));
-            }
-        });
-
+            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
+        }
     }
 
-    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 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);
+        }
+    }
 
-        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 WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
+        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
+        final var iceCandidates =
+                WebRTCDataChannelTransport.iceCandidatesOf(
+                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
+        this.pendingIncomingIceCandidates.addAll(iceCandidates);
     }
 
-    private Collection<JingleCandidate> getOurCandidates() {
-        return Collections2.filter(this.candidates, c -> c != null && c.isOurs());
+    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 sendAccept() {
-        mJingleStatus = JINGLE_STATUS_ACCEPTED;
-        this.mStatus = Transferable.STATUS_DOWNLOADING;
-        this.jingleConnectionManager.updateConversationUi(true);
-        if (initialTransport == S5BTransportInfo.class) {
-            sendAcceptSocks();
+    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 {
-            sendAcceptIbb();
+            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE);
         }
     }
 
-    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, 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 🔗

@@ -14,7 +14,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;
@@ -31,14 +30,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;
@@ -82,96 +77,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;
@@ -197,18 +109,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()) {
@@ -238,7 +138,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(
@@ -331,7 +231,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 =
@@ -531,7 +431,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(
@@ -605,7 +505,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,
@@ -824,7 +724,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");
@@ -890,7 +790,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,
@@ -1075,7 +975,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
@@ -1104,14 +1004,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
@@ -1213,21 +1113,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);
@@ -1309,13 +1195,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 =
@@ -1500,7 +1381,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(
@@ -1537,11 +1418,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
                                             + candidates.size()
                                             + " candidates in session accept");
                             sendSessionAccept(outgoingContentMap.withCandidates(candidates));
-                            webRTCWrapper.resetPendingCandidates();
                         } else {
                             sendSessionAccept(outgoingContentMap);
-                            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                         }
+                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                     }
 
                     @Override
@@ -2003,11 +1883,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
                                             + " candidates in session initiate");
                             sendSessionInitiate(
                                     outgoingContentMap.withCandidates(candidates), targetState);
-                            webRTCWrapper.resetPendingCandidates();
                         } else {
                             sendSessionInitiate(outgoingContentMap, targetState);
-                            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                         }
+                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                     }
 
                     @Override
@@ -2072,24 +1951,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;
@@ -2110,110 +1981,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 -> {
@@ -2409,7 +2176,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;
         }
@@ -2538,22 +2305,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;
@@ -2562,13 +2317,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 =
@@ -2904,98 +2652,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,
@@ -3012,13 +2669,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();
             try {
                 File log = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Cheogram/calls/" + id.getWith().asBareJid() + "." + id.getSessionId() + "." + created + ".log");
                 log.getParentFile().mkdirs();
@@ -3061,14 +2724,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();
     }
@@ -3107,17 +2762,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;
@@ -32,19 +31,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 {
@@ -52,12 +49,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;
             }
@@ -68,13 +68,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);
@@ -95,26 +95,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);
     }
@@ -123,7 +103,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
@@ -147,31 +128,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) {
@@ -184,7 +144,7 @@ public class RtpContentMap {
                 null,
                 ImmutableMap.of(
                         contentName,
-                        new DescriptionTransport(
+                        new DescriptionTransport<>(
                                 descriptionTransport.senders, null, newTransportInfo)));
     }
 
@@ -194,21 +154,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))));
@@ -248,7 +211,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(
@@ -288,7 +251,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;
@@ -301,17 +264,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());
@@ -322,16 +287,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) {
@@ -340,7 +307,7 @@ public class RtpContentMap {
                 checkSenderModification(isInitiator, currentSenders, targetSenders);
                 contentMapBuilder.put(
                         id,
-                        new DescriptionTransport(
+                        new DescriptionTransport<>(
                                 targetSenders,
                                 descriptionTransport.description,
                                 descriptionTransport.transport));
@@ -387,7 +354,7 @@ public class RtpContentMap {
                 Maps.transformValues(
                         this.contents,
                         dt ->
-                                new DescriptionTransport(
+                                new DescriptionTransport<>(
                                         dt.senders,
                                         RtpDescription.stub(dt.description.getMedia()),
                                         IceUdpTransportInfo.STUB)));
@@ -416,120 +383,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 🔗

@@ -50,6 +50,7 @@ import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import javax.annotation.Nonnull;
@@ -429,7 +430,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);
@@ -481,11 +482,6 @@ public class WebRTCWrapper {
                 "setIsReadyToReceiveCandidates(" + ready + ") was=" + was + " is=" + is);
     }
 
-    public void resetPendingCandidates() {
-        this.readyToReceivedIceCandidates.set(true);
-        this.iceCandidates.clear();
-    }
-
     synchronized void close() {
         final PeerConnection peerConnection = this.peerConnection;
         final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
@@ -616,7 +612,8 @@ public class WebRTCWrapper {
         throw new IllegalStateException("Local video track does not exist");
     }
 
-    synchronized ListenableFuture<SessionDescription> setLocalDescription(final boolean waitForCandidates) {
+    synchronized ListenableFuture<SessionDescription> setLocalDescription(
+            final boolean waitForCandidates) {
         this.setIsReadyToReceiveIceCandidates(false);
         return Futures.transformAsync(
                 getPeerConnectionFuture(),
@@ -630,16 +627,20 @@ public class WebRTCWrapper {
                             new SetSdpObserver() {
                                 @Override
                                 public void onSetSuccess() {
-                                    final var delay =
-                                            waitForCandidates
-                                                    ? Futures.catching(Futures.withTimeout(iceGatheringComplete, 2, TimeUnit.SECONDS, JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE), Exception.class, (Exception e) -> { return null; }, MoreExecutors.directExecutor())
-                                                    : Futures.immediateVoidFuture();
-                                    final var delayedSessionDescription =
-                                            Futures.transformAsync(
-                                                    delay,
-                                                    v -> getLocalDescriptionFuture(),
-                                                    MoreExecutors.directExecutor());
-                                    future.setFuture(delayedSessionDescription);
+                                    if (waitForCandidates) {
+                                        final var delay = getIceGatheringCompleteOrTimeout();
+                                        final var delayedSessionDescription =
+                                                Futures.transformAsync(
+                                                        delay,
+                                                        v -> {
+                                                            iceCandidates.clear();
+                                                            return getLocalDescriptionFuture();
+                                                        },
+                                                        MoreExecutors.directExecutor());
+                                        future.setFuture(delayedSessionDescription);
+                                    } else {
+                                        future.setFuture(getLocalDescriptionFuture());
+                                    }
                                 }
 
                                 @Override
@@ -653,6 +654,23 @@ public class WebRTCWrapper {
                 MoreExecutors.directExecutor());
     }
 
+    private ListenableFuture<Void> getIceGatheringCompleteOrTimeout() {
+        return Futures.catching(
+                Futures.withTimeout(
+                        iceGatheringComplete,
+                        2,
+                        TimeUnit.SECONDS,
+                        JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE),
+                TimeoutException.class,
+                ex -> {
+                    Log.d(
+                            EXTENDED_LOGGING_TAG,
+                            "timeout while waiting for ICE gathering to complete");
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
     private ListenableFuture<SessionDescription> getLocalDescriptionFuture() {
         return Futures.submit(
                 () -> {
@@ -801,7 +819,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) {
@@ -827,12 +845,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;
@@ -198,7 +200,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");
@@ -399,7 +401,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 : getChildren()) {
             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 : getChildren()) {
+            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,901 @@
+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.ExecutionException;
+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, selectedByThemCandidate, 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, null, 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 ListenableFuture<Connection> selectedByThemCandidate;
+        private final boolean useTor;
+
+        private ConnectionFinder(
+                final ImmutableList<Candidate> candidates,
+                final String destination,
+                final ListenableFuture<Connection> selectedByThemCandidate,
+                final boolean useTor) {
+            this.candidates = candidates;
+            this.destination = destination;
+            this.selectedByThemCandidate = selectedByThemCandidate;
+            this.useTor = useTor;
+        }
+
+        @Override
+        public void run() {
+            for (final Candidate candidate : this.candidates) {
+                final Integer selectedByThemCandidatePriority =
+                        getSelectedByThemCandidatePriority();
+                if (selectedByThemCandidatePriority != null
+                        && selectedByThemCandidatePriority > candidate.priority) {
+                    Log.d(
+                            Config.LOGTAG,
+                            "The candidate selected by peer had a higher priority then anything we could try");
+                    connectionFuture.setException(
+                            new CandidateErrorException(
+                                    "The candidate selected by peer had a higher priority then anything we could try"));
+                    return;
+                }
+                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);
+        }
+
+        private Integer getSelectedByThemCandidatePriority() {
+            final var future = this.selectedByThemCandidate;
+            if (future != null && future.isDone()) {
+                try {
+                    final var connection = future.get();
+                    return connection.candidate.priority;
+                } catch (ExecutionException | InterruptedException e) {
+                    return null;
+                }
+            } else {
+                return null;
+            }
+        }
+    }
+
+    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() {}
+    }
+}

src/main/res/values-bg/strings.xml 🔗

@@ -31,10 +31,7 @@
     <string name="minutes_ago">преди %d минути</string>
     <plurals name="x_unread_conversations">
         <item quantity="one">%d непрочетен разговор</item>
-
-    
         <item quantity="other">%d непрочетени разговора</item>
-
     </plurals>
     <string name="sending">изпращане…</string>
     <string name="message_decrypting">Дешифроване на съобщението. Моля, изчакайте…</string>
@@ -519,7 +516,9 @@
     <string name="large_images_only">Само за големи изображения</string>
     <string name="battery_optimizations_enabled">Оптимизациите за използв. на батерията са вкл.</string>
     <string name="battery_optimizations_enabled_explained">Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения.\nПрепоръчително е да ги изключите.</string>
-    <string name="battery_optimizations_enabled_dialog">Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения.\nСега ще бъдете помолен(а) да ги изключите.</string>
+    <string name="battery_optimizations_enabled_dialog">Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения.
+\n
+\nСега ще бъдете помолен(а) да ги изключите.</string>
     <string name="disable">Изключване</string>
     <string name="selection_too_large">Избраната област е твърде голяма</string>
     <string name="no_accounts">(Няма активирани профили)</string>
@@ -953,4 +952,4 @@
     <string name="backup_started_message">Създаването на резервно копие е стартирано. Ще получите известие, когато приключи.</string>
     <string name="unable_to_enable_video">Видеото не може да бъде включено.</string>
     <string name="plain_text_document">Обикновен текстов документ</string>
-    </resources>
+</resources>

src/main/res/values-de/strings.xml 🔗

@@ -128,7 +128,7 @@
     <string name="pref_notification_grace_period_summary">Die Zeitspanne, in der Benachrichtigungen nach der Erkennung von Aktivitäten auf einem deiner anderen Geräte unterdrückt werden.</string>
     <string name="pref_advanced_options">Erweitert</string>
     <string name="pref_never_send_crash">Niemals Absturzberichte senden</string>
-    <string name="pref_never_send_crash_summary">Mit dem Einsenden von Absturzberichten hilfst du bei der Weiterentwicklung</string>
+    <string name="pref_never_send_crash_summary">Durch das Einsenden von Absturzberichten hilfst du bei der Weiterentwicklung</string>
     <string name="pref_confirm_messages">Lese- und Empfangsbestätigung senden</string>
     <string name="pref_confirm_messages_summary">Informiere deine Kontakte, wenn du eine Nachricht empfangen und gelesen hast</string>
     <string name="pref_prevent_screenshots">Screenshots verhindern</string>
@@ -264,7 +264,7 @@
     <string name="error_saving_avatar">Profilbild kann nicht gespeichert werden</string>
     <string name="or_long_press_for_default">(Oder klicke lange, um den Standard wiederherzustellen)</string>
     <string name="error_publish_avatar_no_server_support">Dein Server unterstützt die Veröffentlichung von Profilbildern nicht</string>
-    <string name="private_message">private Nachricht:</string>
+    <string name="private_message">private Nachricht</string>
     <string name="private_message_to">an %s</string>
     <string name="send_private_message_to">Private Nachricht an %s senden</string>
     <string name="connect">Verbinden</string>
@@ -534,7 +534,7 @@
     <string name="this_field_is_required">Dieses Feld ist erforderlich</string>
     <string name="correct_message">Nachricht korrigieren</string>
     <string name="send_corrected_message">Korrigierte Nachricht senden</string>
-    <string name="no_keys_just_confirm">Du hast den Fingerabdruck dieser Person bereits sicher verifiziert, um das Vertrauen zu bestätigen. Durch Auswählen von \"Fertig\" bestätigst du, dass %s Teil dieses Gruppenchats ist.</string>
+    <string name="no_keys_just_confirm">Du hast den Fingerabdruck dieser Person bereits verifiziert. Durch Auswählen von \"Fertig\" bestätigst du nur, dass %s Teil dieses Gruppenchats ist.</string>
     <string name="this_account_is_disabled">Du hast dieses Konto deaktiviert</string>
     <string name="security_error_invalid_file_access">Sicherheitsfehler: Dateizugriff nicht erlaubt!</string>
     <string name="no_application_to_share_uri">Keine App zum Teilen der URI gefunden</string>
@@ -905,7 +905,7 @@
     <string name="rtp_state_incoming_video_call">Eingehender Videoanruf</string>
     <string name="rtp_state_content_add_video">Umschalten auf Videoanruf?</string>
     <string name="rtp_state_content_add">Zusätzliche Audiospuren hinzufügen?</string>
-    <string name="rtp_state_connecting">Verbinden</string>
+    <string name="rtp_state_connecting">Verbindet</string>
     <string name="rtp_state_connected">Verbunden</string>
     <string name="rtp_state_reconnecting">Erneut verbinden</string>
     <string name="rtp_state_accepting_call">Anruf annehmen</string>
@@ -1017,5 +1017,7 @@
     <string name="contact_uses_unverified_keys">Dein Kontakt verwendet nicht verifizierte Geräte. Scanne deren 2D-Barcode, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern.</string>
     <string name="log_out">Abmelden</string>
     <string name="account_state_logged_out">Abgemeldet</string>
-    <string name="unverified_devices">Du verwendest nicht verifizierte Geräte. Scanne die 2D-Barcodes deiner anderen Geräte, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern.</string>
+    <string name="unverified_devices">Du verwendest nicht verifizierte Geräte. Scanne den 2D-Barcode auf deinen anderen Geräten, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern.</string>
+    <string name="report_spam">Spam melden</string>
+    <string name="report_spam_and_block">Spam melden und Spammer blockieren</string>
 </resources>

src/main/res/values-es/strings.xml 🔗

@@ -1032,4 +1032,6 @@
     <string name="log_out">Desconectarse</string>
     <string name="account_state_logged_out">Desconectado</string>
     <string name="unverified_devices">Está utilizando dispositivos no verificados. Escanea el código de barras 2D en tus otros dispositivos para realizar la verificación e impedir los ataques MITM activos.</string>
+    <string name="report_spam_and_block">Informar de spam y bloquear al spammer</string>
+    <string name="report_spam">Informar sobre spam</string>
 </resources>

src/main/res/values-fi/strings.xml 🔗

@@ -30,13 +30,10 @@
     <string name="minutes_ago">%d minuuttia sitten</string>
     <plurals name="x_unread_conversations">
         <item quantity="one">%d lukematon keskustelu</item>
-
-    
         <item quantity="other">%d lukematonta keskustelua</item>
-
     </plurals>
-    <string name="sending">lähettää...</string>
-    <string name="message_decrypting">Puretaan viestin salausta. Odota hetki...</string>
+    <string name="sending">lähettää…</string>
+    <string name="message_decrypting">Puretaan viestin salausta. Odota hetki…</string>
     <string name="pgp_message">OpenPGP-salattu viesti</string>
     <string name="nick_in_use">Nimimerkki on jo käytössä</string>
     <string name="invalid_muc_nick">Nimimerkki on virheellinen</string>
@@ -82,12 +79,14 @@
     <string name="send_failed">toimitus epäonnistui</string>
     <string name="preparing_image">Valmistaudutaan lähettämään kuva</string>
     <string name="preparing_images">Valmistaudutaan lähettämään kuvat</string>
-    <string name="sharing_files_please_wait">Jaetaan tiedostoja. Odota hetki...</string>
+    <string name="sharing_files_please_wait">Jaetaan tiedostoja. Odota hetki…</string>
     <string name="action_clear_history">Pyyhi historia</string>
     <string name="clear_conversation_history">Pyyhi keskusteluhistoria</string>
     <string name="clear_histor_msg">Poistetaanko kaikki keskustelun viestit?\n\n<b>Varoitus:</b> Muilla laitteilla tai palvelimilla säilytettyjä kopioita ei poisteta.</string>
     <string name="delete_file_dialog">Poista tiedosto</string>
-    <string name="delete_file_dialog_msg">Haluatko varmasti poistaa tämän tiedoston?\n\n<b>Varoitus:</b> Muilla laitteilla tai palvelimilla olevia kopioita ei poisteta.</string>
+    <string name="delete_file_dialog_msg">Haluatko varmasti poistaa tämän tiedoston\?
+\n
+\n<b>Varoitus:</b> Muilla laitteilla tai palvelimilla olevia kopioita ei poisteta. </string>
     <string name="also_end_conversation">Päätä keskustelu myös</string>
     <string name="choose_presence">Valitse laite</string>
     <string name="send_unencrypted_message">Lähetä salaamaton viesti</string>
@@ -104,15 +103,15 @@
     <string name="restart">Käynnistä uudelleen</string>
     <string name="install">Asenna</string>
     <string name="openkeychain_not_installed">Asenna OpenKeychain</string>
-    <string name="offering">tarjotaan...</string>
-    <string name="waiting">odotetaan...</string>
+    <string name="offering">tarjotaan…</string>
+    <string name="waiting">odotetaan…</string>
     <string name="no_pgp_key">OpenPGP-avainta ei löydy</string>
     <string name="contact_has_no_pgp_key">Viestin salaaminen ei onnistu koska vastaanottaja ei mainosta julkista avaintaan.\n\n<small>Pyydä kontaktiasi ottamaan OpenPGP käyttöön.</small></string>
     <string name="no_pgp_keys">OpenPGP-avaimia ei löydy</string>
     <string name="contacts_have_no_pgp_keys">Viestin salaaminen ei onnistu koska kontaktisi eivät mainosta julkisia avaimiaan.\n\n<small>Pyydä heitä ottamaan OpenPGP käyttöön.</small></string>
     <string name="pref_general">Yleinen</string>
     <string name="pref_accept_files">Lataa tiedostot</string>
-    <string name="pref_accept_files_summary">Lataa automaattisesti tiedostot jotka ovat pienempiä kuin...</string>
+    <string name="pref_accept_files_summary">Lataa automaattisesti tiedostot jotka ovat pienempiä kuin…</string>
     <string name="pref_attachments">Liitteet</string>
     <string name="pref_notification_settings">Ilmoitus</string>
     <string name="pref_vibrate">Värinä</string>
@@ -154,7 +153,7 @@
     <string name="account_status_unknown">Tuntematon</string>
     <string name="account_status_disabled">Väliaikaisesti poistettu käytöstä</string>
     <string name="account_status_online">Paikalla</string>
-    <string name="account_status_connecting">Yhdistäää\u2026</string>
+    <string name="account_status_connecting">Yhdistää…</string>
     <string name="account_status_offline">Poissa</string>
     <string name="account_status_unauthorized">Ei sallittu</string>
     <string name="account_status_not_found">Palvelinta ei löydy</string>
@@ -179,7 +178,7 @@
     <string name="unpublish_pgp_message">Haluatko varmasti poistaa OpenPGP-avaimesi tilamainostuksistasi?\nYhteystietosi eivät voi enää lähettää sinulle OpenPGP-salattuja viestejä.</string>
     <string name="openpgp_has_been_published">OpenPGP julkinen avain julkaistu.</string>
     <string name="mgmt_account_enable">Ota tunnus käyttöön</string>
-    <string name="mgmt_account_delete_confirm_text">Tilin poistaminen pyyhkii koko keskusteluhistoriasi</string>
+    <string name="mgmt_account_delete_confirm_text">Haluatko varmasti poistaa tilisi\? Tilin poistaminen pyyhkii koko keskusteluhistoriasi</string>
     <string name="attach_record_voice">Nauhoita ääntä</string>
     <string name="account_settings_jabber_id">XMPP-osoite</string>
     <string name="block_jabber_id">Estä XMPP-osoite</string>
@@ -218,7 +217,7 @@
     <string name="omemo_fingerprint_x509_selected_message">v\\OMEMO-sormenjälki (viestin lähettäjä)</string>
     <string name="other_devices">Muut laitteet</string>
     <string name="trust_omemo_fingerprints">Luota OMEMO-sormenjälkiin</string>
-    <string name="fetching_keys">Haetaan avaimia...</string>
+    <string name="fetching_keys">Haetaan avaimia…</string>
     <string name="done">Valmis</string>
     <string name="decrypt">Pura salaus</string>
     <string name="search">Haku</string>
@@ -243,7 +242,7 @@
     <string name="could_not_destroy_channel">Kanavan tuhoaminen epäonnistui</string>
     <string name="action_edit_subject">Muokkaa ryhmäkeskustelun aihetta</string>
     <string name="topic">Aihe</string>
-    <string name="joining_conference">Liitytään ryhmäkeskusteluun...</string>
+    <string name="joining_conference">Liitytään ryhmäkeskusteluun…</string>
     <string name="leave">Poistu</string>
     <string name="contact_added_you">Yhteystieto lisätty luetteloon</string>
     <string name="add_back">Lisää takaisin</string>
@@ -253,7 +252,7 @@
     <string name="everyone_has_read_up_to_this_point">Kaikki ovat lukeneet tähän asti</string>
     <string name="publish">Julkaise</string>
     <string name="touch_to_choose_picture">Napauta profiilikuvaa valitaksesi kuvan galleriasta</string>
-    <string name="publishing">Julkaistaan...</string>
+    <string name="publishing">Julkaistaan…</string>
     <string name="error_publish_avatar_server_reject">Palvelin hylkäsi julkaisusi</string>
     <string name="error_publish_avatar_converting">Kuvan muuntaminen epäonnistui</string>
     <string name="error_saving_avatar">Profiilikuvan tallentaminen levylle epäonnistui</string>
@@ -355,7 +354,7 @@
     <string name="error_trustkeys_title">Jokin meni pieleen</string>
     <string name="fetching_history_from_server">Haetaan historiaa palvelimelta</string>
     <string name="no_more_history_on_server">Palvelimella ei ollut enempää historiaa</string>
-    <string name="updating">Päivitetään...</string>
+    <string name="updating">Päivitetään…</string>
     <string name="password_changed">Salasana vaihdettu!</string>
     <string name="could_not_change_password">Salasanan vaihto epäonnistui</string>
     <string name="change_password">Vaihda salasana</string>
@@ -410,9 +409,9 @@
     <string name="sending_x_file">Lähetetään %s</string>
     <string name="offering_x_file">Tarjotaan %s</string>
     <string name="hide_offline">Piilota poissaolevat</string>
-    <string name="contact_is_typing">%s kirjoittaa...</string>
+    <string name="contact_is_typing">%s kirjoittaa…</string>
     <string name="contact_has_stopped_typing">%s lopetti kirjoittamisen</string>
-    <string name="contacts_are_typing">%s kirjoittavat...</string>
+    <string name="contacts_are_typing">%s kirjoittavat…</string>
     <string name="contacts_have_stopped_typing">%s lopettivat kirjoittamisen</string>
     <string name="pref_chat_states">Kirjoitusilmoitukset</string>
     <string name="pref_chat_states_summary">Näyttää ytheystiedoillesi kun kirjoitat heille viestiä</string>
@@ -454,7 +453,7 @@
     <string name="pref_away_when_screen_off">Poissa kun laite on lukittu</string>
     <string name="pref_away_when_screen_off_summary">Näytä minut poissaolevana kun näyttö on lukittu</string>
     <string name="pref_dnd_on_silent_mode">Kiireinen kun laite on äänetön</string>
-    <string name="pref_dnd_on_silent_mode_summary">Näytä minut kiireisenä kun laite on äänettömänäq</string>
+    <string name="pref_dnd_on_silent_mode_summary">Näytä minut kiireisenä kun laite on äänettömänä</string>
     <string name="pref_treat_vibrate_as_silent">Kohtele vain värinä -tilaa äänettömän lailla</string>
     <string name="pref_treat_vibrate_as_dnd_summary">Näytä minut kiireisenä kun laite on vain värinä -tilassa</string>
     <string name="pref_show_connection_options">Laajemmat yhteysasetukset</string>
@@ -464,7 +463,7 @@
     <string name="unable_to_parse_certificate">Varmenteen jäsennys epäonnistui</string>
     <string name="mam_prefs">Arkistointiasetukset</string>
     <string name="server_side_mam_prefs">Palvelimen arkitsointiasetukset</string>
-    <string name="fetching_mam_prefs">Kysytään arkistointiasetuksia. Odota hetki...</string>
+    <string name="fetching_mam_prefs">Kysytään arkistointiasetuksia. Odota hetki…</string>
     <string name="unable_to_fetch_mam_prefs">Arkistointiasetusten haku epäonnistui</string>
     <string name="captcha_required">CAPTCHA vaaditaan</string>
     <string name="captcha_hint">Kirjoita ylläolevassa kuvassa näkyvä teksti</string>
@@ -510,10 +509,10 @@
     <string name="this_field_is_required">Tämä kenttä on pakollinen</string>
     <string name="correct_message">Korjaa viestiä</string>
     <string name="send_corrected_message">Lähetä korjattu viesti</string>
-    <string name="no_keys_just_confirm">Olet jo varmistanut tämän henkilön OMEMO-sormenjäljen turvallisesti luottamuksen varmistamikseksi. Hyväksymällä varmistat vain että %s on osa tätä ryhmäkeskustelua.</string>
+    <string name="no_keys_just_confirm">Olet jo varmistanut tämän henkilön OMEMO-sormenjäljen. Hyväksymällä vahvistat vain että %s on osa tätä ryhmäkeskustelua.</string>
     <string name="this_account_is_disabled">Olet poistanut tämän tilin käytöstä</string>
     <string name="no_application_to_share_uri">URI:n jakamiseen sopivaa sovellusta ei löytynyt</string>
-    <string name="share_uri_with">Jaa URI sovelluksella...</string>
+    <string name="share_uri_with">Jaa URI sovelluksella…</string>
     <string name="agree_and_continue">Hyväksy ja jatka</string>
     <string name="your_full_jid_will_be">XMPP-osoitteesi tulee olemaan kokonaisuudessaan: %s</string>
     <string name="create_account">Luo tunnus</string>
@@ -532,7 +531,7 @@
     <string name="registration_please_wait">Rekisteröinti epäonnistui: Yritä myöhemmin uudelleen</string>
     <string name="registration_password_too_weak">Rekisteröinti epäonnistui: Salasana on liian heikko</string>
     <string name="choose_participants">Valitse osallistujat</string>
-    <string name="creating_conference">Luodaan ryhmää...</string>
+    <string name="creating_conference">Luodaan ryhmää…</string>
     <string name="invite_again">Kutsu uudestaan</string>
     <string name="gp_disable">Poista käytöstä</string>
     <string name="gp_short">Lyhyt</string>
@@ -568,7 +567,8 @@
     <string name="show_error_message">Näytä virheilmoitus</string>
     <string name="error_message">Virheilmoitus</string>
     <string name="data_saver_enabled">Datansäästö käytössä</string>
-    <string name="data_saver_enabled_explained">Käyttöjärjestelmäsi estää %1$s:tä käyttämästä nettiä ollessaan taustalla. Vastaanottaaksesi ilmoitukset uusista viesteistä, salli %1$s:n käyttää esteettä verkkoa datansäästön ollessa käytössä. %1$s tekee silti parhaansa käyttääkseen mahdollisimman vähän dataa. </string>
+    <string name="data_saver_enabled_explained">Käyttöjärjestelmäsi estää %1$s:tä käyttämästä nettiä ollessaan taustalla. Vastaanottaaksesi ilmoitukset uusista viesteistä, salli %1$s:n käyttää esteettä verkkoa datansäästön ollessa käytössä.
+\n%1$s tekee silti parhaansa käyttääkseen mahdollisimman vähän dataa.</string>
     <string name="device_does_not_support_data_saver">Laitteesi ei tue datansäästön poistamista käytöstä sovellukselle %1$s.</string>
     <string name="error_unable_to_create_temporary_file">Väliaikaisen tiedoston luominen epäonnistui</string>
     <string name="this_device_has_been_verified">Laite on varmennettu</string>
@@ -650,7 +650,7 @@
     <string name="mtm_accept_cert">Hyväksytäänkö tuntematon varmenne?</string>
     <string name="mtm_trust_anchor">Palvelimen varmenne ei ole luotetun myöntäjän allekirjoittama.</string>
     <string name="mtm_accept_servername">Hyväksytäänkö eriävä palvelimen nimi?</string>
-    <string name="mtm_hostname_mismatch">Palvelin ei voinut tunnistautua olevansa verkkotunnusta \&quot;%s\&quot;. Varmenne sisältää vain seuraavat verkkotunnukset:</string>
+    <string name="mtm_hostname_mismatch">Palvelin ei voinut tunnistautua olevansa verkkotunnusta \"%s\". Varmenne sisältää vain seuraavat verkkotunnukset:</string>
     <string name="mtm_connect_anyway">Haluatko yhdistää joka tapauksessa?</string>
     <string name="mtm_cert_details">Varmenteen tiedot:</string>
     <string name="once">Kerran</string>
@@ -668,7 +668,7 @@
     <string name="disable_now">Poista käytöstä nyt</string>
     <string name="draft">Luonnos:</string>
     <string name="pref_omemo_setting">OMEMO-salaus</string>
-    <string name="pref_omemo_setting_summary_default_on">OMEMO:a ei koskaan käytetä oletuksena uusissa keskusteluissa.</string>
+    <string name="pref_omemo_setting_summary_default_on">Uusissa keskusteluissa OMEMO otetaan oletuksena käyttöön.</string>
     <string name="create_shortcut">Luo pikakuvake</string>
     <string name="pref_font_size">Kirjasinkoko</string>
     <string name="pref_font_size_summary">Kirjasimen suhteellinen koko sovelluksen sisällä.</string>
@@ -677,8 +677,8 @@
     <string name="small">Pieni</string>
     <string name="medium">Keksikokoinen</string>
     <string name="large">Suuri</string>
-    <string name="not_encrypted_for_this_device">Viestiä ei salattu tälle laitteelle</string>
-    <string name="omemo_decryption_failed">OMEMO-salatun viestin purku epäonnistui</string>
+    <string name="not_encrypted_for_this_device">Viestiä ei salattu tälle laitteelle.</string>
+    <string name="omemo_decryption_failed">OMEMO-salatun viestin purku epäonnistui.</string>
     <string name="undo">peru</string>
     <string name="location_disabled">Sijainnin jakaminen on pois käytöstä</string>
     <string name="action_copy_location">Kopioi sijainti</string>
@@ -687,7 +687,7 @@
     <string name="title_activity_share_location">Jaa sijainti</string>
     <string name="title_activity_show_location">Näytä sijainti</string>
     <string name="share">Jaa</string>
-    <string name="please_wait">Odota hetki...</string>
+    <string name="please_wait">Odota hetki…</string>
     <string name="no_microphone_permission">Salli %1$s:n käyttää mikrofonia</string>
     <string name="gif">GIF</string>
     <string name="view_conversation">Näytä keskustelu</string>
@@ -725,7 +725,7 @@
     <string name="video_360p">Keski (360p)</string>
     <string name="video_720p">Korkea (720p)</string>
     <string name="cancelled">peruutettu</string>
-    <string name="already_drafting_message">Olet jo aloittanut viestin luonnostelun</string>
+    <string name="already_drafting_message">Olet jo aloittanut viestin luonnostelun.</string>
     <string name="feature_not_implemented">Ominaisuutta ei ole toteutettu</string>
     <string name="invalid_country_code">Virheellinen maakoodi</string>
     <string name="choose_a_country">Valitse maa</string>
@@ -743,13 +743,13 @@
     <string name="resend_sms_in">Lähetä uusi tekstivieti (%s)</string>
     <string name="wait_x">Odota (%s)</string>
     <string name="back">takaisin</string>
-    <string name="possible_pin">Mahdollinen PIN-koodi liitettiin leikepöydältä automaattisesti</string>
+    <string name="possible_pin">Mahdollinen PIN-koodi liitettiin leikepöydältä automaattisesti.</string>
     <string name="please_enter_pin">Syötä 6-numeroinen PIN-koodisi.</string>
     <string name="abort_registration_procedure">Haluatko varmasti perua rekisteröintiprosessin?</string>
     <string name="yes">Kyllä</string>
     <string name="no">Ei</string>
-    <string name="verifying">Varmistetaan...</string>
-    <string name="requesting_sms">Pyydetään tekstiviestiä</string>
+    <string name="verifying">Varmistetaan…</string>
+    <string name="requesting_sms">Pyydetään tekstiviestiä…</string>
     <string name="incorrect_pin">Syöttämäsi PIN-koodi on väärä.</string>
     <string name="pin_expired">Lähettämämme PIN-koodi on vanhentunut.</string>
     <string name="unknown_api_error_network">Tuntematon verkkovirhe.</string>
@@ -758,7 +758,7 @@
     <string name="unable_to_establish_secure_connection">Turvallinen yhteys epäonnistui.</string>
     <string name="unable_to_find_server">Palvelinta ei löytynyt.</string>
     <string name="something_went_wrong_processing_your_request">Pyyntösi käsittelyssä tapahtui jokin virhe.</string>
-    <string name="no_network_connection">Ei verkkoyhteyttä</string>
+    <string name="no_network_connection">Ei verkkoyhteyttä.</string>
     <string name="try_again_in_x">Odota %s ja yritä uudelleen</string>
     <string name="too_many_attempts">Liian monta yritystä</string>
     <string name="the_app_is_out_of_date">Käytät vanhentunutta versiota tästä sovelluksesta.</string>
@@ -774,7 +774,7 @@
     <string name="group_chat_will_make_your_jabber_id_public">Tämä kanava julkaisee XMPP-osoitteesi</string>
     <string name="ebook">e-kirja</string>
     <string name="video_original">Alkuperäinen (pakkaamaton)</string>
-    <string name="open_with">Avaa sovelluksella...</string>
+    <string name="open_with">Avaa sovelluksella…</string>
     <string name="set_profile_picture">Conversations-profiilikuva</string>
     <string name="choose_account">Valitse tili</string>
     <string name="restore_backup">Palauta varmuuskopiosta</string>
@@ -794,7 +794,7 @@
     <string name="please_enter_name">Anna kanavalle nimi</string>
     <string name="please_enter_xmpp_address">Anna XMPP-osoite</string>
     <string name="this_is_an_xmpp_address">Tämä on XMPP-osoite. Anna nimi sen sijaan.</string>
-    <string name="creating_channel">Luodaan julkista kanavaa...</string>
+    <string name="creating_channel">Luodaan julkista kanavaa…</string>
     <string name="channel_already_exists">Kanava on jo olemassa</string>
     <string name="joined_an_existing_channel">Liityit olemassa olevalle kanavalle</string>
     <string name="unable_to_set_channel_configuration">Kanavan asetuksia ei saatu tallennettua</string>
@@ -828,7 +828,7 @@
     <string name="account_already_setup">Tämä tili on jo asennettu</string>
     <string name="please_enter_password">Syötä tämän tilin salasana</string>
     <string name="unable_to_perform_this_action">Toiminnon suorittaminen epäonnistui</string>
-    <string name="open_join_dialog">Liity julkiselle kanavalle...</string>
+    <string name="open_join_dialog">Liity julkiselle kanavalle…</string>
     <string name="sharing_application_not_grant_permission">Jakava sovellus ei antanut tarvittavaa lupaa lukea tiedostoa.</string>
     <string name="group_chats_and_channels"><![CDATA[Ryhmäkeskustelut ja kanavat]]></string>
     <string name="jabber_network">jabber.network</string>
@@ -903,6 +903,45 @@
     <string name="server_does_not_support_easy_onboarding_invites">Palvelin ei tue kutsujen luomista</string>
     <string name="no_active_accounts_support_this">Yksikään aktiivinen tili ei tue tätä toimintoa</string>
     <string name="backup_started_message">Varmuuskopion teko aloitettu. Saat ilmoituksen kun se on valmis.</string>
-    <string name="unable_to_enable_video">Videon käyttöönotto epäonnistui</string>
+    <string name="unable_to_enable_video">Videon käyttöönotto epäonnistui.</string>
     <string name="plain_text_document">Perustekstiasiakirja</string>
-    </resources>
+    <string name="pref_omemo_setting_summary_always">OMEMO:a käytetään aina kaikissa yksityisissä keskusteluissa.</string>
+    <string name="search_contacts">Etsi yhteystiedoista</string>
+    <string name="pref_omemo_setting_summary_default_off">OMEMO täytyy ottaa käyttöön käsin uusissa keskusteluissa.</string>
+    <string name="group_chats">Ryhmäkeskustelut</string>
+    <string name="search_group_chats">Etsi ryhmäkeskusteluista</string>
+    <string name="pref_start_search">Suoraan hakuun</string>
+    <string name="channel_discover_opt_in_message">Kanavien löytö käyttää kolmannen osapuolen palvelua nimeltä &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Tämän ominaisuuden käyttö lähettää IP-osoitteesi ja hakusanasi palvelulle. Lue lisää heidän &lt;a href=https://search.jabber.network/privacy&gt;yksityisyyskäytännöstään&lt;/a&gt; (englanniksi).</string>
+    <string name="search_messages">Hae viesteistä</string>
+    <string name="this_account_is_logged_out">Olet kirjautunut ulos tältä tililtä</string>
+    <string name="missed_calls_channel_name">Vastaamattomat puhelut</string>
+    <string name="pref_incoming_call_notification_settings">Saapuvien puheluiden ilmoitusasetukset</string>
+    <string name="temporarily_unavailable">Ei käytössä hetkellisesti. Yritä myöhemmin uudestaan.</string>
+    <string name="reconnecting_video_call">Yhdistetään videopuhelua uudestaan</string>
+    <string name="incoming_call_duration_timestamp">Saapuva puhelu (%s) · %s</string>
+    <string name="reconnecting_call">Yhdistetään puhelua uudestaan</string>
+    <string name="pref_autojoin">Synkronoi kirjanmerkit</string>
+    <string name="outgoing_call_duration_timestamp">Lähtevä puhelu (%s) · %s</string>
+    <string name="download_failed_invalid_file">Lataus epäonnistui: Kelvoton tiedosto</string>
+    <string name="pref_broadcast_last_activity">Julkaise käyttö</string>
+    <string name="continue_btn">Jatka</string>
+    <string name="account_state_logged_out">Kirjautunut ulos</string>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d vastaamaton puhelu</item>
+        <item quantity="other">%d vastaamatonta puhelua</item>
+    </plurals>
+    <string name="rtp_state_reconnecting">Yhdistetään uudelleen</string>
+    <string name="outgoing_call_timestamp">Lähtevä puhelu · %s</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d vastaamaton puhelu henkilöltä %2$s</item>
+        <item quantity="other">%1$d vastaamatonta puhelua henkilöltä %2$s</item>
+    </plurals>
+    <string name="dialog_manage_certs_positivebutton">Poista valinta</string>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d vastaamatonta puhelua %2$d henkilöltä</item>
+        <item quantity="other">%1$d vastaamatonta puhelua %2$d henkilöltä</item>
+    </plurals>
+    <string name="audiobook">Äänikirja</string>
+    <string name="silent_messages_channel_name">Hiljaiset viestit</string>
+    <string name="rtp_state_content_add_video">Vaihdetaanko videopuheluun\?</string>
+</resources>

src/main/res/values-it/strings.xml 🔗

@@ -537,7 +537,7 @@
     <string name="this_field_is_required">Questo campo è obbligatorio</string>
     <string name="correct_message">Correggi messaggio</string>
     <string name="send_corrected_message">Invia messaggio corretto</string>
-    <string name="no_keys_just_confirm">Hai già validato l\'impronta di questa persona in modo sicuro per confermarne la fiducia. Selezionando “Fatto” stai solo confermando che %s fa parte di questa chat di gruppo.</string>
+    <string name="no_keys_just_confirm">Ti stai già fidando dell\'impronta di questa persona. Selezionando “Fatto” stai solo confermando che %s fa parte di questa chat di gruppo.</string>
     <string name="this_account_is_disabled">Hai disattivato questo profilo</string>
     <string name="security_error_invalid_file_access">Errore di sicurezza: accesso file non valido!</string>
     <string name="no_application_to_share_uri">Nessuna app trovata per condividere l\'URI</string>
@@ -1032,4 +1032,6 @@
     <string name="log_out">Disconnetti</string>
     <string name="account_state_logged_out">Disconnesso</string>
     <string name="unverified_devices">Stai usando dispositivi non verificati. Scansiona il codice a barre 2D nei tuoi altri dispositivi per effettuare la verifica e impedire attacchi MITM attivi.</string>
+    <string name="report_spam_and_block">Segnala spam e blocca l\'utente</string>
+    <string name="report_spam">Segnala spam</string>
 </resources>

src/main/res/values-pl/strings.xml 🔗

@@ -544,7 +544,7 @@
     <string name="this_field_is_required">To pole jest wymagane</string>
     <string name="correct_message">Popraw wiadomość</string>
     <string name="send_corrected_message">Wyślij poprawioną wiadomość</string>
-    <string name="no_keys_just_confirm">Już zaufałeś temu kontaktowi. Wybierając \'zrobione\' potwierdzasz, że %s jest członkiem tej rozmowy grupowej.</string>
+    <string name="no_keys_just_confirm">Już zaufano temu osobistemu odciskowi palca. Wybierając \"Zrobione\" potwierdzasz, że %s jest członkiem tej rozmowy grupowej.</string>
     <string name="this_account_is_disabled">Wyłączyłeś to konto</string>
     <string name="security_error_invalid_file_access">Błąd bezpieczeństwa: nieprawidłowy dostęp do pliku!</string>
     <string name="no_application_to_share_uri">Nie odnaleziono aplikacji do udostępnienia URI</string>
@@ -1044,4 +1044,13 @@
     <string name="outdated_backup_file_format">Próbujesz zaimportować plik kopii zapasowej o przestarzałym formacie</string>
     <string name="audiobook">Audiobook</string>
     <string name="reconnect_on_other_host">Połącz się ponownie na innym hoście</string>
+    <string name="this_account_is_logged_out">Wylogowano się z tego konta</string>
+    <string name="log_in">Zaloguj się</string>
+    <string name="hide_notification">Ukryj powiadomienie</string>
+    <string name="contact_uses_unverified_keys">Twój kontakt korzysta z niezweryfikowanych urządzeń. Zeskanuj ich kod kreskowy 2D, aby przeprowadzić weryfikację i uniemożliwić aktywne ataki MITM.</string>
+    <string name="report_spam_and_block">Zgłoś spam i zablokuj nadawcę</string>
+    <string name="log_out">Wyloguj się</string>
+    <string name="account_state_logged_out">Wylogowano</string>
+    <string name="unverified_devices">Używasz z niezweryfikowanych urządzeń. Zeskanuj kod kreskowy 2D na innych urządzeniach, aby przeprowadzić weryfikację i uniemożliwić aktywne ataki MITM.</string>
+    <string name="report_spam">Zgłoś spam</string>
 </resources>

src/main/res/values-ru/strings.xml 🔗

@@ -530,7 +530,7 @@
     <string name="this_field_is_required">Незаполненное поле</string>
     <string name="correct_message">Исправить сообщение</string>
     <string name="send_corrected_message">Отправить исправленное сообщение</string>
-    <string name="no_keys_just_confirm">Вы уже подтвердили, что электронный отпечаток принадлежит этому человеку. Выбрав \"Готово\", вы только подтвердите, что %s является участником конференции.</string>
+    <string name="no_keys_just_confirm">Вы уже пометили отпечаток этого человека как доверенный. Выбрав \"Готово\", вы только подтвердите, что %s является участником конференции.</string>
     <string name="this_account_is_disabled">Вы отключили эту учётную запись</string>
     <string name="security_error_invalid_file_access">Ошибка безопасности: недействительный доступ к файлу</string>
     <string name="no_application_to_share_uri">Не найдено приложения для передачи URI</string>
@@ -1026,7 +1026,7 @@
         <item quantity="many">%1$d пропущенных вызовов от %2$s</item>
         <item quantity="other">%1$d пропущенных вызовов от %2$s</item>
     </plurals>
-    <string name="reconnect_on_other_host">Переподключиться на другой сервер</string>
+    <string name="reconnect_on_other_host">Переподключиться на другом сервере</string>
     <string name="outdated_backup_file_format">Вы попытались импортировать резервную копию в устаревшем формате</string>
     <plurals name="n_missed_calls_from_m_contacts">
         <item quantity="one">%1$d пропущенный вызов от %2$d контакта</item>
@@ -1042,4 +1042,6 @@
     <string name="log_out">Выйти</string>
     <string name="account_state_logged_out">Деавторизован</string>
     <string name="unverified_devices">Вы используете неподтверждённые устройства. Отсканируйте штрих-код на подтверждённом устройстве для проверки и предотвращения атаки посредника.</string>
+    <string name="report_spam_and_block">Пожаловаться на спам и заблокировать</string>
+    <string name="report_spam">Пожаловаться на спам</string>
 </resources>

src/main/res/values-sv/strings.xml 🔗

@@ -549,7 +549,7 @@
     <string name="this_field_is_required">Detta fält är obligatoriskt</string>
     <string name="correct_message">Korrigera meddelande</string>
     <string name="send_corrected_message">Skicka korrigerat meddelande</string>
-    <string name="no_keys_just_confirm">Du har redan validerat den här personens fingeravtryck på ett säkert sätt, för att bekräfta förtroendet. Genom att välja \"Klar\" bekräftar du bara att %s är en del av den här gruppchatten.</string>
+    <string name="no_keys_just_confirm">Du har redan litat på den här personens fingeravtryck. Genom att välja \"Klar\", bekräftar du bara att %s är en del av den här gruppchatten.</string>
     <string name="this_account_is_disabled">Du har inaktiverat det här kontot</string>
     <string name="security_error_invalid_file_access">Säkerhetsfel: Ogiltig filåtkomst!</string>
     <string name="no_application_to_share_uri">Ingen app hittades för att dela URI</string>
@@ -796,79 +796,79 @@
     <string name="verifying">Bekräftar…</string>
     <string name="unknown_api_error_network">Okänt nätverksfel.</string>
     <string name="too_many_attempts">För många försök</string>
-    <string name="the_app_is_out_of_date">Du använder en föråldrad version av denna app.</string>
+    <string name="the_app_is_out_of_date">Du använder en inaktuell version av den här appen.</string>
     <string name="update">Uppdatera</string>
     <string name="your_name">Ditt namn</string>
-    <string name="enter_your_name">Skriv in ditt namn</string>
-    <string name="reject_request">Avslå begäran</string>
+    <string name="enter_your_name">Ange ditt namn</string>
+    <string name="reject_request">Avvisa begäran</string>
     <string name="install_orbot">Installera Orbot</string>
     <string name="start_orbot">Starta Orbot</string>
     <string name="ebook">e-bok</string>
-    <string name="open_with">Öppna med …</string>
-    <string name="set_profile_picture">Conversations-profilbild</string>
+    <string name="open_with">Öppna med…</string>
+    <string name="set_profile_picture">Profilbild för Conversations</string>
     <string name="choose_account">Välj konto</string>
-    <string name="restore_backup">Återställa säkerhetskopiering</string>
-    <string name="restore">Återställa</string>
-    <string name="enter_password_to_restore">Ange ditt lösenord till kontot %s för att återställa säkerhetskopian.</string>
+    <string name="restore_backup">Återställ säkerhetskopia</string>
+    <string name="restore">Återställ</string>
+    <string name="enter_password_to_restore">Ange ditt lösenord för kontot %s, för att återställa säkerhetskopian.</string>
     <string name="unable_to_restore_backup">Det gick inte att återställa säkerhetskopian.</string>
-    <string name="backup_channel_name">Säkerhetskopia &amp; Återställ</string>
+    <string name="backup_channel_name">Säkerhetskopiering &amp; Återställning</string>
     <string name="enter_jabber_id">Ange XMPP-adress</string>
     <string name="create_group_chat">Skapa gruppchatt</string>
-    <string name="join_public_channel">Anslut till publik gruppkonversation</string>
-    <string name="create_private_group_chat">Skapa sluten gruppchatt</string>
-    <string name="create_public_channel">Skapa publik gruppkonversation</string>
-    <string name="create_dialog_channel_name">Kanalnamn</string>
+    <string name="join_public_channel">Anslut till en offentlig gruppchatt</string>
+    <string name="create_private_group_chat">Skapa en privat gruppchatt</string>
+    <string name="create_public_channel">Skapa en publik gruppchatt</string>
+    <string name="create_dialog_channel_name">Gruppchattens namn</string>
     <string name="xmpp_address">XMPP-adress</string>
-    <string name="please_enter_name">Vänligen ange ett namn på kanalen</string>
-    <string name="please_enter_xmpp_address">Ange en XMPP-adress</string>
-    <string name="this_is_an_xmpp_address">Detta är en XMPP-adress. Ange ett namn.</string>
-    <string name="creating_channel">Skapar publik gruppkonversation …</string>
-    <string name="channel_already_exists">Denna kanal finns redan</string>
-    <string name="joined_an_existing_channel">Du har gått med i en befintlig kanal</string>
-    <string name="unable_to_set_channel_configuration">Det gick inte att spara kanalkonfigurationen</string>
-    <string name="allow_participants_to_edit_subject">Tillåt vem som helst att ändra ämnet</string>
+    <string name="please_enter_name">Var god ange ett namn på gruppchatten</string>
+    <string name="please_enter_xmpp_address">Var god och ange en XMPP-adress</string>
+    <string name="this_is_an_xmpp_address">Det här är en XMPP-adress. Var god och ange ett namn.</string>
+    <string name="creating_channel">Skapar publik gruppchatt…</string>
+    <string name="channel_already_exists">Den här gruppchatten finns redan</string>
+    <string name="joined_an_existing_channel">Du har gått med i en befintlig gruppchatt</string>
+    <string name="unable_to_set_channel_configuration">Det gick inte att spara inställningarna för gruppchatten</string>
+    <string name="allow_participants_to_edit_subject">Tillåt vem som helst att redigera ämnet</string>
     <string name="allow_participants_to_invite_others">Tillåt vem som helst att bjuda in andra</string>
-    <string name="anyone_can_edit_subject">Vem som helst kan ändra ämnet.</string>
-    <string name="owners_can_edit_subject">Ägaren kan ändra ämnet.</string>
-    <string name="admins_can_edit_subject">Administratörer kan ändra ämnet.</string>
+    <string name="anyone_can_edit_subject">Vem som helst kan redigera ämnet.</string>
+    <string name="owners_can_edit_subject">Ägare kan redigera ämnet.</string>
+    <string name="admins_can_edit_subject">Administratörer kan redigera ämnet.</string>
     <string name="owners_can_invite_others">Ägare kan bjuda in andra.</string>
     <string name="anyone_can_invite_others">Vem som helst kan bjuda in andra.</string>
     <string name="jabber_ids_are_visible_to_admins">XMPP-adresser är synliga för administratörer.</string>
-    <string name="jabber_ids_are_visible_to_anyone">XMPP-adresser är synliga för alla.</string>
-    <string name="no_users_hint_channel">Den här publika gruppkonversationen har inga deltagare. Bjud in dina kontakter eller använd \'dela-knappen\' för att dela XMPP-adressen.</string>
-    <string name="no_users_hint_group_chat">Denna slutna gruppchatt har inga deltagare.</string>
+    <string name="jabber_ids_are_visible_to_anyone">XMPP-adresser är synliga för vem som helst.</string>
+    <string name="no_users_hint_channel">Den här offentliga gruppchatten har inga deltagare. Bjud in dina kontakter, eller använd dela-knappen för att distribuera dess XMPP-adress.</string>
+    <string name="no_users_hint_group_chat">Den här privata gruppchatten har inga deltagare.</string>
     <string name="manage_permission">Hantera rättigheter</string>
     <string name="search_participants">Sök efter deltagare</string>
-    <string name="file_too_large">För stor fil</string>
+    <string name="file_too_large">Filen är för stor</string>
     <string name="attach">Bifoga</string>
-    <string name="discover_channels">Upptäck kanaler</string>
-    <string name="search_channels">Sök efter gruppkonversationer</string>
+    <string name="discover_channels">Upptäck gruppchattar</string>
+    <string name="search_channels">Sök efter gruppchattar</string>
     <string name="channel_discovery_opt_in_title">Möjlig integritetskränkning!</string>
     <string name="i_already_have_an_account">Jag har redan ett konto</string>
-    <string name="add_existing_account">Lägg till befintligt konto</string>
-    <string name="register_new_account">Skapa nytt konto</string>
-    <string name="this_looks_like_a_domain">Detta verkar vara ett domännamn</string>
+    <string name="add_existing_account">Lägg till ett befintligt konto</string>
+    <string name="register_new_account">Skapa ett nytt konto</string>
+    <string name="this_looks_like_a_domain">Det här ser ut som en domänadress</string>
     <string name="add_anway">Lägg till ändå</string>
-    <string name="this_looks_like_channel">Detta ser ut som en kanaladress</string>
+    <string name="this_looks_like_channel">Det här ser ut som en gruppchattadress</string>
     <string name="share_backup_files">Dela säkerhetskopior</string>
     <string name="conversations_backup">Säkerhetskopior för Conversations</string>
     <string name="event">Händelse</string>
     <string name="open_backup">Öppna säkerhetskopia</string>
-    <string name="not_a_backup_file">Filen du valde är inte en säkerhetskopia till Conversations</string>
+    <string name="not_a_backup_file">Filen du valde är inte en säkerhetskopia för Conversations</string>
     <string name="account_already_setup">Det här kontot har redan konfigurerats</string>
-    <string name="please_enter_password">Var god ange lösenordet för det här kontot</string>
+    <string name="please_enter_password">Var god och ange lösenordet för det här kontot</string>
     <string name="unable_to_perform_this_action">Det gick inte att utföra den här åtgärden</string>
-    <string name="open_join_dialog">Anslut till publik gruppkonversation …</string>
-    <string name="sharing_application_not_grant_permission">Delnings-appen gav inte behörighet till att komma åt den här filen.</string>
-    <string name="group_chats_and_channels"><![CDATA[Gruppkonversationer & Kanaler]]></string>
+    <string name="open_join_dialog">Anslut till publik gruppchatt…</string>
+    <string name="sharing_application_not_grant_permission">Delningsappen gav inte behörighet att komma åt den här filen.</string>
+    <string name="group_chats_and_channels">Gruppchattar &amp; Kanaler</string>
     <string name="jabber_network">jabber.network</string>
     <string name="local_server">Lokal server</string>
-    <string name="pref_channel_discovery_summary">De flesta användare bör välja \"jabber.network\" för bättre förslag från hela det offentliga XMPP-ekosystemet.</string>
+    <string name="pref_channel_discovery_summary">De flesta användare bör välja \"jabber.network\", för att få bättre förslag från hela det offentliga XMPP-ekosystemet.</string>
     <string name="pref_channel_discovery">Metod för kanalupptäckt</string>
     <string name="backup">Säkerhetskopiering</string>
     <string name="category_about">Om</string>
-    <string name="please_enable_an_account">Aktivera ett konto</string>
-    <string name="make_call">Ring</string>
+    <string name="please_enable_an_account">Var god och aktivera ett konto</string>
+    <string name="make_call">Ring ett samtal</string>
     <string name="rtp_state_incoming_call">Inkommande samtal</string>
     <string name="rtp_state_incoming_video_call">Inkommande videosamtal</string>
     <string name="rtp_state_connecting">Ansluter</string>
@@ -881,11 +881,11 @@
     <string name="rtp_state_finding_device">Upptäcker enheter</string>
     <string name="rtp_state_ringing">Ringer</string>
     <string name="rtp_state_declined_or_busy">Upptagen</string>
-    <string name="rtp_state_connectivity_error">Kunde inte koppla samtal</string>
-    <string name="rtp_state_connectivity_lost_error">Anslutning bröts</string>
+    <string name="rtp_state_connectivity_error">Det gick inte att koppla samtalet</string>
+    <string name="rtp_state_connectivity_lost_error">Anslutningen avbröts</string>
     <string name="rtp_state_retracted">Återkallat samtal</string>
-    <string name="rtp_state_application_failure">Appmisslyckande</string>
-    <string name="rtp_state_security_error">Verifikationsproblem</string>
+    <string name="rtp_state_application_failure">Appfel</string>
+    <string name="rtp_state_security_error">Verifieringsproblem</string>
     <string name="hang_up">Lägg på</string>
     <string name="ongoing_call">Pågående samtal</string>
     <string name="ongoing_video_call">Pågående videosamtal</string>
@@ -903,11 +903,11 @@
     <string name="microphone_unavailable">Din mikrofon är inte tillgänglig</string>
     <string name="only_one_call_at_a_time">Du kan bara ha ett samtal åt gången.</string>
     <string name="return_to_ongoing_call">Återgå till pågående samtal</string>
-    <string name="could_not_switch_camera">Kunde inte växla kamera</string>
+    <string name="could_not_switch_camera">Det gick inte att byta kamera</string>
     <string name="add_to_favorites">Fäst flik till toppen</string>
-    <string name="remove_from_favorites">Ta bort flik från toppen</string>
+    <string name="remove_from_favorites">Lossa flik från toppen</string>
     <string name="gpx_track">GPX-spår</string>
-    <string name="could_not_correct_message">Kunde inte korrigera meddelandet</string>
+    <string name="could_not_correct_message">Det gick inte att korrigera meddelandet</string>
     <string name="search_all_conversations">Alla konversationer</string>
     <string name="search_this_conversation">Den här konversationen</string>
     <string name="your_avatar">Din visningsbild</string>
@@ -919,7 +919,7 @@
     <string name="record_voice_mail">Spela in ett röstmeddelande</string>
     <string name="play_audio">Spela upp ljud</string>
     <string name="pause_audio">Pausa ljud</string>
-    <string name="add_contact_or_create_or_join_group_chat">Lägg till kontakt, skapa eller gå med i gruppchatt eller upptäck kanaler</string>
+    <string name="add_contact_or_create_or_join_group_chat">Lägg till kontakt, skapa eller gå med i gruppchatt, eller upptäck kanaler</string>
     <plurals name="view_users">
         <item quantity="one">Visa %1$d deltagare</item>
         <item quantity="other">Visa %1$d deltagare</item>
@@ -943,15 +943,15 @@
     <string name="pref_message_notification_settings">Inställningar för meddelandeaviseringar</string>
     <string name="pref_incoming_call_notification_settings">Aviseringsinställningar för inkommande samtal</string>
     <string name="pref_more_notification_settings_summary">Betydelse, ljud, vibrera</string>
-    <string name="unable_to_establish_secure_connection">Kunde inte upprätta en säker anslutning.</string>
-    <string name="invalid_user_input">Ogiltig inmatning</string>
-    <string name="temporarily_unavailable">Tillfälligt otillgänglig. Försök igen om en stund.</string>
-    <string name="no_network_connection">Ingen kontakt med nätverket.</string>
-    <string name="try_again_in_x">Prova igen om %s</string>
+    <string name="unable_to_establish_secure_connection">Det gick inte att upprätta en säker anslutning.</string>
+    <string name="invalid_user_input">Ogiltig användarinmatning</string>
+    <string name="temporarily_unavailable">Tillfälligt otillgänglig. Försök igen senare.</string>
+    <string name="no_network_connection">Ingen nätverksanslutning.</string>
+    <string name="try_again_in_x">Var god försök igen om %s</string>
     <string name="requesting_sms">Begär sms…</string>
     <string name="incorrect_pin">Den angivna PIN-koden är felaktig.</string>
     <string name="pin_expired">PIN-koden som vi skickade till dig, är inte längre giltig.</string>
-    <string name="unable_to_find_server">Kunde inte hitta servern.</string>
+    <string name="unable_to_find_server">Det gick inte att hitta servern.</string>
     <string name="possible_pin">Möjligtvis automatiskt inklistrad PIN från urklipp.</string>
     <string name="please_enter_pin">Var god ange din 6-siffriga pinkod.</string>
     <string name="abort_registration_procedure">Är du säker på att du vill avbryta registreringsproceduren\?</string>
@@ -969,21 +969,21 @@
     <string name="pref_video_compression_summary">Lägre kvalitet resulterar i mindre filer</string>
     <string name="feature_not_implemented">Funktionen är inte implementerad</string>
     <string name="invalid_country_code">Ogiltig landskod</string>
-    <string name="rate_limited">Du är begränsad</string>
-    <string name="no_name_set_instructions">Använd redigera-knappen för att ange ditt namn.</string>
-    <string name="no_market_app_installed">Ingen butiksapp installerad.</string>
-    <string name="group_chat_will_make_your_jabber_id_public">Den här kanalen gör din XMPP-adress publik</string>
+    <string name="rate_limited">Du är anropsbegränsad</string>
+    <string name="no_name_set_instructions">Använd redigeringsknappen för att ange ditt namn.</string>
+    <string name="no_market_app_installed">Ingen appbutik är installerad.</string>
+    <string name="group_chat_will_make_your_jabber_id_public">Den här kanalen kommer att göra din XMPP-adress offentlig</string>
     <string name="video_original">Original (okomprimerad)</string>
-    <string name="unable_to_decrypt_backup">Kunde inte dekryptera säkerhetskopian. Är lösenordet rätt\?</string>
-    <string name="rtp_state_content_add_video">Byta till videosamtal\?</string>
+    <string name="unable_to_decrypt_backup">Det gick inte att dekryptera säkerhetskopian. Är lösenordet korrekt\?</string>
+    <string name="rtp_state_content_add_video">Växla till videosamtal\?</string>
     <string name="rtp_state_content_add">Lägg till ytterligare spår\?</string>
-    <string name="no_active_accounts_support_this">Inga aktiva konton har stöd för denna funktion</string>
-    <string name="backup_started_message">Säkerhetskopieringen har startat. Du får en notifikation när den är färdig.</string>
-    <string name="unable_to_enable_video">Video kan inte aktiveras.</string>
-    <string name="plain_text_document">Textdokument</string>
+    <string name="no_active_accounts_support_this">Inga aktiva konton stöder den här funktionen</string>
+    <string name="backup_started_message">Säkerhetskopieringen har påbörjats. Du får ett notis när det är klart.</string>
+    <string name="unable_to_enable_video">Det gick inte att aktivera video.</string>
+    <string name="plain_text_document">Oformaterat textdokument</string>
     <string name="account_status_temporary_auth_failure">Tillfälligt autentiseringsfel</string>
-    <string name="delete_avatar">Radera avatar</string>
-    <string name="audio_video_disabled_tor">Samtal är inaktiverat när Tor används</string>
+    <string name="delete_avatar">Ta bort visningsbild</string>
+    <string name="audio_video_disabled_tor">Samtal är inaktiverade när du använder Tor</string>
     <string name="switch_to_video">Växla till video</string>
     <string name="reject_switch_to_video">Avböj förfrågan om att växla till video</string>
     <string name="unified_push_distributor">UnifiedPush-distributör</string>
@@ -991,11 +991,11 @@
     <string name="pref_up_push_account_summary">Kontot genom vilket push-meddelanden tas emot.</string>
     <string name="pref_up_push_server_title">Push-server</string>
     <string name="pref_up_push_server_summary">En användarvald push-server för att vidarebefordra push-meddelanden via XMPP till din enhet.</string>
-    <string name="no_account_deactivated">Ingen (avaktiverad)</string>
-    <string name="logged_in_with_another_device">Det här telefonnumret är inloggat på en annan enhet.</string>
-    <string name="enter_your_name_instructions">Ange ditt namn så att folk som inte har dig i sin adressbok vet vem du är.</string>
-    <string name="unable_to_connect_to_server">Kunde inte kontakta servern.</string>
-    <string name="something_went_wrong_processing_your_request">Något blev fel när din förfrågan hanterades.</string>
+    <string name="no_account_deactivated">Ingen (inaktiverad)</string>
+    <string name="logged_in_with_another_device">Det här telefonnumret är för närvarande inloggat med en annan enhet.</string>
+    <string name="enter_your_name_instructions">Var god ange ditt namn för att låta personer som inte har dig i sina adressböcker, veta vem du är.</string>
+    <string name="unable_to_connect_to_server">Det gick inte att ansluta till servern.</string>
+    <string name="something_went_wrong_processing_your_request">Något gick fel när din begäran behandlades.</string>
     <string name="unknown_api_error_response">Okänt svar från servern.</string>
     <string name="we_have_sent_you_an_sms_to_x">Vi har skickat ett SMS till <b>%s</b>.</string>
     <string name="enter_country_code_and_phone_number">Quicksy kommer att skicka ett SMS (operatörsavgifter kan tillkomma) för att verifiera ditt telefonnummer. Ange din landskod och ditt telefonnummer:</string>
@@ -1007,17 +1007,40 @@
     <string name="pref_autojoin">Synkronisera bokmärken</string>
     <string name="conference_technical_problems">Du lämnade den här gruppchatten på grund av tekniska skäl</string>
     <string name="multimedia_file">multimediafil</string>
-    <string name="restore_warning">Använd inte funktionen för återställning av säkerhetskopia för att försöka klona (köra samtidigt) en installation. Återställning av en säkerhetskopia är avsedd för migreringar eller om du har tappat bort den ursprungliga enheten.</string>
+    <string name="restore_warning">Använd inte funktionen för återställning av säkerhetskopiering i ett försök att klona en installation, för att kunna köra två lika installationer samtidigt. Att återställa en säkerhetskopia är endast avsedd för migrering, eller om du har tappat bort den ursprungliga enheten.</string>
     <plurals name="n_missed_calls_from_m_contacts">
         <item quantity="one">%1$d missat samtal från %2$d kontakt</item>
         <item quantity="other">%1$d missade samtal från %2$d kontakter</item>
     </plurals>
     <plurals name="some_messages_could_not_be_delivered">
         <item quantity="one">Ett meddelande kunde inte levereras</item>
-        <item quantity="other">Några meddelanden kunde inte levereras</item>
+        <item quantity="other">Flera meddelanden kunde inte levereras</item>
     </plurals>
-    <string name="unable_to_parse_invite">Kunde inte tolka inbjudan</string>
-    <string name="server_does_not_support_easy_onboarding_invites">Servern har inte stöd för att skapa inbjudningar</string>
-    <string name="account_registrations_are_not_supported">Det finns inget stöd för att registrera konto</string>
+    <string name="unable_to_parse_invite">Kunde inte hantera inbjudan</string>
+    <string name="server_does_not_support_easy_onboarding_invites">Servern stöder inte generering av inbjudningar</string>
+    <string name="account_registrations_are_not_supported">Kontoregistreringar stöds inte</string>
     <string name="pref_autojoin_summary">Ställ in \"autojoin\"-flaggan när du går in i, eller lämnar en MUC, samt reagera på ändringar gjorda av andra klienter.</string>
+    <string name="this_account_is_logged_out">Du har loggat ut från detta konto</string>
+    <string name="log_in">Logga in</string>
+    <string name="hide_notification">Dölj notis</string>
+    <string name="reconnect_on_other_host">Återanslut på annan värd</string>
+    <string name="contact_uses_unverified_keys">Din kontakt använder overifierade enheter. Skanna deras 2D-streckkod för att utföra en verifiering och för att förhindra aktiva MITM-attacker.</string>
+    <string name="delete_from_server">Ta bort kontot från servern</string>
+    <string name="report_spam_and_block">Rapportera spam och blockera spammaren</string>
+    <string name="incoming_call_duration_timestamp">Inkommande samtal (%s) · %s</string>
+    <string name="log_out">Logga ut</string>
+    <string name="group_chats">Gruppchattar</string>
+    <string name="outdated_backup_file_format">Du försöker importera ett föråldrat filformat för säkerhetskopiering</string>
+    <string name="search_group_chats">Sök gruppchattar</string>
+    <string name="decline">Neka</string>
+    <string name="outgoing_call_duration_timestamp">Utgående samtal (%s) · %s</string>
+    <string name="account_state_logged_out">Utloggad</string>
+    <string name="unverified_devices">Du använder overifierade enheter. Skanna 2D-streckkoden på dina andra enheter, för att utföra en verifiering och för att förhindra aktiva MITM-attacker.</string>
+    <string name="outgoing_call_timestamp">Utgående samtal · %s</string>
+    <string name="save_as_group_chat">Spara som gruppchatt</string>
+    <string name="audiobook">Ljudbok</string>
+    <string name="report_spam">Rapportera spam</string>
+    <string name="channel_discover_opt_in_message">Funktionen Channel Discovery, använder en tredjepartstjänst som heter &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Om du använder den här funktionen, överförs din IP-adress och din söktermer till den tjänsten. Se deras &lt;a href=https://search.jabber.network/privacy&gt;sekretesspolicy&lt;/a&gt; för mer information.</string>
+    <string name="restore_warning_continued">Försök inte att återställa säkerhetskopior som du inte har skapat själv!</string>
+    <string name="could_not_delete_account_from_server">Det gick inte att ta bort kontot från servern</string>
 </resources>

src/main/res/values-zh-rCN/strings.xml 🔗

@@ -102,8 +102,8 @@
     <string name="send_unencrypted">发送未加密的</string>
     <string name="decryption_failed">解密失败。也许您没有正确的私钥。</string>
     <string name="openkeychain_required">OpenKeychain</string>
-    <string name="openkeychain_required_long">%1$s 使用 &lt;b&gt;OpenKeychain&lt;/b&gt; 来加密和解密消息并管理公钥。&lt;br&gt;&lt;br&gt;它在 GPLv3+ 许可证下授权并可在 F-Droid 和 Google Play 上获得。&lt;br&gt;&lt;br&gt;&lt;small&gt;(请之后重新启动 %1$s。)&lt;/small&gt;</string>
-    <string name="restart">重新启动</string>
+    <string name="openkeychain_required_long">%1$s 使用 &lt;b&gt;OpenKeychain&lt;/b&gt; 来加密和解密消息并管理公钥。&lt;br&gt;&lt;br&gt;它在 GPLv3+ 许可证下授权并可在 F-Droid 和 Google Play 上获得。&lt;br&gt;&lt;br&gt;&lt;small&gt;(请之后重启 %1$s。)&lt;/small&gt;</string>
+    <string name="restart">重启</string>
     <string name="install">安装</string>
     <string name="openkeychain_not_installed">请安装 OpenKeychain</string>
     <string name="offering">正在提供…</string>
@@ -641,8 +641,8 @@
     <string name="pref_clean_private_storage">清理私人存储空间</string>
     <string name="pref_clean_private_storage_summary">清理保存文件的私人存储 (它们可以从服务器重新下载)</string>
     <string name="i_followed_this_link_from_a_trusted_source">我从可信来源收到此链接</string>
-    <string name="verifying_omemo_keys_trusted_source">单击链接后,您将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)收到此链接才是安全的。</string>
-    <string name="verifying_omemo_keys_trusted_source_account">您将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)收到此链接才是安全的。</string>
+    <string name="verifying_omemo_keys_trusted_source">单击链接后,您将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)收到此链接才是安全的。</string>
+    <string name="verifying_omemo_keys_trusted_source_account">您将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)收到此链接才是安全的。</string>
     <string name="continue_btn">继续</string>
     <string name="verify_omemo_keys">验证 OMEMO 密钥</string>
     <string name="show_inactive_devices">显示非活动设备</string>
@@ -782,7 +782,7 @@
     <string name="ongoing_calls_channel_name">正在进行的通话</string>
     <string name="missed_calls_channel_name">未接来电</string>
     <string name="silent_messages_channel_name">静音消息</string>
-    <string name="silent_messages_channel_description">此通知组用于显示不应触发任何声音的通知。例如,在另一台设备上处于活动状态时(静默期)。</string>
+    <string name="silent_messages_channel_description">此通知组用于显示不应触发任何声音的通知。例如,在另一台设备上处于活动状态时(静默期)。</string>
     <string name="delivery_failed_channel_name">传递失败</string>
     <string name="pref_message_notification_settings">消息通知设置</string>
     <string name="pref_incoming_call_notification_settings">来电通知设置</string>
@@ -803,7 +803,7 @@
     <string name="choose_a_country">选择国家/地区</string>
     <string name="phone_number">电话号码</string>
     <string name="verify_your_phone_number">验证您的电话号码</string>
-    <string name="enter_country_code_and_phone_number">Quicksy 将发送短信(运营商可能收费)来验证电话号码。请输入您的国家/地区代码和电话号码:</string>
+    <string name="enter_country_code_and_phone_number">Quicksy 将发送短信(运营商可能收费)来验证电话号码。请输入您的国家/地区代码和电话号码:</string>
     <string name="we_will_be_verifying">我们将验证这个电话号码<br/><br/><b>%s</b><br/><br/>可以吗?是否编辑号码?</string>
     <string name="not_a_valid_phone_number">%s 不是有效的电话号码。</string>
     <string name="please_enter_your_phone_number">请输入您的电话号码。</string>
@@ -857,7 +857,7 @@
     <string name="restore_backup">恢复备份</string>
     <string name="restore">恢复</string>
     <string name="enter_password_to_restore">输入 %s 账号的密码以恢复备份。</string>
-    <string name="restore_warning">请勿使用恢复备份功能尝试克隆(同时运行)安装。恢复备份仅适用于迁移或您丢失原始设备的情况。</string>
+    <string name="restore_warning">请勿使用恢复备份功能尝试克隆(同时运行)安装。恢复备份仅适用于迁移或您丢失原始设备的情况。</string>
     <string name="unable_to_restore_backup">无法恢复备份。</string>
     <string name="unable_to_decrypt_backup">无法解密备份。密码是否正确?</string>
     <string name="backup_channel_name">备份 &amp; 恢复</string>
@@ -924,7 +924,7 @@
     <string name="rtp_state_content_add">添加额外轨道?</string>
     <string name="rtp_state_connecting">正在连接</string>
     <string name="rtp_state_connected">已连接</string>
-    <string name="rtp_state_reconnecting">正在重新连接</string>
+    <string name="rtp_state_reconnecting">正在重连</string>
     <string name="rtp_state_accepting_call">正在接受通话</string>
     <string name="rtp_state_ending_call">正在结束通话</string>
     <string name="answer_call">应答</string>
@@ -940,8 +940,8 @@
     <string name="hang_up">挂断</string>
     <string name="ongoing_call">正在进行的通话</string>
     <string name="ongoing_video_call">正在进行的视频通话</string>
-    <string name="reconnecting_call">正在重新连接通话</string>
-    <string name="reconnecting_video_call">正在重新连接视频通话</string>
+    <string name="reconnecting_call">正在重连通话</string>
+    <string name="reconnecting_video_call">正在重连视频通话</string>
     <string name="disable_tor_to_make_call">禁用 Tor 以进行通话</string>
     <string name="incoming_call">来电</string>
     <string name="missed_call_timestamp">未接来电 · %s</string>

src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt 🔗

@@ -4,10 +4,10 @@ Quicksy 是流行的 Jabber/XMPP 客户端 Conversations 的衍生品,具有
 
 从本质上讲,Quicksy 是成熟的 Jabber 客户端,可让您与任何公共联合服务器上的任何用户进行交流。同样,只需将 +phonenumber@quicksy.im 添加到您的联系人列表中,即可从外部联系 Quicksy 上的用户。
 
-除了联系人同步之外,用户界面尽可能地接近 Conversations。这使得用户最终可以从 Quicksy 迁移到 Conversations,而无需重新了解应用程序的工作方式。
+除了联系人同步之外,用户界面尽可能地接近 Conversations。让用户最终可以从 Quicksy 迁移到 Conversations,而无需重新了解应用程序的工作方式。
 
-建议的联系人包括其他 Quicksy 用户和在 Quicksy 目录中输入 Jabber ID 的普通 Jabber/XMPP 用户(https://quicksy.im/#get-listed)。
+建议的联系人包括其他 Quicksy 用户和在 Quicksy 目录(https://quicksy.im/#get-listed)中输入 Jabber ID 的普通 Jabber/XMPP 用户。
 
 注意:要在 Quicksy 目录中输入(https://quicksy.im/enter/)您的 Jabber ID 需要缴纳一次性注册费。
 
-请阅读隐私政策(https://quicksy.im/#privacy),了解更多信息。
+请阅读隐私政策(https://quicksy.im/#privacy)了解更多信息。

src/quicksy/res/values-eo/strings.xml 🔗

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="huawei_protected_apps_summary">Por daŭre ricevi sciigojn, eĉ kiam la ekrano estas malŝaltita, vi devas aldoni Quicksy al la listo de protektitaj programoj.</string>
+    <string name="pref_never_send_crash_summary">Sendante stakspurojn vi helpas la daŭran disvolviĝon de Quicksy</string>
+    <string name="unable_to_verify_server_identity">Ne eblas kontroli servilan identecon.</string>
+    <string name="unknown_security_error">Nekonata sekureca eraro.</string>
+    <string name="timeout_while_connecting_to_server">Eltempiĝo dum konektante al servilo.</string>
+    <string name="not_available_in_your_country">Quicksy ne haveblas en via lando.</string>
+    <string name="set_profile_picture">Quicksy profilbildo</string>
+    <string name="pref_notification_grace_period_summary">La tempodaŭro Quicksy silentas post vidado de agado sur alia aparato</string>
+    <string name="pref_broadcast_last_activity_summary">Sciigi ĉiujn viajn kontaktojn kiam vi uzas Quicksy</string>
+</resources>