浏览代码

Version 5.0.4

Threema 2 年之前
父节点
当前提交
666ac380f1
共有 100 个文件被更改,包括 1756 次插入927 次删除
  1. 2 2
      app/assets/license.html
  2. 45 8
      app/build.gradle
  3. 2 2
      app/src/foss_based/assets/license.html
  4. 14 5
      app/src/main/AndroidManifest.xml
  5. 7 4
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  6. 4 0
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  7. 18 1
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  8. 30 43
      app/src/main/java/ch/threema/app/activities/PinLockActivity.java
  9. 74 29
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  10. 19 0
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  11. 11 2
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java
  12. 1 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  13. 5 1
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  14. 0 9
      app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java
  15. 15 8
      app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt
  16. 2 8
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  17. 1 6
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  18. 1 19
      app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java
  19. 21 0
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  20. 1 21
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  21. 1 0
      app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt
  22. 1 21
      app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java
  23. 1 21
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  24. 36 0
      app/src/main/java/ch/threema/app/backuprestore/RandomUtil.kt
  25. 31 11
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  26. 218 217
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  27. 10 5
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  28. 3 0
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  29. 6 4
      app/src/main/java/ch/threema/app/camera/CameraActivity.java
  30. 25 34
      app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java
  31. 25 1
      app/src/main/java/ch/threema/app/dialogs/TextWithCheckboxDialog.java
  32. 4 1
      app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java
  33. 80 65
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  34. 85 51
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  35. 1 1
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  36. 5 1
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  37. 5 0
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  38. 1 1
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  39. 1 1
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  40. 5 4
      app/src/main/java/ch/threema/app/notifications/BackgroundErrorNotification.java
  41. 9 1
      app/src/main/java/ch/threema/app/notifications/NotificationBuilderWrapper.java
  42. 7 3
      app/src/main/java/ch/threema/app/preference/SettingsTroubleshootingFragment.java
  43. 0 3
      app/src/main/java/ch/threema/app/processors/MessageProcessor.java
  44. 3 1
      app/src/main/java/ch/threema/app/routines/UpdateWorkInfoRoutine.java
  45. 27 2
      app/src/main/java/ch/threema/app/services/AppRestrictionService.java
  46. 31 6
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  47. 67 23
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  48. 1 1
      app/src/main/java/ch/threema/app/services/NotificationService.java
  49. 2 2
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  50. 5 1
      app/src/main/java/ch/threema/app/services/PreferenceService.java
  51. 11 0
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  52. 37 0
      app/src/main/java/ch/threema/app/stores/DatabaseContactStore.java
  53. 1 1
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  54. 1 1
      app/src/main/java/ch/threema/app/ui/MediaItem.java
  55. 1 6
      app/src/main/java/ch/threema/app/ui/NewWizardFingerPrintView.java
  56. 2 0
      app/src/main/java/ch/threema/app/utils/BackupUtils.java
  57. 21 30
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  58. 73 2
      app/src/main/java/ch/threema/app/utils/ForwardSecurityStatusSender.java
  59. 2 2
      app/src/main/java/ch/threema/app/utils/GroupCallUtil.kt
  60. 8 0
      app/src/main/java/ch/threema/app/utils/MessageUtil.java
  61. 2 1
      app/src/main/java/ch/threema/app/utils/MimeUtil.java
  62. 14 3
      app/src/main/java/ch/threema/app/utils/NameUtil.java
  63. 13 10
      app/src/main/java/ch/threema/app/utils/ShortcutUtil.java
  64. 16 1
      app/src/main/java/ch/threema/app/utils/SoundUtil.java
  65. 56 26
      app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java
  66. 2 3
      app/src/main/java/ch/threema/app/voip/VoipBluetoothManager.java
  67. 16 15
      app/src/main/java/ch/threema/app/voip/activities/CallActivity.java
  68. 34 8
      app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt
  69. 3 3
      app/src/main/java/ch/threema/app/voip/activities/WebRTCDebugActivity.java
  70. 39 0
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallIntention.kt
  71. 29 5
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManager.kt
  72. 184 75
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt
  73. 1 1
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallObserver.kt
  74. 9 2
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallThreadUtil.kt
  75. 28 1
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt
  76. 1 3
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt
  77. 10 0
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCall.kt
  78. 10 0
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCallController.kt
  79. 6 2
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/SfuConnectionImpl.kt
  80. 54 11
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Connected.kt
  81. 17 0
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/messages/P2SMessages.kt
  82. 20 21
      app/src/main/java/ch/threema/app/voip/services/VoipStateService.java
  83. 40 16
      app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt
  84. 2 2
      app/src/main/java/ch/threema/app/webclient/activities/WebDiagnosticsActivity.java
  85. 1 1
      app/src/main/java/ch/threema/app/webclient/converter/ClientInfo.java
  86. 0 2
      app/src/main/java/ch/threema/app/webrtc/AudioContext.kt
  87. 0 1
      app/src/main/java/ch/threema/storage/factories/DistributionListMessageModelFactory.java
  88. 9 9
      app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java
  89. 3 1
      app/src/main/java/ch/threema/storage/models/data/status/ForwardSecurityStatusDataModel.kt
  90. 1 1
      app/src/main/res/animator/selector_gallery_image.xml
  91. 1 1
      app/src/main/res/animator/selector_list_checkbox_bg.xml
  92. 1 1
      app/src/main/res/animator/selector_list_checkbox_fg.xml
  93. 1 1
      app/src/main/res/drawable-anydpi/ic_close_black_24dp.xml
  94. 1 1
      app/src/main/res/drawable/bubble_date_separator.xml
  95. 1 1
      app/src/main/res/drawable/circle_transparent.xml
  96. 1 1
      app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml
  97. 1 1
      app/src/main/res/drawable/ic_archive_outline.xml
  98. 1 1
      app/src/main/res/drawable/ic_filter_list_black_24dp.xml
  99. 1 1
      app/src/main/res/drawable/ic_gps_fixed.xml
  100. 1 1
      app/src/main/res/drawable/ic_pin_circle.xml

+ 2 - 2
app/assets/license.html

@@ -308,14 +308,14 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 <h2>saltyrtc-client-java</h2>
 
-<p>Copyright (c) 2016-2018 Threema GmbH</p>
+<p>Copyright (c) 2016-2023 Threema GmbH</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
 
-<p>Copyright (c) 2016-2018 Threema GmbH</p>
+<p>Copyright (c) 2016-2023 Threema GmbH</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 

+ 45 - 8
app/build.gradle

@@ -1,3 +1,7 @@
+import com.android.tools.profgen.ArtProfileKt
+import com.android.tools.profgen.ArtProfileSerializer
+import com.android.tools.profgen.DexFile
+
 plugins {
     id 'org.sonarqube'
 }
