Merge tag '2.11.0'

Stephen Paul Weber created

* tag '2.11.0': (101 commits)
  version bump to 2.11.0
  temporarily use Snikket’s build of WebRTC
  delay candidates until after session-init/accept
  pulled translations from transifex
  pulled translations from transifex
  null PeerConnection reference before disposing; otherwise getState() might be issued against disposed object
  avoid race condition when restarting ICE
  version bump to 2.11.0-beta.2
  pulled translations from transifex
  add switch to video menu item to call
  prepare JingleRtpConnection for content-adds
  trim xmpp address after user input
  add helper methods for content modification to RtpContentMap
  ensure cc-ed proceed is equivalent to accept
  rename initiateIceRestart to renegotiate to handle content adds
  take senders attr into account when converting to and from sdp
  make sure VideoSourceWrapper is stored in property
  refactor WebRTCWrapper to allow for track adds
  use plurals for missed call strings
  version bump to 2.11.0-beta
  ...

Change summary

CHANGELOG.md                                                                       |   8 
build.gradle                                                                       |  13 
fastlane/metadata/android/en-US/changelogs/42041.txt                               |   5 
gradle/wrapper/gradle-wrapper.properties                                           |   4 
src/cheogram/java/eu/siacs/conversations/ui/MagicCreateActivity.java               |   2 
src/conversations/java/eu/siacs/conversations/ui/MagicCreateActivity.java          |   2 
src/conversations/res/values-hr/strings.xml                                        |  16 
src/conversations/res/values-zh-rCN/strings.xml                                    |  12 
src/conversations/res/values-zh-rTW/strings.xml                                    |  10 
src/main/AndroidManifest.xml                                                       |   3 
src/main/java/eu/siacs/conversations/Config.java                                   |  11 
src/main/java/eu/siacs/conversations/crypto/OmemoSetting.java                      |  10 
src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java            |  16 
src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java                    |   9 
src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java               | 120 
src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java      | 100 
src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java                    |  77 
src/main/java/eu/siacs/conversations/crypto/sasl/External.java                     |  12 
src/main/java/eu/siacs/conversations/crypto/sasl/HashedToken.java                  | 190 
src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha256.java            |  23 
src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha512.java            |  23 
src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java                        |  19 
src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java                | 173 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java               | 230 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramPlusMechanism.java           |  23 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1.java                    |  22 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java                |  35 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256.java                  |  21 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java              |  35 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512.java                  |  20 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java              |  35 
src/main/java/eu/siacs/conversations/crypto/sasl/Tokenizer.java                    |  17 
src/main/java/eu/siacs/conversations/entities/Account.java                         | 249 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                      |   4 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java                    |  21 
src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java               |   4 
src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java                |   4 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                     |  10 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                    |  20 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java              |  80 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java                  |  19 
src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java              |  23 
src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java         | 208 
src/main/java/eu/siacs/conversations/services/MessageArchiveService.java           |   4 
src/main/java/eu/siacs/conversations/services/NotificationService.java             | 206 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java           | 112 
src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java              |   5 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                  |   3 
src/main/java/eu/siacs/conversations/ui/CreatePublicChannelDialog.java             |   3 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java                   |  12 
src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java                        |   8 
src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java         |  54 
src/main/java/eu/siacs/conversations/ui/RecordingActivity.java                     |  64 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java                    | 188 
src/main/java/eu/siacs/conversations/ui/SettingsActivity.java                      | 883 
src/main/java/eu/siacs/conversations/ui/ShowLocationActivity.java                  | 403 
src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java             |   6 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                          |  13 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java                |  10 
src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java                        |  37 
src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java                      |   8 
src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java                        |  13 
src/main/java/eu/siacs/conversations/ui/widget/EditMessage.java                    |   8 
src/main/java/eu/siacs/conversations/utils/Compatibility.java                      |  21 
src/main/java/eu/siacs/conversations/utils/CryptoHelper.java                       |  14 
src/main/java/eu/siacs/conversations/utils/MessageUtils.java                       |   6 
src/main/java/eu/siacs/conversations/utils/PhoneHelper.java                        |  68 
src/main/java/eu/siacs/conversations/utils/Random.java                             |  13 
src/main/java/eu/siacs/conversations/utils/SSLSockets.java                         |  46 
src/main/java/eu/siacs/conversations/utils/TLSSocketFactory.java                   |   4 
src/main/java/eu/siacs/conversations/utils/XmlHelper.java                          |  47 
src/main/java/eu/siacs/conversations/xml/Element.java                              |   4 
src/main/java/eu/siacs/conversations/xml/Namespace.java                            |  11 
src/main/java/eu/siacs/conversations/xml/Tag.java                                  | 191 
src/main/java/eu/siacs/conversations/xml/TagWriter.java                            |  11 
src/main/java/eu/siacs/conversations/xmpp/Jid.java                                 |   3 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                      | 798 
src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java                          |  33 
src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java              |  88 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java |  27 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java          | 618 
src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java                        |  15 
src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java                | 221 
src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java              |   1 
src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java           | 160 
src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java                  |  31 
src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java                 |  76 
src/main/java/eu/siacs/conversations/xmpp/jingle/VideoSourceWrapper.java           | 179 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java                | 769 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java              |  74 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java  |  70 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java       | 128 
src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java            |   3 
src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java          |   3 
src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java        |   5 
src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java     |   5 
src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java    |   5 
src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java     |   5 
src/main/res/drawable/ic_baseline_check_24.xml                                     |   5 
src/main/res/drawable/ic_call_missed_white_24db.xml                                |   5 
src/main/res/menu/activity_publish_profile_picture.xml                             |  10 
src/main/res/menu/activity_rtp_session.xml                                         |   5 
src/main/res/values-ar/strings.xml                                                 |   1 
src/main/res/values-bg/strings.xml                                                 |   2 
src/main/res/values-ca/strings.xml                                                 |   2 
src/main/res/values-cs/strings.xml                                                 |   2 
src/main/res/values-da-rDK/strings.xml                                             |   8 
src/main/res/values-de/strings.xml                                                 |  33 
src/main/res/values-el/strings.xml                                                 |   2 
src/main/res/values-es/strings.xml                                                 |  26 
src/main/res/values-eu/strings.xml                                                 |   1 
src/main/res/values-fi/strings.xml                                                 |   2 
src/main/res/values-fr/strings.xml                                                 |   2 
src/main/res/values-gl/strings.xml                                                 |  25 
src/main/res/values-hr/strings.xml                                                 |  76 
src/main/res/values-hu/strings.xml                                                 |   2 
src/main/res/values-it/strings.xml                                                 |  29 
src/main/res/values-ja/strings.xml                                                 |   9 
src/main/res/values-nl/strings.xml                                                 |   1 
src/main/res/values-pl/strings.xml                                                 |  31 
src/main/res/values-pt-rBR/strings.xml                                             |  28 
src/main/res/values-ro-rRO/strings.xml                                             |  28 
src/main/res/values-ru/strings.xml                                                 |   2 
src/main/res/values-sk/strings.xml                                                 |   2 
src/main/res/values-sr/strings.xml                                                 |   2 
src/main/res/values-sv/strings.xml                                                 |   2 
src/main/res/values-szl/strings.xml                                                |   2 
src/main/res/values-tr-rTR/strings.xml                                             |  22 
src/main/res/values-uk/strings.xml                                                 |   2 
src/main/res/values-vi/strings.xml                                                 |   2 
src/main/res/values-zh-rCN/strings.xml                                             |  22 
src/main/res/values-zh-rTW/strings.xml                                             | 372 
src/main/res/values/strings.xml                                                    |  28 
src/main/res/xml/preferences.xml                                                   |   3 
src/quicksy/res/values-zh-rTW/strings.xml                                          |   4 
135 files changed, 6,260 insertions(+), 2,271 deletions(-)

