Detailed changes
  
  
    
    @@ -1,5 +1,13 @@
 # Changelog
 
+### Version 2.11.0
+
+* Implement Extensible SASL Profile, Bind 2.0 and Fast for faster reconnects
+* Implement Channel Binding
+* Add ability to switch from audio call to video call
+* Add ability to delete own avatar
+* Add notification for missed calls
+
 ### Version 2.10.10
 
 * Minor bug fixes
  
  
  
    
    @@ -6,7 +6,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:7.2.2'
+        classpath 'com.android.tools.build:gradle:7.3.1'
     }
 }
 
@@ -49,7 +49,7 @@ dependencies {
 
     implementation 'androidx.viewpager:viewpager:1.0.0'
 
-    playstoreImplementation('com.google.firebase:firebase-messaging:23.0.7') {
+    playstoreImplementation('com.google.firebase:firebase-messaging:23.1.0') {
         exclude group: 'com.google.firebase', module: 'firebase-core'
         exclude group: 'com.google.firebase', module: 'firebase-analytics'
         exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
@@ -59,11 +59,11 @@ dependencies {
     quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1'
     implementation 'org.sufficientlysecure:openpgp-api:10.0'
     implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
-    implementation 'androidx.appcompat:appcompat:1.5.0'
-    implementation 'androidx.exifinterface:exifinterface:1.3.3'
+    implementation 'androidx.appcompat:appcompat:1.5.1'
+    implementation 'androidx.exifinterface:exifinterface:1.3.5'
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
-    implementation 'com.google.android.material:material:1.4.0'
+    implementation 'com.google.android.material:material:1.7.0'
 
     implementation "androidx.emoji2:emoji2:1.2.0"
     freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0"
@@ -77,7 +77,8 @@ dependencies {
     implementation 'org.whispersystems:signal-protocol-android:2.6.2'
     implementation 'com.makeramen:roundedimageview:2.3.0'
     implementation "com.wefika:flowlayout:0.4.1"
-    implementation 'com.otaliastudios:transcoder:0.10.4'
+    //noinspection GradleDependency
+    implementation 'com.otaliastudios:transcoder:0.9.1'
 
     implementation 'org.jxmpp:jxmpp-jid:1.0.3'
     implementation 'org.osmdroid:osmdroid-android:6.1.11'
  
  
  
    
    @@ -0,0 +1,5 @@
+* Implement Extensible SASL Profile, Bind 2.0 and Fast for faster reconnects
+* Implement Channel Binding
+* Add ability to switch from audio call to video call
+* Add ability to delete own avatar
+* Add notification for missed calls
  
  
  
    
    @@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionSha256Sum=c9490e938b221daf0094982288e4038deed954a3f12fb54cbf270ddf4e37d879
+distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
  
  
  
    
    @@ -100,7 +100,7 @@ public class MagicCreateActivity extends XmppActivity implements TextWatcher {
                         account.setOption(Account.OPTION_MAGIC_CREATE, true);
                         account.setOption(Account.OPTION_FIXED_USERNAME, fixedUsername);
                         if (this.preAuth != null) {
-                            account.setKey(Account.PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
+                            account.setKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
                         }
                         xmppConnectionService.createAccount(account);
                     }
  
  
  
    
    @@ -100,7 +100,7 @@ public class MagicCreateActivity extends XmppActivity implements TextWatcher {
                         account.setOption(Account.OPTION_MAGIC_CREATE, true);
                         account.setOption(Account.OPTION_FIXED_USERNAME, fixedUsername);
                         if (this.preAuth != null) {
-                            account.setKey(Account.PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
+                            account.setKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
                         }
                         xmppConnectionService.createAccount(account);
                     }
  
  
  
    
    @@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="pick_a_server">Odaberite svog XMPP davatelja usluga.</string>
+    <string name="use_conversations.im">Koristite conversations.im</string>
+    <string name="create_new_account">Napravi novi račun</string>
+    <string name="do_you_have_an_account">Već imate XMPP račun? To može biti slučaj ako već koristite drugi XMPP klijent ili ste prije koristili Razgovore. Ako niste, možete odmah stvoriti novi XMPP račun.\nSavjet: Neki pružatelji usluga e-pošte također nude XMPP račune.</string>
+    <string name="server_select_text">XMPP je mreža za razmjenu izravnih poruka neovisna o pružatelju usluga. Možete koristiti ovaj klijent s bilo kojim XMPP poslužiteljem koji odaberete.\nMeđutim, radi vaše udobnosti olakšali smo kreiranje računa na conversations.im; pružatelj usluga posebno prilagođen za korištenje s Conversations.</string>
+    <string name="magic_create_text_on_x">Pozvani ste na %1$s. Vodit ćemo vas kroz postupak kreiranja računa.\nPrilikom odabira  %1$s pružatelja moći ćete komunicirati s korisnicima drugih pružatelja dajući im svoju punu XMPP adresu.</string>
+    <string name="magic_create_text_fixed">Pozvani ste na %1$s. Korisničko ime je već odabrano za vas. Vodit ćemo vas kroz postupak kreiranja računa.\nMoći ćete komunicirati s korisnicima drugih pružatelja tako da im date svoju punu XMPP adresu.</string>
+    <string name="your_server_invitation">Vaša pozivnica za poslužitelj</string>
+    <string name="improperly_formatted_provisioning">Neispravno formatiran kod za dodjelu</string>
+    <string name="tap_share_button_send_invite">Dodirnite gumb za dijeljenje kako biste svom kontaktu poslali pozivnicu na %1$s.</string>
+    <string name="if_contact_is_nearby_use_qr">Ako je vaš kontakt u blizini, također može skenirati kod u nastavku kako bi prihvatio vašu pozivnicu.</string>
+    <string name="easy_invite_share_text">Pridružite se %1$s i razgovarajte sa mnom: %2$s</string>
+    <string name="share_invite_with">Podijelite pozivnicu s...</string>
+</resources>
  
  
  
    
    @@ -1,12 +1,12 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="pick_a_server">选择您的XMPP提供者</string>
-    <string name="use_conversations.im">使用conversations.im</string>
+    <string name="pick_a_server">选择您的 XMPP 提供者</string>
+    <string name="use_conversations.im">使用 conversations.im</string>
     <string name="create_new_account">创建新账户</string>
-    <string name="do_you_have_an_account">您已经拥有一个XMPP账户了吗?如果您之前使用过其他的XMPP客户端的话,那么您已经拥有这种账户了。如果没有账户的话,您可以现在创建一个。\n提示:有些电子邮件服务也提供XMPP账户。</string>
-    <string name="server_select_text">XMPP是独立于提供程序的即时消息网络。 您可以将此客户端与所选的任何XMPP服务器一起使用。\ n不过,为了您的方便,我们很容易在对话中创建帐户。im; 特别适合与“对话”配合使用的提供商。</string>
-    <string name="magic_create_text_on_x">您已受邀参加%1$s。 我们将指导您完成创建帐户的过程。\n选择%1$s作为提供者后,您可以通过提供其他人的完整XMPP地址与其他提供者的用户进行交流。</string>
-    <string name="magic_create_text_fixed">您已受邀参加%1$s。 已经为您选择了一个用户名。 我们将指导您完成创建帐户的过程。\n您可以通过向其他提供商的用户提供完整的XMPP地址来与他们进行交流。</string>
+    <string name="do_you_have_an_account">您有 XMPP 账户吗?如果您之前使用过其他的 XMPP 客户端,那么您已经拥有这种账户了。如果没有的话,您现在可以创建一个。\n提示:有些电子邮件服务也提供XMPP账户。</string>
+    <string name="server_select_text">XMPP 是独立于提供者的即时消息网络。您可以将此客户端与任意 XMPP 服务器一同使用。\n不过,您可以很容易地在 conversations.im 上创建账户;它是特别适合与“Conversations”一起使用的提供者。</string>
+    <string name="magic_create_text_on_x">您已受邀加入 %1$s。我们将指导您完成创建帐户的过程。\n使用 %1$s 作为提供者时,您可以通过您的完整 XMPP 地址与其他提供者的用户进行交流。</string>
+    <string name="magic_create_text_fixed">您已受邀加入 %1$s。已为您选择了一个用户名。我们将指导您完成创建帐户的过程。\n您可以使用完整的 XMPP 地址来与其他提供者的用户进行交流。</string>
     <string name="your_server_invitation">你的服务器邀请</string>
     <string name="improperly_formatted_provisioning">格式不正确的配置代码</string>
     <string name="tap_share_button_send_invite">点击分享按钮向您的联系人发送加入 %1$s 的邀请。</string>
  
  
  
    
    @@ -3,6 +3,14 @@
     <string name="pick_a_server">挑選您的 XMPP 提供者</string>
     <string name="use_conversations.im">使用 conversations.im</string>
     <string name="create_new_account">建立新帳戶</string>
+    <string name="do_you_have_an_account">您已經擁有一個 XMPP 賬戶嗎?如果您之前使用過其他 XMPP 客戶端或 Conversations 的話,那麼您已經擁有 XMPP 賬戶了。如果沒有賬戶的話,您可以現在建立一個。\n提示:有些電子郵件服務供應商也會提供 XMPP 賬戶。</string>
+    <string name="server_select_text">XMPP 是提供者無關的即時訊息網絡。 任何你選擇的 XMPP 伺服器都可在此客戶端上使用。\n不過,我們令它在 Coversations.im 中建立帳戶變得更方便; Conversations.im 是特別適合 Conversations 的提供者</string>
+    <string name="magic_create_text_on_x">你已受邀參加 %1$s 。 我們將指導你完成建立帳戶的過程。選擇 %1$s 作爲提供者後,你可以將你完整的 XMPP 地址交給使用其他提供者的用戶,你便能與他們交流。</string>
+    <string name="magic_create_text_fixed">您已被邀請參加 %1$s 。 我們已經爲你選擇了一個用戶名。 我們將指導你完成建立帳戶的過程。\n將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們交流。</string>
     <string name="your_server_invitation">您的伺服器邀請</string>
-    <string name="share_invite_with">分享邀請至…</string>
+    <string name="improperly_formatted_provisioning">配置代碼格式不正確</string>
+    <string name="tap_share_button_send_invite">輕觸分享按鍵向您的聯絡人發送加入 %1$s 的邀請。</string>
+    <string name="if_contact_is_nearby_use_qr">如果你的聯絡人就在附近,他們也可以掃描下面的代碼來接受你的邀請。</string>
+    <string name="easy_invite_share_text">加入 %1$s 和我聊天:%2$s</string>
+    <string name="share_invite_with">分享邀請到...</string>
 </resources>
  
  
  
    
    @@ -64,6 +64,9 @@
             <action android:name="android.intent.action.VIEW" />
             <data android:mimeType="resource/folder" />
         </intent>
+        <intent>
+            <action android:name="android.intent.action.VIEW" />
+        </intent>
     </queries>
 
 
  
  
  
    
    @@ -15,10 +15,9 @@ import eu.siacs.conversations.xmpp.chatstate.ChatState;
 public final class Config {
     private static final int UNENCRYPTED = 1;
     private static final int OPENPGP = 2;
-    private static final int OTR = 4;
     private static final int OMEMO = 8;
 
-    private static final int ENCRYPTION_MASK = UNENCRYPTED | OPENPGP | OTR | OMEMO;
+    private static final int ENCRYPTION_MASK = UNENCRYPTED | OPENPGP | OMEMO;
 
     public static boolean supportUnencrypted() {
         return (ENCRYPTION_MASK & UNENCRYPTED) != 0;
@@ -32,6 +31,10 @@ public final class Config {
         return (ENCRYPTION_MASK & OMEMO) != 0;
     }
 
+    public static boolean omemoOnly() {
+        return !multipleEncryptionChoices() && supportOmemo();
+    }
+
     public static boolean multipleEncryptionChoices() {
         return (ENCRYPTION_MASK & (ENCRYPTION_MASK - 1)) != 0;
     }
@@ -57,6 +60,8 @@ public final class Config {
     public static final long CONTACT_SYNC_RETRY_INTERVAL = 1000L * 60 * 5;
 
 
+    public static final boolean QUICKSTART_ENABLED = true;
+
     //Notification settings
     public static final boolean HIDE_MESSAGE_TEXT_IN_NOTIFICATION = false;
     public static final boolean ALWAYS_NOTIFY_BY_DEFAULT = false;
@@ -210,5 +215,5 @@ public final class Config {
     // How deep nested quotes should be displayed. '2' means one quote nested in another.
     public static final int QUOTE_MAX_DEPTH = 7;
     // How deep nested quotes should be created on quoting a message.
-    public static final int QUOTING_MAX_DEPTH = 1;
+    public static final int QUOTING_MAX_DEPTH = 2;
 }
  
  
  
    
    @@ -34,6 +34,9 @@ import android.content.Context;
 import android.content.SharedPreferences;
 import android.preference.PreferenceManager;
 
+import com.google.common.base.Strings;
+
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.ui.SettingsActivity;
@@ -52,8 +55,13 @@ public class OmemoSetting {
 	}
 
 	public static void load(final Context context, final SharedPreferences sharedPreferences) {
+		if (Config.omemoOnly()) {
+			always = true;
+			encryption = Message.ENCRYPTION_AXOLOTL;
+			return;
+		}
 		final String value = sharedPreferences.getString(SettingsActivity.OMEMO_SETTING, context.getResources().getString(R.string.omemo_setting_default));
-		switch (value) {
+		switch (Strings.nullToEmpty(value)) {
 			case "always":
 				always = true;
 				encryption = Message.ENCRYPTION_AXOLOTL;
  
  
  
    
    @@ -1,5 +1,7 @@
 package eu.siacs.conversations.crypto.axolotl;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.os.Bundle;
 import android.security.KeyChain;
 import android.util.Log;
@@ -499,7 +501,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             PrivateKey x509PrivateKey = KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias());
             X509Certificate[] chain = KeyChain.getCertificateChain(mXmppConnectionService, account.getPrivateKeyAlias());
             Signature verifier = Signature.getInstance("sha256WithRSA");
-            verifier.initSign(x509PrivateKey, mXmppConnectionService.getRNG());
+            verifier.initSign(x509PrivateKey, SECURE_RANDOM);
             verifier.update(axolotlPublicKey.serialize());
             byte[] signature = verifier.sign();
             IqPacket packet = mXmppConnectionService.getIqGenerator().publishVerification(signature, chain, getOwnDeviceId());
@@ -708,11 +710,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     }
 
     public void deleteOmemoIdentity() {
-        final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId();
-        final IqPacket deleteBundleNode = mXmppConnectionService.getIqGenerator().deleteNode(node);
-        mXmppConnectionService.sendIqPacket(account, deleteBundleNode, null);
+        mXmppConnectionService.deletePepNode(
+                account, AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId());
         final Set<Integer> ownDeviceIds = getOwnDeviceIds();
-        publishDeviceIdsAndRefineAccessModel(ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds);
+        publishDeviceIdsAndRefineAccessModel(
+                ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds);
     }
 
     public List<Jid> getCryptoTargets(Conversation conversation) {
@@ -1270,7 +1272,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             }
             descriptionTransportBuilder.put(
                     content.getKey(),
-                    new RtpContentMap.DescriptionTransport(descriptionTransport.description, encryptedTransportInfo)
+                    new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo)
             );
         }
         return Futures.immediateFuture(
@@ -1304,7 +1306,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             omemoVerification.setOrEnsureEqual(decryptedTransport);
             descriptionTransportBuilder.put(
                     content.getKey(),
-                    new RtpContentMap.DescriptionTransport(descriptionTransport.description, decryptedTransport.payload)
+                    new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload)
             );
         }
         processPostponed();
  
  
  
    
    @@ -1,16 +1,15 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import java.security.SecureRandom;
+import javax.net.ssl.SSLSocket;
 
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class Anonymous extends SaslMechanism {
 
     public static final String MECHANISM = "ANONYMOUS";
 
-    public Anonymous(TagWriter tagWriter, Account account, SecureRandom rng) {
-        super(tagWriter, account, rng);
+    public Anonymous(final Account account) {
+        super(account);
     }
 
     @Override
@@ -24,7 +23,7 @@ public class Anonymous extends SaslMechanism {
     }
 
     @Override
-    public String getClientFirstMessage() {
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
         return "";
     }
 }
  
  
  
    
    @@ -0,0 +1,120 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import android.util.Log;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableBiMap;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.SSLSockets;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+
+public enum ChannelBinding {
+    NONE,
+    TLS_EXPORTER,
+    TLS_SERVER_END_POINT,
+    TLS_UNIQUE;
+
+    public static final BiMap<ChannelBinding, String> SHORT_NAMES;
+
+    static {
+        final ImmutableBiMap.Builder<ChannelBinding, String> builder = ImmutableBiMap.builder();
+        for (final ChannelBinding cb : values()) {
+            builder.put(cb, shortName(cb));
+        }
+        SHORT_NAMES = builder.build();
+    }
+
+    public static Collection<ChannelBinding> of(final Element channelBinding) {
+        Preconditions.checkArgument(
+                channelBinding == null
+                        || ("sasl-channel-binding".equals(channelBinding.getName())
+                                && Namespace.CHANNEL_BINDING.equals(channelBinding.getNamespace())),
+                "pass null or a valid channel binding stream feature");
+        return Collections2.filter(
+                Collections2.transform(
+                        Collections2.filter(
+                                channelBinding == null
+                                        ? Collections.emptyList()
+                                        : channelBinding.getChildren(),
+                                c -> c != null && "channel-binding".equals(c.getName())),
+                        c -> c == null ? null : ChannelBinding.of(c.getAttribute("type"))),
+                Predicates.notNull());
+    }
+
+    private static ChannelBinding of(final String type) {
+        if (type == null) {
+            return null;
+        }
+        try {
+            return valueOf(
+                    CaseFormat.LOWER_HYPHEN.converterTo(CaseFormat.UPPER_UNDERSCORE).convert(type));
+        } catch (final IllegalArgumentException e) {
+            Log.d(Config.LOGTAG, type + " is not a known channel binding");
+            return null;
+        }
+    }
+
+    public static ChannelBinding get(final String name) {
+        if (Strings.isNullOrEmpty(name)) {
+            return NONE;
+        }
+        try {
+            return valueOf(name);
+        } catch (final IllegalArgumentException e) {
+            return NONE;
+        }
+    }
+
+    public static ChannelBinding best(
+            final Collection<ChannelBinding> bindings, final SSLSockets.Version sslVersion) {
+        if (sslVersion == SSLSockets.Version.NONE) {
+            return NONE;
+        }
+        if (bindings.contains(TLS_EXPORTER) && sslVersion == SSLSockets.Version.TLS_1_3) {
+            return TLS_EXPORTER;
+        } else if (bindings.contains(TLS_UNIQUE)
+                && Arrays.asList(
+                                SSLSockets.Version.TLS_1_0,
+                                SSLSockets.Version.TLS_1_1,
+                                SSLSockets.Version.TLS_1_2)
+                        .contains(sslVersion)) {
+            return TLS_UNIQUE;
+        } else if (bindings.contains(TLS_SERVER_END_POINT)) {
+            return TLS_SERVER_END_POINT;
+        } else {
+            return NONE;
+        }
+    }
+
+    public static boolean isAvailable(
+            final ChannelBinding channelBinding, final SSLSockets.Version sslVersion) {
+        return ChannelBinding.best(Collections.singleton(channelBinding), sslVersion)
+                == channelBinding;
+    }
+
+    private static String shortName(final ChannelBinding channelBinding) {
+        switch (channelBinding) {
+            case TLS_UNIQUE:
+                return "UNIQ";
+            case TLS_EXPORTER:
+                return "EXPR";
+            case TLS_SERVER_END_POINT:
+                return "ENDP";
+            case NONE:
+                return "NONE";
+            default:
+                throw new AssertionError("Missing short name for " + channelBinding);
+        }
+    }
+}
  
  
  
    
    @@ -0,0 +1,100 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import org.bouncycastle.jcajce.provider.digest.SHA256;
+import org.conscrypt.Conscrypt;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+
+public interface ChannelBindingMechanism {
+
+    String EXPORTER_LABEL = "EXPORTER-Channel-Binding";
+
+    ChannelBinding getChannelBinding();
+
+    static byte[] getChannelBindingData(final SSLSocket sslSocket, final ChannelBinding channelBinding)
+            throws SaslMechanism.AuthenticationException {
+        if (sslSocket == null) {
+            throw new SaslMechanism.AuthenticationException("Channel binding attempt on non secure socket");
+        }
+        if (channelBinding == ChannelBinding.TLS_EXPORTER) {
+            final byte[] keyingMaterial;
+            try {
+                keyingMaterial =
+                        Conscrypt.exportKeyingMaterial(sslSocket, EXPORTER_LABEL, new byte[0], 32);
+            } catch (final SSLException e) {
+                throw new SaslMechanism.AuthenticationException("Could not export keying material");
+            }
+            if (keyingMaterial == null) {
+                throw new SaslMechanism.AuthenticationException(
+                        "Could not export keying material. Socket not ready");
+            }
+            return keyingMaterial;
+        } else if (channelBinding == ChannelBinding.TLS_UNIQUE) {
+            final byte[] unique = Conscrypt.getTlsUnique(sslSocket);
+            if (unique == null) {
+                throw new SaslMechanism.AuthenticationException(
+                        "Could not retrieve tls unique. Socket not ready");
+            }
+            return unique;
+        } else if (channelBinding == ChannelBinding.TLS_SERVER_END_POINT) {
+            return getServerEndPointChannelBinding(sslSocket.getSession());
+        } else {
+            throw new SaslMechanism.AuthenticationException(
+                    String.format("%s is not a valid channel binding", channelBinding));
+        }
+    }
+
+    static byte[] getServerEndPointChannelBinding(final SSLSession session)
+            throws SaslMechanism.AuthenticationException {
+        final Certificate[] certificates;
+        try {
+            certificates = session.getPeerCertificates();
+        } catch (final SSLPeerUnverifiedException e) {
+            throw new SaslMechanism.AuthenticationException("Could not verify peer certificates");
+        }
+        if (certificates == null || certificates.length == 0) {
+            throw new SaslMechanism.AuthenticationException("Could not retrieve peer certificate");
+        }
+        final X509Certificate certificate;
+        if (certificates[0] instanceof X509Certificate) {
+            certificate = (X509Certificate) certificates[0];
+        } else {
+            throw new SaslMechanism.AuthenticationException("Certificate was not X509");
+        }
+        final String algorithm = certificate.getSigAlgName();
+        final int withIndex = algorithm.indexOf("with");
+        if (withIndex <= 0) {
+            throw new SaslMechanism.AuthenticationException("Unable to parse SigAlgName");
+        }
+        final String hashAlgorithm = algorithm.substring(0, withIndex);
+        final MessageDigest messageDigest;
+        // https://www.rfc-editor.org/rfc/rfc5929#section-4.1
+        if ("MD5".equalsIgnoreCase(hashAlgorithm) || "SHA1".equalsIgnoreCase(hashAlgorithm)) {
+            messageDigest = new SHA256.Digest();
+        } else {
+            try {
+                messageDigest = MessageDigest.getInstance(hashAlgorithm);
+            } catch (final NoSuchAlgorithmException e) {
+                throw new SaslMechanism.AuthenticationException(
+                        "Could not instantiate message digest for " + hashAlgorithm);
+            }
+        }
+        final byte[] encodedCertificate;
+        try {
+            encodedCertificate = certificate.getEncoded();
+        } catch (final CertificateEncodingException e) {
+            throw new SaslMechanism.AuthenticationException("Could not encode certificate");
+        }
+        messageDigest.update(encodedCertificate);
+        return messageDigest.digest();
+    }
+}
  
  
  
    
    @@ -5,18 +5,19 @@ import android.util.Base64;
 import java.nio.charset.Charset;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
+
+import javax.net.ssl.SSLSocket;
 
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class DigestMd5 extends SaslMechanism {
 
     public static final String MECHANISM = "DIGEST-MD5";
+    private State state = State.INITIAL;
 
-    public DigestMd5(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        super(tagWriter, account, rng);
+    public DigestMd5(final Account account) {
+        super(account);
     }
 
     @Override
@@ -29,16 +30,16 @@ public class DigestMd5 extends SaslMechanism {
         return MECHANISM;
     }
 
-    private State state = State.INITIAL;
-
     @Override
-    public String getResponse(final String challenge) throws AuthenticationException {
+    public String getResponse(final String challenge, final SSLSocket sslSocket)
+            throws AuthenticationException {
         switch (state) {
             case INITIAL:
                 state = State.RESPONSE_SENT;
                 final String encodedResponse;
                 try {
-                    final Tokenizer tokenizer = new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
+                    final Tokenizer tokenizer =
+                            new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
                     String nonce = "";
                     for (final String token : tokenizer) {
                         final String[] parts = token.split("=", 2);
@@ -50,29 +51,49 @@ public class DigestMd5 extends SaslMechanism {
                     }
                     final String digestUri = "xmpp/" + account.getServer();
                     final String nonceCount = "00000001";
-                    final String x = account.getUsername() + ":" + account.getServer() + ":"
-                            + account.getPassword();
+                    final String x =
+                            account.getUsername()
+                                    + ":"
+                                    + account.getServer()
+                                    + ":"
+                                    + account.getPassword();
                     final MessageDigest md = MessageDigest.getInstance("MD5");
                     final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
-                    final String cNonce = CryptoHelper.random(100, rng);
-                    final byte[] a1 = CryptoHelper.concatenateByteArrays(y,
-                            (":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
+                    final String cNonce = CryptoHelper.random(100);
+                    final byte[] a1 =
+                            CryptoHelper.concatenateByteArrays(
+                                    y,
+                                    (":" + nonce + ":" + cNonce)
+                                            .getBytes(Charset.defaultCharset()));
                     final String a2 = "AUTHENTICATE:" + digestUri;
                     final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
-                    final String ha2 = CryptoHelper.bytesToHex(md.digest(a2.getBytes(Charset
-                            .defaultCharset())));
-                    final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce
-                            + ":auth:" + ha2;
-                    final String response = CryptoHelper.bytesToHex(md.digest(kd.getBytes(Charset
-                            .defaultCharset())));
-                    final String saslString = "username=\"" + account.getUsername()
-                            + "\",realm=\"" + account.getServer() + "\",nonce=\""
-                            + nonce + "\",cnonce=\"" + cNonce + "\",nc=" + nonceCount
-                            + ",qop=auth,digest-uri=\"" + digestUri + "\",response="
-                            + response + ",charset=utf-8";
-                    encodedResponse = Base64.encodeToString(
-                            saslString.getBytes(Charset.defaultCharset()),
-                            Base64.NO_WRAP);
+                    final String ha2 =
+                            CryptoHelper.bytesToHex(
+                                    md.digest(a2.getBytes(Charset.defaultCharset())));
+                    final String kd =
+                            ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
+                    final String response =
+                            CryptoHelper.bytesToHex(
+                                    md.digest(kd.getBytes(Charset.defaultCharset())));
+                    final String saslString =
+                            "username=\""
+                                    + account.getUsername()
+                                    + "\",realm=\""
+                                    + account.getServer()
+                                    + "\",nonce=\""
+                                    + nonce
+                                    + "\",cnonce=\""
+                                    + cNonce
+                                    + "\",nc="
+                                    + nonceCount
+                                    + ",qop=auth,digest-uri=\""
+                                    + digestUri
+                                    + "\",response="
+                                    + response
+                                    + ",charset=utf-8";
+                    encodedResponse =
+                            Base64.encodeToString(
+                                    saslString.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
                 } catch (final NoSuchAlgorithmException e) {
                     throw new AuthenticationException(e);
                 }
@@ -83,7 +104,7 @@ public class DigestMd5 extends SaslMechanism {
                 break;
             case VALID_SERVER_RESPONSE:
                 if (challenge == null) {
-                    return null; //everything is fine
+                    return null; // everything is fine
                 }
             default:
                 throw new InvalidStateException(state);
  
  
  
    
    @@ -2,17 +2,16 @@ package eu.siacs.conversations.crypto.sasl;
 
 import android.util.Base64;
 
-import java.security.SecureRandom;
+import javax.net.ssl.SSLSocket;
 
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class External extends SaslMechanism {
 
     public static final String MECHANISM = "EXTERNAL";
 
-    public External(TagWriter tagWriter, Account account, SecureRandom rng) {
-        super(tagWriter, account, rng);
+    public External(final Account account) {
+        super(account);
     }
 
     @Override
@@ -26,7 +25,8 @@ public class External extends SaslMechanism {
     }
 
     @Override
-    public String getClientFirstMessage() {
-        return Base64.encodeToString(account.getJid().asBareJid().toEscapedString().getBytes(), Base64.NO_WRAP);
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
+        return Base64.encodeToString(
+                account.getJid().asBareJid().toEscapedString().getBytes(), Base64.NO_WRAP);
     }
 }
  
  
  
    
    @@ -0,0 +1,190 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import android.util.Base64;
+import android.util.Log;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.hash.HashFunction;
+import com.google.common.primitives.Bytes;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.utils.SSLSockets;
+
+public abstract class HashedToken extends SaslMechanism implements ChannelBindingMechanism {
+
+    private static final String PREFIX = "HT";
+
+    private static final List<String> HASH_FUNCTIONS = Arrays.asList("SHA-512", "SHA-256");
+    private static final byte[] INITIATOR = "Initiator".getBytes(StandardCharsets.UTF_8);
+    private static final byte[] RESPONDER = "Responder".getBytes(StandardCharsets.UTF_8);
+
+    protected final ChannelBinding channelBinding;
+
+    protected HashedToken(final Account account, final ChannelBinding channelBinding) {
+        super(account);
+        this.channelBinding = channelBinding;
+    }
+
+    @Override
+    public int getPriority() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
+        final String token = Strings.nullToEmpty(this.account.getFastToken());
+        final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
+        final byte[] cbData = getChannelBindingData(sslSocket);
+        final byte[] initiatorHashedToken =
+                hashing.hashBytes(Bytes.concat(INITIATOR, cbData)).asBytes();
+        final byte[] firstMessage =
+                Bytes.concat(
+                        account.getUsername().getBytes(StandardCharsets.UTF_8),
+                        new byte[] {0x00},
+                        initiatorHashedToken);
+        return Base64.encodeToString(firstMessage, Base64.NO_WRAP);
+    }
+
+    private byte[] getChannelBindingData(final SSLSocket sslSocket) {
+        if (this.channelBinding == ChannelBinding.NONE) {
+            return new byte[0];
+        }
+        try {
+            return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
+        } catch (final AuthenticationException e) {
+            Log.e(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": unable to retrieve channel binding data for "
+                            + getMechanism(),
+                    e);
+            return new byte[0];
+        }
+    }
+
+    @Override
+    public String getResponse(final String challenge, final SSLSocket socket)
+            throws AuthenticationException {
+        final byte[] responderMessage;
+        try {
+            responderMessage = Base64.decode(challenge, Base64.NO_WRAP);
+        } catch (final Exception e) {
+            throw new AuthenticationException("Unable to decode responder message", e);
+        }
+        final String token = Strings.nullToEmpty(this.account.getFastToken());
+        final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
+        final byte[] cbData = getChannelBindingData(socket);
+        final byte[] expectedResponderMessage =
+                hashing.hashBytes(Bytes.concat(RESPONDER, cbData)).asBytes();
+        if (Arrays.equals(responderMessage, expectedResponderMessage)) {
+            return null;
+        }
+        throw new AuthenticationException("Responder message did not match");
+    }
+
+    protected abstract HashFunction getHashFunction(final byte[] key);
+
+    public abstract Mechanism getTokenMechanism();
+
+    @Override
+    public String getMechanism() {
+        return getTokenMechanism().name();
+    }
+
+    public static final class Mechanism {
+        public final String hashFunction;
+        public final ChannelBinding channelBinding;
+
+        public Mechanism(String hashFunction, ChannelBinding channelBinding) {
+            this.hashFunction = hashFunction;
+            this.channelBinding = channelBinding;
+        }
+
+        public static Mechanism of(final String mechanism) {
+            final int first = mechanism.indexOf('-');
+            final int last = mechanism.lastIndexOf('-');
+            if (last <= first || mechanism.length() <= last) {
+                throw new IllegalArgumentException("Not a valid HashedToken name");
+            }
+            if (mechanism.substring(0, first).equals(PREFIX)) {
+                final String hashFunction = mechanism.substring(first + 1, last);
+                final String cbShortName = mechanism.substring(last + 1);
+                final ChannelBinding channelBinding =
+                        ChannelBinding.SHORT_NAMES.inverse().get(cbShortName);
+                if (channelBinding == null) {
+                    throw new IllegalArgumentException("Unknown channel binding " + cbShortName);
+                }
+                return new Mechanism(hashFunction, channelBinding);
+            } else {
+                throw new IllegalArgumentException("HashedToken name does not start with HT");
+            }
+        }
+
+        public static Mechanism ofOrNull(final String mechanism) {
+            try {
+                return mechanism == null ? null : of(mechanism);
+            } catch (final IllegalArgumentException e) {
+                return null;
+            }
+        }
+
+        public static Multimap<String, ChannelBinding> of(final Collection<String> mechanisms) {
+            final ImmutableMultimap.Builder<String, ChannelBinding> builder =
+                    ImmutableMultimap.builder();
+            for (final String name : mechanisms) {
+                try {
+                    final Mechanism mechanism = Mechanism.of(name);
+                    builder.put(mechanism.hashFunction, mechanism.channelBinding);
+                } catch (final IllegalArgumentException ignored) {
+                }
+            }
+            return builder.build();
+        }
+
+        public static Mechanism best(
+                final Collection<String> mechanisms, final SSLSockets.Version sslVersion) {
+            final Multimap<String, ChannelBinding> multimap = of(mechanisms);
+            for (final String hashFunction : HASH_FUNCTIONS) {
+                final Collection<ChannelBinding> channelBindings = multimap.get(hashFunction);
+                if (channelBindings.isEmpty()) {
+                    continue;
+                }
+                final ChannelBinding cb = ChannelBinding.best(channelBindings, sslVersion);
+                return new Mechanism(hashFunction, cb);
+            }
+            return null;
+        }
+
+        @NotNull
+        @Override
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("hashFunction", hashFunction)
+                    .add("channelBinding", channelBinding)
+                    .toString();
+        }
+
+        public String name() {
+            return String.format(
+                    "%s-%s-%s",
+                    PREFIX, hashFunction, ChannelBinding.SHORT_NAMES.get(channelBinding));
+        }
+    }
+
+    public ChannelBinding getChannelBinding() {
+        return this.channelBinding;
+    }
+}
  
  
  
    
    @@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class HashedTokenSha256 extends HashedToken {
+
+    public HashedTokenSha256(final Account account, final ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected HashFunction getHashFunction(final byte[] key) {
+        return Hashing.hmacSha256(key);
+    }
+
+    @Override
+    public Mechanism getTokenMechanism() {
+        return new Mechanism("SHA-256", channelBinding);
+    }
+}
  
  
  
    
    @@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class HashedTokenSha512 extends HashedToken {
+
+    public HashedTokenSha512(final Account account, final ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected HashFunction getHashFunction(final byte[] key) {
+        return Hashing.hmacSha512(key);
+    }
+
+    @Override
+    public Mechanism getTokenMechanism() {
+        return new Mechanism("SHA-512", this.channelBinding);
+    }
+}
  
  
  
    
    @@ -4,15 +4,21 @@ import android.util.Base64;
 
 import java.nio.charset.Charset;
 
+import javax.net.ssl.SSLSocket;
+
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class Plain extends SaslMechanism {
 
     public static final String MECHANISM = "PLAIN";
 
-    public Plain(final TagWriter tagWriter, final Account account) {
-        super(tagWriter, account, null);
+    public Plain(final Account account) {
+        super(account);
+    }
+
+    public static String getMessage(String username, String password) {
+        final String message = '\u0000' + username + '\u0000' + password;
+        return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
     }
 
     @Override
@@ -26,12 +32,7 @@ public class Plain extends SaslMechanism {
     }
 
     @Override
-    public String getClientFirstMessage() {
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
         return getMessage(account.getUsername(), account.getPassword());
     }
-
-    public static String getMessage(String username, String password) {
-        final String message = '\u0000' + username + '\u0000' + password;
-        return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
-    }
 }
  
  
  
    
    @@ -1,15 +1,68 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import java.security.SecureRandom;
+import android.util.Log;
 
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
+import eu.siacs.conversations.utils.SSLSockets;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
 
 public abstract class SaslMechanism {
 
-    final protected TagWriter tagWriter;
-    final protected Account account;
-    final protected SecureRandom rng;
+    protected final Account account;
+
+    protected SaslMechanism(final Account account) {
+        this.account = account;
+    }
+
+    public static String namespace(final Version version) {
+        if (version == Version.SASL) {
+            return Namespace.SASL;
+        } else {
+            return Namespace.SASL_2;
+        }
+    }
+
+    /**
+     * The priority is used to pin the authentication mechanism. If authentication fails, it MAY be
+     * retried with another mechanism of the same priority, but MUST NOT be tried with a mechanism
+     * of lower priority (to prevent downgrade attacks).
+     *
+     * @return An arbitrary int representing the priority
+     */
+    public abstract int getPriority();
+
+    public abstract String getMechanism();
+
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
+        return "";
+    }
+
+    public String getResponse(final String challenge, final SSLSocket sslSocket)
+            throws AuthenticationException {
+        return "";
+    }
+
+    public static Collection<String> mechanisms(final Element authElement) {
+        if (authElement == null) {
+            return Collections.emptyList();
+        }
+        return Collections2.transform(
+                Collections2.filter(
+                        authElement.getChildren(),
+                        c -> c != null && "mechanism".equals(c.getName())),
+                c -> c == null ? null : c.getContent());
+    }
 
     protected enum State {
         INITIAL,
@@ -18,6 +71,22 @@ public abstract class SaslMechanism {
         VALID_SERVER_RESPONSE,
     }
 
+    public enum Version {
+        SASL,
+        SASL_2;
+
+        public static Version of(final Element element) {
+            switch (Strings.nullToEmpty(element.getNamespace())) {
+                case Namespace.SASL:
+                    return SASL;
+                case Namespace.SASL_2:
+                    return SASL_2;
+                default:
+                    throw new IllegalArgumentException("Unrecognized SASL namespace");
+            }
+        }
+    }
+
     public static class AuthenticationException extends Exception {
         public AuthenticationException(final String message) {
             super(message);
@@ -42,28 +111,86 @@ public abstract class SaslMechanism {
         }
     }
 
-    public SaslMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        this.tagWriter = tagWriter;
-        this.account = account;
-        this.rng = rng;
-    }
+    public static final class Factory {
 
-    /**
-     * The priority is used to pin the authentication mechanism. If authentication fails, it MAY be retried with another
-     * mechanism of the same priority, but MUST NOT be tried with a mechanism of lower priority (to prevent downgrade
-     * attacks).
-     *
-     * @return An arbitrary int representing the priority
-     */
-    public abstract int getPriority();
+        private final Account account;
 
-    public abstract String getMechanism();
+        public Factory(final Account account) {
+            this.account = account;
+        }
 
-    public String getClientFirstMessage() {
-        return "";
+        private SaslMechanism of(
+                final Collection<String> mechanisms, final ChannelBinding channelBinding) {
+            Preconditions.checkNotNull(channelBinding, "Use ChannelBinding.NONE instead of null");
+            if (mechanisms.contains(External.MECHANISM) && account.getPrivateKeyAlias() != null) {
+                return new External(account);
+            } else if (mechanisms.contains(ScramSha512Plus.MECHANISM)
+                    && channelBinding != ChannelBinding.NONE) {
+                return new ScramSha512Plus(account, channelBinding);
+            } else if (mechanisms.contains(ScramSha256Plus.MECHANISM)
+                    && channelBinding != ChannelBinding.NONE) {
+                return new ScramSha256Plus(account, channelBinding);
+            } else if (mechanisms.contains(ScramSha1Plus.MECHANISM)
+                    && channelBinding != ChannelBinding.NONE) {
+                return new ScramSha1Plus(account, channelBinding);
+            } else if (mechanisms.contains(ScramSha512.MECHANISM)) {
+                return new ScramSha512(account);
+            } else if (mechanisms.contains(ScramSha256.MECHANISM)) {
+                return new ScramSha256(account);
+            } else if (mechanisms.contains(ScramSha1.MECHANISM)) {
+                return new ScramSha1(account);
+            } else if (mechanisms.contains(Plain.MECHANISM)
+                    && !account.getServer().equals("nimbuzz.com")) {
+                return new Plain(account);
+            } else if (mechanisms.contains(DigestMd5.MECHANISM)) {
+                return new DigestMd5(account);
+            } else if (mechanisms.contains(Anonymous.MECHANISM)) {
+                return new Anonymous(account);
+            } else {
+                return null;
+            }
+        }
+
+        public SaslMechanism of(
+                final Collection<String> mechanisms,
+                final Collection<ChannelBinding> bindings,
+                final Version version,
+                final SSLSockets.Version sslVersion) {
+            final HashedToken fastMechanism = account.getFastMechanism();
+            if (version == Version.SASL_2 && fastMechanism != null) {
+                return fastMechanism;
+            }
+            final ChannelBinding channelBinding = ChannelBinding.best(bindings, sslVersion);
+            return of(mechanisms, channelBinding);
+        }
+
+        public SaslMechanism of(final String mechanism, final ChannelBinding channelBinding) {
+            return of(Collections.singleton(mechanism), channelBinding);
+        }
     }
 
-    public String getResponse(final String challenge) throws AuthenticationException {
-        return "";
+    public static SaslMechanism ensureAvailable(
+            final SaslMechanism mechanism, final SSLSockets.Version sslVersion) {
+        if (mechanism instanceof ChannelBindingMechanism) {
+            final ChannelBinding cb = ((ChannelBindingMechanism) mechanism).getChannelBinding();
+            if (ChannelBinding.isAvailable(cb, sslVersion)) {
+                return mechanism;
+            } else {
+                Log.d(
+                        Config.LOGTAG,
+                        "pinned channel binding method " + cb + " no longer available");
+                return null;
+            }
+        } else {
+            return mechanism;
+        }
+    }
+
+    public static boolean hashedToken(final SaslMechanism saslMechanism) {
+        return saslMechanism instanceof HashedToken;
+    }
+
+    public static boolean pin(final SaslMechanism saslMechanism) {
+        return !hashedToken(saslMechanism);
     }
 }
  
  
  
    
    @@ -1,105 +1,85 @@
 package eu.siacs.conversations.crypto.sasl;
 
 import android.util.Base64;
+import android.util.Log;
 
+import com.google.common.base.CaseFormat;
 import com.google.common.base.Objects;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
-
-import org.bouncycastle.crypto.Digest;
-import org.bouncycastle.crypto.macs.HMac;
-import org.bouncycastle.crypto.params.KeyParameter;
+import com.google.common.hash.HashFunction;
 
 import java.nio.charset.Charset;
 import java.security.InvalidKeyException;
-import java.security.SecureRandom;
 import java.util.concurrent.ExecutionException;
 
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.xml.TagWriter;
 
 abstract class ScramMechanism extends SaslMechanism {
-    // TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to indicate support and/or usage.
-    private final static String GS2_HEADER = "n,,";
+
     private static final byte[] CLIENT_KEY_BYTES = "Client Key".getBytes();
     private static final byte[] SERVER_KEY_BYTES = "Server Key".getBytes();
-
-    protected abstract HMac getHMAC();
-
-    protected abstract Digest getDigest();
-
-    private static final Cache<CacheKey, KeyPair> CACHE = CacheBuilder.newBuilder().maximumSize(10).build();
-
-    private static class CacheKey {
-        final String algorithm;
-        final String password;
-        final String salt;
-        final int iterations;
-
-        private CacheKey(String algorithm, String password, String salt, int iterations) {
-            this.algorithm = algorithm;
-            this.password = password;
-            this.salt = salt;
-            this.iterations = iterations;
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) return true;
-            if (o == null || getClass() != o.getClass()) return false;
-            CacheKey cacheKey = (CacheKey) o;
-            return iterations == cacheKey.iterations &&
-                    Objects.equal(algorithm, cacheKey.algorithm) &&
-                    Objects.equal(password, cacheKey.password) &&
-                    Objects.equal(salt, cacheKey.salt);
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hashCode(algorithm, password, salt, iterations);
-        }
-    }
-
-    private KeyPair getKeyPair(final String password, final String salt, final int iterations) throws ExecutionException {
-        return CACHE.get(new CacheKey(getHMAC().getAlgorithmName(), password, salt, iterations), () -> {
-            final byte[] saltedPassword, serverKey, clientKey;
-            saltedPassword = hi(password.getBytes(), Base64.decode(salt, Base64.DEFAULT), iterations);
-            serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
-            clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
-            return new KeyPair(clientKey, serverKey);
-        });
-    }
-
+    private static final Cache<CacheKey, KeyPair> CACHE =
+            CacheBuilder.newBuilder().maximumSize(10).build();
+    protected final ChannelBinding channelBinding;
+    private final String gs2Header;
     private final String clientNonce;
     protected State state = State.INITIAL;
     private String clientFirstMessageBare;
     private byte[] serverSignature = null;
 
-    ScramMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        super(tagWriter, account, rng);
-
+    ScramMechanism(final Account account, final ChannelBinding channelBinding) {
+        super(account);
+        this.channelBinding = channelBinding;
+        if (channelBinding == ChannelBinding.NONE) {
+            // TODO this needs to be changed to "y,," for the scram internal down grade protection
+            // but we might risk compatibility issues if the server supports a binding that we don’t
+            // support
+            this.gs2Header = "n,,";
+        } else {
+            this.gs2Header =
+                    String.format(
+                            "p=%s,,",
+                            CaseFormat.UPPER_UNDERSCORE
+                                    .converterTo(CaseFormat.LOWER_HYPHEN)
+                                    .convert(channelBinding.toString()));
+        }
         // This nonce should be different for each authentication attempt.
-        clientNonce = CryptoHelper.random(100, rng);
+        this.clientNonce = CryptoHelper.random(100);
         clientFirstMessageBare = "";
     }
 
+    protected abstract HashFunction getHMac(final byte[] key);
+
+    protected abstract HashFunction getDigest();
+
+    private KeyPair getKeyPair(final String password, final String salt, final int iterations)
+            throws ExecutionException {
+        return CACHE.get(
+                new CacheKey(getMechanism(), password, salt, iterations),
+                () -> {
+                    final byte[] saltedPassword, serverKey, clientKey;
+                    saltedPassword =
+                            hi(
+                                    password.getBytes(),
+                                    Base64.decode(salt, Base64.DEFAULT),
+                                    iterations);
+                    serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
+                    clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
+                    return new KeyPair(clientKey, serverKey);
+                });
+    }
+
     private byte[] hmac(final byte[] key, final byte[] input) throws InvalidKeyException {
-        final HMac hMac = getHMAC();
-        hMac.init(new KeyParameter(key));
-        hMac.update(input, 0, input.length);
-        final byte[] out = new byte[hMac.getMacSize()];
-        hMac.doFinal(out, 0);
-        return out;
+        return getHMac(key).hashBytes(input).asBytes();
     }
 
-    public byte[] digest(byte[] bytes) {
-        final Digest digest = getDigest();
-        digest.reset();
-        digest.update(bytes, 0, bytes.length);
-        final byte[] out = new byte[digest.getDigestSize()];
-        digest.doFinal(out, 0);
-        return out;
+    private byte[] digest(final byte[] bytes) {
+        return getDigest().hashBytes(bytes).asBytes();
     }
 
     /*
@@ -121,19 +101,23 @@ abstract class ScramMechanism extends SaslMechanism {
     }
 
     @Override
-    public String getClientFirstMessage() {
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
         if (clientFirstMessageBare.isEmpty() && state == State.INITIAL) {
-            clientFirstMessageBare = "n=" + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername())) +
-                    ",r=" + this.clientNonce;
+            clientFirstMessageBare =
+                    "n="
+                            + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername()))
+                            + ",r="
+                            + this.clientNonce;
             state = State.AUTH_TEXT_SENT;
         }
         return Base64.encodeToString(
-                (GS2_HEADER + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
+                (gs2Header + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
                 Base64.NO_WRAP);
     }
 
     @Override
-    public String getResponse(final String challenge) throws AuthenticationException {
+    public String getResponse(final String challenge, final SSLSocket socket)
+            throws AuthenticationException {
         switch (state) {
             case AUTH_TEXT_SENT:
                 if (challenge == null) {
@@ -173,7 +157,8 @@ abstract class ScramMechanism extends SaslMechanism {
                                  * MUST cause authentication failure when the attribute is parsed by
                                  * the other end.
                                  */
-                                throw new AuthenticationException("Server sent reserved token: `m'");
+                                throw new AuthenticationException(
+                                        "Server sent reserved token: `m'");
                         }
                     }
                 }
@@ -182,20 +167,39 @@ abstract class ScramMechanism extends SaslMechanism {
                     throw new AuthenticationException("Server did not send iteration count");
                 }
                 if (nonce.isEmpty() || !nonce.startsWith(clientNonce)) {
-                    throw new AuthenticationException("Server nonce does not contain client nonce: " + nonce);
+                    throw new AuthenticationException(
+                            "Server nonce does not contain client nonce: " + nonce);
                 }
                 if (salt.isEmpty()) {
                     throw new AuthenticationException("Server sent empty salt");
                 }
 
-                final String clientFinalMessageWithoutProof = "c=" + Base64.encodeToString(
-                        GS2_HEADER.getBytes(), Base64.NO_WRAP) + ",r=" + nonce;
-                final byte[] authMessage = (clientFirstMessageBare + ',' + new String(serverFirstMessage) + ','
-                        + clientFinalMessageWithoutProof).getBytes();
+                final byte[] channelBindingData = getChannelBindingData(socket);
+
+                final int gs2Len = this.gs2Header.getBytes().length;
+                final byte[] cMessage = new byte[gs2Len + channelBindingData.length];
+                System.arraycopy(this.gs2Header.getBytes(), 0, cMessage, 0, gs2Len);
+                System.arraycopy(
+                        channelBindingData, 0, cMessage, gs2Len, channelBindingData.length);
+
+                final String clientFinalMessageWithoutProof =
+                        "c=" + Base64.encodeToString(cMessage, Base64.NO_WRAP) + ",r=" + nonce;
+
+                final byte[] authMessage =
+                        (clientFirstMessageBare
+                                        + ','
+                                        + new String(serverFirstMessage)
+                                        + ','
+                                        + clientFinalMessageWithoutProof)
+                                .getBytes();
 
                 final KeyPair keys;
                 try {
-                    keys = getKeyPair(CryptoHelper.saslPrep(account.getPassword()), salt, iterationCount);
+                    keys =
+                            getKeyPair(
+                                    CryptoHelper.saslPrep(account.getPassword()),
+                                    salt,
+                                    iterationCount);
                 } catch (ExecutionException e) {
                     throw new AuthenticationException("Invalid keys generated");
                 }
@@ -213,35 +217,77 @@ abstract class ScramMechanism extends SaslMechanism {
                 final byte[] clientProof = new byte[keys.clientKey.length];
 
                 if (clientSignature.length < keys.clientKey.length) {
-                    throw new AuthenticationException("client signature was shorter than clientKey");
+                    throw new AuthenticationException(
+                            "client signature was shorter than clientKey");
                 }
 
                 for (int i = 0; i < clientProof.length; i++) {
                     clientProof[i] = (byte) (keys.clientKey[i] ^ clientSignature[i]);
                 }
 
-
-                final String clientFinalMessage = clientFinalMessageWithoutProof + ",p=" +
-                        Base64.encodeToString(clientProof, Base64.NO_WRAP);
+                final String clientFinalMessage =
+                        clientFinalMessageWithoutProof
+                                + ",p="
+                                + Base64.encodeToString(clientProof, Base64.NO_WRAP);
                 state = State.RESPONSE_SENT;
                 return Base64.encodeToString(clientFinalMessage.getBytes(), Base64.NO_WRAP);
             case RESPONSE_SENT:
                 try {
-                    final String clientCalculatedServerFinalMessage = "v=" +
-                            Base64.encodeToString(serverSignature, Base64.NO_WRAP);
-                    if (!clientCalculatedServerFinalMessage.equals(new String(Base64.decode(challenge, Base64.DEFAULT)))) {
+                    final String clientCalculatedServerFinalMessage =
+                            "v=" + Base64.encodeToString(serverSignature, Base64.NO_WRAP);
+                    if (!clientCalculatedServerFinalMessage.equals(
+                            new String(Base64.decode(challenge, Base64.DEFAULT)))) {
                         throw new Exception();
                     }
                     state = State.VALID_SERVER_RESPONSE;
                     return "";
                 } catch (Exception e) {
-                    throw new AuthenticationException("Server final message does not match calculated final message");
+                    throw new AuthenticationException(
+                            "Server final message does not match calculated final message");
                 }
             default:
                 throw new InvalidStateException(state);
         }
     }
 
+    protected byte[] getChannelBindingData(final SSLSocket sslSocket)
+            throws AuthenticationException {
+        if (this.channelBinding == ChannelBinding.NONE) {
+            return new byte[0];
+        }
+        throw new AssertionError("getChannelBindingData needs to be overwritten");
+    }
+
+    private static class CacheKey {
+        final String algorithm;
+        final String password;
+        final String salt;
+        final int iterations;
+
+        private CacheKey(String algorithm, String password, String salt, int iterations) {
+            this.algorithm = algorithm;
+            this.password = password;
+            this.salt = salt;
+            this.iterations = iterations;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            CacheKey cacheKey = (CacheKey) o;
+            return iterations == cacheKey.iterations
+                    && Objects.equal(algorithm, cacheKey.algorithm)
+                    && Objects.equal(password, cacheKey.password)
+                    && Objects.equal(salt, cacheKey.salt);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(algorithm, password, salt, iterations);
+        }
+    }
+
     private static class KeyPair {
         final byte[] clientKey;
         final byte[] serverKey;
  
  
  
    
    @@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.entities.Account;
+
+public abstract class ScramPlusMechanism extends ScramMechanism implements ChannelBindingMechanism {
+
+    ScramPlusMechanism(Account account, ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected byte[] getChannelBindingData(final SSLSocket sslSocket)
+            throws AuthenticationException {
+        return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
+    }
+
+    @Override
+    public ChannelBinding getChannelBinding() {
+        return this.channelBinding;
+    }
+}
  
  
  
    
    @@ -1,30 +1,26 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import org.bouncycastle.crypto.Digest;
-import org.bouncycastle.crypto.digests.SHA1Digest;
-import org.bouncycastle.crypto.macs.HMac;
-
-import java.security.SecureRandom;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
 
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class ScramSha1 extends ScramMechanism {
 
     public static final String MECHANISM = "SCRAM-SHA-1";
 
-    @Override
-    protected HMac getHMAC() {
-        return new HMac(new SHA1Digest());
+    public ScramSha1(final Account account) {
+        super(account, ChannelBinding.NONE);
     }
 
     @Override
-    protected Digest getDigest() {
-        return new SHA1Digest();
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha1(key);
     }
 
-    public ScramSha1(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        super(tagWriter, account, rng);
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha1();
     }
 
     @Override
  
  
  
    
    @@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha1Plus extends ScramPlusMechanism {
+
+    public static final String MECHANISM = "SCRAM-SHA-1-PLUS";
+
+    public ScramSha1Plus(final Account account, final ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha1(key);
+    }
+
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha1();
+    }
+
+    @Override
+    public int getPriority() {
+        return 35; // higher than SCRAM-SHA512 (30)
+    }
+
+    @Override
+    public String getMechanism() {
+        return MECHANISM;
+    }
+}
  
  
  
    
    @@ -1,32 +1,31 @@
 package eu.siacs.conversations.crypto.sasl;
 
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
 import org.bouncycastle.crypto.Digest;
 import org.bouncycastle.crypto.digests.SHA256Digest;
 import org.bouncycastle.crypto.macs.HMac;
 
-import java.security.SecureRandom;
-
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class ScramSha256 extends ScramMechanism {
 
     public static final String MECHANISM = "SCRAM-SHA-256";
 
-    @Override
-    protected HMac getHMAC() {
-        return new HMac(new SHA256Digest());
+    public ScramSha256(final Account account) {
+        super(account, ChannelBinding.NONE);
     }
 
     @Override
-    protected Digest getDigest() {
-        return new SHA256Digest();
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha256(key);
     }
 
-    public ScramSha256(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        super(tagWriter, account, rng);
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha256();
     }
-
     @Override
     public int getPriority() {
         return 25;
  
  
  
    
    @@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha256Plus extends ScramPlusMechanism {
+
+    public static final String MECHANISM = "SCRAM-SHA-256-PLUS";
+
+    public ScramSha256Plus(final Account account, final ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha256(key);
+    }
+
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha256();
+    }
+
+    @Override
+    public int getPriority() {
+        return 40;
+    }
+
+    @Override
+    public String getMechanism() {
+        return MECHANISM;
+    }
+}
  
  
  
    
    @@ -1,30 +1,30 @@
 package eu.siacs.conversations.crypto.sasl;
 
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
 import org.bouncycastle.crypto.Digest;
 import org.bouncycastle.crypto.digests.SHA512Digest;
 import org.bouncycastle.crypto.macs.HMac;
 
-import java.security.SecureRandom;
-
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
 
 public class ScramSha512 extends ScramMechanism {
 
     public static final String MECHANISM = "SCRAM-SHA-512";
 
-    @Override
-    protected HMac getHMAC() {
-        return new HMac(new SHA512Digest());
+    public ScramSha512(final Account account) {
+        super(account, ChannelBinding.NONE);
     }
 
     @Override
-    protected Digest getDigest() {
-        return new SHA512Digest();
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha512(key);
     }
 
-    public ScramSha512(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
-        super(tagWriter, account, rng);
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha512();
     }
 
     @Override
  
  
  
    
    @@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha512Plus extends ScramPlusMechanism {
+
+    public static final String MECHANISM = "SCRAM-SHA-512-PLUS";
+
+    public ScramSha512Plus(final Account account, final ChannelBinding channelBinding) {
+        super(account, channelBinding);
+    }
+
+    @Override
+    protected HashFunction getHMac(final byte[] key) {
+        return Hashing.hmacSha512(key);
+    }
+
+    @Override
+    protected HashFunction getDigest() {
+        return Hashing.sha512();
+    }
+
+    @Override
+    public int getPriority() {
+        return 45;
+    }
+
+    @Override
+    public String getMechanism() {
+        return MECHANISM;
+    }
+}
  
  
  
    
    @@ -6,9 +6,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.NoSuchElementException;
 
-/**
- * A tokenizer for GS2 header strings
- */
+/** A tokenizer for GS2 header strings */
 public final class Tokenizer implements Iterator<String>, Iterable<String> {
     private final List<String> parts;
     private int index;
@@ -50,18 +48,19 @@ public final class Tokenizer implements Iterator<String>, Iterable<String> {
     }
 
     /**
-     * Removes the last object returned by {@code next} from the collection.
-     * This method can only be called once between each call to {@code next}.
+     * Removes the last object returned by {@code next} from the collection. This method can only be
+     * called once between each call to {@code next}.
      *
      * @throws UnsupportedOperationException if removing is not supported by the collection being
-     *                                       iterated.
-     * @throws IllegalStateException         if {@code next} has not been called, or {@code remove} has
-     *                                       already been called after the last call to {@code next}.
+     *     iterated.
+     * @throws IllegalStateException if {@code next} has not been called, or {@code remove} has
+     *     already been called after the last call to {@code next}.
      */
     @Override
     public void remove() {
         if (index <= 0) {
-            throw new IllegalStateException("You can't delete an element before first next() method call");
+            throw new IllegalStateException(
+                    "You can't delete an element before first next() method call");
         }
         parts.remove(--index);
     }
  
  
  
    
    @@ -6,6 +6,7 @@ import android.os.SystemClock;
 import android.util.Log;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -24,6 +25,13 @@ import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.PgpDecryptionService;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
+import eu.siacs.conversations.crypto.sasl.ChannelBinding;
+import eu.siacs.conversations.crypto.sasl.ChannelBindingMechanism;
+import eu.siacs.conversations.crypto.sasl.HashedToken;
+import eu.siacs.conversations.crypto.sasl.HashedTokenSha256;
+import eu.siacs.conversations.crypto.sasl.HashedTokenSha512;
+import eu.siacs.conversations.crypto.sasl.SaslMechanism;
+import eu.siacs.conversations.crypto.sasl.ScramPlusMechanism;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.UIHelper;
@@ -49,22 +57,26 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     public static final String STATUS = "status";
     public static final String STATUS_MESSAGE = "status_message";
     public static final String RESOURCE = "resource";
+    public static final String PINNED_MECHANISM = "pinned_mechanism";
+    public static final String PINNED_CHANNEL_BINDING = "pinned_channel_binding";
+    public static final String FAST_MECHANISM = "fast_mechanism";
+    public static final String FAST_TOKEN = "fast_token";
 
-    public static final String PINNED_MECHANISM_KEY = "pinned_mechanism";
-    public static final String PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
-
-    public static final int OPTION_USETLS = 0;
     public static final int OPTION_DISABLED = 1;
     public static final int OPTION_REGISTER = 2;
-    public static final int OPTION_USECOMPRESSION = 3;
     public static final int OPTION_MAGIC_CREATE = 4;
     public static final int OPTION_REQUIRES_ACCESS_MODE_CHANGE = 5;
     public static final int OPTION_LOGGED_IN_SUCCESSFULLY = 6;
     public static final int OPTION_HTTP_UPLOAD_AVAILABLE = 7;
     public static final int OPTION_UNVERIFIED = 8;
     public static final int OPTION_FIXED_USERNAME = 9;
+    public static final int OPTION_QUICKSTART_AVAILABLE = 10;
+
     private static final String KEY_PGP_SIGNATURE = "pgp_signature";
     private static final String KEY_PGP_ID = "pgp_id";
+    private static final String KEY_PINNED_MECHANISM = "pinned_mechanism";
+    public static final String KEY_PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
+
     protected final JSONObject keys;
     private final Roster roster = new Roster(this);
     private final Collection<Jid> blocklist = new CopyOnWriteArraySet<>();
@@ -90,18 +102,50 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private long mEndGracePeriod = 0L;
     private final Map<Jid, Bookmark> bookmarks = new HashMap<>();
     private boolean bookmarksLoaded = false;
-    private Presence.Status presenceStatus = Presence.Status.ONLINE;
-    private String presenceStatusMessage = null;
+    private Presence.Status presenceStatus;
+    private String presenceStatusMessage;
+    private String pinnedMechanism;
+    private String pinnedChannelBinding;
+    private String fastMechanism;
+    private String fastToken;
 
     public Account(final Jid jid, final String password) {
-        this(java.util.UUID.randomUUID().toString(), jid,
-                password, 0, null, "", null, null, null, 5222, Presence.Status.ONLINE, null);
-    }
-
-    private Account(final String uuid, final Jid jid,
-                    final String password, final int options, final String rosterVersion, final String keys,
-                    final String avatar, String displayName, String hostname, int port,
-                    final Presence.Status status, String statusMessage) {
+        this(
+                java.util.UUID.randomUUID().toString(),
+                jid,
+                password,
+                0,
+                null,
+                "",
+                null,
+                null,
+                null,
+                5222,
+                Presence.Status.ONLINE,
+                null,
+                null,
+                null,
+                null,
+                null);
+    }
+
+    private Account(
+            final String uuid,
+            final Jid jid,
+            final String password,
+            final int options,
+            final String rosterVersion,
+            final String keys,
+            final String avatar,
+            String displayName,
+            String hostname,
+            int port,
+            final Presence.Status status,
+            String statusMessage,
+            final String pinnedMechanism,
+            final String pinnedChannelBinding,
+            final String fastMechanism,
+            final String fastToken) {
         this.uuid = uuid;
         this.jid = jid;
         this.password = password;
@@ -120,36 +164,51 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         this.port = port;
         this.presenceStatus = status;
         this.presenceStatusMessage = statusMessage;
+        this.pinnedMechanism = pinnedMechanism;
+        this.pinnedChannelBinding = pinnedChannelBinding;
+        this.fastMechanism = fastMechanism;
+        this.fastToken = fastToken;
     }
 
     public static Account fromCursor(final Cursor cursor) {
         final Jid jid;
         try {
-            String resource = cursor.getString(cursor.getColumnIndex(RESOURCE));
-            jid = Jid.of(
-                    cursor.getString(cursor.getColumnIndex(USERNAME)),
-                    cursor.getString(cursor.getColumnIndex(SERVER)),
-                    resource == null || resource.trim().isEmpty() ? null : resource);
-        } catch (final IllegalArgumentException ignored) {
-            Log.d(Config.LOGTAG, cursor.getString(cursor.getColumnIndex(USERNAME)) + "@" + cursor.getString(cursor.getColumnIndex(SERVER)));
-            throw new AssertionError(ignored);
-        }
-        return new Account(cursor.getString(cursor.getColumnIndex(UUID)),
+            final String resource = cursor.getString(cursor.getColumnIndexOrThrow(RESOURCE));
+            jid =
+                    Jid.of(
+                            cursor.getString(cursor.getColumnIndexOrThrow(USERNAME)),
+                            cursor.getString(cursor.getColumnIndexOrThrow(SERVER)),
+                            resource == null || resource.trim().isEmpty() ? null : resource);
+        } catch (final IllegalArgumentException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    cursor.getString(cursor.getColumnIndexOrThrow(USERNAME))
+                            + "@"
+                            + cursor.getString(cursor.getColumnIndexOrThrow(SERVER)));
+            throw new AssertionError(e);
+        }
+        return new Account(
+                cursor.getString(cursor.getColumnIndexOrThrow(UUID)),
                 jid,
-                cursor.getString(cursor.getColumnIndex(PASSWORD)),
-                cursor.getInt(cursor.getColumnIndex(OPTIONS)),
-                cursor.getString(cursor.getColumnIndex(ROSTERVERSION)),
-                cursor.getString(cursor.getColumnIndex(KEYS)),
-                cursor.getString(cursor.getColumnIndex(AVATAR)),
-                cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)),
-                cursor.getString(cursor.getColumnIndex(HOSTNAME)),
-                cursor.getInt(cursor.getColumnIndex(PORT)),
-                Presence.Status.fromShowString(cursor.getString(cursor.getColumnIndex(STATUS))),
-                cursor.getString(cursor.getColumnIndex(STATUS_MESSAGE)));
-    }
-
-    public boolean httpUploadAvailable(long filesize) {
-        return xmppConnection != null && xmppConnection.getFeatures().httpUpload(filesize);
+                cursor.getString(cursor.getColumnIndexOrThrow(PASSWORD)),
+                cursor.getInt(cursor.getColumnIndexOrThrow(OPTIONS)),
+                cursor.getString(cursor.getColumnIndexOrThrow(ROSTERVERSION)),
+                cursor.getString(cursor.getColumnIndexOrThrow(KEYS)),
+                cursor.getString(cursor.getColumnIndexOrThrow(AVATAR)),
+                cursor.getString(cursor.getColumnIndexOrThrow(DISPLAY_NAME)),
+                cursor.getString(cursor.getColumnIndexOrThrow(HOSTNAME)),
+                cursor.getInt(cursor.getColumnIndexOrThrow(PORT)),
+                Presence.Status.fromShowString(
+                        cursor.getString(cursor.getColumnIndexOrThrow(STATUS))),
+                cursor.getString(cursor.getColumnIndexOrThrow(STATUS_MESSAGE)),
+                cursor.getString(cursor.getColumnIndexOrThrow(PINNED_MECHANISM)),
+                cursor.getString(cursor.getColumnIndexOrThrow(PINNED_CHANNEL_BINDING)),
+                cursor.getString(cursor.getColumnIndexOrThrow(FAST_MECHANISM)),
+                cursor.getString(cursor.getColumnIndexOrThrow(FAST_TOKEN)));
+    }
+
+    public boolean httpUploadAvailable(long size) {
+        return xmppConnection != null && xmppConnection.getFeatures().httpUpload(size);
     }
 
     public boolean httpUploadAvailable() {
@@ -289,6 +348,78 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         }
     }
 
+    public void setPinnedMechanism(final SaslMechanism mechanism) {
+        this.pinnedMechanism = mechanism.getMechanism();
+        if (mechanism instanceof ChannelBindingMechanism) {
+            this.pinnedChannelBinding =
+                    ((ChannelBindingMechanism) mechanism).getChannelBinding().toString();
+        } else {
+            this.pinnedChannelBinding = null;
+        }
+    }
+
+    public void setFastToken(final HashedToken.Mechanism mechanism, final String token) {
+        this.fastMechanism = mechanism.name();
+        this.fastToken = token;
+    }
+
+    public void resetFastToken() {
+        this.fastMechanism = null;
+        this.fastToken = null;
+    }
+
+    public void resetPinnedMechanism() {
+        this.pinnedMechanism = null;
+        this.pinnedChannelBinding = null;
+        setKey(Account.KEY_PINNED_MECHANISM, String.valueOf(-1));
+    }
+
+    public int getPinnedMechanismPriority() {
+        final int fallback = getKeyAsInt(KEY_PINNED_MECHANISM, -1);
+        if (Strings.isNullOrEmpty(this.pinnedMechanism)) {
+            return fallback;
+        }
+        final SaslMechanism saslMechanism = getPinnedMechanism();
+        if (saslMechanism == null) {
+            return fallback;
+        } else {
+            return saslMechanism.getPriority();
+        }
+    }
+
+    private SaslMechanism getPinnedMechanism() {
+        final String mechanism = Strings.nullToEmpty(this.pinnedMechanism);
+        final ChannelBinding channelBinding = ChannelBinding.get(this.pinnedChannelBinding);
+        return new SaslMechanism.Factory(this).of(mechanism, channelBinding);
+    }
+
+    public HashedToken getFastMechanism() {
+        final HashedToken.Mechanism fastMechanism = HashedToken.Mechanism.ofOrNull(this.fastMechanism);
+        final String token = this.fastToken;
+        if (fastMechanism == null || Strings.isNullOrEmpty(token)) {
+            return null;
+        }
+        if (fastMechanism.hashFunction.equals("SHA-256")) {
+            return new HashedTokenSha256(this, fastMechanism.channelBinding);
+        } else if (fastMechanism.hashFunction.equals("SHA-512")) {
+            return new HashedTokenSha512(this, fastMechanism.channelBinding);
+        } else {
+            return null;
+        }
+    }
+
+    public SaslMechanism getQuickStartMechanism() {
+        final HashedToken hashedTokenMechanism = getFastMechanism();
+        if (hashedTokenMechanism != null) {
+            return hashedTokenMechanism;
+        }
+        return getPinnedMechanism();
+    }
+
+    public String getFastToken() {
+        return this.fastToken;
+    }
+
     public State getTrueStatus() {
         return this.status;
     }
@@ -361,8 +492,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         }
     }
 
-    public boolean setPrivateKeyAlias(String alias) {
-        return setKey("private_key_alias", alias);
+    public void setPrivateKeyAlias(final String alias) {
+        setKey("private_key_alias", alias);
     }
 
     public String getPrivateKeyAlias() {
@@ -388,6 +519,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         values.put(STATUS, presenceStatus.toShowString());
         values.put(STATUS_MESSAGE, presenceStatusMessage);
         values.put(RESOURCE, jid.getResource());
+        values.put(PINNED_MECHANISM, pinnedMechanism);
+        values.put(PINNED_CHANNEL_BINDING, pinnedChannelBinding);
+        values.put(FAST_MECHANISM, this.fastMechanism);
+        values.put(FAST_TOKEN, this.fastToken);
         return values;
     }
 
@@ -433,7 +568,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     public int activeDevicesWithRtpCapability() {
         int i = 0;
-        for(Presence presence : getSelfContact().getPresences().getPresences()) {
+        for (Presence presence : getSelfContact().getPresences().getPresences()) {
             if (RtpCapability.check(presence) != RtpCapability.Capability.NONE) {
                 i++;
             }
@@ -490,13 +625,13 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     public Collection<Bookmark> getBookmarks() {
         synchronized (this.bookmarks) {
-            return new HashSet<>(this.bookmarks.values());
+            return ImmutableList.copyOf(this.bookmarks.values());
         }
     }
 
     public boolean areBookmarksLoaded() { return bookmarksLoaded; }
 
-    public void setBookmarks(Map<Jid, Bookmark> bookmarks) {
+    public void setBookmarks(final Map<Jid, Bookmark> bookmarks) {
         synchronized (this.bookmarks) {
             this.bookmarks.clear();
             this.bookmarks.putAll(bookmarks);
@@ -504,7 +639,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         }
     }
 
-    public void putBookmark(Bookmark bookmark) {
+    public void putBookmark(final Bookmark bookmark) {
         synchronized (this.bookmarks) {
             this.bookmarks.put(bookmark.getJid(), bookmark);
         }
@@ -573,7 +708,9 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     public String getShareableLink() {
         List<XmppUri.Fingerprint> fingerprints = this.getFingerprints();
-        String uri = "https://conversations.im/i/" + XmppUri.lameUrlEncode(this.getJid().asBareJid().toEscapedString());
+        String uri =
+                "https://conversations.im/i/"
+                        + XmppUri.lameUrlEncode(this.getJid().asBareJid().toEscapedString());
         if (fingerprints.size() > 0) {
             return XmppUri.getFingerprintUri(uri, fingerprints, '&');
         } else {
@@ -586,10 +723,18 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         if (axolotlService == null) {
             return fingerprints;
         }
-        fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, axolotlService.getOwnFingerprint().substring(2), axolotlService.getOwnDeviceId()));
+        fingerprints.add(
+                new XmppUri.Fingerprint(
+                        XmppUri.FingerprintType.OMEMO,
+                        axolotlService.getOwnFingerprint().substring(2),
+                        axolotlService.getOwnDeviceId()));
         for (XmppAxolotlSession session : axolotlService.findOwnSessions()) {
             if (session.getTrust().isVerified() && session.getTrust().isActive()) {
-                fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, session.getFingerprint().substring(2).replaceAll("\\s", ""), session.getRemoteAddress().getDeviceId()));
+                fingerprints.add(
+                        new XmppUri.Fingerprint(
+                                XmppUri.FingerprintType.OMEMO,
+                                session.getFingerprint().substring(2).replaceAll("\\s", ""),
+                                session.getRemoteAddress().getDeviceId()));
             }
         }
         return fingerprints;
@@ -597,7 +742,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     public boolean isBlocked(final ListItem contact) {
         final Jid jid = contact.getJid();
-        return jid != null && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
+        return jid != null
+                && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
     }
 
     public boolean isBlocked(final Jid jid) {
@@ -641,11 +787,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         REGISTRATION_CONFLICT(true, false),
         REGISTRATION_NOT_SUPPORTED(true, false),
         REGISTRATION_PLEASE_WAIT(true, false),
-        REGISTRATION_INVALID_TOKEN(true,false),
+        REGISTRATION_INVALID_TOKEN(true, false),
         REGISTRATION_PASSWORD_TOO_WEAK(true, false),
         TLS_ERROR,
         TLS_ERROR_DOMAIN,
         INCOMPATIBLE_SERVER,
+        INCOMPATIBLE_CLIENT,
         TOR_NOT_AVAILABLE,
         DOWNGRADE_ATTACK,
         SESSION_FAILURE,
@@ -715,6 +862,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                     return R.string.account_status_tls_error_domain;
                 case INCOMPATIBLE_SERVER:
                     return R.string.account_status_incompatible_server;
+                case INCOMPATIBLE_CLIENT:
+                    return R.string.account_status_incompatible_client;
                 case TOR_NOT_AVAILABLE:
                     return R.string.account_status_tor_unavailable;
                 case BIND_FAILURE:
  
  
  
    
    @@ -156,7 +156,8 @@ public class MucOptions {
     }
 
     public boolean canInvite() {
-        return !membersOnly() || self.getRole().ranks(Role.MODERATOR) || allowInvites();
+        final boolean hasPermission = !membersOnly() || self.getRole().ranks(Role.MODERATOR) || allowInvites();
+        return hasPermission && online();
     }
 
     public boolean allowInvites() {
@@ -725,6 +726,7 @@ public class MucOptions {
         SHUTDOWN,
         DESTROYED,
         INVALID_NICK,
+        TECHNICAL_PROBLEMS,
         UNKNOWN,
         NON_ANONYMOUS
     }
  
  
  
    
    @@ -145,8 +145,8 @@ public class IqGenerator extends AbstractGenerator {
         return publish(Namespace.NICK, item);
     }
 
-    public IqPacket deleteNode(String node) {
-        IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
+    public IqPacket deleteNode(final String node) {
+        final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
         final Element pubsub = packet.addChild("pubsub", Namespace.PUBSUB_OWNER);
         pubsub.addChild("delete").setAttribute("node", node);
         return packet;
@@ -165,9 +165,9 @@ public class IqGenerator extends AbstractGenerator {
     public IqPacket publishAvatar(Avatar avatar, Bundle options) {
         final Element item = new Element("item");
         item.setAttribute("id", avatar.sha1sum);
-        final Element data = item.addChild("data", "urn:xmpp:avatar:data");
+        final Element data = item.addChild("data", Namespace.AVATAR_DATA);
         data.setContent(avatar.image);
-        return publish("urn:xmpp:avatar:data", item, options);
+        return publish(Namespace.AVATAR_DATA, item, options);
     }
 
     public IqPacket publishElement(final String namespace, final Element element, String id, final Bundle options) {
@@ -181,20 +181,20 @@ public class IqGenerator extends AbstractGenerator {
         final Element item = new Element("item");
         item.setAttribute("id", avatar.sha1sum);
         final Element metadata = item
-                .addChild("metadata", "urn:xmpp:avatar:metadata");
+                .addChild("metadata", Namespace.AVATAR_METADATA);
         final Element info = metadata.addChild("info");
         info.setAttribute("bytes", avatar.size);
         info.setAttribute("id", avatar.sha1sum);
         info.setAttribute("height", avatar.height);
         info.setAttribute("width", avatar.height);
         info.setAttribute("type", avatar.type);
-        return publish("urn:xmpp:avatar:metadata", item, options);
+        return publish(Namespace.AVATAR_METADATA, item, options);
     }
 
     public IqPacket retrievePepAvatar(final Avatar avatar) {
         final Element item = new Element("item");
         item.setAttribute("id", avatar.sha1sum);
-        final IqPacket packet = retrieve("urn:xmpp:avatar:data", item);
+        final IqPacket packet = retrieve(Namespace.AVATAR_DATA, item);
         packet.setTo(avatar.owner);
         return packet;
     }
@@ -206,6 +206,13 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
+    public IqPacket retrieveVcardAvatar(final Jid to) {
+        final IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
+        packet.setTo(to);
+        packet.addChild("vCard", "vcard-temp");
+        return packet;
+    }
+
     public IqPacket retrieveAvatarMetaData(final Jid to) {
         final IqPacket packet = retrieve("urn:xmpp:avatar:metadata", null);
         if (to != null) {
  
  
  
    
    @@ -1,5 +1,7 @@
 package eu.siacs.conversations.http;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.os.Build;
 import android.util.Log;
 
@@ -147,7 +149,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
             trustManager = mXmppConnectionService.getMemorizingTrustManager().getNonInteractive();
         }
         try {
-            final SSLSocketFactory sf = new TLSSocketFactory(new X509TrustManager[]{trustManager}, mXmppConnectionService.getRNG());
+            final SSLSocketFactory sf = new TLSSocketFactory(new X509TrustManager[]{trustManager}, SECURE_RANDOM);
             builder.sslSocketFactory(sf, trustManager);
             builder.hostnameVerifier(new StrictHostnameVerifier());
         } catch (final KeyManagementException | NoSuchAlgorithmException ignored) {
  
  
  
    
    @@ -1,5 +1,7 @@
 package eu.siacs.conversations.http;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -124,7 +126,7 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan
                 || message.getEncryption() == Message.ENCRYPTION_AXOLOTL
                 || message.getEncryption() == Message.ENCRYPTION_OTR) {
             this.key = new byte[44];
-            mXmppConnectionService.getRNG().nextBytes(this.key);
+            SECURE_RANDOM.nextBytes(this.key);
             this.file.setKeyAndIv(this.key);
         }
         this.file.setExpectedSize(originalFileSize + (file.getKey() != null ? 16 : 0));
  
  
  
    
    @@ -236,7 +236,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             Element item = items.findChild("item");
             Set<Integer> deviceIds = mXmppConnectionService.getIqParser().deviceIds(item);
             Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received PEP device list " + deviceIds + " update from " + from + ", processing... ");
-            AxolotlService axolotlService = account.getAxolotlService();
+            final AxolotlService axolotlService = account.getAxolotlService();
             axolotlService.registerDevices(from, deviceIds);
         } else if (Namespace.BOOKMARKS.equals(node) && account.getJid().asBareJid().equals(from)) {
             if (account.getXmppConnection().getFeatures().bookmarksConversion()) {
@@ -282,6 +282,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
         } else if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
             account.setBookmarks(Collections.emptyMap());
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmarks node");
+        } else if (Namespace.AVATAR_METADATA.equals(node) && account.getJid().asBareJid().equals(from)) {
+            Log.d(Config.LOGTAG,account.getJid().asBareJid()+": deleted avatar metadata node");
         }
     }
 
@@ -314,7 +316,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
     private boolean handleErrorMessage(final Account account, final MessagePacket packet) {
         if (packet.getType() == MessagePacket.TYPE_ERROR) {
             if (packet.fromServer(account)) {
-                final Pair<MessagePacket, Long> forwarded = packet.getForwardedMessagePacket("received", "urn:xmpp:carbons:2");
+                final Pair<MessagePacket, Long> forwarded = packet.getForwardedMessagePacket("received", Namespace.CARBONS);
                 if (forwarded != null) {
                     return handleErrorMessage(account, forwarded.first);
                 }
@@ -390,8 +392,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             return;
         } else if (original.fromServer(account)) {
             Pair<MessagePacket, Long> f;
-            f = original.getForwardedMessagePacket("received", "urn:xmpp:carbons:2");
-            f = f == null ? original.getForwardedMessagePacket("sent", "urn:xmpp:carbons:2") : f;
+            f = original.getForwardedMessagePacket("received", Namespace.CARBONS);
+            f = f == null ? original.getForwardedMessagePacket("sent", Namespace.CARBONS) : f;
             packet = f != null ? f.first : original;
             if (handleErrorMessage(account, packet)) {
                 return;
  
  
  
    
    @@ -56,7 +56,8 @@ public class PresenceParser extends AbstractParser implements
 	}
 
 	private void processConferencePresence(PresencePacket packet, Conversation conversation) {
-		MucOptions mucOptions = conversation.getMucOptions();
+		final Account account = conversation.getAccount();
+		final MucOptions mucOptions = conversation.getMucOptions();
 		final Jid jid = conversation.getAccount().getJid();
 		final Jid from = packet.getFrom();
 		if (!from.isBareJid()) {
@@ -93,7 +94,7 @@ public class PresenceParser extends AbstractParser implements
 							axolotlService.fetchDeviceIds(user.getRealJid());
 						}
 						if (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) && mucOptions.autoPushConfiguration()) {
-							Log.d(Config.LOGTAG,mucOptions.getAccount().getJid().asBareJid()
+							Log.d(Config.LOGTAG,account.getJid().asBareJid()
 									+": room '"
 									+mucOptions.getConversation().getJid().asBareJid()
 									+"' created. pushing default configuration");
@@ -138,13 +139,24 @@ public class PresenceParser extends AbstractParser implements
 					final Jid alternate = destroy == null ? null : InvalidJid.getNullForInvalid(destroy.getAttributeAsJid("jid"));
 					mucOptions.setError(MucOptions.Error.DESTROYED);
 					if (alternate != null) {
-						Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": muc destroyed. alternate location " + alternate);
+						Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": muc destroyed. alternate location " + alternate);
 					}
 				} else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN) && fullJidMatches) {
 					mucOptions.setError(MucOptions.Error.SHUTDOWN);
 				} else if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE)) {
 					if (codes.contains(MucOptions.STATUS_CODE_TECHNICAL_REASONS)) {
-						mucOptions.setError(MucOptions.Error.UNKNOWN);
+                        final boolean wasOnline = mucOptions.online();
+                        mucOptions.setError(MucOptions.Error.TECHNICAL_PROBLEMS);
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": received status code 333 in room "
+                                        + mucOptions.getConversation().getJid().asBareJid()
+                                        + " online="
+                                        + wasOnline);
+                        if (wasOnline) {
+                            mXmppConnectionService.mucSelfPingAndRejoin(conversation);
+                        }
 					} else if (codes.contains(MucOptions.STATUS_CODE_KICKED)) {
 						mucOptions.setError(MucOptions.Error.KICKED);
 					} else if (codes.contains(MucOptions.STATUS_CODE_BANNED)) {
  
  
  
    
    @@ -67,7 +67,7 @@ import eu.siacs.conversations.xmpp.mam.MamReference;
 public class DatabaseBackend extends SQLiteOpenHelper {
 
     private static final String DATABASE_NAME = "history";
-    private static final int DATABASE_VERSION = 49;
+    private static final int DATABASE_VERSION = 51;
 
     private static boolean requiresMessageIndexRebuild = false;
     private static DatabaseBackend instance = null;
@@ -294,6 +294,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 + Account.KEYS + " TEXT, "
                 + Account.HOSTNAME + " TEXT, "
                 + Account.RESOURCE + " TEXT,"
+                + Account.PINNED_MECHANISM + " TEXT,"
+                + Account.PINNED_CHANNEL_BINDING + " TEXT,"
+                + Account.FAST_MECHANISM + " TEXT,"
+                + Account.FAST_TOKEN + " TEXT,"
                 + Account.PORT + " NUMBER DEFAULT 5222)");
         db.execSQL("create table " + Conversation.TABLENAME + " ("
                 + Conversation.UUID + " TEXT PRIMARY KEY, " + Conversation.NAME
@@ -653,6 +657,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             db.endTransaction();
             requiresMessageIndexRebuild = true;
         }
+        if (oldVersion < 50 && newVersion >= 50) {
+            db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.PINNED_MECHANISM + " TEXT");
+            db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.PINNED_CHANNEL_BINDING + " TEXT");
+        }
+        if (oldVersion < 51 && newVersion >= 51) {
+            db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_MECHANISM + " TEXT");
+            db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_TOKEN + " TEXT");
+        }
     }
 
     private void canonicalizeJids(SQLiteDatabase db) {
@@ -1034,20 +1046,19 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 contactJid.asBareJid().toString() + "/%",
                 contactJid.asBareJid().toString()
         };
-        Cursor cursor = db.query(Conversation.TABLENAME, null,
+        try(final Cursor cursor = db.query(Conversation.TABLENAME, null,
                 Conversation.ACCOUNT + "=? AND (" + Conversation.CONTACTJID
-                        + " like ? OR " + Conversation.CONTACTJID + "=?)", selectionArgs, null, null, null);
-        if (cursor.getCount() == 0) {
-            cursor.close();
-            return null;
-        }
-        cursor.moveToFirst();
-        Conversation conversation = Conversation.fromCursor(cursor);
-        cursor.close();
-        if (conversation.getJid() instanceof InvalidJid) {
-            return null;
+                        + " like ? OR " + Conversation.CONTACTJID + "=?)", selectionArgs, null, null, null)) {
+            if (cursor.getCount() == 0) {
+                return null;
+            }
+            cursor.moveToFirst();
+            final Conversation conversation = Conversation.fromCursor(cursor);
+            if (conversation.getJid() instanceof InvalidJid) {
+                return null;
+            }
+            return conversation;
         }
-        return conversation;
     }
 
     public void updateConversation(final Conversation conversation) {
@@ -1063,33 +1074,28 @@ public class DatabaseBackend extends SQLiteOpenHelper {
     }
 
     public List<Jid> getAccountJids(final boolean enabledOnly) {
-        SQLiteDatabase db = this.getReadableDatabase();
+        final SQLiteDatabase db = this.getReadableDatabase();
         final List<Jid> jids = new ArrayList<>();
         final String[] columns = new String[]{Account.USERNAME, Account.SERVER};
-        String where = enabledOnly ? "not options & (1 <<1)" : null;
-        Cursor cursor = db.query(Account.TABLENAME, columns, where, null, null, null, null);
-        try {
-            while (cursor.moveToNext()) {
+        final String where = enabledOnly ? "not options & (1 <<1)" : null;
+        try (final Cursor cursor = db.query(Account.TABLENAME, columns, where, null, null, null, null)) {
+            while (cursor != null && cursor.moveToNext()) {
                 jids.add(Jid.of(cursor.getString(0), cursor.getString(1), null));
             }
+        } catch (final Exception e) {
             return jids;
-        } catch (Exception e) {
-            return jids;
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
         }
+        return jids;
     }
 
     private List<Account> getAccounts(SQLiteDatabase db) {
-        List<Account> list = new ArrayList<>();
-        Cursor cursor = db.query(Account.TABLENAME, null, null, null, null,
-                null, null);
-        while (cursor.moveToNext()) {
-            list.add(Account.fromCursor(cursor));
+        final List<Account> list = new ArrayList<>();
+        try (final Cursor cursor =
+                db.query(Account.TABLENAME, null, null, null, null, null, null)) {
+            while (cursor != null && cursor.moveToNext()) {
+                list.add(Account.fromCursor(cursor));
+            }
         }
-        cursor.close();
         return list;
     }
 
@@ -1127,14 +1133,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
     }
 
     public void readRoster(Roster roster) {
-        SQLiteDatabase db = this.getReadableDatabase();
-        Cursor cursor;
-        String[] args = {roster.getAccount().getUuid()};
-        cursor = db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", args, null, null, null);
-        while (cursor.moveToNext()) {
-            roster.initContact(Contact.fromCursor(cursor));
+        final SQLiteDatabase db = this.getReadableDatabase();
+        final String[] args = {roster.getAccount().getUuid()};
+        try (final Cursor cursor =
+                db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", args, null, null, null)) {
+            while (cursor.moveToNext()) {
+                roster.initContact(Contact.fromCursor(cursor));
+            }
         }
-        cursor.close();
     }
 
     public void writeRoster(final Roster roster) {
  
  
  
    
    @@ -694,7 +694,7 @@ public class FileBackend {
         } catch (final FileWriterException e) {
             cleanup(file);
             throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
-        } catch (final SecurityException e) {
+        } catch (final SecurityException | IllegalStateException e) {
             cleanup(file);
             throw new FileCopyException(R.string.error_security_exception);
         } catch (final IOException e) {
@@ -1687,19 +1687,19 @@ public class FileBackend {
                 return 0;
             }
             return Integer.parseInt(value);
-        } catch (final IllegalArgumentException e) {
+        } catch (final Exception e) {
             return 0;
         }
     }
 
     private Dimensions getImageDimensions(File file) {
-        BitmapFactory.Options options = new BitmapFactory.Options();
+        final BitmapFactory.Options options = new BitmapFactory.Options();
         options.inJustDecodeBounds = true;
         BitmapFactory.decodeFile(file.getAbsolutePath(), options);
-        int rotation = getRotation(file);
-        boolean rotated = rotation == 90 || rotation == 270;
-        int imageHeight = rotated ? options.outWidth : options.outHeight;
-        int imageWidth = rotated ? options.outHeight : options.outWidth;
+        final int rotation = getRotation(file);
+        final boolean rotated = rotation == 90 || rotation == 270;
+        final int imageHeight = rotated ? options.outWidth : options.outHeight;
+        final int imageWidth = rotated ? options.outHeight : options.outWidth;
         return new Dimensions(imageHeight, imageWidth);
     }
 
@@ -1713,7 +1713,6 @@ public class FileBackend {
         return getVideoDimensions(metadataRetriever);
     }
 
-    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
     private Dimensions getPdfDocumentDimensions(final File file) {
         final ParcelFileDescriptor fileDescriptor;
         try {
@@ -1721,7 +1720,7 @@ public class FileBackend {
             if (fileDescriptor == null) {
                 return new Dimensions(0, 0);
             }
-        } catch (FileNotFoundException e) {
+        } catch (final FileNotFoundException e) {
             return new Dimensions(0, 0);
         }
         try {
@@ -1732,7 +1731,7 @@ public class FileBackend {
             page.close();
             pdfRenderer.close();
             return scalePdfDimensions(new Dimensions(height, width));
-        } catch (IOException | SecurityException e) {
+        } catch (final IOException | SecurityException e) {
             Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
             return new Dimensions(0, 0);
         }
  
  
  
    
    @@ -33,6 +33,7 @@ import java.util.concurrent.CountDownLatch;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.utils.AppRTCUtils;
+import eu.siacs.conversations.xmpp.jingle.Media;
 
 /**
  * AppRTCAudioManager manages all audio related parts of the AppRTC demo.
@@ -44,7 +45,7 @@ public class AppRTCAudioManager {
     private final Context apprtcContext;
     // Contains speakerphone setting: auto, true or false
     @Nullable
-    private final SpeakerPhonePreference speakerPhonePreference;
+    private SpeakerPhonePreference speakerPhonePreference;
     // Handles all tasks related to Bluetooth headset devices.
     private final AppRTCBluetoothManager bluetoothManager;
     @Nullable
@@ -110,6 +111,16 @@ public class AppRTCAudioManager {
         AppRTCUtils.logDeviceInfo(Config.LOGTAG);
     }
 
+    public void switchSpeakerPhonePreference(final SpeakerPhonePreference speakerPhonePreference) {
+        this.speakerPhonePreference = speakerPhonePreference;
+        if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
+            defaultAudioDevice = AudioDevice.EARPIECE;
+        } else {
+            defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+        }
+        updateAudioDeviceState();
+    }
+
     /**
      * Construction.
      */
@@ -587,7 +598,15 @@ public class AppRTCAudioManager {
     }
 
     public enum SpeakerPhonePreference {
-        AUTO, EARPIECE, SPEAKER
+        AUTO, EARPIECE, SPEAKER;
+
+        public static SpeakerPhonePreference of(final Set<Media> media) {
+            if (media.contains(Media.VIDEO)) {
+                return SPEAKER;
+            } else {
+                return EARPIECE;
+            }
+        }
     }
 
     /**
  
  
  
    
    @@ -4,6 +4,7 @@ import android.util.Log;
 
 import androidx.annotation.NonNull;
 
+import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 
@@ -39,7 +40,6 @@ public class ChannelDiscoveryService {
 
     private final XmppConnectionService service;
 
-
     private MuclumbusService muclumbusService;
 
     private final Cache<String, List<Room>> cache;
@@ -50,16 +50,21 @@ public class ChannelDiscoveryService {
     }
 
     void initializeMuclumbusService() {
+        if (Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+            this.muclumbusService = null;
+            return;
+        }
         final OkHttpClient.Builder builder = HttpConnectionManager.OK_HTTP_CLIENT.newBuilder();
         if (service.useTorToConnect()) {
             builder.proxy(HttpConnectionManager.getProxy());
         }
-        Retrofit retrofit = new Retrofit.Builder()
-                .client(builder.build())
-                .baseUrl(Config.CHANNEL_DISCOVERY)
-                .addConverterFactory(GsonConverterFactory.create())
-                .callbackExecutor(Executors.newSingleThreadExecutor())
-                .build();
+        final Retrofit retrofit =
+                new Retrofit.Builder()
+                        .client(builder.build())
+                        .baseUrl(Config.CHANNEL_DISCOVERY)
+                        .addConverterFactory(GsonConverterFactory.create())
+                        .callbackExecutor(Executors.newSingleThreadExecutor())
+                        .build();
         this.muclumbusService = retrofit.create(MuclumbusService.class);
     }
 
@@ -67,7 +72,10 @@ public class ChannelDiscoveryService {
         cache.invalidateAll();
     }
 
-    void discover(@NonNull final String query, Method method, OnChannelSearchResultsFound onChannelSearchResultsFound) {
+    void discover(
+            @NonNull final String query,
+            Method method,
+            OnChannelSearchResultsFound onChannelSearchResultsFound) {
         final List<Room> result = cache.getIfPresent(key(method, query));
         if (result != null) {
             onChannelSearchResultsFound.onChannelSearchResultsFound(result);
@@ -84,59 +92,82 @@ public class ChannelDiscoveryService {
         }
     }
 
-    private void discoverChannelsJabberNetwork(OnChannelSearchResultsFound listener) {
-        Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
-        try {
-            call.enqueue(new Callback<MuclumbusService.Rooms>() {
-                @Override
-                public void onResponse(@NonNull Call<MuclumbusService.Rooms> call, @NonNull Response<MuclumbusService.Rooms> response) {
-                    final MuclumbusService.Rooms body = response.body();
-                    if (body == null) {
-                        listener.onChannelSearchResultsFound(Collections.emptyList());
-                        logError(response);
-                        return;
+    private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
+        if (muclumbusService == null) {
+            listener.onChannelSearchResultsFound(Collections.emptyList());
+            return;
+        }
+        final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
+        call.enqueue(
+                new Callback<MuclumbusService.Rooms>() {
+                    @Override
+                    public void onResponse(
+                            @NonNull Call<MuclumbusService.Rooms> call,
+                            @NonNull Response<MuclumbusService.Rooms> response) {
+                        final MuclumbusService.Rooms body = response.body();
+                        if (body == null) {
+                            listener.onChannelSearchResultsFound(Collections.emptyList());
+                            logError(response);
+                            return;
+                        }
+                        cache.put(key(Method.JABBER_NETWORK, ""), body.items);
+                        listener.onChannelSearchResultsFound(body.items);
                     }
-                    cache.put(key(Method.JABBER_NETWORK, ""), body.items);
-                    listener.onChannelSearchResultsFound(body.items);
-                }
 
-                @Override
-                public void onFailure(@NonNull Call<MuclumbusService.Rooms> call, @NonNull Throwable throwable) {
-                    Log.d(Config.LOGTAG, "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY, throwable);
-                    listener.onChannelSearchResultsFound(Collections.emptyList());
-                }
-            });
-        } catch (Exception e) {
-            e.printStackTrace();
-        }
+                    @Override
+                    public void onFailure(
+                            @NonNull Call<MuclumbusService.Rooms> call,
+                            @NonNull Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
+                                throwable);
+                        listener.onChannelSearchResultsFound(Collections.emptyList());
+                    }
+                });
     }
 
-    private void discoverChannelsJabberNetwork(final String query, OnChannelSearchResultsFound listener) {
-        MuclumbusService.SearchRequest searchRequest = new MuclumbusService.SearchRequest(query);
-        Call<MuclumbusService.SearchResult> searchResultCall = muclumbusService.search(searchRequest);
-
-        searchResultCall.enqueue(new Callback<MuclumbusService.SearchResult>() {
-            @Override
-            public void onResponse(@NonNull Call<MuclumbusService.SearchResult> call, @NonNull Response<MuclumbusService.SearchResult> response) {
-                final MuclumbusService.SearchResult body = response.body();
-                if (body == null) {
-                    listener.onChannelSearchResultsFound(Collections.emptyList());
-                    logError(response);
-                    return;
-                }
-                cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
-                listener.onChannelSearchResultsFound(body.result.items);
-            }
+    private void discoverChannelsJabberNetwork(
+            final String query, final OnChannelSearchResultsFound listener) {
+        if (muclumbusService == null) {
+            listener.onChannelSearchResultsFound(Collections.emptyList());
+            return;
+        }
+        final MuclumbusService.SearchRequest searchRequest =
+                new MuclumbusService.SearchRequest(query);
+        final Call<MuclumbusService.SearchResult> searchResultCall =
+                muclumbusService.search(searchRequest);
+        searchResultCall.enqueue(
+                new Callback<MuclumbusService.SearchResult>() {
+                    @Override
+                    public void onResponse(
+                            @NonNull Call<MuclumbusService.SearchResult> call,
+                            @NonNull Response<MuclumbusService.SearchResult> response) {
+                        final MuclumbusService.SearchResult body = response.body();
+                        if (body == null) {
+                            listener.onChannelSearchResultsFound(Collections.emptyList());
+                            logError(response);
+                            return;
+                        }
+                        cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
+                        listener.onChannelSearchResultsFound(body.result.items);
+                    }
 
-            @Override
-            public void onFailure(@NonNull Call<MuclumbusService.SearchResult> call, @NonNull Throwable throwable) {
-                Log.d(Config.LOGTAG, "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY, throwable);
-                listener.onChannelSearchResultsFound(Collections.emptyList());
-            }
-        });
+                    @Override
+                    public void onFailure(
+                            @NonNull Call<MuclumbusService.SearchResult> call,
+                            @NonNull Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
+                                throwable);
+                        listener.onChannelSearchResultsFound(Collections.emptyList());
+                    }
+                });
     }
 
-    private void discoverChannelsLocalServers(final String query, final OnChannelSearchResultsFound listener) {
+    private void discoverChannelsLocalServers(
+            final String query, final OnChannelSearchResultsFound listener) {
         final Map<Jid, Account> localMucService = getLocalMucServices();
         Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
         if (localMucService.size() == 0) {
@@ -156,38 +187,49 @@ public class ChannelDiscoveryService {
         for (Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
             IqPacket itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
             queriesInFlight.incrementAndGet();
-            service.sendIqPacket(entry.getValue(), itemsRequest, (account, itemsResponse) -> {
-                if (itemsResponse.getType() == IqPacket.TYPE.RESULT) {
-                    final List<Jid> items = IqParser.items(itemsResponse);
-                    for (Jid item : items) {
-                        IqPacket infoRequest = service.getIqGenerator().queryDiscoInfo(item);
-                        queriesInFlight.incrementAndGet();
-                        service.sendIqPacket(account, infoRequest, new OnIqPacketReceived() {
-                            @Override
-                            public void onIqPacketReceived(Account account, IqPacket infoResponse) {
-                                if (infoResponse.getType() == IqPacket.TYPE.RESULT) {
-                                    final Room room = IqParser.parseRoom(infoResponse);
-                                    if (room != null) {
-                                        rooms.add(room);
-                                    }
-                                    if (queriesInFlight.decrementAndGet() <= 0) {
-                                        finishDiscoSearch(rooms, query, listener);
-                                    }
-                                } else {
-                                    queriesInFlight.decrementAndGet();
-                                }
+            service.sendIqPacket(
+                    entry.getValue(),
+                    itemsRequest,
+                    (account, itemsResponse) -> {
+                        if (itemsResponse.getType() == IqPacket.TYPE.RESULT) {
+                            final List<Jid> items = IqParser.items(itemsResponse);
+                            for (Jid item : items) {
+                                IqPacket infoRequest =
+                                        service.getIqGenerator().queryDiscoInfo(item);
+                                queriesInFlight.incrementAndGet();
+                                service.sendIqPacket(
+                                        account,
+                                        infoRequest,
+                                        new OnIqPacketReceived() {
+                                            @Override
+                                            public void onIqPacketReceived(
+                                                    Account account, IqPacket infoResponse) {
+                                                if (infoResponse.getType()
+                                                        == IqPacket.TYPE.RESULT) {
+                                                    final Room room =
+                                                            IqParser.parseRoom(infoResponse);
+                                                    if (room != null) {
+                                                        rooms.add(room);
+                                                    }
+                                                    if (queriesInFlight.decrementAndGet() <= 0) {
+                                                        finishDiscoSearch(rooms, query, listener);
+                                                    }
+                                                } else {
+                                                    queriesInFlight.decrementAndGet();
+                                                }
+                                            }
+                                        });
                             }
-                        });
-                    }
-                }
-                if (queriesInFlight.decrementAndGet() <= 0) {
-                    finishDiscoSearch(rooms, query, listener);
-                }
-            });
+                        }
+                        if (queriesInFlight.decrementAndGet() <= 0) {
+                            finishDiscoSearch(rooms, query, listener);
+                        }
+                    });
         }
     }
 
-    private void finishDiscoSearch(List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
+    private void finishDiscoSearch(
+            List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
         Collections.sort(rooms);
         cache.put(key(Method.LOCAL_SERVER, ""), rooms);
         if (query.isEmpty()) {
@@ -241,7 +283,7 @@ public class ChannelDiscoveryService {
         try {
             Log.d(Config.LOGTAG, "error body=" + errorBody.string());
         } catch (IOException e) {
-            //ignored
+            // ignored
         }
     }
 
  
  
  
    
    @@ -1,5 +1,7 @@
 package eu.siacs.conversations.services;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.util.Log;
 
 import org.jetbrains.annotations.NotNull;
@@ -502,7 +504,7 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
                 this.start = start.getTimestamp();
             }
             this.end = end;
-            this.queryId = new BigInteger(50, mXmppConnectionService.getRNG()).toString(32);
+            this.queryId = new BigInteger(50, SECURE_RANDOM).toString(32);
             this.version = version;
         }
 
  
  
  
    
    @@ -42,6 +42,7 @@ import androidx.core.app.RemoteInput;
 import androidx.core.content.ContextCompat;
 import androidx.core.graphics.drawable.IconCompat;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 
@@ -104,12 +105,13 @@ public class NotificationService {
     private static final int ERROR_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 6;
     private static final int INCOMING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 8;
     public static final int ONGOING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 10;
-    private static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
-    public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 14;
+    public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
+    private static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 13;
     private final XmppConnectionService mXmppConnectionService;
     private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
     private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
-    private final LinkedHashMap<Conversational, MissedCallsInfo> mMissedCalls = new LinkedHashMap<>();
+    private final LinkedHashMap<Conversational, MissedCallsInfo> mMissedCalls =
+            new LinkedHashMap<>();
     private Conversation mOpenConversation;
     private boolean mIsInForeground;
     private long mLastNotification;
@@ -230,9 +232,11 @@ public class NotificationService {
         ongoingCallsChannel.setGroup("calls");
         notificationManager.createNotificationChannel(ongoingCallsChannel);
 
-        final NotificationChannel missedCallsChannel = new NotificationChannel("missed_calls",
-                c.getString(R.string.missed_calls_channel_name),
-                NotificationManager.IMPORTANCE_HIGH);
+        final NotificationChannel missedCallsChannel =
+                new NotificationChannel(
+                        "missed_calls",
+                        c.getString(R.string.missed_calls_channel_name),
+                        NotificationManager.IMPORTANCE_HIGH);
         missedCallsChannel.setShowBadge(true);
         missedCallsChannel.setSound(null, null);
         missedCallsChannel.setLightColor(LED_COLOR);
@@ -419,8 +423,8 @@ public class NotificationService {
         return count;
     }
 
-    void finishBacklog(boolean notify) {
-        finishBacklog(notify, null);
+    void finishBacklog() {
+        finishBacklog(false, null);
     }
 
     private void pushToStack(final Message message) {
@@ -927,7 +931,8 @@ public class NotificationService {
                             singleBuilder.setGroupAlertBehavior(
                                     NotificationCompat.GROUP_ALERT_SUMMARY);
                         }
-                        modifyForSoundVibrationAndLight(singleBuilder, notifyThis, quiteHours, preferences);
+                        modifyForSoundVibrationAndLight(
+                                singleBuilder, notifyThis, quiteHours, preferences);
                         singleBuilder.setGroup(MESSAGES_GROUP);
                         setNotificationColor(singleBuilder);
                         notify(entry.getKey(), NOTIFICATION_ID, singleBuilder.build());
@@ -1031,30 +1036,39 @@ public class NotificationService {
     }
 
     private Builder buildMissedCallsSummary(boolean publicVersion) {
-        final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+        final Builder builder =
+                new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
         int totalCalls = 0;
-        final StringBuilder names = new StringBuilder();
+        final List<String> names = new ArrayList<>();
         long lastTime = 0;
-        for (Map.Entry<Conversational, MissedCallsInfo> entry : mMissedCalls.entrySet()) {
+        for (final Map.Entry<Conversational, MissedCallsInfo> entry : mMissedCalls.entrySet()) {
             final Conversational conversation = entry.getKey();
             final MissedCallsInfo missedCallsInfo = entry.getValue();
-            names.append(conversation.getContact().getDisplayName());
-            names.append(", ");
+            names.add(conversation.getContact().getDisplayName());
             totalCalls += missedCallsInfo.getNumberOfCalls();
             lastTime = Math.max(lastTime, missedCallsInfo.getLastTime());
         }
-        if (names.length() >= 2) {
-            names.delete(names.length() - 2, names.length());
-        }
-        final String title = (totalCalls == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
-                             (mMissedCalls.size() == 1) ? mXmppConnectionService.getString(R.string.n_missed_calls, totalCalls) :
-                             mXmppConnectionService.getString(R.string.n_missed_calls_from_m_contacts, totalCalls, mMissedCalls.size());
+        final String title =
+                (totalCalls == 1)
+                        ? mXmppConnectionService.getString(R.string.missed_call)
+                        : (mMissedCalls.size() == 1)
+                                ? mXmppConnectionService
+                                        .getResources()
+                                        .getQuantityString(
+                                                R.plurals.n_missed_calls, totalCalls, totalCalls)
+                                : mXmppConnectionService
+                                        .getResources()
+                                        .getQuantityString(
+                                                R.plurals.n_missed_calls_from_m_contacts,
+                                                mMissedCalls.size(),
+                                                totalCalls,
+                                                mMissedCalls.size());
         builder.setContentTitle(title);
         builder.setTicker(title);
         if (!publicVersion) {
-            builder.setContentText(names.toString());
+            builder.setContentText(Joiner.on(", ").join(names));
         }
-        builder.setSmallIcon(R.drawable.ic_missed_call_notification);
+        builder.setSmallIcon(R.drawable.ic_call_missed_white_24db);
         builder.setGroupSummary(true);
         builder.setGroup(MISSED_CALLS_GROUP);
         builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN);
@@ -1076,38 +1090,55 @@ public class NotificationService {
         return builder.build();
     }
 
-    private Builder buildMissedCall(final Conversational conversation, final MissedCallsInfo info, boolean publicVersion) {
-        final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
-        final String title = (info.getNumberOfCalls() == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
-                                                              mXmppConnectionService.getString(R.string.n_missed_calls, info.getNumberOfCalls());
+    private Builder buildMissedCall(
+            final Conversational conversation, final MissedCallsInfo info, boolean publicVersion) {
+        final Builder builder =
+                new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+        final String title =
+                (info.getNumberOfCalls() == 1)
+                        ? mXmppConnectionService.getString(R.string.missed_call)
+                        : mXmppConnectionService
+                                .getResources()
+                                .getQuantityString(
+                                        R.plurals.n_missed_calls,
+                                        info.getNumberOfCalls(),
+                                        info.getNumberOfCalls());
         builder.setContentTitle(title);
         final String name = conversation.getContact().getDisplayName();
         if (publicVersion) {
             builder.setTicker(title);
         } else {
-            if (info.getNumberOfCalls() == 1) {
-                builder.setTicker(mXmppConnectionService.getString(R.string.missed_call_from_x, name));
-            } else {
-                builder.setTicker(mXmppConnectionService.getString(R.string.n_missed_calls_from_x, info.getNumberOfCalls(), name));
-            }
+            builder.setTicker(
+                    mXmppConnectionService
+                            .getResources()
+                            .getQuantityString(
+                                    R.plurals.n_missed_calls_from_x,
+                                    info.getNumberOfCalls(),
+                                    info.getNumberOfCalls(),
+                                    name));
             builder.setContentText(name);
         }
-        builder.setSmallIcon(R.drawable.ic_missed_call_notification);
+        builder.setSmallIcon(R.drawable.ic_call_missed_white_24db);
         builder.setGroup(MISSED_CALLS_GROUP);
         builder.setCategory(NotificationCompat.CATEGORY_CALL);
         builder.setWhen(info.getLastTime());
         builder.setContentIntent(createContentIntent(conversation));
         builder.setDeleteIntent(createMissedCallsDeleteIntent(conversation));
         if (!publicVersion && conversation instanceof Conversation) {
-            builder.setLargeIcon(mXmppConnectionService.getAvatarService()
-                    .get((Conversation) conversation, AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
+            builder.setLargeIcon(
+                    mXmppConnectionService
+                            .getAvatarService()
+                            .get(
+                                    (Conversation) conversation,
+                                    AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
         }
         modifyMissedCall(builder);
         return builder;
     }
 
     private void modifyMissedCall(final Builder builder) {
-        final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
+        final SharedPreferences preferences =
+                PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
         final Resources resources = mXmppConnectionService.getResources();
         final boolean led = preferences.getBoolean("led", resources.getBoolean(R.bool.led));
         if (led) {
@@ -1131,42 +1162,39 @@ public class NotificationService {
                                 R.plurals.x_unread_conversations,
                                 notifications.size(),
                                 notifications.size()));
-        final StringBuilder names = new StringBuilder();
+        final List<String> names = new ArrayList<>();
         Conversation conversation = null;
         for (final ArrayList<Message> messages : notifications.values()) {
-            if (messages.size() > 0) {
-                conversation = (Conversation) messages.get(0).getConversation();
-                final String name = conversation.getName().toString();
-                SpannableString styledString;
-                if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) {
-                    int count = messages.size();
-                    styledString =
-                            new SpannableString(
-                                    name
-                                            + ": "
-                                            + mXmppConnectionService
-                                                    .getResources()
-                                                    .getQuantityString(
-                                                            R.plurals.x_messages, count, count));
-                    styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
-                    style.addLine(styledString);
-                } else {
-                    styledString =
-                            new SpannableString(
-                                    name
-                                            + ": "
-                                            + UIHelper.getMessagePreview(
-                                                            mXmppConnectionService, messages.get(0))
-                                                    .first);
-                    styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
-                    style.addLine(styledString);
-                }
-                names.append(name);
-                names.append(", ");
+            if (messages.isEmpty()) {
+                continue;
             }
-        }
-        if (names.length() >= 2) {
-            names.delete(names.length() - 2, names.length());
+            conversation = (Conversation) messages.get(0).getConversation();
+            final String name = conversation.getName().toString();
+            SpannableString styledString;
+            if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) {
+                int count = messages.size();
+                styledString =
+                        new SpannableString(
+                                name
+                                        + ": "
+                                        + mXmppConnectionService
+                                                .getResources()
+                                                .getQuantityString(
+                                                        R.plurals.x_messages, count, count));
+                styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
+                style.addLine(styledString);
+            } else {
+                styledString =
+                        new SpannableString(
+                                name
+                                        + ": "
+                                        + UIHelper.getMessagePreview(
+                                                        mXmppConnectionService, messages.get(0))
+                                                .first);
+                styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
+                style.addLine(styledString);
+            }
+            names.add(name);
         }
         final String contentTitle =
                 mXmppConnectionService
@@ -1177,7 +1205,7 @@ public class NotificationService {
                                 notifications.size());
         mBuilder.setContentTitle(contentTitle);
         mBuilder.setTicker(contentTitle);
-        mBuilder.setContentText(names.toString());
+        mBuilder.setContentText(Joiner.on(", ").join(names));
         mBuilder.setStyle(style);
         if (conversation != null) {
             mBuilder.setContentIntent(createContentIntent(conversation));
@@ -1582,7 +1610,7 @@ public class NotificationService {
         return createContentIntent(conversation.getUuid(), null);
     }
 
-    private PendingIntent createDeleteIntent(Conversation conversation) {
+    private PendingIntent createDeleteIntent(final Conversation conversation) {
         final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
         intent.setAction(XmppConnectionService.ACTION_CLEAR_MESSAGE_NOTIFICATION);
         if (conversation != null) {
@@ -1609,11 +1637,21 @@ public class NotificationService {
         intent.setAction(XmppConnectionService.ACTION_CLEAR_MISSED_CALL_NOTIFICATION);
         if (conversation != null) {
             intent.putExtra("uuid", conversation.getUuid());
-            return PendingIntent.getService(mXmppConnectionService, generateRequestCode(conversation, 21), intent,
-                s() ? PendingIntent.FLAG_IMMUTABLE : 0);
+            return PendingIntent.getService(
+                    mXmppConnectionService,
+                    generateRequestCode(conversation, 21),
+                    intent,
+                    s()
+                            ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+                            : PendingIntent.FLAG_UPDATE_CURRENT);
         }
-        return PendingIntent.getService(mXmppConnectionService, 1, intent,
-                s() ? PendingIntent.FLAG_IMMUTABLE : 0);
+        return PendingIntent.getService(
+                mXmppConnectionService,
+                1,
+                intent,
+                s()
+                        ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+                        : PendingIntent.FLAG_UPDATE_CURRENT);
     }
 
     private PendingIntent createReplyIntent(
@@ -1937,16 +1975,6 @@ public class NotificationService {
         }
     }
 
-    private class VibrationRunnable implements Runnable {
-
-        @Override
-        public void run() {
-            final Vibrator vibrator =
-                    (Vibrator) mXmppConnectionService.getSystemService(Context.VIBRATOR_SERVICE);
-            vibrator.vibrate(CALL_PATTERN, -1);
-			}
-		}
-
     private static class MissedCallsInfo {
         private int numberOfCalls;
         private long lastTime;
@@ -1969,4 +1997,14 @@ public class NotificationService {
             return lastTime;
         }
     }
+
+    private class VibrationRunnable implements Runnable {
+
+        @Override
+        public void run() {
+            final Vibrator vibrator =
+                    (Vibrator) mXmppConnectionService.getSystemService(Context.VIBRATOR_SERVICE);
+            vibrator.vibrate(CALL_PATTERN, -1);
+			}
+		}
 }
  
  
  
    
    @@ -1,6 +1,7 @@
 package eu.siacs.conversations.services;
 
 import static eu.siacs.conversations.utils.Compatibility.s;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
@@ -40,7 +41,6 @@ import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
 import android.security.KeyChain;
 import android.telephony.PhoneStateListener;
-import android.telephony.TelephonyCallback;
 import android.telephony.TelephonyManager;
 import android.text.TextUtils;
 import android.util.DisplayMetrics;
@@ -50,6 +50,7 @@ import android.util.Pair;
 
 import androidx.annotation.BoolRes;
 import androidx.annotation.IntegerRes;
+import androidx.annotation.NonNull;
 import androidx.core.app.RemoteInput;
 import androidx.core.content.ContextCompat;
 
@@ -392,7 +393,6 @@ public class XmppConnectionService extends Service {
         }
     };
     private final AtomicLong mLastExpiryRun = new AtomicLong(0);
-    private SecureRandom mRandom;
     private final LruCache<Pair<String, String>, ServiceDiscoveryResult> discoCache = new LruCache<>(20);
     private final OnStatusChanged statusListener = new OnStatusChanged() {
 
@@ -464,7 +464,7 @@ public class XmppConnectionService extends Service {
                     Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": went into offline state during low ping mode. reconnecting now");
                     reconnectAccount(account, true, false);
                 } else {
-                    int timeToReconnect = mRandom.nextInt(10) + 2;
+                    final int timeToReconnect = SECURE_RANDOM.nextInt(10) + 2;
                     scheduleWakeUpCall(timeToReconnect, account.getUuid().hashCode());
                 }
             } else if (account.getStatus() == Account.State.REGISTRATION_SUCCESSFUL) {
@@ -967,9 +967,11 @@ public class XmppConnectionService extends Service {
 
     public boolean isDataSaverDisabled() {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-            ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
+            final ConnectivityManager connectivityManager =
+                    (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
             return !connectivityManager.isActiveNetworkMetered()
-                    || connectivityManager.getRestrictBackgroundStatus() == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
+                    || Compatibility.getRestrictBackgroundStatus(connectivityManager)
+                            == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
         } else {
             return true;
         }
@@ -1164,7 +1166,6 @@ public class XmppConnectionService extends Service {
             Log.e(Config.LOGTAG, "unable to initialize security provider", throwable);
         }
         Resolver.init(this);
-        this.mRandom = new SecureRandom();
         updateMemorizingTrustmanager();
         final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
         final int cacheSize = maxMemory / 9;
@@ -1900,7 +1901,7 @@ public class XmppConnectionService extends Service {
         IqPacket iqPacket = new IqPacket(IqPacket.TYPE.SET);
         Element query = iqPacket.query("jabber:iq:private");
         Element storage = query.addChild("storage", "storage:bookmarks");
-        for (Bookmark bookmark : account.getBookmarks()) {
+        for (final Bookmark bookmark : account.getBookmarks()) {
             storage.addChild(bookmark);
         }
         sendIqPacket(account, iqPacket, mDefaultIqHandler);
@@ -1910,8 +1911,8 @@ public class XmppConnectionService extends Service {
         if (!account.areBookmarksLoaded()) return;
 
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": pushing bookmarks via pep");
-        Element storage = new Element("storage", "storage:bookmarks");
-        for (Bookmark bookmark : account.getBookmarks()) {
+        final Element storage = new Element("storage", "storage:bookmarks");
+        for (final Bookmark bookmark : account.getBookmarks()) {
             storage.addChild(bookmark);
         }
         pushNodeAndEnforcePublishOptions(account, Namespace.BOOKMARKS, storage, "current", PublishOptions.persistentWhitelistAccess());
@@ -1979,7 +1980,7 @@ public class XmppConnectionService extends Service {
                     databaseBackend.expireOldMessages(deletionDate);
                 }
                 Log.d(Config.LOGTAG, "restoring roster...");
-                for (Account account : accounts) {
+                for (final Account account : accounts) {
                     databaseBackend.readRoster(account.getRoster());
                     account.initAccountServices(XmppConnectionService.this); //roster needs to be loaded at this stage
                 }
@@ -1999,7 +2000,7 @@ public class XmppConnectionService extends Service {
                         restoreMessages(conversation);
                     }
                 }
-                mNotificationService.finishBacklog(false);
+                mNotificationService.finishBacklog();
                 restoredFromDatabaseLatch.countDown();
                 final long diffMessageRestore = SystemClock.elapsedRealtime() - startMessageRestore;
                 Log.d(Config.LOGTAG, "finished restoring messages in " + diffMessageRestore + "ms");
@@ -2017,11 +2018,11 @@ public class XmppConnectionService extends Service {
 
     public void loadPhoneContacts() {
         mContactMergerExecutor.execute(() -> {
-            Map<Jid, JabberIdContact> contacts = JabberIdContact.load(this);
+            final Map<Jid, JabberIdContact> contacts = JabberIdContact.load(this);
             Log.d(Config.LOGTAG, "start merging phone contacts with roster");
-            for (Account account : accounts) {
-                List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(JabberIdContact.class);
-                for (JabberIdContact jidContact : contacts.values()) {
+            for (final Account account : accounts) {
+                final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(JabberIdContact.class);
+                for (final JabberIdContact jidContact : contacts.values()) {
                     final Contact contact = account.getRoster().getContact(jidContact.getJid());
                     boolean needsCacheClean = contact.setPhoneContact(jidContact);
                     if (needsCacheClean) {
@@ -2029,7 +2030,7 @@ public class XmppConnectionService extends Service {
                     }
                     withSystemAccounts.remove(contact);
                 }
-                for (Contact contact : withSystemAccounts) {
+                for (final Contact contact : withSystemAccounts) {
                     boolean needsCacheClean = contact.unsetPhoneContact(JabberIdContact.class);
                     if (needsCacheClean) {
                         getAvatarService().clear(contact);
@@ -2859,7 +2860,6 @@ public class XmppConnectionService extends Service {
             }
         });
     }
-
     public void joinMuc(Conversation conversation) {
         joinMuc(conversation, null, false);
     }
@@ -3077,6 +3077,71 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void deleteAvatar(final Account account) {
+        final AtomicBoolean executed = new AtomicBoolean(false);
+        final Runnable onDeleted =
+                () -> {
+                    if (executed.compareAndSet(false, true)) {
+                        account.setAvatar(null);
+                        databaseBackend.updateAccount(account);
+                        getAvatarService().clear(account);
+                        updateAccountUi();
+                    }
+                };
+        deleteVcardAvatar(account, onDeleted);
+        deletePepNode(account, Namespace.AVATAR_DATA);
+        deletePepNode(account, Namespace.AVATAR_METADATA, onDeleted);
+    }
+
+    public void deletePepNode(final Account account, final String node) {
+        deletePepNode(account, node, null);
+    }
+
+    private void deletePepNode(final Account account, final String node, final Runnable runnable) {
+        final IqPacket request = mIqGenerator.deleteNode(node);
+        sendIqPacket(account, request, (a, packet) -> {
+            if (packet.getType() == IqPacket.TYPE.RESULT) {
+                Log.d(Config.LOGTAG,a.getJid().asBareJid()+": successfully deleted pep node "+node);
+                if (runnable != null) {
+                    runnable.run();
+                }
+            } else {
+                Log.d(Config.LOGTAG,a.getJid().asBareJid()+": failed to delete "+ packet);
+            }
+        });
+    }
+
+    private void deleteVcardAvatar(final Account account, @NonNull final Runnable runnable) {
+        final IqPacket retrieveVcard = mIqGenerator.retrieveVcardAvatar(account.getJid().asBareJid());
+        sendIqPacket(account, retrieveVcard, (a, response) -> {
+            if (response.getType() != IqPacket.TYPE.RESULT) {
+                Log.d(Config.LOGTAG,a.getJid().asBareJid()+": no vCard set. nothing to do");
+                return;
+            }
+            final Element vcard = response.findChild("vCard", "vcard-temp");
+            if (vcard == null) {
+                Log.d(Config.LOGTAG,a.getJid().asBareJid()+": no vCard set. nothing to do");
+                return;
+            }
+            Element photo = vcard.findChild("PHOTO");
+            if (photo == null) {
+                photo = vcard.addChild("PHOTO");
+            }
+            photo.clearChildren();
+            IqPacket publication = new IqPacket(IqPacket.TYPE.SET);
+            publication.setTo(a.getJid().asBareJid());
+            publication.addChild(vcard);
+            sendIqPacket(account, publication, (a1, publicationResponse) -> {
+                if (publicationResponse.getType() == IqPacket.TYPE.RESULT) {
+                    Log.d(Config.LOGTAG,a1.getJid().asBareJid()+": successfully deleted vcard avatar");
+                    runnable.run();
+                } else {
+                    Log.d(Config.LOGTAG, "failed to publish vcard " + publicationResponse.getErrorCondition());
+                }
+            });
+        });
+    }
+
     private boolean hasEnabledAccounts() {
         if (this.accounts == null) {
             return false;
@@ -3253,7 +3318,7 @@ public class XmppConnectionService extends Service {
                     }
                     return false;
                 }
-                final Jid jid = Jid.of(CryptoHelper.pronounceable(getRNG()), server, null);
+                final Jid jid = Jid.of(CryptoHelper.pronounceable(), server, null);
                 final Conversation conversation = findOrCreateConversation(account, jid, true, false, true);
                 joinMuc(conversation, new OnConferenceJoined() {
                     @Override
@@ -3673,7 +3738,7 @@ public class XmppConnectionService extends Service {
                 if (result.getType() == IqPacket.TYPE.RESULT) {
                     publishAvatarMetadata(account, avatar, options, true, callback);
                 } else if (retry && PublishOptions.preconditionNotMet(result)) {
-                    pushNodeConfiguration(account, "urn:xmpp:avatar:data", options, new OnConfigurationPushed() {
+                    pushNodeConfiguration(account, Namespace.AVATAR_DATA, options, new OnConfigurationPushed() {
                         @Override
                         public void onPushSucceeded() {
                             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": changed node configuration for avatar node");
@@ -3713,7 +3778,7 @@ public class XmppConnectionService extends Service {
                         callback.onAvatarPublicationSucceeded();
                     }
                 } else if (retry && PublishOptions.preconditionNotMet(result)) {
-                    pushNodeConfiguration(account, "urn:xmpp:avatar:metadata", options, new OnConfigurationPushed() {
+                    pushNodeConfiguration(account, Namespace.AVATAR_METADATA, options, new OnConfigurationPushed() {
                         @Override
                         public void onPushSucceeded() {
                             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": changed node configuration for avatar meta data node");
@@ -4358,10 +4423,6 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public SecureRandom getRNG() {
-        return this.mRandom;
-    }
-
     public MemorizingTrustManager getMemorizingTrustManager() {
         return this.mMemorizingTrustManager;
     }
@@ -4419,7 +4480,7 @@ public class XmppConnectionService extends Service {
         for (final Account account : accounts) {
             if (account.getXmppConnection() != null) {
                 mucServers.addAll(account.getXmppConnection().getMucServers());
-                for (Bookmark bookmark : account.getBookmarks()) {
+                for (final Bookmark bookmark : account.getBookmarks()) {
                     final Jid jid = bookmark.getJid();
                     final String s = jid == null ? null : jid.getDomain().toEscapedString();
                     if (s != null) {
@@ -4500,7 +4561,6 @@ public class XmppConnectionService extends Service {
         for (Account account : getAccounts()) {
             if (account.isOnlineAndConnected() && mPushManagementService.available(account)) {
                 mPushManagementService.registerPushTokenOnServer(account);
-                //TODO renew mucs
             }
         }
     }
  
  
  
    
    @@ -20,6 +20,8 @@ import android.widget.Toast;
 
 import androidx.databinding.DataBindingUtil;
 
+import com.google.common.base.Strings;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
@@ -90,6 +92,9 @@ public class ChannelDiscoveryActivity extends XmppActivity implements MenuItem.O
     }
 
     private static ChannelDiscoveryService.Method getMethod(final Context c) {
+        if ( Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+            return ChannelDiscoveryService.Method.LOCAL_SERVER;
+        }
         if (QuickConversationsService.isQuicksy()) {
             return ChannelDiscoveryService.Method.JABBER_NETWORK;
         }
  
  
  
    
    @@ -2801,6 +2801,9 @@ public class ConversationFragment extends XmppFragment
                 case KICKED:
                     showSnackbar(R.string.conference_kicked, R.string.join, joinMuc);
                     break;
+                case TECHNICAL_PROBLEMS:
+                    showSnackbar(R.string.conference_technical_problems, R.string.try_again, joinMuc);
+                    break;
                 case UNKNOWN:
                     showSnackbar(R.string.conference_unknown_error, R.string.try_again, joinMuc);
                     break;
  
  
  
    
    @@ -43,7 +43,6 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
     private boolean jidWasModified = false;
     private boolean nameEntered = false;
     private boolean skipTetxWatcher = false;
-    private static final SecureRandom RANDOM = new SecureRandom();
 
     public static CreatePublicChannelDialog newInstance(List<String> accounts) {
         CreatePublicChannelDialog dialog = new CreatePublicChannelDialog();
@@ -158,7 +157,7 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
             try {
                 return Jid.of(localpart, domain, null).toEscapedString();
             } catch (IllegalArgumentException e) {
-                return Jid.of(CryptoHelper.pronounceable(RANDOM), domain, null).toEscapedString();
+                return Jid.of(CryptoHelper.pronounceable(), domain, null).toEscapedString();
             }
         }
     }
  
  
  
    
    @@ -181,7 +181,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
             }
 
             if (inNeedOfSaslAccept()) {
-                mAccount.setKey(Account.PINNED_MECHANISM_KEY, String.valueOf(-1));
+                mAccount.resetPinnedMechanism();
                 if (!xmppConnectionService.updateAccount(mAccount)) {
                     Toast.makeText(EditAccountActivity.this, R.string.unable_to_update_account, Toast.LENGTH_SHORT).show();
                 }
@@ -286,13 +286,14 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
                 mAccount = new Account(jid.asBareJid(), password);
                 mAccount.setPort(numericPort);
                 mAccount.setHostname(hostname);
-                mAccount.setOption(Account.OPTION_USETLS, true);
-                mAccount.setOption(Account.OPTION_USECOMPRESSION, true);
                 mAccount.setOption(Account.OPTION_REGISTER, registerNewAccount);
                 xmppConnectionService.createAccount(mAccount);
             }
             binding.hostnameLayout.setError(null);
             binding.portLayout.setError(null);
+            if (mAccount.isOnion()) {
+                Toast.makeText(EditAccountActivity.this, R.string.audio_video_disabled_tor, Toast.LENGTH_LONG).show();
+            }
             if (mAccount.isEnabled()
                     && !registerNewAccount
                     && !mInitMode) {
@@ -418,7 +419,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
             } else {
                 preset = jid.getDomain();
             }
-            final Intent intent = SignupUtils.getTokenRegistrationIntent(this, preset, mAccount.getKey(Account.PRE_AUTH_REGISTRATION_TOKEN));
+            final Intent intent = SignupUtils.getTokenRegistrationIntent(this, preset, mAccount.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN));
             StartConversationActivity.addInviteUri(intent, getIntent());
             startActivity(intent);
             return;
@@ -824,7 +825,6 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
         }
         if (mUsernameMode) {
             this.binding.accountJidLayout.setHint(getString(R.string.username_hint));
-            this.binding.accountJid.setHint(R.string.username_hint);
         } else {
             final KnownHostsAdapter mKnownHostsAdapter = new KnownHostsAdapter(this,
                     R.layout.simple_list_item,
@@ -892,7 +892,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
     }
 
     private boolean inNeedOfSaslAccept() {
-        return mAccount != null && mAccount.getLastErrorStatus() == Account.State.DOWNGRADE_ATTACK && mAccount.getKeyAsInt(Account.PINNED_MECHANISM_KEY, -1) >= 0 && !accountInfoEdited();
+        return mAccount != null && mAccount.getLastErrorStatus() == Account.State.DOWNGRADE_ATTACK && mAccount.getPinnedMechanismPriority() >= 0 && !accountInfoEdited();
     }
 
     private void shareBarcode() {
  
  
  
    
    @@ -284,14 +284,14 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
         }
 
         if (p == null) {
-            finish.onGatewayResult(binding.jid.getText().toString(), null);
+            finish.onGatewayResult(binding.jid.getText().toString().trim(), null);
         } else if (p.first != null) { // Gateway already responsed to jabber:iq:gateway once
             final Account acct = ((XmppActivity) getActivity()).xmppConnectionService.findAccountByJid(accountJid);
-            ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString(), finish);
+            ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString().trim(), finish);
         } else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) {
-            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString(), p.second.first.getDomain().toString()).toString(), null);
+            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim(), p.second.first.getDomain().toString()).toString(), null);
         } else if (p.second.first.isDomainJid()) {
-            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
+            finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
         } else {
             finish.onGatewayResult(null, null);
         }
  
  
  
    
    @@ -7,6 +7,8 @@ import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
 import android.view.View;
 import android.view.View.OnLongClickListener;
 import android.widget.Button;
@@ -14,6 +16,7 @@ import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 
 import com.theartofdev.edmodo.cropper.CropImage;
@@ -99,18 +102,25 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
                 xmppConnectionService.publishAvatar(account, avatarUri, this);
             }
         });
-        this.cancelButton.setOnClickListener(v -> {
-            if (mInitialAccountSetup) {
-                final Intent intent = new Intent(getApplicationContext(), StartConversationActivity.class);
-                if (xmppConnectionService != null && xmppConnectionService.getAccounts().size() == 1) {
-                    intent.putExtra("init", true);
-                }
-                StartConversationActivity.addInviteUri(intent, getIntent());
-                intent.putExtra(EXTRA_ACCOUNT, account.getJid().asBareJid().toEscapedString());
-                startActivity(intent);
-            }
-            finish();
-        });
+        this.cancelButton.setOnClickListener(
+                v -> {
+                    if (mInitialAccountSetup) {
+                        final Intent intent =
+                                new Intent(
+                                        getApplicationContext(), StartConversationActivity.class);
+                        if (xmppConnectionService != null
+                                && xmppConnectionService.getAccounts().size() == 1) {
+                            intent.putExtra("init", true);
+                        }
+                        StartConversationActivity.addInviteUri(intent, getIntent());
+                        if (account != null) {
+                            intent.putExtra(
+                                    EXTRA_ACCOUNT, account.getJid().asBareJid().toEscapedString());
+                        }
+                        startActivity(intent);
+                    }
+                    finish();
+                });
         this.avatar.setOnClickListener(v -> chooseAvatar(this));
         this.defaultUri = PhoneHelper.getProfilePictureUri(getApplicationContext());
         if (savedInstanceState != null) {
@@ -120,7 +130,25 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
     }
 
     @Override
-    public void onSaveInstanceState(Bundle outState) {
+    public boolean onCreateOptionsMenu(@NonNull final Menu menu) {
+        getMenuInflater().inflate(R.menu.activity_publish_profile_picture, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        if (item.getItemId() == R.id.action_delete_avatar) {
+            if (xmppConnectionService != null && account != null) {
+                xmppConnectionService.deleteAvatar(account);
+            }
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
+        }
+    }
+
+    @Override
+    public void onSaveInstanceState(@NonNull Bundle outState) {
         if (this.avatarUri != null) {
             outState.putParcelable("uri", this.avatarUri);
         }
  
  
  
    
    @@ -18,6 +18,7 @@ import android.widget.Toast;
 import androidx.databinding.DataBindingUtil;
 
 import java.io.File;
+import java.lang.ref.WeakReference;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Locale;
@@ -136,30 +137,41 @@ public class RecordingActivity extends Activity implements View.OnClickListener
             }
         }
         if (saveFile) {
-            new Thread(
-                            () -> {
-                                try {
-                                    if (!outputFileWrittenLatch.await(2, TimeUnit.SECONDS)) {
-                                        Log.d(
-                                                Config.LOGTAG,
-                                                "time out waiting for output file to be written");
-                                    }
-                                } catch (InterruptedException e) {
-                                    Log.d(
-                                            Config.LOGTAG,
-                                            "interrupted while waiting for output file to be written",
-                                            e);
-                                }
-                                runOnUiThread(
-                                        () -> {
-                                            setResult(
-                                                    Activity.RESULT_OK,
-                                                    new Intent()
-                                                            .setData(Uri.fromFile(mOutputFile)));
-                                            finish();
-                                        });
-                            })
-                    .start();
+            new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
+        }
+    }
+
+    private static class Finisher implements Runnable {
+
+        private final CountDownLatch latch;
+        private final File outputFile;
+        private final WeakReference<Activity> activityReference;
+
+        private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
+            this.latch = latch;
+            this.outputFile = outputFile;
+            this.activityReference = new WeakReference<>(activity);
+        }
+
+        @Override
+        public void run() {
+            try {
+                if (!latch.await(8, TimeUnit.SECONDS)) {
+                    Log.d(Config.LOGTAG, "time out waiting for output file to be written");
+                }
+            } catch (final InterruptedException e) {
+                Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
+            }
+            final Activity activity = activityReference.get();
+            if (activity == null) {
+                return;
+            }
+            activity.runOnUiThread(
+                    () -> {
+                        activity.setResult(
+                                Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
+                        activity.finish();
+                    });
         }
     }
 
@@ -187,7 +199,7 @@ public class RecordingActivity extends Activity implements View.OnClickListener
         setupFileObserver(parentDirectory);
     }
 
-    private void setupFileObserver(File directory) {
+    private void setupFileObserver(final File directory) {
         mFileObserver =
                 new FileObserver(directory.getAbsolutePath()) {
                     @Override
@@ -207,7 +219,7 @@ public class RecordingActivity extends Activity implements View.OnClickListener
     }
 
     @Override
-    public void onClick(View view) {
+    public void onClick(final View view) {
         switch (view.getId()) {
             case R.id.cancel_button:
                 mHandler.removeCallbacks(mTickExecutor);
  
  
  
    
    @@ -5,6 +5,7 @@ import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
+import android.app.Activity;
 import android.app.PictureInPictureParams;
 import android.content.ActivityNotFoundException;
 import android.content.Context;
@@ -66,6 +67,7 @@ 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;
 import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
@@ -102,9 +104,12 @@ public class RtpSessionActivity extends XmppActivity
             Arrays.asList(
                     RtpEndUserState.CONNECTING,
                     RtpEndUserState.CONNECTED,
-                    RtpEndUserState.RECONNECTING);
+                    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,
@@ -112,6 +117,8 @@ public class RtpSessionActivity extends XmppActivity
                     RtpEndUserState.RECONNECTING);
     private static final String PROXIMITY_WAKE_LOCK_TAG = "conversations:in-rtp-session";
     private static final int REQUEST_ACCEPT_CALL = 0x1111;
+    private static final int REQUEST_ACCEPT_CONTENT = 0x1112;
+    private static final int REQUEST_ADD_CONTENT = 0x1113;
     private WeakReference<JingleRtpConnection> rtpConnectionReference;
 
     private ActivityRtpSessionBinding binding;
@@ -177,8 +184,10 @@ public class RtpSessionActivity extends XmppActivity
         final MenuItem help = menu.findItem(R.id.action_help);
         final MenuItem gotoChat = menu.findItem(R.id.action_goto_chat);
         final MenuItem dialpad = menu.findItem(R.id.action_dialpad);
-        help.setVisible(isHelpButtonVisible());
+        final MenuItem switchToVideo = menu.findItem(R.id.action_switch_to_video);
+        help.setVisible(Config.HELP != null && isHelpButtonVisible());
         gotoChat.setVisible(isSwitchToConversationVisible());
+        switchToVideo.setVisible(isSwitchToVideoVisible());
         dialpad.setVisible(isAudioOnlyConversation());
         return super.onCreateOptionsMenu(menu);
     }
@@ -224,6 +233,15 @@ public class RtpSessionActivity extends XmppActivity
         return connection != null && !connection.getMedia().contains(Media.VIDEO);
     }
 
+    private boolean isSwitchToVideoVisible() {
+        final JingleRtpConnection connection =
+                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
+        if (connection == null) {
+            return false;
+        }
+        return Media.audioOnly(connection.getMedia()) && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
+    }
+
     private void switchToConversation() {
         final Contact contact = getWith();
         final Conversation conversation =
@@ -245,13 +263,16 @@ public class RtpSessionActivity extends XmppActivity
         switch (item.getItemId()) {
             case R.id.action_help:
                 launchHelpInBrowser();
-                break;
+                return true;
             case R.id.action_goto_chat:
                 switchToConversation();
-                break;
+                return true;
             case R.id.action_dialpad:
                 toggleDialpadVisibility();
-                break;
+                return true;
+            case R.id.action_switch_to_video:
+                requestPermissionAndSwitchToVideo();
+                return true;
         }
         return super.onOptionsItemSelected(item);
     }
@@ -305,9 +326,60 @@ public class RtpSessionActivity extends XmppActivity
         requestPermissionsAndAcceptCall();
     }
 
+    private void acceptContentAdd() {
+        try {
+            requireRtpConnection()
+                    .acceptContentAdd(requireRtpConnection().getPendingContentAddition().summary);
+        } catch (final IllegalStateException e) {
+            Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    private void requestPermissionAndSwitchToVideo() {
+        final List<String> permissions = permissions(ImmutableSet.of(Media.VIDEO, Media.AUDIO));
+        if (PermissionUtils.hasPermission(this, permissions, REQUEST_ADD_CONTENT)) {
+            switchToVideo();
+        }
+    }
+
+    private void switchToVideo() {
+        try {
+            requireRtpConnection().addMedia(Media.VIDEO);
+        } catch (final IllegalStateException e) {
+            Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    private void acceptContentAdd(final ContentAddition contentAddition) {
+        if (contentAddition == null || contentAddition.direction != ContentAddition.Direction.INCOMING) {
+            Log.d(Config.LOGTAG,"ignore press on content-accept button");
+            return;
+        }
+        requestPermissionAndAcceptContentAdd(contentAddition);
+    }
+
+    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);
+        }
+    }
+
+    private void rejectContentAdd(final View view) {
+        requireRtpConnection().rejectContentAdd();
+    }
+
     private void requestPermissionsAndAcceptCall() {
+        final List<String> permissions = permissions(getMedia());
+        if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CALL)) {
+            putScreenInCallMode();
+            checkRecorderAndAcceptCall();
+        }
+    }
+
+    private List<String> permissions(final Set<Media> media) {
         final ImmutableList.Builder<String> permissions = ImmutableList.builder();
-        if (getMedia().contains(Media.VIDEO)) {
+        if (media.contains(Media.VIDEO)) {
             permissions.add(Manifest.permission.CAMERA).add(Manifest.permission.RECORD_AUDIO);
         } else {
             permissions.add(Manifest.permission.RECORD_AUDIO);
@@ -315,10 +387,7 @@ public class RtpSessionActivity extends XmppActivity
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
             permissions.add(Manifest.permission.BLUETOOTH_CONNECT);
         }
-        if (PermissionUtils.hasPermission(this, permissions.build(), REQUEST_ACCEPT_CALL)) {
-            putScreenInCallMode();
-            checkRecorderAndAcceptCall();
-        }
+        return permissions.build();
     }
 
     private void checkRecorderAndAcceptCall() {
@@ -331,21 +400,38 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     private void checkMicrophoneAvailabilityAsync() {
-        new Thread(this::checkMicrophoneAvailability).start();
+        new Thread(new MicrophoneAvailabilityCheck(this)).start();
     }
 
-    private void checkMicrophoneAvailability() {
-        final long start = SystemClock.elapsedRealtime();
-        final boolean isMicrophoneAvailable = AppRTCAudioManager.isMicrophoneAvailable();
-        final long stop = SystemClock.elapsedRealtime();
-        Log.d(Config.LOGTAG, "checking microphone availability took " + (stop - start) + "ms");
-        if (isMicrophoneAvailable) {
-            return;
+    private static class MicrophoneAvailabilityCheck implements Runnable {
+
+        private final WeakReference<Activity> activityReference;
+
+        private MicrophoneAvailabilityCheck(final Activity activity) {
+            this.activityReference = new WeakReference<>(activity);
+        }
+
+        @Override
+        public void run() {
+            final long start = SystemClock.elapsedRealtime();
+            final boolean isMicrophoneAvailable = AppRTCAudioManager.isMicrophoneAvailable();
+            final long stop = SystemClock.elapsedRealtime();
+            Log.d(Config.LOGTAG, "checking microphone availability took " + (stop - start) + "ms");
+            if (isMicrophoneAvailable) {
+                return;
+            }
+            final Activity activity = activityReference.get();
+            if (activity == null) {
+                return;
+            }
+            activity.runOnUiThread(
+                    () ->
+                            Toast.makeText(
+                                            activity,
+                                            R.string.microphone_unavailable,
+                                            Toast.LENGTH_LONG)
+                                    .show());
         }
-        runOnUiThread(
-                () ->
-                        Toast.makeText(this, R.string.microphone_unavailable, Toast.LENGTH_LONG)
-                                .show());
     }
 
     private void putScreenInCallMode() {
@@ -532,11 +618,18 @@ public class RtpSessionActivity extends XmppActivity
         if (PermissionUtils.allGranted(permissionResult.grantResults)) {
             if (requestCode == REQUEST_ACCEPT_CALL) {
                 checkRecorderAndAcceptCall();
+            } else if (requestCode == REQUEST_ACCEPT_CONTENT) {
+                acceptContentAdd();
+            } else if (requestCode == REQUEST_ADD_CONTENT) {
+                switchToVideo();
             }
         } else {
             @StringRes int res;
             final String firstDenied =
                     getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
+            if (firstDenied == null) {
+                return;
+            }
             if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
                 res = R.string.no_microphone_permission;
             } else if (Manifest.permission.CAMERA.equals(firstDenied)) {
@@ -611,8 +704,8 @@ public class RtpSessionActivity extends XmppActivity
     private boolean isConnected() {
         final JingleRtpConnection connection =
                 this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
-        return connection != null
-                && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
+        final RtpEndUserState endUserState = connection == null ? null : connection.getEndUserState();
+        return STATES_CONSIDERED_CONNECTED.contains(endUserState) || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD;
     }
 
     private boolean switchToPictureInPicture() {
@@ -704,6 +797,7 @@ public class RtpSessionActivity extends XmppActivity
             return true;
         }
         final Set<Media> media = getMedia();
+        final ContentAddition contentAddition = getPendingContentAddition();
         if (currentState == RtpEndUserState.INCOMING_CALL) {
             getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
         }
@@ -713,9 +807,9 @@ public class RtpSessionActivity extends XmppActivity
         }
         setWith(currentState);
         updateVideoViews(currentState);
-        updateStateDisplay(currentState, media);
+        updateStateDisplay(currentState, media, contentAddition);
         updateVerifiedShield(verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(currentState));
-        updateButtonConfiguration(currentState, media);
+        updateButtonConfiguration(currentState, media, contentAddition);
         updateIncomingCallScreen(currentState);
         invalidateOptionsMenu();
         return false;
@@ -766,10 +860,10 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     private void updateStateDisplay(final RtpEndUserState state) {
-        updateStateDisplay(state, Collections.emptySet());
+        updateStateDisplay(state, Collections.emptySet(), null);
     }
 
-    private void updateStateDisplay(final RtpEndUserState state, final Set<Media> media) {
+    private void updateStateDisplay(final RtpEndUserState state, final Set<Media> media, final ContentAddition contentAddition) {
         switch (state) {
             case INCOMING_CALL:
                 Preconditions.checkArgument(media.size() > 0, "Media must not be empty");
@@ -779,6 +873,13 @@ public class RtpSessionActivity extends XmppActivity
                     setTitle(R.string.rtp_state_incoming_call);
                 }
                 break;
+            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;
@@ -870,12 +971,16 @@ public class RtpSessionActivity extends XmppActivity
         return requireRtpConnection().getMedia();
     }
 
+    public ContentAddition getPendingContentAddition() {
+        return requireRtpConnection().getPendingContentAddition();
+    }
+
     private void updateButtonConfiguration(final RtpEndUserState state) {
-        updateButtonConfiguration(state, Collections.emptySet());
+        updateButtonConfiguration(state, Collections.emptySet(), null);
     }
 
     @SuppressLint("RestrictedApi")
-    private void updateButtonConfiguration(final RtpEndUserState state, final Set<Media> media) {
+    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);
@@ -890,6 +995,16 @@ public class RtpSessionActivity extends XmppActivity
             this.binding.acceptCall.setOnClickListener(this::acceptCall);
             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.setOnClickListener(this::rejectContentAdd);
+            this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp);
+            this.binding.rejectCall.setVisibility(View.VISIBLE);
+            this.binding.endCall.setVisibility(View.INVISIBLE);
+            this.binding.acceptCall.setContentDescription(getString(R.string.accept));
+            this.binding.acceptCall.setOnClickListener((v -> acceptContentAdd(contentAddition)));
+            this.binding.acceptCall.setImageResource(R.drawable.ic_baseline_check_24);
+            this.binding.acceptCall.setVisibility(View.VISIBLE);
         } else if (state == RtpEndUserState.DECLINED_OR_BUSY) {
             this.binding.rejectCall.setContentDescription(getString(R.string.exit));
             this.binding.rejectCall.setOnClickListener(this::exit);
@@ -1064,6 +1179,12 @@ public class RtpSessionActivity extends XmppActivity
     }
 
     private void disableVideo(View view) {
+        final JingleRtpConnection rtpConnection = requireRtpConnection();
+        final ContentAddition pending = rtpConnection.getPendingContentAddition();
+        if (pending != null && pending.direction == ContentAddition.Direction.OUTGOING) {
+            rtpConnection.retractContentAdd();
+            return;
+        }
         requireRtpConnection().setVideoEnabled(false);
         updateInCallButtonConfigurationVideo(false, requireRtpConnection().isCameraSwitchable());
     }
@@ -1292,6 +1413,7 @@ public class RtpSessionActivity extends XmppActivity
         final AbstractJingleConnection.Id id = requireRtpConnection().getId();
         final boolean verified = requireRtpConnection().isVerified();
         final Set<Media> media = getMedia();
+        final ContentAddition contentAddition = getPendingContentAddition();
         final Contact contact = getWith();
         if (account == id.account && id.with.equals(with) && id.sessionId.equals(sessionId)) {
             if (state == RtpEndUserState.ENDED) {
@@ -1300,10 +1422,10 @@ public class RtpSessionActivity extends XmppActivity
             }
             runOnUiThread(
                     () -> {
-                        updateStateDisplay(state, media);
+                        updateStateDisplay(state, media, contentAddition);
                         updateVerifiedShield(
                                 verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(state));
-                        updateButtonConfiguration(state, media);
+                        updateButtonConfiguration(state, media, contentAddition);
                         updateVideoViews(state);
                         updateIncomingCallScreen(state, contact);
                         invalidateOptionsMenu();
  
  
  
    
    @@ -23,6 +23,8 @@ import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.core.content.ContextCompat;
 
+import com.google.common.base.Strings;
+
 import java.io.File;
 import java.security.KeyStoreException;
 import java.util.ArrayList;
@@ -38,410 +40,489 @@ import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.ExportBackupService;
 import eu.siacs.conversations.services.MemorizingTrustManager;
 import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.ui.util.SettingsUtils;
 import eu.siacs.conversations.ui.util.StyledAttributes;
 import eu.siacs.conversations.utils.GeoHelper;
-import eu.siacs.conversations.ui.util.SettingsUtils;
 import eu.siacs.conversations.utils.TimeFrameUtils;
 import eu.siacs.conversations.xmpp.Jid;
 
-public class SettingsActivity extends XmppActivity implements
-		OnSharedPreferenceChangeListener {
-
-	public static final String KEEP_FOREGROUND_SERVICE = "enable_foreground_service";
-	public static final String AWAY_WHEN_SCREEN_IS_OFF = "away_when_screen_off";
-	public static final String TREAT_VIBRATE_AS_SILENT = "treat_vibrate_as_silent";
-	public static final String DND_ON_SILENT_MODE = "dnd_on_silent_mode";
-	public static final String MANUALLY_CHANGE_PRESENCE = "manually_change_presence";
-	public static final String BLIND_TRUST_BEFORE_VERIFICATION = "btbv";
-	public static final String AUTOMATIC_MESSAGE_DELETION = "automatic_message_deletion";
-	public static final String BROADCAST_LAST_ACTIVITY = "last_activity";
-	public static final String THEME = "theme";
-	public static final String SHOW_DYNAMIC_TAGS = "show_dynamic_tags";
-	public static final String OMEMO_SETTING = "omemo";
-	public static final String PREVENT_SCREENSHOTS = "prevent_screenshots";
-
-	public static final int REQUEST_CREATE_BACKUP = 0xbf8701;
-
-	private SettingsFragment mSettingsFragment;
-
-	@Override
-	protected void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-		setContentView(R.layout.activity_settings);
-		FragmentManager fm = getFragmentManager();
-		mSettingsFragment = (SettingsFragment) fm.findFragmentById(R.id.settings_content);
-		if (mSettingsFragment == null || !mSettingsFragment.getClass().equals(SettingsFragment.class)) {
-			mSettingsFragment = new SettingsFragment();
-			fm.beginTransaction().replace(R.id.settings_content, mSettingsFragment).commit();
-		}
-		mSettingsFragment.setActivityIntent(getIntent());
-		this.mTheme = findTheme();
-		setTheme(this.mTheme);
-		getWindow().getDecorView().setBackgroundColor(StyledAttributes.getColor(this, R.attr.color_background_primary));
-		setSupportActionBar(findViewById(R.id.toolbar));
-		configureActionBar(getSupportActionBar());
-	}
-
-	@Override
-	void onBackendConnected() {
-
-	}
-
-	@Override
-	public void onStart() {
-		super.onStart();
-		PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this);
-
-		changeOmemoSettingSummary();
-
-		if (QuickConversationsService.isQuicksy()) {
-			final PreferenceCategory connectionOptions = (PreferenceCategory) mSettingsFragment.findPreference("connection_options");
-			final PreferenceCategory groupChats = (PreferenceCategory) mSettingsFragment.findPreference("group_chats");
-			final Preference channelDiscoveryMethod = mSettingsFragment.findPreference("channel_discovery_method");
-			PreferenceScreen expert = (PreferenceScreen) mSettingsFragment.findPreference("expert");
-			if (connectionOptions != null) {
-				expert.removePreference(connectionOptions);
-			}
-			if (groupChats != null && channelDiscoveryMethod != null) {
-				groupChats.removePreference(channelDiscoveryMethod);
-			}
-		}
-
-		PreferenceScreen mainPreferenceScreen = (PreferenceScreen) mSettingsFragment.findPreference("main_screen");
-
-		PreferenceCategory attachmentsCategory = (PreferenceCategory) mSettingsFragment.findPreference("attachments");
-		CheckBoxPreference locationPlugin = (CheckBoxPreference) mSettingsFragment.findPreference("use_share_location_plugin");
-		if (attachmentsCategory != null && locationPlugin != null) {
-			if (!GeoHelper.isLocationPluginInstalled(this)) {
-				attachmentsCategory.removePreference(locationPlugin);
-			}
-		}
-
-		//this feature is only available on Huawei Android 6.
-		PreferenceScreen huaweiPreferenceScreen = (PreferenceScreen) mSettingsFragment.findPreference("huawei");
-		if (huaweiPreferenceScreen != null) {
-			Intent intent = huaweiPreferenceScreen.getIntent();
-			//remove when Api version is above M (Version 6.0) or if the intent is not callable
-			if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M || !isCallable(intent)) {
-				PreferenceCategory generalCategory = (PreferenceCategory) mSettingsFragment.findPreference("general");
-				generalCategory.removePreference(huaweiPreferenceScreen);
-				if (generalCategory.getPreferenceCount() == 0) {
-					if (mainPreferenceScreen != null) {
-						mainPreferenceScreen.removePreference(generalCategory);
-					}
-				}
-			}
-		}
-
-		ListPreference automaticMessageDeletionList = (ListPreference) mSettingsFragment.findPreference(AUTOMATIC_MESSAGE_DELETION);
-		if (automaticMessageDeletionList != null) {
-			final int[] choices = getResources().getIntArray(R.array.automatic_message_deletion_values);
-			CharSequence[] entries = new CharSequence[choices.length];
-			CharSequence[] entryValues = new CharSequence[choices.length];
-			for (int i = 0; i < choices.length; ++i) {
-				entryValues[i] = String.valueOf(choices[i]);
-				if (choices[i] == 0) {
-					entries[i] = getString(R.string.never);
-				} else {
-					entries[i] = TimeFrameUtils.resolve(this, 1000L * choices[i]);
-				}
-			}
-			automaticMessageDeletionList.setEntries(entries);
-			automaticMessageDeletionList.setEntryValues(entryValues);
-		}
-
-
-		boolean removeLocation = new Intent("eu.siacs.conversations.location.request").resolveActivity(getPackageManager()) == null;
-		boolean removeVoice = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION).resolveActivity(getPackageManager()) == null;
-
-		ListPreference quickAction = (ListPreference) mSettingsFragment.findPreference("quick_action");
-		if (quickAction != null && (removeLocation || removeVoice)) {
-			ArrayList<CharSequence> entries = new ArrayList<>(Arrays.asList(quickAction.getEntries()));
-			ArrayList<CharSequence> entryValues = new ArrayList<>(Arrays.asList(quickAction.getEntryValues()));
-			int index = entryValues.indexOf("location");
-			if (index > 0 && removeLocation) {
-				entries.remove(index);
-				entryValues.remove(index);
-			}
-			index = entryValues.indexOf("voice");
-			if (index > 0 && removeVoice) {
-				entries.remove(index);
-				entryValues.remove(index);
-			}
-			quickAction.setEntries(entries.toArray(new CharSequence[entries.size()]));
-			quickAction.setEntryValues(entryValues.toArray(new CharSequence[entryValues.size()]));
-		}
-
-		final Preference removeCertsPreference = mSettingsFragment.findPreference("remove_trusted_certificates");
-		if (removeCertsPreference != null) {
-			removeCertsPreference.setOnPreferenceClickListener(preference -> {
-				final MemorizingTrustManager mtm = xmppConnectionService.getMemorizingTrustManager();
-				final ArrayList<String> aliases = Collections.list(mtm.getCertificates());
-				if (aliases.size() == 0) {
-					displayToast(getString(R.string.toast_no_trusted_certs));
-					return true;
-				}
-				final ArrayList<Integer> selectedItems = new ArrayList<>();
-				final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(SettingsActivity.this);
-				dialogBuilder.setTitle(getResources().getString(R.string.dialog_manage_certs_title));
-				dialogBuilder.setMultiChoiceItems(aliases.toArray(new CharSequence[aliases.size()]), null,
-						(dialog, indexSelected, isChecked) -> {
-							if (isChecked) {
-								selectedItems.add(indexSelected);
-							} else if (selectedItems.contains(indexSelected)) {
-								selectedItems.remove(Integer.valueOf(indexSelected));
-							}
-                            ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(selectedItems.size() > 0);
-						});
-
-				dialogBuilder.setPositiveButton(
-						getResources().getString(R.string.dialog_manage_certs_positivebutton), (dialog, which) -> {
-							int count = selectedItems.size();
-							if (count > 0) {
-								for (int i = 0; i < count; i++) {
-									try {
-										Integer item = Integer.valueOf(selectedItems.get(i).toString());
-										String alias = aliases.get(item);
-										mtm.deleteCertificate(alias);
-									} catch (KeyStoreException e) {
-										e.printStackTrace();
-										displayToast("Error: " + e.getLocalizedMessage());
-									}
-								}
-								if (xmppConnectionServiceBound) {
-									reconnectAccounts();
-								}
-								displayToast(getResources().getQuantityString(R.plurals.toast_delete_certificates, count, count));
-							}
-						});
-				dialogBuilder.setNegativeButton(getResources().getString(R.string.dialog_manage_certs_negativebutton), null);
-				AlertDialog removeCertsDialog = dialogBuilder.create();
-				removeCertsDialog.show();
-				removeCertsDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
-				return true;
-			});
-		}
-
-		final Preference createBackupPreference = mSettingsFragment.findPreference("create_backup");
-		if (createBackupPreference != null) {
-			createBackupPreference.setSummary(getString(R.string.pref_create_backup_summary, FileBackend.getBackupDirectory(this).getAbsolutePath()));
-			createBackupPreference.setOnPreferenceClickListener(preference -> {
-				if (hasStoragePermission(REQUEST_CREATE_BACKUP)) {
-					createBackup();
-				}
-				return true;
-			});
-		}
-
-		if (Config.ONLY_INTERNAL_STORAGE) {
-			final Preference cleanCachePreference = mSettingsFragment.findPreference("clean_cache");
-			if (cleanCachePreference != null) {
-				cleanCachePreference.setOnPreferenceClickListener(preference -> cleanCache());
-			}
-
-			final Preference cleanPrivateStoragePreference = mSettingsFragment.findPreference("clean_private_storage");
-			if (cleanPrivateStoragePreference != null) {
-				cleanPrivateStoragePreference.setOnPreferenceClickListener(preference -> cleanPrivateStorage());
-			}
-		}
-
-		final Preference deleteOmemoPreference = mSettingsFragment.findPreference("delete_omemo_identities");
-		if (deleteOmemoPreference != null) {
-			deleteOmemoPreference.setOnPreferenceClickListener(preference -> deleteOmemoIdentities());
-		}
-	}
-
-	private void changeOmemoSettingSummary() {
-		ListPreference omemoPreference = (ListPreference) mSettingsFragment.findPreference(OMEMO_SETTING);
-		if (omemoPreference != null) {
-			String value = omemoPreference.getValue();
-			switch (value) {
-				case "always":
-					omemoPreference.setSummary(R.string.pref_omemo_setting_summary_always);
-					break;
-				case "default_on":
-					omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_on);
-					break;
-				case "default_off":
-					omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_off);
-					break;
-			}
-		} else {
-			Log.d(Config.LOGTAG,"unable to find preference named "+OMEMO_SETTING);
-		}
-	}
-
-	private boolean isCallable(final Intent i) {
-		return i != null && getPackageManager().queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY).size() > 0;
-	}
-
-
-	private boolean cleanCache() {
-		Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
-		intent.setData(Uri.parse("package:" + getPackageName()));
-		startActivity(intent);
-		return true;
-	}
-
-	private boolean cleanPrivateStorage() {
-		for(String type : Arrays.asList("Images", "Videos", "Files", "Recordings")) {
-		        cleanPrivateFiles(type);
-	    }
-		return true;
-	}
-
-	private void cleanPrivateFiles(final String type) {
-		try {
-			File dir = new File(getFilesDir().getAbsolutePath(), "/" + type + "/");
-			File[] array = dir.listFiles();
-			if (array != null) {
-				for (int b = 0; b < array.length; b++) {
-					String name = array[b].getName().toLowerCase();
-					if (name.equals(".nomedia")) {
-						continue;
-					}
-					if (array[b].isFile()) {
-						array[b].delete();
-					}
-				}
-			}
-		} catch (Throwable e) {
-			Log.e("CleanCache", e.toString());
-		}
-	}
-
-	private boolean deleteOmemoIdentities() {
-		AlertDialog.Builder builder = new AlertDialog.Builder(this);
-		builder.setTitle(R.string.pref_delete_omemo_identities);
-		final List<CharSequence> accounts = new ArrayList<>();
-		for (Account account : xmppConnectionService.getAccounts()) {
-			if (account.isEnabled()) {
-				accounts.add(account.getJid().asBareJid().toString());
-			}
-		}
-		final boolean[] checkedItems = new boolean[accounts.size()];
-		builder.setMultiChoiceItems(accounts.toArray(new CharSequence[accounts.size()]), checkedItems, (dialog, which, isChecked) -> {
-			checkedItems[which] = isChecked;
-			final AlertDialog alertDialog = (AlertDialog) dialog;
-			for (boolean item : checkedItems) {
-				if (item) {
-					alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true);
-					return;
-				}
-			}
-			alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
-		});
-		builder.setNegativeButton(R.string.cancel, null);
-		builder.setPositiveButton(R.string.delete_selected_keys, (dialog, which) -> {
-			for (int i = 0; i < checkedItems.length; ++i) {
-				if (checkedItems[i]) {
-					try {
-						Jid jid = Jid.of(accounts.get(i).toString());
-						Account account = xmppConnectionService.findAccountByJid(jid);
-						if (account != null) {
-							account.getAxolotlService().regenerateKeys(true);
-						}
-					} catch (IllegalArgumentException e) {
-						//
-					}
-
-				}
-			}
-		});
-		AlertDialog dialog = builder.create();
-		dialog.show();
-		dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
-		return true;
-	}
-
-	@Override
-	public void onStop() {
-		super.onStop();
-		PreferenceManager.getDefaultSharedPreferences(this)
-				.unregisterOnSharedPreferenceChangeListener(this);
-	}
-
-	@Override
-	public void onSharedPreferenceChanged(SharedPreferences preferences, String name) {
-		final List<String> resendPresence = Arrays.asList(
-				"confirm_messages",
-				DND_ON_SILENT_MODE,
-				AWAY_WHEN_SCREEN_IS_OFF,
-				"allow_message_correction",
-				TREAT_VIBRATE_AS_SILENT,
-				MANUALLY_CHANGE_PRESENCE,
-				BROADCAST_LAST_ACTIVITY);
-		if (name.equals(OMEMO_SETTING)) {
-			OmemoSetting.load(this, preferences);
-			changeOmemoSettingSummary();
-		} else if (name.equals(KEEP_FOREGROUND_SERVICE)) {
-			xmppConnectionService.toggleForegroundService();
-		} else if (resendPresence.contains(name)) {
-			if (xmppConnectionServiceBound) {
-				if (name.equals(AWAY_WHEN_SCREEN_IS_OFF) || name.equals(MANUALLY_CHANGE_PRESENCE)) {
-					xmppConnectionService.toggleScreenEventReceiver();
-				}
-				xmppConnectionService.refreshAllPresences();
-			}
-		} else if (name.equals("dont_trust_system_cas")) {
-			xmppConnectionService.updateMemorizingTrustmanager();
-			reconnectAccounts();
-		} else if (name.equals("use_tor")) {
-			reconnectAccounts();
-			xmppConnectionService.reinitializeMuclumbusService();
-		} else if (name.equals(AUTOMATIC_MESSAGE_DELETION)) {
-			xmppConnectionService.expireOldMessages(true);
-		} else if (name.equals(THEME)) {
-			final int theme = findTheme();
-			if (this.mTheme != theme) {
-				xmppConnectionService.setTheme(theme);
-				recreate();
-			}
-		} else if(name.equals(PREVENT_SCREENSHOTS)){
-			SettingsUtils.applyScreenshotPreventionSetting(this);
-		}
-	}
-
-	@Override
-	public void onResume(){
-		super.onResume();
-		SettingsUtils.applyScreenshotPreventionSetting(this);
-	}
-
-	@Override
-	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
-		if (grantResults.length > 0)
-			if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-				if (requestCode == REQUEST_CREATE_BACKUP) {
-					createBackup();
-				}
-			} else {
-				Toast.makeText(this, getString(R.string.no_storage_permission, getString(R.string.app_name)), Toast.LENGTH_SHORT).show();
-			}
-	}
-
-	private void createBackup() {
-		ContextCompat.startForegroundService(this, new Intent(this, ExportBackupService.class));
-		final AlertDialog.Builder builder = new AlertDialog.Builder(this);
-		builder.setMessage(R.string.backup_started_message);
-		builder.setPositiveButton(R.string.ok, null);
-		builder.create().show();
-	}
-
-	private void displayToast(final String msg) {
-		runOnUiThread(() -> Toast.makeText(SettingsActivity.this, msg, Toast.LENGTH_LONG).show());
-	}
-
-	private void reconnectAccounts() {
-		for (Account account : xmppConnectionService.getAccounts()) {
-			if (account.isEnabled()) {
-				xmppConnectionService.reconnectAccountInBackground(account);
-			}
-		}
-	}
-
-	public void refreshUiReal() {
-		//nothing to do. This Activity doesn't implement any listeners
-	}
-
+public class SettingsActivity extends XmppActivity implements OnSharedPreferenceChangeListener {
+
+    public static final String KEEP_FOREGROUND_SERVICE = "enable_foreground_service";
+    public static final String AWAY_WHEN_SCREEN_IS_OFF = "away_when_screen_off";
+    public static final String TREAT_VIBRATE_AS_SILENT = "treat_vibrate_as_silent";
+    public static final String DND_ON_SILENT_MODE = "dnd_on_silent_mode";
+    public static final String MANUALLY_CHANGE_PRESENCE = "manually_change_presence";
+    public static final String BLIND_TRUST_BEFORE_VERIFICATION = "btbv";
+    public static final String AUTOMATIC_MESSAGE_DELETION = "automatic_message_deletion";
+    public static final String BROADCAST_LAST_ACTIVITY = "last_activity";
+    public static final String THEME = "theme";
+    public static final String SHOW_DYNAMIC_TAGS = "show_dynamic_tags";
+    public static final String OMEMO_SETTING = "omemo";
+    public static final String PREVENT_SCREENSHOTS = "prevent_screenshots";
+
+    public static final int REQUEST_CREATE_BACKUP = 0xbf8701;
+
+    private SettingsFragment mSettingsFragment;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_settings);
+        FragmentManager fm = getFragmentManager();
+        mSettingsFragment = (SettingsFragment) fm.findFragmentById(R.id.settings_content);
+        if (mSettingsFragment == null
+                || !mSettingsFragment.getClass().equals(SettingsFragment.class)) {
+            mSettingsFragment = new SettingsFragment();
+            fm.beginTransaction().replace(R.id.settings_content, mSettingsFragment).commit();
+        }
+        mSettingsFragment.setActivityIntent(getIntent());
+        this.mTheme = findTheme();
+        setTheme(this.mTheme);
+        getWindow()
+                .getDecorView()
+                .setBackgroundColor(
+                        StyledAttributes.getColor(this, R.attr.color_background_primary));
+        setSupportActionBar(findViewById(R.id.toolbar));
+        configureActionBar(getSupportActionBar());
+    }
+
+    @Override
+    void onBackendConnected() {}
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        PreferenceManager.getDefaultSharedPreferences(this)
+                .registerOnSharedPreferenceChangeListener(this);
+
+        changeOmemoSettingSummary();
+
+        if (QuickConversationsService.isQuicksy()
+                || Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+            final PreferenceCategory groupChats =
+                    (PreferenceCategory) mSettingsFragment.findPreference("group_chats");
+            final Preference channelDiscoveryMethod =
+                    mSettingsFragment.findPreference("channel_discovery_method");
+            if (groupChats != null && channelDiscoveryMethod != null) {
+                groupChats.removePreference(channelDiscoveryMethod);
+            }
+        }
+
+        if (QuickConversationsService.isQuicksy()) {
+            final PreferenceCategory connectionOptions =
+                    (PreferenceCategory) mSettingsFragment.findPreference("connection_options");
+            PreferenceScreen expert = (PreferenceScreen) mSettingsFragment.findPreference("expert");
+            if (connectionOptions != null) {
+                expert.removePreference(connectionOptions);
+            }
+        }
+
+        PreferenceScreen mainPreferenceScreen =
+                (PreferenceScreen) mSettingsFragment.findPreference("main_screen");
+
+        PreferenceCategory attachmentsCategory =
+                (PreferenceCategory) mSettingsFragment.findPreference("attachments");
+        CheckBoxPreference locationPlugin =
+                (CheckBoxPreference) mSettingsFragment.findPreference("use_share_location_plugin");
+        if (attachmentsCategory != null && locationPlugin != null) {
+            if (!GeoHelper.isLocationPluginInstalled(this)) {
+                attachmentsCategory.removePreference(locationPlugin);
+            }
+        }
+
+        // this feature is only available on Huawei Android 6.
+        PreferenceScreen huaweiPreferenceScreen =
+                (PreferenceScreen) mSettingsFragment.findPreference("huawei");
+        if (huaweiPreferenceScreen != null) {
+            Intent intent = huaweiPreferenceScreen.getIntent();
+            // remove when Api version is above M (Version 6.0) or if the intent is not callable
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M || !isCallable(intent)) {
+                PreferenceCategory generalCategory =
+                        (PreferenceCategory) mSettingsFragment.findPreference("general");
+                generalCategory.removePreference(huaweiPreferenceScreen);
+                if (generalCategory.getPreferenceCount() == 0) {
+                    if (mainPreferenceScreen != null) {
+                        mainPreferenceScreen.removePreference(generalCategory);
+                    }
+                }
+            }
+        }
+
+        ListPreference automaticMessageDeletionList =
+                (ListPreference) mSettingsFragment.findPreference(AUTOMATIC_MESSAGE_DELETION);
+        if (automaticMessageDeletionList != null) {
+            final int[] choices =
+                    getResources().getIntArray(R.array.automatic_message_deletion_values);
+            CharSequence[] entries = new CharSequence[choices.length];
+            CharSequence[] entryValues = new CharSequence[choices.length];
+            for (int i = 0; i < choices.length; ++i) {
+                entryValues[i] = String.valueOf(choices[i]);
+                if (choices[i] == 0) {
+                    entries[i] = getString(R.string.never);
+                } else {
+                    entries[i] = TimeFrameUtils.resolve(this, 1000L * choices[i]);
+                }
+            }
+            automaticMessageDeletionList.setEntries(entries);
+            automaticMessageDeletionList.setEntryValues(entryValues);
+        }
+
+        boolean removeLocation =
+                new Intent("eu.siacs.conversations.location.request")
+                                .resolveActivity(getPackageManager())
+                        == null;
+        boolean removeVoice =
+                new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)
+                                .resolveActivity(getPackageManager())
+                        == null;
+
+        ListPreference quickAction =
+                (ListPreference) mSettingsFragment.findPreference("quick_action");
+        if (quickAction != null && (removeLocation || removeVoice)) {
+            ArrayList<CharSequence> entries =
+                    new ArrayList<>(Arrays.asList(quickAction.getEntries()));
+            ArrayList<CharSequence> entryValues =
+                    new ArrayList<>(Arrays.asList(quickAction.getEntryValues()));
+            int index = entryValues.indexOf("location");
+            if (index > 0 && removeLocation) {
+                entries.remove(index);
+                entryValues.remove(index);
+            }
+            index = entryValues.indexOf("voice");
+            if (index > 0 && removeVoice) {
+                entries.remove(index);
+                entryValues.remove(index);
+            }
+            quickAction.setEntries(entries.toArray(new CharSequence[entries.size()]));
+            quickAction.setEntryValues(entryValues.toArray(new CharSequence[entryValues.size()]));
+        }
+
+        final Preference removeCertsPreference =
+                mSettingsFragment.findPreference("remove_trusted_certificates");
+        if (removeCertsPreference != null) {
+            removeCertsPreference.setOnPreferenceClickListener(
+                    preference -> {
+                        final MemorizingTrustManager mtm =
+                                xmppConnectionService.getMemorizingTrustManager();
+                        final ArrayList<String> aliases = Collections.list(mtm.getCertificates());
+                        if (aliases.size() == 0) {
+                            displayToast(getString(R.string.toast_no_trusted_certs));
+                            return true;
+                        }
+                        final ArrayList<Integer> selectedItems = new ArrayList<>();
+                        final AlertDialog.Builder dialogBuilder =
+                                new AlertDialog.Builder(SettingsActivity.this);
+                        dialogBuilder.setTitle(
+                                getResources().getString(R.string.dialog_manage_certs_title));
+                        dialogBuilder.setMultiChoiceItems(
+                                aliases.toArray(new CharSequence[aliases.size()]),
+                                null,
+                                (dialog, indexSelected, isChecked) -> {
+                                    if (isChecked) {
+                                        selectedItems.add(indexSelected);
+                                    } else if (selectedItems.contains(indexSelected)) {
+                                        selectedItems.remove(Integer.valueOf(indexSelected));
+                                    }
+                                    ((AlertDialog) dialog)
+                                            .getButton(DialogInterface.BUTTON_POSITIVE)
+                                            .setEnabled(selectedItems.size() > 0);
+                                });
+
+                        dialogBuilder.setPositiveButton(
+                                getResources()
+                                        .getString(R.string.dialog_manage_certs_positivebutton),
+                                (dialog, which) -> {
+                                    int count = selectedItems.size();
+                                    if (count > 0) {
+                                        for (int i = 0; i < count; i++) {
+                                            try {
+                                                Integer item =
+                                                        Integer.valueOf(
+                                                                selectedItems.get(i).toString());
+                                                String alias = aliases.get(item);
+                                                mtm.deleteCertificate(alias);
+                                            } catch (KeyStoreException e) {
+                                                e.printStackTrace();
+                                                displayToast("Error: " + e.getLocalizedMessage());
+                                            }
+                                        }
+                                        if (xmppConnectionServiceBound) {
+                                            reconnectAccounts();
+                                        }
+                                        displayToast(
+                                                getResources()
+                                                        .getQuantityString(
+                                                                R.plurals.toast_delete_certificates,
+                                                                count,
+                                                                count));
+                                    }
+                                });
+                        dialogBuilder.setNegativeButton(
+                                getResources()
+                                        .getString(R.string.dialog_manage_certs_negativebutton),
+                                null);
+                        AlertDialog removeCertsDialog = dialogBuilder.create();
+                        removeCertsDialog.show();
+                        removeCertsDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
+                        return true;
+                    });
+        }
+
+        final Preference createBackupPreference = mSettingsFragment.findPreference("create_backup");
+        if (createBackupPreference != null) {
+            createBackupPreference.setSummary(
+                    getString(
+                            R.string.pref_create_backup_summary,
+                            FileBackend.getBackupDirectory(this).getAbsolutePath()));
+            createBackupPreference.setOnPreferenceClickListener(
+                    preference -> {
+                        if (hasStoragePermission(REQUEST_CREATE_BACKUP)) {
+                            createBackup();
+                        }
+                        return true;
+                    });
+        }
+
+        if (Config.ONLY_INTERNAL_STORAGE) {
+            final Preference cleanCachePreference = mSettingsFragment.findPreference("clean_cache");
+            if (cleanCachePreference != null) {
+                cleanCachePreference.setOnPreferenceClickListener(preference -> cleanCache());
+            }
+
+            final Preference cleanPrivateStoragePreference =
+                    mSettingsFragment.findPreference("clean_private_storage");
+            if (cleanPrivateStoragePreference != null) {
+                cleanPrivateStoragePreference.setOnPreferenceClickListener(
+                        preference -> cleanPrivateStorage());
+            }
+        }
+
+        final Preference deleteOmemoPreference =
+                mSettingsFragment.findPreference("delete_omemo_identities");
+        if (deleteOmemoPreference != null) {
+            deleteOmemoPreference.setOnPreferenceClickListener(
+                    preference -> deleteOmemoIdentities());
+        }
+        if (Config.omemoOnly()) {
+            final PreferenceCategory privacyCategory =
+                    (PreferenceCategory) mSettingsFragment.findPreference("privacy");
+            final Preference omemoPreference =mSettingsFragment.findPreference(OMEMO_SETTING);
+            if (omemoPreference != null) {
+                privacyCategory.removePreference(omemoPreference);
+            }
+        }
+    }
+
+    private void changeOmemoSettingSummary() {
+        final ListPreference omemoPreference =
+                (ListPreference) mSettingsFragment.findPreference(OMEMO_SETTING);
+        if (omemoPreference == null) {
+            return;
+        }
+        final String value = omemoPreference.getValue();
+        switch (value) {
+            case "always":
+                omemoPreference.setSummary(R.string.pref_omemo_setting_summary_always);
+                break;
+            case "default_on":
+                omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_on);
+                break;
+            case "default_off":
+                omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_off);
+                break;
+        }
+    }
+
+    private boolean isCallable(final Intent i) {
+        return i != null
+                && getPackageManager()
+                                .queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY)
+                                .size()
+                        > 0;
+    }
+
+    private boolean cleanCache() {
+        Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+        intent.setData(Uri.parse("package:" + getPackageName()));
+        startActivity(intent);
+        return true;
+    }
+
+    private boolean cleanPrivateStorage() {
+        for (String type : Arrays.asList("Images", "Videos", "Files", "Recordings")) {
+            cleanPrivateFiles(type);
+        }
+        return true;
+    }
+
+    private void cleanPrivateFiles(final String type) {
+        try {
+            File dir = new File(getFilesDir().getAbsolutePath(), "/" + type + "/");
+            File[] array = dir.listFiles();
+            if (array != null) {
+                for (int b = 0; b < array.length; b++) {
+                    String name = array[b].getName().toLowerCase();
+                    if (name.equals(".nomedia")) {
+                        continue;
+                    }
+                    if (array[b].isFile()) {
+                        array[b].delete();
+                    }
+                }
+            }
+        } catch (Throwable e) {
+            Log.e("CleanCache", e.toString());
+        }
+    }
+
+    private boolean deleteOmemoIdentities() {
+        AlertDialog.Builder builder = new AlertDialog.Builder(this);
+        builder.setTitle(R.string.pref_delete_omemo_identities);
+        final List<CharSequence> accounts = new ArrayList<>();
+        for (Account account : xmppConnectionService.getAccounts()) {
+            if (account.isEnabled()) {
+                accounts.add(account.getJid().asBareJid().toString());
+            }
+        }
+        final boolean[] checkedItems = new boolean[accounts.size()];
+        builder.setMultiChoiceItems(
+                accounts.toArray(new CharSequence[accounts.size()]),
+                checkedItems,
+                (dialog, which, isChecked) -> {
+                    checkedItems[which] = isChecked;
+                    final AlertDialog alertDialog = (AlertDialog) dialog;
+                    for (boolean item : checkedItems) {
+                        if (item) {
+                            alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true);
+                            return;
+                        }
+                    }
+                    alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
+                });
+        builder.setNegativeButton(R.string.cancel, null);
+        builder.setPositiveButton(
+                R.string.delete_selected_keys,
+                (dialog, which) -> {
+                    for (int i = 0; i < checkedItems.length; ++i) {
+                        if (checkedItems[i]) {
+                            try {
+                                Jid jid = Jid.of(accounts.get(i).toString());
+                                Account account = xmppConnectionService.findAccountByJid(jid);
+                                if (account != null) {
+                                    account.getAxolotlService().regenerateKeys(true);
+                                }
+                            } catch (IllegalArgumentException e) {
+                                //
+                            }
+                        }
+                    }
+                });
+        AlertDialog dialog = builder.create();
+        dialog.show();
+        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
+        return true;
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+        PreferenceManager.getDefaultSharedPreferences(this)
+                .unregisterOnSharedPreferenceChangeListener(this);
+    }
+
+    @Override
+    public void onSharedPreferenceChanged(SharedPreferences preferences, String name) {
+        final List<String> resendPresence =
+                Arrays.asList(
+                        "confirm_messages",
+                        DND_ON_SILENT_MODE,
+                        AWAY_WHEN_SCREEN_IS_OFF,
+                        "allow_message_correction",
+                        TREAT_VIBRATE_AS_SILENT,
+                        MANUALLY_CHANGE_PRESENCE,
+                        BROADCAST_LAST_ACTIVITY);
+        if (name.equals(OMEMO_SETTING)) {
+            OmemoSetting.load(this, preferences);
+            changeOmemoSettingSummary();
+        } else if (name.equals(KEEP_FOREGROUND_SERVICE)) {
+            xmppConnectionService.toggleForegroundService();
+        } else if (resendPresence.contains(name)) {
+            if (xmppConnectionServiceBound) {
+                if (name.equals(AWAY_WHEN_SCREEN_IS_OFF) || name.equals(MANUALLY_CHANGE_PRESENCE)) {
+                    xmppConnectionService.toggleScreenEventReceiver();
+                }
+                xmppConnectionService.refreshAllPresences();
+            }
+        } else if (name.equals("dont_trust_system_cas")) {
+            xmppConnectionService.updateMemorizingTrustmanager();
+            reconnectAccounts();
+        } else if (name.equals("use_tor")) {
+            if (preferences.getBoolean(name, false)) {
+                displayToast(getString(R.string.audio_video_disabled_tor));
+            }
+            reconnectAccounts();
+            xmppConnectionService.reinitializeMuclumbusService();
+        } else if (name.equals(AUTOMATIC_MESSAGE_DELETION)) {
+            xmppConnectionService.expireOldMessages(true);
+        } else if (name.equals(THEME)) {
+            final int theme = findTheme();
+            if (this.mTheme != theme) {
+                xmppConnectionService.setTheme(theme);
+                recreate();
+            }
+        } else if (name.equals(PREVENT_SCREENSHOTS)) {
+            SettingsUtils.applyScreenshotPreventionSetting(this);
+        }
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        SettingsUtils.applyScreenshotPreventionSetting(this);
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        if (grantResults.length > 0)
+            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                if (requestCode == REQUEST_CREATE_BACKUP) {
+                    createBackup();
+                }
+            } else {
+                Toast.makeText(
+                                this,
+                                getString(
+                                        R.string.no_storage_permission,
+                                        getString(R.string.app_name)),
+                                Toast.LENGTH_SHORT)
+                        .show();
+            }
+    }
+
+    private void createBackup() {
+        ContextCompat.startForegroundService(this, new Intent(this, ExportBackupService.class));
+        final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+        builder.setMessage(R.string.backup_started_message);
+        builder.setPositiveButton(R.string.ok, null);
+        builder.create().show();
+    }
+
+    private void displayToast(final String msg) {
+        runOnUiThread(() -> Toast.makeText(SettingsActivity.this, msg, Toast.LENGTH_LONG).show());
+    }
+
+    private void reconnectAccounts() {
+        for (Account account : xmppConnectionService.getAccounts()) {
+            if (account.isEnabled()) {
+                xmppConnectionService.reconnectAccountInBackground(account);
+            }
+        }
+    }
+
+    public void refreshUiReal() {
+        // nothing to do. This Activity doesn't implement any listeners
+    }
 }
  
  
  
    
    @@ -3,8 +3,8 @@ package eu.siacs.conversations.ui;
 import android.content.ActivityNotFoundException;
 import android.content.ClipData;
 import android.content.ClipboardManager;
-import android.content.ComponentName;
 import android.content.Intent;
+import android.content.pm.ActivityInfo;
 import android.location.Location;
 import android.location.LocationListener;
 import android.net.Uri;
@@ -17,6 +17,7 @@ import android.widget.Toast;
 import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 
+import org.jetbrains.annotations.NotNull;
 import org.osmdroid.util.GeoPoint;
 
 import java.util.HashMap;
@@ -32,198 +33,214 @@ import eu.siacs.conversations.ui.widget.Marker;
 import eu.siacs.conversations.ui.widget.MyLocation;
 import eu.siacs.conversations.utils.LocationProvider;
 
-
 public class ShowLocationActivity extends LocationActivity implements LocationListener {
 
-	private GeoPoint loc = LocationProvider.FALLBACK;
-	private ActivityShowLocationBinding binding;
-
-
-	private Uri createGeoUri() {
-		return Uri.parse("geo:" + this.loc.getLatitude() + "," + this.loc.getLongitude());
-	}
-
-	@Override
-	protected void onCreate(final Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		this.binding = DataBindingUtil.setContentView(this,R.layout.activity_show_location);
-		setSupportActionBar(binding.toolbar);
-
-		configureActionBar(getSupportActionBar());
-		setupMapView(this.binding.map, this.loc);
-
-		this.binding.fab.setOnClickListener(view -> startNavigation());
-
-		final Intent intent = getIntent();
-		if (intent != null) {
-			final String action = intent.getAction();
-			if (action == null) {
-				return;
-			}
-			switch (action) {
-				case "eu.siacs.conversations.location.show":
-					if (intent.hasExtra("longitude") && intent.hasExtra("latitude")) {
-						final double longitude = intent.getDoubleExtra("longitude", 0);
-						final double latitude = intent.getDoubleExtra("latitude", 0);
-						this.loc = new GeoPoint(latitude, longitude);
-					}
-					break;
-				case Intent.ACTION_VIEW:
-					final Uri geoUri = intent.getData();
-
-					// Attempt to set zoom level if the geo URI specifies it
-					if (geoUri != null) {
-						final HashMap<String, String> query = UriHelper.parseQueryString(geoUri.getQuery());
-
-						// Check for zoom level.
-						final String z = query.get("z");
-						if (z != null) {
-							try {
-								mapController.setZoom(Double.valueOf(z));
-							} catch (final Exception ignored) {
-							}
-						}
-
-						// Check for the actual geo query.
-						boolean posInQuery = false;
-						final String q = query.get("q");
-						if (q != null) {
-							final Pattern latlng = Pattern.compile("/^([-+]?[0-9]+(\\.[0-9]+)?),([-+]?[0-9]+(\\.[0-9]+)?)(\\(.*\\))?/");
-							final Matcher m = latlng.matcher(q);
-							if (m.matches()) {
-								try {
-									this.loc = new GeoPoint(Double.valueOf(m.group(1)), Double.valueOf(m.group(3)));
-									posInQuery = true;
-								} catch (final Exception ignored) {
-								}
-							}
-						}
-
-						final String schemeSpecificPart = geoUri.getSchemeSpecificPart();
-						if (schemeSpecificPart != null && !schemeSpecificPart.isEmpty()) {
-							try {
-								final GeoPoint latlong = LocationHelper.parseLatLong(schemeSpecificPart);
-								if (latlong != null && !posInQuery) {
-									this.loc = latlong;
-								}
-							} catch (final NumberFormatException ignored) {
-							}
-						}
-					}
-
-					break;
-			}
-			updateLocationMarkers();
-		}
-	}
-
-	@Override
-	protected void gotoLoc(final boolean setZoomLevel) {
-		if (this.loc != null && mapController != null) {
-			if (setZoomLevel) {
-				mapController.setZoom(Config.Map.FINAL_ZOOM_LEVEL);
-			}
-			mapController.animateTo(new GeoPoint(this.loc));
-		}
-	}
-
-	@Override
-	public void onRequestPermissionsResult(final int requestCode,
-										   @NonNull final String[] permissions,
-										   @NonNull final int[] grantResults) {
-		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
-		updateUi();
-	}
-
-	@Override
-	protected void setMyLoc(final Location location) {
-		this.myLoc = location;
-	}
-
-	@Override
-	public boolean onCreateOptionsMenu(final Menu menu) {
-		// Inflate the menu; this adds items to the action bar if it is present.
-		getMenuInflater().inflate(R.menu.menu_show_location, menu);
-		updateUi();
-		return true;
-	}
-
-	@Override
-	protected void updateLocationMarkers() {
-		super.updateLocationMarkers();
-		if (this.myLoc != null) {
-			this.binding.map.getOverlays().add(new MyLocation(this, null, this.myLoc));
-		}
-		this.binding.map.getOverlays().add(new Marker(this.marker_icon, this.loc));
-	}
-
-	@Override
-	protected void onPause() {
-		super.onPause();
-	}
-
-	@Override
-	public boolean onOptionsItemSelected(final MenuItem item) {
-		switch (item.getItemId()) {
-			case R.id.action_copy_location:
-				final ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
-				if (clipboard != null) {
-					final ClipData clip = ClipData.newPlainText("location", createGeoUri().toString());
-					clipboard.setPrimaryClip(clip);
-					Toast.makeText(this,R.string.url_copied_to_clipboard,Toast.LENGTH_SHORT).show();
-				}
-				return true;
-			case R.id.action_share_location:
-				final Intent shareIntent = new Intent();
-				shareIntent.setAction(Intent.ACTION_SEND);
-				shareIntent.putExtra(Intent.EXTRA_TEXT, createGeoUri().toString());
-				shareIntent.setType("text/plain");
-				try {
-					startActivity(Intent.createChooser(shareIntent, getText(R.string.share_with)));
-				} catch (final ActivityNotFoundException e) {
-					//This should happen only on faulty androids because normally chooser is always available
-					Toast.makeText(this, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT).show();
-				}
-				return true;
-		}
-		return super.onOptionsItemSelected(item);
-	}
-
-	private void startNavigation() {
-		startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(
-				"google.navigation:q=" +
-						this.loc.getLatitude() + "," + this.loc.getLongitude()
-		)));
-	}
-
-	@Override
-	protected void updateUi() {
-		final Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=0,0"));
-		final ComponentName component = i.resolveActivity(getPackageManager());
-		this.binding.fab.setVisibility(component == null ? View.GONE : View.VISIBLE);
-	}
-
-	@Override
-	public void onLocationChanged(final Location location) {
-		if (LocationHelper.isBetterLocation(location, this.myLoc)) {
-			this.myLoc = location;
-			updateLocationMarkers();
-		}
-	}
-
-	@Override
-	public void onStatusChanged(final String provider, final int status, final Bundle extras) {
-
-	}
-
-	@Override
-	public void onProviderEnabled(final String provider) {
-
-	}
-
-	@Override
-	public void onProviderDisabled(final String provider) {
-
-	}
+    private GeoPoint loc = LocationProvider.FALLBACK;
+    private ActivityShowLocationBinding binding;
+
+    private Uri createGeoUri() {
+        return Uri.parse("geo:" + this.loc.getLatitude() + "," + this.loc.getLongitude());
+    }
+
+    @Override
+    protected void onCreate(final Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_show_location);
+        setSupportActionBar(binding.toolbar);
+
+        configureActionBar(getSupportActionBar());
+        setupMapView(this.binding.map, this.loc);
+
+        this.binding.fab.setOnClickListener(view -> startNavigation());
+
+        final Intent intent = getIntent();
+        if (intent != null) {
+            final String action = intent.getAction();
+            if (action == null) {
+                return;
+            }
+            switch (action) {
+                case "eu.siacs.conversations.location.show":
+                    if (intent.hasExtra("longitude") && intent.hasExtra("latitude")) {
+                        final double longitude = intent.getDoubleExtra("longitude", 0);
+                        final double latitude = intent.getDoubleExtra("latitude", 0);
+                        this.loc = new GeoPoint(latitude, longitude);
+                    }
+                    break;
+                case Intent.ACTION_VIEW:
+                    final Uri geoUri = intent.getData();
+
+                    // Attempt to set zoom level if the geo URI specifies it
+                    if (geoUri != null) {
+                        final HashMap<String, String> query =
+                                UriHelper.parseQueryString(geoUri.getQuery());
+
+                        // Check for zoom level.
+                        final String z = query.get("z");
+                        if (z != null) {
+                            try {
+                                mapController.setZoom(Double.valueOf(z));
+                            } catch (final Exception ignored) {
+                            }
+                        }
+
+                        // Check for the actual geo query.
+                        boolean posInQuery = false;
+                        final String q = query.get("q");
+                        if (q != null) {
+                            final Pattern latlng =
+                                    Pattern.compile(
+                                            "/^([-+]?[0-9]+(\\.[0-9]+)?),([-+]?[0-9]+(\\.[0-9]+)?)(\\(.*\\))?/");
+                            final Matcher m = latlng.matcher(q);
+                            if (m.matches()) {
+                                try {
+                                    this.loc =
+                                            new GeoPoint(
+                                                    Double.valueOf(m.group(1)),
+                                                    Double.valueOf(m.group(3)));
+                                    posInQuery = true;
+                                } catch (final Exception ignored) {
+                                }
+                            }
+                        }
+
+                        final String schemeSpecificPart = geoUri.getSchemeSpecificPart();
+                        if (schemeSpecificPart != null && !schemeSpecificPart.isEmpty()) {
+                            try {
+                                final GeoPoint latlong =
+                                        LocationHelper.parseLatLong(schemeSpecificPart);
+                                if (latlong != null && !posInQuery) {
+                                    this.loc = latlong;
+                                }
+                            } catch (final NumberFormatException ignored) {
+                            }
+                        }
+                    }
+
+                    break;
+            }
+            updateLocationMarkers();
+        }
+    }
+
+    @Override
+    protected void gotoLoc(final boolean setZoomLevel) {
+        if (this.loc != null && mapController != null) {
+            if (setZoomLevel) {
+                mapController.setZoom(Config.Map.FINAL_ZOOM_LEVEL);
+            }
+            mapController.animateTo(new GeoPoint(this.loc));
+        }
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            final int requestCode,
+            @NonNull final String[] permissions,
+            @NonNull final int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        updateUi();
+    }
+
+    @Override
+    protected void setMyLoc(final Location location) {
+        this.myLoc = location;
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(@NotNull final Menu menu) {
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.menu_show_location, menu);
+        updateUi();
+        return true;
+    }
+
+    @Override
+    protected void updateLocationMarkers() {
+        super.updateLocationMarkers();
+        if (this.myLoc != null) {
+            this.binding.map.getOverlays().add(new MyLocation(this, null, this.myLoc));
+        }
+        this.binding.map.getOverlays().add(new Marker(this.marker_icon, this.loc));
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        switch (item.getItemId()) {
+            case R.id.action_copy_location:
+                final ClipboardManager clipboard =
+                        (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
+                if (clipboard != null) {
+                    final ClipData clip =
+                            ClipData.newPlainText("location", createGeoUri().toString());
+                    clipboard.setPrimaryClip(clip);
+                    Toast.makeText(this, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT)
+                            .show();
+                }
+                return true;
+            case R.id.action_share_location:
+                final Intent shareIntent = new Intent();
+                shareIntent.setAction(Intent.ACTION_SEND);
+                shareIntent.putExtra(Intent.EXTRA_TEXT, createGeoUri().toString());
+                shareIntent.setType("text/plain");
+                try {
+                    startActivity(Intent.createChooser(shareIntent, getText(R.string.share_with)));
+                } catch (final ActivityNotFoundException e) {
+                    // This should happen only on faulty androids because normally chooser is always
+                    // available
+                    Toast.makeText(
+                                    this,
+                                    R.string.no_application_found_to_open_file,
+                                    Toast.LENGTH_SHORT)
+                            .show();
+                }
+                return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void startNavigation() {
+        final Intent intent = getStartNavigationIntent();
+        startActivity(intent);
+    }
+
+    private Intent getStartNavigationIntent() {
+        return new Intent(
+                Intent.ACTION_VIEW,
+                Uri.parse(
+                        "google.navigation:q="
+                                + this.loc.getLatitude()
+                                + ","
+                                + this.loc.getLongitude()));
+    }
+
+    @Override
+    protected void updateUi() {
+        final Intent intent = getStartNavigationIntent();
+        final ActivityInfo activityInfo = intent.resolveActivityInfo(getPackageManager(), 0);
+        this.binding.fab.setVisibility(activityInfo == null ? View.GONE : View.VISIBLE);
+    }
+
+    @Override
+    public void onLocationChanged(@NotNull final Location location) {
+        if (LocationHelper.isBetterLocation(location, this.myLoc)) {
+            this.myLoc = location;
+            updateLocationMarkers();
+        }
+    }
+
+    @Override
+    public void onStatusChanged(final String provider, final int status, final Bundle extras) {}
+
+    @Override
+    public void onProviderEnabled(@NotNull final String provider) {}
+
+    @Override
+    public void onProviderDisabled(@NotNull final String provider) {}
 }
  
  
  
    
    @@ -1049,9 +1049,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
 
     protected void filterConferences(String needle) {
         this.conferences.clear();
-        for (Account account : xmppConnectionService.getAccounts()) {
+        for (final Account account : xmppConnectionService.getAccounts()) {
             if (account.getStatus() != Account.State.DISABLED) {
-                for (Bookmark bookmark : account.getBookmarks()) {
+                for (final Bookmark bookmark : account.getBookmarks()) {
                     if (bookmark.match(this, needle)) {
                         this.conferences.add(bookmark);
                     }
@@ -1123,7 +1123,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
         if (account == null) {
             return;
         }
-        final String input = jid.getText().toString();
+        final String input = jid.getText().toString().trim();
         Jid conferenceJid;
         try {
             conferenceJid = Jid.ofEscaped(input);
  
  
  
    
    @@ -83,6 +83,7 @@ import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 import eu.siacs.conversations.ui.util.PresenceSelector;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.Compatibility;
 import eu.siacs.conversations.utils.ExceptionHelper;
 import eu.siacs.conversations.ui.util.SettingsUtils;
 import eu.siacs.conversations.utils.ThemeHelper;
@@ -451,22 +452,12 @@ public abstract class XmppActivity extends ActionBarActivity {
             final ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
             return cm != null
                     && cm.isActiveNetworkMetered()
-                    && getRestrictBackgroundStatus(cm) == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
+                    && Compatibility.getRestrictBackgroundStatus(cm) == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
         } else {
             return false;
         }
     }
 
-    @RequiresApi(api = Build.VERSION_CODES.N)
-    private static int getRestrictBackgroundStatus(@NonNull final ConnectivityManager connectivityManager) {
-        try {
-            return connectivityManager.getRestrictBackgroundStatus();
-        } catch (final Exception e) {
-            Log.d(Config.LOGTAG,"platform bug detected. Unable to get restrict background status",e);
-            return -1;
-        }
-    }
-
     private boolean usingEnterKey() {
         return getBooleanPreference("display_enter_key", R.bool.display_enter_key);
     }
  
  
  
    
    @@ -109,19 +109,19 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     private OnContactPictureClicked mOnContactPictureClickedListener;
     private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
     private boolean mUseGreenBackground = false;
-    private boolean mForceNames = false;
+    private final boolean mForceNames;
 
-    public MessageAdapter(XmppActivity activity, List<Message> messages) {
+    public MessageAdapter(final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
         super(activity, 0, messages);
         this.audioPlayer = new AudioPlayer(this);
         this.activity = activity;
         metrics = getContext().getResources().getDisplayMetrics();
         updatePreferences();
+        this.mForceNames = forceNames;
     }
 
-    public MessageAdapter(XmppActivity activity, List<Message> messages, boolean forceNames) {
-        this(activity, messages);
-        mForceNames = forceNames;
+    public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
+        this(activity, messages, false);
     }
 
     private static void resetClickListener(View... views) {
  
  
  
    
    @@ -35,7 +35,15 @@ import android.text.Editable;
 import android.text.style.URLSpan;
 import android.text.util.Linkify;
 
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
 import java.util.Locale;
+import java.util.Objects;
 
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.ListItem;
@@ -144,4 +152,33 @@ public class MyLinkify {
             }
         }
     }
+
+    public static List<String> extractLinks(final Editable body) {
+        MyLinkify.addLinks(body, false);
+        final Collection<URLSpan> spans =
+                Arrays.asList(body.getSpans(0, body.length() - 1, URLSpan.class));
+        final Collection<UrlWrapper> urlWrappers =
+                Collections2.filter(
+                        Collections2.transform(
+                                spans,
+                                s ->
+                                        s == null
+                                                ? null
+                                                : new UrlWrapper(body.getSpanStart(s), s.getURL())),
+                        uw -> uw != null);
+        List<UrlWrapper> sorted = ImmutableList.sortedCopyOf(
+                (a, b) -> Integer.compare(a.position, b.position), urlWrappers);
+        return Lists.transform(sorted, uw -> uw.url);
+
+    }
+
+    private static class UrlWrapper {
+        private final int position;
+        private final String url;
+
+        private UrlWrapper(int position, String url) {
+            this.position = position;
+            this.url = url;
+        }
+    }
 }
  
  
  
    
    @@ -84,13 +84,13 @@ public class QuoteHelper {
         if (isPositionQuoteStart(line, 0)) {
             int nestingDepth = 1;
             for (int i = 1; i < line.length(); i++) {
-                if (isPositionQuoteStart(line, i)) {
+                if (isPositionQuoteCharacter(line, i)) {
                     nestingDepth++;
-                }
-                if (nestingDepth > (Config.QUOTING_MAX_DEPTH - 1)) {
-                    return true;
+                } else if (line.charAt(i) != ' ') {
+                    break;
                 }
             }
+            return nestingDepth >= (Config.QUOTING_MAX_DEPTH);
         }
         return false;
     }
  
  
  
    
    @@ -158,4 +158,17 @@ public class ShareUtil {
 		}
 		return false;
 	}
+
+    public static String getLinkScheme(final SpannableStringBuilder body) {
+        MyLinkify.addLinks(body, false);
+        for (final String url : MyLinkify.extractLinks(body)) {
+            final Uri uri = Uri.parse(url);
+            if ("xmpp".equals(uri.getScheme())) {
+                return uri.getScheme();
+            } else {
+                return "http";
+            }
+        }
+        return null;
+    }
 }
  
  
  
    
    @@ -145,7 +145,13 @@ public class EditMessage extends AppCompatEditText {
 
     public void insertAsQuote(String text) {
         text = QuoteHelper.replaceAltQuoteCharsInText(text);
-        text = text.replaceAll("(\n *){2,}", "\n").replaceAll("(^|\n)(" + QuoteHelper.QUOTE_CHAR + ")", "$1$2$2").replaceAll("(^|\n)([^" + QuoteHelper.QUOTE_CHAR + "])", "$1> $2").replaceAll("\n$", "");
+        text = text
+                // first replace all '>' at the beginning of the line with nice and tidy '>>'
+                // for nested quoting
+                .replaceAll("(^|\n)(" + QuoteHelper.QUOTE_CHAR + ")", "$1$2$2")
+                // then find all other lines and have them start with a '> '
+                .replaceAll("(^|\n)(?!" + QuoteHelper.QUOTE_CHAR + ")(.*)", "$1> $2")
+        ;
         Editable editable = getEditableText();
         int position = getSelectionEnd();
         if (position == -1) position = editable.length();
  
  
  
    
    @@ -8,6 +8,7 @@ import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
 import android.os.Build;
 import android.preference.Preference;
 import android.preference.PreferenceCategory;
@@ -15,6 +16,8 @@ import android.preference.PreferenceManager;
 import android.util.Log;
 
 import androidx.annotation.BoolRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
 import androidx.core.content.ContextCompat;
 
 import java.util.Arrays;
@@ -158,10 +161,20 @@ public class Compatibility {
     @SuppressLint("UnsupportedChromeOsCameraSystemFeature")
     public static boolean hasFeatureCamera(final Context context) {
         final PackageManager packageManager = context.getPackageManager();
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
-            return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
-        } else {
-            return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA);
+        return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    public static int getRestrictBackgroundStatus(
+            @NonNull final ConnectivityManager connectivityManager) {
+        try {
+            return connectivityManager.getRestrictBackgroundStatus();
+        } catch (final Exception e) {
+            Log.d(
+                    Config.LOGTAG,
+                    "platform bug detected. Unable to get restrict background status",
+                    e);
+            return ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
         }
     }
 }
  
  
  
    
    @@ -1,5 +1,7 @@
 package eu.siacs.conversations.utils;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.os.Bundle;
 import android.util.Base64;
 import android.util.Pair;
@@ -64,12 +66,12 @@ public final class CryptoHelper {
         return builder.toString();
     }
 
-    public static String pronounceable(SecureRandom random) {
-        final int rand = random.nextInt(4);
+    public static String pronounceable() {
+        final int rand = SECURE_RANDOM.nextInt(4);
         char[] output = new char[rand * 2 + (5 - rand)];
-        boolean vowel = random.nextBoolean();
+        boolean vowel = SECURE_RANDOM.nextBoolean();
         for (int i = 0; i < output.length; ++i) {
-            output[i] = vowel ? VOWELS[random.nextInt(VOWELS.length)] : CONSONANTS[random.nextInt(CONSONANTS.length)];
+            output[i] = vowel ? VOWELS[SECURE_RANDOM.nextInt(VOWELS.length)] : CONSONANTS[SECURE_RANDOM.nextInt(CONSONANTS.length)];
             vowel = !vowel;
         }
         return String.valueOf(output);
@@ -122,9 +124,9 @@ public final class CryptoHelper {
         return Normalizer.normalize(s, Normalizer.Form.NFKC);
     }
 
-    public static String random(int length, SecureRandom random) {
+    public static String random(final int length) {
         final byte[] bytes = new byte[length];
-        random.nextBytes(bytes);
+        SECURE_RANDOM.nextBytes(bytes);
         return Base64.encodeToString(bytes, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE);
     }
 
  
  
  
    
    @@ -66,11 +66,7 @@ public class MessageUtils {
             body = message.getMergedBody().toString();
         }
         for (String line : body.split("\n")) {
-            if (line.length() <= 0) {
-                continue;
-            }
-            final char c = line.charAt(0);
-            if (QuoteHelper.isNestedTooDeeply(line)) {
+            if (!(line.length() <= 0) && QuoteHelper.isNestedTooDeeply(line)) {
                 continue;
             }
             if (builder.length() != 0) {
  
  
  
    
    @@ -12,27 +12,51 @@ import android.provider.Settings;
 
 public class PhoneHelper {
 
-	@SuppressLint("HardwareIds")
-	public static String getAndroidId(Context context) {
-		return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
-	}
+    @SuppressLint("HardwareIds")
+    public static String getAndroidId(Context context) {
+        return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
+    }
 
-	public static Uri getProfilePictureUri(Context context) {
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
-			return null;
-		}
-		final String[] projection = new String[]{Profile._ID, Profile.PHOTO_URI};
-		final Cursor cursor;
-		try {
-			cursor = context.getContentResolver().query(Profile.CONTENT_URI, projection, null, null, null);
-		} catch (Throwable e) {
-			return null;
-		}
-		if (cursor == null) {
-			return null;
-		}
-		final String uri = cursor.moveToFirst() ? cursor.getString(1) : null;
-		cursor.close();
-		return uri == null ? null : Uri.parse(uri);
-	}
+    public static Uri getProfilePictureUri(Context context) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+                && context.checkSelfPermission(Manifest.permission.READ_CONTACTS)
+                        != PackageManager.PERMISSION_GRANTED) {
+            return null;
+        }
+        final String[] projection = new String[] {Profile._ID, Profile.PHOTO_URI};
+        final Cursor cursor;
+        try {
+            cursor =
+                    context.getContentResolver()
+                            .query(Profile.CONTENT_URI, projection, null, null, null);
+        } catch (Throwable e) {
+            return null;
+        }
+        if (cursor == null) {
+            return null;
+        }
+        final String uri = cursor.moveToFirst() ? cursor.getString(1) : null;
+        cursor.close();
+        return uri == null ? null : Uri.parse(uri);
+    }
+
+    public static boolean isEmulator() {
+        return (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
+                || Build.FINGERPRINT.startsWith("generic")
+                || Build.FINGERPRINT.startsWith("unknown")
+                || Build.HARDWARE.contains("goldfish")
+                || Build.HARDWARE.contains("ranchu")
+                || Build.MODEL.contains("google_sdk")
+                || Build.MODEL.contains("Emulator")
+                || Build.MODEL.contains("Android SDK built for x86")
+                || Build.MANUFACTURER.contains("Genymotion")
+                || Build.PRODUCT.contains("sdk_google")
+                || Build.PRODUCT.contains("google_sdk")
+                || Build.PRODUCT.contains("sdk")
+                || Build.PRODUCT.contains("sdk_x86")
+                || Build.PRODUCT.contains("sdk_gphone64_arm64")
+                || Build.PRODUCT.contains("vbox86p")
+                || Build.PRODUCT.contains("emulator")
+                || Build.PRODUCT.contains("simulator");
+    }
 }
  
  
  
    
    @@ -0,0 +1,13 @@
+package eu.siacs.conversations.utils;
+
+import java.security.SecureRandom;
+
+public final class Random {
+
+    public static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+    private Random() {
+
+    }
+
+}
  
  
  
    
    @@ -5,9 +5,12 @@ import android.util.Log;
 
 import androidx.annotation.RequiresApi;
 
+import com.google.common.base.Strings;
+
 import org.conscrypt.Conscrypt;
 
 import java.lang.reflect.Method;
+import java.net.Socket;
 import java.nio.charset.StandardCharsets;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
@@ -24,7 +27,7 @@ import javax.net.ssl.SSLSocket;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 
-public class SSLSocketHelper {
+public class SSLSockets {
 
     public static void setSecurity(final SSLSocket sslSocket) {
         final String[] supportProtocols;
@@ -100,6 +103,45 @@ public class SSLSocketHelper {
 
     public static void log(Account account, SSLSocket socket) {
         SSLSession session = socket.getSession();
-        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": protocol=" + session.getProtocol() + " cipher=" + session.getCipherSuite());
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid()
+                        + ": protocol="
+                        + session.getProtocol()
+                        + " cipher="
+                        + session.getCipherSuite());
+    }
+
+    public static Version version(final Socket socket) {
+        if (socket instanceof SSLSocket) {
+            final SSLSocket sslSocket = (SSLSocket) socket;
+            return Version.of(sslSocket.getSession().getProtocol());
+        } else {
+            return Version.NONE;
+        }
+    }
+
+    public enum Version {
+        TLS_1_0,
+        TLS_1_1,
+        TLS_1_2,
+        TLS_1_3,
+        UNKNOWN,
+        NONE;
+
+        private static Version of(final String protocol) {
+            switch (Strings.nullToEmpty(protocol)) {
+                case "TLSv1":
+                    return TLS_1_0;
+                case "TLSv1.1":
+                    return TLS_1_1;
+                case "TLSv1.2":
+                    return TLS_1_2;
+                case "TLSv1.3":
+                    return TLS_1_3;
+                default:
+                    return UNKNOWN;
+            }
+        }
     }
 }
  
  
  
    
    @@ -17,7 +17,7 @@ public class TLSSocketFactory extends SSLSocketFactory {
     private final SSLSocketFactory internalSSLSocketFactory;
 
     public TLSSocketFactory(X509TrustManager[] trustManager, SecureRandom random) throws KeyManagementException, NoSuchAlgorithmException {
-        SSLContext context = SSLSocketHelper.getSSLContext();
+        SSLContext context = SSLSockets.getSSLContext();
         context.init(null, trustManager, random);
         this.internalSSLSocketFactory = context.getSocketFactory();
     }
@@ -59,7 +59,7 @@ public class TLSSocketFactory extends SSLSocketFactory {
 
     private static Socket enableTLSOnSocket(Socket socket) {
         if(socket instanceof SSLSocket) {
-            SSLSocketHelper.setSecurity((SSLSocket) socket);
+            SSLSockets.setSecurity((SSLSocket) socket);
         }
         return socket;
     }
  
  
  
    
    @@ -1,30 +1,31 @@
 package eu.siacs.conversations.utils;
 
+import com.google.common.base.Joiner;
+import com.google.common.collect.Lists;
+
+import java.util.Collections;
+import java.util.List;
+
 import eu.siacs.conversations.xml.Element;
 
 public class XmlHelper {
-	public static String encodeEntities(String content) {
-		content = content.replace("&", "&");
-		content = content.replace("<", "<");
-		content = content.replace(">", ">");
-		content = content.replace("\"", """);
-		content = content.replace("'", "'");
-		content = content.replaceAll("[\\p{Cntrl}&&[^\n\t\r]]", "");
-		return content;
-	}
+    public static String encodeEntities(String content) {
+        content = content.replace("&", "&");
+        content = content.replace("<", "<");
+        content = content.replace(">", ">");
+        content = content.replace("\"", """);
+        content = content.replace("'", "'");
+        content = content.replaceAll("[\\p{Cntrl}&&[^\n\t\r]]", "");
+        return content;
+    }
 
-	public static String printElementNames(final Element element) {
-		final StringBuilder builder = new StringBuilder();
-		builder.append('[');
-		if (element != null) {
-			for (Element child : element.getChildren()) {
-				if (builder.length() != 1) {
-					builder.append(',');
-				}
-				builder.append(child.getName());
-			}
-		}
-		builder.append(']');
-		return builder.toString();
-	}
+    public static String printElementNames(final Element element) {
+        final List<String> features =
+                element == null
+                        ? Collections.emptyList()
+                        : Lists.transform(
+                                element.getChildren(),
+                                child -> child != null ? child.getName() : null);
+        return Joiner.on(", ").join(features);
+    }
 }
  
  
  
    
    @@ -211,11 +211,11 @@ public class Element implements Node {
 		final StringBuilder elementOutput = new StringBuilder();
 		if (childNodes.size() == 0) {
 			Tag emptyTag = Tag.empty(name);
-			emptyTag.setAtttributes(this.attributes);
+			emptyTag.setAttributes(this.attributes);
 			elementOutput.append(emptyTag.toString());
 		} else {
 			Tag startTag = Tag.start(name);
-			startTag.setAtttributes(this.attributes);
+			startTag.setAttributes(this.attributes);
 			elementOutput.append(startTag);
 			for (Node child : childNodes) {
 				elementOutput.append(child.toString());
  
  
  
    
    @@ -1,12 +1,14 @@
 package eu.siacs.conversations.xml;
 
 public final class Namespace {
+    public static final String STREAMS = "http://etherx.jabber.org/streams";
     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";
     public static final String BLOCKING = "urn:xmpp:blocking";
     public static final String ROSTER = "jabber:iq:roster";
     public static final String REGISTER = "jabber:iq:register";
+    public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register";
     public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
     public static final String HTTP_UPLOAD = "urn:xmpp:http:upload:0";
     public static final String HTTP_UPLOAD_LEGACY = "urn:xmpp:http:upload";
@@ -15,6 +17,9 @@ public final class Namespace {
     public static final String DATA = "jabber:x:data";
     public static final String OOB = "jabber:x:oob";
     public static final String SASL = "urn:ietf:params:xml:ns:xmpp-sasl";
+    public static final String SASL_2 = "urn:xmpp:sasl:2";
+    public static final String CHANNEL_BINDING = "urn:xmpp:sasl-cb:0";
+    public static final String FAST = "urn:xmpp:fast:0";
     public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
     public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
     public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
@@ -23,9 +28,15 @@ public final class Namespace {
     public static final String NICK = "http://jabber.org/protocol/nick";
     public static final String FLEXIBLE_OFFLINE_MESSAGE_RETRIEVAL = "http://jabber.org/protocol/offline";
     public static final String BIND = "urn:ietf:params:xml:ns:xmpp-bind";
+    public static final String BIND2 = "urn:xmpp:bind:0";
+    public static final String STREAM_MANAGEMENT = "urn:xmpp:sm:3";
+    public static final String CSI = "urn:xmpp:csi:0";
+    public static final String CARBONS = "urn:xmpp:carbons:2";
     public static final String BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0";
     public static final String BOOKMARKS = "storage:bookmarks";
     public static final String SYNCHRONIZATION = "im.quicksy.synchronization:0";
+    public static final String AVATAR_DATA = "urn:xmpp:avatar:data";
+    public static final String AVATAR_METADATA =  "urn:xmpp:avatar:metadata";
     public static final String AVATAR_CONVERSION = "urn:xmpp:pep-vcard-conversion:0";
     public static final String JINGLE = "urn:xmpp:jingle:1";
     public static final String JINGLE_ERRORS = "urn:xmpp:jingle:errors:1";
  
  
  
    
    @@ -1,104 +1,107 @@
 package eu.siacs.conversations.xml;
 
+import org.jetbrains.annotations.NotNull;
+
 import java.util.Hashtable;
-import java.util.Iterator;
 import java.util.Map.Entry;
 import java.util.Set;
 
 import eu.siacs.conversations.utils.XmlHelper;
 
 public class Tag {
-	public static final int NO = -1;
-	public static final int START = 0;
-	public static final int END = 1;
-	public static final int EMPTY = 2;
-
-	protected int type;
-	protected String name;
-	protected Hashtable<String, String> attributes = new Hashtable<String, String>();
-
-	protected Tag(int type, String name) {
-		this.type = type;
-		this.name = name;
-	}
-
-	public static Tag no(String text) {
-		return new Tag(NO, text);
-	}
-
-	public static Tag start(String name) {
-		return new Tag(START, name);
-	}
-
-	public static Tag end(String name) {
-		return new Tag(END, name);
-	}
-
-	public static Tag empty(String name) {
-		return new Tag(EMPTY, name);
-	}
-
-	public String getName() {
-		return name;
-	}
-
-	public String getAttribute(String attrName) {
-		return this.attributes.get(attrName);
-	}
-
-	public Tag setAttribute(String attrName, String attrValue) {
-		this.attributes.put(attrName, attrValue);
-		return this;
-	}
-
-	public Tag setAtttributes(Hashtable<String, String> attributes) {
-		this.attributes = attributes;
-		return this;
-	}
-
-	public boolean isStart(String needle) {
-		if (needle == null)
-			return false;
-		return (this.type == START) && (needle.equals(this.name));
-	}
-
-	public boolean isEnd(String needle) {
-		if (needle == null)
-			return false;
-		return (this.type == END) && (needle.equals(this.name));
-	}
-
-	public boolean isNo() {
-		return (this.type == NO);
-	}
-
-	public String toString() {
-		StringBuilder tagOutput = new StringBuilder();
-		tagOutput.append('<');
-		if (type == END) {
-			tagOutput.append('/');
-		}
-		tagOutput.append(name);
-		if (type != END) {
-			Set<Entry<String, String>> attributeSet = attributes.entrySet();
-			Iterator<Entry<String, String>> it = attributeSet.iterator();
-			while (it.hasNext()) {
-				Entry<String, String> entry = it.next();
-				tagOutput.append(' ');
-				tagOutput.append(entry.getKey());
-				tagOutput.append("=\"");
-				tagOutput.append(XmlHelper.encodeEntities(entry.getValue()));
-				tagOutput.append('"');
-			}
-		}
-		if (type == EMPTY) {
-			tagOutput.append('/');
-		}
-		tagOutput.append('>');
-		return tagOutput.toString();
-	}
-
-	public Hashtable<String, String> getAttributes() {
-		return this.attributes;
-	}
+    public static final int NO = -1;
+    public static final int START = 0;
+    public static final int END = 1;
+    public static final int EMPTY = 2;
+
+    protected int type;
+    protected String name;
+    protected Hashtable<String, String> attributes = new Hashtable<String, String>();
+
+    protected Tag(int type, String name) {
+        this.type = type;
+        this.name = name;
+    }
+
+    public static Tag no(String text) {
+        return new Tag(NO, text);
+    }
+
+    public static Tag start(String name) {
+        return new Tag(START, name);
+    }
+
+    public static Tag end(String name) {
+        return new Tag(END, name);
+    }
+
+    public static Tag empty(String name) {
+        return new Tag(EMPTY, name);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getAttribute(final String attrName) {
+        return this.attributes.get(attrName);
+    }
+
+    public Tag setAttribute(final String attrName, final String attrValue) {
+        this.attributes.put(attrName, attrValue);
+        return this;
+    }
+
+    public void setAttributes(final Hashtable<String, String> attributes) {
+        this.attributes = attributes;
+    }
+
+    public boolean isStart(final String needle) {
+        if (needle == null) {
+            return false;
+        }
+        return (this.type == START) && (needle.equals(this.name));
+    }
+
+    public boolean isStart(final String name, final String namespace) {
+        return isStart(name) && namespace != null && namespace.equals(this.getAttribute("xmlns"));
+    }
+
+    public boolean isEnd(String needle) {
+        if (needle == null) return false;
+        return (this.type == END) && (needle.equals(this.name));
+    }
+
+    public boolean isNo() {
+        return (this.type == NO);
+    }
+
+    @NotNull
+    public String toString() {
+        final StringBuilder tagOutput = new StringBuilder();
+        tagOutput.append('<');
+        if (type == END) {
+            tagOutput.append('/');
+        }
+        tagOutput.append(name);
+        if (type != END) {
+            final Set<Entry<String, String>> attributeSet = attributes.entrySet();
+            for (final Entry<String, String> entry : attributeSet) {
+                tagOutput.append(' ');
+                tagOutput.append(entry.getKey());
+                tagOutput.append("=\"");
+                tagOutput.append(XmlHelper.encodeEntities(entry.getValue()));
+                tagOutput.append('"');
+            }
+        }
+        if (type == EMPTY) {
+            tagOutput.append('/');
+        }
+        tagOutput.append('>');
+        return tagOutput.toString();
+    }
+
+    public Hashtable<String, String> getAttributes() {
+        return this.attributes;
+    }
 }
  
  
  
    
    @@ -58,15 +58,20 @@ public class TagWriter {
             throw new IOException("output stream was null");
         }
         outputStream.write("<?xml version='1.0'?>");
-        outputStream.flush();
     }
 
-    public synchronized void writeTag(Tag tag) throws IOException {
+    public void writeTag(final Tag tag) throws IOException {
+        writeTag(tag, true);
+    }
+
+    public synchronized void writeTag(final Tag tag, final boolean flush) throws IOException {
         if (outputStream == null) {
             throw new IOException("output stream was null");
         }
         outputStream.write(tag.toString());
-        outputStream.flush();
+        if (flush) {
+            outputStream.flush();
+        }
     }
 
     public synchronized void writeElement(Element element) throws IOException {
  
  
  
    
    @@ -118,8 +118,7 @@ public interface Jid extends Comparable<Jid>, Serializable, CharSequence {
     static Jid ofEscaped(CharSequence jid) {
         try {
             return new WrappedJid(JidCreate.from(jid));
-        } catch (XmppStringprepException e) {
-            e.printStackTrace();
+        } catch (final XmppStringprepException e) {
             throw new IllegalArgumentException(e);
         }
     }
  
  
  
    
    @@ -1,8 +1,11 @@
 package eu.siacs.conversations.xmpp;
 
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.os.Build;
 import android.os.SystemClock;
 import android.security.KeyChain;
 import android.util.Base64;
@@ -11,6 +14,7 @@ import android.util.Pair;
 import android.util.SparseArray;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 import com.google.common.base.Strings;
 
@@ -32,6 +36,7 @@ import java.security.PrivateKey;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -58,14 +63,9 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.XmppDomainVerifier;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.crypto.sasl.Anonymous;
-import eu.siacs.conversations.crypto.sasl.DigestMd5;
-import eu.siacs.conversations.crypto.sasl.External;
-import eu.siacs.conversations.crypto.sasl.Plain;
+import eu.siacs.conversations.crypto.sasl.ChannelBinding;
+import eu.siacs.conversations.crypto.sasl.HashedToken;
 import eu.siacs.conversations.crypto.sasl.SaslMechanism;
-import eu.siacs.conversations.crypto.sasl.ScramSha1;
-import eu.siacs.conversations.crypto.sasl.ScramSha256;
-import eu.siacs.conversations.crypto.sasl.ScramSha512;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
@@ -78,8 +78,9 @@ import eu.siacs.conversations.services.NotificationService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.Patterns;
+import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.utils.Resolver;
-import eu.siacs.conversations.utils.SSLSocketHelper;
+import eu.siacs.conversations.utils.SSLSockets;
 import eu.siacs.conversations.utils.SocksSocketFactory;
 import eu.siacs.conversations.utils.XmlHelper;
 import eu.siacs.conversations.xml.Element;
@@ -88,6 +89,7 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xml.Tag;
 import eu.siacs.conversations.xml.TagWriter;
 import eu.siacs.conversations.xml.XmlReader;
+import eu.siacs.conversations.xmpp.bind.Bind2;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
 import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
@@ -109,48 +111,55 @@ public class XmppConnection implements Runnable {
     private static final int PACKET_IQ = 0;
     private static final int PACKET_MESSAGE = 1;
     private static final int PACKET_PRESENCE = 2;
-    public final OnIqPacketReceived registrationResponseListener = (account, packet) -> {
-        if (packet.getType() == IqPacket.TYPE.RESULT) {
-            account.setOption(Account.OPTION_REGISTER, false);
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": successfully registered new account on server");
-            throw new StateChangingError(Account.State.REGISTRATION_SUCCESSFUL);
-        } else {
-            final List<String> PASSWORD_TOO_WEAK_MSGS = Arrays.asList(
-                    "The password is too weak",
-                    "Please use a longer password.");
-            Element error = packet.findChild("error");
-            Account.State state = Account.State.REGISTRATION_FAILED;
-            if (error != null) {
-                if (error.hasChild("conflict")) {
-                    state = Account.State.REGISTRATION_CONFLICT;
-                } else if (error.hasChild("resource-constraint")
-                        && "wait".equals(error.getAttribute("type"))) {
-                    state = Account.State.REGISTRATION_PLEASE_WAIT;
-                } else if (error.hasChild("not-acceptable")
-                        && PASSWORD_TOO_WEAK_MSGS.contains(error.findChildContent("text"))) {
-                    state = Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
+    public final OnIqPacketReceived registrationResponseListener =
+            (account, packet) -> {
+                if (packet.getType() == IqPacket.TYPE.RESULT) {
+                    account.setOption(Account.OPTION_REGISTER, false);
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": successfully registered new account on server");
+                    throw new StateChangingError(Account.State.REGISTRATION_SUCCESSFUL);
+                } else {
+                    final List<String> PASSWORD_TOO_WEAK_MSGS =
+                            Arrays.asList(
+                                    "The password is too weak", "Please use a longer password.");
+                    Element error = packet.findChild("error");
+                    Account.State state = Account.State.REGISTRATION_FAILED;
+                    if (error != null) {
+                        if (error.hasChild("conflict")) {
+                            state = Account.State.REGISTRATION_CONFLICT;
+                        } else if (error.hasChild("resource-constraint")
+                                && "wait".equals(error.getAttribute("type"))) {
+                            state = Account.State.REGISTRATION_PLEASE_WAIT;
+                        } else if (error.hasChild("not-acceptable")
+                                && PASSWORD_TOO_WEAK_MSGS.contains(
+                                        error.findChildContent("text"))) {
+                            state = Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
+                        }
+                    }
+                    throw new StateChangingError(state);
                 }
-            }
-            throw new StateChangingError(state);
-        }
-    };
+            };
     protected final Account account;
     private final Features features = new Features(this);
     private final HashMap<Jid, ServiceDiscoveryResult> disco = new HashMap<>();
     private final HashMap<String, Jid> commands = new HashMap<>();
     private final SparseArray<AbstractAcknowledgeableStanza> mStanzaQueue = new SparseArray<>();
-    private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks = new Hashtable<>();
-    private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners = new HashSet<>();
+    private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks =
+            new Hashtable<>();
+    private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners =
+            new HashSet<>();
     private final XmppConnectionService mXmppConnectionService;
     private Socket socket;
     private XmlReader tagReader;
     private TagWriter tagWriter = new TagWriter();
     private boolean shouldAuthenticate = true;
     private boolean inSmacksSession = false;
+    private boolean quickStartInProgress = false;
     private boolean isBound = false;
     private Element streamFeatures;
     private String streamId = null;
-    private int smVersion = 3;
     private int stanzasReceived = 0;
     private int stanzasSent = 0;
     private long lastPacketReceived = 0;
@@ -173,12 +182,12 @@ public class XmppConnection implements Runnable {
     private OnBindListener bindListener = null;
     private OnMessageAcknowledged acknowledgedListener = null;
     private SaslMechanism saslMechanism;
+    private HashedToken.Mechanism hashTokenRequest;
     private HttpUrl redirectionUrl = null;
     private String verifiedHostname = null;
     private volatile Thread mThread;
     private CountDownLatch mStreamCountDownLatch;
 
-
     public XmppConnection(final Account account, final XmppConnectionService service) {
         this.account = account;
         this.mXmppConnectionService = service;
@@ -186,10 +195,12 @@ public class XmppConnection implements Runnable {
 
     private static void fixResource(Context context, Account account) {
         String resource = account.getResource();
-        int fixedPartLength = context.getString(R.string.app_name).length() + 1; //include the trailing dot
+        int fixedPartLength =
+                context.getString(R.string.app_name).length() + 1; // include the trailing dot
         int randomPartLength = 4; // 3 bytes
         if (resource != null && resource.length() > fixedPartLength + randomPartLength) {
-            if (validBase64(resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
+            if (validBase64(
+                    resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
                 account.setResource(resource.substring(0, fixedPartLength + randomPartLength));
             }
         }
@@ -206,7 +217,12 @@ public class XmppConnection implements Runnable {
     private void changeStatus(final Account.State nextStatus) {
         synchronized (this) {
             if (Thread.currentThread().isInterrupted()) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": not changing status to " + nextStatus + " because thread was interrupted");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": not changing status to "
+                                + nextStatus
+                                + " because thread was interrupted");
                 return;
             }
             if (account.getStatus() != nextStatus) {
@@ -257,10 +273,12 @@ public class XmppConnection implements Runnable {
         }
         Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": connecting");
         features.encryptionEnabled = false;
-        inSmacksSession = false;
-        isBound = false;
+        this.inSmacksSession = false;
+        this.quickStartInProgress = false;
+        this.isBound = false;
         this.attempt++;
-        this.verifiedHostname = null; //will be set if user entered hostname is being used or hostname was verified with dnssec
+        this.verifiedHostname = null; // will be set if user entered hostname is being used or hostname was verified
+        // with dnssec
         try {
             Socket localSocket;
             shouldAuthenticate = !account.isOptionSet(Account.OPTION_REGISTER);
@@ -279,7 +297,13 @@ public class XmppConnection implements Runnable {
                 final int port = account.getPort();
                 final boolean directTls = Resolver.useDirectTls(port);
 
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": connect to " + destination + " via Tor. directTls=" + directTls);
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": connect to "
+                                + destination
+                                + " via Tor. directTls="
+                                + directTls);
                 localSocket = SocksSocketFactory.createSocketOverTor(destination, port);
 
                 if (directTls) {
@@ -289,11 +313,14 @@ public class XmppConnection implements Runnable {
 
                 try {
                     startXmpp(localSocket);
-                } catch (InterruptedException e) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": thread was interrupted before beginning stream");
+                } catch (final InterruptedException e) {
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": thread was interrupted before beginning stream");
                     return;
-                } catch (Exception e) {
-                    throw new IOException(e.getMessage());
+                } catch (final Exception e) {
+                    throw new IOException("Could not start stream", e);
                 }
             } else {
                 final String domain = account.getServer();
@@ -309,41 +336,70 @@ public class XmppConnection implements Runnable {
                     return;
                 }
                 if (results.size() == 0) {
-                    Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": Resolver results were empty");
+                    Log.e(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid() + ": Resolver results were empty");
                     return;
                 }
                 final Resolver.Result storedBackupResult;
                 if (hardcoded) {
                     storedBackupResult = null;
                 } else {
-                    storedBackupResult = mXmppConnectionService.databaseBackend.findResolverResult(domain);
+                    storedBackupResult =
+                            mXmppConnectionService.databaseBackend.findResolverResult(domain);
                     if (storedBackupResult != null && !results.contains(storedBackupResult)) {
                         results.add(storedBackupResult);
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": loaded backup resolver result from db: " + storedBackupResult);
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": loaded backup resolver result from db: "
+                                        + storedBackupResult);
                     }
                 }
-                for (Iterator<Resolver.Result> iterator = results.iterator(); iterator.hasNext(); ) {
+                for (Iterator<Resolver.Result> iterator = results.iterator();
+                        iterator.hasNext(); ) {
                     final Resolver.Result result = iterator.next();
                     if (Thread.currentThread().isInterrupted()) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": Thread was interrupted");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid() + ": Thread was interrupted");
                         return;
                     }
                     try {
                         // if tls is true, encryption is implied and must not be started
                         features.encryptionEnabled = result.isDirectTls();
-                        verifiedHostname = result.isAuthenticated() ? result.getHostname().toString() : null;
+                        verifiedHostname =
+                                result.isAuthenticated() ? result.getHostname().toString() : null;
                         Log.d(Config.LOGTAG, "verified hostname " + verifiedHostname);
                         final InetSocketAddress addr;
                         if (result.getIp() != null) {
                             addr = new InetSocketAddress(result.getIp(), result.getPort());
-                            Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
-                                    + ": using values from resolver " + (result.getHostname() == null ? "" : result.getHostname().toString()
-                                    + "/") + result.getIp().getHostAddress() + ":" + result.getPort() + " tls: " + features.encryptionEnabled);
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid().toString()
+                                            + ": using values from resolver "
+                                            + (result.getHostname() == null
+                                                    ? ""
+                                                    : result.getHostname().toString() + "/")
+                                            + result.getIp().getHostAddress()
+                                            + ":"
+                                            + result.getPort()
+                                            + " tls: "
+                                            + features.encryptionEnabled);
                         } else {
-                            addr = new InetSocketAddress(IDN.toASCII(result.getHostname().toString()), result.getPort());
-                            Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
-                                    + ": using values from resolver "
-                                    + result.getHostname().toString() + ":" + result.getPort() + " tls: " + features.encryptionEnabled);
+                            addr =
+                                    new InetSocketAddress(
+                                            IDN.toASCII(result.getHostname().toString()),
+                                            result.getPort());
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid().toString()
+                                            + ": using values from resolver "
+                                            + result.getHostname().toString()
+                                            + ":"
+                                            + result.getPort()
+                                            + " tls: "
+                                            + features.encryptionEnabled);
                         }
 
                         localSocket = new Socket();
@@ -355,9 +411,12 @@ public class XmppConnection implements Runnable {
 
                         localSocket.setSoTimeout(Config.SOCKET_TIMEOUT * 1000);
                         if (startXmpp(localSocket)) {
-                            localSocket.setSoTimeout(0); //reset to 0; once the connection is established we don’t want this
+                            localSocket.setSoTimeout(
+                                    0); // reset to 0; once the connection is established we don’t
+                            // want this
                             if (!hardcoded && !result.equals(storedBackupResult)) {
-                                mXmppConnectionService.databaseBackend.saveResolverResult(domain, result);
+                                mXmppConnectionService.databaseBackend.saveResolverResult(
+                                        domain, result);
                             }
                             break; // successfully connected to server that speaks xmpp
                         } else {
@@ -369,10 +428,20 @@ public class XmppConnection implements Runnable {
                             throw e;
                         }
                     } catch (InterruptedException e) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": thread was interrupted before beginning stream");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": thread was interrupted before beginning stream");
                         return;
                     } catch (final Throwable e) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": " + e.getMessage() + "(" + e.getClass().getName() + ")");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid().toString()
+                                        + ": "
+                                        + e.getMessage()
+                                        + "("
+                                        + e.getClass().getName()
+                                        + ")");
                         if (!iterator.hasNext()) {
                             throw new UnknownHostException();
                         }
@@ -384,7 +453,9 @@ public class XmppConnection implements Runnable {
             this.changeStatus(Account.State.MISSING_INTERNET_PERMISSION);
         } catch (final StateChangingException e) {
             this.changeStatus(e.state);
-        } catch (final UnknownHostException | ConnectException | SocksSocketFactory.HostNotFoundException e) {
+        } catch (final UnknownHostException
+                | ConnectException
+                | SocksSocketFactory.HostNotFoundException e) {
             this.changeStatus(Account.State.SERVER_NOT_FOUND);
         } catch (final SocksSocketFactory.SocksProxyNotFoundException e) {
             this.changeStatus(Account.State.TOR_NOT_AVAILABLE);
@@ -396,7 +467,10 @@ public class XmppConnection implements Runnable {
             if (!Thread.currentThread().isInterrupted()) {
                 forceCloseSocket();
             } else {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": not force closing socket because thread was interrupted");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": not force closing socket because thread was interrupted");
             }
         }
     }
@@ -406,7 +480,7 @@ public class XmppConnection implements Runnable {
      *
      * @return true if server returns with valid xmpp, false otherwise
      */
-    private boolean startXmpp(Socket socket) throws Exception {
+    private boolean startXmpp(final Socket socket) throws Exception {
         if (Thread.currentThread().isInterrupted()) {
             throw new InterruptedException();
         }
@@ -419,28 +493,45 @@ public class XmppConnection implements Runnable {
         tagWriter.setOutputStream(socket.getOutputStream());
         tagReader.setInputStream(socket.getInputStream());
         tagWriter.beginDocument();
-        sendStartStream();
+        final boolean quickStart;
+        if (socket instanceof SSLSocket) {
+            final SSLSocket sslSocket = (SSLSocket) socket;
+            SSLSockets.log(account, sslSocket);
+            quickStart = establishStream(SSLSockets.version(sslSocket));
+        } else {
+            quickStart = establishStream(SSLSockets.Version.NONE);
+        }
         final Tag tag = tagReader.readTag();
         if (Thread.currentThread().isInterrupted()) {
             throw new InterruptedException();
         }
-        if (socket instanceof SSLSocket) {
-            SSLSocketHelper.log(account, (SSLSocket) socket);
+        final boolean success = tag != null && tag.isStart("stream", Namespace.STREAMS);
+        if (success && quickStart) {
+            this.quickStartInProgress = true;
         }
-        return tag != null && tag.isStart("stream");
+        return success;
     }
 
-    private SSLSocketFactory getSSLSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
-        final SSLContext sc = SSLSocketHelper.getSSLContext();
-        final MemorizingTrustManager trustManager = this.mXmppConnectionService.getMemorizingTrustManager();
+    private SSLSocketFactory getSSLSocketFactory()
+            throws NoSuchAlgorithmException, KeyManagementException {
+        final SSLContext sc = SSLSockets.getSSLContext();
+        final MemorizingTrustManager trustManager =
+                this.mXmppConnectionService.getMemorizingTrustManager();
         final KeyManager[] keyManager;
         if (account.getPrivateKeyAlias() != null) {
-            keyManager = new KeyManager[]{new MyKeyManager()};
+            keyManager = new KeyManager[] {new MyKeyManager()};
         } else {
             keyManager = null;
         }
         final String domain = account.getServer();
-        sc.init(keyManager, new X509TrustManager[]{mInteractive ? trustManager.getInteractive(domain) : trustManager.getNonInteractive(domain)}, mXmppConnectionService.getRNG());
+        sc.init(
+                keyManager,
+                new X509TrustManager[] {
+                    mInteractive
+                            ? trustManager.getInteractive(domain)
+                            : trustManager.getNonInteractive(domain)
+                },
+                SECURE_RANDOM);
         return sc.getSocketFactory();
     }
 
@@ -449,7 +540,10 @@ public class XmppConnection implements Runnable {
         synchronized (this) {
             this.mThread = Thread.currentThread();
             if (this.mThread.isInterrupted()) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": aborting connect because thread was interrupted");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": aborting connect because thread was interrupted");
                 return;
             }
             forceCloseSocket();
@@ -464,134 +558,51 @@ public class XmppConnection implements Runnable {
         while (nextTag != null && !nextTag.isEnd("stream")) {
             if (nextTag.isStart("error")) {
                 processStreamError(nextTag);
-            } else if (nextTag.isStart("features")) {
+            } else if (nextTag.isStart("features", Namespace.STREAMS)) {
                 processStreamFeatures(nextTag);
-            } else if (nextTag.isStart("proceed")) {
+            } else if (nextTag.isStart("proceed", Namespace.TLS)) {
                 switchOverToTls();
             } else if (nextTag.isStart("success")) {
-                final String challenge = tagReader.readElement(nextTag).getContent();
-                try {
-                    saslMechanism.getResponse(challenge);
-                } catch (final SaslMechanism.AuthenticationException e) {
-                    Log.e(Config.LOGTAG, String.valueOf(e));
-                    throw new StateChangingException(Account.State.UNAUTHORIZED);
+                final Element success = tagReader.readElement(nextTag);
+                if (processSuccess(success)) {
+                    break;
                 }
-                Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": logged in");
-                account.setKey(Account.PINNED_MECHANISM_KEY,
-                        String.valueOf(saslMechanism.getPriority()));
-                tagReader.reset();
-                sendStartStream();
-                final Tag tag = tagReader.readTag();
-                if (tag != null && tag.isStart("stream")) {
-                    processStream();
-                } else {
-                    throw new StateChangingException(Account.State.STREAM_OPENING_ERROR);
-                }
-                break;
+
+            } else if (nextTag.isStart("failure", Namespace.TLS)) {
+                throw new StateChangingException(Account.State.TLS_ERROR);
             } else if (nextTag.isStart("failure")) {
                 final Element failure = tagReader.readElement(nextTag);
-                if (Namespace.SASL.equals(failure.getNamespace())) {
-                    if (failure.hasChild("temporary-auth-failure")) {
-                        throw new StateChangingException(Account.State.TEMPORARY_AUTH_FAILURE);
-                    } else if (failure.hasChild("account-disabled")) {
-                        final String text = failure.findChildContent("text");
-                        if ( Strings.isNullOrEmpty(text)) {
-                            throw new StateChangingException(Account.State.UNAUTHORIZED);
-                        }
-                        final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(text);
-                        if (matcher.find()) {
-                            final HttpUrl url;
-                            try {
-                                url = HttpUrl.get(text.substring(matcher.start(), matcher.end()));
-                            } catch (final IllegalArgumentException e) {
-                                throw new StateChangingException(Account.State.UNAUTHORIZED);
-                            }
-                            if (url.isHttps()) {
-                                this.redirectionUrl = url;
-                                throw new StateChangingException(Account.State.PAYMENT_REQUIRED);
-                            }
-                        }
-                    }
-                    throw new StateChangingException(Account.State.UNAUTHORIZED);
-                } else if (Namespace.TLS.equals(failure.getNamespace())) {
-                    throw new StateChangingException(Account.State.TLS_ERROR);
-                } else {
-                    throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
-                }
+                processFailure(failure);
+            } else if (nextTag.isStart("continue", Namespace.SASL_2)) {
+                // two step sasl2 - we don’t support this yet
+                throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT);
             } else if (nextTag.isStart("challenge")) {
-                final String challenge = tagReader.readElement(nextTag).getContent();
-                final Element response = new Element("response", Namespace.SASL);
-                try {
-                    response.setContent(saslMechanism.getResponse(challenge));
-                } catch (final SaslMechanism.AuthenticationException e) {
-                    // TODO: Send auth abort tag.
-                    Log.e(Config.LOGTAG, e.toString());
-                }
-                tagWriter.writeElement(response);
-            } else if (nextTag.isStart("enabled")) {
-                final Element enabled = tagReader.readElement(nextTag);
-                if ("true".equals(enabled.getAttribute("resume"))) {
-                    this.streamId = enabled.getAttribute("id");
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
-                            + ": stream management(" + smVersion
-                            + ") enabled (resumable)");
+                if (isSecure() && this.saslMechanism != null) {
+                    final Element challenge = tagReader.readElement(nextTag);
+                    processChallenge(challenge);
                 } else {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
-                            + ": stream management(" + smVersion + ") enabled");
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": received 'challenge on an unsecure connection");
+                    throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT);
                 }
-                this.stanzasReceived = 0;
-                this.inSmacksSession = true;
-                final RequestPacket r = new RequestPacket(smVersion);
-                tagWriter.writeStanzaAsync(r);
+            } else if (nextTag.isStart("enabled", Namespace.STREAM_MANAGEMENT)) {
+                final Element enabled = tagReader.readElement(nextTag);
+                processEnabled(enabled);
             } else if (nextTag.isStart("resumed")) {
-                this.inSmacksSession = true;
-                this.isBound = true;
-                this.tagWriter.writeStanzaAsync(new RequestPacket(smVersion));
-                lastPacketReceived = SystemClock.elapsedRealtime();
                 final Element resumed = tagReader.readElement(nextTag);
-                final String h = resumed.getAttribute("h");
-                try {
-                    ArrayList<AbstractAcknowledgeableStanza> failedStanzas = new ArrayList<>();
-                    final boolean acknowledgedMessages;
-                    synchronized (this.mStanzaQueue) {
-                        final int serverCount = Integer.parseInt(h);
-                        if (serverCount < stanzasSent) {
-                            Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
-                                    + ": session resumed with lost packages");
-                            stanzasSent = serverCount;
-                        } else {
-                            Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": session resumed");
-                        }
-                        acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
-                        for (int i = 0; i < this.mStanzaQueue.size(); ++i) {
-                            failedStanzas.add(mStanzaQueue.valueAt(i));
-                        }
-                        mStanzaQueue.clear();
-                    }
-                    if (acknowledgedMessages) {
-                        mXmppConnectionService.updateConversationUi();
-                    }
-                    Log.d(Config.LOGTAG, "resending " + failedStanzas.size() + " stanzas");
-                    for (AbstractAcknowledgeableStanza packet : failedStanzas) {
-                        if (packet instanceof MessagePacket) {
-                            MessagePacket message = (MessagePacket) packet;
-                            mXmppConnectionService.markMessage(account,
-                                    message.getTo().asBareJid(),
-                                    message.getId(),
-                                    Message.STATUS_UNSEND);
-                        }
-                        sendPacket(packet);
-                    }
-                } catch (final NumberFormatException ignored) {
-                }
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": online with resource " + account.getResource());
-                changeStatus(Account.State.ONLINE);
+                processResumed(resumed);
             } else if (nextTag.isStart("r")) {
                 tagReader.readElement(nextTag);
                 if (Config.EXTENDED_SM_LOGGING) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": acknowledging stanza #" + this.stanzasReceived);
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": acknowledging stanza #"
+                                    + this.stanzasReceived);
                 }
-                final AckPacket ack = new AckPacket(this.stanzasReceived, smVersion);
+                final AckPacket ack = new AckPacket(this.stanzasReceived);
                 tagWriter.writeStanzaAsync(ack);
             } else if (nextTag.isStart("a")) {
                 boolean accountUiNeedsRefresh = false;
@@ -599,10 +610,19 @@ public class XmppConnection implements Runnable {
                     if (mWaitingForSmCatchup.compareAndSet(true, false)) {
                         final int messageCount = mSmCatchupMessageCounter.get();
                         final int pendingIQs = packetCallbacks.size();
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": SM catchup complete (messages=" + messageCount + ", pending IQs=" + pendingIQs + ")");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": SM catchup complete (messages="
+                                        + messageCount
+                                        + ", pending IQs="
+                                        + pendingIQs
+                                        + ")");
                         accountUiNeedsRefresh = true;
                         if (messageCount > 0) {
-                            mXmppConnectionService.getNotificationService().finishBacklog(true, account);
+                            mXmppConnectionService
+                                    .getNotificationService()
+                                    .finishBacklog(true, account);
                         }
                     }
                 }
@@ -621,25 +641,14 @@ public class XmppConnection implements Runnable {
                         mXmppConnectionService.updateConversationUi();
                     }
                 } catch (NumberFormatException | NullPointerException e) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server send ack without sequence number");
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": server send ack without sequence number");
                 }
             } else if (nextTag.isStart("failed")) {
-                Element failed = tagReader.readElement(nextTag);
-                try {
-                    final int serverCount = Integer.parseInt(failed.getAttribute("h"));
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed but server acknowledged stanza #" + serverCount);
-                    final boolean acknowledgedMessages;
-                    synchronized (this.mStanzaQueue) {
-                        acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
-                    }
-                    if (acknowledgedMessages) {
-                        mXmppConnectionService.updateConversationUi();
-                    }
-                } catch (NumberFormatException | NullPointerException e) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed");
-                }
-                resetStreamId();
-                sendBindRequest();
+                final Element failed = tagReader.readElement(nextTag);
+                processFailed(failed, true);
             } else if (nextTag.isStart("iq")) {
                 processIq(nextTag);
             } else if (nextTag.isStart("message")) {
@@ -654,15 +663,382 @@ public class XmppConnection implements Runnable {
         }
     }
 
+    private void processChallenge(final Element challenge) throws IOException {
+        final SaslMechanism.Version version;
+        try {
+            version = SaslMechanism.Version.of(challenge);
+        } catch (final IllegalArgumentException e) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        final Element response;
+        if (version == SaslMechanism.Version.SASL) {
+            response = new Element("response", Namespace.SASL);
+        } else if (version == SaslMechanism.Version.SASL_2) {
+            response = new Element("response", Namespace.SASL_2);
+        } else {
+            throw new AssertionError("Missing implementation for " + version);
+        }
+        try {
+            response.setContent(saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket)));
+        } catch (final SaslMechanism.AuthenticationException e) {
+            // TODO: Send auth abort tag.
+            Log.e(Config.LOGTAG, e.toString());
+            throw new StateChangingException(Account.State.UNAUTHORIZED);
+        }
+        tagWriter.writeElement(response);
+    }
+
+    private boolean processSuccess(final Element success)
+            throws IOException, XmlPullParserException {
+        final SaslMechanism.Version version;
+        try {
+            version = SaslMechanism.Version.of(success);
+        } catch (final IllegalArgumentException e) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        final SaslMechanism currentSaslMechanism = this.saslMechanism;
+        if (currentSaslMechanism == null) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        final String challenge;
+        if (version == SaslMechanism.Version.SASL) {
+            challenge = success.getContent();
+        } else if (version == SaslMechanism.Version.SASL_2) {
+            challenge = success.findChildContent("additional-data");
+        } else {
+            throw new AssertionError("Missing implementation for " + version);
+        }
+        try {
+            currentSaslMechanism.getResponse(challenge, sslSocketOrNull(socket));
+        } catch (final SaslMechanism.AuthenticationException e) {
+            Log.e(Config.LOGTAG, String.valueOf(e));
+            throw new StateChangingException(Account.State.UNAUTHORIZED);
+        }
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid().toString() + ": logged in (using " + version + ")");
+        if (SaslMechanism.pin(currentSaslMechanism)) {
+            account.setPinnedMechanism(currentSaslMechanism);
+        }
+        if (version == SaslMechanism.Version.SASL_2) {
+            final String authorizationIdentifier =
+                    success.findChildContent("authorization-identifier");
+            final Jid authorizationJid;
+            try {
+                authorizationJid =
+                        Strings.isNullOrEmpty(authorizationIdentifier)
+                                ? null
+                                : Jid.ofEscaped(authorizationIdentifier);
+            } catch (final IllegalArgumentException e) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": SASL 2.0 authorization identifier was not a valid jid");
+                throw new StateChangingException(Account.State.BIND_FAILURE);
+            }
+            if (authorizationJid == null) {
+                throw new StateChangingException(Account.State.BIND_FAILURE);
+            }
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": SASL 2.0 authorization identifier was "
+                            + authorizationJid);
+            if (!account.getJid().getDomain().equals(authorizationJid.getDomain())) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": server tried to re-assign domain to "
+                                + authorizationJid.getDomain());
+                throw new StateChangingError(Account.State.BIND_FAILURE);
+            }
+            if (authorizationJid.isFullJid() && account.setJid(authorizationJid)) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": jid changed during SASL 2.0. updating database");
+            }
+            final boolean nopStreamFeatures;
+            final Element bound = success.findChild("bound", Namespace.BIND2);
+            final Element resumed = success.findChild("resumed", "urn:xmpp:sm:3");
+            final Element failed = success.findChild("failed", "urn:xmpp:sm:3");
+            final Element tokenWrapper = success.findChild("token", Namespace.FAST);
+            final String token = tokenWrapper == null ? null : tokenWrapper.getAttribute("token");
+            if (bound != null && resumed != null) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": server sent bound and resumed in SASL2 success");
+                throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+            }
+            final boolean processNopStreamFeatures;
+            if (resumed != null && streamId != null) {
+                processResumed(resumed);
+            } else if (failed != null) {
+                processFailed(failed, false); // wait for new stream features
+            }
+            if (bound != null) {
+                clearIqCallbacks();
+                this.isBound = true;
+                final Element streamManagementEnabled =
+                        bound.findChild("enabled", Namespace.STREAM_MANAGEMENT);
+                final Element carbonsEnabled = bound.findChild("enabled", Namespace.CARBONS);
+                final boolean waitForDisco;
+                if (streamManagementEnabled != null) {
+                    processEnabled(streamManagementEnabled);
+                    waitForDisco = true;
+                } else {
+                    //if we did not enable stream management in bind do it now
+                    waitForDisco = enableStreamManagement();
+                }
+                if (carbonsEnabled != null) {
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid() + ": successfully enabled carbons");
+                    features.carbonsEnabled = true;
+                }
+                sendPostBindInitialization(waitForDisco, carbonsEnabled != null);
+                processNopStreamFeatures = true;
+            } else {
+                processNopStreamFeatures = false;
+            }
+            final HashedToken.Mechanism tokenMechanism;
+            if (SaslMechanism.hashedToken(currentSaslMechanism)) {
+                tokenMechanism = ((HashedToken) currentSaslMechanism).getTokenMechanism();
+            } else if (this.hashTokenRequest != null) {
+                tokenMechanism = this.hashTokenRequest;
+            } else {
+                tokenMechanism = null;
+            }
+            if (tokenMechanism != null && !Strings.isNullOrEmpty(token)) {
+                this.account.setFastToken(tokenMechanism,token);
+                Log.d(Config.LOGTAG,account.getJid().asBareJid()+": storing hashed token "+tokenMechanism);
+            }
+            // a successful resume will not send stream features
+            if (processNopStreamFeatures) {
+                processNopStreamFeatures();
+            }
+        }
+        mXmppConnectionService.databaseBackend.updateAccount(account);
+        this.quickStartInProgress = false;
+        if (version == SaslMechanism.Version.SASL) {
+            tagReader.reset();
+            sendStartStream(false, true);
+            final Tag tag = tagReader.readTag();
+            if (tag != null && tag.isStart("stream", Namespace.STREAMS)) {
+                processStream();
+                return true;
+            } else {
+                throw new StateChangingException(Account.State.STREAM_OPENING_ERROR);
+            }
+        } else {
+            return false;
+        }
+    }
+
+    private void processNopStreamFeatures() throws IOException {
+        final Tag tag = tagReader.readTag();
+        if (tag != null && tag.isStart("features", Namespace.STREAMS)) {
+            this.streamFeatures = tagReader.readElement(tag);
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": processed NOP stream features after success: "
+                            + XmlHelper.printElementNames(this.streamFeatures));
+        } else {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received " + tag);
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": server did not send stream features after SASL2 success");
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+    }
+
+    private void processFailure(final Element failure) throws IOException {
+        final SaslMechanism.Version version;
+        try {
+            version = SaslMechanism.Version.of(failure);
+        } catch (final IllegalArgumentException e) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        Log.d(Config.LOGTAG, failure.toString());
+        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version);
+        if (SaslMechanism.hashedToken(this.saslMechanism)) {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token");
+            account.resetFastToken();
+            mXmppConnectionService.databaseBackend.updateAccount(account);
+        }
+        if (failure.hasChild("temporary-auth-failure")) {
+            throw new StateChangingException(Account.State.TEMPORARY_AUTH_FAILURE);
+        } else if (failure.hasChild("account-disabled")) {
+            final String text = failure.findChildContent("text");
+            if (Strings.isNullOrEmpty(text)) {
+                throw new StateChangingException(Account.State.UNAUTHORIZED);
+            }
+            final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(text);
+            if (matcher.find()) {
+                final HttpUrl url;
+                try {
+                    url = HttpUrl.get(text.substring(matcher.start(), matcher.end()));
+                } catch (final IllegalArgumentException e) {
+                    throw new StateChangingException(Account.State.UNAUTHORIZED);
+                }
+                if (url.isHttps()) {
+                    this.redirectionUrl = url;
+                    throw new StateChangingException(Account.State.PAYMENT_REQUIRED);
+                }
+            }
+        }
+        if (SaslMechanism.hashedToken(this.saslMechanism)) {
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": fast authentication failed. falling back to regular authentication");
+            authenticate();
+        } else {
+            throw new StateChangingException(Account.State.UNAUTHORIZED);
+        }
+    }
+
+    private static SSLSocket sslSocketOrNull(final Socket socket) {
+        if (socket instanceof SSLSocket) {
+            return (SSLSocket) socket;
+        } else {
+            return null;
+        }
+    }
+
+    private void processEnabled(final Element enabled) {
+        final String streamId;
+        if (enabled.getAttributeAsBoolean("resume")) {
+            streamId = enabled.getAttribute("id");
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid().toString()
+                            + ": stream management enabled (resumable)");
+        } else {
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid().toString() + ": stream management enabled");
+            streamId = null;
+        }
+        this.streamId = streamId;
+        this.stanzasReceived = 0;
+        this.inSmacksSession = true;
+        final RequestPacket r = new RequestPacket();
+        tagWriter.writeStanzaAsync(r);
+    }
+
+    private void processResumed(final Element resumed) throws StateChangingException {
+        this.inSmacksSession = true;
+        this.isBound = true;
+        this.tagWriter.writeStanzaAsync(new RequestPacket());
+        lastPacketReceived = SystemClock.elapsedRealtime();
+        final String h = resumed.getAttribute("h");
+        if (h == null) {
+            resetStreamId();
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        final int serverCount;
+        try {
+            serverCount = Integer.parseInt(h);
+        } catch (final NumberFormatException e) {
+            resetStreamId();
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+        final ArrayList<AbstractAcknowledgeableStanza> failedStanzas = new ArrayList<>();
+        final boolean acknowledgedMessages;
+        synchronized (this.mStanzaQueue) {
+            if (serverCount < stanzasSent) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid() + ": session resumed with lost packages");
+                stanzasSent = serverCount;
+            } else {
+                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": session resumed");
+            }
+            acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
+            for (int i = 0; i < this.mStanzaQueue.size(); ++i) {
+                failedStanzas.add(mStanzaQueue.valueAt(i));
+            }
+            mStanzaQueue.clear();
+        }
+        if (acknowledgedMessages) {
+            mXmppConnectionService.updateConversationUi();
+        }
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid() + ": resending " + failedStanzas.size() + " stanzas");
+        for (final AbstractAcknowledgeableStanza packet : failedStanzas) {
+            if (packet instanceof MessagePacket) {
+                MessagePacket message = (MessagePacket) packet;
+                mXmppConnectionService.markMessage(
+                        account,
+                        message.getTo().asBareJid(),
+                        message.getId(),
+                        Message.STATUS_UNSEND);
+            }
+            sendPacket(packet);
+        }
+        changeStatusToOnline();
+    }
+
+    private void changeStatusToOnline() {
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid() + ": online with resource " + account.getResource());
+        changeStatus(Account.State.ONLINE);
+    }
+
+    private void processFailed(final Element failed, final boolean sendBindRequest) {
+        final int serverCount;
+        try {
+            serverCount = Integer.parseInt(failed.getAttribute("h"));
+        } catch (final NumberFormatException | NullPointerException e) {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed");
+            resetStreamId();
+            if (sendBindRequest) {
+                sendBindRequest();
+            }
+            return;
+        }
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid()
+                        + ": resumption failed but server acknowledged stanza #"
+                        + serverCount);
+        final boolean acknowledgedMessages;
+        synchronized (this.mStanzaQueue) {
+            acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
+        }
+        if (acknowledgedMessages) {
+            mXmppConnectionService.updateConversationUi();
+        }
+        resetStreamId();
+        if (sendBindRequest) {
+            sendBindRequest();
+        }
+    }
+
     private boolean acknowledgeStanzaUpTo(int serverCount) {
         if (serverCount > stanzasSent) {
-            Log.e(Config.LOGTAG, "server acknowledged more stanzas than we sent. serverCount=" + serverCount + ", ourCount=" + stanzasSent);
+            Log.e(
+                    Config.LOGTAG,
+                    "server acknowledged more stanzas than we sent. serverCount="
+                            + serverCount
+                            + ", ourCount="
+                            + stanzasSent);
         }
         boolean acknowledgedMessages = false;
         for (int i = 0; i < mStanzaQueue.size(); ++i) {
             if (serverCount >= mStanzaQueue.keyAt(i)) {
                 if (Config.EXTENDED_SM_LOGGING) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server acknowledged stanza #" + mStanzaQueue.keyAt(i));
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": server acknowledged stanza #"
+                                    + mStanzaQueue.keyAt(i));
                 }
                 final AbstractAcknowledgeableStanza stanza = mStanzaQueue.valueAt(i);
                 if (stanza instanceof MessagePacket && acknowledgedListener != null) {
  
  
  
    
    @@ -0,0 +1,33 @@
+package eu.siacs.conversations.xmpp.bind;
+
+import com.google.common.collect.Collections2;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+
+public class Bind2 {
+
+    public static final Collection<String> QUICKSTART_FEATURES = Arrays.asList(
+            Namespace.CARBONS,
+            Namespace.STREAM_MANAGEMENT
+    );
+
+    public static Collection<String> features(final Element inline) {
+        final Element inlineBind2 =
+                inline != null ? inline.findChild("bind", Namespace.BIND2) : null;
+        final Element inlineBind2Inline =
+                inlineBind2 != null ? inlineBind2.findChild("inline", Namespace.BIND2) : null;
+        if (inlineBind2 == null) {
+            return null;
+        }
+        if (inlineBind2Inline == null) {
+            return Collections.emptyList();
+        }
+        return Collections2.transform(
+                inlineBind2Inline.getChildren(), c -> c == null ? null : c.getAttribute("var"));
+    }
+}
  
  
  
    
    @@ -0,0 +1,88 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+
+public final class ContentAddition {
+
+    public final Direction direction;
+    public final Set<Summary> summary;
+
+    private ContentAddition(Direction direction, Set<Summary> summary) {
+        this.direction = direction;
+        this.summary = summary;
+    }
+
+    public Set<Media> media() {
+        return ImmutableSet.copyOf(Collections2.transform(summary, s -> s.media));
+    }
+
+    public static ContentAddition of(final Direction direction, final RtpContentMap rtpContentMap) {
+        return new ContentAddition(direction, summary(rtpContentMap));
+    }
+
+    public static Set<Summary> summary(final RtpContentMap rtpContentMap) {
+        return ImmutableSet.copyOf(
+                Collections2.transform(
+                        rtpContentMap.contents.entrySet(),
+                        e -> {
+                            final RtpContentMap.DescriptionTransport dt = e.getValue();
+                            return new Summary(e.getKey(), dt.description.getMedia(), dt.senders);
+                        }));
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("direction", direction)
+                .add("summary", summary)
+                .toString();
+    }
+
+    public enum Direction {
+        OUTGOING,
+        INCOMING
+    }
+
+    public static final class Summary {
+        public final String name;
+        public final Media media;
+        public final Content.Senders senders;
+
+        private Summary(final String name, final Media media, final Content.Senders senders) {
+            this.name = name;
+            this.media = media;
+            this.senders = senders;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Summary summary = (Summary) o;
+            return Objects.equal(name, summary.name)
+                    && media == summary.media
+                    && senders == summary.senders;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(name, media, senders);
+        }
+
+        @Override
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("name", name)
+                    .add("media", media)
+                    .add("senders", senders)
+                    .toString();
+        }
+    }
+}
  
  
  
    
    @@ -594,8 +594,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
     private void sendInitRequest() {
         final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE);
-        final Content content = new Content(this.contentCreator, this.contentName);
-        content.setSenders(this.contentSenders);
+        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);
@@ -672,8 +671,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
         gatherAndConnectDirectCandidates();
         this.jingleConnectionManager.getPrimaryCandidate(this.id.account, isInitiator(), (success, candidate) -> {
             final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
-            final Content content = new Content(contentCreator, contentName);
-            content.setSenders(this.contentSenders);
+            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);
@@ -712,8 +710,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
     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, contentName);
-        content.setSenders(this.contentSenders);
+        final Content content = new Content(contentCreator, contentSenders, contentName);
         content.setDescription(this.description);
         content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
         packet.addJingleContent(content);
@@ -926,8 +923,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
     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.contentName);
-        content.setSenders(this.contentSenders);
+        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);
@@ -960,8 +956,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
         final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT);
 
-        final Content content = new Content(contentCreator, contentName);
-        content.setSenders(this.contentSenders);
+        final Content content = new Content(contentCreator, contentSenders, contentName);
         content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
         answer.addJingleContent(content);
 
@@ -1140,8 +1135,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
     private void sendProxyActivated(String cid) {
         final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentName);
-        content.setSenders(this.contentSenders);
+        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);
@@ -1149,8 +1143,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
     private void sendProxyError() {
         final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentName);
-        content.setSenders(this.contentSenders);
+        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);
@@ -1158,8 +1151,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
     private void sendCandidateUsed(final String cid) {
         JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
-        final Content content = new Content(this.contentCreator, this.contentName);
-        content.setSenders(this.contentSenders);
+        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;
@@ -1172,8 +1164,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
     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.contentName);
-        content.setSenders(this.contentSenders);
+        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;
  
  
  
    
    @@ -6,6 +6,7 @@ import android.os.Environment;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Stopwatch;
@@ -14,6 +15,7 @@ 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.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.FutureCallback;
@@ -41,6 +43,7 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
+import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
@@ -55,6 +58,7 @@ import eu.siacs.conversations.utils.IP;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
@@ -165,6 +169,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private Set<Media> proposedMedia;
     private RtpContentMap initiatorRtpContentMap;
     private RtpContentMap responderRtpContentMap;
+    private RtpContentMap incomingContentAdd;
+    private RtpContentMap outgoingContentAdd;
     private IceUdpTransportInfo.Setup peerDtlsSetup;
     private final Stopwatch sessionDuration = Stopwatch.createUnstarted();
     private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
@@ -221,6 +227,18 @@ public class JingleRtpConnection extends AbstractJingleConnection
             case SESSION_TERMINATE:
                 receiveSessionTerminate(jinglePacket);
                 break;
+            case CONTENT_ADD:
+                receiveContentAdd(jinglePacket);
+                break;
+            case CONTENT_ACCEPT:
+                receiveContentAccept(jinglePacket);
+                break;
+            case CONTENT_REJECT:
+                receiveContentReject(jinglePacket);
+                break;
+            case CONTENT_REMOVE:
+                receiveContentRemove(jinglePacket);
+                break;
             default:
                 respondOk(jinglePacket);
                 Log.d(
@@ -353,6 +371,405 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
+    private void receiveContentAdd(final JinglePacket jinglePacket) {
+        final RtpContentMap modification;
+        try {
+            modification = RtpContentMap.of(jinglePacket);
+            modification.requireContentDescriptions();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+        if (isInState(State.SESSION_ACCEPTED)) {
+            receiveContentAdd(jinglePacket, modification);
+        } else {
+            terminateWithOutOfOrder(jinglePacket);
+        }
+    }
+
+    private void receiveContentAdd(
+            final JinglePacket jinglePacket, final RtpContentMap modification) {
+        final RtpContentMap remote = getRemoteContentMap();
+        if (!Collections.disjoint(modification.getNames(), remote.getNames())) {
+            respondOk(jinglePacket);
+            this.webRTCWrapper.close();
+            sendSessionTerminate(
+                    Reason.FAILED_APPLICATION,
+                    String.format(
+                            "contents with names %s already exists",
+                            Joiner.on(", ").join(modification.getNames())));
+            return;
+        }
+        final ContentAddition contentAddition =
+                ContentAddition.of(ContentAddition.Direction.INCOMING, modification);
+
+        final RtpContentMap outgoing = this.outgoingContentAdd;
+        final Set<ContentAddition.Summary> outgoingContentAddSummary =
+                outgoing == null ? Collections.emptySet() : ContentAddition.summary(outgoing);
+
+        if (outgoingContentAddSummary.equals(contentAddition.summary)) {
+            if (isInitiator()) {
+                Log.d(
+                        Config.LOGTAG,
+                        id.getAccount().getJid().asBareJid()
+                                + ": respond with tie break to matching content-add offer");
+                respondWithTieBreak(jinglePacket);
+            } else {
+                Log.d(
+                        Config.LOGTAG,
+                        id.getAccount().getJid().asBareJid()
+                                + ": automatically accept matching content-add offer");
+                acceptContentAdd(contentAddition.summary, modification);
+            }
+            return;
+        }
+
+        // once we can display multiple video tracks we can be more loose with this condition
+        // theoretically it should also be fine to automatically accept audio only contents
+        if (Media.audioOnly(remote.getMedia()) && Media.videoOnly(contentAddition.media())) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid() + ": received " + contentAddition);
+            this.incomingContentAdd = modification;
+            respondOk(jinglePacket);
+            updateEndUserState();
+        } else {
+            respondOk(jinglePacket);
+            // TODO do we want to add a reason?
+            rejectContentAdd(modification);
+        }
+    }
+
+    private void receiveContentAccept(final JinglePacket jinglePacket) {
+        final RtpContentMap receivedContentAccept;
+        try {
+            receivedContentAccept = RtpContentMap.of(jinglePacket);
+            receivedContentAccept.requireContentDescriptions();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+
+        final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+        if (outgoingContentAdd == null) {
+            Log.d(Config.LOGTAG, "received content-accept when we had no outgoing content add");
+            terminateWithOutOfOrder(jinglePacket);
+            return;
+        }
+        final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
+        if (ourSummary.equals(ContentAddition.summary(receivedContentAccept))) {
+            this.outgoingContentAdd = null;
+            respondOk(jinglePacket);
+            receiveContentAccept(receivedContentAccept);
+        } else {
+            Log.d(Config.LOGTAG, "received content-accept did not match our outgoing content-add");
+            terminateWithOutOfOrder(jinglePacket);
+        }
+    }
+
+    private void receiveContentAccept(final RtpContentMap receivedContentAccept) {
+        final IceUdpTransportInfo.Setup peerDtlsSetup = getPeerDtlsSetup();
+        final RtpContentMap modifiedContentMap =
+                getRemoteContentMap().addContent(receivedContentAccept, peerDtlsSetup);
+
+        setRemoteContentMap(modifiedContentMap);
+
+        final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator());
+
+        final org.webrtc.SessionDescription sdp =
+                new org.webrtc.SessionDescription(
+                        org.webrtc.SessionDescription.Type.ANSWER, answer.toString());
+
+        try {
+            this.webRTCWrapper.setRemoteDescription(sdp).get();
+        } catch (final Exception e) {
+            final Throwable cause = Throwables.getRootCause(e);
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid()
+                            + ": unable to set remote description after receiving content-accept",
+                    cause);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+            return;
+        }
+        updateEndUserState();
+        Log.d(
+                Config.LOGTAG,
+                id.getAccount().getJid().asBareJid()
+                        + ": remote has accepted content-add "
+                        + ContentAddition.summary(receivedContentAccept));
+    }
+
+    private void receiveContentReject(final JinglePacket jinglePacket) {
+        final RtpContentMap receivedContentReject;
+        try {
+            receivedContentReject = RtpContentMap.of(jinglePacket);
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            this.webRTCWrapper.close();
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+
+        final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+        if (outgoingContentAdd == null) {
+            Log.d(Config.LOGTAG, "received content-reject when we had no outgoing content add");
+            terminateWithOutOfOrder(jinglePacket);
+            return;
+        }
+        final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
+        if (ourSummary.equals(ContentAddition.summary(receivedContentReject))) {
+            this.outgoingContentAdd = null;
+            respondOk(jinglePacket);
+            Log.d(Config.LOGTAG,jinglePacket.toString());
+            receiveContentReject(ourSummary);
+        } else {
+            Log.d(Config.LOGTAG, "received content-reject did not match our outgoing content-add");
+            terminateWithOutOfOrder(jinglePacket);
+        }
+    }
+
+    private void receiveContentReject(final Set<ContentAddition.Summary> summary) {
+        try {
+            this.webRTCWrapper.removeTrack(Media.VIDEO);
+            final RtpContentMap localContentMap = customRollback();
+            modifyLocalContentMap(localContentMap);
+        } catch (final Exception e) {
+            final Throwable cause = Throwables.getRootCause(e);
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid()
+                            + ": unable to rollback local description after receiving content-reject",
+                    cause);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+            return;
+        }
+        Log.d(
+                Config.LOGTAG,
+                id.getAccount().getJid().asBareJid()
+                        + ": remote has rejected our content-add "
+                        + summary);
+    }
+
+    private void receiveContentRemove(final JinglePacket jinglePacket) {
+        final RtpContentMap receivedContentRemove;
+        try {
+            receivedContentRemove = RtpContentMap.of(jinglePacket);
+            receivedContentRemove.requireContentDescriptions();
+        } catch (final RuntimeException e) {
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+                    Throwables.getRootCause(e));
+            respondOk(jinglePacket);
+            this.webRTCWrapper.close();
+            sendSessionTerminate(Reason.of(e), e.getMessage());
+            return;
+        }
+        respondOk(jinglePacket);
+        receiveContentRemove(receivedContentRemove);
+    }
+
+    private void receiveContentRemove(final RtpContentMap receivedContentRemove) {
+        final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+        final Set<ContentAddition.Summary> contentAddSummary =
+                incomingContentAdd == null
+                        ? Collections.emptySet()
+                        : ContentAddition.summary(incomingContentAdd);
+        final Set<ContentAddition.Summary> removeSummary =
+                ContentAddition.summary(receivedContentRemove);
+        if (contentAddSummary.equals(removeSummary)) {
+            this.incomingContentAdd = null;
+            updateEndUserState();
+        } else {
+            webRTCWrapper.close();
+            sendSessionTerminate(
+                    Reason.FAILED_APPLICATION,
+                    String.format(
+                            "%s only supports %s as a means to retract a not yet accepted %s",
+                            BuildConfig.APP_NAME,
+                            JinglePacket.Action.CONTENT_REMOVE,
+                            JinglePacket.Action.CONTENT_ACCEPT));
+        }
+    }
+
+    public synchronized void retractContentAdd() {
+        final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+        if (outgoingContentAdd == null) {
+            throw new IllegalStateException("Not outgoing content add");
+        }
+        try {
+            webRTCWrapper.removeTrack(Media.VIDEO);
+            final RtpContentMap localContentMap = customRollback();
+            modifyLocalContentMap(localContentMap);
+        } catch (final Exception e) {
+            final Throwable cause = Throwables.getRootCause(e);
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid()
+                            + ": unable to rollback local description after trying to retract content-add",
+                    cause);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+            return;
+        }
+        this.outgoingContentAdd = null;
+        final JinglePacket retract =
+                outgoingContentAdd
+                        .toStub()
+                        .toJinglePacket(JinglePacket.Action.CONTENT_REMOVE, id.sessionId);
+        this.send(retract);
+        Log.d(
+                Config.LOGTAG,
+                id.getAccount().getJid()
+                        + ": retract content-add "
+                        + ContentAddition.summary(outgoingContentAdd));
+    }
+
+    private RtpContentMap customRollback() throws ExecutionException, InterruptedException {
+        final SessionDescription sdp = setLocalSessionDescription();
+        final RtpContentMap localRtpContentMap = RtpContentMap.of(sdp, isInitiator());
+        final SessionDescription answer = generateFakeResponse(localRtpContentMap);
+        this.webRTCWrapper
+                .setRemoteDescription(
+                        new org.webrtc.SessionDescription(
+                                org.webrtc.SessionDescription.Type.ANSWER, answer.toString()))
+                .get();
+        return localRtpContentMap;
+    }
+
+    private SessionDescription generateFakeResponse(final RtpContentMap localContentMap) {
+        final RtpContentMap currentRemote = getRemoteContentMap();
+        final RtpContentMap.Diff diff = currentRemote.diff(localContentMap);
+        if (diff.isEmpty()) {
+            throw new IllegalStateException(
+                    "Unexpected rollback condition. No difference between local and remote");
+        }
+        final RtpContentMap patch = localContentMap.toContentModification(diff.added);
+        if (ImmutableSet.of(Content.Senders.NONE).equals(patch.getSenders())) {
+            final RtpContentMap nextRemote =
+                    currentRemote.addContent(
+                            patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup());
+            return SessionDescription.of(nextRemote, !isInitiator());
+        }
+        throw new IllegalStateException(
+                "Unexpected rollback condition. Senders were not uniformly none");
+    }
+
+    public synchronized void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition) {
+        final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+        if (incomingContentAdd == null) {
+            throw new IllegalStateException("No incoming content add");
+        }
+
+        if (contentAddition.equals(ContentAddition.summary(incomingContentAdd))) {
+            this.incomingContentAdd = null;
+            acceptContentAdd(contentAddition, incomingContentAdd);
+        } else {
+            throw new IllegalStateException("Accepted content add does not match pending content-add");
+        }
+    }
+
+    private void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition, final RtpContentMap incomingContentAdd) {
+        final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
+        final RtpContentMap modifiedContentMap = getRemoteContentMap().addContent(incomingContentAdd, setup);
+        this.setRemoteContentMap(modifiedContentMap);
+
+        final SessionDescription offer;
+        try {
+            offer = SessionDescription.of(modifiedContentMap, !isInitiator());
+        } catch (final IllegalArgumentException | NullPointerException e) {
+            Log.d(Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": unable convert offer from content-add to SDP", e);
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
+            return;
+        }
+        this.incomingContentAdd = null;
+        acceptContentAdd(contentAddition, offer);
+    }
+
+    private void acceptContentAdd(
+            final Set<ContentAddition.Summary> contentAddition, final SessionDescription offer) {
+        final org.webrtc.SessionDescription sdp =
+                new org.webrtc.SessionDescription(
+                        org.webrtc.SessionDescription.Type.OFFER, offer.toString());
+        try {
+            this.webRTCWrapper.setRemoteDescription(sdp).get();
+
+            // TODO add tracks for 'media' where contentAddition.senders matches
+
+            // TODO if senders.sending(isInitiator())
+
+            this.webRTCWrapper.addTrack(Media.VIDEO);
+
+            // TODO add additional transceivers for recv only cases
+
+            final SessionDescription answer = setLocalSessionDescription();
+            final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
+
+            final RtpContentMap contentAcceptMap =
+                    rtpContentMap.toContentModification(
+                            Collections2.transform(contentAddition, ca -> ca.name));
+            Log.d(
+                    Config.LOGTAG,
+                    id.getAccount().getJid().asBareJid()
+                            + ": sending content-accept "
+                            + ContentAddition.summary(contentAcceptMap));
+            modifyLocalContentMap(rtpContentMap);
+            sendContentAccept(contentAcceptMap);
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
+            webRTCWrapper.close();
+            sendSessionTerminate(Reason.FAILED_APPLICATION);
+        }
+    }
+
+    private void sendContentAccept(final RtpContentMap contentAcceptMap) {
+        final JinglePacket jinglePacket = contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId);
+        send(jinglePacket);
+    }
+
+    public synchronized void rejectContentAdd() {
+        final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+        if (incomingContentAdd == null) {
+            throw new IllegalStateException("No incoming content add");
+        }
+        this.incomingContentAdd = null;
+        updateEndUserState();
+        rejectContentAdd(incomingContentAdd);
+    }
+
+    private void rejectContentAdd(final RtpContentMap contentMap) {
+        final JinglePacket jinglePacket =
+                contentMap
+                        .toStub()
+                        .toJinglePacket(JinglePacket.Action.CONTENT_REJECT, id.sessionId);
+        Log.d(
+                Config.LOGTAG,
+                id.getAccount().getJid().asBareJid()
+                        + ": rejecting content "
+                        + ContentAddition.summary(contentMap));
+        send(jinglePacket);
+    }
+
     private boolean checkForIceRestart(
             final JinglePacket jinglePacket, final RtpContentMap rtpContentMap) {
         final RtpContentMap existing = getRemoteContentMap();
@@ -434,7 +851,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             final RtpContentMap restartContentMap,
             final boolean isOffer)
             throws ExecutionException, InterruptedException {
-        final SessionDescription sessionDescription = SessionDescription.of(restartContentMap);
+        final SessionDescription sessionDescription = SessionDescription.of(restartContentMap, !isInitiator());
         final org.webrtc.SessionDescription.Type type =
                 isOffer
                         ? org.webrtc.SessionDescription.Type.OFFER
@@ -453,7 +870,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
         if (isOffer) {
             webRTCWrapper.setIsReadyToReceiveIceCandidates(false);
             final SessionDescription localSessionDescription = setLocalSessionDescription();
-            setLocalContentMap(RtpContentMap.of(localSessionDescription));
+            setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator()));
             // We need to respond OK before sending any candidates
             respondOk(jinglePacket);
             webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
@@ -508,6 +925,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
         return isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
     }
 
+    private RtpContentMap getLocalContentMap() {
+        return isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
+    }
+
     private List<String> toIdentificationTags(final RtpContentMap rtpContentMap) {
         final Group originalGroup = rtpContentMap.group;
         final List<String> identificationTags =
@@ -735,7 +1156,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
         this.storePeerDtlsSetup(contentMap.getDtlsSetup());
         final SessionDescription sessionDescription;
         try {
-            sessionDescription = SessionDescription.of(contentMap);
+            sessionDescription = SessionDescription.of(contentMap, false);
         } catch (final IllegalArgumentException | NullPointerException e) {
             Log.d(
                     Config.LOGTAG,
@@ -772,7 +1193,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
         final SessionDescription offer;
         try {
-            offer = SessionDescription.of(rtpContentMap);
+            offer = SessionDescription.of(rtpContentMap, true);
         } catch (final IllegalArgumentException | NullPointerException e) {
             Log.d(
                     Config.LOGTAG,
@@ -847,10 +1268,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
             final org.webrtc.SessionDescription webRTCSessionDescription) {
         final SessionDescription sessionDescription =
                 SessionDescription.parse(webRTCSessionDescription.description);
-        final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
+        final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false);
         this.responderRtpContentMap = respondingRtpContentMap;
         storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip());
-        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
         final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
                 prepareOutgoingContentMap(respondingRtpContentMap);
         Futures.addCallback(
@@ -859,6 +1279,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                     @Override
                     public void onSuccess(final RtpContentMap outgoingContentMap) {
                         sendSessionAccept(outgoingContentMap);
+                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                     }
 
                     @Override
@@ -955,16 +1376,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                 from.asBareJid().equals(id.account.getJid().asBareJid());
         if (originatedFromMyself) {
             if (transition(State.ACCEPTED)) {
-                if (serverMsgId != null) {
-                    this.message.setServerMsgId(serverMsgId);
-                }
-                this.message.setTime(timestamp);
-                this.message.setCarbon(true); // indicate that call was accepted on other device
-                this.writeLogMessageSuccess(0);
-                this.xmppConnectionService
-                        .getNotificationService()
-                        .cancelIncomingCallNotification();
-                this.finish();
+                acceptedOnOtherDevice(serverMsgId, timestamp);
             } else {
                 Log.d(
                         Config.LOGTAG,
@@ -979,6 +1391,19 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
+    private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
+        if (serverMsgId != null) {
+            this.message.setServerMsgId(serverMsgId);
+        }
+        this.message.setTime(timestamp);
+        this.message.setCarbon(true); // indicate that call was accepted on other device
+        this.writeLogMessageSuccess(0);
+        this.xmppConnectionService
+                .getNotificationService()
+                .cancelIncomingCallNotification();
+        this.finish();
+    }
+
     private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
         final boolean originatedFromMyself =
                 from.asBareJid().equals(id.account.getJid().asBareJid());
@@ -1176,11 +1601,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
                         id.account.getJid().asBareJid()
                                 + ": moved session with "
                                 + id.with
-                                + " into state accepted after received carbon copied procced");
-                this.xmppConnectionService
-                        .getNotificationService()
-                        .cancelIncomingCallNotification();
-                this.finish();
+                                + " into state accepted after received carbon copied proceed");
+                acceptedOnOtherDevice(serverMsgId, timestamp);
             }
         } else {
             Log.d(
@@ -1298,9 +1720,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
             final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) {
         final SessionDescription sessionDescription =
                 SessionDescription.parse(webRTCSessionDescription.description);
-        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
+        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
         this.initiatorRtpContentMap = rtpContentMap;
-        this.webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
         final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
                 encryptSessionInitiate(rtpContentMap);
         Futures.addCallback(
@@ -1309,6 +1730,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                     @Override
                     public void onSuccess(final RtpContentMap outgoingContentMap) {
                         sendSessionInitiate(outgoingContentMap, targetState);
+                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
                     }
 
                     @Override
@@ -1535,6 +1957,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
                     return RtpEndUserState.CONNECTING;
                 }
             case SESSION_ACCEPTED:
+                final ContentAddition ca = getPendingContentAddition();
+                if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) {
+                    return RtpEndUserState.INCOMING_CONTENT_ADD;
+                }
                 return getPeerConnectionStateAsEndUserState();
             case REJECTED:
             case REJECTED_RACED:
@@ -1592,6 +2018,18 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
+    public ContentAddition getPendingContentAddition() {
+        final RtpContentMap in = this.incomingContentAdd;
+        final RtpContentMap out = this.outgoingContentAdd;
+        if (out != null) {
+            return ContentAddition.of(ContentAddition.Direction.OUTGOING, out);
+        } else if (in != null) {
+            return ContentAddition.of(ContentAddition.Direction.INCOMING, in);
+        } else {
+            return null;
+        }
+    }
+
     public Set<Media> getMedia() {
         final State current = getState();
         if (current == State.NULL) {
@@ -1605,14 +2043,16 @@ public class JingleRtpConnection extends AbstractJingleConnection
             return Preconditions.checkNotNull(
                     this.proposedMedia, "RTP connection has not been initialized properly");
         }
+        final RtpContentMap localContentMap = getLocalContentMap();
         final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
-        if (initiatorContentMap != null) {
+        if (localContentMap != null) {
+            return localContentMap.getMedia();
+        } else if (initiatorContentMap != null) {
             return initiatorContentMap.getMedia();
         } else if (isTerminated()) {
-            return Collections.emptySet(); // we might fail before we ever got a chance to set media
+            return Collections.emptySet(); //we might fail before we ever got a chance to set media
         } else {
-            return Preconditions.checkNotNull(
-                    this.proposedMedia, "RTP connection has not been initialized properly");
+            return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
         }
     }
 
@@ -1626,6 +2066,16 @@ public class JingleRtpConnection extends AbstractJingleConnection
         return status != null && status.isVerified();
     }
 
+    public boolean addMedia(final Media media) {
+        final Set<Media> currentMedia = getMedia();
+        if (currentMedia.contains(media)) {
+            throw new IllegalStateException(String.format("%s has already been proposed", media));
+        }
+        // TODO add state protection - can only add while ACCEPTED or so
+        Log.d(Config.LOGTAG,"adding media: "+media);
+        return webRTCWrapper.addTrack(media);
+    }
+
     public synchronized void acceptCall() {
         switch (this.state) {
             case PROPOSED:
@@ -1744,17 +2194,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
         finish();
     }
 
-    private void setupWebRTC(
-            final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
-            throws WebRTCWrapper.InitializationException {
+    private void setupWebRTC(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
         this.jingleConnectionManager.ensureConnectionIsRegistered(this);
-        final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference;
-        if (media.contains(Media.VIDEO)) {
-            speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.SPEAKER;
-        } else {
-            speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.EARPIECE;
-        }
-        this.webRTCWrapper.setup(this.xmppConnectionService, speakerPhonePreference);
+        this.webRTCWrapper.setup(this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media));
         this.webRTCWrapper.initializePeerConnection(media, iceServers);
     }
 
@@ -1906,31 +2348,71 @@ public class JingleRtpConnection extends AbstractJingleConnection
                 webRTCWrapper.execute(this::closeWebRTCSessionAfterFailedConnection);
                 return;
             } else {
-                webRTCWrapper.restartIce();
+                this.restartIce();
             }
         }
         updateEndUserState();
     }
 
+    private void restartIce() {
+        this.stateHistory.clear();
+        this.webRTCWrapper.restartIce();
+    }
+
     @Override
     public void onRenegotiationNeeded() {
-        this.webRTCWrapper.execute(this::initiateIceRestart);
+        this.webRTCWrapper.execute(this::renegotiate);
     }
 
-    private void initiateIceRestart() {
-        // TODO discover new TURN/STUN credentials
-        this.stateHistory.clear();
-        this.webRTCWrapper.setIsReadyToReceiveIceCandidates(false);
+    private void renegotiate() {
         final SessionDescription sessionDescription;
         try {
             sessionDescription = setLocalSessionDescription();
         } catch (final Exception e) {
             final Throwable cause = Throwables.getRootCause(e);
             Log.d(Config.LOGTAG, "failed to renegotiate", cause);
+            webRTCWrapper.close();
             sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
             return;
         }
-        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
+        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, isInitiator());
+        final RtpContentMap currentContentMap = getLocalContentMap();
+        final boolean iceRestart = currentContentMap.iceRestart(rtpContentMap);
+        final RtpContentMap.Diff diff = currentContentMap.diff(rtpContentMap);
+
+        Log.d(
+                Config.LOGTAG,
+                id.getAccount().getJid().asBareJid()
+                        + ": renegotiate. iceRestart="
+                        + iceRestart
+                        + " content id diff="
+                        + diff);
+
+        if (diff.hasModifications() && iceRestart) {
+            webRTCWrapper.close();
+            sendSessionTerminate(
+                    Reason.FAILED_APPLICATION,
+                    "WebRTC unexpectedly tried to modify content and transport at once");
+            return;
+        }
+
+        if (iceRestart) {
+            initiateIceRestart(rtpContentMap);
+            return;
+        } else if (diff.isEmpty()) {
+            Log.d(
+                    Config.LOGTAG,
+                    "renegotiation. nothing to do. SignalingState="
+                            + this.webRTCWrapper.getSignalingState());
+        }
+
+        if (diff.added.size() > 0) {
+            modifyLocalContentMap(rtpContentMap);
+            sendContentAdd(rtpContentMap, diff.added);
+        }
+    }
+
+    private void initiateIceRestart(final RtpContentMap rtpContentMap) {
         final RtpContentMap transportInfo = rtpContentMap.transportInfo();
         final JinglePacket jinglePacket =
                 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
@@ -1947,8 +2429,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
                         return;
                     }
                     if (response.getType() == IqPacket.TYPE.ERROR) {
-                        final Element error = response.findChild("error");
-                        if (error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS)) {
+                        if (isTieBreak(response)) {
                             Log.d(Config.LOGTAG, "received tie-break as result of ice restart");
                             return;
                         }
@@ -1960,6 +2441,42 @@ public class JingleRtpConnection extends AbstractJingleConnection
                 });
     }
 
+    private boolean isTieBreak(final IqPacket response) {
+        final Element error = response.findChild("error");
+        return error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS);
+    }
+
+    private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection<String> added) {
+        final RtpContentMap contentAdd = rtpContentMap.toContentModification(added);
+        this.outgoingContentAdd = contentAdd;
+        final JinglePacket jinglePacket =
+                contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId);
+        jinglePacket.setTo(id.with);
+        xmppConnectionService.sendIqPacket(
+                id.account,
+                jinglePacket,
+                (connection, response) -> {
+                    if (response.getType() == IqPacket.TYPE.RESULT) {
+                        Log.d(
+                                Config.LOGTAG,
+                                id.getAccount().getJid().asBareJid()
+                                        + ": received ACK to our content-add");
+                        return;
+                    }
+                    if (response.getType() == IqPacket.TYPE.ERROR) {
+                        if (isTieBreak(response)) {
+                            this.outgoingContentAdd = null;
+                            Log.d(Config.LOGTAG, "received tie-break as result of our content-add");
+                            return;
+                        }
+                        handleIqErrorResponse(response);
+                    }
+                    if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+                        handleIqTimeoutResponse(response);
+                    }
+                });
+    }
+
     private void setLocalContentMap(final RtpContentMap rtpContentMap) {
         if (isInitiator()) {
             this.initiatorRtpContentMap = rtpContentMap;
@@ -1976,6 +2493,15 @@ public class JingleRtpConnection extends AbstractJingleConnection
         }
     }
 
+    // this method is to be used for content map modifications that modify media
+    private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
+        final RtpContentMap activeContents = rtpContentMap.activeContents();
+        setLocalContentMap(activeContents);
+        this.webRTCWrapper.switchSpeakerPhonePreference(
+                AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia()));
+        updateEndUserState();
+    }
+
     private SessionDescription setLocalSessionDescription()
             throws ExecutionException, InterruptedException {
         final org.webrtc.SessionDescription sessionDescription =
  
  
  
    
    @@ -1,11 +1,18 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import com.google.common.collect.ImmutableSet;
+
 import java.util.Locale;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
 
 public enum Media {
+
     VIDEO, AUDIO, UNKNOWN;
 
     @Override
+    @Nonnull
     public String toString() {
         return super.toString().toLowerCase(Locale.ROOT);
     }
@@ -17,4 +24,12 @@ public enum Media {
             return UNKNOWN;
         }
     }
+
+    public static boolean audioOnly(Set<Media> media) {
+        return ImmutableSet.of(AUDIO).equals(media);
+    }
+
+    public static boolean videoOnly(Set<Media> media) {
+        return ImmutableSet.of(VIDEO).equals(media);
+    }
 }
  
  
  
    
    @@ -1,6 +1,9 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
 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;
@@ -11,10 +14,13 @@ import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import javax.annotation.Nonnull;
+
 import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
@@ -58,13 +64,15 @@ public class RtpContentMap {
         return true;
     }
 
-    public static RtpContentMap of(final SessionDescription sessionDescription) {
+    public static RtpContentMap of(
+            final SessionDescription sessionDescription, final boolean isInitiator) {
         final ImmutableMap.Builder<String, DescriptionTransport> 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, media));
+            contentMapBuilder.put(
+                    id, DescriptionTransport.of(sessionDescription, isInitiator, media));
         }
         final String groupAttribute =
                 Iterables.getFirst(sessionDescription.attributes.get("group"), null);
@@ -85,6 +93,10 @@ 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());
     }
@@ -140,11 +152,16 @@ public class RtpContentMap {
             jinglePacket.addGroup(this.group);
         }
         for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
-            final Content content = new Content(Content.Creator.INITIATOR, entry.getKey());
-            if (entry.getValue().description != null) {
-                content.addChild(entry.getValue().description);
+            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(entry.getValue().transport);
+            content.addChild(descriptionTransport.transport);
             jinglePacket.addJingleContent(content);
         }
         return jinglePacket;
@@ -163,7 +180,10 @@ public class RtpContentMap {
         newTransportInfo.addChild(candidate);
         return new RtpContentMap(
                 null,
-                ImmutableMap.of(contentName, new DescriptionTransport(null, newTransportInfo)));
+                ImmutableMap.of(
+                        contentName,
+                        new DescriptionTransport(
+                                descriptionTransport.senders, null, newTransportInfo)));
     }
 
     RtpContentMap transportInfo() {
@@ -171,7 +191,9 @@ public class RtpContentMap {
                 null,
                 Maps.transformValues(
                         contents,
-                        dt -> new DescriptionTransport(null, dt.transport.cloneWrapper())));
+                        dt ->
+                                new DescriptionTransport(
+                                        dt.senders, null, dt.transport.cloneWrapper())));
     }
 
     public IceUdpTransportInfo.Credentials getDistinctCredentials() {
@@ -179,7 +201,8 @@ public class RtpContentMap {
         final IceUdpTransportInfo.Credentials credentials =
                 Iterables.getFirst(allCredentials, null);
         if (allCredentials.size() == 1 && credentials != null) {
-            if (Strings.isNullOrEmpty(credentials.password) || Strings.isNullOrEmpty(credentials.ufrag)) {
+            if (Strings.isNullOrEmpty(credentials.password)
+                    || Strings.isNullOrEmpty(credentials.ufrag)) {
                 throw new IllegalStateException("Credentials are missing password or ufrag");
             }
             return credentials;
@@ -220,6 +243,23 @@ public class RtpContentMap {
         throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
     }
 
+    private DTLS getDistinctDtls() {
+        final Set<DTLS> dtlsSet =
+                ImmutableSet.copyOf(
+                        Collections2.transform(
+                                contents.values(),
+                                dt -> {
+                                    final IceUdpTransportInfo.Fingerprint fp =
+                                            dt.transport.getFingerprint();
+                                    return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
+                                }));
+        final DTLS dtls = Iterables.getFirst(dtlsSet, null);
+        if (dtlsSet.size() == 1 && dtls != null) {
+            return dtls;
+        }
+        throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
+    }
+
     public boolean emptyCandidates() {
         int count = 0;
         for (DescriptionTransport descriptionTransport : contents.values()) {
@@ -233,23 +273,107 @@ public class RtpContentMap {
         final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
                 new ImmutableMap.Builder<>();
         for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
-            final RtpDescription rtpDescription = content.getValue().description;
-            IceUdpTransportInfo transportInfo = content.getValue().transport;
+            final DescriptionTransport 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(rtpDescription, modifiedTransportInfo));
+                    new DescriptionTransport(
+                            descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
         }
         return new RtpContentMap(this.group, contentMapBuilder.build());
     }
 
+    public RtpContentMap modifiedSenders(final Content.Senders senders) {
+        return new RtpContentMap(
+                this.group,
+                Maps.transformValues(
+                        contents,
+                        dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
+    }
+
+    public RtpContentMap toContentModification(final Collection<String> modifications) {
+        return new RtpContentMap(
+                this.group,
+                Maps.transformValues(
+                        Maps.filterKeys(contents, Predicates.in(modifications)),
+                        dt ->
+                                new DescriptionTransport(
+                                        dt.senders, dt.description, IceUdpTransportInfo.STUB)));
+    }
+
+    public RtpContentMap toStub() {
+        return new RtpContentMap(
+                null,
+                Maps.transformValues(
+                        this.contents,
+                        dt ->
+                                new DescriptionTransport(
+                                        dt.senders,
+                                        RtpDescription.stub(dt.description.getMedia()),
+                                        IceUdpTransportInfo.STUB)));
+    }
+
+    public RtpContentMap activeContents() {
+        return new RtpContentMap(group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
+    }
+
+    public Diff diff(final RtpContentMap rtpContentMap) {
+        final Set<String> existingContentIds = this.contents.keySet();
+        final Set<String> newContentIds = rtpContentMap.contents.keySet();
+        return new Diff(
+                ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
+                ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
+    }
+
+    public boolean iceRestart(final RtpContentMap rtpContentMap) {
+        try {
+            return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
+        } catch (final IllegalStateException e) {
+            return false;
+        }
+    }
+
+    public RtpContentMap addContent(
+            final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
+        final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
+        final DTLS dtls = getDistinctDtls();
+        final IceUdpTransportInfo iceUdpTransportInfo =
+                IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint);
+        final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
+                /*new ImmutableMap.Builder<String, DescriptionTransport>()
+                        .putAll(contents)
+                        .putAll(modification.contents)
+                        .build();*/
+        final Map<String, DescriptionTransport> combinedFixedTransport =
+                Maps.transformValues(
+                        combined,
+                        dt ->
+                                new DescriptionTransport(
+                                        dt.senders, dt.description, iceUdpTransportInfo));
+        return new RtpContentMap(modification.group, combinedFixedTransport);
+    }
+
+    private static Map<String, DescriptionTransport> merge(
+            final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
+        final Map<String, DescriptionTransport> combined = new HashMap<>();
+        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 RtpDescription description, final IceUdpTransportInfo transport) {
+                final Content.Senders senders,
+                final RtpDescription description,
+                final IceUdpTransportInfo transport) {
+            this.senders = senders;
             this.description = description;
             this.transport = transport;
         }
@@ -257,6 +381,7 @@ public class RtpContentMap {
         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) {
@@ -274,22 +399,26 @@ public class RtpContentMap {
                         "Content does not contain ICE-UDP transport");
             }
             return new DescriptionTransport(
-                    rtpDescription, OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
+                    senders,
+                    rtpDescription,
+                    OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
         }
 
-        public static DescriptionTransport of(
-                final SessionDescription sessionDescription, final SessionDescription.Media media) {
+        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(rtpDescription, transportInfo);
+            return new DescriptionTransport(senders, rtpDescription, transportInfo);
         }
 
         public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
             return ImmutableMap.copyOf(
                     Maps.transformValues(
-                            contents,
-                            content -> content == null ? null : of(content)));
+                            contents, content -> content == null ? null : of(content)));
         }
     }
 
@@ -304,4 +433,58 @@ public class RtpContentMap {
             super(message);
         }
     }
+
+    public static final class Diff {
+        public final Set<String> added;
+        public final Set<String> removed;
+
+        private Diff(final Set<String> added, final Set<String> removed) {
+            this.added = added;
+            this.removed = removed;
+        }
+
+        public boolean hasModifications() {
+            return !this.added.isEmpty() || !this.removed.isEmpty();
+        }
+
+        public boolean isEmpty() {
+            return this.added.isEmpty() && this.removed.isEmpty();
+        }
+
+        @Override
+        @Nonnull
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("added", added)
+                    .add("removed", removed)
+                    .toString();
+        }
+    }
+
+    public static final class DTLS {
+        public final String hash;
+        public final IceUdpTransportInfo.Setup setup;
+        public final String fingerprint;
+
+        private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
+            this.hash = hash;
+            this.setup = setup;
+            this.fingerprint = fingerprint;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            DTLS dtls = (DTLS) o;
+            return Objects.equal(hash, dtls.hash)
+                    && setup == dtls.setup
+                    && Objects.equal(fingerprint, dtls.fingerprint);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(hash, setup, fingerprint);
+        }
+    }
 }
  
  
  
    
    @@ -5,6 +5,7 @@ public enum RtpEndUserState {
     CONNECTING, //session-initiate or session-accepted but no webrtc peer connection yet
     CONNECTED, //session-accepted and webrtc peer connection is connected
     RECONNECTING, //session-accepted and webrtc peer connection was connected once but is currently disconnected or failed
+    INCOMING_CONTENT_ADD, //session-accepted with a pending, incoming content-add
     FINDING_DEVICE, //'propose' has been sent out; no 184 ack yet
     RINGING, //'propose' has been sent out and it has been 184 acked
     ACCEPTING_CALL, //'proceed' message has been sent; but no session-initiate has been received
  
  
  
    
    @@ -3,6 +3,8 @@ package eu.siacs.conversations.xmpp.jingle;
 import android.util.Log;
 import android.util.Pair;
 
+import androidx.annotation.NonNull;
+
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -21,11 +23,12 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 
 public class SessionDescription {
 
-    public final static String LINE_DIVIDER = "\r\n";
-    private final static String HARDCODED_MEDIA_PROTOCOL = "UDP/TLS/RTP/SAVPF"; //probably only true for DTLS-SRTP aka when we have a fingerprint
-    private final static int HARDCODED_MEDIA_PORT = 9;
-    private final static String HARDCODED_ICE_OPTIONS = "trickle";
-    private final static String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
+    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 int HARDCODED_MEDIA_PORT = 9;
+    private static final String HARDCODED_ICE_OPTIONS = "trickle";
+    private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
 
     public final int version;
     public final String name;
@@ -33,8 +36,12 @@ public class SessionDescription {
     public final ArrayListMultimap<String, String> attributes;
     public final List<Media> media;
 
-
-    public SessionDescription(int version, String name, String connectionData, ArrayListMultimap<String, String> attributes, List<Media> media) {
+    public SessionDescription(
+            int version,
+            String name,
+            String connectionData,
+            ArrayListMultimap<String, String> attributes,
+            List<Media> media) {
         this.version = version;
         this.name = name;
         this.connectionData = connectionData;
@@ -42,7 +49,8 @@ public class SessionDescription {
         this.media = media;
     }
 
-    private static void appendAttributes(StringBuilder s, ArrayListMultimap<String, String> attributes) {
+    private static void appendAttributes(
+            StringBuilder s, ArrayListMultimap<String, String> attributes) {
         for (Map.Entry<String, String> attribute : attributes.entries()) {
             final String key = attribute.getKey();
             final String value = attribute.getValue();
@@ -109,7 +117,6 @@ public class SessionDescription {
                     }
                     break;
             }
-
         }
         if (currentMediaBuilder != null) {
             currentMediaBuilder.setAttributes(attributeMap);
@@ -121,7 +128,7 @@ public class SessionDescription {
         return sessionDescriptionBuilder.createSessionDescription();
     }
 
-    public static SessionDescription of(final RtpContentMap contentMap) {
+    public static SessionDescription of(final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
         final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
         final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
         final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
@@ -129,12 +136,17 @@ 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()));
+            attributeMap.put(
+                    "group",
+                    group.getSemantics()
+                            + " "
+                            + Joiner.on(' ').join(group.getIdentificationTags()));
         }
 
         attributeMap.put("msid-semantic", " WMS my-media-stream");
 
-        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry : contentMap.contents.entrySet()) {
+        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry :
+                contentMap.contents.entrySet()) {
             final String name = entry.getKey();
             RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue();
             RtpDescription description = descriptionTransport.description;
@@ -143,19 +155,22 @@ public class SessionDescription {
             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");
+                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");
+                throw new IllegalArgumentException(
+                        "Transport element is missing required pwd attribute");
             }
             checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
             mediaAttributes.put("ice-pwd", pwd);
             mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
             final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
             if (fingerprint != null) {
-                mediaAttributes.put("fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
+                mediaAttributes.put(
+                        "fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
                 final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
                 if (setup != null) {
                     mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
@@ -174,37 +189,56 @@ public class SessionDescription {
                 mediaAttributes.put("rtpmap", payloadType.toSdpAttribute());
                 final List<RtpDescription.Parameter> parameters = payloadType.getParameters();
                 if (parameters.size() == 1) {
-                    mediaAttributes.put("fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
+                    mediaAttributes.put(
+                            "fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
                 } else if (parameters.size() > 0) {
-                    mediaAttributes.put("fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
+                    mediaAttributes.put(
+                            "fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
                 }
-                for (RtpDescription.FeedbackNegotiation feedbackNegotiation : payloadType.getFeedbackNegotiations()) {
+                for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
+                        payloadType.getFeedbackNegotiations()) {
                     final String type = feedbackNegotiation.getType();
                     final String subtype = feedbackNegotiation.getSubType();
                     if (Strings.isNullOrEmpty(type)) {
-                        throw new IllegalArgumentException("a feedback for payload-type " + id + " negotiation is missing type");
+                        throw new IllegalArgumentException(
+                                "a feedback for payload-type "
+                                        + id
+                                        + " negotiation is missing type");
                     }
-                    checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
-                    mediaAttributes.put("rtcp-fb", id + " " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
+                    checkNoWhitespace(
+                            type, "feedback negotiation type must not contain whitespace");
+                    mediaAttributes.put(
+                            "rtcp-fb",
+                            id
+                                    + " "
+                                    + type
+                                    + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
                 }
-                for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : payloadType.feedbackNegotiationTrrInts()) {
-                    mediaAttributes.put("rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
+                for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
+                        payloadType.feedbackNegotiationTrrInts()) {
+                    mediaAttributes.put(
+                            "rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
                 }
             }
 
-            for (RtpDescription.FeedbackNegotiation feedbackNegotiation : description.getFeedbackNegotiations()) {
+            for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
+                    description.getFeedbackNegotiations()) {
                 final String type = feedbackNegotiation.getType();
                 final String subtype = feedbackNegotiation.getSubType();
                 if (Strings.isNullOrEmpty(type)) {
                     throw new IllegalArgumentException("a feedback negotiation is missing type");
                 }
                 checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
-                mediaAttributes.put("rtcp-fb", "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
+                mediaAttributes.put(
+                        "rtcp-fb",
+                        "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
             }
-            for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : description.feedbackNegotiationTrrInts()) {
+            for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
+                    description.feedbackNegotiationTrrInts()) {
                 mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
             }
-            for (final RtpDescription.RtpHeaderExtension extension : description.getHeaderExtensions()) {
+            for (final RtpDescription.RtpHeaderExtension extension :
+                    description.getHeaderExtensions()) {
                 final String id = extension.getId();
                 final String uri = extension.getUri();
                 if (Strings.isNullOrEmpty(id)) {
@@ -218,7 +252,8 @@ public class SessionDescription {
                 mediaAttributes.put("extmap", id + " " + uri);
             }
 
-            if (description.hasChild("extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
+            if (description.hasChild(
+                    "extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
                 mediaAttributes.put("extmap-allow-mixed", "");
             }
 
@@ -226,13 +261,16 @@ public class SessionDescription {
                 final String semantics = sourceGroup.getSemantics();
                 final List<String> groups = sourceGroup.getSsrcs();
                 if (Strings.isNullOrEmpty(semantics)) {
-                    throw new IllegalArgumentException("A SSRC group is missing semantics attribute");
+                    throw new IllegalArgumentException(
+                            "A SSRC group is missing semantics attribute");
                 }
                 checkNoWhitespace(semantics, "source group semantics must not contain whitespace");
                 if (groups.size() == 0) {
                     throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
                 }
-                mediaAttributes.put("ssrc-group", String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
+                mediaAttributes.put(
+                        "ssrc-group",
+                        String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
             }
             for (final RtpDescription.Source source : description.getSources()) {
                 for (final RtpDescription.Source.Parameter parameter : source.getParameters()) {
@@ -240,14 +278,18 @@ public class SessionDescription {
                     final String parameterName = parameter.getParameterName();
                     final String parameterValue = parameter.getParameterValue();
                     if (Strings.isNullOrEmpty(id)) {
-                        throw new IllegalArgumentException("A source specific media attribute is missing the id");
+                        throw new IllegalArgumentException(
+                                "A source specific media attribute is missing the id");
                     }
-                    checkNoWhitespace(id, "A source specific media attributes must not contain whitespaces");
+                    checkNoWhitespace(
+                            id, "A source specific media attributes must not contain whitespaces");
                     if (Strings.isNullOrEmpty(parameterName)) {
-                        throw new IllegalArgumentException("A source specific media attribute is missing its name");
+                        throw new IllegalArgumentException(
+                                "A source specific media attribute is missing its name");
                     }
                     if (Strings.isNullOrEmpty(parameterValue)) {
-                        throw new IllegalArgumentException("A source specific media attribute is missing its value");
+                        throw new IllegalArgumentException(
+                                "A source specific media attribute is missing its value");
                     }
                     mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
                 }
@@ -255,14 +297,14 @@ public class SessionDescription {
 
             mediaAttributes.put("mid", name);
 
-            //random additional attributes
-            mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
-            mediaAttributes.put("sendrecv", "");
-
-            if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP)) {
+            mediaAttributes.put(descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
+            if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
                 mediaAttributes.put("rtcp-mux", "");
             }
 
+            // random additional attributes
+            mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
+
             final MediaBuilder mediaBuilder = new MediaBuilder();
             mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
             mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
@@ -271,7 +313,6 @@ public class SessionDescription {
             mediaBuilder.setAttributes(mediaAttributes);
             mediaBuilder.setFormats(formatBuilder.build());
             mediaListBuilder.add(mediaBuilder.createMedia());
-
         }
         sessionDescriptionBuilder.setVersion(0);
         sessionDescriptionBuilder.setName("-");
@@ -317,17 +358,33 @@ public class SessionDescription {
         }
     }
 
+    @NonNull
     @Override
     public String toString() {
-        final StringBuilder s = new StringBuilder()
-                .append("v=").append(version).append(LINE_DIVIDER)
-                //TODO randomize or static
-                .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1").append(LINE_DIVIDER) //what ever that means
-                .append("s=").append(name).append(LINE_DIVIDER)
-                .append("t=0 0").append(LINE_DIVIDER);
+        final StringBuilder s =
+                new StringBuilder()
+                        .append("v=")
+                        .append(version)
+                        .append(LINE_DIVIDER)
+                        // TODO randomize or static
+                        .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1")
+                        .append(LINE_DIVIDER) // what ever that means
+                        .append("s=")
+                        .append(name)
+                        .append(LINE_DIVIDER)
+                        .append("t=0 0")
+                        .append(LINE_DIVIDER);
         appendAttributes(s, attributes);
         for (Media media : this.media) {
-            s.append("m=").append(media.media).append(' ').append(media.port).append(' ').append(media.protocol).append(' ').append(Joiner.on(' ').join(media.formats)).append(LINE_DIVIDER);
+            s.append("m=")
+                    .append(media.media)
+                    .append(' ')
+                    .append(media.port)
+                    .append(' ')
+                    .append(media.protocol)
+                    .append(' ')
+                    .append(Joiner.on(' ').join(media.formats))
+                    .append(LINE_DIVIDER);
             s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
             appendAttributes(s, media.attributes);
         }
@@ -342,7 +399,13 @@ public class SessionDescription {
         public final String connectionData;
         public final ArrayListMultimap<String, String> attributes;
 
-        public Media(String media, int port, String protocol, List<Integer> formats, String connectionData, ArrayListMultimap<String, String> attributes) {
+        public Media(
+                String media,
+                int port,
+                String protocol,
+                List<Integer> formats,
+                String connectionData,
+                ArrayListMultimap<String, String> attributes) {
             this.media = media;
             this.port = port;
             this.protocol = protocol;
@@ -351,5 +414,4 @@ public class SessionDescription {
             this.attributes = attributes;
         }
     }
-
 }
  
  
  
    
    @@ -5,6 +5,7 @@ import android.media.AudioManager;
 import android.media.ToneGenerator;
 import android.util.Log;
 
+import java.util.Arrays;
 import java.util.Set;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -19,6 +20,7 @@ class ToneManager {
     private final Context context;
 
     private ToneState state = null;
+    private RtpEndUserState endUserState = null;
     private ScheduledFuture<?> currentTone;
     private ScheduledFuture<?> currentResetFuture;
     private boolean appRtcAudioManagerHasControl = false;
@@ -51,7 +53,11 @@ class ToneManager {
                 return ToneState.ENDING_CALL;
             }
         }
-        if (state == RtpEndUserState.CONNECTED || state == RtpEndUserState.RECONNECTING) {
+        if (Arrays.asList(
+                        RtpEndUserState.CONNECTED,
+                        RtpEndUserState.RECONNECTING,
+                        RtpEndUserState.INCOMING_CONTENT_ADD)
+                .contains(state)) {
             if (media.contains(Media.VIDEO)) {
                 return ToneState.NULL;
             } else {
@@ -62,14 +68,19 @@ class ToneManager {
     }
 
     void transition(final RtpEndUserState state, final Set<Media> media) {
-        transition(of(true, state, media), media);
+        transition(state, of(true, state, media), media);
     }
 
     void transition(final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
-        transition(of(isInitiator, state, media), media);
+        transition(state, of(isInitiator, state, media), media);
     }
 
-    private synchronized void transition(ToneState state, final Set<Media> media) {
+    private synchronized void transition(final RtpEndUserState endUserState, final ToneState state, final Set<Media> media) {
+        final RtpEndUserState normalizeEndUserState = normalize(endUserState);
+        if (this.endUserState == normalizeEndUserState) {
+            return;
+        }
+        this.endUserState = normalizeEndUserState;
         if (this.state == state) {
             return;
         }
@@ -105,6 +116,18 @@ class ToneManager {
         this.state = state;
     }
 
+    private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
+        if (Arrays.asList(
+                        RtpEndUserState.CONNECTED,
+                        RtpEndUserState.RECONNECTING,
+                        RtpEndUserState.INCOMING_CONTENT_ADD)
+                .contains(endUserState)) {
+            return RtpEndUserState.CONNECTED;
+        } else {
+            return endUserState;
+        }
+    }
+
     void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
         this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
     }
  
  
  
    
    @@ -0,0 +1,76 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.util.Log;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+
+import org.webrtc.MediaStreamTrack;
+import org.webrtc.PeerConnection;
+import org.webrtc.RtpSender;
+import org.webrtc.RtpTransceiver;
+
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import eu.siacs.conversations.Config;
+
+class TrackWrapper<T extends MediaStreamTrack> {
+    public final T track;
+    public final RtpSender rtpSender;
+
+    private TrackWrapper(final T track, final RtpSender rtpSender) {
+        Preconditions.checkNotNull(track);
+        Preconditions.checkNotNull(rtpSender);
+        this.track = track;
+        this.rtpSender = rtpSender;
+    }
+
+    public static <T extends MediaStreamTrack> TrackWrapper<T> addTrack(
+            final PeerConnection peerConnection, final T mediaStreamTrack) {
+        final RtpSender rtpSender = peerConnection.addTrack(mediaStreamTrack);
+        return new TrackWrapper<>(mediaStreamTrack, rtpSender);
+    }
+
+    public static <T extends MediaStreamTrack> Optional<T> get(
+            @Nullable final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
+        if (trackWrapper == null) {
+            return Optional.absent();
+        }
+        final RtpTransceiver transceiver =
+                peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
+        if (transceiver == null) {
+            Log.w(Config.LOGTAG, "unable to detect transceiver for " + trackWrapper.rtpSender.id());
+            return Optional.of(trackWrapper.track);
+        }
+        final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
+        if (direction == RtpTransceiver.RtpTransceiverDirection.SEND_ONLY
+                || direction == RtpTransceiver.RtpTransceiverDirection.SEND_RECV) {
+            return Optional.of(trackWrapper.track);
+        } else {
+            Log.d(Config.LOGTAG, "withholding track because transceiver is " + direction);
+            return Optional.absent();
+        }
+    }
+
+    public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
+            @Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
+        final RtpSender rtpSender = trackWrapper.rtpSender;
+        for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
+            if (transceiver.getSender().id().equals(rtpSender.id())) {
+                return transceiver;
+            }
+        }
+        return null;
+    }
+
+    public static String id(final Class<? extends MediaStreamTrack> clazz) {
+        return String.format(
+                "%s-%s",
+                CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, clazz.getSimpleName()),
+                UUID.randomUUID().toString());
+    }
+}
  
  
  
    
    @@ -0,0 +1,179 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+
+import org.webrtc.Camera2Enumerator;
+import org.webrtc.CameraEnumerationAndroid;
+import org.webrtc.CameraEnumerator;
+import org.webrtc.CameraVideoCapturer;
+import org.webrtc.EglBase;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.SurfaceTextureHelper;
+import org.webrtc.VideoSource;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+import eu.siacs.conversations.Config;
+
+class VideoSourceWrapper {
+
+    private static final int CAPTURING_RESOLUTION = 1920;
+    private static final int CAPTURING_MAX_FRAME_RATE = 30;
+
+    private final CameraVideoCapturer cameraVideoCapturer;
+    private final CameraEnumerationAndroid.CaptureFormat captureFormat;
+    private final Set<String> availableCameras;
+    private boolean isFrontCamera = false;
+    private VideoSource videoSource;
+
+    VideoSourceWrapper(
+            CameraVideoCapturer cameraVideoCapturer,
+            CameraEnumerationAndroid.CaptureFormat captureFormat,
+            Set<String> cameras) {
+        this.cameraVideoCapturer = cameraVideoCapturer;
+        this.captureFormat = captureFormat;
+        this.availableCameras = cameras;
+    }
+
+    private int getFrameRate() {
+        return Math.max(
+                captureFormat.framerate.min,
+                Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
+    }
+
+    public void initialize(
+            final PeerConnectionFactory peerConnectionFactory,
+            final Context context,
+            final EglBase.Context eglBaseContext) {
+        final SurfaceTextureHelper surfaceTextureHelper =
+                SurfaceTextureHelper.create("webrtc", eglBaseContext);
+        this.videoSource = peerConnectionFactory.createVideoSource(false);
+        this.cameraVideoCapturer.initialize(
+                surfaceTextureHelper, context, this.videoSource.getCapturerObserver());
+    }
+
+    public VideoSource getVideoSource() {
+        final VideoSource videoSource = this.videoSource;
+        if (videoSource == null) {
+            throw new IllegalStateException("VideoSourceWrapper was not initialized");
+        }
+        return videoSource;
+    }
+
+    public void startCapture() {
+        final int frameRate = getFrameRate();
+        Log.d(
+                Config.LOGTAG,
+                String.format(
+                        "start capturing at %dx%d@%d",
+                        captureFormat.width, captureFormat.height, frameRate));
+        this.cameraVideoCapturer.startCapture(captureFormat.width, captureFormat.height, frameRate);
+    }
+
+    public void stopCapture() throws InterruptedException {
+        this.cameraVideoCapturer.stopCapture();
+    }
+
+    public void dispose() {
+        this.cameraVideoCapturer.dispose();
+        if (this.videoSource != null) {
+            this.videoSource.dispose();
+        }
+    }
+
+    public ListenableFuture<Boolean> switchCamera() {
+        final SettableFuture<Boolean> future = SettableFuture.create();
+        this.cameraVideoCapturer.switchCamera(
+                new CameraVideoCapturer.CameraSwitchHandler() {
+                    @Override
+                    public void onCameraSwitchDone(final boolean isFrontCamera) {
+                        VideoSourceWrapper.this.isFrontCamera = isFrontCamera;
+                        future.set(isFrontCamera);
+                    }
+
+                    @Override
+                    public void onCameraSwitchError(final String message) {
+                        future.setException(
+                                new IllegalStateException(
+                                        String.format("Unable to switch camera %s", message)));
+                    }
+                });
+        return future;
+    }
+
+    public boolean isFrontCamera() {
+        return this.isFrontCamera;
+    }
+
+    public boolean isCameraSwitchable() {
+        return this.availableCameras.size() > 1;
+    }
+
+    public static class Factory {
+        final Context context;
+
+        public Factory(final Context context) {
+            this.context = context;
+        }
+
+        public VideoSourceWrapper create() {
+            final CameraEnumerator enumerator = new Camera2Enumerator(context);
+            final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
+            for (final String deviceName : deviceNames) {
+                if (isFrontFacing(enumerator, deviceName)) {
+                    final VideoSourceWrapper videoSourceWrapper =
+                            of(enumerator, deviceName, deviceNames);
+                    if (videoSourceWrapper == null) {
+                        return null;
+                    }
+                    videoSourceWrapper.isFrontCamera = true;
+                    return videoSourceWrapper;
+                }
+            }
+            if (deviceNames.size() == 0) {
+                return null;
+            } else {
+                return of(enumerator, Iterables.get(deviceNames, 0), deviceNames);
+            }
+        }
+
+        @Nullable
+        private VideoSourceWrapper of(
+                final CameraEnumerator enumerator,
+                final String deviceName,
+                final Set<String> availableCameras) {
+            final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
+            if (capturer == null) {
+                return null;
+            }
+            final ArrayList<CameraEnumerationAndroid.CaptureFormat> choices =
+                    new ArrayList<>(enumerator.getSupportedFormats(deviceName));
+            Collections.sort(choices, (a, b) -> b.width - a.width);
+            for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
+                if (captureFormat.width <= CAPTURING_RESOLUTION) {
+                    return new VideoSourceWrapper(capturer, captureFormat, availableCameras);
+                }
+            }
+            return null;
+        }
+
+        private static boolean isFrontFacing(
+                final CameraEnumerator cameraEnumerator, final String deviceName) {
+            try {
+                return cameraEnumerator.isFrontFacing(deviceName);
+            } catch (final NullPointerException e) {
+                return false;
+            }
+        }
+    }
+}
  
  
  
    
    @@ -11,7 +11,6 @@ import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
@@ -19,10 +18,6 @@ import com.google.common.util.concurrent.SettableFuture;
 
 import org.webrtc.AudioSource;
 import org.webrtc.AudioTrack;
-import org.webrtc.Camera2Enumerator;
-import org.webrtc.CameraEnumerationAndroid;
-import org.webrtc.CameraEnumerator;
-import org.webrtc.CameraVideoCapturer;
 import org.webrtc.CandidatePairChangeEvent;
 import org.webrtc.DataChannel;
 import org.webrtc.DefaultVideoDecoderFactory;
@@ -39,14 +34,10 @@ import org.webrtc.RtpReceiver;
 import org.webrtc.RtpTransceiver;
 import org.webrtc.SdpObserver;
 import org.webrtc.SessionDescription;
-import org.webrtc.SurfaceTextureHelper;
-import org.webrtc.VideoSource;
 import org.webrtc.VideoTrack;
 import org.webrtc.audio.JavaAudioDeviceModule;
 import org.webrtc.voiceengine.WebRtcAudioEffects;
 
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
@@ -63,30 +54,13 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.services.AppRTCAudioManager;
 import eu.siacs.conversations.services.XmppConnectionService;
 
+@SuppressWarnings("UnstableApiUsage")
 public class WebRTCWrapper {
 
     private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
 
     private final ExecutorService executorService = Executors.newSingleThreadExecutor();
     
-    private static final Set<String> HARDWARE_AEC_BLACKLIST = new ImmutableSet.Builder<String>()
-            .add("Pixel")
-            .add("Pixel XL")
-            .add("Moto G5")
-            .add("Moto G (5S) Plus")
-            .add("Moto G4")
-            .add("TA-1053")
-            .add("Mi A1")
-            .add("Mi A2")
-            .add("E5823") // Sony z5 compact
-            .add("Redmi Note 5")
-            .add("FP2") // Fairphone FP2
-            .add("MI 5")
-            .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
-            .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
-            .add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
-            .build();
-
     private static final int TONE_DURATION = 500;
     private static final Map<String,Integer> TONE_CODES;
     static {
@@ -106,116 +80,159 @@ public class WebRTCWrapper {
         TONE_CODES = builder.build();
     }
 
-    private static final int CAPTURING_RESOLUTION = 1920;
-    private static final int CAPTURING_MAX_FRAME_RATE = 30;
+    private static final Set<String> HARDWARE_AEC_BLACKLIST =
+            new ImmutableSet.Builder<String>()
+                    .add("Pixel")
+                    .add("Pixel XL")
+                    .add("Moto G5")
+                    .add("Moto G (5S) Plus")
+                    .add("Moto G4")
+                    .add("TA-1053")
+                    .add("Mi A1")
+                    .add("Mi A2")
+                    .add("E5823") // Sony z5 compact
+                    .add("Redmi Note 5")
+                    .add("FP2") // Fairphone FP2
+                    .add("MI 5")
+                    .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
+                    .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
+                    .add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
+                    .build();
 
     private final EventCallback eventCallback;
     private final AtomicBoolean readyToReceivedIceCandidates = new AtomicBoolean(false);
     private final Queue<IceCandidate> iceCandidates = new LinkedList<>();
-    private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents = new AppRTCAudioManager.AudioManagerEvents() {
-        @Override
-        public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
-            eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
-        }
-    };
+    private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents =
+            new AppRTCAudioManager.AudioManagerEvents() {
+                @Override
+                public void onAudioDeviceChanged(
+                        AppRTCAudioManager.AudioDevice selectedAudioDevice,
+                        Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+                    eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
+                }
+            };
     private final Handler mainHandler = new Handler(Looper.getMainLooper());
-    private VideoTrack localVideoTrack = null;
+    private TrackWrapper<AudioTrack> localAudioTrack = null;
+    private TrackWrapper<VideoTrack> localVideoTrack = null;
     private VideoTrack remoteVideoTrack = null;
-    private final PeerConnection.Observer peerConnectionObserver = new PeerConnection.Observer() {
-        @Override
-        public void onSignalingChange(PeerConnection.SignalingState signalingState) {
-            Log.d(EXTENDED_LOGGING_TAG, "onSignalingChange(" + signalingState + ")");
-            //this is called after removeTrack or addTrack
-            //and should then trigger a content-add or content-remove or something
-            //https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/removeTrack
-        }
-
-        @Override
-        public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
-            eventCallback.onConnectionChange(newState);
-        }
-
-        @Override
-        public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
-            Log.d(EXTENDED_LOGGING_TAG, "onIceConnectionChange(" + iceConnectionState + ")");
-        }
-
-        @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 final PeerConnection.Observer peerConnectionObserver =
+            new PeerConnection.Observer() {
+                @Override
+                public void onSignalingChange(PeerConnection.SignalingState signalingState) {
+                    Log.d(EXTENDED_LOGGING_TAG, "onSignalingChange(" + signalingState + ")");
+                    // this is called after removeTrack or addTrack
+                    // and should then trigger a content-add or content-remove or something
+                    // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/removeTrack
+                }
 
-        @Override
-        public void onIceConnectionReceivingChange(boolean b) {
+                @Override
+                public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
+                    eventCallback.onConnectionChange(newState);
+                }
 
-        }
+                @Override
+                public void onIceConnectionChange(
+                        PeerConnection.IceConnectionState iceConnectionState) {
+                    Log.d(
+                            EXTENDED_LOGGING_TAG,
+                            "onIceConnectionChange(" + iceConnectionState + ")");
+                }
 
-        @Override
-        public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
-            Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
-        }
+                @Override
+                public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) {
+                    Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote);
+                    Log.d(Config.LOGTAG, "local candidate selected: " + event.local);
+                }
 
-        @Override
-        public void onIceCandidate(IceCandidate iceCandidate) {
-            if (readyToReceivedIceCandidates.get()) {
-                eventCallback.onIceCandidate(iceCandidate);
-            } else {
-                iceCandidates.add(iceCandidate);
-            }
-        }
+                @Override
+                public void onIceConnectionReceivingChange(boolean b) {}
 
-        @Override
-        public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
+                @Override
+                public void onIceGatheringChange(
+                        PeerConnection.IceGatheringState iceGatheringState) {
+                    Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
+                }
 
-        }
+                @Override
+                public void onIceCandidate(IceCandidate iceCandidate) {
+                    if (readyToReceivedIceCandidates.get()) {
+                        eventCallback.onIceCandidate(iceCandidate);
+                    } else {
+                        iceCandidates.add(iceCandidate);
+                    }
+                }
 
-        @Override
-        public void onAddStream(MediaStream mediaStream) {
-            Log.d(EXTENDED_LOGGING_TAG, "onAddStream(numAudioTracks=" + mediaStream.audioTracks.size() + ",numVideoTracks=" + mediaStream.videoTracks.size() + ")");
-        }
+                @Override
+                public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
 
-        @Override
-        public void onRemoveStream(MediaStream mediaStream) {
+                @Override
+                public void onAddStream(MediaStream mediaStream) {
+                    Log.d(
+                            EXTENDED_LOGGING_TAG,
+                            "onAddStream(numAudioTracks="
+                                    + mediaStream.audioTracks.size()
+                                    + ",numVideoTracks="
+                                    + mediaStream.videoTracks.size()
+                                    + ")");
+                }
 
-        }
+                @Override
+                public void onRemoveStream(MediaStream mediaStream) {}
 
-        @Override
-        public void onDataChannel(DataChannel dataChannel) {
+                @Override
+                public void onDataChannel(DataChannel dataChannel) {}
 
-        }
+                @Override
+                public void onRenegotiationNeeded() {
+                    Log.d(EXTENDED_LOGGING_TAG, "onRenegotiationNeeded()");
+                    final PeerConnection.PeerConnectionState currentState =
+                            peerConnection == null ? null : peerConnection.connectionState();
+                    if (currentState != null
+                            && currentState != PeerConnection.PeerConnectionState.NEW) {
+                        eventCallback.onRenegotiationNeeded();
+                    }
+                }
 
-        @Override
-        public void onRenegotiationNeeded() {
-            Log.d(EXTENDED_LOGGING_TAG, "onRenegotiationNeeded()");
-            final PeerConnection.PeerConnectionState currentState = peerConnection == null ? null : peerConnection.connectionState();
-            if (currentState != null && currentState != PeerConnection.PeerConnectionState.NEW) {
-                eventCallback.onRenegotiationNeeded();
-            }
-        }
+                @Override
+                public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
+                    final MediaStreamTrack track = rtpReceiver.track();
+                    Log.d(
+                            EXTENDED_LOGGING_TAG,
+                            "onAddTrack(kind="
+                                    + (track == null ? "null" : track.kind())
+                                    + ",numMediaStreams="
+                                    + mediaStreams.length
+                                    + ")");
+                    if (track instanceof VideoTrack) {
+                        remoteVideoTrack = (VideoTrack) track;
+                    }
+                }
 
-        @Override
-        public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
-            final MediaStreamTrack track = rtpReceiver.track();
-            Log.d(EXTENDED_LOGGING_TAG, "onAddTrack(kind=" + (track == null ? "null" : track.kind()) + ",numMediaStreams=" + mediaStreams.length + ")");
-            if (track instanceof VideoTrack) {
-                remoteVideoTrack = (VideoTrack) track;
-            }
-        }
+                @Override
+                public void onTrack(final RtpTransceiver transceiver) {
+                    Log.d(
+                            EXTENDED_LOGGING_TAG,
+                            "onTrack(mid="
+                                    + transceiver.getMid()
+                                    + ",media="
+                                    + transceiver.getMediaType()
+                                    + ",direction="
+                                    + transceiver.getDirection()
+                                    + ")");
+                }
 
-        @Override
-        public void onTrack(RtpTransceiver transceiver) {
-            Log.d(EXTENDED_LOGGING_TAG, "onTrack(mid=" + transceiver.getMid() + ",media=" + transceiver.getMediaType() + ")");
-        }
-    };
-    @Nullable
-    private PeerConnection peerConnection = null;
-    private AudioTrack localAudioTrack = null;
+                @Override
+                public void onRemoveTrack(final RtpReceiver receiver) {
+                    Log.d(EXTENDED_LOGGING_TAG, "onRemoveTrack(" + receiver.id() + ")");
+                }
+            };
+    @Nullable private PeerConnectionFactory peerConnectionFactory = null;
+    @Nullable private PeerConnection peerConnection = null;
     private AppRTCAudioManager appRTCAudioManager = null;
     private ToneManager toneManager = null;
     private Context context = null;
     private EglBase eglBase = null;
-    private CapturerChoice capturerChoice;
+    private VideoSourceWrapper videoSourceWrapper;
 
     WebRTCWrapper(final EventCallback eventCallback) {
         this.eventCallback = eventCallback;
@@ -229,37 +246,15 @@ public class WebRTCWrapper {
         }
     }
 
-    @Nullable
-    private static CapturerChoice of(CameraEnumerator enumerator, final String deviceName, Set<String> availableCameras) {
-        final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
-        if (capturer == null) {
-            return null;
-        }
-        final ArrayList<CameraEnumerationAndroid.CaptureFormat> choices = new ArrayList<>(enumerator.getSupportedFormats(deviceName));
-        Collections.sort(choices, (a, b) -> b.width - a.width);
-        for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
-            if (captureFormat.width <= CAPTURING_RESOLUTION) {
-                return new CapturerChoice(capturer, captureFormat, availableCameras);
-            }
-        }
-        return null;
-    }
-
-    private static boolean isFrontFacing(final CameraEnumerator cameraEnumerator, final String deviceName) {
-        try {
-            return cameraEnumerator.isFrontFacing(deviceName);
-        } catch (final NullPointerException e) {
-            return false;
-        }
-    }
-
-    public void setup(final XmppConnectionService service, final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference) throws InitializationException {
+    public void setup(
+            final XmppConnectionService service,
+            @Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
+            throws InitializationException {
         try {
             PeerConnectionFactory.initialize(
                     PeerConnectionFactory.InitializationOptions.builder(service)
-                    .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
-                    .createInitializationOptions()
-            );
+                            .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
+                            .createInitializationOptions());
         } catch (final UnsatisfiedLinkError e) {
             throw new InitializationException("Unable to initialize PeerConnectionFactory", e);
         }
@@ -270,68 +265,168 @@ public class WebRTCWrapper {
         }
         this.context = service;
         this.toneManager = service.getJingleConnectionManager().toneManager;
-        mainHandler.post(() -> {
-            appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
-            toneManager.setAppRtcAudioManagerHasControl(true);
-            appRTCAudioManager.start(audioManagerEvents);
-            eventCallback.onAudioDeviceChanged(appRTCAudioManager.getSelectedAudioDevice(), appRTCAudioManager.getAudioDevices());
-        });
-    }
-
-    synchronized void initializePeerConnection(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws InitializationException {
+        mainHandler.post(
+                () -> {
+                    appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
+                    toneManager.setAppRtcAudioManagerHasControl(true);
+                    appRTCAudioManager.start(audioManagerEvents);
+                    eventCallback.onAudioDeviceChanged(
+                            appRTCAudioManager.getSelectedAudioDevice(),
+                            appRTCAudioManager.getAudioDevices());
+                });
+    }
+
+    synchronized void initializePeerConnection(
+            final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
+            throws InitializationException {
         Preconditions.checkState(this.eglBase != null);
         Preconditions.checkNotNull(media);
-        Preconditions.checkArgument(media.size() > 0, "media can not be empty when initializing peer connection");
-        final boolean setUseHardwareAcousticEchoCanceler = WebRtcAudioEffects.canUseAcousticEchoCanceler() && !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
-        Log.d(Config.LOGTAG, String.format("setUseHardwareAcousticEchoCanceler(%s) model=%s", setUseHardwareAcousticEchoCanceler, Build.MODEL));
-        PeerConnectionFactory peerConnectionFactory = PeerConnectionFactory.builder()
-                .setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
-                .setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true))
-                .setAudioDeviceModule(JavaAudioDeviceModule.builder(context)
-                        .setUseHardwareAcousticEchoCanceler(setUseHardwareAcousticEchoCanceler)
-                        .createAudioDeviceModule()
-                )
-                .createPeerConnectionFactory();
-
+        Preconditions.checkArgument(
+                media.size() > 0, "media can not be empty when initializing peer connection");
+        final boolean setUseHardwareAcousticEchoCanceler =
+                WebRtcAudioEffects.canUseAcousticEchoCanceler()
+                        && !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
+        Log.d(
+                Config.LOGTAG,
+                String.format(
+                        "setUseHardwareAcousticEchoCanceler(%s) model=%s",
+                        setUseHardwareAcousticEchoCanceler, Build.MODEL));
+        this.peerConnectionFactory =
+                PeerConnectionFactory.builder()
+                        .setVideoDecoderFactory(
+                                new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
+                        .setVideoEncoderFactory(
+                                new DefaultVideoEncoderFactory(
+                                        eglBase.getEglBaseContext(), true, true))
+                        .setAudioDeviceModule(
+                                JavaAudioDeviceModule.builder(requireContext())
+                                        .setUseHardwareAcousticEchoCanceler(
+                                                setUseHardwareAcousticEchoCanceler)
+                                        .createAudioDeviceModule())
+                        .createPeerConnectionFactory();
 
         final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
-        final PeerConnection peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, peerConnectionObserver);
+        final PeerConnection peerConnection =
+                requirePeerConnectionFactory()
+                        .createPeerConnection(rtcConfig, peerConnectionObserver);
         if (peerConnection == null) {
             throw new InitializationException("Unable to create PeerConnection");
         }
 
-        final Optional<CapturerChoice> optionalCapturerChoice = media.contains(Media.VIDEO) ? getVideoCapturer() : Optional.absent();
-
-        if (optionalCapturerChoice.isPresent()) {
-            this.capturerChoice = optionalCapturerChoice.get();
-            final CameraVideoCapturer capturer = this.capturerChoice.cameraVideoCapturer;
-            final VideoSource videoSource = peerConnectionFactory.createVideoSource(false);
-            SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("webrtc", eglBase.getEglBaseContext());
-            capturer.initialize(surfaceTextureHelper, requireContext(), videoSource.getCapturerObserver());
-            Log.d(Config.LOGTAG, String.format("start capturing at %dx%d@%d", capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate()));
-            capturer.startCapture(capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate());
-
-            this.localVideoTrack = peerConnectionFactory.createVideoTrack("my-video-track", videoSource);
-
-            peerConnection.addTrack(this.localVideoTrack);
+        if (media.contains(Media.VIDEO)) {
+            addVideoTrack(peerConnection);
         }
 
-
         if (media.contains(Media.AUDIO)) {
-            //set up audio track
-            final AudioSource audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());
-            this.localAudioTrack = peerConnectionFactory.createAudioTrack("my-audio-track", audioSource);
-            peerConnection.addTrack(this.localAudioTrack);
+            addAudioTrack(peerConnection);
         }
         peerConnection.setAudioPlayout(true);
         peerConnection.setAudioRecording(true);
+
         this.peerConnection = peerConnection;
     }
 
-    private static PeerConnection.RTCConfiguration buildConfiguration(final List<PeerConnection.IceServer> iceServers) {
-        final PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
-        rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; //XEP-0176 doesn't support tcp
-        rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
+    private VideoSourceWrapper initializeVideoSourceWrapper() {
+        final VideoSourceWrapper existingVideoSourceWrapper = this.videoSourceWrapper;
+        if (existingVideoSourceWrapper != null) {
+            existingVideoSourceWrapper.startCapture();
+            return existingVideoSourceWrapper;
+        }
+        final VideoSourceWrapper videoSourceWrapper =
+                new VideoSourceWrapper.Factory(requireContext()).create();
+        if (videoSourceWrapper == null) {
+            throw new IllegalStateException("Could not instantiate VideoSourceWrapper");
+        }
+        videoSourceWrapper.initialize(
+                requirePeerConnectionFactory(), requireContext(), eglBase.getEglBaseContext());
+        videoSourceWrapper.startCapture();
+        this.videoSourceWrapper = videoSourceWrapper;
+        return videoSourceWrapper;
+    }
+
+    public synchronized boolean addTrack(final Media media) {
+        if (media == Media.VIDEO) {
+            return addVideoTrack(requirePeerConnection());
+        } else if (media == Media.AUDIO) {
+            return addAudioTrack(requirePeerConnection());
+        }
+        throw new IllegalStateException(String.format("Could not add track for %s", media));
+    }
+
+    public synchronized void removeTrack(final Media media) {
+        if (media == Media.VIDEO) {
+            removeVideoTrack(requirePeerConnection());
+        }
+    }
+
+    private boolean addAudioTrack(final PeerConnection peerConnection) {
+        final AudioSource audioSource =
+                requirePeerConnectionFactory().createAudioSource(new MediaConstraints());
+        final AudioTrack audioTrack =
+                requirePeerConnectionFactory()
+                        .createAudioTrack(TrackWrapper.id(AudioTrack.class), audioSource);
+        this.localAudioTrack = TrackWrapper.addTrack(peerConnection, audioTrack);
+        return true;
+    }
+
+    private boolean addVideoTrack(final PeerConnection peerConnection) {
+        final TrackWrapper<VideoTrack> existing = this.localVideoTrack;
+        if (existing != null) {
+            final RtpTransceiver transceiver =
+                    TrackWrapper.getTransceiver(peerConnection, existing);
+            if (transceiver == null) {
+                Log.w(EXTENDED_LOGGING_TAG, "unable to restart video transceiver");
+                return false;
+            }
+            transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV);
+            this.videoSourceWrapper.startCapture();
+            return true;
+        }
+        final VideoSourceWrapper videoSourceWrapper;
+        try {
+            videoSourceWrapper = initializeVideoSourceWrapper();
+        } catch (final IllegalStateException e) {
+            Log.d(Config.LOGTAG, "could not add video track", e);
+            return false;
+        }
+        final VideoTrack videoTrack =
+                requirePeerConnectionFactory()
+                        .createVideoTrack(
+                                TrackWrapper.id(VideoTrack.class),
+                                videoSourceWrapper.getVideoSource());
+        this.localVideoTrack = TrackWrapper.addTrack(peerConnection, videoTrack);
+        return true;
+    }
+
+    private void removeVideoTrack(final PeerConnection peerConnection) {
+        final TrackWrapper<VideoTrack> localVideoTrack = this.localVideoTrack;
+        if (localVideoTrack != null) {
+
+            final RtpTransceiver exactTransceiver =
+                    TrackWrapper.getTransceiver(peerConnection, localVideoTrack);
+            if (exactTransceiver == null) {
+                throw new IllegalStateException();
+            }
+            exactTransceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.INACTIVE);
+        }
+        final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+        if (videoSourceWrapper != null) {
+            try {
+                videoSourceWrapper.stopCapture();
+            } catch (InterruptedException e) {
+                e.printStackTrace();
+            }
+        }
+    }
+
+    private static PeerConnection.RTCConfiguration buildConfiguration(
+            final List<PeerConnection.IceServer> iceServers) {
+        final PeerConnection.RTCConfiguration rtcConfig =
+                new PeerConnection.RTCConfiguration(iceServers);
+        rtcConfig.tcpCandidatePolicy =
+                PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
+        rtcConfig.continualGatheringPolicy =
+                PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
         rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
         rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
         rtcConfig.enableImplicitRollback = true;
@@ -343,7 +438,20 @@ public class WebRTCWrapper {
     }
 
     void restartIce() {
-        executorService.execute(() -> requirePeerConnection().restartIce());
+        executorService.execute(
+                () -> {
+                    final PeerConnection peerConnection;
+                    try {
+                        peerConnection = requirePeerConnection();
+                    } catch (final PeerConnectionNotInitialized e) {
+                        Log.w(
+                                EXTENDED_LOGGING_TAG,
+                                "PeerConnection vanished before we could execute restart");
+                        return;
+                    }
+                    setIsReadyToReceiveIceCandidates(false);
+                    peerConnection.restartIce();
+                });
     }
 
     public void setIsReadyToReceiveIceCandidates(final boolean ready) {
@@ -355,12 +463,13 @@ public class WebRTCWrapper {
 
     synchronized void close() {
         final PeerConnection peerConnection = this.peerConnection;
-        final CapturerChoice capturerChoice = this.capturerChoice;
+        final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
+        final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
         final AppRTCAudioManager audioManager = this.appRTCAudioManager;
         final EglBase eglBase = this.eglBase;
         if (peerConnection != null) {
-            dispose(peerConnection);
             this.peerConnection = null;
+            dispose(peerConnection);
         }
         if (audioManager != null) {
             toneManager.setAppRtcAudioManagerHasControl(false);
@@ -368,17 +477,22 @@ public class WebRTCWrapper {
         }
         this.localVideoTrack = null;
         this.remoteVideoTrack = null;
-        if (capturerChoice != null) {
+        if (videoSourceWrapper != null) {
             try {
-                capturerChoice.cameraVideoCapturer.stopCapture();
-            } catch (InterruptedException e) {
+                videoSourceWrapper.stopCapture();
+            } catch (final InterruptedException e) {
                 Log.e(Config.LOGTAG, "unable to stop capturing");
             }
+            videoSourceWrapper.dispose();
         }
         if (eglBase != null) {
             eglBase.release();
             this.eglBase = null;
         }
+        if (peerConnectionFactory != null) {
+            this.peerConnectionFactory = null;
+            peerConnectionFactory.dispose();
+        }
     }
 
     synchronized void verifyClosed() {
@@ -386,132 +500,152 @@ public class WebRTCWrapper {
                 || this.eglBase != null
                 || this.localVideoTrack != null
                 || this.remoteVideoTrack != null) {
-            final IllegalStateException e = new IllegalStateException("WebRTCWrapper hasn't been closed properly");
+            final IllegalStateException e =
+                    new IllegalStateException("WebRTCWrapper hasn't been closed properly");
             Log.e(Config.LOGTAG, "verifyClosed() failed. Going to throw", e);
             throw e;
         }
     }
 
     boolean isCameraSwitchable() {
-        final CapturerChoice capturerChoice = this.capturerChoice;
-        return capturerChoice != null && capturerChoice.availableCameras.size() > 1;
+        final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+        return videoSourceWrapper != null && videoSourceWrapper.isCameraSwitchable();
     }
 
     boolean isFrontCamera() {
-        final CapturerChoice capturerChoice = this.capturerChoice;
-        return capturerChoice == null || capturerChoice.isFrontCamera;
+        final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+        return videoSourceWrapper == null || videoSourceWrapper.isFrontCamera();
     }
 
     ListenableFuture<Boolean> switchCamera() {
-        final CapturerChoice capturerChoice = this.capturerChoice;
-        if (capturerChoice == null) {
-            return Futures.immediateFailedFuture(new IllegalStateException("CameraCapturer has not been initialized"));
-        }
-        final SettableFuture<Boolean> future = SettableFuture.create();
-        capturerChoice.cameraVideoCapturer.switchCamera(new CameraVideoCapturer.CameraSwitchHandler() {
-            @Override
-            public void onCameraSwitchDone(boolean isFrontCamera) {
-                capturerChoice.isFrontCamera = isFrontCamera;
-                future.set(isFrontCamera);
-            }
-
-            @Override
-            public void onCameraSwitchError(final String message) {
-                future.setException(new IllegalStateException(String.format("Unable to switch camera %s", message)));
-            }
-        });
-        return future;
+        final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+        if (videoSourceWrapper == null) {
+            return Futures.immediateFailedFuture(
+                    new IllegalStateException("VideoSourceWrapper has not been initialized"));
+        }
+        return videoSourceWrapper.switchCamera();
     }
 
     boolean isMicrophoneEnabled() {
-        final AudioTrack audioTrack = this.localAudioTrack;
-        if (audioTrack == null) {
+        final Optional<AudioTrack> audioTrack =
+                TrackWrapper.get(peerConnection, this.localAudioTrack);
+        if (audioTrack.isPresent()) {
+            try {
+                return audioTrack.get().enabled();
+            } catch (final IllegalStateException e) {
+                // sometimes UI might still be rendering the buttons when a background thread has
+                // already ended the call
+                return false;
+            }
+        } else {
             throw new IllegalStateException("Local audio track does not exist (yet)");
         }
-        try {
-            return audioTrack.enabled();
-        } catch (final IllegalStateException e) {
-            //sometimes UI might still be rendering the buttons when a background thread has already ended the call
-            return false;
-        }
     }
 
     boolean setMicrophoneEnabled(final boolean enabled) {
-        final AudioTrack audioTrack = this.localAudioTrack;
-        if (audioTrack == null) {
+        final Optional<AudioTrack> audioTrack =
+                TrackWrapper.get(peerConnection, this.localAudioTrack);
+        if (audioTrack.isPresent()) {
+            try {
+                audioTrack.get().setEnabled(enabled);
+                return true;
+            } catch (final IllegalStateException e) {
+                Log.d(Config.LOGTAG, "unable to toggle microphone", e);
+                // ignoring race condition in case MediaStreamTrack has been disposed
+                return false;
+            }
+        } else {
             throw new IllegalStateException("Local audio track does not exist (yet)");
         }
-        try {
-            audioTrack.setEnabled(enabled);
-            return true;
-        } catch (final IllegalStateException e) {
-            Log.d(Config.LOGTAG, "unable to toggle microphone", e);
-            //ignoring race condition in case MediaStreamTrack has been disposed
-            return false;
-        }
     }
 
     boolean isVideoEnabled() {
-        final VideoTrack videoTrack = this.localVideoTrack;
-        if (videoTrack == null) {
-            return false;
+        final Optional<VideoTrack> videoTrack =
+                TrackWrapper.get(peerConnection, this.localVideoTrack);
+        if (videoTrack.isPresent()) {
+            return videoTrack.get().enabled();
         }
-        return videoTrack.enabled();
+        return false;
     }
 
     void setVideoEnabled(final boolean enabled) {
-        final VideoTrack videoTrack = this.localVideoTrack;
-        if (videoTrack == null) {
-            throw new IllegalStateException("Local video track does not exist");
+        final Optional<VideoTrack> videoTrack =
+                TrackWrapper.get(peerConnection, this.localVideoTrack);
+        if (videoTrack.isPresent()) {
+            videoTrack.get().setEnabled(enabled);
+            return;
         }
-        videoTrack.setEnabled(enabled);
+        throw new IllegalStateException("Local video track does not exist");
     }
 
     synchronized ListenableFuture<SessionDescription> setLocalDescription() {
-        return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
-            final SettableFuture<SessionDescription> future = SettableFuture.create();
-            peerConnection.setLocalDescription(new SetSdpObserver() {
-                @Override
-                public void onSetSuccess() {
-                    final SessionDescription description = peerConnection.getLocalDescription();
-                    Log.d(EXTENDED_LOGGING_TAG, "set local description:");
-                    logDescription(description);
-                    future.set(description);
-                }
-
-                @Override
-                public void onSetFailure(final String message) {
-                    future.setException(new FailureToSetDescriptionException(message));
-                }
-            });
-            return future;
-        }, MoreExecutors.directExecutor());
-    }
-
-    private static void logDescription(final SessionDescription sessionDescription) {
-        for (final String line : sessionDescription.description.split(eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
+        return Futures.transformAsync(
+                getPeerConnectionFuture(),
+                peerConnection -> {
+                    if (peerConnection == null) {
+                        return Futures.immediateFailedFuture(
+                                new IllegalStateException("PeerConnection was null"));
+                    }
+                    final SettableFuture<SessionDescription> future = SettableFuture.create();
+                    peerConnection.setLocalDescription(
+                            new SetSdpObserver() {
+                                @Override
+                                public void onSetSuccess() {
+                                    final SessionDescription description =
+                                            peerConnection.getLocalDescription();
+                                    Log.d(EXTENDED_LOGGING_TAG, "set local description:");
+                                    logDescription(description);
+                                    future.set(description);
+                                }
+
+                                @Override
+                                public void onSetFailure(final String message) {
+                                    future.setException(
+                                            new FailureToSetDescriptionException(message));
+                                }
+                            });
+                    return future;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public static void logDescription(final SessionDescription sessionDescription) {
+        for (final String line :
+                sessionDescription.description.split(
+                        eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
             Log.d(EXTENDED_LOGGING_TAG, line);
         }
     }
 
-    synchronized ListenableFuture<Void> setRemoteDescription(final SessionDescription sessionDescription) {
+    synchronized ListenableFuture<Void> setRemoteDescription(
+            final SessionDescription sessionDescription) {
         Log.d(EXTENDED_LOGGING_TAG, "setting remote description:");
         logDescription(sessionDescription);
-        return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
-            final SettableFuture<Void> future = SettableFuture.create();
-            peerConnection.setRemoteDescription(new SetSdpObserver() {
-                @Override
-                public void onSetSuccess() {
-                    future.set(null);
-                }
-
-                @Override
-                public void onSetFailure(final String message) {
-                    future.setException(new FailureToSetDescriptionException(message));
-                }
-            }, sessionDescription);
-            return future;
-        }, MoreExecutors.directExecutor());
+        return Futures.transformAsync(
+                getPeerConnectionFuture(),
+                peerConnection -> {
+                    if (peerConnection == null) {
+                        return Futures.immediateFailedFuture(
+                                new IllegalStateException("PeerConnection was null"));
+                    }
+                    final SettableFuture<Void> future = SettableFuture.create();
+                    peerConnection.setRemoteDescription(
+                            new SetSdpObserver() {
+                                @Override
+                                public void onSetSuccess() {
+                                    future.set(null);
+                                }
+
+                                @Override
+                                public void onSetFailure(final String message) {
+                                    future.setException(
+                                            new FailureToSetDescriptionException(message));
+                                }
+                            },
+                            sessionDescription);
+                    return future;
+                },
+                MoreExecutors.directExecutor());
     }
 
     @Nonnull
@@ -524,6 +658,7 @@ public class WebRTCWrapper {
         }
     }
 
+    @Nonnull
     private PeerConnection requirePeerConnection() {
         final PeerConnection peerConnection = this.peerConnection;
         if (peerConnection == null) {
@@ -541,28 +676,17 @@ public class WebRTCWrapper {
         return true;
     }
 
-    void addIceCandidate(IceCandidate iceCandidate) {
-        requirePeerConnection().addIceCandidate(iceCandidate);
+    @Nonnull
+    private PeerConnectionFactory requirePeerConnectionFactory() {
+        final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
+        if (peerConnectionFactory == null) {
+            throw new IllegalStateException("Make sure PeerConnectionFactory is initialized");
+        }
+        return peerConnectionFactory;
     }
 
-    private Optional<CapturerChoice> getVideoCapturer() {
-        final CameraEnumerator enumerator = new Camera2Enumerator(requireContext());
-        final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
-        for (final String deviceName : deviceNames) {
-            if (isFrontFacing(enumerator, deviceName)) {
-                final CapturerChoice capturerChoice = of(enumerator, deviceName, deviceNames);
-                if (capturerChoice == null) {
-                    return Optional.absent();
-                }
-                capturerChoice.isFrontCamera = true;
-                return Optional.of(capturerChoice);
-            }
-        }
-        if (deviceNames.size() == 0) {
-            return Optional.absent();
-        } else {
-            return Optional.fromNullable(of(enumerator, Iterables.get(deviceNames, 0), deviceNames));
-        }
+    void addIceCandidate(IceCandidate iceCandidate) {
+        requirePeerConnection().addIceCandidate(iceCandidate);
     }
 
     PeerConnection.PeerConnectionState getState() {
@@ -573,13 +697,12 @@ public class WebRTCWrapper {
         return requirePeerConnection().signalingState();
     }
 
-
     EglBase.Context getEglBaseContext() {
         return this.eglBase.getEglBaseContext();
     }
 
     Optional<VideoTrack> getLocalVideoTrack() {
-        return Optional.fromNullable(this.localVideoTrack);
+        return TrackWrapper.get(peerConnection, this.localVideoTrack);
     }
 
     Optional<VideoTrack> getRemoteVideoTrack() {
@@ -602,17 +725,23 @@ public class WebRTCWrapper {
         executorService.execute(command);
     }
 
+    public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
+        mainHandler.post(() -> appRTCAudioManager.switchSpeakerPhonePreference(preference));
+    }
+
     public interface EventCallback {
         void onIceCandidate(IceCandidate iceCandidate);
 
         void onConnectionChange(PeerConnection.PeerConnectionState newState);
 
-        void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
+        void onAudioDeviceChanged(
+                AppRTCAudioManager.AudioDevice selectedAudioDevice,
+                Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
 
         void onRenegotiationNeeded();
     }
 
-    private static abstract class SetSdpObserver implements SdpObserver {
+    private abstract static class SetSdpObserver implements SdpObserver {
 
         @Override
         public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) {
@@ -623,22 +752,6 @@ public class WebRTCWrapper {
         public void onCreateFailure(String s) {
             throw new IllegalStateException("Not able to use SetSdpObserver");
         }
-
-    }
-
-    private static abstract class CreateSdpObserver implements SdpObserver {
-
-
-        @Override
-        public void onSetSuccess() {
-            throw new IllegalStateException("Not able to use CreateSdpObserver");
-        }
-
-
-        @Override
-        public void onSetFailure(String s) {
-            throw new IllegalStateException("Not able to use CreateSdpObserver");
-        }
     }
 
     static class InitializationException extends Exception {
@@ -657,7 +770,6 @@ public class WebRTCWrapper {
         private PeerConnectionNotInitialized() {
             super("initialize PeerConnection first");
         }
-
     }
 
     private static class FailureToSetDescriptionException extends IllegalArgumentException {
@@ -665,21 +777,4 @@ public class WebRTCWrapper {
             super(message);
         }
     }
-
-    private static class CapturerChoice {
-        private final CameraVideoCapturer cameraVideoCapturer;
-        private final CameraEnumerationAndroid.CaptureFormat captureFormat;
-        private final Set<String> availableCameras;
-        private boolean isFrontCamera = false;
-
-        CapturerChoice(CameraVideoCapturer cameraVideoCapturer, CameraEnumerationAndroid.CaptureFormat captureFormat, Set<String> cameras) {
-            this.cameraVideoCapturer = cameraVideoCapturer;
-            this.captureFormat = captureFormat;
-            this.availableCameras = cameras;
-        }
-
-        int getFrameRate() {
-            return Math.max(captureFormat.framerate.min, Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
-        }
-    }
 }
  
  
  
    
    @@ -1,20 +1,27 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
+import android.util.Log;
+
 import androidx.annotation.NonNull;
 
 import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
 
 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;
 
 public class Content extends Element {
 
-    public Content(final Creator creator, final String name) {
+    public Content(final Creator creator, final Senders senders, final String name) {
         super("content", Namespace.JINGLE);
         this.setAttribute("creator", creator.toString());
         this.setAttribute("name", name);
+        this.setSenders(senders);
     }
 
     private Content() {
@@ -38,11 +45,17 @@ public class Content extends Element {
     }
 
     public Senders getSenders() {
+        final String attribute = getAttribute("senders");
+        if (Strings.isNullOrEmpty(attribute)) {
+            return Senders.BOTH;
+        }
         return Senders.of(getAttribute("senders"));
     }
 
-    public void setSenders(Senders senders) {
-        this.setAttribute("senders", senders.toString());
+    public void setSenders(final Senders senders) {
+        if (senders != null && senders != Senders.BOTH) {
+            this.setAttribute("senders", senders.toString());
+        }
     }
 
     public GenericDescription getDescription() {
@@ -51,9 +64,7 @@ public class Content extends Element {
             return null;
         }
         final String namespace = description.getNamespace();
-        if (FileTransferDescription.NAMESPACES.contains(namespace)) {
-            return FileTransferDescription.upgrade(description);
-        } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
+        if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
             return RtpDescription.upgrade(description);
         } else {
             return GenericDescription.upgrade(description);
@@ -73,11 +84,7 @@ public class Content extends Element {
     public GenericTransportInfo getTransport() {
         final Element transport = this.findChild("transport");
         final String namespace = transport == null ? null : transport.getNamespace();
-        if (Namespace.JINGLE_TRANSPORTS_IBB.equals(namespace)) {
-            return IbbTransportInfo.upgrade(transport);
-        } else if (Namespace.JINGLE_TRANSPORTS_S5B.equals(namespace)) {
-            return S5BTransportInfo.upgrade(transport);
-        } else if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) {
+        if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) {
             return IceUdpTransportInfo.upgrade(transport);
         } else if (transport != null) {
             return GenericTransportInfo.upgrade(transport);
@@ -91,7 +98,8 @@ public class Content extends Element {
     }
 
     public enum Creator {
-        INITIATOR, RESPONDER;
+        INITIATOR,
+        RESPONDER;
 
         public static Creator of(final String value) {
             return Creator.valueOf(value.toUpperCase(Locale.ROOT));
@@ -105,16 +113,56 @@ public class Content extends Element {
     }
 
     public enum Senders {
-        BOTH, INITIATOR, NONE, RESPONDER;
+        BOTH,
+        INITIATOR,
+        NONE,
+        RESPONDER;
 
         public static Senders of(final String value) {
             return Senders.valueOf(value.toUpperCase(Locale.ROOT));
         }
 
+        public static Senders of(final SessionDescription.Media media, final boolean initiator) {
+            final Set<String> attributes = media.attributes.keySet();
+            if (attributes.contains("sendrecv")) {
+                return BOTH;
+            } else if (attributes.contains("inactive")) {
+                return NONE;
+            } else if (attributes.contains("sendonly")) {
+                return initiator ? INITIATOR : RESPONDER;
+            } else if (attributes.contains("recvonly")) {
+                return initiator ? RESPONDER : INITIATOR;
+            }
+            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
+            return BOTH;
+        }
+
         @Override
         @NonNull
         public String toString() {
             return super.toString().toLowerCase(Locale.ROOT);
         }
+
+        public String asMediaAttribute(final boolean initiator) {
+            final boolean responder = !initiator;
+            if (this == Content.Senders.BOTH) {
+                return "sendrecv";
+            } else if (this == Content.Senders.NONE) {
+                return "inactive";
+            } else if ((initiator && this == Content.Senders.INITIATOR)
+                    || (responder && this == Content.Senders.RESPONDER)) {
+                return "sendonly";
+            } else if ((initiator && this == Content.Senders.RESPONDER)
+                    || (responder && this == Content.Senders.INITIATOR)) {
+                return "recvonly";
+            } else {
+                throw new IllegalStateException(
+                        String.format(
+                                "illegal combination of initiator=%s and %s", initiator, this));
+            }
+        }
     }
 }
  
  
  
    
    @@ -12,8 +12,6 @@ import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.Hashtable;
 import java.util.LinkedHashMap;
@@ -28,23 +26,29 @@ import eu.siacs.conversations.xmpp.jingle.SessionDescription;
 
 public class IceUdpTransportInfo extends GenericTransportInfo {
 
+    public static final IceUdpTransportInfo STUB = new IceUdpTransportInfo();
+
     public IceUdpTransportInfo() {
         super("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP);
     }
 
     public static IceUdpTransportInfo upgrade(final Element element) {
-        Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport");
-        Preconditions.checkArgument(Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(element.getNamespace()), "Element does not match ice-udp transport namespace");
+        Preconditions.checkArgument(
+                "transport".equals(element.getName()), "Name of provided element is not transport");
+        Preconditions.checkArgument(
+                Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(element.getNamespace()),
+                "Element does not match ice-udp transport namespace");
         final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
         transportInfo.setAttributes(element.getAttributes());
         transportInfo.setChildren(element.getChildren());
         return transportInfo;
     }
 
-    public static IceUdpTransportInfo of(SessionDescription sessionDescription, SessionDescription.Media media) {
+    public static IceUdpTransportInfo of(
+            SessionDescription sessionDescription, SessionDescription.Media media) {
         final String ufrag = Iterables.getFirst(media.attributes.get("ice-ufrag"), null);
         final String pwd = Iterables.getFirst(media.attributes.get("ice-pwd"), null);
-        IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
+        final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
         if (ufrag != null) {
             iceUdpTransportInfo.setAttribute("ufrag", ufrag);
         }
@@ -56,7 +60,15 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             iceUdpTransportInfo.addChild(fingerprint);
         }
         return iceUdpTransportInfo;
+    }
 
+    public static IceUdpTransportInfo of(
+            final Credentials credentials,  final Setup setup, final String hash, final String fingerprint) {
+        final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
+        iceUdpTransportInfo.addChild(Fingerprint.of(setup, hash, fingerprint));
+        iceUdpTransportInfo.setAttribute("ufrag", credentials.ufrag);
+        iceUdpTransportInfo.setAttribute("pwd", credentials.password);
+        return iceUdpTransportInfo;
     }
 
     public Fingerprint getFingerprint() {
@@ -91,7 +103,8 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
         transportInfo.setAttribute("ufrag", credentials.ufrag);
         transportInfo.setAttribute("pwd", credentials.password);
         for (final Element child : getChildren()) {
-            if (child.getName().equals("fingerprint") && Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
+            if (child.getName().equals("fingerprint")
+                    && Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
                 final Fingerprint fingerprint = new Fingerprint();
                 fingerprint.setAttributes(new Hashtable<>(child.getAttributes()));
                 fingerprint.setContent(child.getContent());
@@ -231,7 +244,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             return getAttributeAsInt("rel-port");
         }
 
-        public String getType() { //TODO might be converted to enum
+        public String getType() { // TODO might be converted to enum
             return getAttribute("type");
         }
 
@@ -256,7 +269,8 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             checkNotNullNoWhitespace(protocol, "protocol");
             final String transport = protocol.toLowerCase(Locale.ROOT);
             if (!"udp".equals(transport)) {
-                throw new IllegalArgumentException(String.format("'%s' is not a supported protocol", transport));
+                throw new IllegalArgumentException(
+                        String.format("'%s' is not a supported protocol", transport));
             }
             final String priority = this.getAttribute("priority");
             checkNotNullNoWhitespace(priority, "priority");
@@ -284,7 +298,15 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             if (ufrag != null) {
                 additionalParameter.put("ufrag", ufrag);
             }
-            final String parametersString = Joiner.on(' ').join(Collections2.transform(additionalParameter.entrySet(), input -> String.format("%s %s", input.getKey(), input.getValue())));
+            final String parametersString =
+                    Joiner.on(' ')
+                            .join(
+                                    Collections2.transform(
+                                            additionalParameter.entrySet(),
+                                            input ->
+                                                    String.format(
+                                                            "%s %s",
+                                                            input.getKey(), input.getValue())));
             return String.format(
                     "candidate:%s %s %s %s %s %s %s",
                     foundation,
@@ -293,20 +315,19 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
                     priority,
                     connectionAddress,
                     port,
-                    parametersString
-
-            );
+                    parametersString);
         }
     }
 
     private static void checkNotNullNoWhitespace(final String value, final String name) {
         if (Strings.isNullOrEmpty(value)) {
-            throw new IllegalArgumentException(String.format("Parameter %s is missing or empty", name));
+            throw new IllegalArgumentException(
+                    String.format("Parameter %s is missing or empty", name));
         }
-        SessionDescription.checkNoWhitespace(value, String.format("Parameter %s contains white spaces", name));
+        SessionDescription.checkNoWhitespace(
+                value, String.format("Parameter %s contains white spaces", name));
     }
 
-
     public static class Fingerprint extends Element {
 
         private Fingerprint() {
@@ -340,11 +361,20 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             return null;
         }
 
-        public static Fingerprint of(final SessionDescription sessionDescription, final SessionDescription.Media media) {
+        public static Fingerprint of(
+                final SessionDescription sessionDescription, final SessionDescription.Media media) {
             final Fingerprint fingerprint = of(media.attributes);
             return fingerprint == null ? of(sessionDescription.attributes) : fingerprint;
         }
 
+        private static Fingerprint of(final Setup setup, final String hash, final String content) {
+            final Fingerprint fingerprint = new Fingerprint();
+            fingerprint.setContent(content);
+            fingerprint.setAttribute("hash", hash);
+            fingerprint.setAttribute("setup", setup.toString().toLowerCase(Locale.ROOT));
+            return fingerprint;
+        }
+
         public String getHash() {
             return this.getAttribute("hash");
         }
@@ -356,7 +386,9 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
     }
 
     public enum Setup {
-        ACTPASS, PASSIVE, ACTIVE;
+        ACTPASS,
+        PASSIVE,
+        ACTIVE;
 
         public static Setup of(String setup) {
             try {
@@ -373,7 +405,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             if (this == ACTIVE) {
                 return PASSIVE;
             }
-            throw new IllegalStateException(this.name()+" can not be flipped");
+            throw new IllegalStateException(this.name() + " can not be flipped");
         }
     }
 }
  
  
  
    
    @@ -22,7 +22,6 @@ import eu.siacs.conversations.xmpp.jingle.SessionDescription;
 
 public class RtpDescription extends GenericDescription {
 
-
     private RtpDescription(final String media) {
         super("description", Namespace.JINGLE_APPS_RTP);
         this.setAttribute("media", media);
@@ -32,6 +31,10 @@ public class RtpDescription extends GenericDescription {
         super("description", Namespace.JINGLE_APPS_RTP);
     }
 
+    public static RtpDescription stub(final Media media) {
+        return new RtpDescription(media.toString());
+    }
+
     public Media getMedia() {
         return Media.of(this.getAttribute("media"));
     }
@@ -57,7 +60,8 @@ public class RtpDescription extends GenericDescription {
     public List<RtpHeaderExtension> getHeaderExtensions() {
         final ImmutableList.Builder<RtpHeaderExtension> builder = new ImmutableList.Builder<>();
         for (final Element child : getChildren()) {
-            if ("rtp-hdrext".equals(child.getName()) && Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(child.getNamespace())) {
+            if ("rtp-hdrext".equals(child.getName())
+                    && Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(child.getNamespace())) {
                 builder.add(RtpHeaderExtension.upgrade(child));
             }
         }
@@ -85,8 +89,12 @@ public class RtpDescription extends GenericDescription {
     }
 
     public static RtpDescription upgrade(final Element element) {
-        Preconditions.checkArgument("description".equals(element.getName()), "Name of provided element is not description");
-        Preconditions.checkArgument(Namespace.JINGLE_APPS_RTP.equals(element.getNamespace()), "Element does not match the jingle rtp namespace");
+        Preconditions.checkArgument(
+                "description".equals(element.getName()),
+                "Name of provided element is not description");
+        Preconditions.checkArgument(
+                Namespace.JINGLE_APPS_RTP.equals(element.getNamespace()),
+                "Element does not match the jingle rtp namespace");
         final RtpDescription description = new RtpDescription();
         description.setAttributes(element.getAttributes());
         description.setChildren(element.getChildren());
@@ -116,7 +124,8 @@ public class RtpDescription extends GenericDescription {
 
         private static FeedbackNegotiation upgrade(final Element element) {
             Preconditions.checkArgument("rtcp-fb".equals(element.getName()));
-            Preconditions.checkArgument(Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
             final FeedbackNegotiation feedback = new FeedbackNegotiation();
             feedback.setAttributes(element.getAttributes());
             feedback.setChildren(element.getChildren());
@@ -126,13 +135,13 @@ public class RtpDescription extends GenericDescription {
         public static List<FeedbackNegotiation> fromChildren(final List<Element> children) {
             ImmutableList.Builder<FeedbackNegotiation> builder = new ImmutableList.Builder<>();
             for (final Element child : children) {
-                if ("rtcp-fb".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
+                if ("rtcp-fb".equals(child.getName())
+                        && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
                     builder.add(upgrade(child));
                 }
             }
             return builder.build();
         }
-
     }
 
     public static class FeedbackNegotiationTrrInt extends Element {
@@ -142,7 +151,6 @@ public class RtpDescription extends GenericDescription {
             this.setAttribute("value", value);
         }
 
-
         private FeedbackNegotiationTrrInt() {
             super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
         }
@@ -150,12 +158,12 @@ public class RtpDescription extends GenericDescription {
         public int getValue() {
             final String value = getAttribute("value");
             return Integer.parseInt(value);
-
         }
 
         private static FeedbackNegotiationTrrInt upgrade(final Element element) {
             Preconditions.checkArgument("rtcp-fb-trr-int".equals(element.getName()));
-            Preconditions.checkArgument(Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
             final FeedbackNegotiationTrrInt trr = new FeedbackNegotiationTrrInt();
             trr.setAttributes(element.getAttributes());
             trr.setChildren(element.getChildren());
@@ -163,9 +171,11 @@ public class RtpDescription extends GenericDescription {
         }
 
         public static List<FeedbackNegotiationTrrInt> fromChildren(final List<Element> children) {
-            ImmutableList.Builder<FeedbackNegotiationTrrInt> builder = new ImmutableList.Builder<>();
+            ImmutableList.Builder<FeedbackNegotiationTrrInt> builder =
+                    new ImmutableList.Builder<>();
             for (final Element child : children) {
-                if ("rtcp-fb-trr-int".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
+                if ("rtcp-fb-trr-int".equals(child.getName())
+                        && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
                     builder.add(upgrade(child));
                 }
             }
@@ -173,9 +183,8 @@ public class RtpDescription extends GenericDescription {
         }
     }
 
-
-    //XEP-0294: Jingle RTP Header Extensions Negotiation
-    //maps to `extmap:$id $uri`
+    // XEP-0294: Jingle RTP Header Extensions Negotiation
+    // maps to `extmap:$id $uri`
     public static class RtpHeaderExtension extends Element {
 
         private RtpHeaderExtension() {
@@ -198,7 +207,8 @@ public class RtpDescription extends GenericDescription {
 
         public static RtpHeaderExtension upgrade(final Element element) {
             Preconditions.checkArgument("rtp-hdrext".equals(element.getName()));
-            Preconditions.checkArgument(Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace()));
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace()));
             final RtpHeaderExtension extension = new RtpHeaderExtension();
             extension.setAttributes(element.getAttributes());
             extension.setChildren(element.getChildren());
@@ -217,7 +227,7 @@ public class RtpDescription extends GenericDescription {
         }
     }
 
-    //maps to `rtpmap:$id $name/$clockrate/$channels`
+    // maps to `rtpmap:$id $name/$clockrate/$channels`
     public static class PayloadType extends Element {
 
         private PayloadType() {
@@ -238,8 +248,14 @@ public class RtpDescription extends GenericDescription {
             final int channels = getChannels();
             final String name = getPayloadTypeName();
             Preconditions.checkArgument(name != null, "Payload-type name must not be empty");
-            SessionDescription.checkNoWhitespace(name, "payload-type name must not contain whitespaces");
-            return getId() + " " + name + "/" + getClockRate() + (channels == 1 ? "" : "/" + channels);
+            SessionDescription.checkNoWhitespace(
+                    name, "payload-type name must not contain whitespaces");
+            return getId()
+                    + " "
+                    + name
+                    + "/"
+                    + getClockRate()
+                    + (channels == 1 ? "" : "/" + channels);
         }
 
         public int getIntId() {
@@ -251,7 +267,6 @@ public class RtpDescription extends GenericDescription {
             return this.getAttribute("id");
         }
 
-
         public String getPayloadTypeName() {
             return this.getAttribute("name");
         }
@@ -271,7 +286,8 @@ public class RtpDescription extends GenericDescription {
         public int getChannels() {
             final String channels = this.getAttribute("channels");
             if (channels == null) {
-                return 1; // The number of channels; if omitted, it MUST be assumed to contain one channel
+                return 1; // The number of channels; if omitted, it MUST be assumed to contain one
+                          // channel
             }
             try {
                 return Integer.parseInt(channels);
@@ -299,7 +315,9 @@ public class RtpDescription extends GenericDescription {
         }
 
         public static PayloadType of(final Element element) {
-            Preconditions.checkArgument("payload-type".equals(element.getName()), "element name must be called payload-type");
+            Preconditions.checkArgument(
+                    "payload-type".equals(element.getName()),
+                    "element name must be called payload-type");
             PayloadType payloadType = new PayloadType();
             payloadType.setAttributes(element.getAttributes());
             payloadType.setChildren(element.getChildren());
@@ -331,8 +349,8 @@ public class RtpDescription extends GenericDescription {
         }
     }
 
-    //map to `fmtp $id key=value;key=value
-    //where id is the id of the parent payload-type
+    // map to `fmtp $id key=value;key=value
+    // where id is the id of the parent payload-type
     public static class Parameter extends Element {
 
         private Parameter() {
@@ -354,7 +372,8 @@ public class RtpDescription extends GenericDescription {
         }
 
         public static Parameter of(final Element element) {
-            Preconditions.checkArgument("parameter".equals(element.getName()), "element name must be called parameter");
+            Preconditions.checkArgument(
+                    "parameter".equals(element.getName()), "element name must be called parameter");
             Parameter parameter = new Parameter();
             parameter.setAttributes(element.getAttributes());
             parameter.setChildren(element.getChildren());
@@ -367,12 +386,18 @@ public class RtpDescription extends GenericDescription {
             for (int i = 0; i < parameters.size(); ++i) {
                 final Parameter p = parameters.get(i);
                 final String name = p.getParameterName();
-                Preconditions.checkArgument(name != null, String.format("parameter for %s must have a name", id));
-                SessionDescription.checkNoWhitespace(name, String.format("parameter names for %s must not contain whitespaces", id));
+                Preconditions.checkArgument(
+                        name != null, String.format("parameter for %s must have a name", id));
+                SessionDescription.checkNoWhitespace(
+                        name,
+                        String.format("parameter names for %s must not contain whitespaces", id));
 
                 final String value = p.getParameterValue();
-                Preconditions.checkArgument(value != null, String.format("parameter for %s must have a value", id));
-                SessionDescription.checkNoWhitespace(value, String.format("parameter values for %s must not contain whitespaces", id));
+                Preconditions.checkArgument(
+                        value != null, String.format("parameter for %s must have a value", id));
+                SessionDescription.checkNoWhitespace(
+                        value,
+                        String.format("parameter values for %s must not contain whitespaces", id));
 
                 stringBuilder.append(name).append('=').append(value);
                 if (i != parameters.size() - 1) {
@@ -385,8 +410,11 @@ public class RtpDescription extends GenericDescription {
         public static String toSdpString(final String id, final Parameter parameter) {
             final String name = parameter.getParameterName();
             final String value = parameter.getParameterValue();
-            Preconditions.checkArgument(value != null, String.format("parameter for %s must have a value", id));
-            SessionDescription.checkNoWhitespace(value, String.format("parameter values for %s must not contain whitespaces", id));
+            Preconditions.checkArgument(
+                    value != null, String.format("parameter for %s must have a value", id));
+            SessionDescription.checkNoWhitespace(
+                    value,
+                    String.format("parameter values for %s must not contain whitespaces", id));
             if (Strings.isNullOrEmpty(name)) {
                 return String.format("%s %s", id, value);
             } else {
@@ -412,8 +440,8 @@ public class RtpDescription extends GenericDescription {
         }
     }
 
-    //XEP-0339: Source-Specific Media Attributes in Jingle
-    //maps to `a=ssrc:<ssrc-id> <attribute>:<value>`
+    // XEP-0339: Source-Specific Media Attributes in Jingle
+    // maps to `a=ssrc:<ssrc-id> <attribute>:<value>`
     public static class Source extends Element {
 
         private Source() {
@@ -444,7 +472,9 @@ public class RtpDescription extends GenericDescription {
 
         public static Source upgrade(final Element element) {
             Preconditions.checkArgument("source".equals(element.getName()));
-            Preconditions.checkArgument(Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(element.getNamespace()));
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
+                            element.getNamespace()));
             final Source source = new Source();
             source.setChildren(element.getChildren());
             source.setAttributes(element.getAttributes());
@@ -481,7 +511,6 @@ public class RtpDescription extends GenericDescription {
                 return parameter;
             }
         }
-
     }
 
     public static class SourceGroup extends Element {
@@ -517,7 +546,9 @@ public class RtpDescription extends GenericDescription {
 
         public static SourceGroup upgrade(final Element element) {
             Preconditions.checkArgument("ssrc-group".equals(element.getName()));
-            Preconditions.checkArgument(Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(element.getNamespace()));
+            Preconditions.checkArgument(
+                    Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
+                            element.getNamespace()));
             final SourceGroup group = new SourceGroup();
             group.setChildren(element.getChildren());
             group.setAttributes(element.getAttributes());
@@ -525,15 +556,18 @@ public class RtpDescription extends GenericDescription {
         }
     }
 
-    public static RtpDescription of(final SessionDescription sessionDescription, final SessionDescription.Media media) {
+    public static RtpDescription of(
+            final SessionDescription sessionDescription, final SessionDescription.Media media) {
         final RtpDescription rtpDescription = new RtpDescription(media.media);
         final Map<String, List<Parameter>> parameterMap = new HashMap<>();
-        final ArrayListMultimap<String, Element> feedbackNegotiationMap = ArrayListMultimap.create();
-        final ArrayListMultimap<String, Source.Parameter> sourceParameterMap = ArrayListMultimap.create();
-        final Set<String> attributes = Sets.newHashSet(Iterables.concat(
-                sessionDescription.attributes.keySet(),
-                media.attributes.keySet()
-        ));
+        final ArrayListMultimap<String, Element> feedbackNegotiationMap =
+                ArrayListMultimap.create();
+        final ArrayListMultimap<String, Source.Parameter> sourceParameterMap =
+                ArrayListMultimap.create();
+        final Set<String> attributes =
+                Sets.newHashSet(
+                        Iterables.concat(
+                                sessionDescription.attributes.keySet(), media.attributes.keySet()));
         for (final String rtcpFb : media.attributes.get("rtcp-fb")) {
             final String[] parts = rtcpFb.split(" ");
             if (parts.length >= 2) {
@@ -542,7 +576,10 @@ public class RtpDescription extends GenericDescription {
                 final String subType = parts.length >= 3 ? parts[2] : null;
                 if ("trr-int".equals(type)) {
                     if (subType != null) {
-                        feedbackNegotiationMap.put(id, new FeedbackNegotiationTrrInt(SessionDescription.ignorantIntParser(subType)));
+                        feedbackNegotiationMap.put(
+                                id,
+                                new FeedbackNegotiationTrrInt(
+                                        SessionDescription.ignorantIntParser(subType)));
                     }
                 } else {
                     feedbackNegotiationMap.put(id, new FeedbackNegotiation(type, subType));
@@ -594,7 +631,8 @@ public class RtpDescription extends GenericDescription {
                 rtpDescription.addChild(new SourceGroup(semantics, builder.build()));
             }
         }
-        for (Map.Entry<String, Collection<Source.Parameter>> source : sourceParameterMap.asMap().entrySet()) {
+        for (Map.Entry<String, Collection<Source.Parameter>> source :
+                sourceParameterMap.asMap().entrySet()) {
             rtpDescription.addChild(new Source(source.getKey(), source.getValue()));
         }
         if (media.attributes.containsKey("rtcp-mux")) {
  
  
  
    
    @@ -1,10 +1,11 @@
 package eu.siacs.conversations.xmpp.stanzas.csi;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class ActivePacket extends AbstractStanza {
 	public ActivePacket() {
 		super("active");
-		setAttribute("xmlns", "urn:xmpp:csi:0");
+		setAttribute("xmlns", Namespace.CSI);
 	}
 }
  
  
  
    
    @@ -1,10 +1,11 @@
 package eu.siacs.conversations.xmpp.stanzas.csi;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class InactivePacket extends AbstractStanza {
 	public InactivePacket() {
 		super("inactive");
-		setAttribute("xmlns", "urn:xmpp:csi:0");
+		setAttribute("xmlns", Namespace.CSI);
 	}
 }
  
  
  
    
    @@ -1,12 +1,13 @@
 package eu.siacs.conversations.xmpp.stanzas.streammgmt;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class AckPacket extends AbstractStanza {
 
-	public AckPacket(int sequence, int smVersion) {
+	public AckPacket(final int sequence) {
 		super("a");
-		this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+		this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
 		this.setAttribute("h", Integer.toString(sequence));
 	}
 
  
  
  
    
    @@ -1,12 +1,13 @@
 package eu.siacs.conversations.xmpp.stanzas.streammgmt;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class EnablePacket extends AbstractStanza {
 
-	public EnablePacket(int smVersion) {
+	public EnablePacket() {
 		super("enable");
-		this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+		this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
 		this.setAttribute("resume", "true");
 	}
 
  
  
  
    
    @@ -1,12 +1,13 @@
 package eu.siacs.conversations.xmpp.stanzas.streammgmt;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class RequestPacket extends AbstractStanza {
 
-	public RequestPacket(int smVersion) {
+	public RequestPacket() {
 		super("r");
-		this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+		this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
 	}
 
 }
  
  
  
    
    @@ -1,12 +1,13 @@
 package eu.siacs.conversations.xmpp.stanzas.streammgmt;
 
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
 
 public class ResumePacket extends AbstractStanza {
 
-	public ResumePacket(String id, int sequence, int smVersion) {
+	public ResumePacket(final String id, final int sequence) {
 		super("resume");
-		this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+		this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
 		this.setAttribute("previd", id);
 		this.setAttribute("h", Integer.toString(sequence));
 	}
  
  
  
    
    @@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
+</vector>
  
  
  
    
    @@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="#FFFFFF" android:viewportHeight="24"
+    android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M19.59,7L12,14.59 6.41,9H11V7H3v8h2v-4.59l7,7 9,-9z"/>
+</vector>
  
  
  
    
    @@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_delete_avatar"
+        android:title="@string/delete_avatar"
+        app:showAsAction="never" />
+</menu>
  
  
  
    
    @@ -17,5 +17,8 @@
         android:id="@+id/action_goto_chat"
         android:icon="?attr/icon_goto_chat"
         android:title="@string/switch_to_conversation"
-        app:showAsAction="ifRoom" />
+        app:showAsAction="always" />
+    <item android:id="@+id/action_switch_to_video"
+        android:title="@string/switch_to_video"
+        app:showAsAction="never"/>
 </menu>
  
  
  
    
    @@ -239,7 +239,6 @@
     <string name="title_pref_enable_quiet_hours">تفعيل ساعات السكون</string>
     <string name="pref_quiet_hours_summary">سوف تكتم التنبيهات إبان ساعات السكون</string>
     <string name="pref_expert_options_other">أخرى</string>
-    <string name="pref_autojoin">زامِن مع الفواصل المرجعية</string>
     <string name="conference_banned">حسابك محظور للإلتحاق بمجموعة المحادثة هذه</string>
     <string name="conference_members_only">هذه المجموعة متاحة للأعضاء المنتمين إليها فقط</string>
     <string name="conference_kicked">تم طردك من مجموعة الدردشة هذه</string>
  
  
  
    
    @@ -294,8 +294,6 @@
     <string name="title_pref_enable_quiet_hours">Включване на тихите часове</string>
     <string name="pref_quiet_hours_summary">Известията ще бъдат заглушени по време на тихите часове</string>
     <string name="pref_expert_options_other">Други</string>
-    <string name="pref_autojoin">Синхронизиране с отметките</string>
-    <string name="pref_autojoin_summary">Автоматично присъединяване към групови разговори, ако такава е настройката на отметката</string>
     <string name="toast_message_omemo_fingerprint">Отпечатъкът OMEMO е копиран</string>
     <string name="conference_banned">Достъпът Ви до този групов разговор е забранен</string>
     <string name="conference_members_only">Този групов разговор е само за членове</string>
  
  
  
    
    @@ -284,8 +284,6 @@
     <string name="title_pref_enable_quiet_hours">Habilitar hores de silenci</string>
     <string name="pref_quiet_hours_summary">Les notificacions seràn silenciades a les hores de silenci</string>
     <string name="pref_expert_options_other">Altres</string>
-    <string name="pref_autojoin">Sincronitzar als marcadors</string>
-    <string name="pref_autojoin_summary">Unir-se als xats de grup automàticament si el marcador l\'indica</string>
     <string name="toast_message_omemo_fingerprint">Empremta digital de OMEMO copiada en el portapapers</string>
     <string name="conference_banned">Estàs prohibit en aquest xat de grup</string>
     <string name="conference_members_only">Aquest xat en grup només és de membres</string>
  
  
  
    
    @@ -297,8 +297,6 @@
     <string name="title_pref_enable_quiet_hours">Povolit tichý režim</string>
     <string name="pref_quiet_hours_summary">Upozornění budou během tichého režimu ztlumena</string>
     <string name="pref_expert_options_other">Další</string>
-    <string name="pref_autojoin">Synchronizovat se záložkami</string>
-    <string name="pref_autojoin_summary">Automaticky se připojovat ke skupinovým chatům, pokud jsou nastaveny v záložkách</string>
     <string name="toast_message_omemo_fingerprint">OMEMO otisk zkopírován do schránky</string>
     <string name="conference_banned">Byl(a) jste blokován(a) v této skupině</string>
     <string name="conference_members_only">Tento skupinový chat je pouze pro registrované členy</string>
  
  
  
    
    @@ -294,8 +294,8 @@
     <string name="title_pref_enable_quiet_hours">Aktiver stilletid</string>
     <string name="pref_quiet_hours_summary">Notifikationer vil være lydløs under stilletid</string>
     <string name="pref_expert_options_other">Andre</string>
-    <string name="pref_autojoin">Synkroniser med bogmærker</string>
-    <string name="pref_autojoin_summary">Deltag automatisk i gruppechat hvis bogmærket tillader det</string>
+    <string name="pref_autojoin">Synkroniser bogmærker</string>
+    <string name="pref_autojoin_summary">Indstil \"autojoin\"-flag, når du går ind i eller forlader en MUC, og reager på ændringer foretaget af andre klienter.</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-fingeraftryk kopieret til udklipsholder</string>
     <string name="conference_banned">Du er udelukket fra denne gruppechat</string>
     <string name="conference_members_only">Denne gruppechat er kun for medlemmer</string>
@@ -303,6 +303,7 @@
     <string name="conference_kicked">Du er blevet smidt ud af denne gruppechat</string>
     <string name="conference_shutdown">Gruppechatten er lukket ned</string>
     <string name="conference_unknown_error">Du er ikke længere i denne gruppechat</string>
+    <string name="conference_technical_problems">Du forlod denne gruppechat af tekniske årsager</string>
     <string name="using_account">anvender konto %s</string>
     <string name="hosted_on">hostet på %s</string>
     <string name="checking_x">Tjekker %s på HTTP vært</string>
@@ -417,6 +418,7 @@
     <string name="video">video</string>
     <string name="image">billede</string>
     <string name="vector_graphic">vektorgrafik</string>
+    <string name="multimedia_file">multimediefil</string>
     <string name="pdf_document">PDF dokument</string>
     <string name="apk">Android App</string>
     <string name="vcard">Kontakt</string>
@@ -974,4 +976,6 @@
     <string name="plain_text_document">Ren tekstdokument</string>
     <string name="account_registrations_are_not_supported">Kontoregistrering er ikke understøttet</string>
     <string name="no_xmpp_adddress_found">Ingen XMPP-adresse fundet</string>
+    <string name="account_status_temporary_auth_failure">Midlertidig godkendelsesfejl</string>
+    <string name="delete_avatar">Slet avatar</string>
     </resources>
  
  
  
    
    @@ -170,6 +170,7 @@
     <string name="account_status_tls_error_domain">Domain nicht überprüfbar</string>
     <string name="account_status_policy_violation">Verstoß gegen die Richtlinien</string>
     <string name="account_status_incompatible_server">Inkompatibler Server</string>
+    <string name="account_status_incompatible_client">Inkompatibler Client</string>
     <string name="account_status_stream_error">Stream-Fehler</string>
     <string name="account_status_stream_opening_error">Fehler beim Öffnen des Streams</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -292,10 +293,10 @@
     <string name="title_pref_quiet_hours_start_time">Beginn</string>
     <string name="title_pref_quiet_hours_end_time">Ende</string>
     <string name="title_pref_enable_quiet_hours">Ruhige Stunden aktivieren</string>
-    <string name="pref_quiet_hours_summary">Benachrichtigungen sind während der ruhigen Stunden stumm.</string>
+    <string name="pref_quiet_hours_summary">Benachrichtigungen sind während der ruhigen Stunden stumm</string>
     <string name="pref_expert_options_other">Sonstiges</string>
-    <string name="pref_autojoin">Mit Lesezeichen synchronisieren</string>
-    <string name="pref_autojoin_summary">Gruppenchats automatisch beitreten, wenn das Lesezeichen dies angibt</string>
+    <string name="pref_autojoin">Lesezeichen synchronisieren</string>
+    <string name="pref_autojoin_summary">Setzt das \"Autojoin\"-Kennzeichen beim Betreten oder Verlassen eines Gruppenchats/Channels und reagiert auf Änderungen durch andere Clients.</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-Fingerabdruck in die Zwischenablage kopiert</string>
     <string name="conference_banned">Du wurdest aus diesem Gruppenchat ausgeschlossen</string>
     <string name="conference_members_only">Dieser Gruppenchat ist nur für Mitglieder</string>
@@ -303,6 +304,7 @@
     <string name="conference_kicked">Du wurdest aus diesem Gruppchat geworfen</string>
     <string name="conference_shutdown">Gruppenchat wurde geschlossen</string>
     <string name="conference_unknown_error">Du bist nicht länger in diesem Gruppenchat</string>
+    <string name="conference_technical_problems">Du hast diesen Gruppenchat aus technischen Gründen verlassen</string>
     <string name="using_account">verwende Konto %s</string>
     <string name="hosted_on">gehostet bei %s</string>
     <string name="checking_x">%s auf HTTP-Host wird überprüft</string>
@@ -362,7 +364,7 @@
     <string name="clear_other_devices">Geräte entfernen</string>
     <string name="clear_other_devices_desc">Bist du sicher, dass du alle anderen Geräte aus der OMEMO-Bekanntmachung entfernen willst? Die Bekanntmachung wird bei der nächsten Verbindung erneuert aber möglicherweise werden keine zwischenzeitlich gesendeten Nachrichten empfangen.</string>
     <string name="error_no_keys_to_trust_server_error">Für diesen Kontakt sind keine nutzbaren Schlüssel verfügbar.\nEs konnten keine neuen Schlüssel vom Server abgerufen werden. Gibt es vielleicht ein Problem mit dem Server deines Kontaktes?</string>
-    <string name="error_no_keys_to_trust_presence">Für diesen Kontakt sind keine benutzbaren Schlüssel verfügbar.\nStelle sicher, dass ihre beide gegenseitig den Online-Status aktiviert habt.</string>
+    <string name="error_no_keys_to_trust_presence">Für diesen Kontakt sind keine benutzbaren Schlüssel verfügbar.\nStelle sicher, dass ihr beide gegenseitig den Online-Status aktiviert habt.</string>
     <string name="error_trustkeys_title">Etwas ist schief gelaufen</string>
     <string name="fetching_history_from_server">Lade Chatverlauf vom Server</string>
     <string name="no_more_history_on_server">Keine weiteren Nachrichten vorhanden</string>
@@ -473,7 +475,7 @@
     <string name="server_info_broken">Fehlerhaft</string>
     <string name="pref_presence_settings">Status</string>
     <string name="pref_away_when_screen_off">Abwesend bei gesperrtem Gerät</string>
-    <string name="pref_away_when_screen_off_summary">Als abwesend anzeigen, wenn das Gerät gesperrt ist.</string>
+    <string name="pref_away_when_screen_off_summary">Als abwesend anzeigen, wenn das Gerät gesperrt ist</string>
     <string name="pref_dnd_on_silent_mode">Beschäftigt im lautlosen Modus</string>
     <string name="pref_dnd_on_silent_mode_summary">Als Beschäftigt anzeigen, wenn sich das Gerät im lautlosen Modus befindet</string>
     <string name="pref_treat_vibrate_as_silent">Vibration als Lautlos behandeln</string>
@@ -556,7 +558,7 @@
     <string name="presence_xa">Nicht verfügbar</string>
     <string name="presence_dnd">Beschäftigt</string>
     <string name="secure_password_generated">Ein sicheres Passwort wurde erstellt</string>
-    <string name="device_does_not_support_battery_op">Dein Gerät unterstützt kein Ausschalten der Akkuoptimierung</string>
+    <string name="device_does_not_support_battery_op">Dein Gerät unterstützt nicht das Ausschalten der Akkuoptimierung</string>
     <string name="registration_please_wait">Registrierung fehlgeschlagen: Bitte später versuchen</string>
     <string name="registration_password_too_weak">Registrierung fehlgeschlagen: Passwort zu schwach</string>
     <string name="choose_participants">Teilnehmer wählen</string>
@@ -764,6 +766,7 @@
     <string name="messages_channel_name">Nachrichten</string>
     <string name="incoming_calls_channel_name">Eingehende Anrufe</string>
     <string name="ongoing_calls_channel_name">Laufende Anrufe</string>
+    <string name="missed_calls_channel_name">Entgangene Anrufe</string>
     <string name="silent_messages_channel_name">Lautlose Nachrichten</string>
     <string name="silent_messages_channel_description">Diese Benachrichtigungsart wird verwendet, um Benachrichtigungen anzuzeigen, die keinen Ton auslösen sollen. Zum Beispiel, wenn du auf einem anderen Gerät aktiv bist (Schonfrist).</string>
     <string name="delivery_failed_channel_name">Fehlgeschlagene Zustellungen</string>
@@ -904,6 +907,8 @@
     <string name="make_call">Anrufen</string>
     <string name="rtp_state_incoming_call">Eingehender Anruf</string>
     <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_connected">Verbunden</string>
     <string name="rtp_state_reconnecting">Erneut verbinden</string>
@@ -931,6 +936,18 @@
     <string name="outgoing_call">Ausgehender Anruf</string>
     <string name="outgoing_call_duration">Ausgehender Anruf · %s</string>
     <string name="missed_call">Entgangener Anruf</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d entgangener Anruf von %2$s</item>
+        <item quantity="other">%1$d entgangene Anrufe von %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d entgangener Anruf</item>
+        <item quantity="other">%d entgangene Anrufe</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d entgangener Anruf von %2$d Kontakt</item>
+        <item quantity="other">%1$d entgangene Anrufe von %2$d Kontakten</item>
+    </plurals>
     <string name="audio_call">Audioanruf</string>
     <string name="video_call">Videoanruf</string>
     <string name="help">Hilfe</string>
@@ -976,5 +993,9 @@
     <string name="account_registrations_are_not_supported">Kontoregistrierungen werden nicht unterstützt</string>
     <string name="no_xmpp_adddress_found">Keine XMPP-Adresse gefunden</string>
     <string name="account_status_temporary_auth_failure">Temporärer Authentifizierungsfehler</string>
+    <string name="delete_avatar">Profilbild löschen</string>
+    <string name="audio_video_disabled_tor">Anrufe sind bei der Verwendung von Tor deaktiviert</string>
+    <string name="switch_to_video">Umschalten auf Video</string>
+    <string name="reject_switch_to_video">Umschalten auf Video ablehnen</string>
 
 </resources>
  
  
  
    
    @@ -294,8 +294,6 @@
     <string name="title_pref_enable_quiet_hours">Ενεργοποίηση ωρών ησυχίας</string>
     <string name="pref_quiet_hours_summary">Οι ειδοποιήσεις θα σιγαστούν κατά τις ώρες ησυχίας</string>
     <string name="pref_expert_options_other">Άλλο</string>
-    <string name="pref_autojoin">Συγχρονισμός με σελιδοδείκτες</string>
-    <string name="pref_autojoin_summary">Συμμετοχή σε ομαδικές συζητήσεις αυτόματα αν ο σελιδοδείκτης αναφέρει αυτόματη συμμετοχή</string>
     <string name="toast_message_omemo_fingerprint">Το αποτύπωμα OMEMO αντιγράφηκε στο πρόχειρο</string>
     <string name="conference_banned">Είστε αποκλεισμένοι από αυτή την ομαδική συζήτηση</string>
     <string name="conference_members_only">Αυτή η ομαδική συζήτηση είναι μόνο για μέλη</string>
  
  
  
    
    @@ -173,6 +173,7 @@
     <string name="account_status_tls_error_domain">Dominio no verificable</string>
     <string name="account_status_policy_violation">Policy violation</string>
     <string name="account_status_incompatible_server">Servidor incompatible</string>
+    <string name="account_status_incompatible_client">Cliente incompatible</string>
     <string name="account_status_stream_error">Error de flujo</string>
     <string name="account_status_stream_opening_error">Error al abrir la secuencia</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -298,7 +299,7 @@
     <string name="pref_quiet_hours_summary">Las notificaciones serán silenciadas durante el horario de silencio</string>
     <string name="pref_expert_options_other">Otros</string>
     <string name="pref_autojoin">Sincronizar marcadores</string>
-    <string name="pref_autojoin_summary">Unirse a conversaciones en grupo automáticamente si el marcador así lo indica</string>
+    <string name="pref_autojoin_summary">Establecer la opción \"unirse automáticamente\" cuando entras o sales de un MUC y reaccionar a las modificaciones realizadas por otros clientes.</string>
     <string name="toast_message_omemo_fingerprint">Huella digital OMEMO copiada al portapapeles</string>
     <string name="conference_banned">Tu entrada a esta conversación en grupo ha sido prohibida</string>
     <string name="conference_members_only">Esta conversación en grupo es solo para miembros</string>
@@ -306,6 +307,7 @@
     <string name="conference_kicked">Has sido expulsado de esta conversación</string>
     <string name="conference_shutdown">La conversación en grupo ha sido cerrada</string>
     <string name="conference_unknown_error">Ya no estás dentro de esta conversación en grupo</string>
+    <string name="conference_technical_problems">Has dejado esta conversación en grupo debido a razones técnicas.</string>
     <string name="using_account">Usando cuenta %s</string>
     <string name="hosted_on">alojado en %s</string>
     <string name="checking_x">Comprobando %s en servidor HTTP</string>
@@ -420,6 +422,7 @@
     <string name="video">vídeo</string>
     <string name="image">imagen</string>
     <string name="vector_graphic">gráfico de vectores</string>
+    <string name="multimedia_file">archivo multimedia</string>
     <string name="pdf_document">documento PDF</string>
     <string name="apk">Android App</string>
     <string name="vcard">Contacto</string>
@@ -774,6 +777,7 @@
     <string name="messages_channel_name">Mensajes</string>
     <string name="incoming_calls_channel_name">Llamadas entrantes</string>
     <string name="ongoing_calls_channel_name">Llamadas salientes</string>
+    <string name="missed_calls_channel_name">Llamadas perdidas</string>
     <string name="silent_messages_channel_name">Mensajes sin sonido</string>
     <string name="silent_messages_channel_description">Este grupo de notificaciones se usa para mostrar notificaciones que no deberían emitir ningún sonido. Por ejemplo, cuando estás activo en otro dispositivo (periodo de gracia).</string>
     <string name="delivery_failed_channel_name">Envíos fallidos</string>
@@ -941,6 +945,21 @@
     <string name="outgoing_call">Llamada saliente</string>
     <string name="outgoing_call_duration">Video llamada saliente · %s</string>
     <string name="missed_call">Llamada perdida</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d llamada perdida de %2$s</item>
+        <item quantity="many">%1$d llamadas perdidas de %2$s</item>
+        <item quantity="other">%1$d llamadas perdidas de %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d llamada perdida</item>
+        <item quantity="many">%d llamadas perdidas</item>
+        <item quantity="other">%d llamadas perdidas</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d llamadas perdidas de %2$d contacto</item>
+        <item quantity="many">%1$d llamadas perdidas de %2$d contacto</item>
+        <item quantity="other">%1$d llamadas perdidas de %2$d contactos</item>
+    </plurals>
     <string name="audio_call">Audio llamada</string>
     <string name="video_call">Video llamada</string>
     <string name="help">Ayuda</string>
@@ -988,5 +1007,6 @@
     <string name="account_registrations_are_not_supported">Los registros de cuenta no están soportados</string>
     <string name="no_xmpp_adddress_found">Dirección XMPP no encontrada</string>
     <string name="account_status_temporary_auth_failure">Fallo temporal de autenticación</string>
-
-</resources>
+    <string name="delete_avatar">Eliminar imagen de perfil</string>
+    <string name="audio_video_disabled_tor">Las llamadas están deshabilitadas cuando se usa Tor</string>
+    </resources>
  
  
  
    
    @@ -239,7 +239,6 @@
     <string name="title_pref_enable_quiet_hours">Ordu lasaiak gaitu</string>
     <string name="pref_quiet_hours_summary">Jakinarazpenak isilaraziko dira ordu lasaiak iraun bitartean </string>
     <string name="pref_expert_options_other">Besteak</string>
-    <string name="pref_autojoin">Laster-markekin sinkronizatu</string>
     <string name="conference_banned">Talde honetara sartzea debekatuta duzu</string>
     <string name="conference_members_only">Talde hau kideentzat da soilik</string>
     <string name="conference_resource_constraint">Baliabide murrizketa</string>
  
  
  
    
    @@ -289,8 +289,6 @@
     <string name="title_pref_enable_quiet_hours">Ota käyttöön hiljaisuus</string>
     <string name="pref_quiet_hours_summary">Ilmoitukset vaimennetaan hiljaisuuden aikana</string>
     <string name="pref_expert_options_other">Muut</string>
-    <string name="pref_autojoin">Synkronoi kirjanmerkkien kanssa</string>
-    <string name="pref_autojoin_summary">Liity ryhmään automaattisesti jos se on kirjanmerkeissäsi</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-sormenjälki kopioitu leikepöydälle</string>
     <string name="conference_banned">Sinut on estetty tästä ryhmäkeskustelusta</string>
     <string name="conference_members_only">Tämä ryhmäkeskustelu on vain jäsenille</string>
  
  
  
    
    @@ -295,8 +295,6 @@
     <string name="title_pref_enable_quiet_hours">Activer les heures tranquilles</string>
     <string name="pref_quiet_hours_summary">Les notifications seront muettes pendant les heures tranquilles.</string>
     <string name="pref_expert_options_other">Autres</string>
-    <string name="pref_autojoin">Synchroniser avec les signets</string>
-    <string name="pref_autojoin_summary">Rejoindre automatiquement les groupes marqués en favoris</string>
     <string name="toast_message_omemo_fingerprint">Empreinte OMEMO copiée dans le presse-papier</string>
     <string name="conference_banned">Vous êtes bannis de ce groupe</string>
     <string name="conference_members_only">Ce groupe est réservé aux membres</string>
  
  
  
    
    @@ -170,6 +170,7 @@
     <string name="account_status_tls_error_domain">Dominio non verificable</string>
     <string name="account_status_policy_violation">Violación da política</string>
     <string name="account_status_incompatible_server">Servidor incompatible</string>
+    <string name="account_status_incompatible_client">Cliente non compatible</string>
     <string name="account_status_stream_error">Erro de fluxo</string>
     <string name="account_status_stream_opening_error">Fallo ao abrir o fluxo</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -294,8 +295,8 @@
     <string name="title_pref_enable_quiet_hours">Establecer horario sen notificacións</string>
     <string name="pref_quiet_hours_summary">As notificacións serán silenciadas durante estas horas</string>
     <string name="pref_expert_options_other">Outro</string>
-    <string name="pref_autojoin">Sincronizar cos marcadores</string>
-    <string name="pref_autojoin_summary">Unirte as conversas en grupo automáticamente se o marcador así o indica</string>
+    <string name="pref_autojoin">Sincronizar marcadores</string>
+    <string name="pref_autojoin_summary">Poñer marca de \"autojoin\" ao entrar ou deixar unha MUC e reaccionar ás modificacións feitas desde outros clientes.</string>
     <string name="toast_message_omemo_fingerprint">Copiouse a impresión dixital OMEMO ao portapapeis</string>
     <string name="conference_banned">Non podes acceder a esta conversa en grupo</string>
     <string name="conference_members_only">Esta conversa en grupo é so para membros</string>
@@ -303,6 +304,7 @@
     <string name="conference_kicked">Xa foi expulsado de esta conversa en grupo</string>
     <string name="conference_shutdown">A conversa en grupo foi apagada</string>
     <string name="conference_unknown_error">Xa non estás nesta conversa en grupo</string>
+    <string name="conference_technical_problems">Deixaches esta conversa en grupo por razóns técnicas</string>
     <string name="using_account">utilizando a conta %s</string>
     <string name="hosted_on">hospedado en %s</string>
     <string name="checking_x">Comprobando %s no servidor HTTP</string>
@@ -764,6 +766,7 @@
     <string name="messages_channel_name">Mensaxes</string>
     <string name="incoming_calls_channel_name">Chamadas recibidas</string>
     <string name="ongoing_calls_channel_name">Chamadas realizadas</string>
+    <string name="missed_calls_channel_name">Chamadas perdidas</string>
     <string name="silent_messages_channel_name">Mensaxes acalados</string>
     <string name="silent_messages_channel_description">Este grupo de notificacións é utilizado para mostrar notificacións que non debería activar ningún son. Por exemplo, cando está activo en outro dispositivo (Período de Graza).</string>
     <string name="delivery_failed_channel_name">Entregas fallidas</string>
@@ -904,6 +907,8 @@
     <string name="make_call">Facer unha chamada</string>
     <string name="rtp_state_incoming_call">Chamada entrante</string>
     <string name="rtp_state_incoming_video_call">Videochamada entrante</string>
+    <string name="rtp_state_content_add_video">Cambiar a unha chamada de vídeo?</string>
+    <string name="rtp_state_content_add">Engadir pistas adicionais?</string>
     <string name="rtp_state_connecting">Conectando</string>
     <string name="rtp_state_connected">Conectado</string>
     <string name="rtp_state_reconnecting">Reconectando</string>
@@ -931,6 +936,18 @@
     <string name="outgoing_call">Chamada realizada</string>
     <string name="outgoing_call_duration">Conversa de · %s</string>
     <string name="missed_call">Chamada perdida</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d chamada perdida de %2$s</item>
+        <item quantity="other">%1$d chamadas perdidas de %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d chamada perdida</item>
+        <item quantity="other">%d chamadas perdidas</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d chamadas perdidas de %2$d contacto</item>
+        <item quantity="other">%1$d chamadas perdidas de %2$d contactos</item>
+    </plurals>
     <string name="audio_call">Chamada de audio</string>
     <string name="video_call">Chamada de vídeo</string>
     <string name="help">Axuda</string>
@@ -976,5 +993,9 @@
     <string name="account_registrations_are_not_supported">Non está permitido o rexistro de novas contas</string>
     <string name="no_xmpp_adddress_found">Non se atopa un enderezo XMPP</string>
     <string name="account_status_temporary_auth_failure">Fallo temporal da autenticación</string>
+    <string name="delete_avatar">Eliminar avatar</string>
+    <string name="audio_video_disabled_tor">As chamadas están desactivadas cando usas Tor</string>
+    <string name="switch_to_video">Cambiar a vídeo</string>
+    <string name="reject_switch_to_video">Rexeitar a solicitude para cambiar a vídeo</string>
 
 </resources>
  
  
  
    
    @@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="action_settings">Postavke</string>
+    <string name="action_add">Novi razgovor</string>
+    <string name="action_accounts">Upravljanje računima</string>
+    <string name="action_account">Upravljaj računom</string>
+    <string name="action_end_conversation">Zatvori razgovor</string>
+    <string name="action_contact_details">Kontakt podaci</string>
+    <string name="action_muc_details">Pojedinosti grupnog razgovora</string>
+    <string name="channel_details">Detalji kanala</string>
+    <string name="action_add_account">Dodaj račun</string>
+    <string name="action_edit_contact">Uredi ime</string>
+    <string name="action_add_phone_book">Dodaj u adresar</string>
+    <string name="action_delete_contact">Izbriši s popisa</string>
+    <string name="action_block_contact">Blokiraj kontakt</string>
+    <string name="action_unblock_contact">Odblokiraj kontakt</string>
+    <string name="action_block_domain">Blokiraj domenu</string>
+    <string name="action_unblock_domain">Odblokiraj domenu</string>
+    <string name="action_block_participant">Blokiraj sudionika</string>
+    <string name="action_unblock_participant">Deblokiraj sudionika</string>
+    <string name="title_activity_manage_accounts">Upravljanje računima</string>
+    <string name="title_activity_settings">Postavke</string>
+    <string name="title_activity_sharewith">Dijeli s Conversation</string>
+    <string name="title_activity_start_conversation">Započni razgovor</string>
+    <string name="title_activity_choose_contact">Odaberite Kontakt</string>
+    <string name="title_activity_choose_contacts">Odaberite kontakte</string>
+    <string name="title_activity_share_via_account">Dijeli putem računa</string>
+    <string name="title_activity_block_list">Lista blokiranih</string>
+    <string name="just_now">upravo sad</string>
+    <string name="minute_ago">prije 1 min</string>
+    <string name="minutes_ago">prije %d min</string>
+    <plurals name="x_unread_conversations">
+        <item quantity="one">%d nepročitan razgovor</item>
+
+    
+        <item quantity="few">%d nepročitanih razgovora</item>
+
+    
+        <item quantity="other">%d nepročitani razgovori</item>
+
+    </plurals>
+    <string name="sending">slanje…</string>
+    <string name="message_decrypting">Dešifriranje poruke. Molimo pričekajte…</string>
+    <string name="pgp_message">OpenPGP šifrirana poruka</string>
+    <string name="nick_in_use">Nadimak je već u upotrebi</string>
+    <string name="invalid_muc_nick">Nevažeći nadimak</string>
+    <string name="admin">Admin</string>
+    <string name="owner">Vlasnik</string>
+    <string name="moderator">Moderator</string>
+    <string name="participant">Sudionik</string>
+    <string name="visitor">Posjetitelj</string>
+    <string name="remove_contact_text">Želite li ukloniti  %s s popisa kontakata? Razgovori s ovim kontaktom neće biti uklonjeni.</string>
+    <string name="block_contact_text">Želite li blokirati %s da vam šalje poruke?</string>
+    <string name="unblock_contact_text">Želite li deblokirati %s i dopustiti im da vam šalju poruke?</string>
+    <string name="block_domain_text">Blokirati sve kontakte iz %s?</string>
+    <string name="unblock_domain_text">Deblokirati sve kontakte iz %s?</string>
+    <string name="contact_blocked">Kontakt blokiran</string>
+    <string name="blocked">Blokiran</string>
+    <string name="remove_bookmark_text">Želite li ukloniti %s kao oznaku? Razgovori s ovom knjižnom oznakom neće biti uklonjeni.</string>
+    <string name="register_account">Registrirajte novi račun na poslužitelju</string>
+    <string name="change_password_on_server">Promjena lozinke na poslužitelju</string>
+    <string name="share_with">Podijeli s…</string>
+    <string name="start_conversation">Započni razgovor</string>
+    <string name="invite_contact">Pozovi kontakt</string>
+    <string name="invite">Pozovi</string>
+    <string name="contacts">Kontakti</string>
+    <string name="contact">Kontakt</string>
+    <string name="cancel">Otkazati</string>
+    <string name="add">Dodati</string>
+    <string name="edit">Uredi</string>
+    <string name="delete">Obriši</string>
+    <string name="block">Blok</string>
+    <string name="unblock">Odblokiraj</string>
+    <string name="save">Sačuvaj</string>
+    <string name="ok">Ok</string>
+    </resources>
  
  
  
    
    @@ -289,8 +289,6 @@
     <string name="title_pref_enable_quiet_hours">Csendes órák engedélyezése</string>
     <string name="pref_quiet_hours_summary">Az értesítések el lesznek némítva a csendes órák alatt</string>
     <string name="pref_expert_options_other">Egyéb</string>
-    <string name="pref_autojoin">Szinkronizálás a könyvjelzőkkel</string>
-    <string name="pref_autojoin_summary">Automatikusan csatlakozzon a csoportos csevegésekhez, ha ez szerepel a könyvjelzőben</string>
     <string name="toast_message_omemo_fingerprint">OMEMO ujjlenyomat a vágólapra lett másolva</string>
     <string name="conference_banned">Ki van tiltva ebből a csoportos csevegésből</string>
     <string name="conference_members_only">Ez a csoportos csevegés csak tagoknak szól</string>
  
  
  
    
    @@ -173,6 +173,7 @@
     <string name="account_status_tls_error_domain">Dominio non verificabile</string>
     <string name="account_status_policy_violation">Violazione della policy</string>
     <string name="account_status_incompatible_server">Server non compatibile</string>
+    <string name="account_status_incompatible_client">Client non compatibile</string>
     <string name="account_status_stream_error">Errore di stream</string>
     <string name="account_status_stream_opening_error">Errore apertura flusso</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
     <string name="title_pref_enable_quiet_hours">Attiva ore di quiete</string>
     <string name="pref_quiet_hours_summary">Le notifiche verranno silenziate durante le ore di quiete</string>
     <string name="pref_expert_options_other">Altro</string>
-    <string name="pref_autojoin">Sincronizza con i segnalibri</string>
-    <string name="pref_autojoin_summary">Entra nelle chat di gruppo automaticamente se il segnalibro dice così</string>
+    <string name="pref_autojoin">Sincronizza i segnalibri</string>
+    <string name="pref_autojoin_summary">Imposta il flag \"auto-entrata\" quando entri o esci da un MUC e reagisci alle modifiche fatte dagli altri client.</string>
     <string name="toast_message_omemo_fingerprint">Impronta OMEMO copiata negli appunti</string>
     <string name="conference_banned">Sei stato bandito da questa chat di gruppo</string>
     <string name="conference_members_only">Questa chat di gruppo è solo per membri</string>
@@ -306,6 +307,7 @@
     <string name="conference_kicked">Sei stato buttato fuori da questa chat di gruppo</string>
     <string name="conference_shutdown">La chat di gruppo è stata chiusa</string>
     <string name="conference_unknown_error">Non sei più in questa chat di gruppo</string>
+    <string name="conference_technical_problems">Hai lasciato questa chat di gruppo per motivi tecnici</string>
     <string name="using_account">usando il profilo %s</string>
     <string name="hosted_on">ospitato su %s</string>
     <string name="checking_x">Controllo %s su host HTTP</string>
@@ -420,6 +422,7 @@
     <string name="video">video</string>
     <string name="image">immagine</string>
     <string name="vector_graphic">grafica vettoriale</string>
+    <string name="multimedia_file">file multimediale</string>
     <string name="pdf_document">Documento PDF</string>
     <string name="apk">Applicazione Android</string>
     <string name="vcard">Contatto</string>
@@ -774,6 +777,7 @@
     <string name="messages_channel_name">Messaggi</string>
     <string name="incoming_calls_channel_name">Chiamate in arrivo</string>
     <string name="ongoing_calls_channel_name">Chiamate in uscita</string>
+    <string name="missed_calls_channel_name">Chiamate perse</string>
     <string name="silent_messages_channel_name">Messaggi silenziosi</string>
     <string name="silent_messages_channel_description">Questo gruppo di notifiche è usato per mostrare notifiche che non devono riprodurre alcun suono. Ad esempio mentre si è attivi su un altro dispositivo (Periodo di grazia).</string>
     <string name="delivery_failed_channel_name">Recapiti falliti</string>
@@ -914,6 +918,8 @@
     <string name="make_call">Chiama</string>
     <string name="rtp_state_incoming_call">Chiamata in arrivo</string>
     <string name="rtp_state_incoming_video_call">Chiamata video in arrivo</string>
+    <string name="rtp_state_content_add_video">Passare a una videochiamata?</string>
+    <string name="rtp_state_content_add">Aggiungere altre tracce?</string>
     <string name="rtp_state_connecting">Connessione</string>
     <string name="rtp_state_connected">Connesso</string>
     <string name="rtp_state_reconnecting">Riconnessione</string>
@@ -941,6 +947,21 @@
     <string name="outgoing_call">Chiamata in uscita</string>
     <string name="outgoing_call_duration">Chiamata in uscita · %s</string>
     <string name="missed_call">Chiamata persa</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d chiamata persa da %2$s</item>
+        <item quantity="many">%1$d chiamate perse da %2$s</item>
+        <item quantity="other">%1$d chiamate perse da %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d chiamata persa</item>
+        <item quantity="many">%d chiamate perse</item>
+        <item quantity="other">%d chiamate perse</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d chiamate perse da %2$d contatto</item>
+        <item quantity="many">%1$d chiamate perse da %2$d contatti</item>
+        <item quantity="other">%1$d chiamate perse da %2$d contatti</item>
+    </plurals>
     <string name="audio_call">Chiamata vocale</string>
     <string name="video_call">Chiamata video</string>
     <string name="help">Aiuto</string>
@@ -988,5 +1009,9 @@
     <string name="account_registrations_are_not_supported">Le registrazioni di profili non sono supportate</string>
     <string name="no_xmpp_adddress_found">Nessun indirizzo XMPP trovato</string>
     <string name="account_status_temporary_auth_failure">Errore di autenticazione temporaneo</string>
+    <string name="delete_avatar">Elimina avatar</string>
+    <string name="audio_video_disabled_tor">Le chiamate sono disattivate quando si usa Tor</string>
+    <string name="switch_to_video">Passa al video</string>
+    <string name="reject_switch_to_video">Rifiuta richiesta di passare al video</string>
 
 </resources>
  
  
  
    
    @@ -291,8 +291,7 @@
     <string name="title_pref_enable_quiet_hours">消音時間を有効化</string>
     <string name="pref_quiet_hours_summary">消音時間の間、通知は無音になります</string>
     <string name="pref_expert_options_other">その他</string>
-    <string name="pref_autojoin">ブックマークと同期</string>
-    <string name="pref_autojoin_summary">ブックマークに従って、グループチャットに自動で参加します。</string>
+    <string name="pref_autojoin">ブックマーク同期</string>
     <string name="toast_message_omemo_fingerprint">OMEMO フィンガープリントをクリップボードにコピーしました</string>
     <string name="conference_banned">このグループチャットから出禁にされています</string>
     <string name="conference_members_only">このグループチャットはメンバー制です</string>
@@ -300,6 +299,7 @@
     <string name="conference_kicked">このグループチャットから蹴り出されています</string>
     <string name="conference_shutdown">このグループチャットは閉鎖されました</string>
     <string name="conference_unknown_error">あなたはもうこのグループチャットに参加していません</string>
+    <string name="conference_technical_problems">技術的理由の為、あなたはこのグループチャットを離れました</string>
     <string name="using_account">アカウント %s を使用</string>
     <string name="hosted_on">%s 上でホストされた</string>
     <string name="checking_x">HTTP ホスト上の %s を確認中</string>
@@ -414,6 +414,7 @@
     <string name="video">ビデオ</string>
     <string name="image">画像</string>
     <string name="vector_graphic">ベクター画像</string>
+    <string name="multimedia_file">マルチメディアファイル</string>
     <string name="pdf_document">PDF 文書</string>
     <string name="apk">Android アプリ</string>
     <string name="vcard">連絡先</string>
@@ -956,5 +957,5 @@
     <string name="account_registrations_are_not_supported">アカウント登録はサポートされていません</string>
     <string name="no_xmpp_adddress_found">XMPPアドレスがみつかりません</string>
     <string name="account_status_temporary_auth_failure">一時的な認証失敗</string>
-
-</resources>
+    <string name="delete_avatar">アバターを削除</string>
+    </resources>
  
  
  
    
    @@ -276,7 +276,6 @@
     <string name="title_pref_enable_quiet_hours">Stille uren inschakelen</string>
     <string name="pref_quiet_hours_summary">Tijdens stille uren worden meldingen onderdrukt</string>
     <string name="pref_expert_options_other">Andere</string>
-    <string name="pref_autojoin">Synchroniseren met bladwijzers</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-vingerafdruk gekopieerd naar klembord</string>
     <string name="conference_banned">Je bent verbannen uit dit groepsgesprek</string>
     <string name="conference_members_only">Dit groepsgesprek is enkel voor leden</string>
  
  
  
    
    @@ -176,6 +176,7 @@
     <string name="account_status_tls_error_domain">Nie można zweryfikować tej domeny</string>
     <string name="account_status_policy_violation">Naruszenie zasad</string>
     <string name="account_status_incompatible_server">Serwer niekompatybilny</string>
+    <string name="account_status_incompatible_client">Niekompatybilny klient</string>
     <string name="account_status_stream_error">Błąd strumienia</string>
     <string name="account_status_stream_opening_error">Błąd otwierania strumienia</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -300,8 +301,8 @@
     <string name="title_pref_enable_quiet_hours">Włącz godziny ciszy</string>
     <string name="pref_quiet_hours_summary">Powiadomienia będą wyciszone w wybranym przedziale czasu</string>
     <string name="pref_expert_options_other">Inne</string>
-    <string name="pref_autojoin">Synchronizuj z zakładkami</string>
-    <string name="pref_autojoin_summary">Dołączaj do rozmów grupowych automatycznie jeśli na to wskazuje zakładka</string>
+    <string name="pref_autojoin">Synchronizuj zakładki</string>
+    <string name="pref_autojoin_summary">Ustaw flagę automatycznego dołączania przy wchodzeniu lub opuszczaniu pokoju i reaguj na zmiany innych klientów</string>
     <string name="toast_message_omemo_fingerprint">Odcisk klucza OMEMO został skopiowany do schowka</string>
     <string name="conference_banned">Zbanowany</string>
     <string name="conference_members_only">Konferencja tylko dla użytkowników</string>
@@ -309,6 +310,7 @@
     <string name="conference_kicked">Wykopany</string>
     <string name="conference_shutdown">Konferencja została zamknięta</string>
     <string name="conference_unknown_error">Nie uczestniczysz już w tej konferencji</string>
+    <string name="conference_technical_problems">Opuszczono rozmowę grupową z powodu usterki technicznej</string>
     <string name="using_account">używając konta %s</string>
     <string name="hosted_on">udostępnione na %s</string>
     <string name="checking_x">Sprawdzanie %s na hoście HTTP</string>
@@ -787,6 +789,7 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
     <string name="messages_channel_name">Wiadomości</string>
     <string name="incoming_calls_channel_name">Połączenia przychodzące</string>
     <string name="ongoing_calls_channel_name">Połączenia wychodzące</string>
+    <string name="missed_calls_channel_name">Nieodebrane rozmowy</string>
     <string name="silent_messages_channel_name">Ciche wiadomości</string>
     <string name="silent_messages_channel_description">Ta kategoria powiadomień jest używana aby wyświetlać powiadomienia które nie powodują żadnych dźwięków. Na przykład w ciągu aktywności na innym urządzeniu (okres karencji).</string>
     <string name="delivery_failed_channel_name">Nie dostarczone wiadomości</string>
@@ -927,6 +930,8 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
     <string name="make_call">Zadzwoń</string>
     <string name="rtp_state_incoming_call">Połączenie przychodzące</string>
     <string name="rtp_state_incoming_video_call">Wideorozmowa przychodząca</string>
+    <string name="rtp_state_content_add_video">Przełączyć na rozmowę wideo?</string>
+    <string name="rtp_state_content_add">Włączyć dodatkowe ścieżki?</string>
     <string name="rtp_state_connecting">Łączenie</string>
     <string name="rtp_state_connected">Połączony</string>
     <string name="rtp_state_reconnecting">Ponowne łączenie</string>
@@ -954,6 +959,24 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
     <string name="outgoing_call">Połączenie wychodzące</string>
     <string name="outgoing_call_duration">Połączenie wychodzące · %s</string>
     <string name="missed_call">Nieodebrane połączenie</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d nieodebrana rozmowa od %2$s</item>
+        <item quantity="few">%1$d nieodebrane rozmowy od %2$s</item>
+        <item quantity="many">%1$d nieodebranych rozmów od %2$s</item>
+        <item quantity="other">%1$d nieodebranych rozmów od %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d nieodebrana rozmowa</item>
+        <item quantity="few">%d nieodebrane rozmowy</item>
+        <item quantity="many">%d nieodebranych rozmów</item>
+        <item quantity="other">%d nieodebranych rozmów</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d nieodebrana rozmowa od %2$d kontaktu</item>
+        <item quantity="few">%1$d nieodebrane rozmowy od %2$d kontaktu</item>
+        <item quantity="many">%1$d nieodebranych rozmów od %2$d kontaktów</item>
+        <item quantity="other">%1$d nieodebranych rozmów od %2$d kontaktów</item>
+    </plurals>
     <string name="audio_call">Połączenie audio</string>
     <string name="video_call">Połączenie wideo</string>
     <string name="help">Pomoc</string>
@@ -1003,5 +1026,9 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
     <string name="account_registrations_are_not_supported">Rejestracja kont nie jest wspierana</string>
     <string name="no_xmpp_adddress_found">Nie znaleziono adresu XMPP</string>
     <string name="account_status_temporary_auth_failure">Tymczasowy błąd uwierzytelniania</string>
+    <string name="delete_avatar">Usuń awatar</string>
+    <string name="audio_video_disabled_tor">Dzwonienie jest wyłączone podczas używania Tora</string>
+    <string name="switch_to_video">Przełącz na wideo</string>
+    <string name="reject_switch_to_video">Odrzuć prośbę przełączenia na wideo</string>
 
 </resources>
  
  
  
    
    @@ -173,6 +173,7 @@
     <string name="account_status_tls_error_domain">Domínio não verificável</string>
     <string name="account_status_policy_violation">Violação de política</string>
     <string name="account_status_incompatible_server">Servidor incompatível</string>
+    <string name="account_status_incompatible_client">Cliente incompatível</string>
     <string name="account_status_stream_error">Erro de fluxo</string>
     <string name="account_status_stream_opening_error">Erro na abertura do fluxo</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
     <string name="title_pref_enable_quiet_hours">Habilitar horário de sossego</string>
     <string name="pref_quiet_hours_summary">As notificações serão silenciadas no horário de sossego.</string>
     <string name="pref_expert_options_other">Outras</string>
-    <string name="pref_autojoin">Sincronizar com os favoritos</string>
-    <string name="pref_autojoin_summary">Entre nas conversas em grupo automaticamente caso isso esteja definido no favorito</string>
+    <string name="pref_autojoin">Sincronizar favoritos</string>
+    <string name="pref_autojoin_summary">Define a flag \"autojoin\" ao entrar ou sair de uma sala e reage a modificações feitas por outros clientes.</string>
     <string name="toast_message_omemo_fingerprint">Impressão digital OMEMO copiada para a área de transferência</string>
     <string name="conference_banned">Você foi banido desta conversa em grupo</string>
     <string name="conference_members_only">Somente membros podem entrar nessa conversa em grupo</string>
@@ -306,6 +307,7 @@
     <string name="conference_kicked">Você foi retirado desta conversa em grupo</string>
     <string name="conference_shutdown">A conversa em grupo foi encerrada</string>
     <string name="conference_unknown_error">Você não está mais nesta conversa em grupo</string>
+    <string name="conference_technical_problems">Você saiu desta conversa em grupo devido a razões técnicas</string>
     <string name="using_account">usando a conta %s</string>
     <string name="hosted_on">hospedado em %s</string>
     <string name="checking_x">Verificando %s no host HTTP</string>
@@ -775,6 +777,7 @@
     <string name="messages_channel_name">Mensagens</string>
     <string name="incoming_calls_channel_name">Chamadas recebidas</string>
     <string name="ongoing_calls_channel_name">Chamadas em andamento</string>
+    <string name="missed_calls_channel_name">Chamadas perdidas</string>
     <string name="silent_messages_channel_name">Silenciar mensagens</string>
     <string name="silent_messages_channel_description">Essa categoria de notificação é utilizada para exibir notificações que não deveriam gerar nenhum som. Por exemplo, quando estiver ativo em outro dispositivo (Período de Espera).</string>
     <string name="delivery_failed_channel_name">Entregas não efetuadas</string>
@@ -915,6 +918,8 @@
     <string name="make_call">Fazer chamada</string>
     <string name="rtp_state_incoming_call">Recebendo chamada</string>
     <string name="rtp_state_incoming_video_call">Recebendo chamada de vídeo</string>
+    <string name="rtp_state_content_add_video">Mudar para videochamada?</string>
+    <string name="rtp_state_content_add">Adicionar outras trilhas?</string>
     <string name="rtp_state_connecting">Conectando</string>
     <string name="rtp_state_connected">Conectado</string>
     <string name="rtp_state_reconnecting">Reconectando</string>
@@ -942,6 +947,21 @@
     <string name="outgoing_call">Chamada realizada</string>
     <string name="outgoing_call_duration">Chamada realizada · %s</string>
     <string name="missed_call">Chamada perdida</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d chamada perdida para %2$s</item>
+        <item quantity="many">%1$d chamadas perdidas para %2$s</item>
+        <item quantity="other">%1$d chamadas perdidas para %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d chamada perdida</item>
+        <item quantity="many">%d chamadas perdidas</item>
+        <item quantity="other">%d chamadas perdidas</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d chamadas perdidas de %2$d contato</item>
+        <item quantity="many">%1$d chamadas perdidas de %2$d contatos</item>
+        <item quantity="other">%1$d chamadas perdidas de %2$d contatos</item>
+    </plurals>
     <string name="audio_call">Chamada de áudio</string>
     <string name="video_call">Chamada de vídeo</string>
     <string name="help">Ajuda</string>
@@ -989,5 +1009,9 @@
     <string name="account_registrations_are_not_supported">O registro de contas não está ativo</string>
     <string name="no_xmpp_adddress_found">Não foi encontrado nenhum endereço XMPP</string>
     <string name="account_status_temporary_auth_failure">Falha temporária na autenticação</string>
+    <string name="delete_avatar">Excluir avatar</string>
+    <string name="audio_video_disabled_tor">As chamadas estão desabilitadas ao usar Tor</string>
+    <string name="switch_to_video">Mudar para vídeo</string>
+    <string name="reject_switch_to_video">Recusar requisição de mudança para vídeo</string>
 
 </resources>
  
  
  
    
    @@ -173,6 +173,7 @@
     <string name="account_status_tls_error_domain">Domeniul nu se poate verifica</string>
     <string name="account_status_policy_violation">Încălcare condiții furnizare serviciu</string>
     <string name="account_status_incompatible_server">Server incompatibil</string>
+    <string name="account_status_incompatible_client">Client incompatibil</string>
     <string name="account_status_stream_error">Eroare de date</string>
     <string name="account_status_stream_opening_error">Eroare deschidere flux de date</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
     <string name="title_pref_enable_quiet_hours">Activează orar de liniște</string>
     <string name="pref_quiet_hours_summary">Notificările vor fi reduse la tăcere în timpul orelor de liniște</string>
     <string name="pref_expert_options_other">Altele</string>
-    <string name="pref_autojoin">Sincronizează cu semnele de carte</string>
-    <string name="pref_autojoin_summary">Alătură-te discuției de grup în mod automat dacă semnul de carte este setat așa</string>
+    <string name="pref_autojoin">Sincronizare semne de carte</string>
+    <string name="pref_autojoin_summary">Setați \"autojoin\" la intrarea sau ieșirea dintr-o discuție de grup și reacționați la modificările efectuate de alți clienți.</string>
     <string name="toast_message_omemo_fingerprint">Amprentă OMEMO copiată în memorie</string>
     <string name="conference_banned">V-a fost interzis accesul la această discuție de grup</string>
     <string name="conference_members_only">Această discuție de grup este rezervată membrilor</string>
@@ -306,6 +307,7 @@
     <string name="conference_kicked">Ați fost dat(ă) afară din această discuție de grup</string>
     <string name="conference_shutdown">Discuția de grup a fost închisă</string>
     <string name="conference_unknown_error">Nu mai sunteți în această discuție de grup</string>
+    <string name="conference_technical_problems">Ați părăsit această discuție de grup din motive tehnice</string>
     <string name="using_account">folosind cont %s</string>
     <string name="hosted_on">găzduit pe %s</string>
     <string name="checking_x">Verifica %s pe gazda HTTP</string>
@@ -775,6 +777,7 @@
     <string name="messages_channel_name">Mesaje</string>
     <string name="incoming_calls_channel_name">Apeluri primite</string>
     <string name="ongoing_calls_channel_name">Apeluri în curs</string>
+    <string name="missed_calls_channel_name">Apeluri pierdute</string>
     <string name="silent_messages_channel_name">Mesaje silențioase</string>
     <string name="silent_messages_channel_description">Acest grup de notificări este folosit pentru a arăta notificări care nu emit sunete. De exemplu atunci când sunteți activi pe un alt dispozitiv (Perioada de grație).</string>
     <string name="delivery_failed_channel_name">Trimiteri eșuate</string>
@@ -915,6 +918,8 @@
     <string name="make_call">Apelează</string>
     <string name="rtp_state_incoming_call">Apel primit</string>
     <string name="rtp_state_incoming_video_call">Apel video primit</string>
+    <string name="rtp_state_content_add_video">Comută la apel video?</string>
+    <string name="rtp_state_content_add">Adăugați canale suplimentare?</string>
     <string name="rtp_state_connecting">Conectare</string>
     <string name="rtp_state_connected">Conectat</string>
     <string name="rtp_state_reconnecting">Reconectare</string>
@@ -942,6 +947,21 @@
     <string name="outgoing_call">Apel efectuat</string>
     <string name="outgoing_call_duration">Apel efectuat · %s</string>
     <string name="missed_call">Apel pierdut</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d apel pierdut de la %2$s</item>
+        <item quantity="few">%1$d apeluri pierdute de la %2$s</item>
+        <item quantity="other">%1$d de apeluri pierdute de la %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d apel pierdut</item>
+        <item quantity="few">%d apeluri pierdute</item>
+        <item quantity="other">%d de apeluri pierdute</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d apel pierdut de la %2$d contact</item>
+        <item quantity="few">%1$d apeluri pierdute de la %2$d contact</item>
+        <item quantity="other">%1$d de apeluri pierdute de la %2$d contacte</item>
+    </plurals>
     <string name="audio_call">Apel audio</string>
     <string name="video_call">Apel video</string>
     <string name="help">Ajutor</string>
@@ -989,5 +1009,9 @@
     <string name="account_registrations_are_not_supported">Nu este posibilă înregistrarea unui cont</string>
     <string name="no_xmpp_adddress_found">Nu a fost găsită o adresă XMPP</string>
     <string name="account_status_temporary_auth_failure">Eroare temporară de autentificare</string>
+    <string name="delete_avatar">Șterge avatar</string>
+    <string name="audio_video_disabled_tor">Apelurile sunt dezactivate atunci când utilizați Tor</string>
+    <string name="switch_to_video">Comută la video</string>
+    <string name="reject_switch_to_video">Respinge solicitarea de comutare la video</string>
 
 </resources>
  
  
  
    
    @@ -300,8 +300,6 @@
     <string name="title_pref_enable_quiet_hours">Включить режим «тихих часов»</string>
     <string name="pref_quiet_hours_summary">Уведомления будут отключены во время «тихих часов»</string>
     <string name="pref_expert_options_other">Другие</string>
-    <string name="pref_autojoin">Синхронизировать с закладками</string>
-    <string name="pref_autojoin_summary">Автоматически заходить в конференции при установленном флаге в настройках закладки</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-отпечаток скопирован в буфер обмена</string>
     <string name="conference_banned">Вы заблокированы в этой конференции</string>
     <string name="conference_members_only">Эта конференция — только для участников</string>
  
  
  
    
    @@ -287,8 +287,6 @@
     <string name="title_pref_enable_quiet_hours">Povoliť tichý režim</string>
     <string name="pref_quiet_hours_summary">Upozornenia budú počas tichého režimu stlmené</string>
     <string name="pref_expert_options_other">Ďalší</string>
-    <string name="pref_autojoin">Synchronizovať so záložkami</string>
-    <string name="pref_autojoin_summary">Automaticky sa pripojiť k skupinovému rozhovoru, ak to hovorí záložka</string>
     <string name="toast_message_omemo_fingerprint">OMEMO odtlačok skopírovaný do schránky</string>
     <string name="conference_banned">Ste zakázaný na tomto skupinovom rozhovore</string>
     <string name="conference_members_only">Skupinový rozhovor len pre členov</string>
  
  
  
    
    @@ -293,8 +293,6 @@
     <string name="title_pref_enable_quiet_hours">Укључи тихе сате</string>
     <string name="pref_quiet_hours_summary">Обавештења ће бити ућуткана за време тихих сати</string>
     <string name="pref_expert_options_other">Остало</string>
-    <string name="pref_autojoin">Синхронизуј са обележивачима</string>
-    <string name="pref_autojoin_summary">Аутоматски се придружите групним ћаскањима по поставци обележивача</string>
     <string name="toast_message_omemo_fingerprint">ОМЕМО отисак копиран на клипборд</string>
     <string name="conference_banned">Забрањен вам је приступ овом групном ћаскању</string>
     <string name="conference_members_only">Ово групно ћаскање је само за чланове</string>
  
  
  
    
    @@ -294,8 +294,6 @@
     <string name="title_pref_enable_quiet_hours">Aktivera tysta timmar</string>
     <string name="pref_quiet_hours_summary">Notifieringar kommer vara tysta under tysta timmar</string>
     <string name="pref_expert_options_other">Annat</string>
-    <string name="pref_autojoin">Synkronisera med bokmärken</string>
-    <string name="pref_autojoin_summary">Gå med i gruppchattar automatiskt om bokmärket säger det</string>
     <string name="toast_message_omemo_fingerprint">OMEMO-fingeravtryck kopierat till urklipp</string>
     <string name="conference_banned">Du är avstängd från denna gruppchatt</string>
     <string name="conference_members_only">Denna gruppchatt är endast för medlemmar</string>
  
  
  
    
    @@ -316,8 +316,6 @@
     <string name="title_pref_enable_quiet_hours">Włōncz godziny cisze</string>
     <string name="pref_quiet_hours_summary">Powiadōmiynia bydōm wyciszōne we ôbranych godzinach</string>
     <string name="pref_expert_options_other">Inksze</string>
-    <string name="pref_autojoin">Synchrōnizuj ze zokłodkami</string>
-    <string name="pref_autojoin_summary">Przistympuj do godek grupowych autōmatycznie, jeźli tak pado zokłodka</string>
     <string name="toast_message_omemo_fingerprint">Ôdcisk klucza OMEMO bōł skopiowany do skrytki</string>
     <string name="conference_banned">Ôd tyj grupy mosz wykluczynie</string>
     <string name="conference_members_only">Kōnferyncyjo ino dlo czōnkōw</string>
  
  
  
    
    @@ -170,6 +170,7 @@
     <string name="account_status_tls_error_domain">Alan adı doğrulanamıyor</string>
     <string name="account_status_policy_violation">Politika ihlali</string>
     <string name="account_status_incompatible_server">Sunucu uyuşmazlığı</string>
+    <string name="account_status_incompatible_client">Uyumsuz istemci</string>
     <string name="account_status_stream_error">Akış hatası</string>
     <string name="account_status_stream_opening_error">Akış açılım hatası</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -294,8 +295,7 @@
     <string name="title_pref_enable_quiet_hours">Sessiz saatleri etkinleştir</string>
     <string name="pref_quiet_hours_summary">Bildirimler sessiz saatler boyunca sessize alınacaktır</string>
     <string name="pref_expert_options_other">Diğer</string>
-    <string name="pref_autojoin">Yer imleri ile senkronize et.</string>
-    <string name="pref_autojoin_summary">Yer imleri öyle belirtmişse grup konuşmalarına otomatik olarak katıl.</string>
+    <string name="pref_autojoin">Yer imleriyle senkronize et</string>
     <string name="toast_message_omemo_fingerprint">OMEMO parmak izi panoya kopyalandı</string>
     <string name="conference_banned">Bu grup konuşmasından menedildiniz</string>
     <string name="conference_members_only">Bu grup konuşması yalnızca üyeleri içindir</string>
@@ -303,6 +303,7 @@
     <string name="conference_kicked">Bu grup konuşmasından atıldınız</string>
     <string name="conference_shutdown">Grup konuşması kapatıldı</string>
     <string name="conference_unknown_error">Artık bu grup konuşmasında değilsiniz</string>
+    <string name="conference_technical_problems">Teknik sebeplerden dolayı bu grup sohbetinden ayrıldınız</string>
     <string name="using_account">%s hesabını kullanarak</string>
     <string name="hosted_on">%sev sahipliğinde</string>
     <string name="checking_x">HTTP sunucusundaki %s denetleniyor</string>
@@ -417,6 +418,7 @@
     <string name="video">video</string>
     <string name="image">görüntü</string>
     <string name="vector_graphic">Vektör grafik</string>
+    <string name="multimedia_file">Multimedya dosyası</string>
     <string name="pdf_document">PDF belgesi</string>
     <string name="apk">Android uygulaması</string>
     <string name="vcard">Kişi</string>
@@ -763,6 +765,7 @@
     <string name="messages_channel_name">İletiler</string>
     <string name="incoming_calls_channel_name">Gelen aramalar</string>
     <string name="ongoing_calls_channel_name">Yapılan aramalar</string>
+    <string name="missed_calls_channel_name">Cevapsız aramalar</string>
     <string name="silent_messages_channel_name">Sessiz iletiler</string>
     <string name="silent_messages_channel_description">Bu bildirim grubu, bildirimlerin herhangi bir ses çıkarmaması gerektiğini belirtmekte kullanılır. Mesela başka bir cihazda aktif olunduğunda (Mühlet)</string>
     <string name="delivery_failed_channel_name">Başarısız gönderiler</string>
@@ -930,6 +933,18 @@
     <string name="outgoing_call">Yapılan arama</string>
     <string name="outgoing_call_duration">Yapılan arama. %s</string>
     <string name="missed_call">Cevapsız arama</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one"> %2$s tarafından %1$d cevapsız çağrı</item>
+        <item quantity="other">%2$s tarafından %1$d cevapsız çağrı </item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d cevapsız çağrı</item>
+        <item quantity="other">%d cevapsız çağrı </item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%2$d tarafından %1$d cevapsız çağrı</item>
+        <item quantity="other">%2$d kişi tarafından %1$d cevapsız çağrı</item>
+    </plurals>
     <string name="audio_call">Sesli arama</string>
     <string name="video_call">Görüntülü arama</string>
     <string name="help">Yardım</string>
@@ -974,4 +989,7 @@
     <string name="plain_text_document">Düz metin dosyası</string>
     <string name="account_registrations_are_not_supported">Hesap kayıtları desteklenmemektedir.</string>
     <string name="no_xmpp_adddress_found">Herhangi bir XMPP adresi bulunamadı</string>
+    <string name="account_status_temporary_auth_failure">Geçici doğrulama hatası</string>
+    <string name="delete_avatar">Avatar\'ı sil</string>
+    <string name="audio_video_disabled_tor">Tor kullanırken çağrılar devre dışı</string>
     </resources>
  
  
  
    
    @@ -276,8 +276,6 @@
     <string name="title_pref_enable_quiet_hours">Увімкнути години тиші</string>
     <string name="pref_quiet_hours_summary">Сповіщення не звучатимуть під час годин тиші</string>
     <string name="pref_expert_options_other">Інше</string>
-    <string name="pref_autojoin">Синхронізовувати з закладками</string>
-    <string name="pref_autojoin_summary">Приєднуватися до груп і полишати їх відповідно до опції автоматичного приєднання, вибраної в закладках.</string>
     <string name="toast_message_omemo_fingerprint">Цифровий підпис OMEMO скопійовано</string>
     <string name="conference_banned">Вам заборонили доступ до цієї групи</string>
     <string name="conference_members_only">Ця група лише для учасників</string>
  
  
  
    
    @@ -291,8 +291,6 @@
     <string name="title_pref_enable_quiet_hours">Bật giờ yên lặng</string>
     <string name="pref_quiet_hours_summary">Thông báo sẽ được tắt trong giờ yên lặng</string>
     <string name="pref_expert_options_other">Khác</string>
-    <string name="pref_autojoin">Đồng bộ hoá bằng dấu trang</string>
-    <string name="pref_autojoin_summary">Tự động tham gia các cuộc trò chuyện nhóm nếu dấu trang bảo thế</string>
     <string name="toast_message_omemo_fingerprint">Đã sao chép mã vân tay OMEMO vào bộ nhớ tạm</string>
     <string name="conference_banned">Bạn bị cấm khỏi cuộc trò chuyện nhóm này</string>
     <string name="conference_members_only">Cuộc trò chuyện nhóm này chỉ dành cho thành viên</string>
  
  
  
    
    @@ -167,6 +167,7 @@
     <string name="account_status_tls_error_domain">域名不可验证</string>
     <string name="account_status_policy_violation">违反政策</string>
     <string name="account_status_incompatible_server">服务器不兼容</string>
+    <string name="account_status_incompatible_client">不兼容的客户端</string>
     <string name="account_status_stream_error">流错误</string>
     <string name="account_status_stream_opening_error">流打开错误</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -291,8 +292,8 @@
     <string name="title_pref_enable_quiet_hours">启用静默时间段</string>
     <string name="pref_quiet_hours_summary">在静默时间段内通知将保持静音</string>
     <string name="pref_expert_options_other">其他</string>
-    <string name="pref_autojoin">与书签同步</string>
-    <string name="pref_autojoin_summary">根据书签标记自动加入群聊。</string>
+    <string name="pref_autojoin">同步书签</string>
+    <string name="pref_autojoin_summary">加入或离开多用户聊天时设置 “autojoin\" 标志,并回应其他客户端所做更改。</string>
     <string name="toast_message_omemo_fingerprint">OMEMO指纹已拷贝到剪贴板</string>
     <string name="conference_banned">您被封禁了</string>
     <string name="conference_members_only">这个群聊只允许成员聊天</string>
@@ -300,6 +301,7 @@
     <string name="conference_kicked">您被从此群聊踢出</string>
     <string name="conference_shutdown">这个群聊已被关闭</string>
     <string name="conference_unknown_error">您已不在该群组</string>
+    <string name="conference_technical_problems">你出于技术原因离开了群聊</string>
     <string name="using_account">使用帐户%s</string>
     <string name="hosted_on">托管于%s</string>
     <string name="checking_x">正在HTTP服务器中检查%s</string>
@@ -753,6 +755,7 @@
     <string name="messages_channel_name">消息</string>
     <string name="incoming_calls_channel_name">来电</string>
     <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="delivery_failed_channel_name">发送失败</string>
@@ -893,6 +896,8 @@
     <string name="make_call">进行通话</string>
     <string name="rtp_state_incoming_call">来电</string>
     <string name="rtp_state_incoming_video_call">视频来电</string>
+    <string name="rtp_state_content_add_video">切换到视频通话?</string>
+    <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>
@@ -920,6 +925,15 @@
     <string name="outgoing_call">去电</string>
     <string name="outgoing_call_duration">去电 · %s</string>
     <string name="missed_call">未接电话</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="other">%1$d 错过了来自 %2$s 的电话</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="other">%d 个未接电话</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="other">%1$d 个未接电话,来自 %2$d 位联系人</item>
+    </plurals>
     <string name="audio_call">语音通话</string>
     <string name="video_call">视频通话</string>
     <string name="help">帮助</string>
@@ -963,5 +977,9 @@
     <string name="account_registrations_are_not_supported">不支持注册账户</string>
     <string name="no_xmpp_adddress_found">未找到 XMPP 地址</string>
     <string name="account_status_temporary_auth_failure">临时认证失败</string>
+    <string name="delete_avatar">删除群聊</string>
+    <string name="audio_video_disabled_tor">使用 Tor 时通话被禁用</string>
+    <string name="switch_to_video">切换到视频</string>
+    <string name="reject_switch_to_video">拒绝切换到视频的请求</string>
 
 </resources>
  
  
  
    
    @@ -124,8 +124,11 @@
     <string name="pref_prevent_screenshots">防止截圖</string>
     <string name="pref_prevent_screenshots_summary">在多工畫面隱藏應用程式聯絡人並且封鎖螢幕截圖</string>
     <string name="pref_ui_options">UI</string>
+    <string name="openpgp_error">OpenKeychain 產生一個錯誤。</string>
+    <string name="bad_key_for_encryption">錯誤加密金鑰</string>
     <string name="accept">接受</string>
     <string name="error">產生了一個錯誤</string>
+    <string name="recording_error">錯誤</string>
     <string name="your_account">你的帳戶</string>
     <string name="send_presence_updates">發送線上連絡人列表更新</string>
     <string name="receive_presence_updates">接收線上連絡人列表更新</string>
@@ -134,6 +137,7 @@
     <string name="attach_take_picture">照相</string>
     <string name="preemptively_grant">預先同意訂閱請求</string>
     <string name="error_not_an_image_file">選擇的檔案不是一張圖片</string>
+    <string name="error_compressing_image">無法轉換圖片檔案</string>
     <string name="error_file_not_found">找不到檔案</string>
     <string name="error_io_exception">常規的 I/O 錯誤。可能是存儲空間不足?</string>
     <string name="account_status_unknown">未知</string>
@@ -145,11 +149,17 @@
     <string name="account_status_not_found">未找到伺服器</string>
     <string name="account_status_no_internet">未連接網路</string>
     <string name="account_status_regis_fail">註冊失敗</string>
-    <string name="account_status_regis_conflict"> 用戶名已存在</string>
+    <string name="account_status_regis_conflict">使用者名稱已被使用</string>
     <string name="account_status_regis_success">註冊完成</string>
+    <string name="account_status_regis_not_sup">伺服器不支援註冊</string>
+    <string name="account_status_regis_invalid_token">無效的註冊權杖</string>
+    <string name="account_status_tls_error">TLS 協商失敗</string>
+    <string name="account_status_tls_error_domain">網域不可驗證</string>
     <string name="account_status_policy_violation">違反政策</string>
     <string name="account_status_incompatible_server">伺服器不相容</string>
+    <string name="account_status_incompatible_client">不兼容的客戶端</string>
     <string name="account_status_stream_error">串流錯誤</string>
+    <string name="account_status_stream_opening_error">串流開啟錯誤</string>
     <string name="encryption_choice_unencrypted">TLS</string>
     <string name="encryption_choice_otr">OTR</string>
     <string name="encryption_choice_pgp">OpenPGP</string>
@@ -160,8 +170,10 @@
     <string name="mgmt_account_publish_pgp">發佈 OpenPGP 公開金鑰</string>
     <string name="unpublish_pgp">移除 OpenPGP 公開金鑰</string>
     <string name="unpublish_pgp_message">確定要移除上線狀態中的 OpenPGP 公開金鑰嗎?\n這樣一來,你的聯絡人就無法傳送以 OpenPGP 加密的訊息給你了。</string>
+    <string name="openpgp_has_been_published">OpenPGP 公開金鑰已發佈 </string>
     <string name="mgmt_account_enable">啟用帳戶</string>
     <string name="mgmt_account_are_you_sure">確定?</string>
+    <string name="mgmt_account_delete_confirm_text">刪除帳戶將清除您全部的會話記錄</string>
     <string name="attach_record_voice">錄音</string>
     <string name="account_settings_jabber_id">XMPP 位址</string>
     <string name="block_jabber_id">封鎖 XMPP 位址</string>
@@ -185,19 +197,26 @@
     <string name="server_info_unavailable">無效</string>
     <string name="missing_public_keys">缺少公開金鑰通知</string>
     <string name="last_seen_now">剛剛查看過</string>
+    <string name="last_seen_min">一分鐘前查看過</string>
     <string name="last_seen_mins">%d 分鐘前查看過</string>
+    <string name="last_seen_hour">一小時前查看過</string>
     <string name="last_seen_hours">%d 小時前查看過</string>
+    <string name="last_seen_day">一天前查看過</string>
     <string name="last_seen_days">%d 天前查看過</string>
+    <string name="install_openkeychain">訊息已加密。請安裝 OpenKeychain 以解密該訊息。</string>
+    <string name="openpgp_messages_found">發現新的 OpenPGP 加密訊息</string>
     <string name="openpgp_key_id">OpenPGP 金鑰 ID</string>
     <string name="omemo_fingerprint">OMEMO 指紋</string>
     <string name="omemo_fingerprint_x509">v\\OMEMO 指紋</string>
+    <string name="omemo_fingerprint_selected_message">OMEMO 指紋 (訊息來源)</string>
+    <string name="omemo_fingerprint_x509_selected_message">v\\OMEMO 指紋 (訊息來源)</string>
     <string name="other_devices">其他裝置</string>
     <string name="trust_omemo_fingerprints">信任的 OMEMO 指紋</string>
     <string name="fetching_keys">正在擷取金鑰…</string>
     <string name="done">完成</string>
     <string name="decrypt">解密</string>
     <string name="bookmarks">書籤</string>
-    <string name="search">尋找</string>
+    <string name="search">搜尋</string>
     <string name="enter_contact">輸入聯絡人</string>
     <string name="delete_contact">刪除聯絡人</string>
     <string name="view_contact_details">檢視聯絡人詳細資料</string>
@@ -211,17 +230,28 @@
     <string name="channel_bare_jid_example">channel@conference.example.com</string>
     <string name="save_as_bookmark">儲存為書籤</string>
     <string name="delete_bookmark">刪除書籤</string>
+    <string name="destroy_room">解散群組聊天</string>
+    <string name="destroy_channel">解散頻道</string>
+    <string name="could_not_destroy_room">不能解散群組聊天</string>
+    <string name="could_not_destroy_channel">無法解散頻道</string>
+    <string name="action_edit_subject">編輯群組聊天主題</string>
     <string name="topic">主旨</string>
     <string name="joining_conference">正在加入群組聊天…</string>
     <string name="leave">離開</string>
     <string name="contact_added_you">聯絡人已新增至你的聯絡人清單</string>
     <string name="add_back">新增回</string>
     <string name="contact_has_read_up_to_this_point">%s 已讀此句</string>
+    <string name="contacts_have_read_up_to_this_point">%s 已讀到這裏</string>
+    <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s 和其他 %2$d 位已經讀到這裏</string>
+    <string name="everyone_has_read_up_to_this_point">所有人已讀到這裏</string>
     <string name="publish">發佈</string>
+    <string name="touch_to_choose_picture">輕觸頭像以從相片庫中選擇相片</string>
     <string name="publishing">正在發佈…</string>
     <string name="error_publish_avatar_server_reject">伺服器拒絕了您的發佈請求</string>
+    <string name="error_publish_avatar_converting">無法轉換你的相片</string>
     <string name="error_saving_avatar">不能將頭像保存至磁片</string>
     <string name="or_long_press_for_default">(或長按按鈕將返回預設頭像)</string>
+    <string name="error_publish_avatar_no_server_support">你的伺服器不支援發佈頭像</string>
     <string name="private_message">私聊</string>
     <string name="private_message_to">至 %s</string>
     <string name="send_private_message_to">送私密訊息給 %s</string>
@@ -249,13 +279,23 @@
     <string name="pref_quiet_hours_summary">在靜默時間段內通知將保持靜音</string>
     <string name="pref_expert_options_other">其他</string>
     <string name="pref_autojoin">同步處理書籤</string>
+    <string name="toast_message_omemo_fingerprint">OMEMO 指紋已複製到剪貼簿</string>
+    <string name="conference_banned">你已被這群組聊天封鎖</string>
+    <string name="conference_members_only">這群組聊天只有會員可以加入</string>
+    <string name="conference_resource_constraint">資源限制</string>
+    <string name="conference_kicked">你已被踢出群組聊天</string>
+    <string name="conference_shutdown">群組聊天已被關閉</string>
+    <string name="conference_unknown_error">你已不在該群組聊天</string>
+    <string name="conference_technical_problems">出於技術性原因,你離開了群組聊天</string>
     <string name="using_account">用帳戶  %s</string>
+    <string name="hosted_on">託管於 %s</string>
     <string name="checking_x">正在 HTTP 伺服器中檢查 %s</string>
     <string name="not_connected_try_again">你沒有連接。請稍後重試</string>
     <string name="check_x_filesize">檢查 %s 大小</string>
     <string name="check_x_filesize_on_host">在 %2$s 上檢查 %1$s 的大小</string>
     <string name="message_options">訊息選項</string>
     <string name="quote">引用</string>
+    <string name="paste_as_quote">作為引用貼上</string>
     <string name="copy_original_url">拷貝原始URL</string>
     <string name="send_again">再次發送</string>
     <string name="file_url">檔案 URL</string>
@@ -269,6 +309,7 @@
     <string name="account_details">帳戶詳情</string>
     <string name="confirm">確認</string>
     <string name="try_again">再試一遍</string>
+    <string name="pref_keep_foreground_service">前臺服務</string>
     <string name="pref_keep_foreground_service_summary">防止作業系統中斷你的連接</string>
     <string name="pref_create_backup">建立備份</string>
     <string name="pref_create_backup_summary">備份檔案將被儲存至 %s</string>
@@ -289,13 +330,21 @@
     <string name="x_file_offered_for_download">可以下載 %s</string>
     <string name="cancel_transmission">取消傳送</string>
     <string name="file_transmission_failed">無法分享檔案</string>
+    <string name="file_transmission_cancelled">檔案傳輸已取消</string>
     <string name="file_deleted">檔案已刪除</string>
+    <string name="no_application_found_to_open_file">沒有可以打開檔案的應用程式</string>
+    <string name="no_application_found_to_open_link">沒有可以打開連結的應用程式</string>
+    <string name="no_application_found_to_view_contact">沒有可以查看聯絡人的應用程式</string>
+    <string name="pref_show_dynamic_tags">動態標簽</string>
     <string name="pref_show_dynamic_tags_summary">在連絡人下方顯示唯讀標籤</string>
     <string name="enable_notifications">啟用通知</string>
+    <string name="no_conference_server_found">未找到群組聊天伺服器</string>
+    <string name="conference_creation_failed">未能建立群組聊天</string>
     <string name="account_image_description">帳戶頭像</string>
     <string name="copy_omemo_clipboard_description">拷貝 OMEMO 指紋到剪貼板</string>
     <string name="regenerate_omemo_key">重新生成 OMEMO 金鑰</string>
     <string name="clear_other_devices">清除設備</string>
+    <string name="error_trustkeys_title">出錯了</string>
     <string name="fetching_history_from_server">從伺服器獲取歷史記錄</string>
     <string name="no_more_history_on_server">伺服器上沒有更多歷史記錄</string>
     <string name="updating">更新中…</string>
@@ -304,47 +353,71 @@
     <string name="change_password">修改密碼</string>
     <string name="current_password">當前密碼</string>
     <string name="new_password">新密碼</string>
+    <string name="password_should_not_be_empty">密碼不能留空</string>
     <string name="enable_all_accounts">啟用所有帳戶</string>
     <string name="disable_all_accounts">禁用所有帳戶</string>
     <string name="perform_action_with">選擇一個操作</string>
     <string name="no_affiliation">沒有從屬關係</string>
     <string name="no_role">離線</string>
     <string name="outcast">拋棄</string>
-    <string name="member">成員</string>
-    <string name="advanced_mode">高級模式</string>
+    <string name="member">會員</string>
+    <string name="advanced_mode">進階模式</string>
+    <string name="grant_membership">授予會員許可權</string>
+    <string name="remove_membership">撤銷會員許可權</string>
     <string name="grant_admin_privileges">授予管理員許可權</string>
     <string name="remove_admin_privileges">吊銷管理員許可權</string>
+    <string name="grant_owner_privileges">授予擁有者許可權</string>
+    <string name="remove_owner_privileges">撤銷擁有者許可權</string>
+    <string name="remove_from_room">從群組聊天移除</string>
+    <string name="remove_from_channel">從頻道中移除</string>
     <string name="could_not_change_affiliation">不能修改 %s 的從屬關係</string>
-    <string name="ban_now">現在遮罩</string>
+    <string name="ban_from_conference">從群組聊天封鎖</string>
+    <string name="ban_from_channel">從頻道中封鎖</string>
+    <string name="removing_from_public_conference">你正在嘗試從公用頻道中移除 %s。只有永遠封鎖此用戶方能做到。</string>
+    <string name="ban_now">立即封鎖</string>
     <string name="could_not_change_role">不能修改 %s 的角色</string>
-    <string name="members_only">私密,只有成員可以加入</string>
+    <string name="conference_options">設置私人群組聊天</string>
+    <string name="channel_options">設置公用頻道</string>
+    <string name="members_only">私密,只有會員可以加入</string>
+    <string name="non_anonymous">令所有人可以看見 XMPP 地址</string>
+    <string name="moderated">使頻道受到管理</string>
     <string name="you_are_not_participating">您尚未參與</string>
+    <string name="modified_conference_options">成功修改群組聊天選項!</string>
+    <string name="could_not_modify_conference_options">無法修改群組聊天選項</string>
     <string name="never">從不</string>
     <string name="until_further_notice">直到新的通知</string>
+    <string name="snooze">延遲</string>
+    <string name="reply">回覆</string>
+    <string name="mark_as_read">標示為已讀</string>
     <string name="pref_input_options">輸入</string>
-    <string name="pref_enter_is_send">回車是發送</string>
-    <string name="pref_display_enter_key">顯示回車鍵</string>
-    <string name="pref_display_enter_key_summary">改變表情鍵為回車鍵</string>
+    <string name="pref_enter_is_send">Enter 鍵傳送</string>
+    <string name="pref_display_enter_key">顯示 Enter 鍵</string>
+    <string name="pref_display_enter_key_summary">變更表情符號鍵為 Enter 鍵</string>
     <string name="audio">音訊</string>
     <string name="video">影片</string>
-    <string name="image">圖像</string>
-    <string name="pdf_document">PDF 文檔</string>
-    <string name="apk">Android App</string>
-    <string name="vcard">連絡人</string>
+    <string name="image">圖片</string>
+    <string name="vector_graphic">向量圖形</string>
+    <string name="multimedia_file">多媒體檔案</string>
+    <string name="pdf_document">PDF 文件</string>
+    <string name="apk">Android 應用程式</string>
+    <string name="vcard">聯絡人</string>
     <string name="avatar_has_been_published">頭像已經發佈!</string>
     <string name="sending_x_file">發送中 %s</string>
     <string name="offering_x_file">提供中 %s</string>
     <string name="hide_offline">隱藏離線連絡人</string>
-    <string name="contact_is_typing">%s 正在輸入中…</string>
-    <string name="contact_has_stopped_typing">%s 停止輸入了</string>
-    <string name="contacts_are_typing">%s 正在輸入中…</string>
-    <string name="contacts_have_stopped_typing">%s 停止輸入了</string>
+    <string name="contact_is_typing">%s 正在輸入…</string>
+    <string name="contact_has_stopped_typing">%s 已停止輸入</string>
+    <string name="contacts_are_typing">%s 正在輸入…</string>
+    <string name="contacts_have_stopped_typing">%s 已停止輸入</string>
     <string name="pref_chat_states">鍵盤輸入通知</string>
     <string name="pref_chat_states_summary">讓聯絡人知道你正在寫訊息送給它們</string>
-    <string name="send_location">發送位置</string>
+    <string name="send_location">傳送位置</string>
     <string name="show_location">顯示位置</string>
+    <string name="no_application_found_to_display_location">找不到可以顯示位置的應用程式</string>
     <string name="location">位置</string>
     <string name="title_undo_swipe_out_conversation">Conversation 已關閉</string>
+    <string name="title_undo_swipe_out_group_chat">離開私人群組聊天</string>
+    <string name="title_undo_swipe_out_channel">離開了公用頻道</string>
     <string name="pref_dont_trust_system_cas_title">不信任系統的憑證機構</string>
     <string name="pref_dont_trust_system_cas_summary">所有證書必須人工通過</string>
     <string name="pref_remove_trusted_certificates_title">移除證書</string>
@@ -356,11 +429,15 @@
     <plurals name="toast_delete_certificates">
         <item quantity="other">%d 個證書已被刪除</item>
     </plurals>
+    <string name="pref_quick_action_summary">以快速動作代替「發送」按鈕</string>
     <string name="pref_quick_action">快速動作</string>
     <string name="none">無</string>
     <string name="recently_used">最近使用過的</string>
     <string name="choose_quick_action">選擇快速動作</string>
+    <string name="search_contacts">搜尋聯絡人</string>
+    <string name="search_bookmarks">搜尋書籤</string>
     <string name="send_private_message">送私密訊息</string>
+    <string name="user_has_left_conference">%1$s 離開了群組聊天</string>
     <string name="username">用戶名</string>
     <string name="username_hint">用戶名</string>
     <string name="invalid_username">該用戶名無效</string>
@@ -368,17 +445,31 @@
     <string name="download_failed_file_not_found">下載失敗:找不到檔案</string>
     <string name="download_failed_could_not_connect">下載失敗:無法連接到伺服器</string>
     <string name="download_failed_could_not_write_file">下載失敗:無法寫入檔案</string>
+    <string name="download_failed_invalid_file">下載失敗:無效的檔案</string>
     <string name="account_status_tor_unavailable">Tor network 不可用</string>
     <string name="account_status_bind_failure">綁定失敗</string>
+    <string name="account_status_host_unknown">伺服器不負責此網域名稱</string>
     <string name="server_info_broken">損壞</string>
+    <string name="pref_presence_settings">在線狀態</string>
+    <string name="pref_away_when_screen_off">裝置上鎖時離開</string>
+    <string name="pref_away_when_screen_off_summary">裝置上鎖時顯示為離開</string>
+    <string name="pref_dnd_on_silent_mode">靜音模式時忙碌</string>
+    <string name="pref_dnd_on_silent_mode_summary">靜音模式時顯示為忙碌</string>
     <string name="pref_treat_vibrate_as_silent">靜音模式開啟振動</string>
+    <string name="pref_treat_vibrate_as_dnd_summary">裝置振動時顯示為忙碌</string>
     <string name="pref_show_connection_options">高級連接設置</string>
     <string name="pref_show_connection_options_summary">註冊帳戶時顯示主機名稱和埠</string>
     <string name="hostname_example">xmpp.example.com</string>
+    <string name="action_add_account_with_certificate">以證書登入</string>
+    <string name="unable_to_parse_certificate">無法解析證書</string>
     <string name="mam_prefs">壓縮設置</string>
     <string name="server_side_mam_prefs">服務端壓縮設置</string>
     <string name="fetching_mam_prefs">正在獲取壓縮設置。請稍後...</string>
+    <string name="unable_to_fetch_mam_prefs">無法獲取封存設置</string>
+    <string name="captcha_required">需要 CAPTCHA</string>
     <string name="captcha_hint">輸入上圖中的文字</string>
+    <string name="certificate_chain_is_not_trusted">未受信任的證書鏈</string>
+    <string name="jid_does_not_match_certificate">XMPP 地址與證書不相符</string>
     <string name="action_renew_certificate">更新證書</string>
     <string name="error_fetching_omemo_key">獲取 OMEMO 金鑰錯誤!</string>
     <string name="verified_omemo_key_with_certificate">請用證書驗證 OMEMO 金鑰!</string>
@@ -388,6 +479,7 @@
     <string name="pref_use_tor_summary">所有連接使用 Tor 網路傳輸,需要 Orbot</string>
     <string name="account_settings_hostname">主機名稱</string>
     <string name="account_settings_port">埠</string>
+    <string name="hostname_or_onion">伺服器- 或 .orion- 地址</string>
     <string name="not_a_valid_port">該埠號無效</string>
     <string name="not_valid_hostname">該主機名稱無效</string>
     <string name="connected_accounts">%2$d 個中的 %1$d 個帳戶已連接</string>
@@ -395,11 +487,20 @@
         <item quantity="other">%d 則訊息</item>
     </plurals>
     <string name="load_more_messages">載入更多訊息</string>
+    <string name="shared_file_with_x">與 %s 分享的檔案</string>
+    <string name="shared_image_with_x">與 %s 分享的圖片</string>
+    <string name="shared_images_with_x">與 %s 分享的圖片</string>
+    <string name="shared_text_with_x">與 %s 分享的文字</string>
+    <string name="no_storage_permission">授予 %1$s 存取外部儲存</string>
+    <string name="no_camera_permission">授予 %1$s 存取相機</string>
     <string name="sync_with_contacts">與連絡人同步</string>
     <string name="notify_on_all_messages">為所有訊息顯示通知</string>
+    <string name="notify_only_when_highlighted">只在被提到時通知</string>
     <string name="notify_never">關閉通知</string>
     <string name="notify_paused">暫停通知</string>
+    <string name="pref_picture_compression">圖像壓縮</string>
     <string name="always">總是</string>
+    <string name="large_images_only">只限大圖片</string>
     <string name="battery_optimizations_enabled">啟用節電模式</string>
     <string name="disable">禁用</string>
     <string name="selection_too_large">選擇區域過大</string>
@@ -408,10 +509,17 @@
     <string name="correct_message">更正訊息</string>
     <string name="send_corrected_message">發送更正後的訊息</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>
     <string name="share_uri_with">分享網址(URI)…</string>
-    <string name="create_account">創建帳戶</string>
+    <string name="agree_and_continue">同意並繼續</string>
+    <string name="magic_create_text">此指引將爲你在conversations.im¹上建立一個賬戶。\n使用 conversations.im 為你的提供者,再將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們進行交流。</string>
+    <string name="your_full_jid_will_be">您的 XMPP 完整地址將會是: %s</string>
+    <string name="create_account">建立帳戶</string>
     <string name="use_own_provider">使用我自己的服務端</string>
     <string name="pick_your_username">輸入您的用戶名</string>
+    <string name="pref_manually_change_presence">手動更改在線狀態</string>
+    <string name="pref_manually_change_presence_summary">在編輯你的狀態訊息時設立你的在線狀態</string>
     <string name="status_message">狀態訊息</string>
     <string name="presence_chat">免費聊天室</string>
     <string name="presence_online">線上</string>
@@ -423,6 +531,7 @@
     <string name="registration_please_wait">註冊失敗:請重試</string>
     <string name="registration_password_too_weak">註冊失敗:密碼太弱</string>
     <string name="choose_participants">選擇成員</string>
+    <string name="creating_conference">正在建立群組聊天...</string>
     <string name="invite_again">重新邀請</string>
     <string name="gp_disable">禁用</string>
     <string name="gp_short">短</string>
@@ -431,6 +540,10 @@
     <string name="pref_privacy">隱私</string>
     <string name="pref_theme_options">主題</string>
     <string name="pref_theme_options_summary">選擇調色板</string>
+    <string name="pref_theme_automatic">自動</string>
+    <string name="pref_theme_light">明亮</string>
+    <string name="pref_theme_dark">深色</string>
+    <string name="unable_to_connect_to_keychain">無法連接到 OpenKeychain</string>
     <string name="this_device_is_no_longer_in_use">此設備不再使用</string>
     <string name="type_pc">電腦</string>
     <string name="type_phone">行動電話</string>
@@ -438,19 +551,27 @@
     <string name="type_web">流覽器</string>
     <string name="type_console">控制台</string>
     <string name="payment_required">需要付款</string>
+    <string name="missing_internet_permission">允計互聯網存取權</string>
     <string name="me">我</string>
     <string name="contact_asks_for_presence_subscription">連絡人請求線上訂閱</string>
     <string name="allow">允許</string>
     <string name="no_permission_to_access_x">沒有訪問 %s 的許可</string>
     <string name="remote_server_not_found">找不到遠端伺服器</string>
+    <string name="remote_server_timeout">遠端伺服器超時</string>
+    <string name="unable_to_update_account">無法更新帳戶</string>
+    <string name="report_jid_as_spammer">舉報此 XMPP 地址發送垃圾信息</string>
     <string name="pref_delete_omemo_identities">刪除 OMEMO 身份</string>
     <string name="delete_selected_keys">刪除選擇的金鑰</string>
     <string name="error_publish_avatar_offline">你需要連接才能發佈頭像</string>
     <string name="show_error_message">顯示錯誤訊息</string>
     <string name="error_message">錯誤訊息</string>
     <string name="data_saver_enabled">省流量模式已啟動</string>
+    <string name="device_does_not_support_data_saver">該設備不支援對 %1$s 禁用節省流量模式</string>
+    <string name="error_unable_to_create_temporary_file">無法建立暫存檔案</string>
     <string name="this_device_has_been_verified">已經驗證這個設備了</string>
     <string name="copy_fingerprint">複製指紋</string>
+    <string name="all_omemo_keys_have_been_verified">你已驗證了你擁有的所有 OMEMO 密鑰</string>
+    <string name="barcode_does_not_contain_fingerprints_for_this_conversation">條碼中沒有這個會話的指紋。</string>
     <string name="verified_fingerprints">驗證過的指紋</string>
     <string name="use_camera_icon_to_scan_barcode">使用相機來掃描聯絡人的條碼</string>
     <string name="please_wait_for_keys_to_be_fetched">取得金鑰中,請稍後</string>
@@ -459,18 +580,38 @@
     <string name="share_as_http">分享網頁連結</string>
     <string name="pref_blind_trust_before_verification">在驗證前總是信任</string>
     <string name="not_trusted">不可信任</string>
-    <string name="invalid_barcode">二維條碼不合格</string>
+    <string name="invalid_barcode">二維條碼無效</string>
     <string name="pref_clean_cache">清理快取資料</string>
     <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="continue_btn">繼續</string>
     <string name="verify_omemo_keys">驗證 OMEMO 金鑰</string>
     <string name="distrust_omemo_key">停止信任設備</string>
+    <plurals name="seconds">
+        <item quantity="other">%d 秒</item>
+    </plurals>
+    <plurals name="minutes">
+        <item quantity="other">%d 分鐘</item>
+    </plurals>
+    <plurals name="hours">
+        <item quantity="other">%d 小時</item>
+    </plurals>
+    <plurals name="days">
+        <item quantity="other">%d 天</item>
+    </plurals>
+    <plurals name="weeks">
+        <item quantity="other">%d 星期</item>
+    </plurals>
+    <plurals name="months">
+        <item quantity="other">%d 月</item>
+    </plurals>
     <string name="pref_automatically_delete_messages">自動刪除訊息</string>
     <string name="pref_automatically_delete_messages_description">自動從這個設備刪除比設定的時間區間還舊的訊息。</string>
     <string name="encrypting_message">訊息加密中</string>
     <string name="not_fetching_history_retention_period">訊息的時間因為超過本機保留區間而沒有下載。</string>
+    <string name="transcoding_video">壓縮影片中</string>
     <string name="corresponding_conversations_closed">關閉相關的對話了。</string>
     <string name="contact_blocked_past_tense">已經封鎖聯絡人了。</string>
     <string name="pref_notifications_from_strangers">陌生人訊息通知</string>
@@ -480,9 +621,17 @@
     <string name="online_right_now">剛剛上線了</string>
     <string name="retry_decryption">再試解密ㄧ次</string>
     <string name="session_failure">通訊對話錯誤</string>
+    <string name="sasl_downgrade">已降級的 SASL 機制</string>
+    <string name="account_status_regis_web">伺服器要求在網站上註冊</string>
+    <string name="open_website">開啟網站</string>
+    <string name="application_found_to_open_website">沒有可以打開網站的應用程式</string>
     <string name="pref_headsup_notifications">頭條通知</string>
+    <string name="pref_headsup_notifications_summary">顯示頭條通知</string>
     <string name="today">今天</string>
     <string name="yesterday">昨天</string>
+    <string name="pref_validate_hostname">以 DNSSEC 驗證主機名稱</string>
+    <string name="certificate_does_not_contain_jid">證書不包含 XMPP 地址</string>
+    <string name="server_info_partial">部份</string>
     <string name="attach_record_video">錄製影片</string>
     <string name="copy_to_clipboard">複製到剪貼簿</string>
     <string name="message_copied_to_clipboard">訊息已複製到剪貼簿</string>
@@ -490,8 +639,12 @@
     <string name="private_messages_are_disabled">私密訊息已停用</string>
     <string name="huawei_protected_apps">受保護的應用程式</string>
     <string name="mtm_accept_cert">接受未知憑證?</string>
+    <string name="mtm_trust_anchor">伺服器證書未由已知證書機構簽發</string>
+    <string name="mtm_accept_servername">接受不相符的伺服器名稱?</string>
+    <string name="mtm_connect_anyway">你仍然想連線嗎?</string>
     <string name="mtm_cert_details">憑證詳細資料:</string>
     <string name="once">僅一次</string>
+    <string name="qr_code_scanner_needs_access_to_camera">二維條碼掃描器需要相機權限</string>
     <string name="pref_scroll_to_bottom">捲動至底部</string>
     <string name="pref_scroll_to_bottom_summary">傳送訊息後向下捲動</string>
     <string name="edit_status_message_title">編輯狀態訊息</string>
@@ -499,7 +652,9 @@
     <string name="disable_encryption">停用加密</string>
     <string name="error_trustkey_device_list">無法擷取裝置清單</string>
     <string name="error_trustkey_bundle">無法擷取加密金鑰</string>
+    <string name="error_trustkey_hint_mutual">提示:某些情況下,將對方加入聯絡人列表,便可以解決此問題。</string>
     <string name="disable_now">立即停用</string>
+    <string name="draft">草稿:</string>
     <string name="pref_omemo_setting">OMEMO 加密</string>
     <string name="pref_omemo_setting_summary_always">一對一以及私人群組的聊天一定會用 OMEMO</string>
     <string name="pref_omemo_setting_summary_default_on">新的對話預設會用 OMEMO 加密</string>
@@ -512,6 +667,8 @@
     <string name="small">小</string>
     <string name="medium">中</string>
     <string name="large">大</string>
+    <string name="not_encrypted_for_this_device">訊息未在此裝置加密</string>
+    <string name="omemo_decryption_failed">OMEMO 訊息解密失敗</string>
     <string name="undo">復原</string>
     <string name="location_disabled">位置分享已停用</string>
     <string name="action_fix_to_location">固定位置</string>
@@ -532,71 +689,246 @@
     <string name="pref_use_share_location_plugin_summary">使用分享位置外掛程式而非內建地圖</string>
     <string name="copy_link">複製網站位址</string>
     <string name="copy_jabber_id">複製 XMPP 位址</string>
+    <string name="p1_s3_filetransfer">用於 S3 的 HTTP 檔案分享</string>
     <string name="pref_start_search">直接搜尋</string>
+    <string name="pref_start_search_summary">在「開始對話」版面上打開鍵盤並將遊標放在搜尋列</string>
+    <string name="group_chat_avatar">群組聊天頭像</string>
+    <string name="host_does_not_support_group_chat_avatars">主機不支援群組聊天頭像</string>
+    <string name="only_the_owner_can_change_group_chat_avatar">只有擁有者才能變更群組聊天頭像</string>
+    <string name="contact_name">聯絡人名稱</string>
     <string name="nickname">暱稱</string>
     <string name="group_chat_name">名稱</string>
+    <string name="providing_a_name_is_optional">可選擇提供名稱</string>
     <string name="create_dialog_group_chat_name">聊天群組名稱</string>
+    <string name="conference_destroyed">此群組聊天已被解散</string>
     <string name="unable_to_save_recording">無法儲存錄製</string>
+    <string name="foreground_service_channel_name">前臺服務</string>
+    <string name="foreground_service_channel_description">此通知類別用於顯示 %1$s 正在運行永久通知。</string>
     <string name="notification_group_status_information">狀態資訊</string>
+    <string name="error_channel_name">連接問題</string>
+    <string name="error_channel_description">此通知類別用於顯示帳戶連接問題的通知。</string>
     <string name="notification_group_messages">訊息 </string>
     <string name="notification_group_calls">通話</string>
     <string name="messages_channel_name">訊息</string>
     <string name="incoming_calls_channel_name">來電</string>
     <string name="ongoing_calls_channel_name">正在進行的通話</string>
+    <string name="missed_calls_channel_name">未接來電</string>
     <string name="silent_messages_channel_name">無聲訊息</string>
+    <string name="delivery_failed_channel_name">傳送失敗</string>
     <string name="pref_message_notification_settings">訊息通知設定</string>
     <string name="pref_incoming_call_notification_settings">來電通知設定</string>
+    <string name="pref_more_notification_settings_summary">重要程度,聲音,振動</string>
     <string name="video_compression_channel_name">影片壓縮</string>
     <string name="view_media">檢視媒體</string>
     <string name="group_chat_members">成員</string>
     <string name="media_browser">媒體瀏覽器</string>
+    <string name="security_violation_not_attaching_file">由於違反安全規定,你的檔案已被刪除。</string>
     <string name="pref_video_compression">影片質量</string>
     <string name="pref_video_compression_summary">低質量意味這更小的檔案</string>
     <string name="video_360p">中 (360P)</string>
     <string name="video_720p">高 (720P)</string>
     <string name="cancelled">已取消</string>
+    <string name="already_drafting_message">你已經在起草一條訊息。</string>
+    <string name="feature_not_implemented">沒有此功能</string>
     <string name="invalid_country_code">無效的國家碼</string>
     <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="not_a_valid_phone_number">%s 不是有效的電話號碼</string>
     <string name="please_enter_your_phone_number">請輸入您的電話號碼。</string>
     <string name="search_countries">搜尋國家</string>
     <string name="verify_x">驗證 %s</string>
+    <string name="we_have_sent_you_an_sms_to_x"><![CDATA[我們已將你的短訊傳送到 <b>%s</b>。]]></string>
+    <string name="we_have_sent_you_another_sms">我們已向你發出另一個包含六位數字代碼的簡訊。</string>
+    <string name="please_enter_pin_below">請在下面輸入六位數字的 PIN 碼。</string>
     <string name="resend_sms">重新傳送簡訊</string>
     <string name="resend_sms_in">重新傳送簡訊 (%s)</string>
     <string name="wait_x">請等候 (%s)</string>
     <string name="back">返回</string>
+    <string name="possible_pin">已自動從剪貼簿貼上可能的 PIN 碼</string>
+    <string name="please_enter_pin">請輸入六位數字的 PIN 碼。</string>
+    <string name="abort_registration_procedure">你確定要終止註冊?</string>
     <string name="yes">是</string>
     <string name="no">否</string>
     <string name="verifying">正在驗證…</string>
     <string name="requesting_sms">正在要求簡訊…</string>
+    <string name="incorrect_pin">你輸入的 PIN 碼不正確。</string>
+    <string name="pin_expired">我們向你發出的 PIN 碼已經過期。</string>
     <string name="unknown_api_error_network">未知網路錯誤。</string>
+    <string name="unknown_api_error_response">伺服器的未知回應。</string>
+    <string name="unable_to_connect_to_server">無法與伺服器連接。</string>
+    <string name="unable_to_establish_secure_connection">無法建立安全連線。</string>
+    <string name="unable_to_find_server">找不到伺服器</string>
+    <string name="something_went_wrong_processing_your_request">處理你的請求時出錯</string>
+    <string name="invalid_user_input">無效的用戶輸入</string>
+    <string name="temporarily_unavailable">暫時無法連接,請稍候再試。</string>
     <string name="no_network_connection">沒有網路連線。</string>
+    <string name="try_again_in_x">請在 %s 後再次嘗試</string>
+    <string name="rate_limited">你的頻率已被限制</string>
+    <string name="too_many_attempts">太多的嘗試</string>
+    <string name="the_app_is_out_of_date">你正在使用此應用程式的過時版本。</string>
     <string name="update">更新</string>
+    <string name="logged_in_with_another_device">此電話號碼已在其他裝置上登錄</string>
+    <string name="enter_your_name_instructions">請輸入您的名稱,使那些沒有把你加入通訊錄的人也知道你是誰。</string>
     <string name="your_name">你的名稱</string>
     <string name="enter_your_name">輸入你的名稱</string>
+    <string name="no_name_set_instructions">用編輯按鍵設立你的名稱</string>
     <string name="reject_request">拒絕要求</string>
+    <string name="install_orbot">安裝 Orbot</string>
+    <string name="start_orbot">啟動 Orbot</string>
+    <string name="no_market_app_installed">沒有安裝軟件商店</string>
+    <string name="group_chat_will_make_your_jabber_id_public">這頻道將會公開你的 XMPP 地址</string>
     <string name="ebook">電子書</string>
+    <string name="video_original">原始(未壓縮)</string>
     <string name="open_with">開啟為…</string>
+    <string name="set_profile_picture">Conversations 設定檔圖片</string>
     <string name="choose_account">選擇帳戶</string>
     <string name="restore_backup">還原備份</string>
     <string name="restore">還原</string>
+    <string name="enter_password_to_restore">輸入帳戶 %s 的密碼以恢復備份。</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">備份與還原</string>
+    <string name="enter_jabber_id">輸入 XMPP 地址</string>
     <string name="create_group_chat">建立群組聊天</string>
     <string name="join_public_channel">加入公用頻道</string>
     <string name="create_private_group_chat">建立私人群組聊天</string>
     <string name="create_public_channel">建立公用頻道</string>
     <string name="create_dialog_channel_name">頻道名稱</string>
     <string name="xmpp_address">XMPP 位址</string>
+    <string name="please_enter_name">請為頻道提供一個名稱</string>
+    <string name="please_enter_xmpp_address">請提供 XMPP 地址</string>
+    <string name="this_is_an_xmpp_address">這是一個 XMPP 地址。請提供名稱。</string>
+    <string name="creating_channel">正在建立公用頻道...</string>
+    <string name="channel_already_exists">此頻道已經存在</string>
+    <string name="joined_an_existing_channel">你已加入一個已經存在的頻道廿</string>
+    <string name="unable_to_set_channel_configuration">無法儲存頻道設置</string>
+    <string name="allow_participants_to_edit_subject">允許所有人編輯主題</string>
+    <string name="allow_participants_to_invite_others">允許所有人邀請其他人</string>
+    <string name="anyone_can_edit_subject">所有人都可以編輯主題</string>
+    <string name="owners_can_edit_subject">擁有人可以編輯主題</string>
+    <string name="admins_can_edit_subject">管理員可以編輯主題</string>
+    <string name="owners_can_invite_others">擁有人可以邀請其他人</string>
+    <string name="anyone_can_invite_others">所有人都可以邀請其他人</string>
+    <string name="jabber_ids_are_visible_to_admins">管理員可以看見此 XMPP 地址</string>
+    <string name="jabber_ids_are_visible_to_anyone">所有人可以看見 XMPP 地址</string>
+    <string name="no_users_hint_channel">此公開頻道沒有成員。邀請聯絡人或使用分享按鍵傳播 XMPP 地址。</string>
+    <string name="no_users_hint_group_chat">此私人群組聊天沒有成員</string>
+    <string name="manage_permission">管理許可權</string>
+    <string name="search_participants">搜尋成員</string>
+    <string name="file_too_large">檔案太大</string>
+    <string name="attach">附加</string>
+    <string name="discover_channels">探索頻道</string>
+    <string name="search_channels">搜尋頻道</string>
+    <string name="channel_discovery_opt_in_title">可能侵犯私隱!</string>
+    <string name="channel_discover_opt_in_message"><![CDATA[頻道探索使用了名爲<a href=\"https://search.jabber.network\">search.jabber.network</a><br><br>的第三方服務。使用此功能會將你的IP地址和搜尋字詞傳輸到該服務。 有關更多資訊,請參閱其<a href=\"https://search.jabber.network/privacy\">私隱政策</a>。]]></string>
+    <string name="i_already_have_an_account">我已經有一個帳戶</string>
+    <string name="add_existing_account">添加已有帳戶</string>
+    <string name="register_new_account">註冊新帳戶</string>
+    <string name="this_looks_like_a_domain">這看似是一個網域地址</string>
+    <string name="add_anway">仍然添加</string>
+    <string name="this_looks_like_channel">這看似是一個頻道地址</string>
+    <string name="share_backup_files">分享備份檔案</string>
+    <string name="conversations_backup">Conversations 備份</string>
     <string name="event">活動</string>
     <string name="open_backup">開啟備份</string>
+    <string name="not_a_backup_file">你選擇的並不是 Conversations 的備份檔案</string>
+    <string name="account_already_setup">此帳戶已設置</string>
+    <string name="please_enter_password">請輸入此帳戶的密碼</string>
+    <string name="unable_to_perform_this_action">無法執行此操作</string>
+    <string name="open_join_dialog">加入公用頻道...</string>
+    <string name="sharing_application_not_grant_permission">分享程式沒有存取檔案的權限</string>
+    <string name="group_chats_and_channels"><![CDATA[群組聊天 & 頻道]]> </string>
     <string name="local_server">本機伺服器</string>
+    <string name="pref_channel_discovery_summary">大多數用戶應該選擇 “jabber.network” 以從整個公開的 XMPP 生態系統中獲得更好的建議。</string>
+    <string name="pref_channel_discovery">頻道探索方法</string>
+    <string name="backup">備份</string>
     <string name="category_about">關於</string>
+    <string name="please_enable_an_account">請啟用一個帳戶</string>
+    <string name="make_call">進行通話</string>
+    <string name="rtp_state_incoming_call">來電</string>
+    <string name="rtp_state_incoming_video_call">視像通話來電</string>
+    <string name="rtp_state_connecting">正在連接</string>
+    <string name="rtp_state_connected">已接通</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>
+    <string name="dismiss_call">拒接</string>
+    <string name="rtp_state_finding_device">正在探索裝置</string>
+    <string name="rtp_state_ringing">正在響鈴</string>
     <string name="rtp_state_declined_or_busy">忙碌</string>
+    <string name="rtp_state_connectivity_error">無法連接通話</string>
+    <string name="rtp_state_connectivity_lost_error">連接失敗</string>
+    <string name="rtp_state_retracted">通話已撤銷</string>
+    <string name="rtp_state_application_failure">程式錯誤</string>
+    <string name="rtp_state_security_error">驗証問題</string>
+    <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="disable_tor_to_make_call">關閉 Tor 以進行通話</string>
+    <string name="incoming_call">來電</string>
+    <string name="incoming_call_duration">來電 %s</string>
+    <string name="missed_call_timestamp">未接來電 %s</string>
+    <string name="outgoing_call">撥出通話</string>
+    <string name="outgoing_call_duration">撥出通話 %s</string>
+    <string name="missed_call">未接來電</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="other">來自 %2$s 的 %1$d 個未接來電</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="other">%d 未接來電</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="other">來自 %2$d 個聯絡人的 %1$d 個未接來電</item>
+    </plurals>
+    <string name="audio_call">語音通話</string>
+    <string name="video_call">視像通話</string>
     <string name="help">說明</string>
+    <string name="switch_to_conversation">切換到會話</string>
+    <string name="microphone_unavailable">你的麥克風未能使用</string>
+    <string name="only_one_call_at_a_time">你同時只能有一個通話</string>
+    <string name="return_to_ongoing_call">返回正在進行的通話</string>
+    <string name="could_not_switch_camera">無法切換鏡頭</string>
     <string name="add_to_favorites">釘選</string>
     <string name="remove_from_favorites">取消釘選</string>
+    <string name="gpx_track">GPX 追綜</string>
+    <string name="could_not_correct_message">無法更正訊息</string>
+    <string name="search_all_conversations">所有會話</string>
+    <string name="search_this_conversation">這會話</string>
+    <string name="your_avatar">你的頭像</string>
+    <string name="avatar_for_x">%s 的頭像</string>
+    <string name="encrypted_with_omemo">以 OMEMO 加密</string>
+    <string name="encrypted_with_openpgp">以 OpenPGP 加密</string>
+    <string name="not_encrypted">沒有加密</string>
     <string name="exit">離開</string>
+    <string name="record_voice_mail">錄製語音訊息</string>
     <string name="play_audio">播放音訊</string>
+    <string name="pause_audio">暫停音訊</string>
+    <string name="add_contact_or_create_or_join_group_chat">添加聯絡人, 建立或加入群組聊天, 或探索頻道</string>
+    <plurals name="view_users">
+        <item quantity="other">查看 %1$d 成員</item>
+    </plurals>
+    <plurals name="some_messages_could_not_be_delivered">
+        <item quantity="other">有些訊息無法傳送</item>
+    </plurals>
+    <string name="failed_deliveries">傳送失敗</string>
     <string name="more_options">更多選項</string>
+    <string name="no_application_found">沒有找到應用程式</string>
+    <string name="invite_to_app">邀請到 Conversations</string>
+    <string name="unable_to_parse_invite">無法解析邀請</string>
+    <string name="server_does_not_support_easy_onboarding_invites">伺服器不支援產生邀請</string>
+    <string name="no_active_accounts_support_this">沒有活躍帳戶支持此功能</string>
+    <string name="backup_started_message">已開始進行備份。完成後你會收到一則通知。</string>
+    <string name="unable_to_enable_video">無法啓用視訊</string>
+    <string name="plain_text_document">純文字檔案</string>
+    <string name="account_registrations_are_not_supported">不支援帳戶註冊</string>
+    <string name="no_xmpp_adddress_found">未找到 XMPP 地址</string>
+    <string name="account_status_temporary_auth_failure">臨時驗證失敗</string>
+    <string name="delete_avatar">刪除頭像</string>
+    <string name="audio_video_disabled_tor">使用 Tor 時不能進行通話</string>
     </resources>
  
  
  
    
    @@ -169,6 +169,7 @@
     <string name="account_status_tls_error_domain">Domain not verifiable</string>
     <string name="account_status_policy_violation">Policy violation</string>
     <string name="account_status_incompatible_server">Incompatible server</string>
+    <string name="account_status_incompatible_client">Incompatible client</string>
     <string name="account_status_stream_error">Stream error</string>
     <string name="account_status_stream_opening_error">Stream opening error</string>
     <string name="encryption_choice_unencrypted">TLS</string>
@@ -293,8 +294,8 @@
     <string name="title_pref_enable_quiet_hours">Enable quiet hours</string>
     <string name="pref_quiet_hours_summary">Notifications will be silenced during quiet hours</string>
     <string name="pref_expert_options_other">Other</string>
-    <string name="pref_autojoin">Synchronize with bookmarks</string>
-    <string name="pref_autojoin_summary">Join group chats automatically if the bookmark says so</string>
+    <string name="pref_autojoin">Synchronize bookmarks</string>
+    <string name="pref_autojoin_summary">Set “autojoin” flag when entering or leaving a MUC and react to modifications made by other clients.</string>
     <string name="toast_message_omemo_fingerprint">OMEMO fingerprint copied to clipboard</string>
     <string name="conference_banned">You are banned from this group chat</string>
     <string name="conference_members_only">This group chat is members only</string>
@@ -302,6 +303,7 @@
     <string name="conference_kicked">You have been kicked from this group chat</string>
     <string name="conference_shutdown">The group chat was shut down</string>
     <string name="conference_unknown_error">You are no longer in this group chat</string>
+    <string name="conference_technical_problems">You left this group chat due to technical reasons</string>
     <string name="using_account">using account %s</string>
     <string name="hosted_on">hosted on %s</string>
     <string name="checking_x">Checking %s on HTTP host</string>
@@ -910,6 +912,8 @@
     <string name="make_call">Make call</string>
     <string name="rtp_state_incoming_call">Incoming call</string>
     <string name="rtp_state_incoming_video_call">Incoming video call</string>
+    <string name="rtp_state_content_add_video">Switch to video call?</string>
+    <string name="rtp_state_content_add">Add additional tracks?</string>
     <string name="rtp_state_connecting">Connecting</string>
     <string name="rtp_state_connected">Connected</string>
     <string name="rtp_state_reconnecting">Reconnecting</string>
@@ -941,10 +945,18 @@
     <string name="outgoing_call_duration">Outgoing call (%s)</string>
     <string name="outgoing_call_duration_timestamp">Outgoing call (%s) . %s</string>
     <string name="missed_call">Missed call</string>
-    <string name="missed_call_from_x">Missed call from %s</string>
-    <string name="n_missed_calls_from_x">%1$d missed calls from %2$s</string>
-    <string name="n_missed_calls">%d missed calls</string>
-    <string name="n_missed_calls_from_m_contacts">%1$d missed calls from %2$d contacts</string>
+    <plurals name="n_missed_calls_from_x">
+        <item quantity="one">%1$d missed call from %2$s</item>
+        <item quantity="other">%1$d missed calls from %2$s</item>
+    </plurals>
+    <plurals name="n_missed_calls">
+        <item quantity="one">%d missed call</item>
+        <item quantity="other">%d missed calls</item>
+    </plurals>
+    <plurals name="n_missed_calls_from_m_contacts">
+        <item quantity="one">%1$d missed calls from %2$d contact</item>
+        <item quantity="other">%1$d missed calls from %2$d contacts</item>
+    </plurals>
     <string name="audio_call">Audio call</string>
     <string name="video_call">Video call</string>
     <string name="help">Help</string>
@@ -991,5 +1003,9 @@
     <string name="no_xmpp_adddress_found">No Jabber ID found</string>
     <string name="account_status_temporary_auth_failure">Temporary authentication failure</string>
     <string name="microphone_permission_for_call">Microphone permission required to complete call</string>
+    <string name="delete_avatar">Delete avatar</string>
+    <string name="audio_video_disabled_tor">Calls are disabled when using Tor</string>
+    <string name="switch_to_video">Switch to video</string>
+    <string name="reject_switch_to_video">Reject switch to video request</string>
 
 </resources>
  
  
  
    
    @@ -15,7 +15,8 @@
                 android:targetPackage="com.huawei.systemmanager" />
         </PreferenceScreen>
     </PreferenceCategory>
-    <PreferenceCategory android:title="@string/pref_privacy">
+    <PreferenceCategory android:title="@string/pref_privacy"
+        android:key="privacy">
         <CheckBoxPreference
             android:defaultValue="@bool/confirm_messages"
             android:key="confirm_messages"
  
  
  
    
    @@ -1,5 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
+    <string name="pref_notification_grace_period_summary">發現在其它設備上的活動後,Quicksy 保持安靜的時間</string>
+    <string name="pref_never_send_crash_summary">發送堆疊跟蹤説明以幫助 Quicksy 持續開發</string>
+    <string name="pref_broadcast_last_activity_summary">讓你的所有聯絡人知道你何時使用 Quicksy</string>
+    <string name="huawei_protected_apps_summary">爲了在螢幕關閉時也能收到通知,你需要將 Quicksy 加入受保護的應用程式列表。</string>
     <string name="set_profile_picture">Quicksy 設定檔圖片</string>
     <string name="not_available_in_your_country">Quicksy 在您的國家無法使用。</string>
     <string name="unable_to_verify_server_identity">無法驗證伺服器身分。</string>