~singpolyma/cheogram-android

e3eb4865439b55a6fd6649fe42a494dbdad6170a — Stephen Paul Weber 9 months ago 7489ef4 + f851761
Merge tag '2.11.0'

* 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
  ...
135 files changed, 7077 insertions(+), 2644 deletions(-)

M CHANGELOG.md
M build.gradle
A fastlane/metadata/android/en-US/changelogs/42041.txt
M gradle/wrapper/gradle-wrapper.properties
M src/cheogram/java/eu/siacs/conversations/ui/MagicCreateActivity.java
M src/conversations/java/eu/siacs/conversations/ui/MagicCreateActivity.java
A src/conversations/res/values-hr/strings.xml
M src/conversations/res/values-zh-rCN/strings.xml
M src/conversations/res/values-zh-rTW/strings.xml
M src/main/AndroidManifest.xml
M src/main/java/eu/siacs/conversations/Config.java
M src/main/java/eu/siacs/conversations/crypto/OmemoSetting.java
M src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java
M src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java
M src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java
M src/main/java/eu/siacs/conversations/crypto/sasl/External.java
A src/main/java/eu/siacs/conversations/crypto/sasl/HashedToken.java
A src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha256.java
A src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha512.java
M src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java
M src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java
M src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ScramPlusMechanism.java
M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java
M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java
M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512.java
A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java
M src/main/java/eu/siacs/conversations/crypto/sasl/Tokenizer.java
M src/main/java/eu/siacs/conversations/entities/Account.java
M src/main/java/eu/siacs/conversations/entities/MucOptions.java
M src/main/java/eu/siacs/conversations/generator/IqGenerator.java
M src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java
M src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java
M src/main/java/eu/siacs/conversations/parser/MessageParser.java
M src/main/java/eu/siacs/conversations/parser/PresenceParser.java
M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java
M src/main/java/eu/siacs/conversations/persistance/FileBackend.java
M src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
M src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java
M src/main/java/eu/siacs/conversations/services/MessageArchiveService.java
M src/main/java/eu/siacs/conversations/services/NotificationService.java
M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
M src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java
M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
M src/main/java/eu/siacs/conversations/ui/CreatePublicChannelDialog.java
M src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java
M src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java
M src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java
M src/main/java/eu/siacs/conversations/ui/RecordingActivity.java
M src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
M src/main/java/eu/siacs/conversations/ui/SettingsActivity.java
M src/main/java/eu/siacs/conversations/ui/ShowLocationActivity.java
M src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java
M src/main/java/eu/siacs/conversations/ui/XmppActivity.java
M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
M src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java
M src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java
M src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java
M src/main/java/eu/siacs/conversations/ui/widget/EditMessage.java
M src/main/java/eu/siacs/conversations/utils/Compatibility.java
M src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
M src/main/java/eu/siacs/conversations/utils/MessageUtils.java
M src/main/java/eu/siacs/conversations/utils/PhoneHelper.java
A src/main/java/eu/siacs/conversations/utils/Random.java
R src/main/java/eu/siacs/conversations/utils/{SSLSocketHelper.java => SSLSockets.java}
M src/main/java/eu/siacs/conversations/utils/TLSSocketFactory.java
M src/main/java/eu/siacs/conversations/utils/XmlHelper.java
M src/main/java/eu/siacs/conversations/xml/Element.java
M src/main/java/eu/siacs/conversations/xml/Namespace.java
M src/main/java/eu/siacs/conversations/xml/Tag.java
M src/main/java/eu/siacs/conversations/xml/TagWriter.java
M src/main/java/eu/siacs/conversations/xmpp/Jid.java
M src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java
A src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java
A src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/ToneManager.java
A src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java
A src/main/java/eu/siacs/conversations/xmpp/jingle/VideoSourceWrapper.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java
A src/main/res/drawable/ic_baseline_check_24.xml
A src/main/res/drawable/ic_call_missed_white_24db.xml
A src/main/res/menu/activity_publish_profile_picture.xml
M src/main/res/menu/activity_rtp_session.xml
M src/main/res/values-ar/strings.xml
M src/main/res/values-bg/strings.xml
M src/main/res/values-ca/strings.xml
M src/main/res/values-cs/strings.xml
M src/main/res/values-da-rDK/strings.xml
M src/main/res/values-de/strings.xml
M src/main/res/values-el/strings.xml
M src/main/res/values-es/strings.xml
M src/main/res/values-eu/strings.xml
M src/main/res/values-fi/strings.xml
M src/main/res/values-fr/strings.xml
M src/main/res/values-gl/strings.xml
A src/main/res/values-hr/strings.xml
M src/main/res/values-hu/strings.xml
M src/main/res/values-it/strings.xml
M src/main/res/values-ja/strings.xml
M src/main/res/values-nl/strings.xml
M src/main/res/values-pl/strings.xml
M src/main/res/values-pt-rBR/strings.xml
M src/main/res/values-ro-rRO/strings.xml
M src/main/res/values-ru/strings.xml
M src/main/res/values-sk/strings.xml
M src/main/res/values-sr/strings.xml
M src/main/res/values-sv/strings.xml
M src/main/res/values-szl/strings.xml
M src/main/res/values-tr-rTR/strings.xml
M src/main/res/values-uk/strings.xml
M src/main/res/values-vi/strings.xml
M src/main/res/values-zh-rCN/strings.xml
M src/main/res/values-zh-rTW/strings.xml
M src/main/res/values/strings.xml
M src/main/res/xml/preferences.xml
M src/quicksy/res/values-zh-rTW/strings.xml
M CHANGELOG.md => CHANGELOG.md +8 -0
@@ 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

