~singpolyma/cheogram-android

5e149cfcd1fd26fe33ecd81b3272d3d469516262 — Stephen Paul Weber 1 year, 6 months ago 74f684f + ceceead
Merge remote-tracking branch 'upstream/master'

* upstream/master: (27 commits)
  show 'using account …' in incoming call screen
  show contact jid in call screen
  bump copyright year
  Add handling of status code 333
  increase default pw length
  do not build emoji flavors
  pulled translations from transifex
  add changelog
  fix ice candidate sending when different credentials are used
  remove security check that ensures rtp connection was properly finished
  code clean up
  bump agp
  store encrypted pgp files in private cache dir
  do not restart wakelock if activity is finishing
  delete pre lolipop weOwnFile()
  use try with resources. remove unused methods
  rename version suffix to playstore/free
  bump appcompat, migrate to emoji2 and get rid of emoji flavor
  fix rare npe
  store recordings and documents in their respective folders
  ...
69 files changed, 2534 insertions(+), 1810 deletions(-)

M .builds/debian-stable.yml
M .github/workflows/android.yml
M CHANGELOG.md
M build.gradle
M src/cheogram/java/eu/siacs/conversations/services/ImportBackupService.java
D src/compat/java/eu/siacs/conversations/ui/widget/EmojiWrapperEditText.java
D src/compat/java/eu/siacs/conversations/utils/EmojiWrapper.java
M src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java
M src/conversations/res/values-gl/strings.xml
A src/free/java/eu/siacs/conversations/services/EmojiInitializationService.java
D src/freeCompat/java/eu/siacs/conversations/ui/service/EmojiService.java
M src/main/AndroidManifest.xml
M src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java
M src/main/java/eu/siacs/conversations/entities/DownloadableFile.java
M src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java
M src/main/java/eu/siacs/conversations/persistance/FileBackend.java
M src/main/java/eu/siacs/conversations/services/AttachFileToConversationRunnable.java
M src/main/java/eu/siacs/conversations/services/ExportBackupService.java
M src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java
M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
M src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
M src/main/java/eu/siacs/conversations/ui/EnterJidDialog.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/XmppActivity.java
M src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java
M src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java
M src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java
M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
M src/main/java/eu/siacs/conversations/ui/util/ViewUtil.java
M src/main/java/eu/siacs/conversations/ui/widget/EditMessage.java
M src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
M src/main/java/eu/siacs/conversations/utils/FileWriterException.java
M src/main/java/eu/siacs/conversations/utils/MimeUtils.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.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/RtpContentMap.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java
M src/main/res/layout/activity_muc_details.xml
M src/main/res/layout/activity_rtp_session.xml
M src/main/res/layout/create_conference_dialog.xml
M src/main/res/layout/create_public_channel_dialog.xml
M src/main/res/layout/dialog_quickedit.xml
M src/main/res/values-bg/strings.xml
M src/main/res/values-da-rDK/strings.xml
M src/main/res/values-de/strings.xml
M src/main/res/values-es/strings.xml
M src/main/res/values-fi/strings.xml
M src/main/res/values-gl/strings.xml
M src/main/res/values-it/strings.xml
M src/main/res/values-ja/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-sv/strings.xml
M src/main/res/values-tr-rTR/strings.xml
M src/main/res/values-vi/strings.xml
M src/main/res/values-zh-rCN/strings.xml
M src/main/res/values/about.xml
A src/playstore/java/eu/siacs/conversations/services/EmojiInitializationService.java
D src/playstoreCompat/java/eu/siacs/conversations/ui/service/EmojiService.java
D src/playstoreCompat/res/values/font_certs.xml
D src/system/java/eu/siacs/conversations/ui/service/EmojiService.java
D src/system/java/eu/siacs/conversations/ui/widget/EmojiWrapperEditText.java
D src/system/java/eu/siacs/conversations/utils/EmojiWrapper.java
M .builds/debian-stable.yml => .builds/debian-stable.yml +2 -2
@@ 28,6 28,6 @@ tasks:
    sed -ie 's/\/\/ INSERT/implementation "io.sentry:sentry-android:5.6.1"/' build.gradle
- build: |
    cd cheogram-android
    ./gradlew assembleCheogramFreeCompatDebug
    ./gradlew assembleCheogramFreeDebug