@@ -13,7 +17,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "5.0.3.1"
+def app_version = "5.0.4"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -92,7 +96,7 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 785
+        versionCode 790
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -143,7 +147,8 @@ android {
             uriScheme: "threema",
             contactActionUrl: "threema.id",
             groupLinkActionUrl: "threema.group",
-            actionUrl: "go.threema.ch"
+            actionUrl: "go.threema.ch",
+            callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.call",
         ]
 
         ndk {
@@ -204,6 +209,7 @@ android {
             manifestPlaceholders = [
                 uriScheme: "threemawork",
                 actionUrl: "work.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
         }
         sandbox {
@@ -287,6 +293,7 @@ android {
             manifestPlaceholders = [
                 uriScheme: "threemaonprem",
                 actionUrl: "onprem.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
             ]
         }
 
@@ -321,6 +328,7 @@ android {
             manifestPlaceholders = [
                 uriScheme: "threemared",
                 actionUrl: "red.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.red.call",
             ]
         }
         hms {
@@ -348,6 +356,7 @@ android {
             manifestPlaceholders = [
                 uriScheme: "threemawork",
                 actionUrl: "work.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
         }
         libre {
@@ -451,7 +460,7 @@ android {
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
         }
         sandbox_work {
-            java.srcDirs = ['src/store_google_work/java', 'src/google_services_based/java']
+            java.srcDir 'src/google_services_based/java'
             res.srcDir 'src/store_google_work/res'
             manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
         }
@@ -593,6 +602,33 @@ android {
         noCompress 'png'
     }
 
+    // Fix non-producible `baseline.profm` in release builds due to unstable ordering.
+    // See https://issuetracker.google.com/issues/231837768
+    project.afterEvaluate {
+        applicationVariants.all { variant ->
+            if (variant.name.endsWith("Release")) {
+                tasks["compile${variant.name.capitalize()}ArtProfile"].doLast {
+                    outputs.files.each { file ->
+                        if (file.toString().endsWith(".profm")) {
+                            println("Sorting ${file} ...")
+                            def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
+                            def profile = ArtProfileKt.ArtProfile(file)
+                            def keys = new ArrayList(profile.profileData.keySet())
+                            def sortedData = new LinkedHashMap()
+                            Collections.sort keys, new DexFile.Companion()
+                            keys.each { key -> sortedData[key] = profile.profileData[key] }
+                            new FileOutputStream(file).with {
+                                write(version.magicBytes$profgen)
+                                write(version.versionBytes$profgen)
+                                version.write$profgen(it, sortedData, "")
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     lint {
         // if true, stop the gradle build if errors are found
         abortOnError true
@@ -644,10 +680,11 @@ dependencies {
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
     implementation 'net.sf.opencsv:opencsv:2.3'
-    implementation 'net.lingala.zip4j:zip4j:2.11.2'
+    implementation 'net.lingala.zip4j:zip4j:2.11.4'
     implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.3'
     // commons-io >2.6 requires android 8
     implementation 'commons-io:commons-io:2.6'
+    implementation 'org.apache.commons:commons-text:1.9'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
     implementation 'com.github.CanHub:Android-Image-Cropper:4.3.0'
@@ -686,8 +723,8 @@ dependencies {
     kapt 'androidx.room:room-compiler:2.4.3'
 
     implementation 'com.google.android.material:material:1.7.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.1'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.1'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.2'
+    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.2'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.57' // make sure to update this in domain's build.gradle as well
 
@@ -801,7 +838,7 @@ dependencies {
     redImplementation(name: 'libgsaverification-client', ext: 'aar')
 
     // Maplibre (may have transitive dependencies on Google location services)
-    def maplibreDependency = 'org.maplibre.gl:android-sdk:9.5.2'
+    def maplibreDependency = 'org.maplibre.gl:android-sdk:9.6.0'
     noneImplementation maplibreDependency
     store_googleImplementation maplibreDependency
     store_google_workImplementation maplibreDependency

+ 2 - 2
app/src/foss_based/assets/license.html

@@ -268,14 +268,14 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 <h2>saltyrtc-client-java</h2>
 
-<p>Copyright (c) 2016-2018 Threema GmbH</p>
+<p>Copyright (c) 2016-2023 Threema GmbH</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
 
-<p>Copyright (c) 2016-2018 Threema GmbH</p>
+<p>Copyright (c) 2016-2023 Threema GmbH</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 

+ 14 - 5
app/src/main/AndroidManifest.xml

@@ -258,7 +258,9 @@
 					android:host="compose"
 					android:scheme="${uriScheme}"/>
 			</intent-filter>
-			<intent-filter android:label="@string/app_name">
+			<intent-filter
+				android:label="@string/app_name"
+				android:autoVerify="true">
 				<action android:name="android.intent.action.VIEW"/>
 				<action android:name="android.intent.action.SENDTO"/>
 				<category android:name="android.intent.category.DEFAULT"/>
@@ -325,7 +327,9 @@
 					android:host="add"
 					android:scheme="${uriScheme}"/>
 			</intent-filter>
-			<intent-filter android:label="@string/app_name">
+			<intent-filter
+				android:label="@string/app_name"
+				android:autoVerify="true">
 				<action android:name="android.intent.action.VIEW"/>
 
 				<category android:name="android.intent.category.DEFAULT"/>
@@ -370,7 +374,10 @@
 			android:theme="@style/Theme.Threema.Wizard"
 			android:windowSoftInputMode="adjustPan"
 			android:exported="true">
-			<intent-filter android:priority="1000">
+			<intent-filter
+				android:label="@string/app_name"
+				android:priority="1000"
+				android:autoVerify="true">
 				<action android:name="android.intent.action.VIEW"/>
 
 				<category android:name="android.intent.category.DEFAULT"/>
@@ -606,7 +613,7 @@
 				<!-- Handle calls from phonebook -->
 				<action android:name="android.intent.action.VIEW"/>
 				<category android:name="android.intent.category.DEFAULT"/>
-				<data android:mimeType="vnd.android.cursor.item/vnd.ch.threema.app.call"/>
+				<data android:mimeType="${callMimeType}"/>
 			</intent-filter>
 		</activity>
 		<activity
@@ -755,7 +762,9 @@
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:theme="@style/Theme.Threema.Transparent.Background"
 			android:exported="true">
-			<intent-filter android:autoVerify="true">
+			<intent-filter
+				android:label="@string/app_name"
+				android:autoVerify="true">
 				<action android:name="android.intent.action.VIEW"/>
 
 				<category android:name="android.intent.category.DEFAULT"/>

+ 7 - 4
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -1765,9 +1765,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								}
 
 								if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
-									//on private voting, only show default update msg!
-									message = serviceManager
-												.getContext().getString(R.string.status_ballot_voting_changed, ballotModel.getName());
+									// Only show status message for first vote from a voter on private voting
+									if (isFirstVote) {
+										//on private voting, only show default update msg!
+										message = serviceManager
+											.getContext().getString(R.string.status_ballot_voting_changed, ballotModel.getName());
+									}
 								} else {
 
 									if (receiver != null) {
@@ -1792,7 +1795,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								}
 
 								//now check if every participant has voted
-								if(ballotService.getPendingParticipants(ballotModel.getId()).size() == 0) {
+								if (isFirstVote && ballotService.getPendingParticipants(ballotModel.getId()).size() == 0) {
 									String ballotAllVotesMessage = serviceManager
 													.getContext().getString(R.string.status_ballot_all_votes,
 															ballotModel.getName());

+ 4 - 0
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java

@@ -231,6 +231,10 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 						getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
 						composeMessageFragment.onNewIntent(this.currentIntent);
 					}
+				} else {
+					if (!ConfigUtils.isTabletLayout()) {
+						finish();
+					}
 				}
 				break;
 			case ThreemaActivity.ACTIVITY_ID_UNLOCK_MASTER_KEY:

+ 18 - 1
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -351,6 +351,19 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
+	/**
+	 * Notify the user about the unsent message that are kept in {@link #unsentMessages} and also
+	 * the message passed as argument. The passed message is not kept in the unsent messages and
+	 * therefore is shown only once in a notification.
+	 *
+	 * @param msg the unsent message that should be shown in the notification
+	 */
+	private void notifyUnsentMessages(@NonNull AbstractMessageModel msg) {
+		List<AbstractMessageModel> allUnsentMessages = new ArrayList<>(unsentMessages);
+		allUnsentMessages.add(msg);
+		notificationService.showUnsentMessageNotification(allUnsentMessages);
+	}
+
 	private final SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
 		@Override
 		public void onVerified() {
@@ -429,9 +442,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 					switch (modifiedMessageModel.getState()) {
 						case SENDFAILED:
-						case FS_KEY_MISMATCH:
 							updateUnsentMessagesList(modifiedMessageModel, true);
 							break;
+						case FS_KEY_MISMATCH:
+							// Only notify and don't keep in unsentMessages to prevent that the
+							// notification is shown every time a message is sent
+							notifyUnsentMessages(modifiedMessageModel);
+							break;
 						default:
 							updateUnsentMessagesList(modifiedMessageModel, false);
 							break;

+ 30 - 43
app/src/main/java/ch/threema/app/activities/PinLockActivity.java

@@ -34,11 +34,12 @@ import android.view.inputmethod.EditorInfo;
 import android.widget.Button;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+
 import org.slf4j.Logger;
 
 import java.security.MessageDigest;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
@@ -60,11 +61,9 @@ public class PinLockActivity extends ThreemaActivity {
 	private static final int DEFAULT_LOCKOUT_TIMEOUT = 30 * 1000;
 
 	private TextView passwordEntry;
-	private TextView headerTextView;
-	private TextView detailsTextView;
 	private TextView errorTextView;
 	private int numWrongConfirmAttempts;
-	private Handler handler = new Handler();
+	private final Handler handler = new Handler();
 	private CountDownTimer countDownTimer;
 	private boolean isCheckOnly;
 	private String pinPreset;
@@ -98,33 +97,26 @@ public class PinLockActivity extends ThreemaActivity {
 			finish();
 		}
 
-		if (savedInstanceState != null) {
-			numWrongConfirmAttempts = savedInstanceState.getInt(
-					KEY_NUM_WRONG_CONFIRM_ATTEMPTS, 0);
-		}
+		numWrongConfirmAttempts = preferenceService.getLockoutAttempts();
 
 		setContentView(R.layout.activity_pin_lock);
 		getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
 
 		passwordEntry = findViewById(R.id.password_entry);
-		passwordEntry.setOnEditorActionListener(new TextView.OnEditorActionListener() {
-			@Override
-			public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
-				// Check if this was the result of hitting the enter or "done" key
-				if (actionId == EditorInfo.IME_NULL
-						|| actionId == EditorInfo.IME_ACTION_DONE
-						|| actionId == EditorInfo.IME_ACTION_NEXT) {
-					handleNext();
-					return true;
-				}
-				return false;
+		passwordEntry.setOnEditorActionListener((v, actionId, event) -> {
+			// Check if this was the result of hitting the enter or "done" key
+			if (actionId == EditorInfo.IME_NULL
+					|| actionId == EditorInfo.IME_ACTION_DONE
+					|| actionId == EditorInfo.IME_ACTION_NEXT) {
+				handleNext();
+				return true;
 			}
+			return false;
 		});
 		passwordEntry.setFilters(new InputFilter[]{new InputFilter.LengthFilter(ThreemaApplication.MAX_PIN_LENGTH)});
 
-
-		headerTextView = findViewById(R.id.headerText);
-		detailsTextView = findViewById(R.id.detailsText);
+		TextView headerTextView = findViewById(R.id.headerText);
+		TextView detailsTextView = findViewById(R.id.detailsText);
 		errorTextView = findViewById(R.id.errorText);
 
 		headerTextView.setText(R.string.confirm_your_pin);
@@ -140,11 +132,6 @@ public class PinLockActivity extends ThreemaActivity {
 		return false;
 	}
 
-	@Override
-	public void onConfigurationChanged(@NonNull Configuration newConfig) {
-		super.onConfigurationChanged(newConfig);
-	}
-
 	@Override
 	public void onPause() {
 		super.onPause();
@@ -169,12 +156,6 @@ public class PinLockActivity extends ThreemaActivity {
 		}
 	}
 
-	@Override
-	public void onSaveInstanceState(Bundle outState) {
-		super.onSaveInstanceState(outState);
-		outState.putInt(KEY_NUM_WRONG_CONFIRM_ATTEMPTS, numWrongConfirmAttempts);
-	}
-
 	@Override
 	public void onBackPressed() {
 		quit();
@@ -198,19 +179,21 @@ public class PinLockActivity extends ThreemaActivity {
 			EditTextUtil.hideSoftKeyboard(passwordEntry);
 
 			setResult(RESULT_OK);
+			numWrongConfirmAttempts = 0;
 			finish();
 		} else {
-			if (isCheckOnly) {
-				passwordEntry.setEnabled(false);
-
-				handler.postDelayed(() -> RuntimeUtil.runOnUiThread(this::quit), 1000);
-			}
 			if (++numWrongConfirmAttempts >= FAILED_ATTEMPTS_BEFORE_TIMEOUT) {
-				long deadline = setLockoutAttemptDeadline(DEFAULT_LOCKOUT_TIMEOUT); // TODO default value
+				long deadline = setLockoutAttemptDeadline(DEFAULT_LOCKOUT_TIMEOUT);
 				handleAttemptLockout(deadline);
 			} else {
 				showError(R.string.pinentry_wrong_pin);
 			}
+
+			if (isCheckOnly) {
+				passwordEntry.setEnabled(false);
+
+				handler.postDelayed(() -> RuntimeUtil.runOnUiThread(this::quit), 1000);
+			}
 		}
 	}
 
@@ -280,10 +263,6 @@ public class PinLockActivity extends ThreemaActivity {
 	 * enter a pattern.
 	 */
 	public long getLockoutAttemptDeadline() {
-		if (isCheckOnly) {
-			return 0L;
-		}
-
 		final long deadline = preferenceService.getLockoutDeadline();
 		final long timeoutMs = preferenceService.getLockoutTimeout();
 
@@ -293,4 +272,12 @@ public class PinLockActivity extends ThreemaActivity {
 		}
 		return deadline;
 	}
+
+	@Override
+	protected void onDestroy() {
+		if (preferenceService != null) {
+			preferenceService.setLockoutAttempts(numWrongConfirmAttempts);
+		}
+		super.onDestroy();
+	}
 }

+ 74 - 29
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -49,19 +49,6 @@ import android.view.ViewGroup;
 import android.widget.ProgressBar;
 import android.widget.Toast;
 
-import com.google.android.material.snackbar.Snackbar;
-import com.google.android.material.tabs.TabLayout;
-
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.Executors;
-
 import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -77,6 +64,20 @@ import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
+
+import com.google.android.material.snackbar.Snackbar;
+import com.google.android.material.tabs.TabLayout;
+
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executors;
+
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -105,7 +106,6 @@ import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
-import ch.threema.app.ui.ComposeEditText;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SingleToast;
 import ch.threema.app.ui.ThreemaSearchView;
@@ -158,6 +158,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	public static final String INTENT_DATA_MULTISELECT_FOR_COMPOSE = "msi"; // allow multi select for composing a new message (automatically creates a distribution list)
 
 	private static final int REQUEST_READ_EXTERNAL_STORAGE = 1;
+	private static final String BUNDLE_QUERY_TEXT = "query";
 
 	private ViewPager viewPager;
 	private UserGroupPagerAdapter userGroupPagerAdapter;
@@ -165,7 +166,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	private ThreemaSearchView searchView;
 
 	private boolean hideUi, hideRecents, multiSelect, multiSelectIdentities;
-	private String captionText;
+	private String captionText, queryText;
 	private final List<MediaItem> mediaItems = new ArrayList<>();
 	private final List<MessageReceiver> recipientMessageReceivers = new ArrayList<>();
 	private final List<AbstractMessageModel> originalMessageModels = new ArrayList<>();
@@ -210,14 +211,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 	@Override
 	public boolean onQueryTextChange(String newText) {
-		int currentItem = viewPager.getCurrentItem();
-		Fragment fragment = userGroupPagerAdapter.getRegisteredFragment(currentItem);
-
-		if (fragment != null) {
-			FilterableListAdapter listAdapter = ((RecipientListFragment) fragment).getAdapter();
-			// adapter can be null if it has not been initialized yet (runs in different thread)
-			if (listAdapter == null) return false;
-			listAdapter.getFilter().filter(newText);
+		int itemCount = userGroupPagerAdapter.getCount();
+
+		// apply filter to all adapters
+		for (int currentItem = 0; currentItem < itemCount; currentItem++) {
+			Fragment fragment = userGroupPagerAdapter.getRegisteredFragment(currentItem);
+			if (fragment != null) {
+				FilterableListAdapter listAdapter = ((RecipientListFragment) fragment).getAdapter();
+				// adapter can be null if it has not been initialized yet (runs in different thread)
+				if (listAdapter != null) {
+					listAdapter.getFilter().filter(newText);
+				}
+			}
 		}
 		return true;
 	}
@@ -351,13 +356,31 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
 				@Override
 				public void onPageSelected(int position) {
-					if (searchMenuItem != null) {
-						searchMenuItem.collapseActionView();
-						if (searchView != null) {
-							searchView.setQuery("", false);
+					if (searchView != null) {
+						if (searchMenuItem != null) {
+							CharSequence query = searchView.getQuery();
+							if (TestUtil.empty(query)) {
+								invalidateOptionsMenu();
+								if (searchMenuItem.isActionViewExpanded()) {
+									searchMenuItem.collapseActionView();
+									onQueryTextChange(null);
+								}
+								searchView.setQuery("", false);
+								queryText = null;
+							} else {
+								searchMenuItem.getActionView().post(new Runnable() {
+									@Override
+									public void run() {
+										if (!searchMenuItem.isActionViewExpanded()) {
+											searchMenuItem.expandActionView();
+										}
+										searchView.setQuery(query, true);
+									}
+								});
+								queryText = query.toString();
+							}
 						}
 					}
-					invalidateOptionsMenu();
 				}
 			});
 
@@ -679,7 +702,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		setupUI();
 	}
 
-	private @Nullable String getMimeTypeFromContentUri(@NonNull Uri uri) {
+	@Nullable
+	private String getMimeTypeFromContentUri(@NonNull Uri uri) {
 		if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
 			// query database for correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*"
 			String[] proj = {
@@ -812,6 +836,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			this.searchView.setOnQueryTextListener(this);
 			if (hideUi) {
 				this.searchMenuItem.setVisible(false);
+			} else if (!TestUtil.empty(queryText)) {
+				this.searchMenuItem.expandActionView();
+				this.searchView.setQuery(queryText, true);
 			}
 		} else {
 			this.searchMenuItem.setVisible(false);
@@ -1097,6 +1124,15 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		super.onPause();
 	}
 
+	@Override
+	protected void onCreate(Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		if (savedInstanceState != null) {
+			queryText = savedInstanceState.getString(BUNDLE_QUERY_TEXT, null);
+		}
+	}
+
 	@Override
 	public void onUserInteraction() {
 		logger.debug("onUserInteraction");
@@ -1366,4 +1402,13 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	public boolean isCalledFromExternalApp() {
 		return false;
 	}
+
+	@Override
+	public void onSaveInstanceState(@NonNull Bundle outState) {
+		if (searchView != null) {
+			CharSequence query = searchView.getQuery();
+			outState.putString(BUNDLE_QUERY_TEXT, TextUtils.isEmpty(query) ? null : query.toString());
+		}
+		super.onSaveInstanceState(outState);
+	}
 }

+ 19 - 0
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java

@@ -70,6 +70,7 @@ import androidx.viewpager2.widget.ViewPager2;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.google.android.material.snackbar.Snackbar;
 
+import org.apache.commons.text.similarity.JaroWinklerSimilarity;
 import org.slf4j.Logger;
 
 import java.io.File;
@@ -117,6 +118,7 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.localcrypto.MasterKeyLockedException;
 
+import static ch.threema.app.ThreemaApplication.getMessageDraft;
 import static ch.threema.app.adapters.SendMediaPreviewAdapter.VIEW_TYPE_NORMAL;
 import static ch.threema.app.services.PreferenceService.ImageScale_SEND_AS_FILE;
 import static ch.threema.app.services.PreferenceService.VideoSize_DEFAULT;
@@ -966,6 +968,23 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		messageService.sendMediaAsync(mediaAdapterManager.getItems(), messageReceivers, null);
 
+		if (messageReceivers.size() == 1) {
+			String messageDraft = getMessageDraft(messageReceivers.get(0).getUniqueIdString());
+			if (!TestUtil.empty(messageDraft)) {
+				for (MediaItem mediaItem : mediaAdapterManager.getItems()) {
+					try {
+						double similarity = new JaroWinklerSimilarity().apply(mediaItem.getCaption(), messageDraft);
+						if (similarity > 0.8D) {
+							ThreemaApplication.putMessageDraft(messageReceivers.get(0).getUniqueIdString(), null, null);
+							break;
+						}
+					} catch (IllegalArgumentException ignore) {
+						// one argument is probably null
+					}
+				}
+			}
+		}
+
 		// return last media filter to chat via intermediate hop through MediaAttachActivity
 		if (lastMediaFilter != null) {
 			Intent lastMediaSelectionResult = IntentDataUtil.addLastMediaFilterToIntent(new Intent(), this.lastMediaFilter);

+ 11 - 2
app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java

@@ -50,6 +50,7 @@ import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
+import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.PreferenceService;
@@ -71,6 +72,7 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 	private static final String DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM = "de";
 	private static final String DIALOG_TAG_DOWNLOADING_BACKUP = "dwnldBkp";
 	private static final String DIALOG_TAG_NO_INTERNET = "nin";
+	private static final String DIALOG_TAG_ERROR_TMP_FILE_DIR = "tmpFileDialog";
 
 	public static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 541;
 
@@ -177,7 +179,14 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 			GenericProgressDialog.newInstance(R.string.importing_files, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP);
 
 			new Thread(() -> {
-				final File file = fileService.copyUriToTempFile(uri, "file", "zip", true);
+				final File file;
+				final File externalFile = fileService.copyUriToTempFile(uri, "file", "zip", true);
+				if (externalFile != null) {
+					file = externalFile;
+				} else {
+					logger.warn("Could not copy the backup file to temp file; trying to copy it to internal storage instead.");
+					file = fileService.copyUriToTempFile(uri, "file", "zip", false);
+				}
 
 				RuntimeUtil.runOnUiThread(() -> {
 					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP, true);
@@ -186,7 +195,7 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 						restoreBackupFile(file);
 						file.deleteOnExit();
 					} else {
-						Toast.makeText(this, "Unable to access/copy selected file to temporary directory", Toast.LENGTH_LONG).show();
+						SimpleStringAlertDialog.newInstance(R.string.an_error_occurred, R.string.missing_permission_external_storage).show(getSupportFragmentManager(), DIALOG_TAG_ERROR_TMP_FILE_DIR);
 					}
 				});
 			}).start();

+ 1 - 1
app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java

@@ -47,7 +47,7 @@ import ch.threema.base.utils.LoggingUtil;
 public class WizardFingerPrintActivity extends WizardBackgroundActivity implements WizardDialog.WizardDialogCallback, GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardFingerPrintActivity");
 
-	public static final int PROGRESS_MAX = 50;
+	public static final int PROGRESS_MAX = 100;
 	private static final String DIALOG_TAG_CREATE_ID = "ci";
 	private static final String DIALOG_TAG_CREATE_ERROR = "ni";
 	private static final String DIALOG_TAG_FINGERPRINT_INFO = "fi";

+ 5 - 1
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -222,7 +222,11 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 
 			this.groupService.loadAvatarIntoImage(groupModel, itemHolder.avatarView, AvatarOptions.PRESET_DEFAULT_FALLBACK);
 
-			itemHolder.nameView.setText(groupModel.getName());
+			String groupName = groupModel.getName();
+			if (groupName == null) {
+				groupName = groupService.getMembersString(groupModel);
+			}
+			itemHolder.nameView.setText(groupName);
 
 			if (groupService.isGroupOwner(groupModel)) {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_outline);

+ 0 - 9
app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java

@@ -47,18 +47,9 @@ import ch.threema.storage.models.ContactModel;
 
 public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSyncAdapter");
-	private Context context;
 
 	public ContactsSyncAdapter(Context context, boolean autoInitialize) {
 		super(context, autoInitialize);
-
-		this.context = context;
-	}
-
-	public ContactsSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) {
-		super(context, autoInitialize, allowParallelSyncs);
-
-		this.context = context;
 	}
 
 	@Override

+ 15 - 8
app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt

@@ -112,7 +112,18 @@ class GroupCallParticipantsAdapter(
 		private var detachSinkFn: DetachSinkFn? = null
 
 		@UiThread
-		internal fun subscribeCamera() {
+		internal fun updateCameraSubscription() {
+			participant?.let {
+				if (isAttachedToWindow && it.cameraActive) {
+					subscribeCamera()
+				} else {
+					unsubscribeCamera()
+				}
+			}
+		}
+
+		@UiThread
+		private fun subscribeCamera() {
 			cancelCameraSubscription()
 			logger.trace("Subscribe camera for participant={}", participant?.id)
 			participant?.let { participant ->
@@ -167,7 +178,7 @@ class GroupCallParticipantsAdapter(
 
 		@UiThread
 		fun updateCaptureState() {
-			logger.trace("UpdateCaptureState")
+			logger.trace("UpdateCaptureState for {}", participant)
 			participant?.let {
 				itemView.post {
 					microphoneMuted.visibility = if (it.microphoneActive) {
@@ -177,11 +188,7 @@ class GroupCallParticipantsAdapter(
 					}
 				}
 
-				if (isAttachedToWindow && it.cameraActive) {
-					subscribeCamera()
-				} else {
-					unsubscribeCamera()
-				}
+				updateCameraSubscription()
 			}
 		}
 
@@ -248,7 +255,7 @@ class GroupCallParticipantsAdapter(
 			logger.trace("Layout changed; update view holder heights.")
 			activeViewHolders.values.forEach {
 				it.itemView.layoutParams.height = getViewHeight(it.parent)
-				it.subscribeCamera()
+				it.updateCameraSubscription()
 			}
 		}
 	}

+ 2 - 8
app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java

@@ -46,7 +46,6 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.emojis.EmojiMarkupUtil;
-import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
@@ -223,13 +222,8 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		public ConversationModel getConversationModel() { return conversationModel; }
 
 		@Override
-		public void onGroupCallStart(@NonNull GroupModel groupModel, @Nullable GroupCallDescription call) {
-			ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
-				@Override
-				public void handle(ConversationListener listener) {
-					listener.onModified(conversationModel, null);
-				}
-			});
+		public void onGroupCallStart(@NonNull GroupModel groupModel) {
+			ListenerManager.conversationListeners.handle(listener -> listener.onModified(conversationModel, null));
 		}
 	}
 

+ 1 - 6
app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java

@@ -137,12 +137,7 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 			setDefaultBackground(holder);
 		}
 
-		if (!TestUtil.empty(fileData.getCaption())) {
-			holder.bodyTextView.setText(formatTextString(fileData.getCaption(), filterString));
-			showHide(holder.bodyTextView, true);
-		} else {
-			showHide(holder.bodyTextView, false);
-		}
+		configureBodyText(holder, fileData.getCaption());
 
 		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, fileData, fileSize));
 

+ 1 - 19
app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java

@@ -25,7 +25,6 @@ import android.annotation.SuppressLint;
 import android.content.Context;
 import android.os.Build;
 import android.os.PowerManager;
-import android.text.TextUtils;
 import android.view.View;
 import android.widget.SeekBar;
 import android.widget.Toast;
@@ -39,14 +38,12 @@ import java.io.File;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.AudioProgressBarView;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
@@ -371,22 +368,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 			holder.contentView.getLayoutParams().width = ConfigUtils.getPreferredAudioMessageWidth(getContext(), false);
 		}
 
-		// format caption
-		if (!TextUtils.isEmpty(caption)) {
-			holder.bodyTextView.setText(formatTextString(caption, filterString));
-
-			LinkifyUtil.getInstance().linkify(
-				(ComposeMessageFragment) helper.getFragment(),
-				holder.bodyTextView,
-				getMessageModel(),
-				true,
-				actionModeStatus.getActionModeEnabled(),
-				onClickElement);
-
-			showHide(holder.bodyTextView, true);
-		} else {
-			showHide(holder.bodyTextView, false);
-		}
+		configureBodyText(holder, caption);
 	}
 
 	@UiThread

+ 21 - 0
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -33,6 +33,7 @@ import android.view.View;
 import android.widget.TextView;
 
 import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.content.res.AppCompatResources;
 import androidx.fragment.app.Fragment;
@@ -44,6 +45,7 @@ import java.util.Map;
 
 import ch.threema.app.R;
 import ch.threema.app.cache.ThumbnailCache;
+import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DownloadService;
@@ -56,6 +58,7 @@ import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.messageplayer.MessagePlayerService;
 import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.StateBitmapUtil;
@@ -539,4 +542,22 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			}
 		}
 	}
+
+	protected void configureBodyText(@NonNull ComposeMessageHolder holder, @Nullable String caption) {
+		if (!TestUtil.empty(caption)) {
+			holder.bodyTextView.setText(formatTextString(caption, filterString));
+
+			LinkifyUtil.getInstance().linkify(
+				(ComposeMessageFragment) helper.getFragment(),
+				holder.bodyTextView,
+				getMessageModel(),
+				true,
+				actionModeStatus.getActionModeEnabled(),
+				onClickElement);
+
+			showHide(holder.bodyTextView, true);
+		} else {
+			showHide(holder.bodyTextView, false);
+		}
+	}
 }

+ 1 - 21
app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java

@@ -36,7 +36,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import java.io.File;
 
 import ch.threema.app.R;
-import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.messageplayer.FileMessagePlayer;
 import ch.threema.app.services.messageplayer.MessagePlayer;
@@ -46,7 +45,6 @@ import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AvatarConverterUtil;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.ImageViewUtil;
-import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
@@ -95,7 +93,7 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 		}, holder.messageBlockView);
 
 		configureFileMessagePlayer(holder, position);
-		configureBodyText(holder);
+		configureBodyText(holder, fileData.getCaption());
 		configureTertiaryText(holder);
 		configureSecondaryText(holder);
 		configureSizeText(holder);
@@ -148,24 +146,6 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 	}
 
-	private void configureBodyText(@NonNull ComposeMessageHolder holder) {
-		if (!TestUtil.empty(fileData.getCaption())) {
-			holder.bodyTextView.setText(formatTextString(fileData.getCaption(), filterString));
-
-			LinkifyUtil.getInstance().linkify(
-				(ComposeMessageFragment) helper.getFragment(),
-				holder.bodyTextView,
-				getMessageModel(),
-				true,
-				actionModeStatus.getActionModeEnabled(),
-				onClickElement);
-
-			showHide(holder.bodyTextView, true);
-		} else {
-			showHide(holder.bodyTextView, false);
-		}
-	}
-
 	private void configureFileMessagePlayer(@NonNull ComposeMessageHolder holder, int position) {
 		fileMessagePlayer
 			.addListener(LISTENER_TAG, new MessagePlayer.PlaybackListener() {

+ 1 - 0
app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt

@@ -41,6 +41,7 @@ class ForwardSecurityStatusChatAdapterDecorator(context: Context?, messageModel:
             ForwardSecurityStatusType.FORWARD_SECURITY_ESTABLISHED_RX -> body = context.getString(R.string.forward_security_established_rx)
             ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER -> body = context.getString(R.string.forward_security_message_out_of_order)
             ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGES_SKIPPED -> body = ConfigUtils.getSafeQuantityString(context, R.plurals.forward_security_messages_skipped, statusDataModel.quantity, statusDataModel.quantity)
+            ForwardSecurityStatusType.FORWARD_SECURITY_UNAVAILABLE_DOWNGRADE -> body = context.getString(R.string.forward_security_downgraded_status_message)
         }
         if (showHide(holder.bodyTextView, !TestUtil.empty(body))) {
             holder.bodyTextView.text = body

+ 1 - 21
app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java

@@ -35,7 +35,6 @@ import org.slf4j.Logger;
 import ch.threema.app.R;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.activities.ThreemaActivity;
-import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.DebouncedOnClickListener;
@@ -43,7 +42,6 @@ import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -91,7 +89,7 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 			setControllerState(holder, getMessageModel().getImageData());
 		});
 
-		configureBodyText(holder);
+		configureBodyText(holder, getMessageModel().getCaption());
 
 		configureMessagePlayer(holder, imageMessagePlayer);
 	}
@@ -126,24 +124,6 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 				});
 	}
 
-	private void configureBodyText(@NonNull ComposeMessageHolder holder) {
-		if (!TestUtil.empty(getMessageModel().getCaption())) {
-			holder.bodyTextView.setText(formatTextString(getMessageModel().getCaption(), filterString));
-
-			LinkifyUtil.getInstance().linkify(
-				(ComposeMessageFragment) helper.getFragment(),
-				holder.bodyTextView,
-				getMessageModel(),
-				true,
-				actionModeStatus.getActionModeEnabled(),
-				onClickElement);
-
-			showHide(holder.bodyTextView, true);
-		} else {
-			showHide(holder.bodyTextView, false);
-		}
-	}
-
 	private void setControllerClickListener(@NonNull ComposeMessageHolder holder, @NonNull MessagePlayer imageMessagePlayer) {
 		if (holder.controller != null) {
 			holder.controller.setOnClickListener(new DebouncedOnClickListener(500) {

+ 1 - 21
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -35,13 +35,11 @@ import org.slf4j.Logger;
 import java.io.File;
 
 import ch.threema.app.R;
-import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.ImageViewUtil;
-import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
@@ -143,7 +141,7 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 			setDatePrefix(datePrefixString, 0);
 		}
 
-		configureBodyText(holder);
+		configureBodyText(holder, getMessageModel().getFileData().getCaption());
 
 		configureBackground(holder);
 	}
@@ -156,24 +154,6 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 	}
 
-	private void configureBodyText(@NonNull ComposeMessageHolder holder) {
-		if (!TestUtil.empty(getMessageModel().getFileData().getCaption())) {
-			holder.bodyTextView.setText(formatTextString(getMessageModel().getFileData().getCaption(), filterString));
-
-			LinkifyUtil.getInstance().linkify(
-				(ComposeMessageFragment) helper.getFragment(),
-				holder.bodyTextView,
-				getMessageModel(),
-				true,
-				actionModeStatus.getActionModeEnabled(),
-				onClickElement);
-
-			showHide(holder.bodyTextView, true);
-		} else {
-			showHide(holder.bodyTextView, false);
-		}
-	}
-
 	private void configureVideoMessagePlayer(@NonNull ComposeMessageHolder holder, @NonNull MessagePlayer videoMessagePlayer) {
 		videoMessagePlayer
 			// decrypt listener

+ 36 - 0
app/src/main/java/ch/threema/app/backuprestore/RandomUtil.kt

@@ -0,0 +1,36 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.backuprestore
+
+import java.util.concurrent.ThreadLocalRandom
+
+object RandomUtil {
+    /**
+     * Get an iterator for obtaining distinct non-cryptographically safe random positive integers
+     */
+    @JvmStatic
+    fun getDistinctRandomIterator(): Iterator<Int> {
+        return generateSequence {
+            ThreadLocalRandom.current().nextInt(0, Integer.MAX_VALUE)
+        }.distinct().iterator()
+    }
+}

+ 31 - 11
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -56,7 +56,10 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStreamWriter;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 
 import ch.threema.app.BuildConfig;
@@ -65,6 +68,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.DummyActivity;
 import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.backuprestore.BackupRestoreDataConfig;
+import ch.threema.app.backuprestore.RandomUtil;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.managers.ServiceManager;
@@ -154,6 +158,8 @@ public class BackupService extends Service {
 
 	private static DocumentFile backupFile = null;
 	private BackupRestoreDataConfig config = null;
+	private final HashMap<Integer, String> groupUidMap = new HashMap<>();
+	private final Iterator<Integer> randomIterator = RandomUtil.getDistinctRandomIterator();
 
 	public static boolean isRunning() {
 		return isRunning;
@@ -205,7 +211,7 @@ public class BackupService extends Service {
 					return START_NOT_STICKY;
 				}
 
-				String filename = "threema-backup_" + userService.getIdentity() + "_" + now.getTime() + "_1";
+				String filename = "threema-backup_" + now.getTime() + "_1";
 
 				if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(backupUri.getScheme())) {
 					zipFile = DocumentFile.fromFile(new File(backupUri.getPath(), INCOMPLETE_BACKUP_FILENAME_PREFIX + filename + ".zip"));
@@ -504,7 +510,7 @@ public class BackupService extends Service {
 				ZipUtil.addZipStream(
 					zipOutputStream,
 					this.fileService.getContactAvatarStream(contactService.getMe()),
-					Tags.CONTACT_AVATAR_FILE_PREFIX + contactService.getMe().getIdentity(),
+					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
 					false
 				);
 			} catch (IOException e) {
@@ -523,7 +529,8 @@ public class BackupService extends Service {
 			Tags.TAG_CONTACT_NICK_NAME,
 			Tags.TAG_CONTACT_HIDDEN,
 			Tags.TAG_CONTACT_ARCHIVED,
-			Tags.TAG_CONTACT_FORWARD_SECURITY
+			Tags.TAG_CONTACT_FORWARD_SECURITY,
+			Tags.TAG_CONTACT_IDENTITY_ID
 		};
 		final String[] messageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
@@ -549,12 +556,13 @@ public class BackupService extends Service {
 		// Iterate over all contacts. Then backup every contact with the corresponding messages.
 		try (final ByteArrayOutputStream contactBuffer = new ByteArrayOutputStream()) {
 			try (final CSVWriter contactCsv = new CSVWriter(new OutputStreamWriter(contactBuffer), contactCsvHeader)) {
-
 				for (final ContactModel contactModel : contactService.find(null)) {
 					if (!this.next("backup contact " + contactModel.getIdentity())) {
 						return false;
 					}
 
+					String identityId = getFormattedUniqueId();
+
 					// Write contact
 					contactCsv.createRow()
 						.write(Tags.TAG_CONTACT_IDENTITY, contactModel.getIdentity())
@@ -568,6 +576,7 @@ public class BackupService extends Service {
 						.write(Tags.TAG_CONTACT_HIDDEN, contactModel.isHidden())
 						.write(Tags.TAG_CONTACT_ARCHIVED, contactModel.isArchived())
 						.write(Tags.TAG_CONTACT_FORWARD_SECURITY, contactModel.isForwardSecurityEnabled())
+						.write(Tags.TAG_CONTACT_IDENTITY_ID, identityId)
 						.write();
 
 					// Back up contact profile pictures
@@ -577,7 +586,7 @@ public class BackupService extends Service {
 								ZipUtil.addZipStream(
 									zipOutputStream,
 									this.fileService.getContactAvatarStream(contactModel),
-									Tags.CONTACT_AVATAR_FILE_PREFIX + contactModel.getIdentity(),
+									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
 									false
 								);
 							}
@@ -590,7 +599,7 @@ public class BackupService extends Service {
 							ZipUtil.addZipStream(
 								zipOutputStream,
 								this.fileService.getContactPhotoStream(contactModel),
-								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + contactModel.getIdentity(),
+								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
 								false
 							);
 						} catch (IOException e) {
@@ -650,7 +659,7 @@ public class BackupService extends Service {
 						ZipUtil.addZipStream(
 							zipOutputStream,
 							new ByteArrayInputStream(messageBuffer.toByteArray()),
-							Tags.MESSAGE_FILE_PREFIX + contactModel.getIdentity() + Tags.CSV_FILE_POSTFIX,
+							Tags.MESSAGE_FILE_PREFIX + identityId + Tags.CSV_FILE_POSTFIX,
 							true
 						);
 					}
@@ -684,7 +693,8 @@ public class BackupService extends Service {
 			Tags.TAG_GROUP_DELETED,
 			Tags.TAG_GROUP_ARCHIVED,
 			Tags.TAG_GROUP_DESC,
-			Tags.TAG_GROUP_DESC_TIMESTAMP
+			Tags.TAG_GROUP_DESC_TIMESTAMP,
+			Tags.TAG_GROUP_UID,
 		};
 		final String[] groupMessageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
@@ -738,9 +748,9 @@ public class BackupService extends Service {
 		// Iterate over all groups
 		try (final ByteArrayOutputStream groupBuffer = new ByteArrayOutputStream()) {
 			try (final CSVWriter groupCsv = new CSVWriter(new OutputStreamWriter(groupBuffer), groupCsvHeader)) {
-
 				for (final GroupModel groupModel : this.groupService.getAll(groupFilter)) {
-					String groupUid = BackupUtils.buildGroupUid(groupModel);
+					String groupUid = getFormattedUniqueId();
+					groupUidMap.put(groupModel.getId(), groupUid);
 
 					if (!this.next("backup group " + groupModel.getApiGroupId())) {
 						return false;
@@ -756,6 +766,7 @@ public class BackupService extends Service {
 						.write(Tags.TAG_GROUP_ARCHIVED, groupModel.isArchived())
 						.write(Tags.TAG_GROUP_DESC, groupModel.getGroupDesc())
 						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
+						.write(Tags.TAG_GROUP_UID, groupUid)
 						.write();
 
 					//check if the group have a photo
@@ -936,7 +947,7 @@ public class BackupService extends Service {
 							}
 
 							ref = "GroupBallotModel";
-							refId = BackupUtils.buildGroupUid(groupModel);
+							refId = groupUidMap.get(groupModel.getId());
 						} else if (link instanceof IdentityBallotModel) {
 							ref = "IdentityBallotModel";
 							refId = ((IdentityBallotModel) link).getIdentity();
@@ -1433,6 +1444,15 @@ public class BackupService extends Service {
 		isRunning = false;
 		stopSelf();
 	}
+
+	/**
+	 * Return a string representation of the next value in randomIterator
+	 * @return a 10 character string
+	 */
+	@NonNull
+	private String getFormattedUniqueId() {
+		return String.format(Locale.US, "%010d", randomIterator.next());
+	}
 }
 
 

+ 218 - 217
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -21,10 +21,6 @@
 
 package ch.threema.app.backuprestore.csv;
 
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
-import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
-
 import android.annotation.SuppressLint;
 import android.app.Notification;
 import android.app.NotificationManager;
@@ -119,6 +115,10 @@ import ch.threema.storage.models.data.MessageContentsType;
 import ch.threema.storage.models.data.media.BallotDataModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 
+import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
+import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
+
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
 
@@ -143,6 +143,9 @@ public class RestoreService extends Service {
 	private static final String EXTRA_ID_CANCEL = "cnc";
 
 	private final RestoreResultImpl restoreResult = new RestoreResultImpl();
+	private final HashMap<String, String> identityIdMap = new HashMap<>();
+	private final HashMap<String, Integer> groupUidMap = new HashMap<>();
+
 	private long currentProgressStep = 0;
 	private long progressSteps = 0;
 	private int latestPercentStep = -1;
@@ -313,7 +316,7 @@ public class RestoreService extends Service {
 	}
 
 	// ---------------------------------------------------------------------------
-	private class RestoreResultImpl implements BackupRestoreDataService.RestoreResult {
+	private static class RestoreResultImpl implements BackupRestoreDataService.RestoreResult {
 		private long contactSuccess = 0;
 		private long contactFailed = 0;
 		private long messageSuccess = 0;
@@ -367,7 +370,6 @@ public class RestoreService extends Service {
 	}
 
 	private RestoreSettings restoreSettings;
-	private final HashMap<String, Integer> groupIdMap = new HashMap<>();
 	private final HashMap<String, Integer> ballotIdMap = new HashMap<>();
 	private final HashMap<Integer, Integer> ballotOldIdMap = new HashMap<>();
 	private final HashMap<String, Integer> ballotChoiceIdMap = new HashMap<>();
@@ -411,7 +413,8 @@ public class RestoreService extends Service {
 					this.initProgress(stepSizeTotal);
 				}
 
-				this.groupIdMap.clear();
+				this.identityIdMap.clear();
+				this.groupUidMap.clear();
 				this.ballotIdMap.clear();
 				this.ballotOldIdMap.clear();
 				this.ballotChoiceIdMap.clear();
@@ -441,22 +444,13 @@ public class RestoreService extends Service {
 					fileService.clearDirectory(fileService.getAppDataPath(), false);
 				}
 
-				/* make map of file headers for quick access */
-				@SuppressWarnings({"unchecked"})
 				List<FileHeader> fileHeaders = zipFile.getFileHeaders();
 
 				// The restore settings file contains the data backup format version
-				this.restoreSettings = new RestoreSettings();
-				FileHeader settingsHeader = Functional.select(
-					fileHeaders,
-					type -> TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME)
-				);
-				if (settingsHeader != null) {
-					try (InputStream inputStream = zipFile.getInputStream(settingsHeader);
-					     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-					     CSVReader csvReader = new CSVReader(inputStreamReader)) {
-						restoreSettings.parse(csvReader.readAll());
-					}
+				this.restoreSettings = getRestoreSettings(fileHeaders);
+
+				if (restoreSettings.isUnsupportedVersion()) {
+					throw new ThreemaException(getString(R.string.backup_version_mismatch));
 				}
 
 				// Restore the identity
@@ -525,7 +519,7 @@ public class RestoreService extends Service {
 				}
 
 				if (!writeToDb) {
-					stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES)  + (mediaCount * STEP_SIZE_MEDIA);
+					stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES)  + ((long) mediaCount * STEP_SIZE_MEDIA);
 				}
 			}
 
@@ -541,6 +535,9 @@ public class RestoreService extends Service {
 		} catch (RestoreCanceledException e) {
 			logger.error("Restore cancelled", e);
 			message = getString(R.string.restore_data_cancelled);
+		} catch(IOException e) {
+			logger.error("Exception while restoring backup", e);
+			message = getString(R.string.invalid_zip_restore_failed, e.getMessage());
 		} catch (Exception e) {
 			// wrong password? no connection? throw
 			logger.error("Exception while restoring backup", e);
@@ -552,6 +549,24 @@ public class RestoreService extends Service {
 		return false;
 	}
 
+	@NonNull
+	private RestoreSettings getRestoreSettings(List<FileHeader> fileHeaders) throws ThreemaException, IOException {
+		FileHeader settingsHeader = Functional.select(fileHeaders, type -> TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME));
+		if (settingsHeader == null) {
+			logger.error("Settings file header is missing");
+			throw new ThreemaException(getString(R.string.invalid_backup));
+		}
+		try (
+			InputStream is = zipFile.getInputStream(settingsHeader);
+			InputStreamReader inputStreamReader = new InputStreamReader(is);
+			CSVReader csvReader = new CSVReader(inputStreamReader)
+		) {
+			RestoreSettings settings = new RestoreSettings();
+			settings.parse(csvReader.readAll());
+			return settings;
+		}
+	}
+
 	/**
 	 * restore the main files (contacts, groups, distribution lists)
 	 */
@@ -673,19 +688,19 @@ public class RestoreService extends Service {
 			}
 
 			final String groupUid = fileName.substring(Tags.GROUP_AVATAR_PREFIX.length());
-			if(groupIdMap.containsKey(groupUid)) {
-				GroupModel m = databaseServiceNew.getGroupModelFactory().getById(
-						groupIdMap.get(groupUid)
-				);
-
-				if (m != null) {
-					try (InputStream inputStream = zipFile.getInputStream(fileHeader)) {
-						this.fileService.writeGroupAvatar(m, IOUtils.toByteArray(inputStream));
-					} catch (Exception e) {
-						//ignore, just the avatar :)
-						success = false;
+			if (!TestUtil.empty(groupUid)) {
+				Integer groupId = groupUidMap.get(groupUid);
+				if (groupId != null) {
+					GroupModel m = databaseServiceNew.getGroupModelFactory().getById(groupId);
+					if (m != null) {
+						try (InputStream inputStream = zipFile.getInputStream(fileHeader)) {
+							this.fileService.writeGroupAvatar(m, IOUtils.toByteArray(inputStream));
+						} catch (Exception e) {
+							//ignore, just the avatar :)
+							success = false;
+						}
+						//
 					}
-					//
 				}
 			}
 		}
@@ -699,19 +714,19 @@ public class RestoreService extends Service {
 	private int restoreMessageMediaFiles(List<FileHeader> fileHeaders) throws RestoreCanceledException {
 		int count = 0;
 
-		count += this.restoreMessageMediaFiles(fileHeaders, Tags.MESSAGE_MEDIA_FILE_PREFIX, Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX, new GetMessageModel() {
-			@Override
-			public AbstractMessageModel get(String uid) {
-				return databaseServiceNew.getMessageModelFactory().getByUid(uid);
-			}
-		});
+		count += this.restoreMessageMediaFiles(
+			fileHeaders,
+			Tags.MESSAGE_MEDIA_FILE_PREFIX,
+			Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+			uid -> databaseServiceNew.getMessageModelFactory().getByUid(uid)
+		);
 
-		count += this.restoreMessageMediaFiles(fileHeaders, Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX, Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX, new GetMessageModel() {
-			@Override
-			public AbstractMessageModel get(String uid) {
-				return databaseServiceNew.getGroupMessageModelFactory().getByUid(uid);
-			}
-		});
+		count += this.restoreMessageMediaFiles(
+			fileHeaders,
+			Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX,
+			Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+			uid -> databaseServiceNew.getGroupMessageModelFactory().getByUid(uid)
+		);
 
 		return count;
 	}
@@ -724,7 +739,7 @@ public class RestoreService extends Service {
 		int count = 0;
 
 		//process all thumbnails
-		Map<String, FileHeader> thumbnailFileHeaders = new HashMap<String, FileHeader>();
+		Map<String, FileHeader> thumbnailFileHeaders = new HashMap<>();
 
 		for (FileHeader fileHeader : fileHeaders) {
 			String fileName = fileHeader.getFileName();
@@ -800,23 +815,20 @@ public class RestoreService extends Service {
 	}
 
 	private boolean restoreContactFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					ContactModel contactModel = createContactModel(row, restoreSettings);
-					if (writeToDb) {
-						//set the default color
-						ContactModelFactory contactModelFactory = databaseServiceNew.getContactModelFactory();
-						contactModelFactory.createOrUpdate(contactModel);
-						restoreResult.incContactSuccess();
-					}
-				} catch (Exception x) {
-					logger.error("Could not restore contact", x);
-					if (writeToDb) {
-						//process next
-						restoreResult.incContactFailed();
-					}
+		return this.processCsvFile(fileHeader, row -> {
+			try {
+				ContactModel contactModel = createContactModel(row, restoreSettings);
+				if (writeToDb) {
+					//set the default color
+					ContactModelFactory contactModelFactory = databaseServiceNew.getContactModelFactory();
+					contactModelFactory.createOrUpdate(contactModel);
+					restoreResult.incContactSuccess();
+				}
+			} catch (Exception x) {
+				logger.error("Could not restore contact", x);
+				if (writeToDb) {
+					//process next
+					restoreResult.incContactFailed();
 				}
 			}
 		});
@@ -830,11 +842,18 @@ public class RestoreService extends Service {
 		}
 
 		// Look up contact model for this avatar
-		String identity = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
-		if (TestUtil.empty(identity)) {
+		String identityId = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
+		if (TestUtil.empty(identityId)) {
 			return false;
 		}
-		ContactModel contactModel = contactService.getByIdentity(identity);
+
+		ContactModel contactModel;
+		if (Tags.CONTACT_AVATAR_FILE_SUFFIX_ME.equals(identityId)) {
+			contactModel = contactService.getMe();
+		} else {
+			contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
+		}
+
 		if (contactModel == null) {
 			return false;
 		}
@@ -863,11 +882,11 @@ public class RestoreService extends Service {
 		}
 
 		// Look up contact model for this avatar
-		String identity = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
-		if (TestUtil.empty(identity)) {
+		String identityId = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
+		if (TestUtil.empty(identityId)) {
 			return false;
 		}
-		ContactModel contactModel = contactService.getByIdentity(identity);
+		ContactModel contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
 		if (contactModel == null) {
 			return false;
 		}
@@ -884,74 +903,74 @@ public class RestoreService extends Service {
 	}
 
 	private boolean restoreGroupFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					GroupModel groupModel = createGroupModel(row, restoreSettings);
+		return this.processCsvFile(fileHeader, row -> {
+			try {
+				GroupModel groupModel = createGroupModel(row, restoreSettings);
 
-					if (writeToDb) {
-						databaseServiceNew.getGroupModelFactory().create(
-								groupModel
-						);
-						groupIdMap.put(BackupUtils.buildGroupUid(groupModel), groupModel.getId());
-						restoreResult.incContactSuccess();
+				if (writeToDb) {
+					databaseServiceNew.getGroupModelFactory().create(
+							groupModel
+					);
+
+					if (restoreSettings.getVersion() >= 19) {
+						groupUidMap.put(row.getString(Tags.TAG_GROUP_UID), groupModel.getId());
+					} else {
+						groupUidMap.put(BackupUtils.buildGroupUid(groupModel), groupModel.getId());
 					}
 
-					List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
-					if (writeToDb) {
-						for (GroupMemberModel groupMemberModel : groupMemberModels) {
-							databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
-						}
+					restoreResult.incContactSuccess();
+				}
 
-						if (!groupModel.isDeleted()) {
-							if (groupService.isGroupOwner(groupModel)) {
-								groupService.sendSync(groupModel);
-							} else {
-								groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId().toString())));
-							}
-						}
+				List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
+				if (writeToDb) {
+					for (GroupMemberModel groupMemberModel : groupMemberModels) {
+						databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
 					}
-				} catch (Exception x) {
-					logger.error("Could not restore group", x);
-					if (writeToDb) {
-						//process next
-						restoreResult.incContactFailed();
+
+					if (!groupModel.isDeleted()) {
+						if (groupService.isGroupOwner(groupModel)) {
+							groupService.sendSync(groupModel);
+						} else {
+							groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId().toString())));
+						}
 					}
 				}
+			} catch (Exception x) {
+				logger.error("Could not restore group", x);
+				if (writeToDb) {
+					//process next
+					restoreResult.incContactFailed();
+				}
 			}
 		});
 	}
 
 	private boolean restoreDistributionListFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					DistributionListModel distributionListModel = createDistributionListModel(row);
+		return this.processCsvFile(fileHeader, row -> {
+			try {
+				DistributionListModel distributionListModel = createDistributionListModel(row);
 
-					if (writeToDb) {
-						databaseServiceNew.getDistributionListModelFactory().create(
-								distributionListModel);
-						distributionListIdMap.put(BackupUtils.buildDistributionListUid(distributionListModel), distributionListModel.getId());
-						restoreResult.incContactSuccess();
-					}
+				if (writeToDb) {
+					databaseServiceNew.getDistributionListModelFactory().create(
+							distributionListModel);
+					distributionListIdMap.put(BackupUtils.buildDistributionListUid(distributionListModel), distributionListModel.getId());
+					restoreResult.incContactSuccess();
+				}
 
-					List<DistributionListMemberModel> distributionListMemberModels = createDistributionListMembers(row, distributionListModel.getId());
-					if (writeToDb) {
-						for (DistributionListMemberModel distributionListMemberModel : distributionListMemberModels) {
-							databaseServiceNew.getDistributionListMemberModelFactory().create(
-									distributionListMemberModel
-							);
-						}
-					}
-				} catch (Exception x) {
-					logger.error("Could not restore distribution list", x);
-					if (writeToDb) {
-						//process next
-						restoreResult.incContactFailed();
+				List<DistributionListMemberModel> distributionListMemberModels = createDistributionListMembers(row, distributionListModel.getId());
+				if (writeToDb) {
+					for (DistributionListMemberModel distributionListMemberModel : distributionListMemberModels) {
+						databaseServiceNew.getDistributionListMemberModelFactory().create(
+								distributionListMemberModel
+						);
 					}
 				}
+			} catch (Exception x) {
+				logger.error("Could not restore distribution list", x);
+				if (writeToDb) {
+					//process next
+					restoreResult.incContactFailed();
+				}
 			}
 		});
 	}
@@ -961,85 +980,76 @@ public class RestoreService extends Service {
 		@NonNull final FileHeader ballotChoice,
 		@NonNull FileHeader ballotVote
 	) throws IOException, RestoreCanceledException {
-		this.processCsvFile(ballotMain, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					BallotModel ballotModel = createBallotModel(row);
+		this.processCsvFile(ballotMain, row -> {
+			try {
+				BallotModel ballotModel = createBallotModel(row);
 
-					if (writeToDb) {
-						databaseServiceNew.getBallotModelFactory().create(
-								ballotModel
-						);
+				if (writeToDb) {
+					databaseServiceNew.getBallotModelFactory().create(
+							ballotModel
+					);
 
-						ballotIdMap.put(BackupUtils.buildBallotUid(ballotModel), ballotModel.getId());
-						ballotOldIdMap.put(row.getInteger(Tags.TAG_BALLOT_ID), ballotModel.getId());
-					}
+					ballotIdMap.put(BackupUtils.buildBallotUid(ballotModel), ballotModel.getId());
+					ballotOldIdMap.put(row.getInteger(Tags.TAG_BALLOT_ID), ballotModel.getId());
+				}
 
-					LinkBallotModel ballotLinkModel = createLinkBallotModel(row, ballotModel.getId());
+				LinkBallotModel ballotLinkModel = createLinkBallotModel(row, ballotModel.getId());
 
-					if (writeToDb) {
-						if(ballotLinkModel == null) {
-							//link failed
-							logger.error("link failed");
-						}
-						if(ballotLinkModel instanceof GroupBallotModel) {
-							databaseServiceNew.getGroupBallotModelFactory().create(
-									(GroupBallotModel)ballotLinkModel
-									);
-						}
-						else if(ballotLinkModel instanceof IdentityBallotModel) {
-							databaseServiceNew.getIdentityBallotModelFactory().create(
-									(IdentityBallotModel)ballotLinkModel
-							);
-						}
-						else {
-							logger.error("not handled link");
-						}
+				if (writeToDb) {
+					if(ballotLinkModel == null) {
+						//link failed
+						logger.error("link failed");
 					}
-
-				} catch (Exception x) {
-					logger.error("Could not restore ballot", x);
-					if (writeToDb) {
-						//process next
-						restoreResult.incContactFailed();
+					if(ballotLinkModel instanceof GroupBallotModel) {
+						databaseServiceNew.getGroupBallotModelFactory().create(
+								(GroupBallotModel)ballotLinkModel
+								);
+					}
+					else if(ballotLinkModel instanceof IdentityBallotModel) {
+						databaseServiceNew.getIdentityBallotModelFactory().create(
+								(IdentityBallotModel)ballotLinkModel
+						);
+					}
+					else {
+						logger.error("not handled link");
 					}
 				}
+
+			} catch (Exception x) {
+				logger.error("Could not restore ballot", x);
+				if (writeToDb) {
+					//process next
+					restoreResult.incContactFailed();
+				}
 			}
 		});
 
-		this.processCsvFile(ballotChoice, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					BallotChoiceModel ballotChoiceModel = createBallotChoiceModel(row);
-					if (ballotChoiceModel != null && writeToDb) {
-						databaseServiceNew.getBallotChoiceModelFactory().create(
-								ballotChoiceModel
-						);
-						ballotChoiceIdMap.put(BackupUtils.buildBallotChoiceUid(ballotChoiceModel), ballotChoiceModel.getId());
-					}
-				} catch (Exception x) {
-					logger.error("Exception", x);
-					//continue!
+		this.processCsvFile(ballotChoice, row -> {
+			try {
+				BallotChoiceModel ballotChoiceModel = createBallotChoiceModel(row);
+				if (ballotChoiceModel != null && writeToDb) {
+					databaseServiceNew.getBallotChoiceModelFactory().create(
+							ballotChoiceModel
+					);
+					ballotChoiceIdMap.put(BackupUtils.buildBallotChoiceUid(ballotChoiceModel), ballotChoiceModel.getId());
 				}
+			} catch (Exception x) {
+				logger.error("Exception", x);
+				//continue!
 			}
 		});
 
-		this.processCsvFile(ballotVote, new ProcessCsvFile() {
-			@Override
-			public void row(CSVRow row) {
-				try {
-					BallotVoteModel ballotVoteModel = createBallotVoteModel(row);
-					if (ballotVoteModel != null && writeToDb) {
-						databaseServiceNew.getBallotVoteModelFactory().create(
-								ballotVoteModel
-						);
-					}
-				} catch (Exception x) {
-					logger.error("Exception", x);
-					//continue!
+		this.processCsvFile(ballotVote, row -> {
+			try {
+				BallotVoteModel ballotVoteModel = createBallotVoteModel(row);
+				if (ballotVoteModel != null && writeToDb) {
+					databaseServiceNew.getBallotVoteModelFactory().create(
+							ballotVoteModel
+					);
 				}
+			} catch (Exception x) {
+				logger.error("Exception", x);
+				//continue!
 			}
 		});
 	}
@@ -1122,14 +1132,14 @@ public class RestoreService extends Service {
 		String identity = null;
 
 		if(reference.endsWith("GroupBallotModel")) {
-			groupId = this.groupIdMap.get(referenceId);
+			groupId = this.groupUidMap.get(referenceId);
 		}
 		else if(reference.endsWith("IdentityBallotModel")) {
 			identity = referenceId;
 		}
 		else {
 			//first try to get the reference as group
-			groupId = this.groupIdMap.get(referenceId);
+			groupId = this.groupUidMap.get(referenceId);
 			if(groupId == null) {
 				if(referenceId != null && referenceId.length() == ProtocolDefines.IDENTITY_LEN) {
 					identity = referenceId;
@@ -1211,11 +1221,13 @@ public class RestoreService extends Service {
 			throw new ThreemaException(null);
 		}
 
-		final String identity = fileName.substring(Tags.MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
-		if (TestUtil.empty(identity)) {
+		final String identityId = fileName.substring(Tags.MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
+		if (TestUtil.empty(identityId)) {
 			throw new ThreemaException(null);
 		}
 
+		String identity = identityIdMap.get(identityId);
+
 		if (!this.processCsvFile(fileHeader, row -> {
 			try {
 				MessageModel messageModel = createMessageModel(row, restoreSettings);
@@ -1251,14 +1263,9 @@ public class RestoreService extends Service {
 		if(fileName == null) {
 			throw new ThreemaException(null);
 		}
-		String[] pieces = fileName.substring(Tags.GROUP_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX)).split("-");
-		if(pieces.length != 2) {
-			throw new ThreemaException(null);
-		}
-		final String apiId = pieces[0];
-		final String identity = pieces[1];
 
-		if (TestUtil.empty(apiId, identity)) {
+		final String groupUid = fileName.substring(Tags.GROUP_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
+		if (TestUtil.empty(groupUid)) {
 			throw new ThreemaException(null);
 		}
 
@@ -1269,14 +1276,8 @@ public class RestoreService extends Service {
 
 				if (writeToDb) {
 					updateProgress(STEP_SIZE_MESSAGES);
-
-					Integer groupId = null;
-
-					if(groupIdMap.containsKey(BackupUtils.buildGroupUid(apiId, identity))) {
-						groupId = groupIdMap.get(BackupUtils.buildGroupUid(apiId, identity));
-					}
-
-					if(groupId != null) {
+					Integer groupId = groupUidMap.get(groupUid);
+					if (groupId != null) {
 						groupMessageModel.setGroupId(groupId);
 						databaseServiceNew.getGroupMessageModelFactory().create(
 								groupMessageModel
@@ -1363,7 +1364,7 @@ public class RestoreService extends Service {
 	}
 
 	private List<GroupMemberModel> createGroupMembers(CSVRow row, int groupId) throws ThreemaException {
-		List<GroupMemberModel> res = new ArrayList<GroupMemberModel>();
+		List<GroupMemberModel> res = new ArrayList<>();
 		for(String identity: row.getStrings(Tags.TAG_GROUP_MEMBERS)) {
 			if(!TestUtil.empty(identity)) {
 				GroupMemberModel m = new GroupMemberModel();
@@ -1377,7 +1378,7 @@ public class RestoreService extends Service {
 	}
 
 	private List<DistributionListMemberModel> createDistributionListMembers(CSVRow row, long distributionListId) throws ThreemaException {
-		List<DistributionListMemberModel> res = new ArrayList<DistributionListMemberModel>();
+		List<DistributionListMemberModel> res = new ArrayList<>();
 		for(String identity: row.getStrings(Tags.TAG_DISTRIBUTION_MEMBERS)) {
 			if(!TestUtil.empty(identity)) {
 				DistributionListMemberModel m = new DistributionListMemberModel();
@@ -1420,6 +1421,11 @@ public class RestoreService extends Service {
 		if(restoreSettings.getVersion() >= 18) {
 			contactModel.setForwardSecurityEnabled(row.getBoolean(Tags.TAG_CONTACT_FORWARD_SECURITY));
 		}
+		if (restoreSettings.getVersion() >= 19) {
+			identityIdMap.put(row.getString(Tags.TAG_CONTACT_IDENTITY_ID), contactModel.getIdentity());
+		} else {
+			identityIdMap.put(contactModel.getIdentity(), contactModel.getIdentity());
+		}
 		contactModel.setIsRestored(true);
 
 		return contactModel;
@@ -1502,11 +1508,9 @@ public class RestoreService extends Service {
 		if(messageModel.getType() == MessageType.BALLOT) {
 			//try to update to new ballot id
 			BallotDataModel ballotData = messageModel.getBallotData();
-			if(ballotData != null) {
-				if(this.ballotOldIdMap.containsKey(ballotData.getBallotId())) {
-					BallotDataModel newBallotData = new BallotDataModel(ballotData.getType(), this.ballotOldIdMap.get(ballotData.getBallotId()));
-					messageModel.setBallotData(newBallotData);
-				}
+			if(this.ballotOldIdMap.containsKey(ballotData.getBallotId())) {
+				BallotDataModel newBallotData = new BallotDataModel(ballotData.getType(), this.ballotOldIdMap.get(ballotData.getBallotId()));
+				messageModel.setBallotData(newBallotData);
 			}
 		}
 		if(restoreSettings.getVersion() >= 2) {
@@ -1693,13 +1697,10 @@ public class RestoreService extends Service {
 			}
 			showRestoreErrorNotification(message);
 
-			new DeleteIdentityAsyncTask(null, new Runnable() {
-				@Override
-				public void run() {
-					isRunning = false;
+			new DeleteIdentityAsyncTask(null, () -> {
+				isRunning = false;
 
-					System.exit(0);
-				}
+				System.exit(0);
 			}).execute();
 		}
 	}

+ 10 - 5
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java

@@ -38,9 +38,10 @@ public class RestoreSettings {
 	 * 16: added read and delivered date
 	 * 17: group message states (ack / dec) and group descriptions
 	 * 18: contact forward security flag
+	 * 19: add random contact id
 	 */
-	public static final int CURRENT_VERSION = 18;
-	private int version = 1;
+	public static final int CURRENT_VERSION = 19;
+	private int version;
 
 	public RestoreSettings(int version) {
 		this.version = version;
@@ -50,6 +51,10 @@ public class RestoreSettings {
 		this(1);
 	}
 
+	public boolean isUnsupportedVersion() {
+		return version > CURRENT_VERSION;
+	}
+
 	public int getVersion() {
 		return this.version;
 	}
@@ -57,15 +62,15 @@ public class RestoreSettings {
 		for(String[] row: strings) {
 			if(row.length == 2) {
 				if(row[0].equals(Tags.TAG_INFO_VERSION)) {
-					this.version = Integer.valueOf(row[1]);
+					this.version = Integer.parseInt(row[1]);
 				}
 			}
 		}
 	}
 
 	public List<String[]> toList() {
-		List<String[]> l = new ArrayList<String[]>();
+		List<String[]> l = new ArrayList<>();
 		l.add(new String[]{Tags.TAG_INFO_VERSION, String.valueOf(this.version)});
 		return l;
 	}
-};
+}

+ 3 - 0
app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

@@ -35,6 +35,7 @@ public abstract class Tags {
 	public static final String MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "message_thumbnail_";
 	public static final String GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "group_message_thumbnail_";
 	public static final String CONTACT_AVATAR_FILE_PREFIX = "contact_avatar_";
+	public static final String CONTACT_AVATAR_FILE_SUFFIX_ME = "me";
 	public static final String CONTACT_PROFILE_PIC_FILE_PREFIX = "contact_profile_pic_";
 
 	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX = "distribution_list_message_media_";
@@ -61,6 +62,7 @@ public abstract class Tags {
 	public static final String TAG_CONTACT_HIDDEN = "hidden";
 	public static final String TAG_CONTACT_ARCHIVED = "archived";
 	public static final String TAG_CONTACT_FORWARD_SECURITY = "forward_security";
+	public static final String TAG_CONTACT_IDENTITY_ID = "identity_id"; // a unique ID representing the identity of a contact
 
 	public static final String TAG_GROUP_ID = "id";
 	public static final String TAG_GROUP_CREATOR = "creator";
@@ -71,6 +73,7 @@ public abstract class Tags {
 	public static final String TAG_GROUP_ARCHIVED = "archived";
 	public static final String TAG_GROUP_DESC = "groupDesc";
 	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
+	public static final String TAG_GROUP_UID = "group_uid";
 
 	public static final String TAG_MESSAGE_UID = "uid";
 	public static final String TAG_MESSAGE_IDENTITY = "identity";

+ 6 - 4
app/src/main/java/ch/threema/app/camera/CameraActivity.java

@@ -31,16 +31,17 @@ import android.view.KeyEvent;
 import android.view.View;
 import android.view.WindowManager;
 
-import com.google.common.util.concurrent.ListenableFuture;
-
-import org.slf4j.Logger;
-
 import androidx.annotation.Nullable;
 import androidx.camera.lifecycle.ProcessCameraProvider;
 import androidx.core.content.ContextCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
@@ -73,6 +74,7 @@ public class CameraActivity extends ThreemaAppCompatActivity implements CameraFr
 		getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 		getWindow().setStatusBarColor(Color.TRANSPARENT);
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			// we want dark icons, i.e. a light status bar

+ 25 - 34
app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java

@@ -35,7 +35,6 @@ import android.text.method.LinkMovementMethod;
 import android.text.util.Linkify;
 import android.view.View;
 import android.view.WindowManager;
-import android.widget.CompoundButton;
 import android.widget.EditText;
 import android.widget.TextView;
 
@@ -44,6 +43,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.android.material.textfield.TextInputEditText;
 import com.google.android.material.textfield.TextInputLayout;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
@@ -73,7 +73,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	                                              @StringRes int positive, @StringRes int negative,
 	                                              int minLength, int maxLength,
 	                                              int confirmHint, int inputType, int checkboxText,
-	                                              ForgotHintType showForgotPwHint ) {
+	                                              ForgotHintType showForgotPwHint) {
 		PasswordEntryDialog dialog = new PasswordEntryDialog();
 		Bundle args = new Bundle();
 		args.putInt("title", title);
@@ -149,12 +149,13 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	}
 
 	@Override
-	public void onAttach(Activity activity) {
+	public void onAttach(@NonNull Activity activity) {
 		super.onAttach(activity);
 
 		this.activity = activity;
 	}
 
+	@NonNull
 	@Override
 	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
 		if (savedInstanceState != null && alertDialog != null) {
@@ -168,7 +169,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 		int negative = getArguments().getInt("negative");
 		int inputType = getArguments().getInt("inputType", 0);
 		minLength = getArguments().getInt("minLength", 0);
-		 maxLength = getArguments().getInt("maxLength", 0);
+		maxLength = getArguments().getInt("maxLength", 0);
 		final int confirmHint = getArguments().getInt("confirmHint", 0);
 		final int checkboxText = getArguments().getInt("checkboxText", 0);
 		final int checkboxConfirmText = getArguments().getInt("checkboxConfirmText", 0);
@@ -188,9 +189,6 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 		final TextInputLayout editText2Layout = dialogView.findViewById(R.id.password2layout);
 		checkBox = dialogView.findViewById(R.id.check_box);
 
-//		editText1.setCustomSelectionActionModeCallback(deadEndCallback);
-//		editText2.setCustomSelectionActionModeCallback(deadEndCallback);
-
 		editText1.addTextChangedListener(new PasswordWatcher(editText1, editText2));
 		editText2.addTextChangedListener(new PasswordWatcher(editText1, editText2));
 
@@ -228,15 +226,12 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 			checkBox.setText(checkboxText);
 
 			if (checkboxConfirmText != 0) {
-				checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
-					@Override
-					public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
-						if (isChecked) {
-							DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_CONFIRM_CHECKBOX, true);
-							GenericAlertDialog genericAlertDialog = GenericAlertDialog.newInstance(title, checkboxConfirmText, R.string.ok, R.string.cancel);
-							genericAlertDialog.setTargetFragment(PasswordEntryDialog.this, 0);
-							genericAlertDialog.show(getFragmentManager(), DIALOG_TAG_CONFIRM_CHECKBOX);
-						}
+				checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+					if (isChecked) {
+						DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_CONFIRM_CHECKBOX, true);
+						GenericAlertDialog genericAlertDialog = GenericAlertDialog.newInstance(title, checkboxConfirmText, R.string.ok, R.string.cancel);
+						genericAlertDialog.setTargetFragment(this, 0);
+						genericAlertDialog.show(getFragmentManager(), DIALOG_TAG_CONFIRM_CHECKBOX);
 					}
 				});
 			}
@@ -280,22 +275,17 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 
 		builder.setView(dialogView);
 
-		builder.setPositiveButton(getString(positive), new DialogInterface.OnClickListener() {
-					public void onClick(DialogInterface dialog, int whichButton) {
-						if (checkboxText != 0) {
-							callback.onYes(tag, editText1.getText().toString(), checkBox.isChecked(), object);
-						} else {
-							callback.onYes(tag, editText1.getText().toString(), false, object);
-						}
-					}
-				}
-		);
-		builder.setNegativeButton(getString(negative), new DialogInterface.OnClickListener() {
-							public void onClick(DialogInterface dialog, int whichButton) {
-								callback.onNo(tag);
-							}
-						}
-				);
+		builder.setPositiveButton(getString(positive), (dialog, whichButton) -> {
+			if (checkboxText != 0) {
+				callback.onYes(tag, editText1.getText().toString(), checkBox.isChecked(), object);
+			} else {
+				callback.onYes(tag, editText1.getText().toString(), false, object);
+			}
+		});
+		builder.setNegativeButton(getString(negative), (dialog, whichButton) -> callback.onNo(tag));
+
+		builder.setBackgroundInsetTop(getResources().getDimensionPixelSize(R.dimen.dialog_inset_top_bottom));
+		builder.setBackgroundInsetBottom(getResources().getDimensionPixelSize(R.dimen.dialog_inset_top_bottom));
 
 		alertDialog = builder.create();
 		alertDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
@@ -303,12 +293,13 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	}
 
 	@Override
-	public void onCancel(DialogInterface dialogInterface) {
+	public void onCancel(@NonNull DialogInterface dialogInterface) {
 		callback.onNo(this.getTag());
 	}
 
 	public class PasswordWatcher implements TextWatcher {
-		private EditText password1, password2;
+		private final EditText password1;
+		private final EditText password2;
 
 		public PasswordWatcher(final EditText password1, final EditText password2) {
 			this.password1 = password1;

+ 25 - 1
app/src/main/java/ch/threema/app/dialogs/TextWithCheckboxDialog.java

@@ -27,6 +27,7 @@ import android.view.View;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
+import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.AppCompatDialog;
@@ -46,11 +47,27 @@ public class TextWithCheckboxDialog extends ThreemaDialogFragment {
 	private final static String CHECKBOX_LABEL_KEY = "checkboxLabel";
 	private final static String POSITIVE_KEY = "positive";
 	private final static String NEGATIVE_KEY = "negative";
+	private static final String ARG_ICON = "icon";
+
 
 	public interface TextWithCheckboxDialogClickListener {
 		void onYes(String tag, Object data, boolean checked);
 	}
 
+	public static TextWithCheckboxDialog newInstance(String title, @DrawableRes int icon, @NonNull String message, @StringRes int checkboxLabel, @StringRes int positive, @StringRes int negative) {
+		TextWithCheckboxDialog dialog = new TextWithCheckboxDialog();
+		Bundle args = new Bundle();
+		args.putString(TITLE_KEY, title);
+		args.putString(MESSAGE_KEY, message);
+		args.putInt(CHECKBOX_LABEL_KEY, checkboxLabel);
+		args.putInt(POSITIVE_KEY, positive);
+		args.putInt(NEGATIVE_KEY, negative);
+		args.putInt(ARG_ICON, icon);
+
+		dialog.setArguments(args);
+		return dialog;
+	}
+
 	public static TextWithCheckboxDialog newInstance(String title, @StringRes int messageRes, @StringRes int checkboxLabel, @StringRes int positive, @StringRes int negative) {
 		TextWithCheckboxDialog dialog = new TextWithCheckboxDialog();
 		Bundle args = new Bundle();
@@ -121,6 +138,7 @@ public class TextWithCheckboxDialog extends ThreemaDialogFragment {
 		@StringRes int checkboxLabel = getArguments().getInt(CHECKBOX_LABEL_KEY);
 		@StringRes int positive = getArguments().getInt(POSITIVE_KEY);
 		@StringRes int negative = getArguments().getInt(NEGATIVE_KEY);
+		@DrawableRes int icon = getArguments().getInt(ARG_ICON, 0);
 
 		final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_text_with_checkbox, null);
 		final AppCompatCheckBox checkbox = dialogView.findViewById(R.id.checkbox);
@@ -133,13 +151,19 @@ public class TextWithCheckboxDialog extends ThreemaDialogFragment {
 			.setNegativeButton(negative, null)
 			.setPositiveButton(positive, (dialog, which) -> callback.onYes(tag, object, checkbox.isChecked()));
 
+		if (icon != 0) {
+			builder.setIcon(icon);
+		}
+
 		if (messageRes != 0) {
-			checkbox.setTextSize(14);
 			builder.setMessage(messageRes);
+		} else if (message != null) {
+			builder.setMessage(message);
 		}
 
 		checkbox.setChecked(false);
 		if (checkboxLabel != 0) {
+			checkbox.setTextSize(14);
 			checkbox.setText(checkboxLabel);
 		} else {
 			checkbox.setVisibility(View.GONE);

+ 4 - 1
app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java

@@ -241,7 +241,7 @@ public class EmojiMarkupUtil {
 	/**
 	 * Replace mentions by text instead of spans (used where spans cannot be displayed, i.e. in notifications)
 	 * @param inputText
-	 * @return ChatSequence where all mentions have been replaced by contact names
+	 * @return ChatSequence where all mentions have been replaced by contact names or ids
 	 */
 	private CharSequence applyTextMentionMarkup(CharSequence inputText) {
 		String match, identity;
@@ -254,6 +254,9 @@ public class EmojiMarkupUtil {
 			String quoteName = NameUtil.getQuoteName(identity, getContactService(), getUserService());
 
 			if (TestUtil.empty(quoteName)) {
+				// Note that the quote name is only empty if there went something wrong while
+				// accessing the contact. If the contact is unknown, the quote name consists of its
+				// threema id.
 				outputText = TextUtils.replace(outputText, new String[]{match}, new CharSequence[]{""});
 			} else {
 				outputText = TextUtils.replace(outputText, new String[]{match}, new CharSequence[]{MENTION_INDICATOR +

+ 80 - 65
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -694,25 +694,23 @@ public class ComposeMessageFragment extends Fragment implements
 		@Override
 		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 			updateToolBarTitleInUIThread();
-
-			if (!groupService.isGroupMember(group)) {
-				// Remove ongoing group call notice if not a member of the group anymore
-				RuntimeUtil.runOnUiThread(() -> {
-					updateOngoingGroupCallState(null);
-					removeGroupCallObserver();
-				});
-			}
 		}
 
 		@Override
 		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
 			updateToolBarTitleInUIThread();
+
+			if (userService.isMe(identity)) {
+				updateGroupCallObserverRegistration();
+			}
 		}
 
 
 		@Override
 		public void onUpdate(GroupModel groupModel) {
 			updateToolBarTitleInUIThread();
+
+			updateGroupCallObserverRegistration();
 		}
 
 		@Override
@@ -721,6 +719,16 @@ public class ComposeMessageFragment extends Fragment implements
 				RuntimeUtil.runOnUiThread(() -> finishActivity());
 			}
 		}
+
+		private void updateGroupCallObserverRegistration() {
+			if (groupService.isGroupMember(groupModel)) {
+				registerGroupCallObserver();
+			} else {
+				// Remove ongoing group call notice if not a member of the group anymore
+				RuntimeUtil.runOnUiThread(() -> updateOngoingGroupCallState(null));
+				removeGroupCallObserver();
+			}
+		}
 	};
 
 	private final ContactListener contactListener = new ContactListener() {
@@ -824,14 +832,11 @@ public class ComposeMessageFragment extends Fragment implements
 
 	private final QRCodeScanListener qrCodeScanListener = new QRCodeScanListener() {
 		@Override
-		public void onScanCompleted(String scanResult) {
+		public void onScanCompleted(final String scanResult) {
 			if (scanResult != null && scanResult.length() > 0) {
-				RuntimeUtil.runOnUiThread(() -> {
-					if (messageText != null) {
-						messageText.setText(scanResult);
-						messageText.setSelection(messageText.length());
-					}
-				});
+				if (messageReceiver != null) {
+					ThreemaApplication.putMessageDraft(messageReceiver.getUniqueIdString(), scanResult, null);
+				}
 			}
 		}
 	};
@@ -1082,21 +1087,24 @@ public class ComposeMessageFragment extends Fragment implements
 		return this.fragmentView;
 	}
 
+	@UiThread
 	private void initOngoingCallState() {
 		ongoingCallNoticeView = fragmentView.findViewById(R.id.ongoing_call_notice);
-		if (ongoingCallNoticeView != null && groupModel != null && groupService.isGroupMember(groupModel)) {
-			ongoingCallNoticeView.setButtonAction(() -> {
-				if (groupModel != null) {
-					GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
-					if (call != null) {
-						startActivity(GroupCallActivity.getStartOrJoinCallIntent(requireActivity(), groupModel.getId()));
-					}
-				}
-			});
+		if (ongoingCallNoticeView != null) {
+			ongoingCallNoticeView.setButtonAction(this::joinOngoingGroupCall);
 			ongoingCallNoticeView.setContainerAction(null);
-			registerGroupCallObserver();
-		} else if (ongoingCallNoticeView != null) {
-			updateOngoingGroupCallState(null);
+
+			if (groupModel != null && groupService.isGroupMember(groupModel)) {
+				registerGroupCallObserver();
+			} else {
+				updateOngoingGroupCallState(null);
+			}
+		}
+	}
+
+	private void joinOngoingGroupCall() {
+		if (groupModel != null) {
+			startActivity(GroupCallActivity.getJoinCallIntent(requireActivity(), groupModel.getId()));
 		}
 	}
 
@@ -1118,7 +1126,7 @@ public class ComposeMessageFragment extends Fragment implements
 				}
 
 				@Override
-				public void onGroupCallStart(@NonNull GroupModel groupModel, @Nullable GroupCallDescription call) {
+				public void onGroupCallStart(@NonNull GroupModel groupModel) {
 					// noop
 				}
 			};
@@ -1266,7 +1274,9 @@ public class ComposeMessageFragment extends Fragment implements
 
 			// update menus
 			updateMuteMenu();
-			updateGroupCallMenuItem();
+			if (isGroupChat) {
+				updateGroupCallMenuItem();
+			}
 
 			// start media players again
 			this.messagePlayerService.resumeAll(getActivity(), this.messageReceiver, SOURCE_LIFECYCLE);
@@ -2122,7 +2132,7 @@ public class ComposeMessageFragment extends Fragment implements
 		this.openBallotNoticeView.setVisibilityListener(this);
 
 		// restore draft before setting predefined text
-		restoreMessageDraft();
+		restoreMessageDraft(false);
 
 		String defaultText = intent.getStringExtra(ThreemaApplication.INTENT_DATA_TEXT);
 		if (!TestUtil.empty(defaultText)) {
@@ -3854,8 +3864,11 @@ public class ComposeMessageFragment extends Fragment implements
 				this.messagePlayerService.resumeAll(getActivity(), messageReceiver, SOURCE_AUDIORECORDER);
 			}
 		}
-		if (requestCode == ThreemaActivity.ACTIVITY_ID_ATTACH_MEDIA && resultCode == Activity.RESULT_OK) {
-			this.lastMediaFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
+		if (requestCode == ThreemaActivity.ACTIVITY_ID_ATTACH_MEDIA) {
+			restoreMessageDraft(true);
+			if (resultCode == Activity.RESULT_OK) {
+				this.lastMediaFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
+			}
 		}
 	}
 
@@ -4464,34 +4477,36 @@ public class ComposeMessageFragment extends Fragment implements
 	}
 
 	protected void instantiate() {
-		ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
-		this.preferenceService = serviceManager.getPreferenceService();
-		try {
-			this.userService = serviceManager.getUserService();
-			this.contactService = serviceManager.getContactService();
-			this.groupService = serviceManager.getGroupService();
-			this.groupCallManager = serviceManager.getGroupCallManager();
-			this.messageService = serviceManager.getMessageService();
-			this.fileService = serviceManager.getFileService();
-			this.notificationService = serviceManager.getNotificationService();
-			this.distributionListService = serviceManager.getDistributionListService();
-			this.messagePlayerService = serviceManager.getMessagePlayerService();
-			this.blackListIdentityService = serviceManager.getBlackListService();
-			this.ballotService = serviceManager.getBallotService();
-			this.databaseServiceNew = serviceManager.getDatabaseServiceNew();
-			this.conversationService = serviceManager.getConversationService();
-			this.deviceService =serviceManager.getDeviceService();
-			this.wallpaperService = serviceManager.getWallpaperService();
-			this.wallpaperLauncher = wallpaperService.getWallpaperActivityResultLauncher(this, this::setBackgroundWallpaper, () -> this.messageReceiver);
-			this.mutedChatsListService = serviceManager.getMutedChatsListService();
-			this.mentionOnlyChatsListService = serviceManager.getMentionOnlyChatsListService();
-			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
-			this.ringtoneService = serviceManager.getRingtoneService();
-			this.voipStateService = serviceManager.getVoipStateService();
-			this.downloadService = serviceManager.getDownloadService();
-			this.licenseService = serviceManager.getLicenseService();
-		} catch (Exception e) {
-			LogUtil.exception(e, activity);
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+		if (serviceManager != null) {
+			this.preferenceService = serviceManager.getPreferenceService();
+			try {
+				this.userService = serviceManager.getUserService();
+				this.contactService = serviceManager.getContactService();
+				this.groupService = serviceManager.getGroupService();
+				this.groupCallManager = serviceManager.getGroupCallManager();
+				this.messageService = serviceManager.getMessageService();
+				this.fileService = serviceManager.getFileService();
+				this.notificationService = serviceManager.getNotificationService();
+				this.distributionListService = serviceManager.getDistributionListService();
+				this.messagePlayerService = serviceManager.getMessagePlayerService();
+				this.blackListIdentityService = serviceManager.getBlackListService();
+				this.ballotService = serviceManager.getBallotService();
+				this.databaseServiceNew = serviceManager.getDatabaseServiceNew();
+				this.conversationService = serviceManager.getConversationService();
+				this.deviceService =serviceManager.getDeviceService();
+				this.wallpaperService = serviceManager.getWallpaperService();
+				this.wallpaperLauncher = wallpaperService.getWallpaperActivityResultLauncher(this, this::setBackgroundWallpaper, () -> this.messageReceiver);
+				this.mutedChatsListService = serviceManager.getMutedChatsListService();
+				this.mentionOnlyChatsListService = serviceManager.getMentionOnlyChatsListService();
+				this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
+				this.ringtoneService = serviceManager.getRingtoneService();
+				this.voipStateService = serviceManager.getVoipStateService();
+				this.downloadService = serviceManager.getDownloadService();
+				this.licenseService = serviceManager.getLicenseService();
+			} catch (Exception e) {
+				LogUtil.exception(e, activity);
+			}
 		}
 	}
 
@@ -4638,8 +4653,8 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
-	private void restoreMessageDraft() {
-		if (this.messageReceiver != null && this.messageText != null && TestUtil.empty(this.messageText.getText())) {
+	private void restoreMessageDraft(boolean force) {
+		if (this.messageReceiver != null && this.messageText != null && (force || TestUtil.empty(this.messageText.getText()))) {
 			String messageDraft = ThreemaApplication.getMessageDraft(this.messageReceiver.getUniqueIdString());
 
 			if (!TextUtils.isEmpty(messageDraft)) {
@@ -4695,11 +4710,11 @@ public class ComposeMessageFragment extends Fragment implements
 		if (isEmojiPickerShown()) {
 			emojiPicker.onKeyboardShown();
 		}
-		if (isResumed() && !emojiPicker.isShown() && messageText != null && !messageText.hasFocus()) {
+		if (isResumed() && !emojiPicker.isShown() && searchActionMode == null && messageText != null && !messageText.hasFocus()) {
 			// In some cases when the activity is launched where the previous activity finished with
 			// an open keyboard, the messageText does not have focus even if the keyboard is shown
-			// Only request focus if the emoji picker is hidden, otherwise the keyboard is needed to
-			// search emojis.
+			// Only request focus if the emoji picker is hidden and the search bar is not shown,
+			// otherwise the keyboard is needed to search emojis or the chat.
 			messageText.requestFocus();
 		}
 	}

+ 85 - 51
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -24,7 +24,6 @@ package ch.threema.app.fragments;
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
-import android.app.AlertDialog;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -46,7 +45,6 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.AbsListView;
 import android.widget.AdapterView;
-import android.widget.CheckBox;
 import android.widget.FrameLayout;
 import android.widget.ListView;
 import android.widget.Toast;
@@ -85,8 +83,10 @@ import ch.threema.app.asynctasks.DeleteContactAsyncTask;
 import ch.threema.app.asynctasks.EmptyChatAsyncTask;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetGridDialog;
+import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.dialogs.TextWithCheckboxDialog;
+import ch.threema.app.dialogs.ThreemaDialogFragment;
 import ch.threema.app.emojis.EmojiTextView;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.listeners.ContactListener;
@@ -132,7 +132,6 @@ import static android.view.MenuItem.SHOW_AS_ACTION_ALWAYS;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_NEVER;
 import static ch.threema.app.ThreemaApplication.WORKER_WORK_SYNC;
-import static ch.threema.app.ThreemaApplication.getAppContext;
 
 public class ContactsSectionFragment
 		extends MainFragment
@@ -142,12 +141,15 @@ public class ContactsSectionFragment
 		ContactListAdapter.AvatarListener,
 		SelectorDialog.SelectorDialogClickListener,
 		BottomSheetAbstractDialog.BottomSheetDialogCallback,
-		TextWithCheckboxDialog.TextWithCheckboxDialogClickListener {
+		TextWithCheckboxDialog.TextWithCheckboxDialogClickListener,
+		GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSectionFragment");
 
 	private static final int PERMISSION_REQUEST_REFRESH_CONTACTS = 1;
 	private static final String DIALOG_TAG_SHARE_WITH = "wsw";
 	private static final String DIALOG_TAG_RECENTLY_ADDED_SELECTOR = "ras";
+	private static final String DIALOG_TAG_REALLY_DELETE_CONTACTS = "rdc";
+	private static final String DIALOG_TAG_REPORT_SPAM = "spam";
 
 	private static final String RUN_ON_ACTIVE_SHOW_LOADING = "show_loading";
 	private static final String RUN_ON_ACTIVE_HIDE_LOADING = "hide_loading";
@@ -1260,20 +1262,27 @@ public class ContactsSectionFragment
 	private void deleteContacts(@NonNull Set<ContactModel> contacts) {
 		int contactsSelectedToDelete = contacts.size();
 		final String deleteContactTitle = getString(contactsSelectedToDelete > 1 ? R.string.delete_multiple_contact_action : R.string.delete_contact_action);
+		final String message = String.format(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.really_delete_contacts_message, contactsSelectedToDelete, contactsSelectedToDelete), contactListAdapter.getCheckedItemCount());
 
-		final View view = View.inflate(getAppContext(), R.layout.dialog_checkbox, null);
-		final CheckBox checkBox = view.findViewById(R.id.dialog_checkbox);
-		checkBox.setText(getString(R.string.exclude_contact));
-
-		AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
-		builder.setTitle(deleteContactTitle);
-		builder.setMessage(String.format(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.really_delete_contacts_message, contactsSelectedToDelete, contactsSelectedToDelete), contactListAdapter.getCheckedItemCount()));
+		ThreemaDialogFragment dialog;
 		if (showExcludeFromContactSync(contacts)) {
-			builder.setView(view);
-		}
-		builder.setPositiveButton(R.string.ok, (dialog1, which) -> reallyDeleteContacts(contacts, checkBox.isChecked()));
-		builder.setNegativeButton(R.string.cancel, null);
-		builder.show();
+			dialog = TextWithCheckboxDialog.newInstance(
+				deleteContactTitle,
+				0,
+				message,
+				R.string.exclude_contact,
+				R.string.ok,
+				R.string.cancel);
+		} else {
+			dialog = GenericAlertDialog.newInstance(
+				deleteContactTitle,
+				message,
+				R.string.ok,
+				R.string.cancel);
+		}
+		dialog.setTargetFragment(this, 0);
+		dialog.setData(contacts);
+		dialog.show(getFragmentManager(), DIALOG_TAG_REALLY_DELETE_CONTACTS);
 	}
 
 	@SuppressLint("StaticFieldLeak")
@@ -1417,7 +1426,7 @@ public class ContactsSectionFragment
 					R.string.spam_report_dialog_block_checkbox, R.string.spam_report_short, R.string.cancel);
 				sdialog.setData(contactModel);
 				sdialog.setTargetFragment(this, 0);
-				sdialog.show(getParentFragmentManager(), "");
+				sdialog.show(getParentFragmentManager(), DIALOG_TAG_REPORT_SPAM);
 				break;
 			case SELECTOR_TAG_BLOCK:
 				serviceManager.getBlackListService().toggle(getActivity(), contactModel);
@@ -1435,44 +1444,69 @@ public class ContactsSectionFragment
 	public void onNo(String tag) {}
 
 	/* callback from TextWithCheckboxDialog */
-
 	@Override
 	public void onYes(String tag, Object data, boolean checked) {
-		ContactModel contactModel = (ContactModel) data;
-
-		contactService.reportSpam(contactModel,
-			unused -> {
-				if (isAdded()) {
-					Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
-				}
+		switch(tag) {
+			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
+				reallyDeleteContacts((Set<ContactModel>) data, checked);
+				break;
+			case DIALOG_TAG_REPORT_SPAM:
+				ContactModel contactModel = (ContactModel) data;
 
-				if (checked) {
-					ThreemaApplication.requireServiceManager().getBlackListService().add(contactModel.getIdentity());
-					ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(contactModel.getIdentity());
+				contactService.reportSpam(contactModel,
+					unused -> {
+						if (isAdded()) {
+							Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
+						}
 
-					try {
-						new EmptyChatAsyncTask(
-							contactService.createReceiver(contactModel),
-							ThreemaApplication.requireServiceManager().getMessageService(),
-							ThreemaApplication.requireServiceManager().getConversationService(),
-							null,
-							true,
-							() -> {
-								ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
-								ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
-							}).execute();
-					} catch (Exception e) {
-						logger.error("Unable to empty chat", e);
+						if (checked) {
+							ThreemaApplication.requireServiceManager().getBlackListService().add(contactModel.getIdentity());
+							ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(contactModel.getIdentity());
+
+							try {
+								new EmptyChatAsyncTask(
+									contactService.createReceiver(contactModel),
+									ThreemaApplication.requireServiceManager().getMessageService(),
+									ThreemaApplication.requireServiceManager().getConversationService(),
+									null,
+									true,
+									() -> {
+										ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
+										ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+									}).execute();
+							} catch (Exception e) {
+								logger.error("Unable to empty chat", e);
+							}
+						} else {
+							ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+						}
+					},
+					message -> {
+						if (isAdded()) {
+							Toast.makeText(getContext(), requireContext().getString(R.string.spam_error_reporting, message), Toast.LENGTH_LONG).show();
+						}
 					}
-				} else {
-					ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
-				}
-			},
-			message -> {
-				if (isAdded()) {
-					Toast.makeText(getContext(), requireContext().getString(R.string.spam_error_reporting, message), Toast.LENGTH_LONG).show();
-				}
-			}
-		);
+				);
+				break;
+			default:
+				break;
+		}
 	}
+
+	/**
+	 * Callbacks from GenericAlertDialog
+	 */
+	@Override
+	public void onYes(String tag, Object data) {
+		switch(tag) {
+			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
+				reallyDeleteContacts((Set<ContactModel>) data, false);
+				break;
+			default:
+				break;
+		}
+	}
+
+	@Override
+	public void onNo(String tag, Object data) {	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -1114,7 +1114,7 @@ public class MessageSectionFragment extends MainFragment
 	public void onJoinGroupCallClick(ConversationModel conversationModel) {
 		GroupModel group = conversationModel.getGroup();
 		if (group != null) {
-			startActivity(GroupCallActivity.getStartOrJoinCallIntent(requireActivity(), group.getId()));
+			startActivity(GroupCallActivity.getJoinCallIntent(requireActivity(), group.getId()));
 		}
 	}
 

+ 5 - 1
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -1077,7 +1077,11 @@ public class ServiceManager {
 
 	public @NonNull SfuConnection getSfuConnection() {
 		if (sfuConnection == null) {
-			sfuConnection = new SfuConnectionImpl(getAPIConnector(), getIdentityStore());
+			sfuConnection = new SfuConnectionImpl(
+				getAPIConnector(),
+				getIdentityStore(),
+				ThreemaApplication.getAppVersion()
+			);
 		}
 		return sfuConnection;
 	}

+ 5 - 0
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -93,6 +93,7 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
+import static ch.threema.app.ThreemaApplication.getMessageDraft;
 import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
 
@@ -579,6 +580,10 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 	public void onEdit(final List<Uri> uriList, boolean asFiles) {
 		ArrayList<MediaItem> mediaItems = MediaItem.getFromUris(uriList, this, asFiles);
 		if (mediaItems.size() > 0) {
+			if (getMessageDraft(messageReceiver.getUniqueIdString()) != null) {
+				mediaItems.get(0).setCaption(getMessageDraft(messageReceiver.getUniqueIdString()));
+			}
+
 			Intent intent = IntentDataUtil.addMessageReceiversToIntent(new Intent(this, SendMediaActivity.class), new MessageReceiver[]{this.messageReceiver});
 			intent.putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems);
 			intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, messageReceiver.getDisplayName());

+ 1 - 1
app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java

@@ -219,7 +219,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 				.setFileSize(modelFileData.getFileSize())
 				.setFileName(modelFileData.getFileName())
 				.setRenderingType(modelFileData.getRenderingType())
-				.setDescription(modelFileData.getCaption())
+				.setCaption(modelFileData.getCaption())
 				.setCorrelationId(messageModel.getCorrelationId())
 				.setMetaData(modelFileData.getMetaData());
 

+ 1 - 1
app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java

@@ -200,7 +200,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 					.setFileSize(modelFileData.getFileSize())
 					.setFileName(modelFileData.getFileName())
 					.setRenderingType(modelFileData.getRenderingType())
-					.setDescription(modelFileData.getCaption())
+					.setCaption(modelFileData.getCaption())
 					.setCorrelationId(messageModel.getCorrelationId())
 					.setMetaData(modelFileData.getMetaData());
 

+ 5 - 4
app/src/main/java/ch/threema/app/notifications/BackgroundErrorNotification.java

@@ -38,6 +38,7 @@ import ch.threema.app.receivers.SendTextToContactBroadcastReceiver;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LocaleUtil;
 
+import static ch.threema.app.preference.SettingsTroubleshootingFragment.THREEMA_SUPPORT_IDENTITY;
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
 import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
 
@@ -94,12 +95,12 @@ public class BackgroundErrorNotification {
 			supportChannelText.append("Hello Threema Support!\n\nAn error occurred in ").append(scope).append(":");
 			supportChannelText.append(separator).append(text).append(separator);
 			if (exception != null) {
-				supportChannelText.append(exception.toString()).append(separator);
+				supportChannelText.append(exception).append(separator);
 			}
 			supportChannelText.append("My phone model: ")
-				.append(ConfigUtils.getDeviceInfo(appContext, false));
+				.append(ConfigUtils.getSupportDeviceInfo(appContext));
 			supportChannelText.append("\nMy app version: ")
-				.append(ConfigUtils.getFullAppVersion(appContext));
+				.append(ConfigUtils.getAppVersion(appContext));
 			supportChannelText.append("\nMy app language: ")
 				.append(LocaleUtil.getAppLanguage());
 
@@ -108,7 +109,7 @@ public class BackgroundErrorNotification {
 			replyIntent.putExtra(EXTRA_TEXT_TO_SEND, supportChannelText.toString());
 			replyIntent.putExtra(EXTRA_NOTIFICATION_ID, NotificationIDs.BACKGROUND_ERROR);
 
-			replyIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, "*SUPPORT");
+			replyIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, THREEMA_SUPPORT_IDENTITY);
 
 			PendingIntent pendingIntent = PendingIntent.getBroadcast(
 				appContext,

+ 9 - 1
app/src/main/java/ch/threema/app/notifications/NotificationBuilderWrapper.java

@@ -369,6 +369,13 @@ public class NotificationBuilderWrapper extends NotificationCompat.Builder {
 			return false;
 		}
 
+		try {
+			logger.info("Audio Attributes Usage: {} -> {}", existingNotificationChannel.getAudioAttributes().getUsage(), newNotificationChannel.getAudioAttributes().getUsage());
+			if (existingNotificationChannel.getAudioAttributes().getUsage() != newNotificationChannel.getAudioAttributes().getUsage()) {
+				return false;
+			}
+		} catch (Exception e) { /* */ }
+
 		try {
 			logger.info("Vibrate: {} -> {}", existingNotificationChannel.shouldVibrate(), newNotificationChannel.shouldVibrate());
 		} catch (Exception e) { /* */ }
@@ -427,7 +434,7 @@ public class NotificationBuilderWrapper extends NotificationCompat.Builder {
 	 */
 	@TargetApi(Build.VERSION_CODES.O)
 	private static NotificationChannel createNotificationChannel(String hash, NotificationChannelSettings notificationChannelSettings) {
-		AudioAttributes audioAttributes = SoundUtil.getAudioAttributesForUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT);
+		AudioAttributes audioAttributes = SoundUtil.getAudioAttributesForUsage(AudioAttributes.USAGE_NOTIFICATION);
 
 		@SuppressLint("WrongConstant") NotificationChannel newNotificationChannel = new NotificationChannel(
 				hash, hash.substring(0, 16),
@@ -463,6 +470,7 @@ public class NotificationBuilderWrapper extends NotificationCompat.Builder {
 		return newNotificationChannel;
 	}
 
+	@NonNull
 	@Override
 	public Notification build() {
 		if (notificationSchema != null && !ConfigUtils.supportsNotificationChannels()) {

+ 7 - 3
app/src/main/java/ch/threema/app/preference/SettingsTroubleshootingFragment.java

@@ -447,7 +447,7 @@ public class SettingsTroubleshootingFragment extends ThreemaPreferenceFragment i
 				value = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__disable_calls));
 			}
 
-			if (value != null) {
+			if (value != null && value) {
 				PreferenceCategory preferenceCategory = findPreference("pref_key_voip");
 				if (preferenceCategory != null) {
 					preferenceScreen.removePreference(preferenceCategory);
@@ -478,7 +478,11 @@ public class SettingsTroubleshootingFragment extends ThreemaPreferenceFragment i
 	private void updateReadPhoneStatePermissionPref() {
 		Context context = getContext();
 
-		Preference phonePref = getPref(R.string.preferences__grant_read_phone_state_permission);
+		Preference phonePref = getPrefOrNull(R.string.preferences__grant_read_phone_state_permission);
+		if (phonePref == null) {
+			// This preference is not available if th_disable_calls is set to true
+			return;
+		}
 		if (context != null && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
 			phonePref.setEnabled(false);
 		} else {
@@ -762,7 +766,7 @@ public class SettingsTroubleshootingFragment extends ThreemaPreferenceFragment i
 
 					messageService.sendText(caption +
 						"\n-- \n" +
-						ConfigUtils.getDeviceInfo(getActivity(), false) + "\n" +
+						ConfigUtils.getSupportDeviceInfo(getActivity()) + "\n" +
 						getVersionString() + "\n" +
 						userService.getIdentity(), receiver);
 

+ 0 - 3
app/src/main/java/ch/threema/app/processors/MessageProcessor.java

@@ -49,7 +49,6 @@ import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
-import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.coders.MessageBox;
@@ -59,7 +58,6 @@ import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage;
 import ch.threema.domain.protocol.csp.messages.AbstractMessage;
 import ch.threema.domain.protocol.csp.messages.BadMessageException;
-import ch.threema.domain.protocol.csp.messages.BoxTextMessage;
 import ch.threema.domain.protocol.csp.messages.ContactDeletePhotoMessage;
 import ch.threema.domain.protocol.csp.messages.ContactRequestPhotoMessage;
 import ch.threema.domain.protocol.csp.messages.ContactSetPhotoMessage;
@@ -71,7 +69,6 @@ import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage;
 import ch.threema.domain.protocol.csp.messages.GroupRenameMessage;
 import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage;
 import ch.threema.domain.protocol.csp.messages.GroupSetPhotoMessage;
-import ch.threema.domain.protocol.csp.messages.GroupTextMessage;
 import ch.threema.domain.protocol.csp.messages.MissingPublicKeyException;
 import ch.threema.domain.protocol.csp.messages.TypingIndicatorMessage;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVoteInterface;

+ 3 - 1
app/src/main/java/ch/threema/app/routines/UpdateWorkInfoRoutine.java

@@ -29,6 +29,7 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.AppRestrictionService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.license.LicenseServiceUser;
@@ -105,7 +106,8 @@ public class UpdateWorkInfoRoutine implements Runnable {
 						mdmFirstName,
 						mdmLastName,
 						mdmCSI,
-						mdmCategory
+						mdmCategory,
+						AppRestrictionService.getInstance().getMdmSource()
 				)) {
 					logger.debug("work info successfully updated");
 				}

+ 27 - 2
app/src/main/java/ch/threema/app/services/AppRestrictionService.java

@@ -33,6 +33,8 @@ import org.slf4j.Logger;
 import java.util.Iterator;
 import java.util.Map;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.license.UserCredentials;
@@ -89,11 +91,33 @@ public class AppRestrictionService {
 		return this.workMDMSettings;
 	}
 
+	/**
+	 * Get the source of active mdm parameters in text representation.
+	 *
+	 * If at least one Threema-MDM parameter and at least one external MDM parameter is active, "me" is returned.
+	 * If at least one Threema-MDM parameter is active, append "m" is returned.
+	 * If at least one external MDM parameter is active, append "e" is returned.
+	 *
+	 * (See https://confluence.threema.ch/display/EN/Update+Work+Info)
+	 *
+	 * @return the source(s) of active mdm parameters as text, null if no mdm parameters are active
+	 */
+	public @Nullable String getMdmSource() {
+		StringBuilder mdmSource = new StringBuilder();
+		if (hasThreemaMDMRestrictions()) {
+			mdmSource.append("m");
+		}
+		if (hasExternalMDMRestrictions()) {
+			mdmSource.append("e");
+		}
+		return mdmSource.length() > 0 ? mdmSource.toString() : null;
+	}
+
 	/**
 	 * Determine if this app is under control of Threema MDM and has at least one parameter set
 	 * @return true if Threema MDM is active
 	 */
-	public boolean hasThreemaMDMRestrictions() {
+	private boolean hasThreemaMDMRestrictions() {
 		return this.workMDMSettings != null && this.workMDMSettings.parameters != null && this.workMDMSettings.parameters.size() > 0;
 	}
 
@@ -101,7 +125,7 @@ public class AppRestrictionService {
 	 * Determine if this app is under control of an external MDM/EMM with a local DPC and at least one parameter set
 	 * @return true if an external MDM is active
 	 */
-	public boolean hasExternalMDMRestrictions() {
+	private boolean hasExternalMDMRestrictions() {
 		return this.hasExternalMDMRestrictions;
 	}
 
@@ -235,6 +259,7 @@ public class AppRestrictionService {
 	private static volatile AppRestrictionService instance;
 	private static final Object lock = new Object();
 
+	@NonNull
 	public static AppRestrictionService getInstance() {
 		if (instance == null) {
 			synchronized (lock) {

+ 31 - 6
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -282,6 +282,7 @@ public class GroupServiceImpl implements GroupService {
 				listener.onMemberKicked(groupModel, userService.getIdentity(), identities.length);
 			}
 		});
+		updateAllowedCallParticipants(groupModel);
 
 		return true;
 	}
@@ -548,6 +549,7 @@ public class GroupServiceImpl implements GroupService {
 							listener.onGroupStateChanged(model, groupState, getGroupState(model));
 						}
 					});
+					updateAllowedCallParticipants(model);
 				}
 
 				return true;
@@ -639,8 +641,7 @@ public class GroupServiceImpl implements GroupService {
 				// ignore this groupCreate message
 				result.success = true;
 				result.groupModel = null;
-			}
-			else {
+			} else {
 				// i was kicked out of group
 				// remove all members
 				this.databaseServiceNew.getGroupMemberModelFactory().deleteByGroupId(
@@ -662,6 +663,7 @@ public class GroupServiceImpl implements GroupService {
 						listener.onMemberKicked(groupModel, userService.getIdentity(), previousMemberCount);
 					}
 				});
+				updateAllowedCallParticipants(groupModel);
 			}
 
 			ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(result.groupModel, groupState, getGroupState(result.groupModel)));
@@ -751,6 +753,7 @@ public class GroupServiceImpl implements GroupService {
 		}
 
 		ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(result.groupModel, groupState, getGroupState(result.groupModel)));
+		updateAllowedCallParticipants(result.groupModel);
 
 		return result;
 	}
@@ -1127,6 +1130,7 @@ public class GroupServiceImpl implements GroupService {
 		}
 
 		ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(groupModel, groupState, getGroupState(groupModel)));
+		updateAllowedCallParticipants(groupModel);
 
 		return groupModel;
 	}
@@ -1148,6 +1152,19 @@ public class GroupServiceImpl implements GroupService {
 		}
 	}
 
+	private void updateAllowedCallParticipants(@NonNull GroupModel groupModel) {
+		try {
+			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+			if (serviceManager == null) {
+				logger.error("Service manager is null. Abort updating allowed call participants");
+				return;
+			}
+			serviceManager.getGroupCallManager().updateAllowedCallParticipants(groupModel);
+		} catch (ThreemaException e) {
+			logger.error("Could not get group call manager. Abort updating allowed call participants");
+		}
+	}
+
 	@Override
 	public boolean renameGroup(GroupRenameMessage renameMessage) throws ThreemaException {
 		final GroupModel groupModel = this.getGroup(renameMessage);
@@ -1286,12 +1303,14 @@ public class GroupServiceImpl implements GroupService {
 		final GroupModel groupModel = this.getGroup(msg);
 
 		if (groupModel != null) {
-			this.fileService.removeGroupAvatar(groupModel);
+			if (this.fileService.hasGroupAvatarFile(groupModel)) {
+				this.fileService.removeGroupAvatar(groupModel);
 
-			//reset the avatar cache entry
-			this.avatarCacheService.reset(groupModel);
+				//reset the avatar cache entry
+				this.avatarCacheService.reset(groupModel);
 
-			ListenerManager.groupListeners.handle(listener -> listener.onUpdatePhoto(groupModel));
+				ListenerManager.groupListeners.handle(listener -> listener.onUpdatePhoto(groupModel));
+			}
 
 			return true;
 		}
@@ -1551,6 +1570,12 @@ public class GroupServiceImpl implements GroupService {
 					logger.error("Exception", e);
 					return false;
 				}
+			} else {
+				this.groupMessagingService.sendMessage(groupModel, memberIdentities, messageId -> {
+					GroupDeletePhotoMessage msg = new GroupDeletePhotoMessage();
+					msg.setMessageId(messageId);
+					return msg;
+				});
 			}
 
 

+ 67 - 23
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -643,15 +643,22 @@ public class MessageServiceImpl implements MessageService {
 				 * message causing him to re-send the profile pic at his earliest convenience, i.e. accompanying a regular message
 				 * */
 				for (ContactModel contactModel : restoredContacts) {
+					final String identity = contactModel.getIdentity();
+					final boolean isEchoecho = ThreemaApplication.ECHO_USER_IDENTITY.equals(identity);
+					final boolean isGatewayId = ContactUtil.isChannelContact(identity);
+					if (isEchoecho || isGatewayId) {
+						// Don't send profile picture requests to ECHOECHO or to gateway IDs
+						continue;
+					}
 					ContactRequestPhotoMessage msg = new ContactRequestPhotoMessage();
-					msg.setToIdentity(contactModel.getIdentity());
+					msg.setToIdentity(identity);
 
 					logger.info("Enqueue request profile picture message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
 					MessageBox messageBox = null;
 					try {
 						messageBox = messageQueue.enqueue(msg);
 					} catch (ThreemaException e) {
-						logger.error("Exception", e);
+						logger.error("Failed to enqueue profile picture request message", e);
 					}
 
 					if (messageBox != null) {
@@ -1517,7 +1524,7 @@ public class MessageServiceImpl implements MessageService {
 				ContactMessageReceiver receiver = contactService.createReceiver(senderContact);
 
 				if (message.getForwardSecurityMode() == null || message.getForwardSecurityMode() == ForwardSecurityMode.NONE) {
-					// Check if this contact has sent FS messages before. Warn the user is this is the case.
+					// Check if this contact has sent FS messages before. Warn the user if this is the case.
 					if (fsmp.hasContactUsedForwardSecurity(senderContact)) {
 						if (senderContact.getForwardSecurityState() == ContactModel.FS_ON) {
 							contactService.setForwardSecurityState(senderContact, ContactModel.FS_OFF);
@@ -2014,7 +2021,7 @@ public class MessageServiceImpl implements MessageService {
 				fileData.getFileSize(),
 				FileUtil.sanitizeFileName(fileData.getFileName()),
 				fileData.getRenderingType(),
-				fileData.getDescription(),
+				fileData.getCaption(),
 				false,
 				fileData.getMetaData());
 
@@ -3899,8 +3906,11 @@ public class MessageServiceImpl implements MessageService {
 			}
 
 			try {
-				final byte[] contentData = generateContentData(mediaItem, resolvedReceivers, messageModels, fileDataModel);
-				final byte[] thumbnailData = generateThumbnailData(mediaItem, fileDataModel);
+				final Map<String, Object> metaData = new HashMap<>();
+				final byte[] contentData = generateContentData(mediaItem, resolvedReceivers, messageModels, fileDataModel, metaData);
+				final byte[] thumbnailData = generateThumbnailData(mediaItem, fileDataModel, metaData);
+				fileDataModel.setMetaData(metaData);
+
 				if (thumbnailData != null) {
 					writeThumbnails(messageModels, resolvedReceivers, thumbnailData);
 				} else {
@@ -3970,10 +3980,13 @@ public class MessageServiceImpl implements MessageService {
 	 * @return content data as a byte array or null if content data could not be generated
 	 */
 	@WorkerThread
-	private @Nullable byte[] generateContentData(@NonNull MediaItem mediaItem,
-	                                             @NonNull MessageReceiver[] resolvedReceivers,
-	                                             @NonNull Map<MessageReceiver, AbstractMessageModel> messageModels,
-	                                             @NonNull FileDataModel fileDataModel) throws ThreemaException {
+	private @Nullable byte[] generateContentData(
+		@NonNull MediaItem mediaItem,
+		@NonNull MessageReceiver[] resolvedReceivers,
+		@NonNull Map<MessageReceiver, AbstractMessageModel> messageModels,
+		@NonNull FileDataModel fileDataModel,
+		@NonNull Map <String, Object> metaData
+	) throws ThreemaException {
 		switch (mediaItem.getType()) {
 			case TYPE_VIDEO:
 				// fallthrough
@@ -3995,9 +4008,7 @@ public class MessageServiceImpl implements MessageService {
 					boolean hasNoTransparency = MimeUtil.MIME_TYPE_IMAGE_JPG.equals(mediaItem.getMimeType());
 					bitmap = BitmapUtil.safeGetBitmapFromUri(context, mediaItem.getUri(), maxSize, false);
 					if (bitmap != null) {
-						bitmap = BitmapUtil.rotateBitmap(bitmap,
-							mediaItem.getExifRotation(),
-							mediaItem.getExifFlip());
+						bitmap = adjustBitmapOrientation(bitmap, mediaItem, metaData);
 
 						final byte[] imageByteArray;
 						if (hasNoTransparency) {
@@ -4040,10 +4051,7 @@ public class MessageServiceImpl implements MessageService {
 					if (inputStream != null && inputStream.available() > 0) {
 						bitmap = BitmapFactory.decodeStream(new BufferedInputStream(inputStream), null, null);
 						if (bitmap != null) {
-							bitmap = BitmapUtil.rotateBitmap(
-								bitmap,
-								mediaItem.getExifRotation(),
-								mediaItem.getExifFlip());
+							bitmap = adjustBitmapOrientation(bitmap, mediaItem, metaData);
 
 							final byte[] imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
 							if (imageByteArray != null) {
@@ -4073,15 +4081,45 @@ public class MessageServiceImpl implements MessageService {
 		return null;
 	}
 
+	/**
+	 * Rotate/flip bitmap according to exif information and add final dimensions to the file message's meta data also keeping in
+	 * account local orientation (if any)
+	 * @param bitmap The Bitmap
+	 * @param mediaItem The MediaItem instance that contains orientation info about this particular item
+	 * @param metaData A map with meta data that is going to be added to a file message
+	 *
+	 * @return A new bitmap with adjusted orientation
+	 */
+	@NonNull
+	private Bitmap adjustBitmapOrientation(
+		@NonNull Bitmap bitmap,
+		@NonNull MediaItem mediaItem,
+		@NonNull Map<String, Object> metaData
+	) {
+		bitmap = BitmapUtil.rotateBitmap(
+			bitmap,
+			mediaItem.getExifRotation(),
+			mediaItem.getExifFlip());
+
+		boolean isRotated = mediaItem.getRotation() == 90 || mediaItem.getRotation() == 270;
+		metaData.put(FileDataModel.METADATA_KEY_WIDTH, isRotated ? bitmap.getHeight() : bitmap.getWidth());
+		metaData.put(FileDataModel.METADATA_KEY_HEIGHT, isRotated ? bitmap.getWidth() : bitmap.getHeight());
+
+		return bitmap;
+	}
+
 	/**
 	 * Generate thumbnail data for this MediaItem
 	 *
 	 * @return byte array of the thumbnail bitmap, null if thumbnail could not be generated
 	 */
 	@WorkerThread
-	private @Nullable byte[] generateThumbnailData(@NonNull MediaItem mediaItem, @NonNull FileDataModel fileDataModel) {
+	private @Nullable byte[] generateThumbnailData(
+		@NonNull MediaItem mediaItem,
+		@NonNull FileDataModel fileDataModel,
+		@NonNull Map <String, Object> metaData
+	) {
 		Bitmap thumbnailBitmap = null;
-		final Map<String, Object> metaData = new HashMap<>();
 
 		int mediaType = mediaItem.getType();
 
@@ -4158,8 +4196,6 @@ public class MessageServiceImpl implements MessageService {
 				break;
 		}
 
-		fileDataModel.setMetaData(metaData);
-
 		final byte[] thumbnailData;
 		if (thumbnailBitmap != null) {
 			// convert bitmap to byte array
@@ -4377,7 +4413,10 @@ public class MessageServiceImpl implements MessageService {
 			messageModel.setState(MessageState.PENDING); // shows a progress bar
 			messageModel.setFileData(fileDataModel);
 			messageModel.setCorrelationId(correlationId);
-			messageModel.setCaption(mediaItem.getTrimmedCaption());
+			String trimmedCaption = mediaItem.getTrimmedCaption();
+			if (trimmedCaption != null && !trimmedCaption.isBlank()) {
+				messageModel.setCaption(trimmedCaption);
+			}
 			messageModel.setSaved(true);
 
 			messageReceiver.saveLocalModel(messageModel);
@@ -4474,12 +4513,17 @@ public class MessageServiceImpl implements MessageService {
 			filename = FileUtil.getDefaultFilename(mimeType);
 		}
 
+		String caption = mediaItem.getTrimmedCaption();
+		if (caption != null && caption.isBlank()) {
+			caption = null;
+		}
+
 		return new FileDataModel(mimeType,
 			null,
 			0,
 			filename,
 			renderingType,
-			mediaItem.getTrimmedCaption(),
+			caption,
 			true,
 			null);
 	}

+ 1 - 1
app/src/main/java/ch/threema/app/services/NotificationService.java

@@ -355,7 +355,7 @@ public interface NotificationService {
 	 */
 	void showNotEnoughDiskSpace(long availableSpace, long requiredSpace);
 
-	void showUnsentMessageNotification(@NonNull ArrayList<AbstractMessageModel> failedMessages);
+	void showUnsentMessageNotification(@NonNull List<AbstractMessageModel> failedMessages);
 
 	void showSafeBackupFailed(int numDays);
 

+ 2 - 2
app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java

@@ -507,7 +507,7 @@ public class NotificationServiceImpl implements NotificationService {
 		return PendingIntent.getActivity(
 			context,
 			GC_PENDING_INTENT_BASE + groupId,
-			GroupCallActivity.getStartOrJoinCallIntent(context, groupId),
+			GroupCallActivity.getJoinCallIntent(context, groupId),
 			flags
 		);
 	}
@@ -1766,7 +1766,7 @@ public class NotificationServiceImpl implements NotificationService {
 	}
 
 	@Override
-	public void showUnsentMessageNotification(@NonNull ArrayList<AbstractMessageModel> failedMessages) {
+	public void showUnsentMessageNotification(@NonNull List<AbstractMessageModel> failedMessages) {
 		int num = failedMessages.size();
 		boolean isFSKeyMismatch = StreamSupport.stream(failedMessages)
 			.anyMatch(m -> m.getState() == MessageState.FS_KEY_MISMATCH);

+ 5 - 1
app/src/main/java/ch/threema/app/services/PreferenceService.java

@@ -43,7 +43,7 @@ import ch.threema.domain.protocol.api.work.WorkOrganization;
 
 public interface PreferenceService {
 
-	@Retention(RetentionPolicy.SOURCE)
+    @Retention(RetentionPolicy.SOURCE)
 	@IntDef({ImageScale_DEFAULT, ImageScale_SMALL, ImageScale_MEDIUM, ImageScale_LARGE, ImageScale_XLARGE, ImageScale_ORIGINAL, ImageScale_SEND_AS_FILE})
 	@interface ImageScale {}
 	int ImageScale_DEFAULT = -1;
@@ -284,6 +284,10 @@ public interface PreferenceService {
 
 	long getLockoutTimeout();
 
+	void setLockoutAttempts(int numWrongConfirmAttempts);
+
+	int getLockoutAttempts();
+
 	void setWizardRunning(boolean running);
 
 	boolean getWizardRunning();

+ 11 - 0
app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java

@@ -774,6 +774,17 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getLong(this.getKeyName(R.string.preferences__lockout_timeout));
 	}
 
+    @Override
+    public void setLockoutAttempts(int numWrongConfirmAttempts) {
+		this.preferenceStore.save(this.getKeyName(R.string.preferences__lockout_attempts), numWrongConfirmAttempts);
+
+	}
+
+	@Override
+	public int getLockoutAttempts() {
+		return this.preferenceStore.getInt(this.getKeyName(R.string.preferences__lockout_attempts));
+	}
+
 	@Override
 	public void setWizardRunning(boolean running) {
 		this.preferenceStore.save(this.getKeyName(R.string.preferences__wizard_running), running);

+ 37 - 0
app/src/main/java/ch/threema/app/stores/DatabaseContactStore.java

@@ -29,8 +29,12 @@ import java.util.Date;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
+import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
+import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.SynchronizeContactsUtil;
 import ch.threema.app.utils.TestUtil;
@@ -39,11 +43,13 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.models.VerificationLevel;
+import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.stores.ContactStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.ContactModel;
+import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel;
 
 /**
  * The {@link DatabaseContactStore} is an implementation of the {@link ContactStore} interface
@@ -200,6 +206,17 @@ public class DatabaseContactStore implements ContactStore {
 				logger.debug("do not save unmodified contact");
 				return;
 			}
+			if (ThreemaFeature.canForwardSecurity(existingModel.getFeatureMask()) && !ThreemaFeature.canForwardSecurity(contactModel.getFeatureMask())) {
+				logger.info("Forward security feature has been downgraded for contact {}", contactModel.getIdentity());
+				if (existingModel.isForwardSecurityEnabled()) {
+					// If forward security was enabled for this contact, create a status message that
+					// forward security has been disabled for this contact due to a downgrade.
+					createForwardSecurityDowngradedStatus(contactModel);
+
+					// Disable forward security for this contact
+					contactModel.setForwardSecurityEnabled(false);
+				}
+			}
 		}
 
 		contactModelFactory.createOrUpdate(contactModel);
@@ -212,6 +229,26 @@ public class DatabaseContactStore implements ContactStore {
 		}
 	}
 
+	private void createForwardSecurityDowngradedStatus(@NonNull ContactModel contactModel) {
+		try {
+			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+			if (serviceManager != null) {
+				MessageService messageService = serviceManager.getMessageService();
+				ContactService contactService = serviceManager.getContactService();
+				messageService.createForwardSecurityStatus(
+					contactService.createReceiver(contactModel),
+					ForwardSecurityStatusDataModel.ForwardSecurityStatusType.FORWARD_SECURITY_UNAVAILABLE_DOWNGRADE,
+					0,
+					null
+				);
+			} else {
+				logger.error("ServiceManager is null");
+			}
+		} catch (ThreemaException e) {
+			logger.error("Error while creating forward security downgrade status message", e);
+		}
+	}
+
 	/**
 	 * Mark the contact as hidden / unhidden. Then store or update the contact in the database.
 	 */

+ 1 - 1
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java

@@ -1240,7 +1240,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 	private void parseInfo(JSONObject info) throws ThreemaException, JSONException {
 		int version = info.getInt(TAG_SAFE_INFO_VERSION);
 		if (version > PROTOCOL_VERSION) {
-			throw new ThreemaException(context.getResources().getString(R.string.safe_version_mismatch));
+			throw new ThreemaException(context.getResources().getString(R.string.backup_version_mismatch));
 		}
 	}
 

+ 1 - 1
app/src/main/java/ch/threema/app/ui/MediaItem.java

@@ -55,7 +55,7 @@ public class MediaItem implements Parcelable {
 	@MediaType private int type;
 	private Uri originalUri; // Uri of original media item before creating a local copy
 	private Uri uri;
-	private int rotation;
+	private int rotation; // Rotation in Degrees
 	private int exifRotation;
 	private long durationMs;
 	private String caption;

+ 1 - 6
app/src/main/java/ch/threema/app/ui/NewWizardFingerPrintView.java

@@ -66,7 +66,7 @@ public class NewWizardFingerPrintView extends SquareImageView implements View.On
 	private int fixedCharCount;
 	private int charsToFixPerStep;
 
-	private class Char {
+	private static class Char {
 		public boolean isFixed = false;
 		public char text;
 		public int[] position = new int[2];
@@ -105,9 +105,7 @@ public class NewWizardFingerPrintView extends SquareImageView implements View.On
 
 	@Override
 	protected void onWindowVisibilityChanged(int visibility) {
-
 		ViewParent p = this.getParent();
-
 		while (p != null) {
 			if (p instanceof LockableScrollView) {
 				this.lockableScrollViewParent = (LockableScrollView) p;
@@ -116,7 +114,6 @@ public class NewWizardFingerPrintView extends SquareImageView implements View.On
 
 			p = p.getParent();
 		}
-
 		super.onWindowVisibilityChanged(visibility);
 	}
 
@@ -139,7 +136,6 @@ public class NewWizardFingerPrintView extends SquareImageView implements View.On
 
 	@Override
 	public boolean onTouch(View view, MotionEvent motionEvent) {
-
 		if (!this.isEnabled()) {
 			return false;
 		}
@@ -205,7 +201,6 @@ public class NewWizardFingerPrintView extends SquareImageView implements View.On
 
 		this.pointLeakCount++;
 
-
 		return true;
 	}
 

+ 2 - 0
app/src/main/java/ch/threema/app/utils/BackupUtils.java

@@ -39,10 +39,12 @@ public class BackupUtils {
 		return apiId + "-" + creator;
 	}
 
+	@Deprecated
 	public static String buildGroupUid(String apiId, String creator) {
 		return apiId + "-" + creator;
 	}
 
+	@Deprecated
 	public static String buildGroupUid(GroupModel groupModel) {
 		return buildGroupUid(groupModel.getApiGroupId().toString(), groupModel.getCreatorIdentity());
 	}

+ 21 - 30
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -459,9 +459,9 @@ public class ConfigUtils {
 	}
 
 	/**
-	 * Get user-facing application version string without alpha/beta version suffix
-	 * @param context
-	 * @return version string
+	 * Get user-facing application version string including alpha/beta version suffix
+	 *
+	 * @return application version string
 	 */
 	public static String getAppVersion(@NonNull Context context) {
 		try {
@@ -490,17 +490,6 @@ public class ConfigUtils {
 		return 1.0f;
 	}
 
-	/**
-	 * Get full user-facing application version string including alpha/beta version suffix
-	 * Deprecated! use getAppVersion()
-	 * @param context
-	 * @return version string
-	 */
-	@Deprecated
-	public static String getFullAppVersion(@NonNull Context context) {
-		return getAppVersion(context);
-	}
-
 	/**
 	 * Get build number of this app build
 	 * @param context
@@ -527,22 +516,6 @@ public class ConfigUtils {
 		final StringBuilder info = new StringBuilder();
 		if (includeAppVersion) {
 			info.append(getAppVersion(context)).append("/");
-			if (ConfigUtils.isWorkRestricted()) {
-				AppRestrictionService appRestrictionService = AppRestrictionService.getInstance();
-				if (appRestrictionService != null) {
-					final StringBuilder mdmBuilder = new StringBuilder();
-					if (appRestrictionService.hasThreemaMDMRestrictions()) {
-						mdmBuilder.append("m");
-					}
-					if (appRestrictionService.hasExternalMDMRestrictions()) {
-						mdmBuilder.append("e");
-					}
-
-					if (mdmBuilder.length() > 0) {
-						info.append(mdmBuilder).append("/");
-					}
-				}
-			}
 		}
 		info.append(Build.MANUFACTURER).append(";")
 			.append(Build.MODEL).append("/")
@@ -551,6 +524,24 @@ public class ConfigUtils {
 		return info.toString();
 	}
 
+	/**
+	 * Return information about the device including the manufacturer and the model.
+	 * The version is NOT included.
+	 * If mdm parameters are active on this device they are also appended according to ANDR-2213.
+	 *
+	 * @return The device info meant to be sent with support requests
+	 */
+	public static @NonNull String getSupportDeviceInfo(Context context) {
+		final StringBuilder info = new StringBuilder(getDeviceInfo(context, false));
+		if (isWorkRestricted()) {
+			String mdmSource = AppRestrictionService.getInstance().getMdmSource();
+			if (mdmSource != null) {
+				info.append("/").append(mdmSource);
+			}
+		}
+		return info.toString();
+	}
+
 	public static String getPrivacyPolicyURL(Context context) {
 		return getLicenceURL(context, R.string.privacy_policy_url);
 	}

+ 73 - 2
app/src/main/java/ch/threema/app/utils/ForwardSecurityStatusSender.java

@@ -21,22 +21,30 @@
 
 package ch.threema.app.utils;
 
+import org.slf4j.Logger;
+
 import java.util.Collections;
+import java.util.List;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.routines.UpdateFeatureLevelRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.MessageService;
+import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.fs.DHSessionId;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityStatusListener;
+import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
+import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel;
 
 public class ForwardSecurityStatusSender implements ForwardSecurityStatusListener {
+	private final static Logger logger = LoggingUtil.getThreemaLogger("ForwardSecurityStatusSender");
 	private final boolean debug;
 	private final ContactService contactService;
 	private final MessageService messageService;
@@ -122,12 +130,22 @@ public class ForwardSecurityStatusSender implements ForwardSecurityStatusListene
 	}
 
 	@Override
-	public void messageOutOfOrder(DHSessionId sessionId, Contact contact) {
+	public void messageOutOfOrder(DHSessionId sessionId, Contact contact, MessageId messageId) {
 		if (debug) {
 			postStatusMessageDebug("Message out of order (ID " + sessionId + ")", contact);
 		}
 
-		postStatusMessage(contact, ForwardSecurityStatusDataModel.ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER);
+		if (contact != null && messageId != null && hasLastMessageId(contact, messageId)) {
+			// If the latest message of a contact is processed again, it cannot be decrypted again due to FS. It is very
+			// likely that the message has been processed but could not be acknowledged on the server. Therefore we do
+			// not show a warning if the message is already displayed in the chat.
+			logger.warn("The latest message with id '{}' was processed twice. Ignoring the second message.", messageId);
+			if (debug) {
+				postStatusMessageDebug(String.format("The latest message with id '%s' was processed twice.", messageId), contact);
+			}
+		} else {
+			postStatusMessage(contact, ForwardSecurityStatusDataModel.ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER);
+		}
 	}
 
 	@Override
@@ -185,4 +203,57 @@ public class ForwardSecurityStatusSender implements ForwardSecurityStatusListene
 			Collections.singletonList(contactModel)
 		).run();
 	}
+
+	private boolean hasLastMessageId(@NonNull Contact contact, @NonNull MessageId messageId) {
+		ContactMessageReceiver r = contactService.createReceiver(contactService.getByIdentity(contact.getIdentity()));
+
+		List<AbstractMessageModel> messageModels = this.messageService.getMessagesForReceiver(r, new MessageService.MessageFilter() {
+			@Override
+			public long getPageSize() {
+				return 1;
+			}
+
+			@Override
+			public Integer getPageReferenceId() {
+				return null;
+			}
+
+			@Override
+			public boolean withStatusMessages() {
+				return false;
+			}
+
+			@Override
+			public boolean withUnsaved() {
+				return false;
+			}
+
+			@Override
+			public boolean onlyUnread() {
+				return false;
+			}
+
+			@Override
+			public boolean onlyDownloaded() {
+				return false;
+			}
+
+			@Override
+			public MessageType[] types() {
+				return null;
+			}
+
+			@Override
+			public int[] contentTypes() {
+				return null;
+			}
+		});
+
+		if (messageModels != null && !messageModels.isEmpty()) {
+			return messageId.toString().equals(messageModels.get(0).getApiMessageId());
+		}
+
+		return false;
+	}
+
 }

+ 2 - 2
app/src/main/java/ch/threema/app/utils/GroupCallUtil.kt

@@ -34,7 +34,7 @@ import ch.threema.app.routines.UpdateFeatureLevelRoutine
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.UserService
-import ch.threema.app.voip.activities.GroupCallActivity.Companion.getStartOrJoinCallIntent
+import ch.threema.app.voip.activities.GroupCallActivity
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.domain.protocol.ThreemaFeature
 import ch.threema.domain.protocol.api.APIConnector
@@ -142,7 +142,7 @@ private fun launchActivity(context: Context, groupModel: GroupModel, otherMember
             Toast.LENGTH_LONG
         ).show()
     }
-    ContextCompat.startActivity(context, getStartOrJoinCallIntent(context, groupModel.id), null)
+    ContextCompat.startActivity(context, GroupCallActivity.getStartCallIntent(context, groupModel.id), null)
 }
 
 fun qualifiesForGroupCalls(groupService: GroupService, groupModel: GroupModel): Boolean {

+ 8 - 0
app/src/main/java/ch/threema/app/utils/MessageUtil.java

@@ -741,6 +741,14 @@ public class MessageUtil {
 									null,
 									null
 								);
+							case ForwardSecurityStatusDataModel.ForwardSecurityStatusType.FORWARD_SECURITY_UNAVAILABLE_DOWNGRADE:
+								return new MessageViewElement(
+									R.drawable.ic_baseline_key_24,
+									context.getString(R.string.forward_security_downgraded_status_message),
+									context.getString(R.string.forward_security_downgraded_status_message),
+									null,
+									null
+								);
 							default:
 								return new MessageViewElement(
 									R.drawable.ic_baseline_key_24,

+ 2 - 1
app/src/main/java/ch/threema/app/utils/MimeUtil.java

@@ -64,6 +64,7 @@ public class MimeUtil {
 	public static final String MIME_TYPE_VIDEO_MP4 = "video/mp4";
 	public static final String MIME_TYPE_VIDEO_AVC = "video/avc";
 	public static final String MIME_TYPE_AUDIO_AAC = "audio/aac";
+	public static final String MIME_TYPE_AUDIO_M4A = "audio/x-m4a"; // mime type used by ios voice messages
 	public static final String MIME_TYPE_AUDIO_MIDI = "audio/midi";
 	public static final String MIME_TYPE_AUDIO_XMIDI = "audio/x-midi";
 	public static final String MIME_TYPE_AUDIO_FLAC = "audio/flac";
@@ -467,7 +468,7 @@ public class MimeUtil {
 			}
 		} else if (MimeUtil.isVideoFile(mimeType)) {
 			return TYPE_VIDEO;
-		} else if (MimeUtil.isAudioFile(mimeType) && mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_AAC)) {
+		} else if (MimeUtil.isAudioFile(mimeType) && (mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_AAC) || mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_M4A))) {
 			return TYPE_VOICEMESSAGE;
 		}
 		return TYPE_FILE;

+ 14 - 3
app/src/main/java/ch/threema/app/utils/NameUtil.java

@@ -274,12 +274,18 @@ public class NameUtil {
 	}
 
 	/**
-	 * Return the name used for quotes and mentions.
+	 * Return the name used for quotes and mentions. If the contact is not known or an error occurs
+	 * while getting the quote name, the identity is returned if not null. Otherwise an empty string
+	 * is returned.
 	 */
 	@NonNull
 	public static String getQuoteName(@Nullable String identity, ContactService contactService, UserService userService) {
 		if (contactService == null || userService == null || identity == null) {
-			return "";
+			if (identity != null) {
+				return identity;
+			} else {
+				return "";
+			}
 		}
 
 		if (ContactService.ALL_USERS_PLACEHOLDER_ID.equals(identity)) {
@@ -287,7 +293,12 @@ public class NameUtil {
 		}
 
 		final ContactModel contactModel = contactService.getByIdentity(identity);
-		return getQuoteName(contactModel, userService);
+		String quoteName = getQuoteName(contactModel, userService);
+		if (quoteName.isBlank()) {
+			return identity;
+		} else {
+			return quoteName;
+		}
 	}
 
 	public static void showNicknameInView(TextView nickNameTextView, ContactModel contactModel, String filterString, FilterableListAdapter adapter) {

+ 13 - 10
app/src/main/java/ch/threema/app/utils/ShortcutUtil.java

@@ -32,15 +32,6 @@ import android.os.PersistableBundle;
 import android.os.SystemClock;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
@@ -50,6 +41,16 @@ import androidx.core.content.pm.ShortcutInfoCompat;
 import androidx.core.content.pm.ShortcutManagerCompat;
 import androidx.core.graphics.drawable.IconCompat;
 import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
+
+import org.slf4j.Logger;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
@@ -82,6 +83,7 @@ public final class ShortcutUtil {
 	public static final int TYPE_NONE = 0;
 	public static final int TYPE_CHAT = 1;
 	public static final int TYPE_CALL = 2;
+	public static final String EXTRA_CALLED_FROM_SHORTCUT = "shortcut";
 
 	private static final Object dynamicShortcutLock = new Object();
 
@@ -240,6 +242,7 @@ public final class ShortcutUtil {
 		intent.setData((Uri.parse("foobar://" + SystemClock.elapsedRealtime())));
 		intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
 		intent.setAction(Intent.ACTION_MAIN);
+		intent.putExtra(EXTRA_CALLED_FROM_SHORTCUT, true);
 
 		return intent;
 	}
@@ -248,7 +251,7 @@ public final class ShortcutUtil {
 		Intent intent = new Intent(getContext(), CallActivity.class);
 		intent.setData((Uri.parse("foobar://" + SystemClock.elapsedRealtime())));
 		intent.setAction(Intent.ACTION_MAIN);
-		intent.putExtra(CallActivity.EXTRA_CALL_FROM_SHORTCUT, true);
+		intent.putExtra(EXTRA_CALLED_FROM_SHORTCUT, true);
 		intent.putExtra(VoipCallService.EXTRA_IS_INITIATOR, true);
 		intent.putExtra(VoipCallService.EXTRA_CALL_ID, -1L);
 

+ 16 - 1
app/src/main/java/ch/threema/app/utils/SoundUtil.java

@@ -27,14 +27,16 @@ import android.media.AudioAttributes;
 import android.media.AudioManager;
 import android.media.MediaPlayer;
 
+import androidx.annotation.MainThread;
+
 import org.slf4j.Logger;
 
-import androidx.annotation.MainThread;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.base.utils.LoggingUtil;
 
 public class SoundUtil {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("SoundUtil");
+	private static final int FLAG_BYPASS_INTERRUPTION_POLICY = 0x1 << 6;
 
 	private SoundUtil() {
 		throw new IllegalStateException("Utility class");
@@ -87,4 +89,17 @@ public class SoundUtil {
 			.setUsage(usage)
 			.build();
 	}
+
+	/**
+	 * Get audio attributes for playing a ringtone accompaigning a call notification
+	 * Android 12+ will always mute the sound when DND is on. In order to be able to play a ringtone for incoming messages from a "starred" contact when INTERRUPTION_FILTER_PRIORITY is set,
+	 * we use the private FLAG_BYPASS_INTERRUPTION_POLICY flag.
+	 * @return AudioAttributes
+	 */
+	public static AudioAttributes getAudioAttributesForCallNotification() {
+		return new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN)
+			.setUsage(AudioAttributes.USAGE_NOTIFICATION)
+			.setFlags(FLAG_BYPASS_INTERRUPTION_POLICY)
+			.build();
+	}
 }

+ 56 - 26
app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java

@@ -21,12 +21,15 @@
 
 package ch.threema.app.voicemessage;
 
+import static android.Manifest.permission.BLUETOOTH_CONNECT;
+
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothHeadset;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.pm.PackageManager;
 import android.graphics.PorterDuff;
 import android.media.AudioManager;
 import android.media.MediaPlayer;
@@ -47,6 +50,7 @@ import android.widget.Toast;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.LifecycleOwner;
 
@@ -84,11 +88,12 @@ public class VoiceRecorderActivity extends AppCompatActivity implements DefaultL
 	public static final int MAX_VOICE_MESSAGE_LENGTH_MILLIS = (int) DateUtils.HOUR_IN_MILLIS;
 	private static final String SENSOR_TAG_VOICE_RECORDER = "voice";
 
-	public static final int DEFAULT_SAMPLING_RATE_HZ = 22050;
+	public static final int DEFAULT_SAMPLING_RATE_HZ = 44100;
 	public static final int BLUETOOTH_SAMPLING_RATE_HZ = 8000;
 
 	public static final String VOICEMESSAGE_FILE_EXTENSION = ".aac";
 	private static final int DISCARD_CONFIRMATION_THRESHOLD_SECONDS = 10;
+	private static final int PERMISSION_REQUEST_BLUETOOTH_CONNECT = 45454;
 
 	private enum MediaState {
 		STATE_NONE,
@@ -171,8 +176,18 @@ public class VoiceRecorderActivity extends AppCompatActivity implements DefaultL
 
 		audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
 
-		Intent intent = getIntent();
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+			if (!ConfigUtils.requestBluetoothConnectPermissions(this, null, PERMISSION_REQUEST_BLUETOOTH_CONNECT,
+				ActivityCompat.shouldShowRequestPermissionRationale(this, BLUETOOTH_CONNECT))) {
+				return;
+			}
+		}
+
+		postPermissionOnCreate();
+	}
 
+	private void postPermissionOnCreate() {
+		Intent intent = getIntent();
 		if (intent != null) {
 			messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(this, intent);
 			if (messageReceiver == null) {
@@ -262,31 +277,31 @@ public class VoiceRecorderActivity extends AppCompatActivity implements DefaultL
 			}
 
 			audioStateChangedReceiver = new BroadcastReceiver() {
-			@Override
-			public void onReceive(Context context, Intent intent) {
-				scoAudioState = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1);
+				@Override
+				public void onReceive(Context context, Intent intent) {
+					scoAudioState = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1);
+
+					String stateString = "";
+					switch (scoAudioState) {
+						case AudioManager.SCO_AUDIO_STATE_CONNECTED:
+							stateString = "connected";
+							break;
+						case AudioManager.SCO_AUDIO_STATE_DISCONNECTED:
+							stateString = "disconnected";
+							break;
+						case AudioManager.SCO_AUDIO_STATE_CONNECTING:
+							stateString = "connecting";
+							break;
+						case AudioManager.SCO_AUDIO_STATE_ERROR:
+							stateString = "error";
+							break;
+						default:
+							break;
+					}
 
-				String stateString = "";
-				switch (scoAudioState) {
-					case AudioManager.SCO_AUDIO_STATE_CONNECTED:
-						stateString = "connected";
-						break;
-					case AudioManager.SCO_AUDIO_STATE_DISCONNECTED:
-						stateString = "disconnected";
-						break;
-					case AudioManager.SCO_AUDIO_STATE_CONNECTING:
-						stateString = "connecting";
-						break;
-					case AudioManager.SCO_AUDIO_STATE_ERROR:
-						stateString = "error";
-						break;
-					default:
-						break;
+					logger.debug("Audio SCO state: " + stateString);
+					updateBluetoothButton();
 				}
-
-				logger.debug("Audio SCO state: " + stateString);
-				updateBluetoothButton();
-			}
 			};
 			registerReceiver(audioStateChangedReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED));
 
@@ -332,13 +347,19 @@ public class VoiceRecorderActivity extends AppCompatActivity implements DefaultL
 		pauseMedia();
 	}
 
+	@SuppressWarnings("MissingPermission")
 	private boolean isBluetoothEnabled() {
 		if (audioManager == null) {
 			return false;
 		}
 
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+			ActivityCompat.checkSelfPermission(this, BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
+			return false;
+		}
+
 		BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
-		boolean result = bluetoothAdapter != null && bluetoothAdapter.isEnabled() && bluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED;
+		boolean result = bluetoothAdapter != null && bluetoothAdapter.isEnabled() && bluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothAdapter.STATE_CONNECTED;
 
 		logger.debug("isBluetoothEnabled = {}",result);
 
@@ -969,4 +990,13 @@ public class VoiceRecorderActivity extends AppCompatActivity implements DefaultL
 	public void onSensorChanged(String key, boolean value) {
 		logger.debug("onSensorChanged: " + value);
 	}
+
+	@Override
+	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+		if (requestCode == PERMISSION_REQUEST_BLUETOOTH_CONNECT) {
+			postPermissionOnCreate();
+		}
+	}
 }

+ 2 - 3
app/src/main/java/ch/threema/app/voip/VoipBluetoothManager.java

@@ -376,10 +376,9 @@ public class VoipBluetoothManager {
 	 */
 	public void stop() {
 		ThreadUtils.checkIsOnMainThread();
-		try {
+		if (bluetoothState != State.UNINITIALIZED) {
+			// Only unregister receiver if it has been registered (to reduce unnecessary stack traces in the log)
 			unregisterReceiver(bluetoothHeadsetReceiver);
-		} catch (IllegalArgumentException e) {
-			logger.error("Unable to unregister bluetooth headset receiver", e);
 		}
 		logger.debug("stop: BT state={}", bluetoothState);
 		if (bluetoothAdapter != null) {

+ 16 - 15
app/src/main/java/ch/threema/app/voip/activities/CallActivity.java

@@ -66,18 +66,6 @@ import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import com.getkeepsafe.taptargetview.TapTarget;
-import com.getkeepsafe.taptargetview.TapTargetView;
-
-import org.slf4j.Logger;
-import org.webrtc.RendererCommon;
-import org.webrtc.SurfaceViewRenderer;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.concurrent.ExecutionException;
-
 import androidx.annotation.AnyThread;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.IdRes;
@@ -98,6 +86,19 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.transition.ChangeBounds;
 import androidx.transition.Transition;
 import androidx.transition.TransitionManager;
+
+import com.getkeepsafe.taptargetview.TapTarget;
+import com.getkeepsafe.taptargetview.TapTargetView;
+
+import org.slf4j.Logger;
+import org.webrtc.RendererCommon;
+import org.webrtc.SurfaceViewRenderer;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.concurrent.ExecutionException;
+
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -150,6 +151,7 @@ import ch.threema.storage.models.ContactModel;
 import java8.util.concurrent.CompletableFuture;
 
 import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+import static ch.threema.app.utils.ShortcutUtil.EXTRA_CALLED_FROM_SHORTCUT;
 import static ch.threema.app.voip.services.VideoContext.CAMERA_FRONT;
 import static ch.threema.app.voip.services.VoipCallService.EXTRA_ACTIVITY_MODE;
 import static ch.threema.app.voip.services.VoipStateService.VIDEO_RENDER_FLAG_INCOMING;
@@ -168,7 +170,6 @@ public class CallActivity extends ThreemaActivity implements
 	private static final Logger logger = LoggingUtil.getThreemaLogger("CallActivity");
 	private static final String LIFETIME_SERVICE_TAG = "CallActivity";
 	private static final String SENSOR_TAG_CALL = "voipcall";
-	public static final String EXTRA_CALL_FROM_SHORTCUT = "shortcut";
 	public static final String EXTRA_ACCEPT_INCOMING_CALL = "ACCEPT_INCOMING_CALL";
 	private static final String DIALOG_TAG_OK = "ok";
 
@@ -267,7 +268,7 @@ public class CallActivity extends ThreemaActivity implements
 	public static final String ACTION_DISABLE_VIDEO = BuildConfig.APPLICATION_ID + ".VIDEO_DISABLE";
 
 	private boolean callDebugInfoEnabled = false;
-	private boolean sensorEnabled = false;
+	private boolean sensorEnabled = true;
 	private boolean toggleVideoTooltipShown = false, audioSelectorTooltipShown = false;
 	private byte activityMode;
 	private boolean navigationShown = true;
@@ -935,7 +936,7 @@ public class CallActivity extends ThreemaActivity implements
 		}
 
 		// Determine activity mode
-		if (intent.getBooleanExtra(EXTRA_CALL_FROM_SHORTCUT, false)) {
+		if (intent.getBooleanExtra(EXTRA_CALLED_FROM_SHORTCUT, false)) {
 			if (!callState.isIdle()) {
 				logger.error("Ongoing call - ignore shortcut");
 				return false;

+ 34 - 8
app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt

@@ -69,6 +69,7 @@ import ch.threema.app.ui.TooltipPopup
 import ch.threema.app.utils.*
 import ch.threema.app.voip.CallAudioSelectorButton
 import ch.threema.app.voip.groupcall.GroupCallDescription
+import ch.threema.app.voip.groupcall.GroupCallIntention
 import ch.threema.app.voip.groupcall.LocalGroupId
 import ch.threema.app.voip.util.VoipUtil
 import ch.threema.app.voip.viewmodel.GroupCallViewModel
@@ -90,6 +91,7 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 	companion object {
 		private const val EXTRA_GROUP_ID = "EXTRA_GROUP_ID"
 		private const val EXTRA_MICROPHONE_ACTIVE = "EXTRA_MICROPHONE_ACTIVE"
+		private const val EXTRA_INTENTION = "EXTRA_INTENTION"
 
 		private const val DURATION_ANIMATE_NAVIGATION_MILLIS = 300L
 		private const val DURATION_ANIMATE_GRADIENT_VISIBILITY_MILLIS = 200L
@@ -103,17 +105,29 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 		private const val KEEP_ALIVE_DELAY = 20000L
 
 		@JvmStatic
-		fun getStartOrJoinCallIntent(context: Context, groupId: Int): Intent {
-			return getGroupCallIntent(context, groupId)
-				.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+		fun getStartCallIntent(context: Context, groupId: Int): Intent {
+			return getStartOrJoinCallIntent(context, groupId)
+				.putExtra(EXTRA_INTENTION, GroupCallIntention.JOIN_OR_CREATE)
 		}
 
 		@JvmStatic
-		fun getStartOrJoinCallIntent(context: Context, groupId: Int, microphoneActive: Boolean = true): Intent {
-			return getStartOrJoinCallIntent(context, groupId)
+		fun getJoinCallIntent(context: Context, groupId: Int, microphoneActive: Boolean = true): Intent {
+			return getJoinCallIntent(context, groupId)
 				.putExtra(EXTRA_MICROPHONE_ACTIVE, microphoneActive)
 		}
 
+		@JvmStatic
+		fun getJoinCallIntent(context: Context, groupId: Int): Intent {
+			return getStartOrJoinCallIntent(context, groupId)
+				.putExtra(EXTRA_INTENTION, GroupCallIntention.JOIN)
+		}
+
+		@JvmStatic
+		private fun getStartOrJoinCallIntent(context: Context, groupId: Int): Intent {
+			return getGroupCallIntent(context, groupId)
+				.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+		}
+
 		private fun getGroupCallIntent(context: Context, groupId: Int): Intent {
 			return Intent(context, GroupCallActivity::class.java)
 				.putExtra(EXTRA_GROUP_ID, groupId)
@@ -152,6 +166,8 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 		hideInfoAndControls()
 	}
 
+	private var intention = GroupCallIntention.JOIN
+
 	private val viewModel: GroupCallViewModel by viewModels()
 
 	private lateinit var lockAppService: LockAppService
@@ -349,6 +365,8 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 	private fun handleIntent(intent: Intent) {
 		logger.debug("handleIntent")
 
+		intention = getIntention(intent)
+
 		val groupId = LocalGroupId(intent.getIntExtra(EXTRA_GROUP_ID, -1))
 		viewModel.setGroupId(groupId)
 		viewModel.cancelNotification()
@@ -365,6 +383,15 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 		}
 	}
 
+	private fun getIntention(intent: Intent): GroupCallIntention {
+		return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+			intent.getSerializableExtra(EXTRA_INTENTION, GroupCallIntention::class.java)
+		} else {
+			@Suppress("DEPRECATION")
+			intent.getSerializableExtra(EXTRA_INTENTION) as? GroupCallIntention
+		} ?: GroupCallIntention.JOIN
+	}
+
 	private fun checkPhoneStateAndJoinCall() {
 		try {
 			if (VoipUtil.isPSTNCallOngoingRespectPreference(this, this::joinCall, readPhoneStateSettingsLauncher)) {
@@ -392,7 +419,7 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 					// continue without bluetooth support
 				}
 				withContext(Dispatchers.Main) {
-					viewModel.joinCall()
+					viewModel.joinCall(intention)
 				}
 			} else {
 				logger.info("Microphone permission denied")
@@ -653,7 +680,6 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 		viewModel.getEglBaseAndParticipants().observe(this) { (eglBase, participants) ->
 			participantsAdapter.isPortrait = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
 			participantsLayoutManager.spanCount = getParticipantsLayoutManagerSpanCount(participants.size)
-			// TODO(ANDR-1956): It is actually not necessary to set eglBase each time, but it must be set, before any viewholders are created
 			participantsAdapter.eglBase = eglBase
 			participantsAdapter.setParticipants(participants)
 		}
@@ -738,8 +764,8 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 			GroupCallViewModel.FinishEvent.Reason.ERROR -> showToast(R.string.voip_gc_call_error)
 			GroupCallViewModel.FinishEvent.Reason.INVALID_DATA,
 			GroupCallViewModel.FinishEvent.Reason.TOKEN_INVALID,
-			GroupCallViewModel.FinishEvent.Reason.NO_SUCH_CALL,
 			GroupCallViewModel.FinishEvent.Reason.UNSUPPORTED_PROTOCOL_VERSION -> showToast(R.string.voip_gc_call_start_error)
+			GroupCallViewModel.FinishEvent.Reason.NO_SUCH_CALL -> showToast(R.string.voip_gc_call_already_ended)
 			GroupCallViewModel.FinishEvent.Reason.SFU_NOT_AVAILABLE -> showToast(R.string.voip_gc_sfu_not_available)
 			GroupCallViewModel.FinishEvent.Reason.FULL -> showCallFullToast(event.call)
 			GroupCallViewModel.FinishEvent.Reason.LEFT -> Unit

+ 3 - 3
app/src/main/java/ch/threema/app/voip/activities/WebRTCDebugActivity.java

@@ -199,7 +199,7 @@ public class WebRTCDebugActivity extends ThreemaToolbarActivity implements PeerC
 		this.addToLog("Starting Call Diagnostics...");
 		this.addToLog("----------------");
 		this.addToLog("Device info: " + ConfigUtils.getDeviceInfo(this, false));
-		this.addToLog("App version: " + ConfigUtils.getFullAppVersion(this));
+		this.addToLog("App version: " + ConfigUtils.getAppVersion(this));
 		this.addToLog("App language: " + LocaleUtil.getAppLanguage());
 		this.addToLog("----------------");
 
@@ -445,8 +445,8 @@ public class WebRTCDebugActivity extends ThreemaToolbarActivity implements PeerC
 						"\n---\n" +
 						caption +
 						"\n---\n" +
-						ConfigUtils.getDeviceInfo(WebRTCDebugActivity.this, false) + "\n" +
-						"Threema " + ConfigUtils.getFullAppVersion(WebRTCDebugActivity.this) + "\n" +
+						ConfigUtils.getSupportDeviceInfo(WebRTCDebugActivity.this) + "\n" +
+						"Threema " + ConfigUtils.getAppVersion(WebRTCDebugActivity.this) + "\n" +
 						getMyIdentity(), messageReceiver);
 
 					return true;

+ 39 - 0
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallIntention.kt

@@ -0,0 +1,39 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.voip.groupcall
+
+/**
+ * The intention when creating or joining a call
+ */
+enum class GroupCallIntention {
+	/**
+	 * Join an existing call. If there is no call considered running in this group, start
+	 * a new call.
+	 */
+	JOIN_OR_CREATE,
+
+	/**
+	 * Join an existing call. If there is no call considered running in this group, do not
+	 * start a new call.
+	 */
+	JOIN
+}

+ 29 - 5
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManager.kt

@@ -73,26 +73,47 @@ interface GroupCallManager {
     @UiThread
     fun removeGroupCallObserver(group: GroupModel, observer: GroupCallObserver)
 
+    /**
+     * Join a GroupCall that is currently running.
+     *
+     * If there is no call considered running for this group no new call will be started.
+     *
+     * If the call for this group is already joined the method should return the corresponding
+     * GroupCallController without executing any further connection steps.
+     *
+     * When joining a group call a connection to the sfu will be established.
+     *
+     * Note: There can only be ONE call (be it 1:1 or be it GroupCall) at any time! If there is another
+     * ongoing call the other call must be ended.
+     *
+     * @param group The group in which the call should be joined
+     *
+     * @return The [GroupCallController] of the joined call or `null` if no call is running in this group
+     */
+    @WorkerThread
+    suspend fun joinCall(group: GroupModel): GroupCallController?
+
     /**
      * Join a GroupCall. This may be a call that has to be created yet or a call
      * that has been started by someone else.
      *
-     * If there is no existing call for this group, a new call will be created (with the current user
-     * as creator) according to the group call protocol.
-     *
      * If the call for this group is already joined the method should return the corresponding
      * GroupCallController without executing any further connection steps.
      *
-     * When joining the call a connection to the SFU is established and the call created if it does not exist.
+     * When joining or creating a group call a connection to the sfu will be established.
      *
      * If the current user is the creator of the call a GroupCallStart message will be sent to other group
      * members upon call creation on the sfu.
      *
      * Note: There can only be ONE call (be it 1:1 or be it GroupCall) at any time! If there is another
      * ongoing call the other call must be ended.
+     *
+     * @param group The group in which the call should be joined/started
+     *
+     * @return The [GroupCallController] of the joined or created call
      */
     @WorkerThread
-    suspend fun joinCall(group: GroupModel): GroupCallController
+    suspend fun createCall(group: GroupModel): GroupCallController
 
     /**
      * This aborts the current call. Since the call might not be ended gracefully (normally the call service
@@ -143,4 +164,7 @@ interface GroupCallManager {
      */
     @AnyThread
     fun sendGroupCallStartToNewMembers(groupModel: GroupModel, newMembers: List<String>)
+
+    @AnyThread
+    fun updateAllowedCallParticipants(groupModel: GroupModel)
 }

+ 184 - 75
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt

@@ -23,7 +23,6 @@ package ch.threema.app.voip.groupcall
 
 import android.content.Context
 import androidx.annotation.AnyThread
-import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
 import androidx.core.content.ContextCompat
 import ch.threema.app.BuildConfig
@@ -44,6 +43,7 @@ import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.groupcall.GroupCallControlMessage
 import ch.threema.domain.protocol.csp.messages.groupcall.GroupCallStartData
+import ch.threema.domain.protocol.csp.messages.groupcall.GroupCallStartData.Companion.GCK_LENGTH
 import ch.threema.domain.protocol.csp.messages.groupcall.GroupCallStartMessage
 import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.models.ContactModel
@@ -71,15 +71,20 @@ class GroupCallManagerImpl(
 	private val notificationService: NotificationService,
 	private val sfuConnection: SfuConnection
 ) : GroupCallManager {
+	private companion object {
+		private const val ARTIFICIAL_GC_CREATE_WAIT_PERIOD_MILLIS: Long = 2000L
+	}
+
 	private val callObservers: MutableMap<LocalGroupId, MutableSet<GroupCallObserver>> = mutableMapOf()
 	private val callRefreshTimers: MutableMap<LocalGroupId, Job> = Collections.synchronizedMap(mutableMapOf())
 
 	// TODO(ANDR-1957): Unsure if this is guarded properly for use outside of the GC thread. There
 	//  is synchronization but it's not used consistently.
-	private val peekFailedCounters: PeekFailedCounter = PeekFailedCounter()
 	private val runningCalls: MutableMap<CallId, GroupCallDescription>
 	private val chosenCalls: MutableMap<LocalGroupId, GroupCallDescription> = mutableMapOf()
 
+	private val peekFailedCounters: PeekFailedCounter = PeekFailedCounter()
+
 	private var serviceConnection = GroupCallServiceConnection()
 
 	/*
@@ -112,12 +117,12 @@ class GroupCallManagerImpl(
 		return serviceConnection.getCallAudioManager()
 	}
 
-	@UiThread
+	@AnyThread
 	override fun addGroupCallObserver(group: GroupModel, observer: GroupCallObserver) {
 		addGroupCallObserver(group.localGroupId, observer)
 	}
 
-	@UiThread
+	@AnyThread
 	override fun addGroupCallObserver(groupId: LocalGroupId, observer: GroupCallObserver) {
 		synchronized(callObservers) {
 			if (groupId !in callObservers) {
@@ -129,12 +134,12 @@ class GroupCallManagerImpl(
 		}
 	}
 
-	@UiThread
+	@AnyThread
 	override fun removeGroupCallObserver(group: GroupModel, observer: GroupCallObserver) {
 		removeGroupCallObserver(group.localGroupId, observer)
 	}
 
-	@UiThread
+	@AnyThread
 	override fun removeGroupCallObserver(groupId: LocalGroupId, observer: GroupCallObserver) {
 		synchronized(callObservers) {
 			callObservers[groupId]?.remove(observer)
@@ -142,40 +147,54 @@ class GroupCallManagerImpl(
 	}
 
 	@WorkerThread
-	override suspend fun joinCall(group: GroupModel): GroupCallController {
+	override suspend fun joinCall(group: GroupModel): GroupCallController? {
 		GroupCallThreadUtil.assertDispatcherThread()
 
 		val groupId = group.localGroupId
-		logger.debug("Join call for group {}", groupId)
+
+		logger.debug("Try joining call for group {}", groupId)
 
 		val controller = getGroupCallControllerForJoinedCall(groupId)
 		return if (controller != null) {
+			logger.info("Call already joined")
 			controller
 		} else {
-			val chosenCall = getChosenCall(groupId)
-			if (chosenCall == null) {
-				// there is no group call considered running for this group. Start it!
-				logger.debug("Create new group call")
-				createCall(group)
-			} else {
-				// join `callId` and return controls
-				logger.debug("Join existing group call")
-				joinCall(chosenCall).also {
-					// wait until the call is 'CONNECTED'
-					it.connectedSignal.await()
-					// always confirm the call when joining and not creating a call
-					it.confirmCall()
-					// If two participants almost simultaneously start a group call, only one group
-					// call will be created. The other participant will join the chosen call while
-					// aborting the creation of its own group call. This can lead to a race where
-					// the group call start notification is shown even though the join process has
-					// already started. For this case, we cancel the notification here.
-					notificationService.cancelGroupCallNotification(groupId.id)
-				}
+			getChosenCall(groupId)?.let {
+				logger.info("Join existing call with id {}", it.callId)
+				joinAndConfirmCall(it, groupId)
 			}
 		}
 	}
 
+	override suspend fun createCall(group: GroupModel): GroupCallController {
+		GroupCallThreadUtil.assertDispatcherThread()
+
+		return joinCall(group) ?: run {
+			// there is no group call considered running for this group. Start it!
+			logger.info("Create new group call")
+			createNewCall(group)
+		}
+	}
+
+	@WorkerThread
+	private suspend fun joinAndConfirmCall(call: GroupCallDescription, groupId: LocalGroupId): GroupCallController {
+		logger.info("Join existing group call")
+		GroupCallThreadUtil.assertDispatcherThread()
+
+		return joinCall(call).also {
+			// wait until the call is 'CONNECTED'
+			it.connectedSignal.await()
+			// always confirm the call when joining and not creating a call
+			it.confirmCall()
+			// If two participants almost simultaneously start a group call, only one group
+			// call will be created. The other participant will join the chosen call while
+			// aborting the creation of its own group call. This can lead to a race where
+			// the group call start notification is shown even though the join process has
+			// already started. For this case, we cancel the notification here.
+			notificationService.cancelGroupCallNotification(groupId.id)
+		}
+	}
+
 	@WorkerThread
 	override fun abortCurrentCall() {
 		logger.warn("Aborting current call")
@@ -229,15 +248,39 @@ class GroupCallManagerImpl(
 		}
 		CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
 			val groupCallDescription = getChosenCall(groupModel.localGroupId) ?: return@launch
-			val sfuToken = obtainToken()
-			if (sfuToken == null) {
-				logger.warn("Could not send group call start to new members")
-			} else {
-				sendGroupCallStartMessage(groupModel, GroupCallStartData(
+			val startData = try {
+				GroupCallStartData(
 					ProtocolDefines.GC_PROTOCOL_VERSION.toUInt(),
 					groupCallDescription.gck,
-					sfuToken.sfuBaseUrl
-				), groupCallDescription.getStartedAtDate(), newMembers.toTypedArray())
+					groupCallDescription.sfuBaseUrl
+				)
+			} catch (e: IllegalArgumentException) {
+				logger.warn("Could not create call start data", e)
+				null
+			}
+			if (startData == null) {
+				logger.warn("Could not send group call start to new members")
+			} else {
+				sendGroupCallStartMessage(groupModel, startData, groupCallDescription.getStartedAtDate(), newMembers.toTypedArray())
+			}
+		}
+	}
+
+	override fun updateAllowedCallParticipants(groupModel: GroupModel) {
+		logger.debug("Update allowed call participants")
+		CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
+			getGroupCallControllerForJoinedCall(groupModel.localGroupId)?.let {
+				val members = groupService.getGroupIdentities(groupModel).toSet()
+				it.purgeCallParticipants(members)
+			}
+			if (!groupService.isGroupMember(groupModel)) {
+				val groupId = groupModel.localGroupId
+				val callIds = getRunningCalls(groupId)
+					.map { it.callId }
+					.toSet()
+				removeRunningCalls(callIds)
+				chosenCalls.remove(groupId)
+				purgeCallRefreshTimers(groupId)
 			}
 		}
 	}
@@ -294,7 +337,7 @@ class GroupCallManagerImpl(
 
 	@WorkerThread
 	@Throws(GroupCallException::class)
-	private suspend fun createCall(group: GroupModel): GroupCallController {
+	private suspend fun createNewCall(group: GroupModel): GroupCallController {
 		GroupCallThreadUtil.assertDispatcherThread()
 
 		val callStartData = createGroupCallStartData()
@@ -317,19 +360,18 @@ class GroupCallManagerImpl(
 		val (startedAt, participantIds) = callController.connectedSignal.await()
 		callDescription.startedAt = startedAt
 
-		// Make function cancellable. This enables cancelling call creation before GroupCallStart
-		// is sent to other group members.
-		// This is useful when someone accidentally pressed the call button and immediately hangs up
-		// in the GroupCallActivity.
-		yield()
-
-		val chosenCall = getCurrentChosenCall(group)
+		// Wait some time for another chosen call.
+		// This delay is actually meant to allow a butter fingered user to cancel a call again
+		// if it has been started by mistake.
+		// If a call in this group is started by another group member in the meantime, this call
+		// will be joined immediately instead.
+		val chosenCall = waitForChosenCall(group, ARTIFICIAL_GC_CREATE_WAIT_PERIOD_MILLIS)
 
 		if (chosenCall != null && chosenCall.callId != callId) {
 			callController.leave()
 			callController.callDisposedSignal.await()
 			logger.warn("There is already another chosen call for group ${group.localGroupId}")
-			return joinCall(group)
+			return joinAndConfirmCall(chosenCall, groupId)
 		}
 
 		logger.debug("Got {} participants", participantIds.size)
@@ -359,6 +401,43 @@ class GroupCallManagerImpl(
 		return callController
 	}
 
+	/**
+	 * Wait for at most [waitPeriodMillis] for another chosen call in this [group].
+	 * If after this wait period no chosen call is available for this group, `null` will be returned.
+	 * If another chosen call is created for this group it is returned immediately upon creation,
+	 * even if the wait period has not yet expired.
+	 */
+	private suspend fun waitForChosenCall(group: GroupModel, waitPeriodMillis: Long): GroupCallDescription? {
+		val signal = CompletableDeferred<GroupCallDescription>()
+
+		val groupCallObserver = object : GroupCallObserver {
+			override fun onGroupCallUpdate(call: GroupCallDescription?) {
+				if (call != null) {
+					signal.complete(call)
+				}
+			}
+
+			override fun onGroupCallStart(groupModel: GroupModel) {
+				// noop
+			}
+		}
+
+		return try {
+			logger.debug("Start artificial wait period before sending group call start message")
+			addGroupCallObserver(group, groupCallObserver)
+			withTimeout(waitPeriodMillis) {
+				signal.await().also {
+					logger.debug("Another chosen call has been started for this group. Stop waiting for chosen call.")
+				}
+			}
+		} catch (e: TimeoutCancellationException) {
+			logger.debug("Artificial wait period has expired without another chosen call")
+			null
+		} finally {
+			removeGroupCallObserver(group, groupCallObserver)
+		}
+	}
+
 	/**
 	 * Get the {@link GroupCallController} for a joined call for a certain group.
 	 *
@@ -396,7 +475,12 @@ class GroupCallManagerImpl(
 
 		val group = groupService.getGroup(message)
 		val groupId = group.localGroupId
-		logger.debug("Process group call start for group {}: {}", groupId, message.data)
+		logger.info("Process group call start for group {}: {}", groupId, message.data)
+
+		if (ProtocolDefines.GC_PROTOCOL_VERSION != message.data.protocolVersion.toInt()) {
+			logger.warn("Invalid protocol version {} received. Abort handling of group call start", message.data.protocolVersion)
+			return
+		}
 
 		if (isInvalidSfuBaseUrl(message.data.sfuBaseUrl)) {
 			logger.warn("Invalid sfu base url: {}", message.data.sfuBaseUrl)
@@ -431,29 +515,46 @@ class GroupCallManagerImpl(
 					message.date)
 		}
 
+		notifyGroupCallStart(group, callerContactModel)
+	}
+
+	private suspend fun notifyGroupCallStart(group: GroupModel, callerContactModel: ContactModel?) {
+		val groupId = group.localGroupId
+
 		val chosenCall = runGroupCallRefreshSteps(groupId)
 
-		if (chosenCall != null) {
-			if (ConfigUtils.isGroupCallsEnabled() && !consolidateJoinedCall(chosenCall, groupId)) {
-				if (callerContactModel != null) {
-					// Only needed if the call is not yet joined
-					if (!isJoinedCall(chosenCall)) {
-						logger.debug("Show group call notification")
-						notificationService.addGroupCallNotification(group, callerContactModel)
-						synchronized(callObservers) {
-							callObservers[groupId]?.forEach { it.onGroupCallStart(group, call) }
-						}
-					} else {
-						logger.debug("Call already joined")
-					}
-				} else {
-					logger.debug("Caller could not be determined")
-				}
-			} else if (!ConfigUtils.isGroupCallsEnabled()) {
-				logger.info("Group call is running but disabled. Not showing notification.")
-			}
-		} else {
-			logger.info("Group call seems not to be running any more. Not showing notification.")
+		if (chosenCall == null) {
+			logger.info("Group call seems not to be running any more. Not showing notifications.")
+			return
+		}
+
+		// Only check this _after_ running the group call refresh steps.
+		// During the refresh steps the 'ended'-status will be created if the call has already been
+		// ended
+		if (!ConfigUtils.isGroupCallsEnabled()) {
+			logger.info("Group call is running but disabled. Not showing notifications.")
+			return
+		}
+
+		if (callerContactModel == null) {
+			logger.warn("Caller could not be determined. Not showing notifications.")
+			return
+		}
+
+		if (isJoinedCall(chosenCall)) {
+			logger.info("Call already joined. Not showing notifications.")
+			return
+		}
+
+		logger.debug("Show group call notification")
+		notificationService.addGroupCallNotification(group, callerContactModel)
+
+		notifyGroupCallStartObservers(group)
+	}
+
+	private fun notifyGroupCallStartObservers(group: GroupModel) {
+		synchronized(callObservers) {
+			callObservers[group.localGroupId]?.forEach { it.onGroupCallStart(group) }
 		}
 	}
 
@@ -505,12 +606,13 @@ class GroupCallManagerImpl(
 			leaveCall(joinedCall)
 
 			groupService.getById(groupId.id)?.let {
-				val newGroupController = joinCall(it)
-				newGroupController.microphoneActive = microphoneActive
+				joinCall(it)?.let {
+					it.microphoneActive = microphoneActive
+				}
 			}
 
 			context.startActivity(
-				GroupCallActivity.getStartOrJoinCallIntent(
+				GroupCallActivity.getJoinCallIntent(
 					context,
 					groupId.id,
 					microphoneActive,
@@ -566,7 +668,7 @@ class GroupCallManagerImpl(
 	@WorkerThread
 	private fun createGck(): ByteArray {
 		val random = SecureRandom()
-		val gck = ByteArray(ProtocolDefines.GC_GCK_LENGTH)
+		val gck = ByteArray(GCK_LENGTH)
 		random.nextBytes(gck)
 		return gck
 	}
@@ -589,6 +691,7 @@ class GroupCallManagerImpl(
 	private fun sendGroupCallStartMessage(group: GroupModel, data: GroupCallStartData, startedAt: Date, sendTo: Array<String>?) {
 		GroupCallThreadUtil.assertDispatcherThread()
 
+		logger.debug("Send group call start message")
 		val identities = (sendTo ?: groupService.getGroupIdentities(group))
 			.filter { identity -> contactService.getByIdentity(identity)?.let { ThreemaFeature.canGroupCalls(it.featureMask) } ?: false }
 			.toTypedArray()
@@ -927,15 +1030,21 @@ class GroupCallManagerImpl(
 	}
 
 	@WorkerThread
-	private fun removeRunningCall(callId: CallId): GroupCallDescription? {
+	private fun removeRunningCall(callId: CallId) {
+		removeRunningCalls(setOf(callId))
+	}
+
+	@WorkerThread
+	private fun removeRunningCalls(callIds: Set<CallId>) {
 		GroupCallThreadUtil.assertDispatcherThread()
 
 		return synchronized(runningCalls) {
-			val removedCall = runningCalls.remove(callId).also {
-				logger.debug("call removed: {}, id={}", it != null, callId)
+			callIds.forEach { callId ->
+				val removedCall = runningCalls.remove(callId).also {
+					logger.debug("call removed: {}, id={}", it != null, callId)
+				}
+				removedCall?.let { removePersistedRunningCall(it) }
 			}
-			removedCall?.let { removePersistedRunningCall(it) }
-			removedCall
 		}
 	}
 

+ 1 - 1
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallObserver.kt

@@ -34,5 +34,5 @@ interface GroupCallObserver {
     fun onGroupCallUpdate(call: GroupCallDescription?)
 
     @AnyThread
-    fun onGroupCallStart(groupModel: GroupModel, call: GroupCallDescription?)
+    fun onGroupCallStart(groupModel: GroupModel)
 }

+ 9 - 2
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallThreadUtil.kt

@@ -27,6 +27,7 @@ import kotlinx.coroutines.*
 import java.lang.Runnable
 import java.util.concurrent.Executors
 import java.util.concurrent.ThreadFactory
+import kotlin.coroutines.CoroutineContext
 
 private val logger = LoggingUtil.getThreemaLogger("GroupCallThreadUtil")
 
@@ -48,15 +49,21 @@ class TrulySingleThreadExecutorThreadFactory(
 }
 
 class GroupCallThreadUtil {
+    interface ExceptionHandler {
+        fun handle(t: Throwable)
+    }
+
     companion object {
-        val DISPATCHER: ExecutorCoroutineDispatcher
+        var exceptionHandler: ExceptionHandler? = null
+        val DISPATCHER: CoroutineContext
         lateinit var THREAD: Thread
 
         init {
             val factory = TrulySingleThreadExecutorThreadFactory("GroupCallWorker") {
                 THREAD = it
             }
-            DISPATCHER = Executors.newSingleThreadExecutor(factory).asCoroutineDispatcher()
+            val handler = CoroutineExceptionHandler { _, exception -> exceptionHandler?.handle(exception) ?: throw exception }
+            DISPATCHER = Executors.newSingleThreadExecutor(factory).asCoroutineDispatcher().plus(handler)
         }
 
         fun assertDispatcherThread() {

+ 28 - 1
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt

@@ -89,6 +89,8 @@ internal class GroupCallControllerImpl (
 
     override val connectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>> = CompletableDeferred()
 
+    override val dislodgedParticipants = MutableSharedFlow<ParticipantId>()
+
     private lateinit var _eglBase: EglBase
     override val eglBase: EglBase
         get() = if (this::_eglBase.isInitialized) {
@@ -187,6 +189,26 @@ internal class GroupCallControllerImpl (
         localParticipant = participant
     }
 
+    @WorkerThread
+    override fun purgeCallParticipants(groupMembers: Set<String>) {
+        logger.info("Purge call participants")
+        GroupCallThreadUtil.assertDispatcherThread()
+
+        if (localParticipant.identity !in groupMembers) {
+            logger.info("Not in group anymore. Leave call.")
+            leave()
+        } else {
+            CoroutineScope(Dispatchers.Default).launch {
+                remoteParticipants
+                    .filter { it.identity !in groupMembers }
+                    .forEach {
+                        logger.info("Dislodge participant {}", it.id)
+                        dislodgedParticipants.emit(it.id)
+                    }
+            }
+        }
+    }
+
     @WorkerThread
     override fun updateParticipants(update: GroupCall.ParticipantsUpdate) {
         GroupCallThreadUtil.assertDispatcherThread()
@@ -252,7 +274,12 @@ internal class GroupCallControllerImpl (
     @WorkerThread
     suspend fun join(context: Context, sfuBaseUrl: String, sfuConnection: SfuConnection, onError: () -> Unit) {
         GroupCallThreadUtil.assertDispatcherThread()
-
+        GroupCallThreadUtil.exceptionHandler = object : GroupCallThreadUtil.ExceptionHandler {
+            override fun handle(t: Throwable) {
+                GroupCallThreadUtil.exceptionHandler = null
+                this@GroupCallControllerImpl.callLeftSignal.completeExceptionally(t)
+            }
+        }
         try {
             descriptionSetSignal.await()
             Joining(

+ 1 - 3
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt

@@ -253,7 +253,7 @@ class GroupCallService : Service() {
         return PendingIntent.getActivity(
             applicationContext,
             REQUEST_CODE_JOIN_CALL,
-            GroupCallActivity.getStartOrJoinCallIntent(applicationContext, groupId.id),
+            GroupCallActivity.getJoinCallIntent(applicationContext, groupId.id),
             flags or PENDING_INTENT_FLAG_IMMUTABLE
         )
 
@@ -348,8 +348,6 @@ class GroupCallService : Service() {
         stopService()
     }
 
-    // TODO(ANDR-1956): Maybe add a stop service signal that will be listened to by the service and can be triggered
-    //  if for any reason / exception the service has/should be stopped
     private fun stopService() {
         logger.info("Stop service")
         // TODO(ANDR-1964): When a call is left, this is called twice: one time from leaveCall() and one time from onUnbind()

+ 10 - 0
app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCall.kt

@@ -27,6 +27,7 @@ import ch.threema.app.voip.groupcall.sfu.connection.GroupCallConnectionState
 import ch.threema.app.voip.groupcall.sfu.webrtc.RemoteCtx
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.flow.Flow
 import org.webrtc.EglBase
 
 internal interface GroupCall {
@@ -39,6 +40,15 @@ internal interface GroupCall {
     val callLeftSignal: Deferred<Unit>
     val connectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>>
 
+    /**
+     * In some cases the [GroupCallController] can decide that a participant should be treated as if
+     * it had left the call. This can for example be the case, when someone has been kicked from a group
+     * during a call.
+     *
+     * In such cases the corresponding [Participant]'s [ParticipantId] is emitted by this flow.
+     */
+    val dislodgedParticipants: Flow<ParticipantId>
+
     var description: GroupCallDescription
     var parameters: GroupCallParameters
     var dependencies: GroupCallDependencies

+ 10 - 0
app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCallController.kt

@@ -105,4 +105,14 @@ interface GroupCallController {
 
     @WorkerThread
     fun declineCall()
+
+    /**
+     * Remove all participants (including self) from the call that are no members of the call's group.
+     *
+     * If the local participant is no longer a member of the group, the call must be left.
+     *
+     * @param groupMembers A set containing all group member's identities
+     */
+    @WorkerThread
+    fun purgeCallParticipants(groupMembers: Set<String>)
 }

+ 6 - 2
app/src/main/java/ch/threema/app/voip/groupcall/sfu/SfuConnectionImpl.kt

@@ -49,7 +49,11 @@ private const val SFU_PEEK_PATH_SEGMENT = "peek"
 private const val SFU_JOIN_PATH_SEGMENT = "join"
 
 @WorkerThread
-internal class SfuConnectionImpl (private val apiConnector: APIConnector, private val identityStore: IdentityStoreInterface) : SfuConnection {
+internal class SfuConnectionImpl (
+    private val apiConnector: APIConnector,
+    private val identityStore: IdentityStoreInterface,
+    private val version: Version
+) : SfuConnection {
     private var cachedSfuToken: SfuToken? = null
 
     @AnyThread
@@ -173,7 +177,7 @@ internal class SfuConnectionImpl (private val apiConnector: APIConnector, privat
     }
 
     private fun getUserAgent(): String {
-        return "${ProtocolStrings.USER_AGENT}/${Version().version}"
+        return "${ProtocolStrings.USER_AGENT}/${version.version}"
     }
 
     @WorkerThread

+ 54 - 11
app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Connected.kt

@@ -37,6 +37,7 @@ import ch.threema.domain.protocol.csp.ProtocolDefines
 import com.google.protobuf.InvalidProtocolBufferException
 import java8.util.function.Function
 import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.cancellable
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import org.webrtc.DataChannel
@@ -85,6 +86,9 @@ class Connected internal constructor(
         logger.trace("Set data channel observer")
         setP2sDataChannelObserver()
 
+        logger.trace("Set dislodged participants observer")
+        setDislodgedParticipantsObserver()
+
         // Update the call
         logger.trace("Update call initiated")
         val (_, participantIds) = call.connectedSignal.await()
@@ -151,6 +155,39 @@ class Connected internal constructor(
         })
     }
 
+    @WorkerThread
+    private fun setDislodgedParticipantsObserver() {
+        CoroutineScope(Dispatchers.Default).launch {
+            val observerJob = launch {
+                call.dislodgedParticipants
+                    .cancellable()
+                    .collect {
+                        withContext(GroupCallThreadUtil.DISPATCHER) {
+                            removeDislodgedParticipant(it)
+                        }
+                    }
+            }
+            launch {
+                try {
+                    call.callLeftSignal.await()
+                } catch (e: Exception) {
+                    // noop
+                }
+                logger.debug("Cancel dislodged participants observer job")
+                observerJob.cancel()
+            }
+        }
+    }
+
+    @WorkerThread
+    private suspend fun removeDislodgedParticipant(participantId: ParticipantId) {
+        logger.debug("Remove dislodged participant {}", participantId)
+        GroupCallThreadUtil.assertDispatcherThread()
+
+        unsubscribeMicrophone(participantId)
+        removeParticipantFromCall(participantId)
+    }
+
     @WorkerThread
     private fun removeP2sDataChannelObserver() {
         GroupCallThreadUtil.assertDispatcherThread()
@@ -252,10 +289,7 @@ class Connected internal constructor(
     private suspend fun handleLeaveMessage(message: S2PMessage.SfuParticipantLeft) {
         GroupCallThreadUtil.assertDispatcherThread()
 
-        logger.info("Scheduling to remove participant '{}' from call", message.participantId)
-        updateCallMutex.withLock {
-            removeParticipantFromCall(message.participantId)
-        }
+        removeParticipantFromCall(message.participantId)
     }
 
     @WorkerThread
@@ -316,6 +350,13 @@ class Connected internal constructor(
         call.context.sendMessageToSfu { P2SMessage.SubscribeParticipantMicrophone(contexts.remote.participant.id) }
     }
 
+    @WorkerThread
+    private fun unsubscribeMicrophone(participantId: ParticipantId) {
+        GroupCallThreadUtil.assertDispatcherThread()
+
+        call.context.sendMessageToSfu { P2SMessage.UnsubscribeParticipantMicrophone(participantId) }
+    }
+
     @WorkerThread
     private fun sendCurrentCaptureStates(contexts: P2PContexts) {
         GroupCallThreadUtil.assertDispatcherThread()
@@ -359,18 +400,20 @@ class Connected internal constructor(
         runPostHandshakeStepsOnHandshakeComplete(handshake)
     }
 
-    /** May only be called with `updateCallMutex` held! */
     @WorkerThread
     private suspend fun removeParticipantFromCall(participantId: ParticipantId) {
         GroupCallThreadUtil.assertDispatcherThread()
 
-        logger.info("Removing participant '{}' from call", participantId)
-        call.context.removeParticipant(participantId)?.let {
-            call.updateParticipants(GroupCall.ParticipantsUpdate.removeParticipant(it))
-            ctx.updateCall(call, remove = mutableSetOf(it.id), add = mutableSetOf())
-            increaseEpoch()
+        logger.info("Scheduling to remove participant '{}' from call", participantId)
+        updateCallMutex.withLock {
+            logger.info("Removing participant '{}' from call", participantId)
+            call.context.removeParticipant(participantId)?.let {
+                call.updateParticipants(GroupCall.ParticipantsUpdate.removeParticipant(it))
+                ctx.updateCall(call, remove = mutableSetOf(it.id), add = mutableSetOf())
+                increaseEpoch()
+            }
+            refreshCallStateUpdateInterval()
         }
-        refreshCallStateUpdateInterval()
     }
 
     /** May only be called with `updateCallMutex` held! */

+ 17 - 0
app/src/main/java/ch/threema/app/voip/groupcall/sfu/messages/P2SMessages.kt

@@ -84,6 +84,23 @@ sealed class P2SMessage {
         }
     }
 
+    data class UnsubscribeParticipantMicrophone(val participantId: ParticipantId) : P2SMessage() {
+        override val type = "UnsubscribeParticipantMicrophone"
+
+        override fun wrap(envelope: ParticipantToSfu.Envelope.Builder): ParticipantToSfu.Envelope.Builder {
+            val unsubscribe = ParticipantToSfu.ParticipantMicrophone.Unsubscribe
+                .newBuilder()
+                .build()
+
+            val participantMicrophone = ParticipantToSfu.ParticipantMicrophone.newBuilder()
+                .setParticipantId(participantId.id.toInt())
+                .setUnsubscribe(unsubscribe)
+                .build()
+
+            return envelope.setRequestParticipantMicrophone(participantMicrophone)
+        }
+    }
+
     data class SubscribeParticipantCamera(val participantId: ParticipantId, val width: Int, val height: Int, val fps: Int) : P2SMessage() {
         override val type = "SubscribeParticipantCamera"
 

+ 20 - 21
app/src/main/java/ch/threema/app/voip/services/VoipStateService.java

@@ -21,25 +21,6 @@
 
 package ch.threema.app.voip.services;
 
-import static androidx.media.AudioAttributesCompat.USAGE_NOTIFICATION_RINGTONE;
-import static ch.threema.app.ThreemaApplication.INCOMING_CALL_NOTIFICATION_ID;
-import static ch.threema.app.ThreemaApplication.getAppContext;
-import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PATTERN_INCOMING_CALL;
-import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PATTERN_SILENT;
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_CALL;
-import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
-import static ch.threema.app.voip.activities.CallActivity.EXTRA_ACCEPT_INCOMING_CALL;
-import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CALL_ID;
-import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CONTACT_IDENTITY;
-import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_REJECT_REASON;
-import static ch.threema.app.voip.services.VoipCallService.ACTION_ICE_CANDIDATES;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_ACTIVITY_MODE;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_CALL_ID;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_CANCEL_WEAR;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_CANDIDATES;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_CONTACT_IDENTITY;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_IS_INITIATOR;
-
 import android.app.Notification;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
@@ -82,9 +63,9 @@ import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.Set;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -127,6 +108,24 @@ import ch.threema.domain.protocol.csp.messages.voip.features.VideoFeature;
 import ch.threema.storage.models.ContactModel;
 import java8.util.concurrent.CompletableFuture;
 
+import static ch.threema.app.ThreemaApplication.INCOMING_CALL_NOTIFICATION_ID;
+import static ch.threema.app.ThreemaApplication.getAppContext;
+import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PATTERN_INCOMING_CALL;
+import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PATTERN_SILENT;
+import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_CALL;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
+import static ch.threema.app.voip.activities.CallActivity.EXTRA_ACCEPT_INCOMING_CALL;
+import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CALL_ID;
+import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CONTACT_IDENTITY;
+import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_REJECT_REASON;
+import static ch.threema.app.voip.services.VoipCallService.ACTION_ICE_CANDIDATES;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_ACTIVITY_MODE;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_CALL_ID;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_CANCEL_WEAR;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_CANDIDATES;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_CONTACT_IDENTITY;
+import static ch.threema.app.voip.services.VoipCallService.EXTRA_IS_INITIATOR;
+
 /**
  * The service keeping track of VoIP call state.
  *
@@ -1604,7 +1603,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 				if (Build.VERSION.SDK_INT <= 21) {
 					ringtonePlayer.setAudioStreamType(AudioManager.STREAM_RING);
 				} else {
-					ringtonePlayer.setAudioAttributes(SoundUtil.getAudioAttributesForUsage(USAGE_NOTIFICATION_RINGTONE));
+					ringtonePlayer.setAudioAttributes(SoundUtil.getAudioAttributesForCallNotification());
 				}
 
 				try {

+ 40 - 16
app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt

@@ -25,6 +25,7 @@ import android.app.Application
 import android.graphics.Bitmap
 import androidx.annotation.AnyThread
 import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
 import androidx.lifecycle.*
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
@@ -141,7 +142,7 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 	}
 
 	@AnyThread
-	override fun onGroupCallStart(groupModel: GroupModel, call: GroupCallDescription?) {
+	override fun onGroupCallStart(groupModel: GroupModel) {
 		logger.trace("Group call start")
 	}
 
@@ -183,25 +184,19 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 	}
 
 	@UiThread
-	fun joinCall() {
+	fun joinCall(intention: GroupCallIntention) {
 		groupId.value?.let {
 			groupService.getById(it.id)?.let {
 				joinJob = CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
 					try {
-						if (!groupCallManager.hasJoinedCall(it.localGroupId)) {
-							if (groupCallManager.hasJoinedCall()) {
-								val groupCallController = groupCallManager.getCurrentGroupCallController()
-								groupCallManager.abortCurrentCall()
-								groupCallController?.callDisposedSignal?.await()
-							}
-							connectingState.postValue(ConnectingState.INITIATED)
-						}
-						callController = groupCallManager.joinCall(it)
-						connectingState.postValue(ConnectingState.COMPLETED)
-						audioManager = groupCallManager.getAudioManager()
-						callRunning.postValue(true)
-						withContext(Dispatchers.Main) {
-							initialiseValues()
+						ensureNoCallsInOtherGroup(it)
+
+						val controller = joinOrCreateCall(it, intention)
+
+						if (controller == null) {
+							finishEvents.postValue(FinishEvent(FinishEvent.Reason.NO_SUCH_CALL))
+						} else {
+							completeJoining(controller)
 						}
 					} catch (e: CancellationException) {
 						logger.warn("Join call has been cancelled")
@@ -217,6 +212,35 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 		}
 	}
 
+	@WorkerThread
+	private suspend fun ensureNoCallsInOtherGroup(groupModel: GroupModel) {
+		if (!groupCallManager.hasJoinedCall(groupModel.localGroupId)) {
+			if (groupCallManager.hasJoinedCall()) {
+				val groupCallController = groupCallManager.getCurrentGroupCallController()
+				groupCallManager.abortCurrentCall()
+				groupCallController?.callDisposedSignal?.await()
+			}
+			connectingState.postValue(ConnectingState.INITIATED)
+		}
+	}
+
+	@WorkerThread
+	private suspend fun joinOrCreateCall(groupModel: GroupModel, intention: GroupCallIntention) = when (intention) {
+		GroupCallIntention.JOIN -> groupCallManager.joinCall(groupModel)
+		GroupCallIntention.JOIN_OR_CREATE -> groupCallManager.createCall(groupModel)
+	}
+
+	@WorkerThread
+	private suspend fun completeJoining(controller: GroupCallController) {
+		callController = controller
+		connectingState.postValue(ConnectingState.COMPLETED)
+		audioManager = groupCallManager.getAudioManager()
+		callRunning.postValue(true)
+		withContext(Dispatchers.Main) {
+			initialiseValues()
+		}
+	}
+
 	@UiThread
 	fun selectAudioDevice(device: AudioDevice) {
 		audioManager.selectAudioDevice(device)

+ 2 - 2
app/src/main/java/ch/threema/app/webclient/activities/WebDiagnosticsActivity.java

@@ -324,8 +324,8 @@ public class WebDiagnosticsActivity extends ThreemaToolbarActivity implements Te
 						"\n---\n" +
 						caption +
 						"\n---\n" +
-						ConfigUtils.getDeviceInfo(WebDiagnosticsActivity.this, false) + "\n" +
-						"Threema " + ConfigUtils.getFullAppVersion(WebDiagnosticsActivity.this) + "\n" +
+						ConfigUtils.getSupportDeviceInfo(WebDiagnosticsActivity.this) + "\n" +
+						"Threema " + ConfigUtils.getAppVersion(WebDiagnosticsActivity.this) + "\n" +
 						getMyIdentity(), messageReceiver);
 					Toast.makeText(getApplicationContext(), R.string.message_sent, Toast.LENGTH_LONG).show();
 					finish();

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/converter/ClientInfo.java

@@ -109,7 +109,7 @@ public class ClientInfo extends Converter {
 		data.put(DEVICE, Build.MODEL);
 		data.put(OS, "android");
 		data.put(OS_VERSION, Build.VERSION.RELEASE);
-		data.put(APP_VERSION, ConfigUtils.getFullAppVersion(appContext));
+		data.put(APP_VERSION, ConfigUtils.getAppVersion(appContext));
 		if (pushToken != null) {
 			// To be able to differentiate between HMS and FCM push tokens without any
 			// protocol changes, we'll prefix the push token with "hms;".

+ 0 - 2
app/src/main/java/ch/threema/app/webrtc/AudioContext.kt

@@ -27,8 +27,6 @@ import org.webrtc.*
 import java.util.concurrent.locks.ReentrantLock
 import kotlin.concurrent.withLock
 
-// TODO(ANDR-1956): replace completable futures with suspend..
-
 abstract class AudioContext(
     track: AudioTrack,
 ) {

+ 0 - 1
app/src/main/java/ch/threema/storage/factories/DistributionListMessageModelFactory.java

@@ -37,7 +37,6 @@ import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseUtil;
 import ch.threema.storage.QueryBuilder;
 import ch.threema.storage.models.DistributionListMessageModel;
-import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.MessageType;
 
 public class DistributionListMessageModelFactory extends AbstractMessageModelFactory {

+ 9 - 9
app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java

@@ -23,6 +23,9 @@ package ch.threema.storage.models.data.media;
 
 import android.util.JsonWriter;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
 import org.slf4j.Logger;
 
 import java.io.IOException;
@@ -30,8 +33,6 @@ import java.io.StringWriter;
 import java.util.Iterator;
 import java.util.Map;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import ch.threema.app.utils.JsonUtil;
 import ch.threema.app.utils.ListReader;
 import ch.threema.app.utils.MimeUtil;
@@ -77,8 +78,8 @@ public class FileDataModel implements MediaMessageDataInterface {
 		this.fileSize = fileSize;
 		this.fileName = fileName;
 		this.renderingType = renderingType;
-		this.isDownloaded = isDownloaded;
 		this.caption = caption;
+		this.isDownloaded = isDownloaded;
 		this.metaData = metaData;
 	}
 
@@ -96,8 +97,8 @@ public class FileDataModel implements MediaMessageDataInterface {
 		this.fileSize = fileSize;
 		this.fileName = fileName;
 		this.renderingType = renderingType;
-		this.isDownloaded = isDownloaded;
 		this.caption = caption;
+		this.isDownloaded = isDownloaded;
 		this.metaData = metaData;
 	}
 
@@ -141,7 +142,8 @@ public class FileDataModel implements MediaMessageDataInterface {
 		return new byte[0];
 	}
 
-	public @NonNull String getMimeType() {
+	@NonNull
+	public String getMimeType() {
 		if (this.mimeType == null) {
 			return MimeUtil.MIME_TYPE_DEFAULT;
 		}
@@ -152,10 +154,8 @@ public class FileDataModel implements MediaMessageDataInterface {
 		this.mimeType = mimeType;
 	}
 
-	public @NonNull String getThumbnailMimeType() {
-		if (this.thumbnailMimeType == null) {
-			return MimeUtil.MIME_TYPE_IMAGE_JPG;
-		}
+	@Nullable
+	public String getThumbnailMimeType() {
 		return this.thumbnailMimeType;
 	}
 

+ 3 - 1
app/src/main/java/ch/threema/storage/models/data/status/ForwardSecurityStatusDataModel.kt

@@ -34,7 +34,8 @@ class ForwardSecurityStatusDataModel : StatusDataModelInterface {
         ForwardSecurityStatusType.FORWARD_SECURITY_ESTABLISHED,
         ForwardSecurityStatusType.FORWARD_SECURITY_ESTABLISHED_RX,
         ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGES_SKIPPED,
-        ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER
+        ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER,
+        ForwardSecurityStatusType.FORWARD_SECURITY_UNAVAILABLE_DOWNGRADE,
     ])
     @Retention(AnnotationRetention.SOURCE)
     annotation class ForwardSecurityStatusType {
@@ -46,6 +47,7 @@ class ForwardSecurityStatusDataModel : StatusDataModelInterface {
 			const val FORWARD_SECURITY_ESTABLISHED_RX = 4
 			const val FORWARD_SECURITY_MESSAGES_SKIPPED = 5
             const val FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER = 6
+            const val FORWARD_SECURITY_UNAVAILABLE_DOWNGRADE = 7
         }
     }
 

+ 1 - 1
app/src/main/res/animator/selector_gallery_image.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/animator/selector_list_checkbox_bg.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/animator/selector_list_checkbox_fg.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable-anydpi/ic_close_black_24dp.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/bubble_date_separator.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/circle_transparent.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 <selector xmlns:android="http://schemas.android.com/apk/res/android">

+ 1 - 1
app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/ic_archive_outline.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/ic_filter_list_black_24dp.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/ic_gps_fixed.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2019 Threema GmbH
+  ~ Copyright (c) 2019-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

+ 1 - 1
app/src/main/res/drawable/ic_pin_circle.xml

@@ -1,5 +1,5 @@
 <!--
-  ~ Copyright (c) 2018 Threema GmbH
+  ~ Copyright (c) 2018-2023 Threema GmbH
   ~ All rights reserved.
   -->
 

部分文件因为文件数量过多而无法显示