M build.gradle => build.gradle +7 -6
@@ 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'

A fastlane/metadata/android/en-US/changelogs/42041.txt => fastlane/metadata/android/en-US/changelogs/42041.txt +5 -0
@@ 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

M gradle/wrapper/gradle-wrapper.properties => gradle/wrapper/gradle-wrapper.properties +2 -2
@@ 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

M src/cheogram/java/eu/siacs/conversations/ui/MagicCreateActivity.java => src/cheogram/java/eu/siacs/conversations/ui/MagicCreateActivity.java +1 -1
@@ 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);
                    }

M src/conversations/java/eu/siacs/conversations/ui/MagicCreateActivity.java => src/conversations/java/eu/siacs/conversations/ui/MagicCreateActivity.java +1 -1
@@ 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);
                    }

A src/conversations/res/values-hr/strings.xml => src/conversations/res/values-hr/strings.xml +16 -0
@@ 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>
\ No newline at end of file

M src/conversations/res/values-zh-rCN/strings.xml => src/conversations/res/values-zh-rCN/strings.xml +6 -6
@@ 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>

M src/conversations/res/values-zh-rTW/strings.xml => src/conversations/res/values-zh-rTW/strings.xml +9 -1
@@ 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>
\ No newline at end of file

M src/main/AndroidManifest.xml => src/main/AndroidManifest.xml +3 -0
@@ 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>



M src/main/java/eu/siacs/conversations/Config.java => src/main/java/eu/siacs/conversations/Config.java +8 -3
@@ 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;
}

M src/main/java/eu/siacs/conversations/crypto/OmemoSetting.java => src/main/java/eu/siacs/conversations/crypto/OmemoSetting.java +9 -1
@@ 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;

M src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java => src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java +9 -7
@@ 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();

M src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java => src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java +4 -5
@@ 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 "";
    }
}

A src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java => src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBinding.java +120 -0
@@ 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);
        }
    }
}

A src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java => src/main/java/eu/siacs/conversations/crypto/sasl/ChannelBindingMechanism.java +100 -0
@@ 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();
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java => src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java +49 -28
@@ 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);

M src/main/java/eu/siacs/conversations/crypto/sasl/External.java => src/main/java/eu/siacs/conversations/crypto/sasl/External.java +6 -6
@@ 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);
    }
}

A src/main/java/eu/siacs/conversations/crypto/sasl/HashedToken.java => src/main/java/eu/siacs/conversations/crypto/sasl/HashedToken.java +190 -0
@@ 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;
    }
}

A src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha256.java => src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha256.java +23 -0
@@ 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);
    }
}

A src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha512.java => src/main/java/eu/siacs/conversations/crypto/sasl/HashedTokenSha512.java +23 -0
@@ 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);
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java => src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java +10 -9
@@ 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);
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java => src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java +150 -23
@@ 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);
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java +138 -92
@@ 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;

A src/main/java/eu/siacs/conversations/crypto/sasl/ScramPlusMechanism.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramPlusMechanism.java +23 -0
@@ 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;
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1.java +9 -13
@@ 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

A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha1Plus.java +35 -0
@@ 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;
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256.java +10 -11
@@ 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;

A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha256Plus.java +35 -0
@@ 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;
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512.java +10 -10
@@ 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

A src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java => src/main/java/eu/siacs/conversations/crypto/sasl/ScramSha512Plus.java +35 -0
@@ 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;
    }
}

M src/main/java/eu/siacs/conversations/crypto/sasl/Tokenizer.java => src/main/java/eu/siacs/conversations/crypto/sasl/Tokenizer.java +8 -9
@@ 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);
    }

M src/main/java/eu/siacs/conversations/entities/Account.java => src/main/java/eu/siacs/conversations/entities/Account.java +199 -50
@@ 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:

M src/main/java/eu/siacs/conversations/entities/MucOptions.java => src/main/java/eu/siacs/conversations/entities/MucOptions.java +3 -1
@@ 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
    }

M src/main/java/eu/siacs/conversations/generator/IqGenerator.java => src/main/java/eu/siacs/conversations/generator/IqGenerator.java +14 -7
@@ 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) {

M src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java => src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java +3 -1
@@ 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) {

M src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java => src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java +3 -1
@@ 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));

M src/main/java/eu/siacs/conversations/parser/MessageParser.java => src/main/java/eu/siacs/conversations/parser/MessageParser.java +6 -4
@@ 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;

M src/main/java/eu/siacs/conversations/parser/PresenceParser.java => src/main/java/eu/siacs/conversations/parser/PresenceParser.java +16 -4
@@ 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)) {

M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java => src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +43 -37
@@ 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) {

M src/main/java/eu/siacs/conversations/persistance/FileBackend.java => src/main/java/eu/siacs/conversations/persistance/FileBackend.java +9 -10
@@ 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);
        }

M src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java => src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java +21 -2
@@ 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;
            }
        }
    }

    /**

M src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java => src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java +125 -83
@@ 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
        }
    }


M src/main/java/eu/siacs/conversations/services/MessageArchiveService.java => src/main/java/eu/siacs/conversations/services/MessageArchiveService.java +3 -1
@@ 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;
        }


M src/main/java/eu/siacs/conversations/services/NotificationService.java => src/main/java/eu/siacs/conversations/services/NotificationService.java +122 -84
@@ 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);
			}
		}
}

M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java => src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +86 -26
@@ 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");