- assets: |
    mv cheogram-android/build/outputs/apk/cheogramFreeCompat/debug/*.apk cheogram.apk
    mv cheogram-android/build/outputs/apk/cheogramFree/debug/*.apk cheogram.apk

M .github/workflows/android.yml => .github/workflows/android.yml +4 -8
@@ 22,14 22,10 @@ jobs:
      run: mkdir libs && wget -O libs/libwebrtc-m92.aar https://gultsch.de/files/libwebrtc-m92.aar
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    - name: Build Quicksy (Compat)
      run: ./gradlew assembleQuicksyFreeCompatDebug
    - name: Build Quicksy (System)
      run: ./gradlew assembleQuicksyFreeSystemDebug
    - name: Build Conversations (Compat)
      run: ./gradlew assembleConversationsFreeCompatDebug
    - name: Build Conversations (System)
      run: ./gradlew assembleConversationsFreeSystemDebug
    - name: Build Quicksy
      run: ./gradlew assembleQuicksyFreeDebug
    - name: Build Conversations
      run: ./gradlew assembleConversationsFreeDebug
    - uses: actions/upload-artifact@v2
      with:
        name: Conversations all-flavors (debug)

M CHANGELOG.md => CHANGELOG.md +5 -0
@@ 1,5 1,10 @@
# Changelog

### Version 2.10.3

* Store files in location appropriate for Android 11
* Attempt to reconnect call after network switch

### Version 2.10.2

* Fix crash when rendering some quotes

M build.gradle => build.gradle +23 -75
@@ 6,7 6,7 @@ buildscript {
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:7.1.1'
        classpath 'com.android.tools.build:gradle:7.1.2'
    }
}



@@ 38,14 38,13 @@ def urlFile = { url, name ->

configurations {
    playstoreImplementation
    compatImplementation
    conversationsFreeCompatImplementation
    cheogramFreeCompatImplementation
    conversationsPlaystoreCompatImplementation
    conversationsPlaystoreSystemImplementation
    quicksyPlaystoreCompatImplementation
    quicksyPlaystoreSystemImplementation
    quicksyFreeCompatImplementation
    freeImplementation
    conversationsFreeImplementation
    conversationsPlaystorImplementation
    conversationsPlaystoreImplementation
    quicksyPlaystoreImplementation
    quicksyPlaystoreImplementation
    quicksyFreeImplementation
    quicksyImplementation
}



@@ 57,22 56,19 @@ dependencies {
        exclude group: 'com.google.firebase', module: 'firebase-analytics'
        exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
    }
    conversationsPlaystoreCompatImplementation("com.android.installreferrer:installreferrer:2.2")
    conversationsPlaystoreSystemImplementation("com.android.installreferrer:installreferrer:2.2")
    quicksyPlaystoreCompatImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1'
    quicksyPlaystoreSystemImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1'
    conversationsPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2")
    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.3.1'
    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'androidx.exifinterface:exifinterface:1.3.3'
    implementation 'androidx.cardview:cardview:1.0.0'
    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
    implementation 'androidx.emoji:emoji:1.1.0'
    implementation 'com.google.android.material:material:1.4.0'
    compatImplementation 'androidx.emoji:emoji-appcompat:1.1.0'
    conversationsFreeCompatImplementation 'androidx.emoji:emoji-bundled:1.1.0'
    cheogramFreeCompatImplementation 'androidx.emoji:emoji-bundled:1.1.0'
    quicksyFreeCompatImplementation 'androidx.emoji:emoji-bundled:1.1.0'

    implementation "androidx.emoji2:emoji2:1.1.0-rc01"
    freeImplementation "androidx.emoji2:emoji2-bundled:1.1.0-rc01"

    implementation 'org.bouncycastle:bcmail-jdk15on:1.64'
    //zxing stopped supporting Java 7 so we have to stick with 3.3.3
    //https://github.com/zxing/zxing/issues/1170


@@ 138,7 134,7 @@ android {
        targetCompatibility JavaVersion.VERSION_1_8
    }

    flavorDimensions("mode", "distribution", "emoji")
    flavorDimensions("mode", "distribution")

    productFlavors {



@@ 169,45 165,21 @@ android {

        playstore {
            dimension "distribution"
            versionNameSuffix "+p"
            versionNameSuffix "+playstore"
        }
        free {
            dimension "distribution"
            versionNameSuffix "+f"
        }
        system {
            dimension "emoji"
            versionNameSuffix "s"
        }
        compat {
            dimension "emoji"
            versionNameSuffix "c"
            versionNameSuffix "+free"
        }
    }

    sourceSets {
        quicksyFreeSystem {
            java {
                srcDir 'src/quicksyFree/java'
            }
        }
        quicksyFreeCompat {
        quicksyFree {
            java {
                srcDir 'src/freeCompat/java'
                srcDir 'src/quicksyFree/java'
            }
        }
        quicksyPlaystoreCompat {
            java {
                srcDir 'src/playstoreCompat/java'
                srcDir 'src/quicksyPlaystore/java'
            }
            res {
                srcDir 'src/playstoreCompat/res'
                srcDir 'src/quicksyPlaystore/res'
            }
        }
        quicksyPlaystoreSystem {
        quicksyPlaystore {
            java {
                srcDir 'src/quicksyPlaystore/java'
            }


@@ 215,39 187,17 @@ android {
                srcDir 'src/quicksyPlaystore/res'
            }
        }
        conversationsFreeCompat {
        conversationsFree {
            java {
                srcDir 'src/freeCompat/java'
                srcDir 'src/conversationsFree/java'
            }
        }
        conversationsFreeSystem {
        cheogramFree {
            java {
                srcDir 'src/conversationsFree/java'
            }
        }
        cheogramFreeCompat {
            java {
                srcDir 'src/freeCompat/java'
                srcDir 'src/conversationsFree/java'
            }
        }
        cheogramFreeSystem {
            java {
                srcDir 'src/conversationsFree/java'
            }
        }
        conversationsPlaystoreCompat {
            java {
                srcDir 'src/playstoreCompat/java'
                srcDir 'src/conversationsPlaystore/java'
            }
            res {
                srcDir 'src/playstoreCompat/res'
                srcDir 'src/conversationsPlaystore/res'
            }
        }
        conversationsPlaystoreSystem {
        conversationsPlaystore {
            java {
                srcDir 'src/conversationsPlaystore/java'
            }


@@ 262,13 212,11 @@ android {
            shrinkResources true
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            versionNameSuffix "r"
        }
        debug {
            shrinkResources true
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            versionNameSuffix "d"
        }
    }


M src/cheogram/java/eu/siacs/conversations/services/ImportBackupService.java => src/cheogram/java/eu/siacs/conversations/services/ImportBackupService.java +7 -4
@@ 128,16 128,19 @@ public class ImportBackupService extends Service {
            final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
            final ArrayList<BackupFile> backupFiles = new ArrayList<>();
            final Set<String> apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name)));
            for (String app : apps) {
                final File directory = new File(FileBackend.getBackupDirectory(app));
            final List<File> directories = new ArrayList<>();
            for (final String app : apps) {
                directories.add(FileBackend.getLegacyBackupDirectory(app));
            }
            directories.add(FileBackend.getBackupDirectory(this));
            for (final File directory : directories) {
                if (!directory.exists() || !directory.isDirectory()) {
                    Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
                    continue;
                }
                final File[] files = directory.listFiles();
                if (files == null) {
                    onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
                    return;
                    continue;
                }
                for (final File file : files) {
                    if (file.isFile() && file.getName().endsWith(".ceb")) {

D src/compat/java/eu/siacs/conversations/ui/widget/EmojiWrapperEditText.java => src/compat/java/eu/siacs/conversations/ui/widget/EmojiWrapperEditText.java +0 -18
@@ 1,18 0,0 @@
package eu.siacs.conversations.ui.widget;

import android.content.Context;
import android.util.AttributeSet;

import androidx.emoji.widget.EmojiAppCompatEditText;

public class EmojiWrapperEditText extends EmojiAppCompatEditText {

    public EmojiWrapperEditText(Context context) {
        super(context);
    }

    public EmojiWrapperEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

}
\ No newline at end of file

D src/compat/java/eu/siacs/conversations/utils/EmojiWrapper.java => src/compat/java/eu/siacs/conversations/utils/EmojiWrapper.java +0 -47
@@ 1,47 0,0 @@
/*
 * Copyright (c) 2017, Daniel Gultsch All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation and/or
 * other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its contributors
 * may be used to endorse or promote products derived from this software without
 * specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package eu.siacs.conversations.utils;

import androidx.emoji.text.EmojiCompat;

public class EmojiWrapper {

	public static CharSequence transform(CharSequence input) {
		try {
			if (EmojiCompat.get().getLoadState() == EmojiCompat.LOAD_STATE_SUCCEEDED) {
				return EmojiCompat.get().process(input);
			} else {
				return input;
			}
		} catch (IllegalStateException e) {
			return input;
		}
	}
}

M src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java => src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +7 -4
@@ 128,16 128,19 @@ public class ImportBackupService extends Service {
            final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
            final ArrayList<BackupFile> backupFiles = new ArrayList<>();
            final Set<String> apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name)));
            for (String app : apps) {
                final File directory = new File(FileBackend.getBackupDirectory(app));
            final List<File> directories = new ArrayList<>();
            for (final String app : apps) {
                directories.add(FileBackend.getLegacyBackupDirectory(app));
            }
            directories.add(FileBackend.getBackupDirectory(this));
            for (final File directory : directories) {
                if (!directory.exists() || !directory.isDirectory()) {
                    Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
                    continue;
                }
                final File[] files = directory.listFiles();
                if (files == null) {
                    onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
                    return;
                    continue;
                }
                for (final File file : files) {
                    if (file.isFile() && file.getName().endsWith(".ceb")) {

M src/conversations/res/values-gl/strings.xml => src/conversations/res/values-gl/strings.xml +4 -4
@@ 1,15 1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="pick_a_server">Escolle o teu provedor XMPP</string>
    <string name="pick_a_server">Elixe o teu provedor XMPP</string>
    <string name="use_conversations.im">Utilizar conversations.im</string>
    <string name="create_new_account">Crear nova conta</string>
    <string name="do_you_have_an_account">Xa posúes unha conta XMPP? Este pode ser o caso se xa estás a utilizar outro cliente XMPP ou utilizaches Conversations previamente. Se non é así podes crear unha nova conta agora mesmo.\nTruco: Algúns provedores de correo tamén proporcionan contas XMPP.</string>
    <string name="server_select_text">XMPP é unha rede de mensaxería independente do provedor. Podes utilizar este cliente con calquera provedor XMPP da túa elección.\nMais para a tua conveniencia fixemos que fose doado crear unha conta en conversations.im¹; un provedor especialmente axeitado para utilizar con Conversations.</string>
    <string name="magic_create_text_on_x">Convidáronte a %1$s. Guiarémoste no proceso para crear unha conta.\nAo escoller %1$s como provedor poderás comunicarte con usuarias de outros provedores cando lles deas o teu enderezo XMPP completo.</string>
    <string name="magic_create_text_fixed">Convidáronte a %1$s. Escollemos un nome de usuaria por ti. Guiarémoste no proceso de crear unha conta.\nPoderás comunicarte con usuarias de outros provedores cando lles digas o teu enderezo XMPP completo.</string>
    <string name="magic_create_text_on_x">Convidáronte a %1$s. Guiarémoste no proceso para crear unha conta.\nAo elexir %1$s como provedor poderás comunicarte con usuarias doutros provedores cando lles deas o teu enderezo XMPP completo.</string>
    <string name="magic_create_text_fixed">Convidáronte a %1$s. Xa eleximos un nome de usuaria para ti. Guiarémoste no proceso de crear unha conta.\nPoderás comunicarte con usuarias doutros provedores cando lles digas o teu enderezo XMPP completo.</string>
    <string name="your_server_invitation">O convite do teu servidor</string>
    <string name="improperly_formatted_provisioning">Código de aprovisionamento con formato non válido</string>
    <string name="tap_share_button_send_invite">Toca no botón compartir para convidar ó teu contacto a %1$s.</string>
    <string name="tap_share_button_send_invite">Toca no botón compartir para convidar ao teu contacto a %1$s.</string>
    <string name="if_contact_is_nearby_use_qr">Se o contacto está preto de ti, pode escanear o código inferior para aceptar o teu convite.</string>
    <string name="easy_invite_share_text">Únete a %1$s e conversa conmigo: %2$s</string>
    <string name="share_invite_with">Enviar convite a...</string>

A src/free/java/eu/siacs/conversations/services/EmojiInitializationService.java => src/free/java/eu/siacs/conversations/services/EmojiInitializationService.java +14 -0
@@ 0,0 1,14 @@
package eu.siacs.conversations.services;

import android.content.Context;

import androidx.emoji2.bundled.BundledEmojiCompatConfig;
import androidx.emoji2.text.EmojiCompat;

public class EmojiInitializationService {

    public static void execute(final Context context) {
        EmojiCompat.init(new BundledEmojiCompatConfig(context).setReplaceAll(true));
    }

}

D src/freeCompat/java/eu/siacs/conversations/ui/service/EmojiService.java => src/freeCompat/java/eu/siacs/conversations/ui/service/EmojiService.java +0 -27
@@ 1,27 0,0 @@
package eu.siacs.conversations.ui.service;

import android.content.Context;
import android.os.Build;
import androidx.emoji.text.EmojiCompat;
import androidx.emoji.text.FontRequestEmojiCompatConfig;
import androidx.emoji.bundled.BundledEmojiCompatConfig;

public class EmojiService {

    private final Context context;

    public EmojiService(Context context) {
        this.context = context;
    }

    public void init() {
        BundledEmojiCompatConfig config = new BundledEmojiCompatConfig(context);
        //On recent Androids we assume to have the latest emojis
        //there are some annoying bugs with emoji compat that make it a safer choice not to use it when possible
        // a) the text preview has annoying glitches when the cut of text contains emojis (the emoji will be half visible)
        // b) can trigger a hardware rendering bug https://issuetracker.google.com/issues/67102093
        config.setReplaceAll(Build.VERSION.SDK_INT < Build.VERSION_CODES.O);
        EmojiCompat.init(config);
    }

}
\ No newline at end of file

M src/main/AndroidManifest.xml => src/main/AndroidManifest.xml +5 -0
@@ 50,6 50,10 @@
        android:name="android.hardware.microphone"
        android:required="false" />

    <queries>
        <package android:name="org.sufficientlysecure.keychain"/>
    </queries>


    <application
        android:allowBackup="true"


@@ 61,6 65,7 @@
        android:largeHeap="true"
        android:networkSecurityConfig="@xml/network_security_configuration"
        android:requestLegacyExternalStorage="true"
        android:preserveLegacyExternalStorage="true"
        android:theme="@style/ConversationsTheme"
        tools:replace="android:label"
        tools:targetApi="q">

M src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java => src/main/java/eu/siacs/conversations/crypto/PgpDecryptionService.java +5 -7
@@ 9,6 9,7 @@ import org.openintents.openpgp.util.OpenPgpApi;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;


@@ 147,9 148,6 @@ public class PgpDecryptionService {
						try {
							os.flush();
							final String body = os.toString();
							if (body == null) {
								throw new IOException("body was null");
							}
							message.setBody(body);
							message.setEncryption(Message.ENCRYPTION_DECRYPTED);
							final HttpConnectionManager manager = mXmppConnectionService.getHttpConnectionManager();


@@ 194,9 192,9 @@ public class PgpDecryptionService {
							String originalExtension = originalFilename == null ? null : MimeUtils.extractRelevantExtension(originalFilename);
							if (originalExtension != null && MimeUtils.extractRelevantExtension(outputFile.getName()) == null) {
								Log.d(Config.LOGTAG,"detected original filename during pgp decryption");
								String mime = MimeUtils.guessMimeTypeFromExtension(originalExtension);
								String path = outputFile.getName()+"."+originalExtension;
								DownloadableFile fixedFile = mXmppConnectionService.getFileBackend().getFileForPath(path,mime);
								final String mime = MimeUtils.guessMimeTypeFromExtension(originalExtension);
								final String filename = outputFile.getName()+"."+originalExtension;
								final File fixedFile = mXmppConnectionService.getFileBackend().getStorageLocation(filename,mime);
								if (fixedFile.getParentFile().mkdirs()) {
									Log.d(Config.LOGTAG,"created parent directories for "+fixedFile.getAbsolutePath());
								}


@@ 205,7 203,7 @@ public class PgpDecryptionService {
								}
								if (outputFile.renameTo(fixedFile)) {
									Log.d(Config.LOGTAG, "renamed " + outputFile.getAbsolutePath() + " to " + fixedFile.getAbsolutePath());
									message.setRelativeFilePath(path);
									message.setRelativeFilePath(fixedFile.getAbsolutePath());
								}
							}
							final String url = message.getFileParams().url;

M src/main/java/eu/siacs/conversations/entities/DownloadableFile.java => src/main/java/eu/siacs/conversations/entities/DownloadableFile.java +4 -0
@@ 16,6 16,10 @@ public class DownloadableFile extends File {
	private byte[] aeskey;
	private byte[] iv;

	public DownloadableFile(final File parent, final String file) {
		super(parent, file);
	}

	public DownloadableFile(String path) {
		super(path);
	}

M src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java => src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java +7 -9
@@ 96,11 96,8 @@ public class HttpDownloadConnection implements Transferable {
                this.message.setEncryption(Message.ENCRYPTION_NONE);
            }
            final String ext = extension.getExtension();
            if (ext != null) {
                message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), ext));
            } else if (Strings.isNullOrEmpty(message.getRelativeFilePath())) {
                message.setRelativeFilePath(message.getUuid());
            }
            final String filename = Strings.isNullOrEmpty(ext) ? message.getUuid() : String.format("%s.%s", message.getUuid(), ext);
            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
            setupFile();
            if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
                this.message.setEncryption(Message.ENCRYPTION_NONE);


@@ 122,7 119,7 @@ public class HttpDownloadConnection implements Transferable {
    private void setupFile() {
        final String reference = mUrl.fragment();
        if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
            this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
            this.file = new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
            this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
            Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
        } else {


@@ 326,7 323,7 @@ public class HttpDownloadConnection implements Transferable {
                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
                    if (fileExtension != null) {
                        message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), fileExtension));
                        mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), fileExtension), contentType);
                        Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
                        setupFile();
                    }


@@ 419,8 416,9 @@ public class HttpDownloadConnection implements Transferable {
                    Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
                }
                file.getParentFile().mkdirs();
                Log.d(Config.LOGTAG,"creating file: "+file.getAbsolutePath());
                if (!file.exists() && !file.createNewFile()) {
                    throw new FileWriterException();
                    throw new FileWriterException(file);
                }
                outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
            }


@@ 431,7 429,7 @@ public class HttpDownloadConnection implements Transferable {
                try {
                    outputStream.write(buffer, 0, count);
                } catch (IOException e) {
                    throw new FileWriterException();
                    throw new FileWriterException(file);
                }
                updateProgress(Math.round(((double) transmitted / expected) * 100));
            }

M src/main/java/eu/siacs/conversations/persistance/FileBackend.java => src/main/java/eu/siacs/conversations/persistance/FileBackend.java +356 -227
@@ 1,6 1,5 @@
package eu.siacs.conversations.persistance;

import android.annotation.TargetApi;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;


@@ 33,6 32,7 @@ import androidx.annotation.StringRes;
import androidx.core.content.FileProvider;
import androidx.exifinterface.media.ExifInterface;

import com.google.common.base.Strings;
import com.google.common.io.ByteStreams;

import java.io.ByteArrayOutputStream;


@@ 63,7 63,7 @@ import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.services.AttachFileToConversationRunnable;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.RecordingActivity;
import eu.siacs.conversations.ui.adapter.MediaAdapter;
import eu.siacs.conversations.ui.util.Attachment;
import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.CryptoHelper;


@@ 76,7 76,8 @@ public class FileBackend {

    private static final Object THUMBNAIL_LOCK = new Object();

    private static final SimpleDateFormat IMAGE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
    private static final SimpleDateFormat IMAGE_DATE_FORMAT =
            new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);

    private static final String FILE_PROVIDER = ".files";
    private static final float IGNORE_PADDING = 0.15f;


@@ 86,19 87,6 @@ public class FileBackend {
        this.mXmppConnectionService = service;
    }

    private static boolean isInDirectoryThatShouldNotBeScanned(Context context, File file) {
        return isInDirectoryThatShouldNotBeScanned(context, file.getAbsolutePath());
    }

    public static boolean isInDirectoryThatShouldNotBeScanned(Context context, String path) {
        for (String type : new String[]{RecordingActivity.STORAGE_DIRECTORY_TYPE_NAME, "Files"}) {
            if (path.startsWith(getConversationsDirectory(context, type))) {
                return true;
            }
        }
        return false;
    }

    public static long getFileSize(Context context, Uri uri) {
        try {
            final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);


@@ 114,11 102,14 @@ public class FileBackend {
        }
    }

    public static boolean allFilesUnderSize(Context context, List<Attachment> attachments, long max) {
        final boolean compressVideo = !AttachFileToConversationRunnable.getVideoCompression(context).equals("uncompressed");
    public static boolean allFilesUnderSize(
            Context context, List<Attachment> attachments, long max) {
        final boolean compressVideo =
                !AttachFileToConversationRunnable.getVideoCompression(context)
                        .equals("uncompressed");
        if (max <= 0) {
            Log.d(Config.LOGTAG, "server did not report max file size for http upload");
            return true; //exception to be compatible with HTTP Upload < v0.2
            return true; // exception to be compatible with HTTP Upload < v0.2
        }
        for (Attachment attachment : attachments) {
            if (attachment.getType() != Attachment.Type.FILE) {


@@ 127,41 118,42 @@ public class FileBackend {
            String mime = attachment.getMime();
            if (mime != null && mime.startsWith("video/") && compressVideo) {
                try {
                    Dimensions dimensions = FileBackend.getVideoDimensions(context, attachment.getUri());
                    Dimensions dimensions =
                            FileBackend.getVideoDimensions(context, attachment.getUri());
                    if (dimensions.getMin() > 720) {
                        Log.d(Config.LOGTAG, "do not consider video file with min width larger than 720 for size check");
                        Log.d(
                                Config.LOGTAG,
                                "do not consider video file with min width larger than 720 for size check");
                        continue;
                    }
                } catch (NotAVideoFile notAVideoFile) {
                    //ignore and fall through
                    // ignore and fall through
                }
            }
            if (FileBackend.getFileSize(context, attachment.getUri()) > max) {
                Log.d(Config.LOGTAG, "not all files are under " + max + " bytes. suggesting falling back to jingle");
                Log.d(
                        Config.LOGTAG,
                        "not all files are under "
                                + max
                                + " bytes. suggesting falling back to jingle");
                return false;
            }
        }
        return true;
    }

    public static String getConversationsDirectory(Context context, final String type) {
        if (Config.ONLY_INTERNAL_STORAGE) {
            return context.getFilesDir().getAbsolutePath() + "/" + type + "/";
        } else {
            return getAppMediaDirectory(context) + context.getString(R.string.app_name) + " " + type + "/";
        }
    }

    public static String getAppMediaDirectory(Context context) {
        return Environment.getExternalStorageDirectory().getAbsolutePath() + "/" + context.getString(R.string.app_name) + "/Media/";
    public static File getBackupDirectory(final Context context) {
        final File conversationsDownloadDirectory =
                new File(
                        Environment.getExternalStoragePublicDirectory(
                                Environment.DIRECTORY_DOWNLOADS),
                        context.getString(R.string.app_name));
        return new File(conversationsDownloadDirectory, "Backup");
    }

    public static String getBackupDirectory(Context context) {
        return getBackupDirectory(context.getString(R.string.app_name));
    }

    public static String getBackupDirectory(String app) {
        return Environment.getExternalStorageDirectory().getAbsolutePath() + "/" + app + "/Backup/";
    public static File getLegacyBackupDirectory(final String app) {
        final File appDirectory = new File(Environment.getExternalStorageDirectory(), app);
        return new File(appDirectory, "Backup");
    }

    private static Bitmap rotate(final Bitmap bitmap, final int degree) {


@@ 180,7 172,8 @@ public class FileBackend {
    }

    public static boolean isPathBlacklisted(String path) {
        final String androidDataPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/";
        final String androidDataPath =
                Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/";
        return path.startsWith(androidDataPath);
    }



@@ 192,10 185,6 @@ public class FileBackend {
        return paint;
    }

    private static String getTakePhotoPath() {
        return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + "/Camera/";
    }

    public static Uri getUriForUri(Context context, Uri uri) {
        if ("file".equals(uri.getScheme())) {
            return getUriForFile(context, new File(uri.getPath()));


@@ 246,7 235,6 @@ public class FileBackend {
        return calcSampleSize(options, size);
    }


    private static int calcSampleSize(BitmapFactory.Options options, int size) {
        int height = options.outHeight;
        int width = options.outWidth;


@@ 256,8 244,7 @@ public class FileBackend {
            int halfHeight = height / 2;
            int halfWidth = width / 2;

            while ((halfHeight / inSampleSize) > size
                    && (halfWidth / inSampleSize) > size) {
            while ((halfHeight / inSampleSize) > size && (halfWidth / inSampleSize) > size) {
                inSampleSize *= 2;
            }
        }


@@ 274,7 261,8 @@ public class FileBackend {
        return getVideoDimensions(mediaMetadataRetriever);
    }

    private static Dimensions getVideoDimensionsOfFrame(MediaMetadataRetriever mediaMetadataRetriever) {
    private static Dimensions getVideoDimensionsOfFrame(
            MediaMetadataRetriever mediaMetadataRetriever) {
        Bitmap bitmap = null;
        try {
            bitmap = mediaMetadataRetriever.getFrameAtTime();


@@ 288,8 276,10 @@ public class FileBackend {
        }
    }

    private static Dimensions getVideoDimensions(MediaMetadataRetriever metadataRetriever) throws NotAVideoFile {
        String hasVideo = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
    private static Dimensions getVideoDimensions(MediaMetadataRetriever metadataRetriever)
            throws NotAVideoFile {
        String hasVideo =
                metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
        if (hasVideo == null) {
            throw new NotAVideoFile();
        }


@@ 301,14 291,18 @@ public class FileBackend {
        boolean rotated = rotation == 90 || rotation == 270;
        int height;
        try {
            String h = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
            String h =
                    metadataRetriever.extractMetadata(
                            MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
            height = Integer.parseInt(h);
        } catch (Exception e) {
            height = -1;
        }
        int width;
        try {
            String w = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
            String w =
                    metadataRetriever.extractMetadata(
                            MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
            width = Integer.parseInt(w);
        } catch (Exception e) {
            width = -1;


@@ 319,7 313,9 @@ public class FileBackend {
    }

    private static int extractRotationFromMediaRetriever(MediaMetadataRetriever metadataRetriever) {
        String r = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
        String r =
                metadataRetriever.extractMetadata(
                        MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
        try {
            return Integer.parseInt(r);
        } catch (Exception e) {


@@ 357,36 353,20 @@ public class FileBackend {
        }
    }

    public static boolean weOwnFile(Context context, Uri uri) {
    public static boolean weOwnFile(final Uri uri) {
        if (uri == null || !ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
            return false;
        } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            return fileIsInFilesDir(context, uri);
        } else {
            return weOwnFileLollipop(uri);
        }
    }

    /**
     * This is more than hacky but probably way better than doing nothing
     * Further 'optimizations' might contain to get the parents of CacheDir and NoBackupDir
     * and check against those as well
     */
    private static boolean fileIsInFilesDir(Context context, Uri uri) {
        try {
            final String haystack = context.getFilesDir().getParentFile().getCanonicalPath();
            final String needle = new File(uri.getPath()).getCanonicalPath();
            return needle.startsWith(haystack);
        } catch (IOException e) {
            return false;
        }
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private static boolean weOwnFileLollipop(Uri uri) {
    private static boolean weOwnFileLollipop(final Uri uri) {
        try {
            File file = new File(uri.getPath());
            FileDescriptor fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).getFileDescriptor();
            FileDescriptor fd =
                    ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
                            .getFileDescriptor();
            StructStat st = Os.fstat(fd);
            return st.st_uid == android.os.Process.myUid();
        } catch (FileNotFoundException e) {


@@ 400,18 380,22 @@ public class FileBackend {
        final String filePath = file.getAbsolutePath();
        final Cursor cursor;
        try {
            cursor = context.getContentResolver().query(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    new String[]{MediaStore.Images.Media._ID},
                    MediaStore.Images.Media.DATA + "=? ",
                    new String[]{filePath}, null);
            cursor =
                    context.getContentResolver()
                            .query(
                                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                                    new String[] {MediaStore.Images.Media._ID},
                                    MediaStore.Images.Media.DATA + "=? ",
                                    new String[] {filePath},
                                    null);
        } catch (SecurityException e) {
            return null;
        }
        if (cursor != null && cursor.moveToFirst()) {
            final int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
            cursor.close();
            return Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
            return Uri.withAppendedPath(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
        } else {
            return null;
        }


@@ 433,15 417,30 @@ public class FileBackend {
        final String mime = attachment.getMime();
        if ("application/pdf".equals(mime) && Compatibility.runsTwentyOne()) {
            bitmap = cropCenterSquarePdf(attachment.getUri(), size);
            drawOverlay(bitmap, paintOverlayBlackPdf(bitmap) ? R.drawable.open_pdf_black : R.drawable.open_pdf_white, 0.75f);
            drawOverlay(
                    bitmap,
                    paintOverlayBlackPdf(bitmap)
                            ? R.drawable.open_pdf_black
                            : R.drawable.open_pdf_white,
                    0.75f);
        } else if (mime != null && mime.startsWith("video/")) {
            bitmap = cropCenterSquareVideo(attachment.getUri(), size);
            drawOverlay(bitmap, paintOverlayBlack(bitmap) ? R.drawable.play_video_black : R.drawable.play_video_white, 0.75f);
            drawOverlay(
                    bitmap,
                    paintOverlayBlack(bitmap)
                            ? R.drawable.play_video_black
                            : R.drawable.play_video_white,
                    0.75f);
        } else {
            bitmap = cropCenterSquare(attachment.getUri(), size);
            if (bitmap != null && "image/gif".equals(mime)) {
                Bitmap withGifOverlay = bitmap.copy(Bitmap.Config.ARGB_8888, true);
                drawOverlay(withGifOverlay, paintOverlayBlack(withGifOverlay) ? R.drawable.play_gif_black : R.drawable.play_gif_white, 1.0f);
                drawOverlay(
                        withGifOverlay,
                        paintOverlayBlack(withGifOverlay)
                                ? R.drawable.play_gif_black
                                : R.drawable.play_gif_white,
                        1.0f);
                bitmap.recycle();
                bitmap = withGifOverlay;
            }


@@ 452,53 451,31 @@ public class FileBackend {
        return bitmap;
    }

    private void createNoMedia(File diretory) {
        final File noMedia = new File(diretory, ".nomedia");
        if (!noMedia.exists()) {
            try {
                if (!noMedia.createNewFile()) {
                    Log.d(Config.LOGTAG, "created nomedia file " + noMedia.getAbsolutePath());
                }
            } catch (Exception e) {
                Log.d(Config.LOGTAG, "could not create nomedia file");
            }
        }
    }

    public void updateMediaScanner(File file) {
        updateMediaScanner(file, null);
    }

    public void updateMediaScanner(File file, final Runnable callback) {
        if (!isInDirectoryThatShouldNotBeScanned(mXmppConnectionService, file)) {
            MediaScannerConnection.scanFile(mXmppConnectionService, new String[]{file.getAbsolutePath()}, null, new MediaScannerConnection.MediaScannerConnectionClient() {
                @Override
                public void onMediaScannerConnected() {

                }

                @Override
                public void onScanCompleted(String path, Uri uri) {
                    if (callback != null && file.getAbsolutePath().equals(path)) {
                        callback.run();
                    } else {
                        Log.d(Config.LOGTAG, "media scanner scanned wrong file");
                        if (callback != null) {
        MediaScannerConnection.scanFile(
                mXmppConnectionService,
                new String[] {file.getAbsolutePath()},
                null,
                new MediaScannerConnection.MediaScannerConnectionClient() {
                    @Override
                    public void onMediaScannerConnected() {}

                    @Override
                    public void onScanCompleted(String path, Uri uri) {
                        if (callback != null && file.getAbsolutePath().equals(path)) {
                            callback.run();
                        } else {
                            Log.d(Config.LOGTAG, "media scanner scanned wrong file");
                            if (callback != null) {
                                callback.run();
                            }
                        }
                    }
                }
            });
            return;
            /*Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
            intent.setData(Uri.fromFile(file));
            mXmppConnectionService.sendBroadcast(intent);*/
        } else if (file.getAbsolutePath().startsWith(getAppMediaDirectory(mXmppConnectionService))) {
            createNoMedia(file.getParentFile());
        }
        if (callback != null) {
            callback.run();
        }
                });
    }

    public boolean deleteFile(Message message) {


@@ 515,25 492,30 @@ public class FileBackend {
        return getFile(message, true);
    }


    public DownloadableFile getFileForPath(String path) {
        return getFileForPath(path, MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path)));
        return getFileForPath(
                path,
                MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path)));
    }

    public DownloadableFile getFileForPath(String path, String mime) {
        final DownloadableFile file;
    private DownloadableFile getFileForPath(final String path, final String mime) {
        if (path.startsWith("/")) {
            file = new DownloadableFile(path);
            return new DownloadableFile(path);
        } else {
            if (mime != null && mime.startsWith("image/")) {
                file = new DownloadableFile(getConversationsDirectory("Images") + path);
            } else if (mime != null && mime.startsWith("video/")) {
                file = new DownloadableFile(getConversationsDirectory("Videos") + path);
            } else {
                file = new DownloadableFile(getConversationsDirectory("Files") + path);
            }
            return getLegacyFileForFilename(path, mime);
        }
    }

    public DownloadableFile getLegacyFileForFilename(final String filename, final String mime) {
        if (Strings.isNullOrEmpty(mime)) {
            return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
        } else if (mime.startsWith("image/")) {
            return new DownloadableFile(getLegacyStorageLocation("Images"), filename);
        } else if (mime.startsWith("video/")) {
            return new DownloadableFile(getLegacyStorageLocation("Videos"), filename);
        } else {
            return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
        }
        return file;
    }

    public boolean isInternalFile(final File file) {


@@ 542,33 524,50 @@ public class FileBackend {
    }

    public DownloadableFile getFile(Message message, boolean decrypted) {
        final boolean encrypted = !decrypted
                && (message.getEncryption() == Message.ENCRYPTION_PGP
                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
        final boolean encrypted =
                !decrypted
                        && (message.getEncryption() == Message.ENCRYPTION_PGP
                                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
        String path = message.getRelativeFilePath();
        if (path == null) {
            path = message.getUuid();
        }
        final DownloadableFile file = getFileForPath(path, message.getMimeType());
        if (encrypted) {
            return new DownloadableFile(getConversationsDirectory("Files") + file.getName() + ".pgp");
            return new DownloadableFile(
                    mXmppConnectionService.getCacheDir(),
                    String.format("%s.%s", file.getName(), "pgp"));
        } else {
            return file;
        }
    }

    public List<Attachment> convertToAttachments(List<DatabaseBackend.FilePath> relativeFilePaths) {
        List<Attachment> attachments = new ArrayList<>();
        for (DatabaseBackend.FilePath relativeFilePath : relativeFilePaths) {
            final String mime = MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(relativeFilePath.path));
        final List<Attachment> attachments = new ArrayList<>();
        for (final DatabaseBackend.FilePath relativeFilePath : relativeFilePaths) {
            final String mime =
                    MimeUtils.guessMimeTypeFromExtension(
                            MimeUtils.extractRelevantExtension(relativeFilePath.path));
            final File file = getFileForPath(relativeFilePath.path, mime);
            attachments.add(Attachment.of(relativeFilePath.uuid, file, mime));
        }
        return attachments;
    }

    private String getConversationsDirectory(final String type) {
        return getConversationsDirectory(mXmppConnectionService, type);
    private File getLegacyStorageLocation(final String type) {
        if (Config.ONLY_INTERNAL_STORAGE) {
            return new File(mXmppConnectionService.getFilesDir(), type);
        } else {
            final File appDirectory =
                    new File(
                            Environment.getExternalStorageDirectory(),
                            mXmppConnectionService.getString(R.string.app_name));
            final File appMediaDirectory = new File(appDirectory, "Media");
            final String locationName =
                    String.format(
                            "%s %s", mXmppConnectionService.getString(R.string.app_name), type);
            return new File(appMediaDirectory, locationName);
        }
    }

    private Bitmap resize(final Bitmap originalBitmap, int size) throws IOException {


@@ 586,7 585,8 @@ public class FileBackend {
                scalledW = size;
                scalledH = Math.max((int) (h / ((double) w / size)), 1);
            }
            final Bitmap result = Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
            final Bitmap result =
                    Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
            if (!originalBitmap.isRecycled()) {
                originalBitmap.recycle();
            }


@@ 603,19 603,26 @@ public class FileBackend {
        }
        final File file = new File(path);
        long size = file.length();
        if (size == 0 || size >= mXmppConnectionService.getResources().getInteger(R.integer.auto_accept_filesize)) {
        if (size == 0
                || size
                        >= mXmppConnectionService
                                .getResources()
                                .getInteger(R.integer.auto_accept_filesize)) {
            return false;
        }
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        try {
            final InputStream inputStream = mXmppConnectionService.getContentResolver().openInputStream(uri);
            final InputStream inputStream =
                    mXmppConnectionService.getContentResolver().openInputStream(uri);
            BitmapFactory.decodeStream(inputStream, null, options);
            close(inputStream);
            if (options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
                return false;
            }
            return (options.outWidth <= Config.IMAGE_SIZE && options.outHeight <= Config.IMAGE_SIZE && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
            return (options.outWidth <= Config.IMAGE_SIZE
                    && options.outHeight <= Config.IMAGE_SIZE
                    && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
        } catch (FileNotFoundException e) {
            Log.d(Config.LOGTAG, "unable to get image dimensions", e);
            return false;


@@ 627,7 634,9 @@ public class FileBackend {
    }

    private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException {
        Log.d(Config.LOGTAG, "copy file (" + uri.toString() + ") to private storage " + file.getAbsolutePath());
        Log.d(
                Config.LOGTAG,
                "copy file (" + uri.toString() + ") to private storage " + file.getAbsolutePath());
        file.getParentFile().mkdirs();
        try {
            file.createNewFile();


@@ 635,19 644,20 @@ public class FileBackend {
            throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
        }
        try (final OutputStream os = new FileOutputStream(file);
             final InputStream is = mXmppConnectionService.getContentResolver().openInputStream(uri)) {
                final InputStream is =
                        mXmppConnectionService.getContentResolver().openInputStream(uri)) {
            if (is == null) {
                throw new FileCopyException(R.string.error_file_not_found);
            }
            try {
                ByteStreams.copy(is, os);
            } catch (IOException e) {
                throw new FileWriterException();
                throw new FileWriterException(file);
            }
            try {
                os.flush();
            } catch (IOException e) {
                throw new FileWriterException();
                throw new FileWriterException(file);
            }
        } catch (final FileNotFoundException e) {
            cleanup(file);


@@ 664,7 674,8 @@ public class FileBackend {
        }
    }

    public void copyFileToPrivateStorage(Message message, Uri uri, String type) throws FileCopyException {
    public void copyFileToPrivateStorage(Message message, Uri uri, String type)
            throws FileCopyException {
        String mime = MimeUtils.guessMimeTypeFromUriAndMime(mXmppConnectionService, uri, type);
        Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage (mime=" + mime + ")");
        String extension = MimeUtils.guessExtensionFromMimeType(mime);


@@ 675,29 686,22 @@ public class FileBackend {
        if ("ogg".equals(extension) && type != null && type.startsWith("audio/")) {
            extension = "oga";
        }
        message.setRelativeFilePath(message.getUuid() + "." + extension);
        setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), extension));
        copyFileToPrivateStorage(mXmppConnectionService.getFileBackend().getFile(message), uri);
    }

    private String getExtensionFromUri(Uri uri) {
        String[] projection = {MediaStore.MediaColumns.DATA};
    private String getExtensionFromUri(final Uri uri) {
        final String[] projection = {MediaStore.MediaColumns.DATA};
        String filename = null;
        Cursor cursor;
        try {
            cursor = mXmppConnectionService.getContentResolver().query(uri, projection, null, null, null);
        } catch (IllegalArgumentException e) {
            cursor = null;
        }
        if (cursor != null) {
            try {
                if (cursor.moveToFirst()) {
                    filename = cursor.getString(0);
                }
            } catch (Exception e) {
                filename = null;
            } finally {
                cursor.close();
        try (final Cursor cursor =
                mXmppConnectionService
                        .getContentResolver()
                        .query(uri, projection, null, null, null)) {
            if (cursor != null && cursor.moveToFirst()) {
                filename = cursor.getString(0);
            }
        } catch (final SecurityException | IllegalArgumentException e) {
            filename = null;
        }
        if (filename == null) {
            final List<String> segments = uri.getPathSegments();


@@ 705,11 709,12 @@ public class FileBackend {
                filename = segments.get(segments.size() - 1);
            }
        }
        int pos = filename == null ? -1 : filename.lastIndexOf('.');
        final int pos = filename == null ? -1 : filename.lastIndexOf('.');
        return pos > 0 ? filename.substring(pos + 1) : null;
    }

    private void copyImageToPrivateStorage(File file, Uri image, int sampleSize) throws FileCopyException, ImageCompressionException {
    private void copyImageToPrivateStorage(File file, Uri image, int sampleSize)
            throws FileCopyException, ImageCompressionException {
        final File parent = file.getParentFile();
        if (parent != null && parent.mkdirs()) {
            Log.d(Config.LOGTAG, "created parent directory");


@@ 743,7 748,10 @@ public class FileBackend {
            scaledBitmap = rotate(scaledBitmap, rotation);
            boolean targetSizeReached = false;
            int quality = Config.IMAGE_QUALITY;
            final int imageMaxSize = mXmppConnectionService.getResources().getInteger(R.integer.auto_accept_filesize);
            final int imageMaxSize =
                    mXmppConnectionService
                            .getResources()
                            .getInteger(R.integer.auto_accept_filesize);
            while (!targetSizeReached) {
                os = new FileOutputStream(file);
                Log.d(Config.LOGTAG, "compressing image with quality " + quality);


@@ 788,32 796,79 @@ public class FileBackend {
        }
    }

    public void copyImageToPrivateStorage(File file, Uri image) throws FileCopyException, ImageCompressionException {
        Log.d(Config.LOGTAG, "copy image (" + image.toString() + ") to private storage " + file.getAbsolutePath());
    public void copyImageToPrivateStorage(File file, Uri image)
            throws FileCopyException, ImageCompressionException {
        Log.d(
                Config.LOGTAG,
                "copy image ("
                        + image.toString()
                        + ") to private storage "
                        + file.getAbsolutePath());
        copyImageToPrivateStorage(file, image, 0);
    }

    public void copyImageToPrivateStorage(Message message, Uri image) throws FileCopyException, ImageCompressionException {
    public void copyImageToPrivateStorage(Message message, Uri image)
            throws FileCopyException, ImageCompressionException {
        final String filename;
        switch (Config.IMAGE_FORMAT) {
            case JPEG:
                message.setRelativeFilePath(message.getUuid() + ".jpg");
                filename = String.format("%s.%s", message.getUuid(), "jpg");
                break;
            case PNG:
                message.setRelativeFilePath(message.getUuid() + ".png");
                filename = String.format("%s.%s", message.getUuid(), "png");
                break;
            case WEBP:
                message.setRelativeFilePath(message.getUuid() + ".webp");
                filename = String.format("%s.%s", message.getUuid(), "webp");
                break;
            default:
                throw new IllegalStateException("Unknown image format");
        }
        setupRelativeFilePath(message, filename);
        copyImageToPrivateStorage(getFile(message), image);
        updateFileParams(message);
    }

    public void setupRelativeFilePath(final Message message, final String filename) {
        final String extension = MimeUtils.extractRelevantExtension(filename);
        final String mime = MimeUtils.guessMimeTypeFromExtension(extension);
        setupRelativeFilePath(message, filename, mime);
    }

    public File getStorageLocation(final String filename, final String mime) {
        final File parentDirectory;
        if (Strings.isNullOrEmpty(mime)) {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
        } else if (mime.startsWith("image/")) {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        } else if (mime.startsWith("video/")) {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
        } else if (MediaAdapter.DOCUMENT_MIMES.contains(mime)) {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
        } else {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
        }
        final File appDirectory =
                new File(parentDirectory, mXmppConnectionService.getString(R.string.app_name));
        return new File(appDirectory, filename);
    }

    public void setupRelativeFilePath(
            final Message message, final String filename, final String mime) {
        final File file = getStorageLocation(filename, mime);
        message.setRelativeFilePath(file.getAbsolutePath());
    }

    public boolean unusualBounds(final Uri image) {
        try {
            final BitmapFactory.Options options = new BitmapFactory.Options();
            options.inJustDecodeBounds = true;
            final InputStream inputStream = mXmppConnectionService.getContentResolver().openInputStream(image);
            final InputStream inputStream =
                    mXmppConnectionService.getContentResolver().openInputStream(image);
            BitmapFactory.decodeStream(inputStream, null, options);
            close(inputStream);
            float ratio = (float) options.outHeight / options.outWidth;


@@ 833,7 888,8 @@ public class FileBackend {
    }

    private int getRotation(final Uri image) {
        try (final InputStream is = mXmppConnectionService.getContentResolver().openInputStream(image)) {
        try (final InputStream is =
                mXmppConnectionService.getContentResolver().openInputStream(image)) {
            return is == null ? 0 : getRotation(is);
        } catch (final Exception e) {
            return 0;


@@ 842,7 898,9 @@ public class FileBackend {

    private static int getRotation(final InputStream inputStream) throws IOException {
        final ExifInterface exif = new ExifInterface(inputStream);
        final int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
        final int orientation =
                exif.getAttributeInt(
                        ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
        switch (orientation) {
            case ExifInterface.ORIENTATION_ROTATE_180:
                return 180;


@@ 880,7 938,12 @@ public class FileBackend {
                    thumbnail = rotate(thumbnail, getRotation(file));
                    if (mime.equals("image/gif")) {
                        Bitmap withGifOverlay = thumbnail.copy(Bitmap.Config.ARGB_8888, true);
                        drawOverlay(withGifOverlay, paintOverlayBlack(withGifOverlay) ? R.drawable.play_gif_black : R.drawable.play_gif_white, 1.0f);
                        drawOverlay(
                                withGifOverlay,
                                paintOverlayBlack(withGifOverlay)
                                        ? R.drawable.play_gif_black
                                        : R.drawable.play_gif_white,
                                1.0f);
                        thumbnail.recycle();
                        thumbnail = withGifOverlay;
                    }


@@ 903,27 966,36 @@ public class FileBackend {
    }

    private void drawOverlay(Bitmap bitmap, int resource, float factor) {
        Bitmap overlay = BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
        Bitmap overlay =
                BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
        Canvas canvas = new Canvas(bitmap);
        float targetSize = Math.min(canvas.getWidth(), canvas.getHeight()) * factor;
        Log.d(Config.LOGTAG, "target size overlay: " + targetSize + " overlay bitmap size was " + overlay.getHeight());
        Log.d(
                Config.LOGTAG,
                "target size overlay: "
                        + targetSize
                        + " overlay bitmap size was "
                        + overlay.getHeight());
        float left = (canvas.getWidth() - targetSize) / 2.0f;
        float top = (canvas.getHeight() - targetSize) / 2.0f;
        RectF dst = new RectF(left, top, left + targetSize - 1, top + targetSize - 1);
        canvas.drawBitmap(overlay, null, dst, createAntiAliasingPaint());
    }

    /**
     * https://stackoverflow.com/a/3943023/210897
     */
    /** https://stackoverflow.com/a/3943023/210897 */
    private boolean paintOverlayBlack(final Bitmap bitmap) {
        final int h = bitmap.getHeight();
        final int w = bitmap.getWidth();
        int record = 0;
        for (int y = Math.round(h * IGNORE_PADDING); y < h - Math.round(h * IGNORE_PADDING); ++y) {
            for (int x = Math.round(w * IGNORE_PADDING); x < w - Math.round(w * IGNORE_PADDING); ++x) {
            for (int x = Math.round(w * IGNORE_PADDING);
                    x < w - Math.round(w * IGNORE_PADDING);
                    ++x) {
                int pixel = bitmap.getPixel(x, y);
                if ((Color.red(pixel) * 0.299 + Color.green(pixel) * 0.587 + Color.blue(pixel) * 0.114) > 186) {
                if ((Color.red(pixel) * 0.299
                                + Color.green(pixel) * 0.587
                                + Color.blue(pixel) * 0.114)
                        > 186) {
                    --record;
                } else {
                    ++record;


@@ 940,7 1012,10 @@ public class FileBackend {
        for (int y = 0; y < h; ++y) {
            for (int x = 0; x < w; ++x) {
                int pixel = bitmap.getPixel(x, y);
                if ((Color.red(pixel) * 0.299 + Color.green(pixel) * 0.587 + Color.blue(pixel) * 0.114) > 186) {
                if ((Color.red(pixel) * 0.299
                                + Color.green(pixel) * 0.587
                                + Color.blue(pixel) * 0.114)
                        > 186) {
                    white++;
                }
            }


@@ 975,16 1050,27 @@ public class FileBackend {
            frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
            frame.eraseColor(0xff000000);
        }
        drawOverlay(frame, paintOverlayBlack(frame) ? R.drawable.play_video_black : R.drawable.play_video_white, 0.75f);
        drawOverlay(
                frame,
                paintOverlayBlack(frame)
                        ? R.drawable.play_video_black
                        : R.drawable.play_video_white,
                0.75f);
        return frame;
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private Bitmap getPdfDocumentPreview(final File file, final int size) {
        try {
            final ParcelFileDescriptor fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
            final ParcelFileDescriptor fileDescriptor =
                    ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
            final Bitmap rendered = renderPdfDocument(fileDescriptor, size, true);
            drawOverlay(rendered, paintOverlayBlackPdf(rendered) ? R.drawable.open_pdf_black : R.drawable.open_pdf_white, 0.75f);
            drawOverlay(
                    rendered,
                    paintOverlayBlackPdf(rendered)
                            ? R.drawable.open_pdf_black
                            : R.drawable.open_pdf_white,
                    0.75f);
            return rendered;
        } catch (final IOException | SecurityException e) {
            Log.d(Config.LOGTAG, "unable to render PDF document preview", e);


@@ 994,11 1080,11 @@ public class FileBackend {
        }
    }


    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
        try {
            ParcelFileDescriptor fileDescriptor = mXmppConnectionService.getContentResolver().openFileDescriptor(uri, "r");
            ParcelFileDescriptor fileDescriptor =
                    mXmppConnectionService.getContentResolver().openFileDescriptor(uri, "r");
            final Bitmap bitmap = renderPdfDocument(fileDescriptor, size, false);
            return cropCenterSquare(bitmap, size);
        } catch (Exception e) {


@@ 1009,11 1095,15 @@ public class FileBackend {
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private Bitmap renderPdfDocument(ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
    private Bitmap renderPdfDocument(
            ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
        final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
        final PdfRenderer.Page page = pdfRenderer.openPage(0);
        final Dimensions dimensions = scalePdfDimensions(new Dimensions(page.getHeight(), page.getWidth()), targetSize, fit);
        final Bitmap rendered = Bitmap.createBitmap(dimensions.width, dimensions.height, Bitmap.Config.ARGB_8888);
        final Dimensions dimensions =
                scalePdfDimensions(
                        new Dimensions(page.getHeight(), page.getWidth()), targetSize, fit);
        final Bitmap rendered =
                Bitmap.createBitmap(dimensions.width, dimensions.height, Bitmap.Config.ARGB_8888);
        rendered.eraseColor(0xffffffff);
        page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
        page.close();


@@ 1023,12 1113,19 @@ public class FileBackend {
    }

    public Uri getTakePhotoUri() {
        File file;
        final String filename =
                String.format("IMG_%s.%s", IMAGE_DATE_FORMAT.format(new Date()), "jpg");
        final File directory;
        if (Config.ONLY_INTERNAL_STORAGE) {
            file = new File(mXmppConnectionService.getCacheDir().getAbsolutePath(), "Camera/IMG_" + IMAGE_DATE_FORMAT.format(new Date()) + ".jpg");
            directory = new File(mXmppConnectionService.getCacheDir(), "Camera");
        } else {
            file = new File(getTakePhotoPath() + "IMG_" + IMAGE_DATE_FORMAT.format(new Date()) + ".jpg");
            directory =
                    new File(
                            Environment.getExternalStoragePublicDirectory(
                                    Environment.DIRECTORY_DCIM),
                            "Camera");
        }
        final File file = new File(directory, filename);
        file.getParentFile().mkdirs();
        return getUriForFile(mXmppConnectionService, file);
    }


@@ 1036,11 1133,15 @@ public class FileBackend {
    public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {

        final Avatar uncompressAvatar = getUncompressedAvatar(image);
        if (uncompressAvatar != null && uncompressAvatar.image.length() <= Config.AVATAR_CHAR_LIMIT) {
        if (uncompressAvatar != null
                && uncompressAvatar.image.length() <= Config.AVATAR_CHAR_LIMIT) {
            return uncompressAvatar;
        }
        if (uncompressAvatar != null) {
            Log.d(Config.LOGTAG, "uncompressed avatar exceeded char limit by " + (uncompressAvatar.image.length() - Config.AVATAR_CHAR_LIMIT));
            Log.d(
                    Config.LOGTAG,
                    "uncompressed avatar exceeded char limit by "
                            + (uncompressAvatar.image.length() - Config.AVATAR_CHAR_LIMIT));
        }

        Bitmap bm = cropCenterSquare(image, size);


@@ 1059,7 1160,9 @@ public class FileBackend {
    private Avatar getUncompressedAvatar(Uri uri) {
        Bitmap bitmap = null;
        try {
            bitmap = BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(uri));
            bitmap =
                    BitmapFactory.decodeStream(
                            mXmppConnectionService.getContentResolver().openInputStream(uri));
            return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
        } catch (Exception e) {
            return null;


@@ 1073,18 1176,24 @@ public class FileBackend {
    private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
        try {
            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
            Base64OutputStream mBase64OutputStream = new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
            Base64OutputStream mBase64OutputStream =
                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
            MessageDigest digest = MessageDigest.getInstance("SHA-1");
            DigestOutputStream mDigestOutputStream = new DigestOutputStream(mBase64OutputStream, digest);
            DigestOutputStream mDigestOutputStream =
                    new DigestOutputStream(mBase64OutputStream, digest);
            if (!bitmap.compress(format, quality, mDigestOutputStream)) {
                return null;
            }
            mDigestOutputStream.flush();
            mDigestOutputStream.close();
            long chars = mByteArrayOutputStream.size();
            if (format != Bitmap.CompressFormat.PNG && quality >= 50 && chars >= Config.AVATAR_CHAR_LIMIT) {
            if (format != Bitmap.CompressFormat.PNG
                    && quality >= 50
                    && chars >= Config.AVATAR_CHAR_LIMIT) {
                int q = quality - 2;
                Log.d(Config.LOGTAG, "avatar char length was " + chars + " reducing quality to " + q);
                Log.d(
                        Config.LOGTAG,
                        "avatar char length was " + chars + " reducing quality to " + q);
                return getPepAvatar(bitmap, format, q);
            }
            Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);


@@ 1123,7 1232,8 @@ public class FileBackend {
            BitmapFactory.decodeFile(file.getAbsolutePath(), options);
            is = new FileInputStream(file);
            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
            Base64OutputStream mBase64OutputStream = new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
            Base64OutputStream mBase64OutputStream =
                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
            MessageDigest digest = MessageDigest.getInstance("SHA-1");
            DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
            byte[] buffer = new byte[4096];


@@ 1157,14 1267,20 @@ public class FileBackend {
            file = getAvatarFile(avatar.getFilename());
            avatar.size = file.length();
        } else {
            file = new File(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + UUID.randomUUID().toString());
            file =
                    new File(
                            mXmppConnectionService.getCacheDir().getAbsolutePath()
                                    + "/"
                                    + UUID.randomUUID().toString());
            if (file.getParentFile().mkdirs()) {
                Log.d(Config.LOGTAG, "created cache directory");
            }
            OutputStream os = null;
            try {
                if (!file.createNewFile()) {
                    Log.d(Config.LOGTAG, "unable to create temporary file " + file.getAbsolutePath());
                    Log.d(
                            Config.LOGTAG,
                            "unable to create temporary file " + file.getAbsolutePath());
                }
                os = new FileOutputStream(file);
                MessageDigest digest = MessageDigest.getInstance("SHA-1");


@@ 1182,7 1298,9 @@ public class FileBackend {
                    }
                    final File avatarFile = getAvatarFile(avatar.getFilename());
                    if (!file.renameTo(avatarFile)) {
                        Log.d(Config.LOGTAG, "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
                        Log.d(
                                Config.LOGTAG,
                                "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
                        return false;
                    }
                } else {


@@ 1294,7 1412,7 @@ public class FileBackend {
            }
            return dest;
        } catch (SecurityException e) {
            return null; //android 6.0 with revoked permissions for example
            return null; // android 6.0 with revoked permissions for example
        } catch (FileNotFoundException e) {
            return null;
        } finally {


@@ 1323,10 1441,12 @@ public class FileBackend {
        return output;
    }

    private int calcSampleSize(Uri image, int size) throws FileNotFoundException, SecurityException {
    private int calcSampleSize(Uri image, int size)
            throws FileNotFoundException, SecurityException {
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        final InputStream inputStream = mXmppConnectionService.getContentResolver().openInputStream(image);
        final InputStream inputStream =
                mXmppConnectionService.getContentResolver().openInputStream(image);
        BitmapFactory.decodeStream(inputStream, null, options);
        close(inputStream);
        return calcSampleSize(options, size);


@@ 1340,7 1460,9 @@ public class FileBackend {
        DownloadableFile file = getFile(message);
        final String mime = file.getMimeType();
        final boolean privateMessage = message.isPrivateMessage();
        final boolean image = message.getType() == Message.TYPE_IMAGE || (mime != null && mime.startsWith("image/"));
        final boolean image =
                message.getType() == Message.TYPE_IMAGE
                        || (mime != null && mime.startsWith("image/"));
        final boolean video = mime != null && mime.startsWith("video/");
        final boolean audio = mime != null && mime.startsWith("audio/");
        final boolean pdf = "application/pdf".equals(mime);


@@ 1363,22 1485,29 @@ public class FileBackend {
                    body.append('|').append(dimensions.width).append('|').append(dimensions.height);
                }
            } catch (NotAVideoFile notAVideoFile) {
                Log.d(Config.LOGTAG, "file with mime type " + file.getMimeType() + " was not a video file");
                //fall threw
                Log.d(
                        Config.LOGTAG,
                        "file with mime type " + file.getMimeType() + " was not a video file");
                // fall threw
            }
        } else if (audio) {
            body.append("|0|0|").append(getMediaRuntime(file));
        }
        message.setBody(body.toString());
        message.setDeleted(false);
        message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
        message.setType(
                privateMessage
                        ? Message.TYPE_PRIVATE_FILE
                        : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
    }

    private int getMediaRuntime(File file) {
        try {
            MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
            mediaMetadataRetriever.setDataSource(file.toString());
            return Integer.parseInt(mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
            return Integer.parseInt(
                    mediaMetadataRetriever.extractMetadata(
                            MediaMetadataRetriever.METADATA_KEY_DURATION));
        } catch (RuntimeException e) {
            return 0;
        }


@@ 1431,12 1560,14 @@ public class FileBackend {
    }

    private Dimensions scalePdfDimensions(Dimensions in) {
        final DisplayMetrics displayMetrics = mXmppConnectionService.getResources().getDisplayMetrics();
        final DisplayMetrics displayMetrics =
                mXmppConnectionService.getResources().getDisplayMetrics();
        final int target = (int) (displayMetrics.density * 288);
        return scalePdfDimensions(in, target, true);
    }

    private static Dimensions scalePdfDimensions(final Dimensions in, final int target, final boolean fit) {
    private static Dimensions scalePdfDimensions(
            final Dimensions in, final int target, final boolean fit) {
        final int w, h;
        if (fit == (in.width <= in.height)) {
            w = Math.max((int) (in.width / ((double) in.height / target)), 1);


@@ 1491,7 1622,6 @@ public class FileBackend {
        }
    }


    public static class FileCopyException extends Exception {
        private final int resId;



@@ 1499,8 1629,7 @@ public class FileBackend {
            this.resId = resId;
        }

        public @StringRes
        int getResId() {
        public @StringRes int getResId() {
            return resId;
        }
    }

M src/main/java/eu/siacs/conversations/services/AttachFileToConversationRunnable.java => src/main/java/eu/siacs/conversations/services/AttachFileToConversationRunnable.java +1 -1
@@ 91,7 91,7 @@ public class AttachFileToConversationRunnable implements Runnable, TranscoderLis
    private void processAsVideo() throws FileNotFoundException {
        Log.d(Config.LOGTAG, "processing file as video");
        mXmppConnectionService.startForcingForegroundNotification();
        message.setRelativeFilePath(message.getUuid() + ".mp4");
        mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), "mp4"));
        final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
        if (Objects.requireNonNull(file.getParentFile()).mkdirs()) {
            Log.d(Config.LOGTAG, "created parent directory for video file");

M src/main/java/eu/siacs/conversations/services/ExportBackupService.java => src/main/java/eu/siacs/conversations/services/ExportBackupService.java +3 -3
@@ 291,7 291,7 @@ public class ExportBackupService extends Service {
            secureRandom.nextBytes(salt);
            final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
            final Progress progress = new Progress(mBuilder, max, count);
            final File file = new File(FileBackend.getBackupDirectory(this) + account.getJid().asBareJid().toEscapedString() + ".ceb");
            final File file = new File(FileBackend.getBackupDirectory(this), account.getJid().asBareJid().toEscapedString() + ".ceb");
            files.add(file);
            final File directory = file.getParentFile();
            if (directory != null && directory.mkdirs()) {


@@ 335,7 335,7 @@ public class ExportBackupService extends Service {
    }

    private void notifySuccess(final List<File> files) {
        final String path = FileBackend.getBackupDirectory(this);
        final String path = FileBackend.getBackupDirectory(this).getAbsolutePath();

        PendingIntent openFolderIntent = null;



@@ 363,7 363,7 @@ public class ExportBackupService extends Service {
        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
        mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
                .setContentText(getString(R.string.notification_backup_created_subtitle, path))
                .setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this))))
                .setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this).getAbsolutePath())))
                .setAutoCancel(true)
                .setContentIntent(openFolderIntent)
                .setSmallIcon(R.drawable.ic_archive_white_24dp);

M src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java => src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java +3 -4
@@ 46,7 46,6 @@ import eu.siacs.conversations.ui.util.MyLinkify;
import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
import eu.siacs.conversations.utils.AccountUtils;
import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.EmojiWrapper;
import eu.siacs.conversations.utils.StringUtils;
import eu.siacs.conversations.utils.StylingHelper;
import eu.siacs.conversations.utils.XmppUri;


@@ 471,11 470,11 @@ public class ConferenceDetailsActivity extends XmppActivity implements OnConvers
        String subject = mucOptions.getSubject();
        final boolean hasTitle;
        if (printableValue(roomName)) {
            this.binding.mucTitle.setText(EmojiWrapper.transform(roomName));
            this.binding.mucTitle.setText(roomName);
            this.binding.mucTitle.setVisibility(View.VISIBLE);
            hasTitle = true;
        } else if (!printableValue(subject)) {
            this.binding.mucTitle.setText(EmojiWrapper.transform(mConversation.getName()));
            this.binding.mucTitle.setText(mConversation.getName());
            hasTitle = true;
            this.binding.mucTitle.setVisibility(View.VISIBLE);
        } else {


@@ 486,7 485,7 @@ public class ConferenceDetailsActivity extends XmppActivity implements OnConvers
            SpannableStringBuilder spannable = new SpannableStringBuilder(subject);
            StylingHelper.format(spannable, this.binding.mucSubject.getCurrentTextColor());
            MyLinkify.addLinks(spannable, false);
            this.binding.mucSubject.setText(EmojiWrapper.transform(spannable));
            this.binding.mucSubject.setText(spannable);
            this.binding.mucSubject.setTextAppearance(this, subject.length() > (hasTitle ? 128 : 196) ? R.style.TextAppearance_Conversations_Body1_Linkified : R.style.TextAppearance_Conversations_Subhead);
            this.binding.mucSubject.setAutoLinkMask(0);
            this.binding.mucSubject.setVisibility(View.VISIBLE);

M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java => src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +7 -6
@@ 6,6 6,7 @@ import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;


@@ 1183,8 1184,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
                cancelTransmission.setVisible(true);
            }
            if (m.isFileOrImage() && !deleted && !cancelable) {
                String path = m.getRelativeFilePath();
                if (path == null || !path.startsWith("/") || FileBackend.isInDirectoryThatShouldNotBeScanned(getActivity(), path)) {
                final String path = m.getRelativeFilePath();
                if (path == null || !path.startsWith("/")) {
                    deleteFile.setVisible(true);
                    deleteFile.setTitle(activity.getString(R.string.delete_x_file, UIHelper.getFileDescriptionString(activity, m)));
                }


@@ 1744,7 1745,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
        if (context == null) {
            return;
        }
        if (intent.resolveActivity(context.getPackageManager()) != null) {
        try {
            if (chooser) {
                startActivityForResult(
                        Intent.createChooser(intent, getString(R.string.perform_action_with)),


@@ 1752,7 1753,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
            } else {
                startActivityForResult(intent, attachmentChoice);
            }
        } else {
        } catch (final ActivityNotFoundException e) {
            Toast.makeText(context, R.string.no_application_found, Toast.LENGTH_LONG).show();
        }
    }


@@ 2254,10 2255,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
    }

    private List<Uri> cleanUris(final List<Uri> uris) {
        Iterator<Uri> iterator = uris.iterator();
        final Iterator<Uri> iterator = uris.iterator();
        while (iterator.hasNext()) {
            final Uri uri = iterator.next();
            if (FileBackend.weOwnFile(getActivity(), uri)) {
            if (FileBackend.weOwnFile(uri)) {
                iterator.remove();
                Toast.makeText(getActivity(), R.string.security_violation_not_attaching_file, Toast.LENGTH_SHORT).show();
            }

M src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java => src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +1 -2
@@ 81,7 81,6 @@ import eu.siacs.conversations.ui.util.ActivityResult;
import eu.siacs.conversations.ui.util.ConversationMenuConfigurator;
import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
import eu.siacs.conversations.ui.util.PendingItem;
import eu.siacs.conversations.utils.EmojiWrapper;
import eu.siacs.conversations.utils.ExceptionHelper;
import eu.siacs.conversations.utils.SignupUtils;
import eu.siacs.conversations.utils.XmppUri;


@@ 615,7 614,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
        if (mainFragment instanceof ConversationFragment) {
            final Conversation conversation = ((ConversationFragment) mainFragment).getConversation();
            if (conversation != null) {
                actionBar.setTitle(EmojiWrapper.transform(conversation.getName()));
                actionBar.setTitle(conversation.getName());
                actionBar.setDisplayHomeAsUpEnabled(true);
                ActionBarUtil.setActionBarOnClickListener(
                        binding.toolbar,

M src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java => src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java +247 -230
@@ 22,6 22,7 @@ import java.util.List;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.EnterJidDialogBinding;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.adapter.KnownHostsAdapter;
import eu.siacs.conversations.ui.interfaces.OnBackendConnected;
import eu.siacs.conversations.ui.util.DelayedHintHelper;


@@ 29,234 30,250 @@ import eu.siacs.conversations.xmpp.Jid;

public class EnterJidDialog extends DialogFragment implements OnBackendConnected, TextWatcher {


	private static final List<String> SUSPICIOUS_DOMAINS = Arrays.asList("conference","muc","room","rooms","chat");

	private OnEnterJidDialogPositiveListener mListener = null;

	private static final String TITLE_KEY = "title";
	private static final String POSITIVE_BUTTON_KEY = "positive_button";
	private static final String PREFILLED_JID_KEY = "prefilled_jid";
	private static final String ACCOUNT_KEY = "account";
	private static final String ALLOW_EDIT_JID_KEY = "allow_edit_jid";
	private static final String ACCOUNTS_LIST_KEY = "activated_accounts_list";
	private static final String SANITY_CHECK_JID = "sanity_check_jid";

	private KnownHostsAdapter knownHostsAdapter;
	private Collection<String> whitelistedDomains = Collections.emptyList();

	private EnterJidDialogBinding binding;
	private AlertDialog dialog;
	private boolean sanityCheckJid = false;


	private boolean issuedWarning = false;

	public static EnterJidDialog newInstance(final List<String> activatedAccounts,
	                                         final String title, final String positiveButton,
	                                         final String prefilledJid, final String account,
											 boolean allowEditJid, final boolean sanity_check_jid) {
		EnterJidDialog dialog = new EnterJidDialog();
		Bundle bundle = new Bundle();
		bundle.putString(TITLE_KEY, title);
		bundle.putString(POSITIVE_BUTTON_KEY, positiveButton);
		bundle.putString(PREFILLED_JID_KEY, prefilledJid);
		bundle.putString(ACCOUNT_KEY, account);
		bundle.putBoolean(ALLOW_EDIT_JID_KEY, allowEditJid);
		bundle.putStringArrayList(ACCOUNTS_LIST_KEY, (ArrayList<String>) activatedAccounts);
		bundle.putBoolean(SANITY_CHECK_JID, sanity_check_jid);
		dialog.setArguments(bundle);
		return dialog;
	}

	@Override
	public void onActivityCreated(Bundle savedInstanceState) {
		super.onActivityCreated(savedInstanceState);
		setRetainInstance(true);
	}

	@Override
	public void onStart() {
		super.onStart();
		final Activity activity = getActivity();
		if (activity instanceof XmppActivity && ((XmppActivity) activity).xmppConnectionService != null) {
			refreshKnownHosts();
		}
	}

	@NonNull
	@Override
	public Dialog onCreateDialog(Bundle savedInstanceState) {
		final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
		builder.setTitle(getArguments().getString(TITLE_KEY));
		binding = DataBindingUtil.inflate(getActivity().getLayoutInflater(), R.layout.enter_jid_dialog, null, false);
		this.knownHostsAdapter = new KnownHostsAdapter(getActivity(), R.layout.simple_list_item);
		binding.jid.setAdapter(this.knownHostsAdapter);
		binding.jid.addTextChangedListener(this);
		String prefilledJid = getArguments().getString(PREFILLED_JID_KEY);
		if (prefilledJid != null) {
			binding.jid.append(prefilledJid);
			if (!getArguments().getBoolean(ALLOW_EDIT_JID_KEY)) {
				binding.jid.setFocusable(false);
				binding.jid.setFocusableInTouchMode(false);
				binding.jid.setClickable(false);
				binding.jid.setCursorVisible(false);
			}
		}
		sanityCheckJid = getArguments().getBoolean(SANITY_CHECK_JID, false);

		DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);

		String account = getArguments().getString(ACCOUNT_KEY);
		if (account == null) {
			StartConversationActivity.populateAccountSpinner(getActivity(), getArguments().getStringArrayList(ACCOUNTS_LIST_KEY), binding.account);
		} else {
			ArrayAdapter<String> adapter = new ArrayAdapter<>(getActivity(),
					R.layout.simple_list_item,
					new String[]{account});
			binding.account.setEnabled(false);
			adapter.setDropDownViewResource(R.layout.simple_list_item);
			binding.account.setAdapter(adapter);
		}



		builder.setView(binding.getRoot());
		builder.setNegativeButton(R.string.cancel, null);
		builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null);
		this.dialog = builder.create();

		View.OnClickListener dialogOnClick = v -> {
			handleEnter(binding, account);
		};

		binding.jid.setOnEditorActionListener((v, actionId, event) -> {
			handleEnter(binding, account);
			return true;
		});

		dialog.show();
		dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(dialogOnClick);
		return dialog;
	}

	private void handleEnter(EnterJidDialogBinding binding, String account) {
		final Jid accountJid;
		if (!binding.account.isEnabled() && account == null) {
			return;
		}
		try {
			if (Config.DOMAIN_LOCK != null) {
				accountJid = Jid.ofEscaped((String) binding.account.getSelectedItem(), Config.DOMAIN_LOCK, null);
			} else {
				accountJid = Jid.ofEscaped((String) binding.account.getSelectedItem());
			}
		} catch (final IllegalArgumentException e) {
			return;
		}
		final Jid contactJid;
		try {
			contactJid = Jid.ofEscaped(binding.jid.getText().toString());
		} catch (final IllegalArgumentException e) {
			binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
			return;
		}

		if (!issuedWarning && sanityCheckJid) {
			if (contactJid.isDomainJid()) {
				binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_a_domain));
				dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
				issuedWarning = true;
				return;
			}
			if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
				binding.jidLayout.setError(getActivity().getString(R.string.this_looks_like_channel));
				dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
				issuedWarning = true;
				return;
			}
		}

		if (mListener != null) {
			try {
				if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
					dialog.dismiss();
				}
			} catch (JidError error) {
				binding.jidLayout.setError(error.toString());
				dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
				issuedWarning = false;
			}
		}
	}

	public void setOnEnterJidDialogPositiveListener(OnEnterJidDialogPositiveListener listener) {
		this.mListener = listener;
	}

	@Override
	public void onBackendConnected() {
		refreshKnownHosts();
	}

	private void refreshKnownHosts() {
		Activity activity = getActivity();
		if (activity instanceof XmppActivity) {
			Collection<String> hosts = ((XmppActivity) activity).xmppConnectionService.getKnownHosts();
			this.knownHostsAdapter.refresh(hosts);
			this.whitelistedDomains = hosts;
		}
	}

	@Override
	public void beforeTextChanged(CharSequence s, int start, int count, int after) {

	}

	@Override
	public void onTextChanged(CharSequence s, int start, int before, int count) {

	}

	@Override
	public void afterTextChanged(Editable s) {
		if (issuedWarning) {
			dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
			binding.jidLayout.setError(null);
			issuedWarning = false;
		}
	}

	public interface OnEnterJidDialogPositiveListener {
		boolean onEnterJidDialogPositive(Jid account, Jid contact) throws EnterJidDialog.JidError;
	}

	public static class JidError extends Exception {
		final String msg;

		public JidError(final String msg) {
			this.msg = msg;
		}

		public String toString() {
			return msg;
		}
	}

	@Override
	public void onDestroyView() {
		Dialog dialog = getDialog();
		if (dialog != null && getRetainInstance()) {
			dialog.setDismissMessage(null);
		}
		super.onDestroyView();
	}

	private boolean suspiciousSubDomain(String domain) {
		if (this.whitelistedDomains.contains(domain)) {
			return false;
		}
		final String[] parts = domain.split("\\.");
		return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
	}
    private static final List<String> SUSPICIOUS_DOMAINS =
            Arrays.asList("conference", "muc", "room", "rooms", "chat");

    private OnEnterJidDialogPositiveListener mListener = null;

    private static final String TITLE_KEY = "title";
    private static final String POSITIVE_BUTTON_KEY = "positive_button";
    private static final String PREFILLED_JID_KEY = "prefilled_jid";
    private static final String ACCOUNT_KEY = "account";
    private static final String ALLOW_EDIT_JID_KEY = "allow_edit_jid";
    private static final String ACCOUNTS_LIST_KEY = "activated_accounts_list";
    private static final String SANITY_CHECK_JID = "sanity_check_jid";

    private KnownHostsAdapter knownHostsAdapter;
    private Collection<String> whitelistedDomains = Collections.emptyList();

    private EnterJidDialogBinding binding;
    private AlertDialog dialog;
    private boolean sanityCheckJid = false;

    private boolean issuedWarning = false;

    public static EnterJidDialog newInstance(
            final List<String> activatedAccounts,
            final String title,
            final String positiveButton,
            final String prefilledJid,
            final String account,
            boolean allowEditJid,
            final boolean sanity_check_jid) {
        EnterJidDialog dialog = new EnterJidDialog();
        Bundle bundle = new Bundle();
        bundle.putString(TITLE_KEY, title);
        bundle.putString(POSITIVE_BUTTON_KEY, positiveButton);
        bundle.putString(PREFILLED_JID_KEY, prefilledJid);
        bundle.putString(ACCOUNT_KEY, account);
        bundle.putBoolean(ALLOW_EDIT_JID_KEY, allowEditJid);
        bundle.putStringArrayList(ACCOUNTS_LIST_KEY, (ArrayList<String>) activatedAccounts);
        bundle.putBoolean(SANITY_CHECK_JID, sanity_check_jid);
        dialog.setArguments(bundle);
        return dialog;
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        setRetainInstance(true);
    }

    @Override
    public void onStart() {
        super.onStart();
        final Activity activity = getActivity();
        if (activity instanceof XmppActivity
                && ((XmppActivity) activity).xmppConnectionService != null) {
            refreshKnownHosts();
        }
    }

    @NonNull
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setTitle(getArguments().getString(TITLE_KEY));
        binding =
                DataBindingUtil.inflate(
                        getActivity().getLayoutInflater(), R.layout.enter_jid_dialog, null, false);
        this.knownHostsAdapter = new KnownHostsAdapter(getActivity(), R.layout.simple_list_item);
        binding.jid.setAdapter(this.knownHostsAdapter);
        binding.jid.addTextChangedListener(this);
        String prefilledJid = getArguments().getString(PREFILLED_JID_KEY);
        if (prefilledJid != null) {
            binding.jid.append(prefilledJid);
            if (!getArguments().getBoolean(ALLOW_EDIT_JID_KEY)) {
                binding.jid.setFocusable(false);
                binding.jid.setFocusableInTouchMode(false);
                binding.jid.setClickable(false);
                binding.jid.setCursorVisible(false);
            }
        }
        sanityCheckJid = getArguments().getBoolean(SANITY_CHECK_JID, false);

        DelayedHintHelper.setHint(R.string.account_settings_example_jabber_id, binding.jid);

        String account = getArguments().getString(ACCOUNT_KEY);
        if (account == null) {
            StartConversationActivity.populateAccountSpinner(
                    getActivity(),
                    getArguments().getStringArrayList(ACCOUNTS_LIST_KEY),
                    binding.account);
        } else {
            ArrayAdapter<String> adapter =
                    new ArrayAdapter<>(
                            getActivity(), R.layout.simple_list_item, new String[] {account});
            binding.account.setEnabled(false);
            adapter.setDropDownViewResource(R.layout.simple_list_item);
            binding.account.setAdapter(adapter);
        }

        builder.setView(binding.getRoot());
        builder.setNegativeButton(R.string.cancel, null);
        builder.setPositiveButton(getArguments().getString(POSITIVE_BUTTON_KEY), null);
        this.dialog = builder.create();

        View.OnClickListener dialogOnClick =
                v -> {
                    handleEnter(binding, account);
                };

        binding.jid.setOnEditorActionListener(
                (v, actionId, event) -> {
                    handleEnter(binding, account);
                    return true;
                });

        dialog.show();
        dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(dialogOnClick);
        return dialog;
    }

    private void handleEnter(EnterJidDialogBinding binding, String account) {
        final Jid accountJid;
        if (!binding.account.isEnabled() && account == null) {
            return;
        }
        try {
            if (Config.DOMAIN_LOCK != null) {
                accountJid =
                        Jid.ofEscaped(
                                (String) binding.account.getSelectedItem(),
                                Config.DOMAIN_LOCK,
                                null);
            } else {
                accountJid = Jid.ofEscaped((String) binding.account.getSelectedItem());
            }
        } catch (final IllegalArgumentException e) {
            return;
        }
        final Jid contactJid;
        try {
            contactJid = Jid.ofEscaped(binding.jid.getText().toString());
        } catch (final IllegalArgumentException e) {
            binding.jidLayout.setError(getActivity().getString(R.string.invalid_jid));
            return;
        }

        if (!issuedWarning && sanityCheckJid) {
            if (contactJid.isDomainJid()) {
                binding.jidLayout.setError(
                        getActivity().getString(R.string.this_looks_like_a_domain));
                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
                issuedWarning = true;
                return;
            }
            if (suspiciousSubDomain(contactJid.getDomain().toEscapedString())) {
                binding.jidLayout.setError(
                        getActivity().getString(R.string.this_looks_like_channel));
                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add_anway);
                issuedWarning = true;
                return;
            }
        }

        if (mListener != null) {
            try {
                if (mListener.onEnterJidDialogPositive(accountJid, contactJid)) {
                    dialog.dismiss();
                }
            } catch (JidError error) {
                binding.jidLayout.setError(error.toString());
                dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
                issuedWarning = false;
            }
        }
    }

    public void setOnEnterJidDialogPositiveListener(OnEnterJidDialogPositiveListener listener) {
        this.mListener = listener;
    }

    @Override
    public void onBackendConnected() {
        refreshKnownHosts();
    }

    private void refreshKnownHosts() {
        final Activity activity = getActivity();
        if (activity instanceof XmppActivity) {
            final XmppConnectionService service = ((XmppActivity) activity).xmppConnectionService;
            if (service == null) {
                return;
            }
            final Collection<String> hosts = service.getKnownHosts();
            this.knownHostsAdapter.refresh(hosts);
            this.whitelistedDomains = hosts;
        }
    }

    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {}

    @Override
    public void afterTextChanged(Editable s) {
        if (issuedWarning) {
            dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.add);
            binding.jidLayout.setError(null);
            issuedWarning = false;
        }
    }

    public interface OnEnterJidDialogPositiveListener {
        boolean onEnterJidDialogPositive(Jid account, Jid contact) throws EnterJidDialog.JidError;
    }

    public static class JidError extends Exception {
        final String msg;

        public JidError(final String msg) {
            this.msg = msg;
        }

        @NonNull
        public String toString() {
            return msg;
        }
    }

    @Override
    public void onDestroyView() {
        Dialog dialog = getDialog();
        if (dialog != null && getRetainInstance()) {
            dialog.setDismissMessage(null);
        }
        super.onDestroyView();
    }

    private boolean suspiciousSubDomain(String domain) {
        if (this.whitelistedDomains.contains(domain)) {
            return false;
        }
        final String[] parts = domain.split("\\.");
        return parts.length >= 3 && SUSPICIOUS_DOMAINS.contains(parts[0]);
    }
}

M src/main/java/eu/siacs/conversations/ui/RecordingActivity.java => src/main/java/eu/siacs/conversations/ui/RecordingActivity.java +63 -51
@@ 1,11 1,12 @@
package eu.siacs.conversations.ui;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.media.MediaRecorder;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.FileObserver;
import android.os.Handler;
import android.os.SystemClock;


@@ 17,25 18,22 @@ import android.widget.Toast;
import androidx.databinding.DataBindingUtil;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ActivityRecordingBinding;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.util.SettingsUtils;
import eu.siacs.conversations.utils.ThemeHelper;
import eu.siacs.conversations.utils.TimeFrameUtils;

public class RecordingActivity extends Activity implements View.OnClickListener {

    public static String STORAGE_DIRECTORY_TYPE_NAME = "Recordings";

    private ActivityRecordingBinding binding;

    private MediaRecorder mRecorder;


@@ 44,13 42,14 @@ public class RecordingActivity extends Activity implements View.OnClickListener 
    private final CountDownLatch outputFileWrittenLatch = new CountDownLatch(1);

    private final Handler mHandler = new Handler();
    private final Runnable mTickExecutor = new Runnable() {
        @Override
        public void run() {
            tick();
            mHandler.postDelayed(mTickExecutor, 100);
        }
    };
    private final Runnable mTickExecutor =
            new Runnable() {
                @Override
                public void run() {
                    tick();
                    mHandler.postDelayed(mTickExecutor, 100);
                }
            };

    private File mOutputFile;



@@ 68,7 67,7 @@ public class RecordingActivity extends Activity implements View.OnClickListener 
    }

    @Override
    protected void onResume(){
    protected void onResume() {
        super.onResume();
        SettingsUtils.applyScreenshotPreventionSetting(this);
    }


@@ 137,56 136,69 @@ public class RecordingActivity extends Activity implements View.OnClickListener 
            }
        }
        if (saveFile) {
            new Thread(() -> {
                try {
                    if (!outputFileWrittenLatch.await(2, TimeUnit.SECONDS)) {
                        Log.d(Config.LOGTAG, "time out waiting for output file to be written");
                    }
                } catch (InterruptedException e) {
                    Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
                }
                runOnUiThread(() -> {
                    setResult(Activity.RESULT_OK, new Intent().setData(Uri.fromFile(mOutputFile)));
                    finish();
                });
            }).start();
            new Thread(
                            () -> {
                                try {
                                    if (!outputFileWrittenLatch.await(2, TimeUnit.SECONDS)) {
                                        Log.d(
                                                Config.LOGTAG,
                                                "time out waiting for output file to be written");
                                    }
                                } catch (InterruptedException e) {
                                    Log.d(
                                            Config.LOGTAG,
                                            "interrupted while waiting for output file to be written",
                                            e);
                                }
                                runOnUiThread(
                                        () -> {
                                            setResult(
                                                    Activity.RESULT_OK,
                                                    new Intent()
                                                            .setData(Uri.fromFile(mOutputFile)));
                                            finish();
                                        });
                            })
                    .start();
        }
    }

    private static File generateOutputFilename(Context context) {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
        String filename = "RECORDING_" + dateFormat.format(new Date()) + ".m4a";
        return new File(FileBackend.getConversationsDirectory(context, STORAGE_DIRECTORY_TYPE_NAME) + "/" + filename);
    private File generateOutputFilename() {
        final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
        final String filename = "RECORDING_" + dateFormat.format(new Date()) + ".m4a";
        final File parentDirectory;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
        } else {
            parentDirectory =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
        }
        final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
        return new File(conversationsDirectory, filename);
    }

    private void setupOutputFile() {
        mOutputFile = generateOutputFilename(this);
        File parentDirectory = mOutputFile.getParentFile();
        if (parentDirectory.mkdirs()) {
        mOutputFile = generateOutputFilename();
        final File parentDirectory = mOutputFile.getParentFile();
        if (Objects.requireNonNull(parentDirectory).mkdirs()) {
            Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
        }
        File noMedia = new File(parentDirectory, ".nomedia");
        if (!noMedia.exists()) {
            try {
                if (noMedia.createNewFile()) {
                    Log.d(Config.LOGTAG, "created nomedia file in " + parentDirectory.getAbsolutePath());
                }
            } catch (IOException e) {
                Log.d(Config.LOGTAG, "unable to create nomedia file in " + parentDirectory.getAbsolutePath(), e);
            }
        }
        setupFileObserver(parentDirectory);
    }

    private void setupFileObserver(File directory) {
        mFileObserver = new FileObserver(directory.getAbsolutePath()) {
            @Override
            public void onEvent(int event, String s) {
                if (s != null && s.equals(mOutputFile.getName()) && event == FileObserver.CLOSE_WRITE) {
                    outputFileWrittenLatch.countDown();
                }
            }
        };
        mFileObserver =
                new FileObserver(directory.getAbsolutePath()) {
                    @Override
                    public void onEvent(int event, String s) {
                        if (s != null
                                && s.equals(mOutputFile.getName())
                                && event == FileObserver.CLOSE_WRITE) {
                            outputFileWrittenLatch.countDown();
                        }
                    }
                };
        mFileObserver.startWatching();
    }


M src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java => src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +338 -217
@@ 71,10 71,9 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;

import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
import static java.util.Arrays.asList;

public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate, eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged {
public class RtpSessionActivity extends XmppActivity
        implements XmppConnectionService.OnJingleRtpConnectionUpdate,
                eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged {

    public static final String EXTRA_WITH = "with";
    public static final String EXTRA_SESSION_ID = "session_id";


@@ 86,33 85,31 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe

    private static final int CALL_DURATION_UPDATE_INTERVAL = 333;

    public static final List<RtpEndUserState> END_CARD = Arrays.asList(
            RtpEndUserState.APPLICATION_ERROR,
            RtpEndUserState.SECURITY_ERROR,
            RtpEndUserState.DECLINED_OR_BUSY,
            RtpEndUserState.CONNECTIVITY_ERROR,
            RtpEndUserState.CONNECTIVITY_LOST_ERROR,
            RtpEndUserState.RETRACTED
    );
    private static final List<RtpEndUserState> STATES_SHOWING_HELP_BUTTON = Arrays.asList(
            RtpEndUserState.APPLICATION_ERROR,
            RtpEndUserState.CONNECTIVITY_ERROR,
            RtpEndUserState.SECURITY_ERROR
    );
    private static final List<RtpEndUserState> STATES_SHOWING_SWITCH_TO_CHAT = Arrays.asList(
            RtpEndUserState.CONNECTING,
            RtpEndUserState.CONNECTED,
            RtpEndUserState.RECONNECTING
    );
    private static final List<RtpEndUserState> STATES_CONSIDERED_CONNECTED = Arrays.asList(
            RtpEndUserState.CONNECTED,
            RtpEndUserState.RECONNECTING
    );
    private static final List<RtpEndUserState> STATES_SHOWING_PIP_PLACEHOLDER = Arrays.asList(
            RtpEndUserState.ACCEPTING_CALL,
            RtpEndUserState.CONNECTING,
            RtpEndUserState.RECONNECTING
    );
    public static final List<RtpEndUserState> END_CARD =
            Arrays.asList(
                    RtpEndUserState.APPLICATION_ERROR,
                    RtpEndUserState.SECURITY_ERROR,
                    RtpEndUserState.DECLINED_OR_BUSY,
                    RtpEndUserState.CONNECTIVITY_ERROR,
                    RtpEndUserState.CONNECTIVITY_LOST_ERROR,
                    RtpEndUserState.RETRACTED);
    private static final List<RtpEndUserState> STATES_SHOWING_HELP_BUTTON =
            Arrays.asList(
                    RtpEndUserState.APPLICATION_ERROR,
                    RtpEndUserState.CONNECTIVITY_ERROR,
                    RtpEndUserState.SECURITY_ERROR);
    private static final List<RtpEndUserState> STATES_SHOWING_SWITCH_TO_CHAT =
            Arrays.asList(
                    RtpEndUserState.CONNECTING,
                    RtpEndUserState.CONNECTED,
                    RtpEndUserState.RECONNECTING);
    private static final List<RtpEndUserState> STATES_CONSIDERED_CONNECTED =
            Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING);
    private static final List<RtpEndUserState> STATES_SHOWING_PIP_PLACEHOLDER =
            Arrays.asList(
                    RtpEndUserState.ACCEPTING_CALL,
                    RtpEndUserState.CONNECTING,
                    RtpEndUserState.RECONNECTING);
    private static final String PROXIMITY_WAKE_LOCK_TAG = "conversations:in-rtp-session";
    private static final int REQUEST_ACCEPT_CALL = 0x1111;
    private WeakReference<JingleRtpConnection> rtpConnectionReference;


@@ 121,13 118,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    private PowerManager.WakeLock mProximityWakeLock;

    private final Handler mHandler = new Handler();
    private final Runnable mTickExecutor = new Runnable() {
        @Override
        public void run() {
            updateCallDuration();
            mHandler.postDelayed(mTickExecutor, CALL_DURATION_UPDATE_INTERVAL);
        }
    };
    private final Runnable mTickExecutor =
            new Runnable() {
                @Override
                public void run() {
                    updateCallDuration();
                    mHandler.postDelayed(mTickExecutor, CALL_DURATION_UPDATE_INTERVAL);
                }
            };

    private static Set<Media> actionToMedia(final String action) {
        if (ACTION_MAKE_VIDEO_CALL.equals(action)) {


@@ 137,21 135,27 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        }
    }

    private static void addSink(final VideoTrack videoTrack, final SurfaceViewRenderer surfaceViewRenderer) {
    private static void addSink(
            final VideoTrack videoTrack, final SurfaceViewRenderer surfaceViewRenderer) {
        try {
            videoTrack.addSink(surfaceViewRenderer);
        } catch (final IllegalStateException e) {
            Log.e(Config.LOGTAG, "possible race condition on trying to display video track. ignoring", e);
            Log.e(
                    Config.LOGTAG,
                    "possible race condition on trying to display video track. ignoring",
                    e);
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
                | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
                | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
        getWindow()
                .addFlags(
                        WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                                | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
                                | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
                                | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_rtp_session);
        setSupportActionBar(binding.toolbar);



@@ 194,7 198,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            return STATES_SHOWING_HELP_BUTTON.contains(requireRtpConnection().getEndUserState());
        } catch (IllegalStateException e) {
            final Intent intent = getIntent();
            final String state = intent != null ? intent.getStringExtra(EXTRA_LAST_REPORTED_STATE) : null;
            final String state =
                    intent != null ? intent.getStringExtra(EXTRA_LAST_REPORTED_STATE) : null;
            if (state != null) {
                return STATES_SHOWING_HELP_BUTTON.contains(RtpEndUserState.valueOf(state));
            } else {


@@ 204,8 209,10 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private boolean isSwitchToConversationVisible() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        return connection != null && STATES_SHOWING_SWITCH_TO_CHAT.contains(connection.getEndUserState());
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        return connection != null
                && STATES_SHOWING_SWITCH_TO_CHAT.contains(connection.getEndUserState());
    }

    private boolean isAudioOnlyConversation() {


@@ 217,7 224,9 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe

    private void switchToConversation() {
        final Contact contact = getWith();
        final Conversation conversation = xmppConnectionService.findOrCreateConversation(contact.getAccount(), contact.getJid(), false, true);
        final Conversation conversation =
                xmppConnectionService.findOrCreateConversation(
                        contact.getAccount(), contact.getJid(), false, true);
        switchToConversation(conversation);
    }



@@ 250,7 259,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        try {
            startActivity(intent);
        } catch (final ActivityNotFoundException e) {
            Toast.makeText(this, R.string.no_application_found_to_open_link, Toast.LENGTH_LONG).show();
            Toast.makeText(this, R.string.no_application_found_to_open_link, Toast.LENGTH_LONG)
                    .show();
        }
    }



@@ 273,10 283,15 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        final Account account = extractAccount(intent);
        final Jid with = Jid.ofEscaped(intent.getStringExtra(EXTRA_WITH));
        final String state = intent.getStringExtra(EXTRA_LAST_REPORTED_STATE);
        if (!Intent.ACTION_VIEW.equals(action) || state == null || !END_CARD.contains(RtpEndUserState.valueOf(state))) {
            resetIntent(account, with, RtpEndUserState.RETRACTED, actionToMedia(intent.getAction()));
        if (!Intent.ACTION_VIEW.equals(action)
                || state == null
                || !END_CARD.contains(RtpEndUserState.valueOf(state))) {
            resetIntent(
                    account, with, RtpEndUserState.RETRACTED, actionToMedia(intent.getAction()));
        }
        xmppConnectionService.getJingleConnectionManager().retractSessionProposal(account, with.asBareJid());
        xmppConnectionService
                .getJingleConnectionManager()
                .retractSessionProposal(account, with.asBareJid());
    }

    private void rejectCall(View view) {


@@ 291,7 306,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    private void requestPermissionsAndAcceptCall() {
        final List<String> permissions;
        if (getMedia().contains(Media.VIDEO)) {
            permissions = ImmutableList.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO);
            permissions =
                    ImmutableList.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO);
        } else {
            permissions = ImmutableList.of(Manifest.permission.RECORD_AUDIO);
        }


@@ 302,7 318,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private void checkRecorderAndAcceptCall() {
        checkMicrophoneAvailability();
        checkMicrophoneAvailabilityAsync();
        try {
            requireRtpConnection().acceptCall();
        } catch (final IllegalStateException e) {


@@ 310,18 326,22 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        }
    }

    private void checkMicrophoneAvailabilityAsync() {
        new Thread(this::checkMicrophoneAvailability).start();
    }

    private void checkMicrophoneAvailability() {
        new Thread(() -> {
            final long start = SystemClock.elapsedRealtime();
            final boolean isMicrophoneAvailable = AppRTCAudioManager.isMicrophoneAvailable();
            final long stop = SystemClock.elapsedRealtime();
            Log.d(Config.LOGTAG, "checking microphone availability took " + (stop - start) + "ms");
            if (isMicrophoneAvailable) {
                return;
            }
            runOnUiThread(() -> Toast.makeText(this, R.string.microphone_unavailable, Toast.LENGTH_LONG).show());
        final long start = SystemClock.elapsedRealtime();
        final boolean isMicrophoneAvailable = AppRTCAudioManager.isMicrophoneAvailable();
        final long stop = SystemClock.elapsedRealtime();
        Log.d(Config.LOGTAG, "checking microphone availability took " + (stop - start) + "ms");
        if (isMicrophoneAvailable) {
            return;
        }
        ).start();
        runOnUiThread(
                () ->
                        Toast.makeText(this, R.string.microphone_unavailable, Toast.LENGTH_LONG)
                                .show());
    }

    private void putScreenInCallMode() {


@@ 331,9 351,13 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    private void putScreenInCallMode(final Set<Media> media) {
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        if (!media.contains(Media.VIDEO)) {
            final JingleRtpConnection rtpConnection = rtpConnectionReference != null ? rtpConnectionReference.get() : null;
            final AppRTCAudioManager audioManager = rtpConnection == null ? null : rtpConnection.getAudioManager();
            if (audioManager == null || audioManager.getSelectedAudioDevice() == AppRTCAudioManager.AudioDevice.EARPIECE) {
            final JingleRtpConnection rtpConnection =
                    rtpConnectionReference != null ? rtpConnectionReference.get() : null;
            final AppRTCAudioManager audioManager =
                    rtpConnection == null ? null : rtpConnection.getAudioManager();
            if (audioManager == null
                    || audioManager.getSelectedAudioDevice()
                            == AppRTCAudioManager.AudioDevice.EARPIECE) {
                acquireProximityWakeLock();
            }
        }


@@ 346,30 370,31 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            Log.e(Config.LOGTAG, "power manager not available");
            return;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            if (this.mProximityWakeLock == null) {
                this.mProximityWakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, PROXIMITY_WAKE_LOCK_TAG);
            }
            if (!this.mProximityWakeLock.isHeld()) {
                Log.d(Config.LOGTAG, "acquiring proximity wake lock");
                this.mProximityWakeLock.acquire();
            }
        if (isFinishing()) {
            Log.e(Config.LOGTAG, "do not acquire wakelock. activity is finishing");
            return;
        }
        if (this.mProximityWakeLock == null) {
            this.mProximityWakeLock =
                    powerManager.newWakeLock(
                            PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, PROXIMITY_WAKE_LOCK_TAG);
        }
        if (!this.mProximityWakeLock.isHeld()) {
            Log.d(Config.LOGTAG, "acquiring proximity wake lock");
            this.mProximityWakeLock.acquire();
        }
    }

    private void releaseProximityWakeLock() {
        if (this.mProximityWakeLock != null && mProximityWakeLock.isHeld()) {
            Log.d(Config.LOGTAG, "releasing proximity wake lock");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                this.mProximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY);
            } else {
                this.mProximityWakeLock.release();
            }
            this.mProximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY);
            this.mProximityWakeLock = null;
        }
    }

    private void putProximityWakeLockInProperState(final AppRTCAudioManager.AudioDevice audioDevice) {
    private void putProximityWakeLockInProperState(
            final AppRTCAudioManager.AudioDevice audioDevice) {
        if (audioDevice == AppRTCAudioManager.AudioDevice.EARPIECE) {
            acquireProximityWakeLock();
        } else {


@@ 378,9 403,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    @Override
    protected void refreshUiReal() {

    }
    protected void refreshUiReal() {}

    @Override
    public void onNewIntent(final Intent intent) {


@@ 388,7 411,9 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        super.onNewIntent(intent);
        setIntent(intent);
        if (xmppConnectionService == null) {
            Log.d(Config.LOGTAG, "RtpSessionActivity: background service wasn't bound in onNewIntent()");
            Log.d(
                    Config.LOGTAG,
                    "RtpSessionActivity: background service wasn't bound in onNewIntent()");
            return;
        }
        final Account account = extractAccount(intent);


@@ 407,8 432,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            }
        } else if (asList(ACTION_MAKE_VIDEO_CALL, ACTION_MAKE_VOICE_CALL).contains(action)) {
            proposeJingleRtpSession(account, with, actionToMedia(action));
            binding.with.setText(account.getRoster().getContact(with).getDisplayName());
            binding.withJid.setText(with.asBareJid());
            setWith(account.getRoster().getContact(with));
        } else {
            throw new IllegalStateException("received onNewIntent without sessionId");
        }


@@ 432,25 456,29 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            }
        } else if (asList(ACTION_MAKE_VIDEO_CALL, ACTION_MAKE_VOICE_CALL).contains(action)) {
            proposeJingleRtpSession(account, with, actionToMedia(action));
            binding.with.setText(account.getRoster().getContact(with).getDisplayName());
            binding.withJid.setText(with.asBareJid());
            setWith(account.getRoster().getContact(with));
        } else if (Intent.ACTION_VIEW.equals(action)) {
            final String extraLastState = intent.getStringExtra(EXTRA_LAST_REPORTED_STATE);
            final RtpEndUserState state = extraLastState == null ? null : RtpEndUserState.valueOf(extraLastState);
            final RtpEndUserState state =
                    extraLastState == null ? null : RtpEndUserState.valueOf(extraLastState);
            if (state != null) {
                Log.d(Config.LOGTAG, "restored last state from intent extra");
                updateButtonConfiguration(state);
                updateVerifiedShield(false);
                updateStateDisplay(state);
                updateProfilePicture(state);
                updateIncomingCallScreen(state);
                invalidateOptionsMenu();
            }
            binding.with.setText(account.getRoster().getContact(with).getDisplayName());
            binding.withJid.setText(with.asBareJid());
            if (xmppConnectionService.getJingleConnectionManager().fireJingleRtpConnectionStateUpdates()) {
            setWith(account.getRoster().getContact(with));
            if (xmppConnectionService
                    .getJingleConnectionManager()
                    .fireJingleRtpConnectionStateUpdates()) {
                return;
            }
            if (END_CARD.contains(state) || xmppConnectionService.getJingleConnectionManager().hasMatchingProposal(account, with)) {
            if (END_CARD.contains(state)
                    || xmppConnectionService
                            .getJingleConnectionManager()
                            .hasMatchingProposal(account, with)) {
                return;
            }
            Log.d(Config.LOGTAG, "restored state (" + state + ") was not an end card. finishing");


@@ 458,12 486,27 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        }
    }

    private void proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
        checkMicrophoneAvailability();
    private void setWith() {
        setWith(getWith());
    }

    private void setWith(final Contact contact) {
        binding.with.setText(contact.getDisplayName());
        binding.withJid.setText(contact.getJid().asBareJid().toEscapedString());
    }

    private void proposeJingleRtpSession(
            final Account account, final Jid with, final Set<Media> media) {
        checkMicrophoneAvailabilityAsync();
        if (with.isBareJid()) {
            xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(account, with, media);
            xmppConnectionService
                    .getJingleConnectionManager()
                    .proposeJingleRtpSession(account, with, media);
        } else {
            final String sessionId = xmppConnectionService.getJingleConnectionManager().initializeRtpSession(account, with, media);
            final String sessionId =
                    xmppConnectionService
                            .getJingleConnectionManager()
                            .initializeRtpSession(account, with, media);
            initializeActivityWithRunningRtpSession(account, with, sessionId);
            resetIntent(account, with, sessionId);
        }


@@ 471,7 514,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    public void onRequestPermissionsResult(
            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (PermissionUtils.allGranted(grantResults)) {
            if (requestCode == REQUEST_ACCEPT_CALL) {


@@ 487,7 531,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            } else {
                throw new IllegalStateException("Invalid permission result request");
            }
            Toast.makeText(this, getString(res, getString(R.string.app_name)), Toast.LENGTH_SHORT).show();
            Toast.makeText(this, getString(res, getString(R.string.app_name)), Toast.LENGTH_SHORT)
                    .show();
        }
    }



@@ 505,7 550,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        binding.remoteVideo.setOnAspectRatioChanged(null);
        binding.localVideo.release();
        final WeakReference<JingleRtpConnection> weakReference = this.rtpConnectionReference;
        final JingleRtpConnection jingleRtpConnection = weakReference == null ? null : weakReference.get();
        final JingleRtpConnection jingleRtpConnection =
                weakReference == null ? null : weakReference.get();
        if (jingleRtpConnection != null) {
            releaseVideoTracks(jingleRtpConnection);
        }


@@ 542,15 588,18 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        if (switchToPictureInPicture()) {
            return;
        }
        //TODO apparently this method is not getting called on Android 10 when using the task switcher
        // TODO apparently this method is not getting called on Android 10 when using the task
        // switcher
        if (emptyReference(rtpConnectionReference) && xmppConnectionService != null) {
            retractSessionProposal();
        }
    }

    private boolean isConnected() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        return connection != null && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        return connection != null
                && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
    }

    private boolean switchToPictureInPicture() {


@@ 568,14 617,13 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        try {
            final Rational rational = this.binding.remoteVideo.getAspectRatio();
            final Rational clippedRational = Rationals.clip(rational);
            Log.d(Config.LOGTAG, "suggested rational " + rational + ". clipped to " + clippedRational);
            Log.d(
                    Config.LOGTAG,
                    "suggested rational " + rational + ". clipped to " + clippedRational);
            enterPictureInPictureMode(
                    new PictureInPictureParams.Builder()
                            .setAspectRatio(clippedRational)
                            .build()
            );
                    new PictureInPictureParams.Builder().setAspectRatio(clippedRational).build());
        } catch (final IllegalStateException e) {
            //this sometimes happens on Samsung phones (possibly when Knox is enabled)
            // this sometimes happens on Samsung phones (possibly when Knox is enabled)
            Log.w(Config.LOGTAG, "unable to enter picture in picture mode", e);
        }
    }


@@ 584,10 632,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    public void onAspectRatioChanged(final Rational rational) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isPictureInPicture()) {
            final Rational clippedRational = Rationals.clip(rational);
            Log.d(Config.LOGTAG, "suggested rational after aspect ratio change " + rational + ". clipped to " + clippedRational);
            setPictureInPictureParams(new PictureInPictureParams.Builder()
                    .setAspectRatio(clippedRational)
                    .build());
            Log.d(
                    Config.LOGTAG,
                    "suggested rational after aspect ratio change "
                            + rational
                            + ". clipped to "
                            + clippedRational);
            setPictureInPictureParams(
                    new PictureInPictureParams.Builder().setAspectRatio(clippedRational).build());
        }
    }



@@ 602,24 654,31 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    private boolean shouldBePictureInPicture() {
        try {
            final JingleRtpConnection rtpConnection = requireRtpConnection();
            return rtpConnection.getMedia().contains(Media.VIDEO) && Arrays.asList(
                    RtpEndUserState.ACCEPTING_CALL,
                    RtpEndUserState.CONNECTING,
                    RtpEndUserState.CONNECTED
            ).contains(rtpConnection.getEndUserState());
            return rtpConnection.getMedia().contains(Media.VIDEO)
                    && Arrays.asList(
                                    RtpEndUserState.ACCEPTING_CALL,
                                    RtpEndUserState.CONNECTING,
                                    RtpEndUserState.CONNECTED)
                            .contains(rtpConnection.getEndUserState());
        } catch (final IllegalStateException e) {
            return false;
        }
    }

    private boolean initializeActivityWithRunningRtpSession(final Account account, Jid with, String sessionId) {
        final WeakReference<JingleRtpConnection> reference = xmppConnectionService.getJingleConnectionManager()
                .findJingleRtpConnection(account, with, sessionId);
    private boolean initializeActivityWithRunningRtpSession(
            final Account account, Jid with, String sessionId) {
        final WeakReference<JingleRtpConnection> reference =
                xmppConnectionService
                        .getJingleConnectionManager()
                        .findJingleRtpConnection(account, with, sessionId);
        if (reference == null || reference.get() == null) {
            final JingleConnectionManager.TerminatedRtpSession terminatedRtpSession = xmppConnectionService
                    .getJingleConnectionManager().getTerminalSessionState(with, sessionId);
            final JingleConnectionManager.TerminatedRtpSession terminatedRtpSession =
                    xmppConnectionService
                            .getJingleConnectionManager()
                            .getTerminalSessionState(with, sessionId);
            if (terminatedRtpSession == null) {
                throw new IllegalStateException("failed to initialize activity with running rtp session. session not found");
                throw new IllegalStateException(
                        "failed to initialize activity with running rtp session. session not found");
            }
            initializeWithTerminatedSessionState(account, with, terminatedRtpSession);
            return true;


@@ 628,7 687,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        final RtpEndUserState currentState = requireRtpConnection().getEndUserState();
        final boolean verified = requireRtpConnection().isVerified();
        if (currentState == RtpEndUserState.ENDED) {
            reference.get().throwStateTransitionException();
            finish();
            return true;
        }


@@ 636,21 694,24 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        if (currentState == RtpEndUserState.INCOMING_CALL) {
            getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        }
        if (JingleRtpConnection.STATES_SHOWING_ONGOING_CALL.contains(requireRtpConnection().getState())) {
        if (JingleRtpConnection.STATES_SHOWING_ONGOING_CALL.contains(
                requireRtpConnection().getState())) {
            putScreenInCallMode();
        }
        binding.with.setText(getWith().getDisplayName());
        binding.withJid.setText(with.asBareJid());
        setWith();
        updateVideoViews(currentState);
        updateStateDisplay(currentState, media);
        updateVerifiedShield(verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(currentState));
        updateButtonConfiguration(currentState, media);
        updateProfilePicture(currentState);
        updateIncomingCallScreen(currentState);
        invalidateOptionsMenu();
        return false;
    }

    private void initializeWithTerminatedSessionState(final Account account, final Jid with, final JingleConnectionManager.TerminatedRtpSession terminatedRtpSession) {
    private void initializeWithTerminatedSessionState(
            final Account account,
            final Jid with,
            final JingleConnectionManager.TerminatedRtpSession terminatedRtpSession) {
        Log.d(Config.LOGTAG, "initializeWithTerminatedSessionState()");
        if (terminatedRtpSession.state == RtpEndUserState.ENDED) {
            finish();


@@ 660,15 721,15 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        resetIntent(account, with, terminatedRtpSession.state, terminatedRtpSession.media);
        updateButtonConfiguration(state);
        updateStateDisplay(state);
        updateProfilePicture(state);
        updateIncomingCallScreen(state);
        updateCallDuration();
        updateVerifiedShield(false);
        invalidateOptionsMenu();
        binding.with.setText(account.getRoster().getContact(with).getDisplayName());
        binding.withJid.setText(with.asBareJid());
        setWith(account.getRoster().getContact(with));
    }

    private void reInitializeActivityWithRunningRtpSession(final Account account, Jid with, String sessionId) {
    private void reInitializeActivityWithRunningRtpSession(
            final Account account, Jid with, String sessionId) {
        runOnUiThread(() -> initializeActivityWithRunningRtpSession(account, with, sessionId));
        resetIntent(account, with, sessionId);
    }


@@ 686,7 747,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        try {
            surfaceViewRenderer.init(requireRtpConnection().getEglBaseContext(), null);
        } catch (final IllegalStateException e) {
            //Log.d(Config.LOGTAG, "SurfaceViewRenderer was already initialized");
            // Log.d(Config.LOGTAG, "SurfaceViewRenderer was already initialized");
        }
        surfaceViewRenderer.setEnableHardwareScaler(true);
    }


@@ 745,9 806,11 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
                setTitle(R.string.rtp_state_security_error);
                break;
            case ENDED:
                throw new IllegalStateException("Activity should have called finishAndReleaseWakeLock();");
                throw new IllegalStateException(
                        "Activity should have called finishAndReleaseWakeLock();");
            default:
                throw new IllegalStateException(String.format("State %s has not been handled in UI", state));
                throw new IllegalStateException(
                        String.format("State %s has not been handled in UI", state));
        }
    }



@@ 759,24 822,33 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        this.binding.verified.setVisibility(verified ? View.VISIBLE : View.GONE);
    }

    private void updateProfilePicture(final RtpEndUserState state) {
        updateProfilePicture(state, null);
    private void updateIncomingCallScreen(final RtpEndUserState state) {
        updateIncomingCallScreen(state, null);
    }

    private void updateProfilePicture(final RtpEndUserState state, final Contact contact) {
    private void updateIncomingCallScreen(final RtpEndUserState state, final Contact contact) {
        if (state == RtpEndUserState.INCOMING_CALL || state == RtpEndUserState.ACCEPTING_CALL) {
            final boolean show = getResources().getBoolean(R.bool.show_avatar_incoming_call);
            if (show) {
                binding.contactPhoto.setVisibility(View.VISIBLE);
                if (contact == null) {
                    AvatarWorkerTask.loadAvatar(getWith(), binding.contactPhoto, R.dimen.publish_avatar_size);
                    AvatarWorkerTask.loadAvatar(
                            getWith(), binding.contactPhoto, R.dimen.publish_avatar_size);
                } else {
                    AvatarWorkerTask.loadAvatar(contact, binding.contactPhoto, R.dimen.publish_avatar_size);
                    AvatarWorkerTask.loadAvatar(
                            contact, binding.contactPhoto, R.dimen.publish_avatar_size);
                }
            } else {
                binding.contactPhoto.setVisibility(View.GONE);
            }
            final Account account = contact == null ? getWith().getAccount() : contact.getAccount();
            binding.usingAccount.setVisibility(View.VISIBLE);
            binding.usingAccount.setText(
                    getString(
                            R.string.using_account,
                            account.getJid().asBareJid().toEscapedString()));
        } else {
            binding.usingAccount.setVisibility(View.GONE);
            binding.contactPhoto.setVisibility(View.GONE);
        }
    }


@@ 816,12 888,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            this.binding.acceptCall.setImageResource(R.drawable.ic_voicemail_white_24dp);
            this.binding.acceptCall.setVisibility(View.VISIBLE);
        } else if (asList(
                RtpEndUserState.CONNECTIVITY_ERROR,
                RtpEndUserState.CONNECTIVITY_LOST_ERROR,
                RtpEndUserState.APPLICATION_ERROR,
                RtpEndUserState.RETRACTED,
                RtpEndUserState.SECURITY_ERROR
        ).contains(state)) {
                        RtpEndUserState.CONNECTIVITY_ERROR,
                        RtpEndUserState.CONNECTIVITY_LOST_ERROR,
                        RtpEndUserState.APPLICATION_ERROR,
                        RtpEndUserState.RETRACTED,
                        RtpEndUserState.SECURITY_ERROR)
                .contains(state)) {
            this.binding.rejectCall.setContentDescription(getString(R.string.exit));
            this.binding.rejectCall.setOnClickListener(this::exit);
            this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp);


@@ 851,26 923,29 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private void updateInCallButtonConfiguration() {
        updateInCallButtonConfiguration(requireRtpConnection().getEndUserState(), requireRtpConnection().getMedia());
        updateInCallButtonConfiguration(
                requireRtpConnection().getEndUserState(), requireRtpConnection().getMedia());
    }

    @SuppressLint("RestrictedApi")
    private void updateInCallButtonConfiguration(final RtpEndUserState state, final Set<Media> media) {
    private void updateInCallButtonConfiguration(
            final RtpEndUserState state, final Set<Media> media) {
        if (STATES_CONSIDERED_CONNECTED.contains(state) && !isPictureInPicture()) {
            Preconditions.checkArgument(media.size() > 0, "Media must not be empty");
            if (media.contains(Media.VIDEO)) {
                final JingleRtpConnection rtpConnection = requireRtpConnection();
                updateInCallButtonConfigurationVideo(rtpConnection.isVideoEnabled(), rtpConnection.isCameraSwitchable());
                updateInCallButtonConfigurationVideo(
                        rtpConnection.isVideoEnabled(), rtpConnection.isCameraSwitchable());
            } else {
                final AppRTCAudioManager audioManager = requireRtpConnection().getAudioManager();
                updateInCallButtonConfigurationSpeaker(
                        audioManager.getSelectedAudioDevice(),
                        audioManager.getAudioDevices().size()
                );
                        audioManager.getAudioDevices().size());
                this.binding.inCallActionFarRight.setVisibility(View.GONE);
            }
            if (media.contains(Media.AUDIO)) {
                updateInCallButtonConfigurationMicrophone(requireRtpConnection().isMicrophoneEnabled());
                updateInCallButtonConfigurationMicrophone(
                        requireRtpConnection().isMicrophoneEnabled());
            } else {
                this.binding.inCallActionLeft.setVisibility(View.GONE);
            }


@@ 882,10 957,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    @SuppressLint("RestrictedApi")
    private void updateInCallButtonConfigurationSpeaker(final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) {
    private void updateInCallButtonConfigurationSpeaker(
            final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) {
        switch (selectedAudioDevice) {
            case EARPIECE:
                this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_off_black_24dp);
                this.binding.inCallActionRight.setImageResource(
                        R.drawable.ic_volume_off_black_24dp);
                if (numberOfChoices >= 2) {
                    this.binding.inCallActionRight.setOnClickListener(this::switchToSpeaker);
                } else {


@@ 908,7 985,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
                }
                break;
            case BLUETOOTH:
                this.binding.inCallActionRight.setImageResource(R.drawable.ic_bluetooth_audio_black_24dp);
                this.binding.inCallActionRight.setImageResource(
                        R.drawable.ic_bluetooth_audio_black_24dp);
                this.binding.inCallActionRight.setOnClickListener(null);
                this.binding.inCallActionRight.setClickable(false);
                break;


@@ 917,10 995,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    @SuppressLint("RestrictedApi")
    private void updateInCallButtonConfigurationVideo(final boolean videoEnabled, final boolean isCameraSwitchable) {
    private void updateInCallButtonConfigurationVideo(
            final boolean videoEnabled, final boolean isCameraSwitchable) {
        this.binding.inCallActionRight.setVisibility(View.VISIBLE);
        if (isCameraSwitchable) {
            this.binding.inCallActionFarRight.setImageResource(R.drawable.ic_flip_camera_android_black_24dp);
            this.binding.inCallActionFarRight.setImageResource(
                    R.drawable.ic_flip_camera_android_black_24dp);
            this.binding.inCallActionFarRight.setVisibility(View.VISIBLE);
            this.binding.inCallActionFarRight.setOnClickListener(this::switchCamera);
        } else {


@@ 936,18 1016,28 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private void switchCamera(final View view) {
        Futures.addCallback(requireRtpConnection().switchCamera(), new FutureCallback<Boolean>() {
            @Override
            public void onSuccess(@NullableDecl Boolean isFrontCamera) {
                binding.localVideo.setMirror(isFrontCamera);
            }

            @Override
            public void onFailure(@NonNull final Throwable throwable) {
                Log.d(Config.LOGTAG, "could not switch camera", Throwables.getRootCause(throwable));
                Toast.makeText(RtpSessionActivity.this, R.string.could_not_switch_camera, Toast.LENGTH_LONG).show();
            }
        }, MainThreadExecutor.getInstance());
        Futures.addCallback(
                requireRtpConnection().switchCamera(),
                new FutureCallback<Boolean>() {
                    @Override
                    public void onSuccess(@NullableDecl Boolean isFrontCamera) {
                        binding.localVideo.setMirror(isFrontCamera);
                    }

                    @Override
                    public void onFailure(@NonNull final Throwable throwable) {
                        Log.d(
                                Config.LOGTAG,
                                "could not switch camera",
                                Throwables.getRootCause(throwable));
                        Toast.makeText(
                                        RtpSessionActivity.this,
                                        R.string.could_not_switch_camera,
                                        Toast.LENGTH_LONG)
                                .show();
                    }
                },
                MainThreadExecutor.getInstance());
    }

    private void enableVideo(View view) {


@@ 963,7 1053,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    private void disableVideo(View view) {
        requireRtpConnection().setVideoEnabled(false);
        updateInCallButtonConfigurationVideo(false, requireRtpConnection().isCameraSwitchable());

    }

    @SuppressLint("RestrictedApi")


@@ 979,7 1068,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private void updateCallDuration() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        if (connection == null || connection.getMedia().contains(Media.VIDEO)) {
            this.binding.duration.setVisibility(View.GONE);
            return;


@@ 987,7 1077,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        if (connection.zeroDuration()) {
            this.binding.duration.setVisibility(View.GONE);
        } else {
            this.binding.duration.setText(TimeFrameUtils.formatElapsedTime(connection.getCallDuration(), false));
            this.binding.duration.setText(
                    TimeFrameUtils.formatElapsedTime(connection.getCallDuration(), false));
            this.binding.duration.setVisibility(View.VISIBLE);
        }
    }


@@ 1003,9 1094,9 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
                binding.appBarLayout.setVisibility(View.GONE);
                binding.pipPlaceholder.setVisibility(View.VISIBLE);
                if (Arrays.asList(
                        RtpEndUserState.APPLICATION_ERROR,
                        RtpEndUserState.CONNECTIVITY_ERROR,
                        RtpEndUserState.SECURITY_ERROR)
                                RtpEndUserState.APPLICATION_ERROR,
                                RtpEndUserState.CONNECTIVITY_ERROR,
                                RtpEndUserState.SECURITY_ERROR)
                        .contains(state)) {
                    binding.pipWarning.setVisibility(View.VISIBLE);
                    binding.pipWaiting.setVisibility(View.GONE);


@@ 1033,7 1124,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        final Optional<VideoTrack> localVideoTrack = getLocalVideoTrack();
        if (localVideoTrack.isPresent() && !isPictureInPicture()) {
            ensureSurfaceViewRendererIsSetup(binding.localVideo);
            //paint local view over remote view
            // paint local view over remote view
            binding.localVideo.setZOrderMediaOverlay(true);
            binding.localVideo.setMirror(requireRtpConnection().isFrontCamera());
            addSink(localVideoTrack.get(), binding.localVideo);


@@ 1046,8 1137,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            addSink(remoteVideoTrack.get(), binding.remoteVideo);
            binding.remoteVideo.setScalingType(
                    RendererCommon.ScalingType.SCALE_ASPECT_FILL,
                    RendererCommon.ScalingType.SCALE_ASPECT_FIT
            );
                    RendererCommon.ScalingType.SCALE_ASPECT_FIT);
            if (state == RtpEndUserState.CONNECTED) {
                binding.appBarLayout.setVisibility(View.GONE);
                getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);


@@ 1070,7 1160,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private Optional<VideoTrack> getLocalVideoTrack() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        if (connection == null) {
            return Optional.absent();
        }


@@ 1078,7 1169,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private Optional<VideoTrack> getRemoteVideoTrack() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        if (connection == null) {
            return Optional.absent();
        }


@@ 1100,12 1192,16 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private void switchToEarpiece(View view) {
        requireRtpConnection().getAudioManager().setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.EARPIECE);
        requireRtpConnection()
                .getAudioManager()
                .setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.EARPIECE);
        acquireProximityWakeLock();
    }

    private void switchToSpeaker(View view) {
        requireRtpConnection().getAudioManager().setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
        requireRtpConnection()
                .getAudioManager()
                .setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
        releaseProximityWakeLock();
    }



@@ 1129,12 1225,15 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        final Intent intent = getIntent();
        final Account account = extractAccount(intent);
        final Jid with = Jid.ofEscaped(intent.getStringExtra(EXTRA_WITH));
        final Conversation conversation = xmppConnectionService.findOrCreateConversation(account, with, false, true);
        final Conversation conversation =
                xmppConnectionService.findOrCreateConversation(account, with, false, true);
        final Intent launchIntent = new Intent(this, ConversationsActivity.class);
        launchIntent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
        launchIntent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, conversation.getUuid());
        launchIntent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        launchIntent.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, ConversationsActivity.POST_ACTION_RECORD_VOICE);
        launchIntent.putExtra(
                ConversationsActivity.EXTRA_POST_INIT_ACTION,
                ConversationsActivity.POST_ACTION_RECORD_VOICE);
        startActivity(launchIntent);
        finish();
    }


@@ 1146,7 1245,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    private JingleRtpConnection requireRtpConnection() {
        final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        final JingleRtpConnection connection =
                this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
        if (connection == null) {
            throw new IllegalStateException("No RTP connection found");
        }


@@ 1154,12 1254,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    }

    @Override
    public void onJingleRtpConnectionUpdate(Account account, Jid with, final String sessionId, RtpEndUserState state) {
    public void onJingleRtpConnectionUpdate(
            Account account, Jid with, final String sessionId, RtpEndUserState state) {
        Log.d(Config.LOGTAG, "onJingleRtpConnectionUpdate(" + state + ")");
        if (END_CARD.contains(state)) {
            Log.d(Config.LOGTAG, "end card reached");
            releaseProximityWakeLock();
            runOnUiThread(() -> getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));
            runOnUiThread(
                    () -> getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));
        }
        if (with.isBareJid()) {
            updateRtpSessionProposalState(account, with, state);


@@ 1170,7 1272,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
                Log.d(Config.LOGTAG, "not reinitializing session");
                return;
            }
            //this happens when going from proposed session to actual session
            // this happens when going from proposed session to actual session
            reInitializeActivityWithRunningRtpSession(account, with, sessionId);
            return;
        }


@@ 1183,14 1285,16 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
                finish();
                return;
            }
            runOnUiThread(() -> {
                updateStateDisplay(state, media);
                updateVerifiedShield(verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(state));
                updateButtonConfiguration(state, media);
                updateVideoViews(state);
                updateProfilePicture(state, contact);
                invalidateOptionsMenu();
            });
            runOnUiThread(
                    () -> {
                        updateStateDisplay(state, media);
                        updateVerifiedShield(
                                verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(state));
                        updateButtonConfiguration(state, media);
                        updateVideoViews(