Detailed changes

CHANGELOG.md 🔗

@@ -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

build.gradle 🔗

@@ -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'

fastlane/metadata/android/en-US/changelogs/42041.txt 🔗

@@ -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

gradle/wrapper/gradle-wrapper.properties 🔗

@@ -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

src/cheogram/java/eu/siacs/conversations/ui/MagicCreateActivity.java 🔗

@@ -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);
                     }

src/conversations/java/eu/siacs/conversations/ui/MagicCreateActivity.java 🔗

@@ -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);
                     }

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

@@ -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>

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

@@ -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>

src/conversations/res/values-zh-rTW/strings.xml 🔗

@@ -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>

src/main/AndroidManifest.xml 🔗

@@ -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>
 
 

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

@@ -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;
 }

src/main/java/eu/siacs/conversations/crypto/OmemoSetting.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java 🔗

@@ -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();

src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java 🔗

@@ -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 "";
     }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java 🔗

@@ -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);
+        }
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java 🔗

@@ -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();
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java 🔗

@@ -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);

src/main/java/eu/siacs/conversations/crypto/sasl/External.java 🔗

@@ -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);
     }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/HashedToken.java 🔗

@@ -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;
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha256.java 🔗

@@ -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);
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha512.java 🔗

@@ -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);
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java 🔗

@@ -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);
-    }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java 🔗

@@ -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);
     }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/crypto/sasl/ScramPlusMechanism.java 🔗

@@ -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;
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1.java 🔗

@@ -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

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java 🔗

@@ -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;
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java 🔗

@@ -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;
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512.java 🔗

@@ -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

src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java 🔗

@@ -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;
+    }
+}

src/main/java/eu/siacs/conversations/crypto/sasl/Tokenizer.java 🔗

@@ -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);
     }

src/main/java/eu/siacs/conversations/entities/Account.java 🔗

@@ -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:

src/main/java/eu/siacs/conversations/entities/MucOptions.java 🔗

@@ -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
     }

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java 🔗

@@ -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));

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/parser/PresenceParser.java 🔗

@@ -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)) {

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -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);
         }

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

@@ -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;
+            }
+        }
     }
 
     /**

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

@@ -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
         }
     }
 

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

@@ -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;
         }
 

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

@@ -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);
+			}
+		}
 }

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

@@ -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
             }
         }
     }

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

@@ -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;
         }

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

@@ -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;

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

@@ -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();
             }
         }
     }

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

@@ -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() {

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

@@ -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);
         }

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

@@ -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);
         }

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

@@ -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);

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

@@ -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();

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

@@ -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
+    }
 }

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

@@ -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) {}
 }

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

@@ -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);

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

@@ -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);
     }

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java 🔗

@@ -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;
+        }
+    }
 }

src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java 🔗

@@ -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;
     }

src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java 🔗

@@ -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;
+    }
 }

src/main/java/eu/siacs/conversations/ui/widget/EditMessage.java 🔗

@@ -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();

src/main/java/eu/siacs/conversations/utils/Compatibility.java 🔗

@@ -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;
         }
     }
 }

src/main/java/eu/siacs/conversations/utils/CryptoHelper.java 🔗

@@ -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);
     }
 

src/main/java/eu/siacs/conversations/utils/MessageUtils.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/utils/PhoneHelper.java 🔗

@@ -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");
+    }
 }

src/main/java/eu/siacs/conversations/utils/Random.java 🔗

@@ -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() {
+
+    }
+
+}

src/main/java/eu/siacs/conversations/utils/SSLSocketHelper.java → src/main/java/eu/siacs/conversations/utils/SSLSockets.java 🔗

@@ -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;
+            }
+        }
     }
 }

src/main/java/eu/siacs/conversations/utils/TLSSocketFactory.java 🔗

@@ -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;
     }

src/main/java/eu/siacs/conversations/utils/XmlHelper.java 🔗

@@ -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("&", "&amp;");
-		content = content.replace("<", "&lt;");
-		content = content.replace(">", "&gt;");
-		content = content.replace("\"", "&quot;");
-		content = content.replace("'", "&apos;");
-		content = content.replaceAll("[\\p{Cntrl}&&[^\n\t\r]]", "");
-		return content;
-	}
+    public static String encodeEntities(String content) {
+        content = content.replace("&", "&amp;");
+        content = content.replace("<", "&lt;");
+        content = content.replace(">", "&gt;");
+        content = content.replace("\"", "&quot;");
+        content = content.replace("'", "&apos;");
+        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);
+    }
 }

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

@@ -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());

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

@@ -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";

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

@@ -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;
+    }
 }

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

@@ -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 {

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

@@ -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);
         }
     }

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

@@ -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) {

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

@@ -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"));
+    }
+}

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

@@ -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();
+        }
+    }
+}

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

@@ -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;

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

@@ -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 =

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

@@ -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);
+    }
 }

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

@@ -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);
+        }
+    }
 }

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

@@ -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

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

@@ -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;
         }
     }
-
 }

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

@@ -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;
     }

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

@@ -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());
+    }
+}

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

@@ -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;
+            }
+        }
+    }
+}

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

@@ -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));
-        }
-    }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java 🔗

@@ -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));
+            }
+        }
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java 🔗

@@ -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");
         }
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java 🔗

@@ -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")) {

src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java 🔗

@@ -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);
 	}
 }

src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java 🔗

@@ -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);
 	}
 }

src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java 🔗

@@ -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));
 	}
 

src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java 🔗

@@ -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");
 	}
 

src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java 🔗

@@ -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);
 	}
 
 }

src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java 🔗

@@ -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));
 	}

src/main/res/drawable/ic_baseline_check_24.xml 🔗

@@ -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>

src/main/res/drawable/ic_call_missed_white_24db.xml 🔗

@@ -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>

src/main/res/menu/activity_publish_profile_picture.xml 🔗

@@ -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>

src/main/res/menu/activity_rtp_session.xml 🔗

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

src/main/res/values-da-rDK/strings.xml 🔗

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

src/main/res/values-pt-rBR/strings.xml 🔗

@@ -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>

src/main/res/values-ro-rRO/strings.xml 🔗

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

src/main/res/values-tr-rTR/strings.xml 🔗

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

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

@@ -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>

src/main/res/xml/preferences.xml 🔗

@@ -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"

src/quicksy/res/values-zh-rTW/strings.xml 🔗

@@ -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>