Browse Source

Version 5.2

Threema 1 year ago
parent
commit
a59da1542c
100 changed files with 4369 additions and 820 deletions
  1. 6 6
      app/assets/license.html
  2. 64 21
      app/build.gradle
  3. 4 0
      app/jni/Android.mk
  4. BIN
      app/libs/agconnect-apms-plugin-1.5.2.302.jar
  5. BIN
      app/libs/agconnect-apms-plugin-1.6.2.300.jar
  6. BIN
      app/libs/agconnect-core-1.5.0.300.aar
  7. BIN
      app/libs/agconnect-core-1.9.1.301.aar
  8. BIN
      app/libs/agconnect-crash-symbol-lib-1.6.0.300.jar
  9. BIN
      app/libs/agconnect-crash-symbol-lib-1.9.1.301.jar
  10. BIN
      app/libs/agcp-1.6.0.300.jar
  11. BIN
      app/libs/agcp-1.9.1.301.jar
  12. 1 1
      app/proguard-project.txt
  13. 53 0
      app/src/androidTest/java/ch/threema/app/PermissionRuleUtils.kt
  14. 4 8
      app/src/androidTest/java/ch/threema/app/ScreenshotTakingRule.java
  15. 3 2
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  16. 499 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt
  17. 67 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt
  18. 333 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  19. 282 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  20. 564 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  21. 180 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt
  22. 40 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt
  23. 31 43
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java
  24. 123 6
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  25. 6 3
      app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java
  26. 3 2
      app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.java
  27. 2 2
      app/src/foss_based/assets/license.html
  28. 30 4
      app/src/hms/agconnect-services.json
  29. 12 4
      app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  30. 2 2
      app/src/hms_services_based/java/ch/threema/app/push/PushService.java
  31. 32 6
      app/src/hms_work/agconnect-services.json
  32. 33 14
      app/src/main/AndroidManifest.xml
  33. 0 123
      app/src/main/java/ch/threema/app/AutostartService.java
  34. 146 132
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  35. 0 2
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  36. 8 9
      app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java
  37. 202 0
      app/src/main/java/ch/threema/app/activities/BackupRestoreProgressActivity.kt
  38. 25 8
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  39. 1 11
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  40. 7 1
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  41. 6 2
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  42. 5 1
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  43. 1 1
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  44. 1 1
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  45. 136 59
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  46. 149 29
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  47. 26 9
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  48. 6 1
      app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java
  49. 20 11
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  50. 22 27
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  51. 1 1
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  52. 6 1
      app/src/main/java/ch/threema/app/activities/NotificationsActivity.java
  53. 6 1
      app/src/main/java/ch/threema/app/activities/PinLockActivity.java
  54. 6 1
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.java
  55. 2 2
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  56. 26 25
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  57. 365 0
      app/src/main/java/ch/threema/app/activities/StarredMessagesActivity.kt
  58. 98 47
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  59. 1 1
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  60. 3 1
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  61. 35 1
      app/src/main/java/ch/threema/app/activities/ThreemaAppCompatActivity.java
  62. 3 20
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  63. 1 1
      app/src/main/java/ch/threema/app/activities/UnlockMasterKeyActivity.java
  64. 6 1
      app/src/main/java/ch/threema/app/activities/WhatsNew2Activity.java
  65. 6 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java
  66. 6 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java
  67. 6 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  68. 7 2
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java
  69. 6 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackgroundActivity.java
  70. 31 8
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java
  71. 40 8
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  72. 3 2
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  73. 3 2
      app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java
  74. 49 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardIntroActivity.java
  75. 58 18
      app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java
  76. 5 0
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  77. 1 1
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  78. 111 23
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  79. 1 1
      app/src/main/java/ch/threema/app/adapters/GroupListAdapter.java
  80. 1 1
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.kt
  81. 1 2
      app/src/main/java/ch/threema/app/adapters/MediaGalleryRepository.kt
  82. 3 2
      app/src/main/java/ch/threema/app/adapters/MessageListAdapterItem.kt
  83. 8 6
      app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt
  84. 1 1
      app/src/main/java/ch/threema/app/adapters/RecentListAdapter.java
  85. 1 1
      app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt
  86. 4 1
      app/src/main/java/ch/threema/app/adapters/decorators/AdapterDecorator.java
  87. 14 6
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  88. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt
  89. 110 0
      app/src/main/java/ch/threema/app/adapters/decorators/GroupStatusAdapterDecorator.kt
  90. 13 5
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  91. 1 1
      app/src/main/java/ch/threema/app/asynctasks/DeleteContactAsyncTask.java
  92. 2 2
      app/src/main/java/ch/threema/app/asynctasks/DeleteConversationsAsyncTask.java
  93. 1 1
      app/src/main/java/ch/threema/app/asynctasks/DeleteGroupAsyncTask.java
  94. 1 1
      app/src/main/java/ch/threema/app/asynctasks/DeleteMyGroupAsyncTask.java
  95. 1 1
      app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java
  96. 1 1
      app/src/main/java/ch/threema/app/asynctasks/LeaveGroupAsyncTask.java
  97. 82 29
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  98. 92 33
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  99. 3 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  100. 1 0
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

+ 6 - 6
app/assets/license.html

@@ -175,12 +175,6 @@ SUCH DAMAGE.</p>
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
 
-<h2>ExoPlayer</h2>
-
-<p>Copyright (c) 2017 Google, Inc.</p>
-
-<p>Licensed under the Apache License, version 2.0 (copy below).</p>
-
 <h2>ez-vcard</h2>
 
 <p>Copyright (c) 2012-2021, Michael Angstadt</p>
@@ -216,6 +210,12 @@ SUCH DAMAGE.</p>
     of the authors and should not be interpreted as representing official policies,
     either expressed or implied, of the FreeBSD Project.</p>
 
+<h2>Gesture Views</h2>
+
+<p>Copyright (c) 2022 Alex Vasilkov</p>
+
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+
 <h2>Jackson JSON-processor</h2>
 
 <p>Copyright (c) 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>

+ 64 - 21
app/build.gradle

@@ -1,6 +1,7 @@
 import com.android.tools.profgen.ArtProfileKt
 import com.android.tools.profgen.ArtProfileSerializer
 import com.android.tools.profgen.DexFile
+import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 
 plugins {
     id 'org.sonarqube'
@@ -17,8 +18,9 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "5.1.4"
+def app_version = "5.2"
 def beta_suffix = "" // with leading dash
+def defaultVersionCode = 930
 
 /**
  * Return the git hash, if git is installed.
@@ -86,17 +88,19 @@ android {
     //       make sure to adjust them in `scripts/Dockerfile` and
     //       `.gitlab-ci.yml` as well!
     compileSdkVersion 33
-    buildToolsVersion '33.0.0'
-    ndkVersion '25.1.8937393'
+    buildToolsVersion '33.0.2'
+    ndkVersion '26.0.10792818'
 
     defaultConfig {
+        // https://developer.android.com/training/testing/espresso/setup#analytics
+        testInstrumentationRunnerArguments notAnnotation: 'ch.threema.app.TestFastlaneOnly,ch.threema.app.DangerousTest', disableAnalytics: 'true'
         minSdkVersion 21
         //noinspection OldTargetApi
-        targetSdkVersion 31
+        targetSdkVersion 33
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 922
+        versionCode defaultVersionCode
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -129,6 +133,7 @@ android {
         buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-%h.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
+        buildConfigField "byte[]", "THREEMA_PUSH_PUBLIC_KEY", "new byte[] {(byte) 0xfd, (byte) 0x71, (byte) 0x1e, (byte) 0x1a, (byte) 0x0d, (byte) 0xb0, (byte) 0xe2, (byte) 0xf0, (byte) 0x3f, (byte) 0xca, (byte) 0xab, (byte) 0x6c, (byte) 0x43, (byte) 0xda, (byte) 0x25, (byte) 0x75, (byte) 0xb9, (byte) 0x51, (byte) 0x36, (byte) 0x64, (byte) 0xa6, (byte) 0x2a, (byte) 0x12, (byte) 0xbd, (byte) 0x07, (byte) 0x28, (byte) 0xd8, (byte) 0x7f, (byte) 0x71, (byte) 0x25, (byte) 0xcc, (byte) 0x24}"
         buildConfigField "String", "ONPREM_ID_PREFIX", "\"O\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
         buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
@@ -157,26 +162,50 @@ android {
         }
 
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
-        testInstrumentationRunnerArgument 'notAnnotation', 'ch.threema.app.TestFastlaneOnly,ch.threema.app.DangerousTest'
-        testInstrumentationRunnerArgument 'disableAnalytics', 'true' // https://developer.android.com/training/testing/espresso/setup#analytics
+
+        // Only include language resources for those languages
+        resourceConfigurations += [
+            "en",
+            "be-rBY",
+            "ca",
+            "cs",
+            "de",
+            "es",
+            "fr",
+            "hu",
+            "it",
+            "ja",
+            "nl-rNL",
+            "no",
+            "pl",
+            "pt-rBR",
+            "rm",
+            "ru",
+            "sk",
+            "tr",
+            "uk",
+            "zh-rCN",
+            "zh-rTW"
+        ]
     }
 
     splits {
         abi {
             enable true
             reset()
-            include 'armeabi-v7a', 'x86', "arm64-v8a", "x86_64"
+            include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
             exclude 'armeabi', 'mips', 'mips64'
             universalApk true
         }
     }
 
     // Assign different version code for each output
-    project.ext.versionCodes = ['armeabi': 1, 'armeabi-v7a': 2, 'arm64-v8a': 3, 'mips': 5, 'mips64': 6, 'x86': 8, 'x86_64': 9]
+    def abiVersionCodes = ['armeabi-v7a': 2, 'arm64-v8a': 3, 'x86': 8, 'x86_64': 9]
     android.applicationVariants.all { variant ->
         variant.outputs.each { output ->
+            def abi = output.getFilter("ABI")
             output.versionCodeOverride =
-                    project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 0) * 1000000 + android.defaultConfig.versionCode
+                    abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
         }
     }
 
@@ -586,6 +615,10 @@ android {
                     outputs.upToDateWhen { false }
                     exceptionFormat = 'full'
                 }
+
+                jvmArgs = jvmArgs + ['--add-opens=java.base/java.util=ALL-UNNAMED']
+                jvmArgs = jvmArgs + ['--add-opens=java.base/java.util.stream=ALL-UNNAMED']
+                jvmArgs = jvmArgs + ['--add-opens=java.base/java.lang=ALL-UNNAMED']
             }
             // By default, local unit tests throw an exception any time the code you are testing tries to access
             // Android platform APIs (unless you mock Android dependencies yourself or with a testing
@@ -602,12 +635,12 @@ android {
 
     java {
         toolchain {
-            languageVersion.set(JavaLanguageVersion.of(11))
+            languageVersion.set(JavaLanguageVersion.of(17))
         }
     }
 
     kotlin {
-        jvmToolchain(11)
+        jvmToolchain(17)
     }
 
     androidResources {
@@ -687,6 +720,7 @@ dependencies {
     }
 
     implementation project(':domain')
+    implementation project(path: ':task-manager')
 
     implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
 
@@ -699,21 +733,22 @@ dependencies {
     implementation 'org.apache.commons:commons-text:1.10.0'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.28'
-    implementation 'com.github.CanHub:Android-Image-Cropper:4.3.0'
+    implementation 'com.vanniktech:android-image-cropper:4.5.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
-    implementation 'me.zhanghai.android.fastscroll:library:1.2.0'
+    implementation 'me.zhanghai.android.fastscroll:library:1.3.0'
     implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3'
+    implementation 'com.alexvasilkov:gesture-views:2.8.3'
 
     // AndroidX / Jetpack support libraries
     implementation "androidx.preference:preference-ktx:1.2.1"
-    implementation 'androidx.recyclerview:recyclerview:1.3.1'
+    implementation 'androidx.recyclerview:recyclerview:1.3.2'
     implementation 'androidx.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation 'androidx.work:work-runtime-ktx:2.8.1'
-    implementation 'androidx.fragment:fragment-ktx:1.5.7'
+    implementation 'androidx.fragment:fragment-ktx:1.6.2'
     implementation 'androidx.activity:activity-ktx:1.7.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
@@ -734,7 +769,7 @@ dependencies {
     implementation "androidx.lifecycle:lifecycle-process:2.6.2"
     implementation "androidx.lifecycle:lifecycle-common-java8:2.6.2"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
-    implementation "androidx.paging:paging-runtime-ktx:3.1.1"
+    implementation "androidx.paging:paging-runtime-ktx:3.2.1"
     implementation "androidx.sharetarget:sharetarget:1.2.0"
     implementation 'androidx.room:room-runtime:2.5.2'
     kapt 'androidx.room:room-compiler:2.5.2'
@@ -802,7 +837,7 @@ dependencies {
     androidTestImplementation 'androidx.test:runner:1.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+    androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.appcompat', module: 'appcompat'
@@ -816,6 +851,8 @@ dependencies {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
+    androidTestImplementation 'androidx.test:core-ktx:1.5.0'
+    androidTestImplementation "org.mockito:mockito-core:4.8.1"
 
     // Google Play Services and related libraries
     def googleDependencies = [
@@ -823,7 +860,7 @@ dependencies {
         'com.google.android.gms:play-services-base:18.1.0': [],
 
         // Firebase push
-        'com.google.firebase:firebase-messaging:23.1.2': [
+        'com.google.firebase:firebase-messaging:23.2.1': [
             [group: 'com.google.firebase', module: 'firebase-core'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
@@ -880,8 +917,14 @@ dependencies {
         hmsImplementation(dependency) { excludes.each { exclude it } }
         hms_workImplementation(dependency) { excludes.each { exclude it } }
     }
-    hmsImplementation(name: 'agconnect-core-1.5.0.300', ext: 'aar')
-    hms_workImplementation(name: 'agconnect-core-1.5.0.300', ext: 'aar')
+    hmsImplementation(name: 'agconnect-core-1.9.1.301', ext: 'aar')
+    hms_workImplementation(name: 'agconnect-core-1.9.1.301', ext: 'aar')
+}
+
+tasks.withType(KaptGenerateStubs).configureEach {
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_11.toString()
+    }
 }
 
 sonarqube {

+ 4 - 0
app/jni/Android.mk

@@ -25,6 +25,8 @@ LOCAL_C_INCLUDES := $(LOCAL_PATH)/scrypt/include
 LOCAL_CFLAGS     += -DANDROID -DHAVE_CONFIG_H -DANDROID_TARGET_ARCH="$(TARGET_ARCH)"
 LOCAL_LDFLAGS    += -lc -llog
 
+LOCAL_LDFLAGS    += -Wl,--build-id=none  # Reproducible builds
+
 include $(BUILD_SHARED_LIBRARY)
 
 # libnacl
@@ -39,4 +41,6 @@ LOCAL_SRC_FILES  += $(LOCAL_PATH)/nacl/curve25519-jni.c
 LOCAL_SRC_FILES  += $(LOCAL_PATH)/nacl/smult_donna.c
 LOCAL_SRC_FILES  += $(LOCAL_PATH)/nacl/smult_donna-c64.c
 
+LOCAL_LDFLAGS    += -Wl,--build-id=none  # Reproducible builds
+
 include $(BUILD_SHARED_LIBRARY)

BIN
app/libs/agconnect-apms-plugin-1.5.2.302.jar


BIN
app/libs/agconnect-apms-plugin-1.6.2.300.jar


BIN
app/libs/agconnect-core-1.5.0.300.aar


BIN
app/libs/agconnect-core-1.9.1.301.aar


BIN
app/libs/agconnect-crash-symbol-lib-1.6.0.300.jar


BIN
app/libs/agconnect-crash-symbol-lib-1.9.1.301.jar


BIN
app/libs/agcp-1.6.0.300.jar


BIN
app/libs/agcp-1.9.1.301.jar


+ 1 - 1
app/proguard-project.txt

@@ -22,7 +22,7 @@
 -dontobfuscate
 -verbose
 
--keepattributes EnclosingMethod,InnerClasses,Exceptions,*Annotation*,SourceFile,LineNumberTable
+-keepattributes EnclosingMethod,InnerClasses,Exceptions,*Annotation*,SourceFile,LineNumberTable,Signature
 
 -keeppackagenames ch.threema.**
 -keeppackagenames org.saltyrtc.**

+ 53 - 0
app/src/androidTest/java/ch/threema/app/PermissionRuleUtils.kt

@@ -0,0 +1,53 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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
+
+import android.os.Build
+import androidx.test.rule.GrantPermissionRule
+
+/**
+ * Get the permission rule for the notification permission. This method can be used to only grant
+ * the permission for Android TIRAMISU and newer.
+ */
+fun getNotificationPermissionRule(): GrantPermissionRule {
+    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+        GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS)
+    } else {
+        GrantPermissionRule.grant()
+    }
+}
+
+/**
+ * Get the READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE permissions. This method can be used to
+ * grant the permissions only for those android versions that need them.
+ */
+fun getReadWriteExternalStoragePermissionRule(): GrantPermissionRule {
+    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+        // Not needed for Q and newer, therefore return an empty grant permission rule
+        GrantPermissionRule.grant()
+    } else {
+        GrantPermissionRule.grant(
+            android.Manifest.permission.READ_EXTERNAL_STORAGE,
+            android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+        )
+    }
+}

+ 4 - 8
app/src/androidTest/java/ch/threema/app/ScreenshotTakingRule.java

@@ -23,7 +23,6 @@ package ch.threema.app;
 
 import android.util.Log;
 
-import org.junit.Rule;
 import org.junit.rules.RuleChain;
 import org.junit.rules.TestWatcher;
 import org.junit.runner.Description;
@@ -35,9 +34,10 @@ import java.io.IOException;
 import java.io.OutputStream;
 
 import androidx.test.platform.app.InstrumentationRegistry;
-import androidx.test.rule.GrantPermissionRule;
 import androidx.test.uiautomator.UiDevice;
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 /**
  * When a test fails, take a screenshot.
  *
@@ -50,14 +50,10 @@ public class ScreenshotTakingRule extends TestWatcher {
 
 	public static RuleChain getRuleChain() {
 		return RuleChain
-			.outerRule(GrantPermissionRule.grant(
-				"android.permission.READ_EXTERNAL_STORAGE",
-				"android.permission.WRITE_EXTERNAL_STORAGE"
-			))
+			.outerRule(getReadWriteExternalStoragePermissionRule())
 			.around(new ScreenshotTakingRule());
 	}
 
-	@SuppressWarnings("ResultOfMethodCallIgnored")
 	@Override
 	protected void failed(Throwable e, Description description) {
 		final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
@@ -76,7 +72,7 @@ public class ScreenshotTakingRule extends TestWatcher {
 
 		// Dump UI state
 		try {
-			try (OutputStream stream = new BufferedOutputStream(new FileOutputStream(new File(basePath + ".uix")))) {
+			try (OutputStream stream = new BufferedOutputStream(new FileOutputStream(basePath + ".uix"))) {
 				// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
 				// method leaks a file descriptor.
 				device.dumpWindowHierarchy(stream);

+ 3 - 2
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -21,7 +21,6 @@
 
 package ch.threema.app.backuprestore.csv;
 
-import android.Manifest;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Build;
@@ -73,6 +72,8 @@ import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import java8.util.stream.StreamSupport;
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @DangerousTest // Deletes data and possibly identity
@@ -94,7 +95,7 @@ public class BackupServiceTest {
     private @NonNull BallotService ballotService;
 
 	@Rule
-	public GrantPermissionRule permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
 
 	/**
 	 * Ensure that an identity is set up, initialize static {@link #TEST_IDENTITY} variable.

+ 499 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt

@@ -0,0 +1,499 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import android.Manifest
+import android.os.Build
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.launchActivity
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.NoMatchingViewException
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.rule.GrantPermissionRule
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.activities.HomeActivity
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.services.FileService
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.base.crypto.NonceStoreInterface
+import ch.threema.domain.helpers.InMemoryContactStore
+import ch.threema.domain.models.GroupId
+import ch.threema.domain.protocol.csp.coders.MessageBox
+import ch.threema.domain.protocol.csp.coders.MessageCoder
+import ch.threema.domain.protocol.csp.connection.MessageQueue
+import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
+import ch.threema.domain.protocol.csp.messages.AbstractMessage
+import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
+import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
+import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
+import ch.threema.domain.stores.ContactStore
+import ch.threema.domain.stores.IdentityStoreInterface
+import ch.threema.storage.DatabaseServiceNew
+import ch.threema.storage.models.GroupMemberModel
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.lang.reflect.Field
+
+/**
+ * A collection of basic data and utility functions to test group control messages. If the common
+ * group receive steps should not be executed for a certain message type, the common group step
+ * receive methods should be overridden.
+ */
+abstract class GroupControlTest<T : AbstractGroupMessage> {
+
+    protected val myContact: TestContact = TestHelpers.TEST_CONTACT
+    protected val contactA = TestContact("12345678")
+    protected val contactB = TestContact("ABCDEFGH")
+    protected val contactC = TestContact("SX96PM5A")
+
+    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA), "MyGroup")
+    protected val myGroupWithProfilePicture = TestGroup(GroupId(1), myContact, listOf(myContact, contactA), "MyGroupWithPicture", byteArrayOf(0, 1, 2, 3))
+    protected val groupA = TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA")
+    protected val groupB = TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB")
+    protected val groupAB = TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB")
+    protected val groupAUnknown = TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown")
+    protected val groupALeft = TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft")
+    protected val myUnknownGroup = TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup")
+    protected val myLeftGroup = TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup")
+    protected val newAGroup = TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup")
+
+    protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
+    private val contactStore: ContactStore = InMemoryContactStore().apply {
+        addContact(myContact.contact, true)
+        addContact(contactA.contact, true)
+        addContact(contactB.contact, true)
+        addContact(contactC.contact, true)
+    }
+
+    private val mutableSentMessages: MutableList<AbstractMessage> = mutableListOf()
+    protected val sentMessages: List<AbstractMessage> = mutableSentMessages
+
+    protected val initialContacts = listOf(myContact, contactA, contactB, contactC)
+
+    protected val initialGroups =
+        listOf(myGroup, myGroupWithProfilePicture, groupA, groupB, groupAB, groupALeft, myLeftGroup)
+
+    @JvmField
+    @Rule
+    val grantPermissionRule: GrantPermissionRule =
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
+        } else {
+            GrantPermissionRule.grant()
+        }
+
+    /**
+     * Asserts that the correct identity is set up and fills the database with the initial data.
+     */
+    @Before
+    fun setup() {
+        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
+
+        setMessageQueue()
+
+        serviceManager.connection.stop()
+
+        cleanup()
+
+        fillDatabase()
+    }
+
+    /**
+     * Clean the data after the tests. This includes the deletion of the database entries, the
+     * avatar files, and the blocked contacts.
+     */
+    @After
+    fun cleanup() {
+        // Clear conversations
+        serviceManager.conversationService.getAll(true).forEach {
+            serviceManager.conversationService.clear(it)
+        }
+
+        // Delete database
+        serviceManager.databaseServiceNew.apply {
+            contactModelFactory.deleteAll()
+            messageModelFactory.deleteAll()
+            groupCallModelFactory.deleteAll()
+            groupInviteModelFactory.deleteAll()
+            groupBallotModelFactory.deleteAll()
+            groupMessagePendingMessageIdModelFactory.deleteAll()
+            groupMemberModelFactory.deleteAll()
+            groupMessageModelFactory.deleteAll()
+            // Remove group models from group service to empty the group service cache
+            serviceManager.groupService.removeAll()
+            distributionListModelFactory.deleteAll()
+            distributionListMemberModelFactory.deleteAll()
+            distributionListMessageModelFactory.deleteAll()
+            groupRequestSyncLogModelFactory.deleteAll()
+            ballotModelFactory.deleteAll()
+            ballotChoiceModelFactory.deleteAll()
+            ballotVoteModelFactory.deleteAll()
+            identityBallotModelFactory.deleteAll()
+            webClientSessionModelFactory.deleteAll()
+            conversationTagFactory.deleteAll()
+            outgoingGroupJoinRequestModelFactory.deleteAll()
+            incomingGroupJoinRequestModelFactory.deleteAll()
+            serverMessageModelFactory.deleteAll()
+        }
+
+        // Remove files
+        serviceManager.fileService.removeAllAvatars()
+
+        // Unblock contacts
+        serviceManager.blackListService.removeAll()
+    }
+
+    /**
+     * Fills basic data into the database. This is executed before each test. Override this if other
+     * database entries are needed.
+     */
+    open fun fillDatabase() {
+        val databaseService = serviceManager.databaseServiceNew
+        val fileService = serviceManager.fileService
+
+        initialContacts.forEach { addContactToDatabase(it, databaseService, true) }
+
+        initialGroups.forEach { addGroupToDatabase(it, databaseService, fileService) }
+    }
+
+    /**
+     * Create a message of the tested group message type. This is used to create a message that will
+     * be used to test the common group receive steps.
+     */
+    abstract fun createMessageForGroup(): T
+
+    protected fun startScenario(): ActivityScenario<HomeActivity> {
+        Intents.init()
+
+        val scenario = launchActivity<HomeActivity>()
+
+        Thread.sleep(200)
+
+        do {
+            var switchedToMessages = false
+            try {
+                Espresso.onView(ViewMatchers.withId(R.id.messages)).perform(ViewActions.click())
+                switchedToMessages = true
+            } catch (exception: NoMatchingViewException) {
+                Espresso.onView(ViewMatchers.withId(R.id.close_button)).perform(ViewActions.click())
+            }
+        } while (!switchedToMessages)
+
+        Intents.release()
+
+        Thread.sleep(200)
+
+        return scenario
+    }
+
+    /**
+     * Send a message from a user with the provided identity store.
+     */
+    protected fun processMessage(message: AbstractMessage, identityStore: IdentityStoreInterface) {
+        val messageBox = createMessageBox(message, identityStore)
+
+        // Process the group message
+        val processingResult = serviceManager.messageProcessor.processIncomingMessage(messageBox)
+        assertTrue(processingResult.wasProcessed())
+
+        // Give the listeners enough time to fire the event
+        Thread.sleep(200)
+    }
+
+    /**
+     * Check step 2.1 of the common group receive steps: The group could not be found and the user
+     * is the creator of the group (as alleged by the message). The message should be discarded.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep2_1() {
+        val (message, identityStore) = getMyUnknownGroupMessage()
+        setupAndProcessMessage(message, identityStore)
+
+        // Nothing is expected to be sent
+        assertEquals(0, sentMessages.size)
+    }
+
+    /**
+     * Check step 2.2 of the common group receive steps: The group could not be found and the user
+     * is not the creator. In this case, a group sync request should be sent.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep2_2() {
+        val (message, identityStore) = getUnknownGroupMessage()
+        setupAndProcessMessage(message, identityStore)
+
+        assertEquals(1, sentMessages.size)
+        val firstMessage = sentMessages.first() as GroupRequestSyncMessage
+        assertEquals(message.groupCreator, firstMessage.toIdentity)
+        assertEquals(message.toIdentity, firstMessage.fromIdentity)
+        assertEquals(message.apiGroupId, firstMessage.apiGroupId)
+        assertEquals(message.groupCreator, firstMessage.groupCreator)
+    }
+
+    /**
+     * Check step 3.1 of the common group receive steps: The group is marked as left and the user is
+     * the creator of the group. In this case, a group setup with an empty member list should be
+     * sent back to the sender.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep3_1() {
+        val (message, identityStore) = getMyLeftGroupMessage()
+        setupAndProcessMessage(message, identityStore)
+
+        // Check that empty sync is sent.
+        assertEquals(1, sentMessages.size)
+        val firstMessage = sentMessages.first() as GroupCreateMessage
+        assertEquals(message.fromIdentity, firstMessage.toIdentity)
+        assertEquals(myContact.identity, firstMessage.fromIdentity)
+        assertEquals(message.apiGroupId, firstMessage.apiGroupId)
+        assertEquals(message.groupCreator, firstMessage.groupCreator)
+        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+    }
+
+    /**
+     * Check step 3.2 of the common group receive steps: The group is marked left and the user is
+     * not the creator of the group. In this case, a group leave should be sent back to the sender.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep3_2() {
+        // First, test the common group receive steps for a message from the group creator
+        val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
+        setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
+
+        // Check that a group leave is sent back to the sender
+        assertEquals(1, sentMessages.size)
+        val firstSentMessage = sentMessages.first() as GroupLeaveMessage
+        assertEquals(firstIncomingMessage.fromIdentity, firstSentMessage.toIdentity)
+        assertEquals(myContact.identity, firstSentMessage.fromIdentity)
+        assertEquals(firstIncomingMessage.apiGroupId, firstSentMessage.apiGroupId)
+        assertEquals(firstIncomingMessage.groupCreator, firstSentMessage.groupCreator)
+
+        // Second, test the common group receive steps for a message from a group member
+        val (secondIncomingMessage, secondIdentityStore) = getLeftGroupMessage()
+        setupAndProcessMessage(secondIncomingMessage, secondIdentityStore)
+
+        // Check that a group leave is sent back to the sender
+        assertEquals(2, sentMessages.size)
+        val secondSentMessage = sentMessages[1] as GroupLeaveMessage
+        assertEquals(secondIncomingMessage.fromIdentity, secondSentMessage.toIdentity)
+        assertEquals(myContact.identity, secondSentMessage.fromIdentity)
+        assertEquals(secondIncomingMessage.apiGroupId, secondSentMessage.apiGroupId)
+        assertEquals(secondIncomingMessage.groupCreator, secondSentMessage.groupCreator)
+    }
+
+    /**
+     * Check step 4.1 of the common group receive steps: The sender is not a member of the group and
+     * the user is the creator of the group. In this case, a group setup with an empty members list
+     * should be sent back to the sender.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep4_1() {
+        val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
+        setupAndProcessMessage(message, identityStore)
+
+        // Check that a group setup with empty member list is sent back to the sender
+        assertEquals(1, sentMessages.size)
+        val firstMessage = sentMessages.first() as GroupCreateMessage
+        assertEquals(message.fromIdentity, firstMessage.toIdentity)
+        assertEquals(myContact.identity, firstMessage.fromIdentity)
+        assertEquals(message.apiGroupId, firstMessage.apiGroupId)
+        assertEquals(message.groupCreator, firstMessage.groupCreator)
+        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+    }
+
+    /**
+     * Check step 4.2 of the common group receive steps: The sender is not a member of the group and
+     * the user is not the creator of the group. The message should be discarded.
+     */
+    @Test
+    open fun testCommonGroupReceiveStep4_2() {
+        val (message, identityStore) = getSenderNotMemberMessage()
+        setupAndProcessMessage(message, identityStore)
+
+        // Check that no message is sent
+        assertEquals(0, sentMessages.size)
+    }
+
+    private fun addContactToDatabase(
+        testContact: TestContact,
+        databaseService: DatabaseServiceNew,
+        addHidden: Boolean = false,
+    ) {
+        databaseService.contactModelFactory.createOrUpdate(
+            testContact.contactModel.setIsHidden(addHidden)
+        )
+    }
+
+    private fun addGroupToDatabase(
+        testGroup: TestGroup,
+        databaseService: DatabaseServiceNew,
+        fileService: FileService,
+    ) {
+        val groupModel = testGroup.groupModel
+        databaseService.groupModelFactory.createOrUpdate(groupModel)
+        testGroup.setLocalGroupId(groupModel.id)
+        testGroup.members.forEach { member ->
+            val memberModel = GroupMemberModel()
+                .setGroupId(groupModel.id)
+                .setIdentity(member.identity)
+            databaseService.groupMemberModelFactory.createOrUpdate(memberModel)
+        }
+        if (testGroup.profilePicture != null) {
+            fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
+        }
+    }
+
+    private fun setupAndProcessMessage(
+        message: AbstractGroupMessage,
+        identityStore: IdentityStoreInterface
+    ) {
+        // Start home activity and navigate to chat section
+        launchActivity<HomeActivity>()
+
+        Espresso.onView(ViewMatchers.withId(R.id.messages)).perform(ViewActions.click())
+
+        processMessage(message, identityStore)
+    }
+
+    /**
+     * Get a group message where the user is the creator (as alleged by the received message).
+     * Common Group Receive Step 2.1
+     */
+    private fun getMyUnknownGroupMessage() = createMessageForGroup().apply {
+        enrich(myUnknownGroup)
+    } to myUnknownGroup.groupCreator.identityStore
+
+    /**
+     * Get a group message in an unknown group.
+     * Common Group Receive Step 2.2
+     */
+    private fun getUnknownGroupMessage() = createMessageForGroup().apply {
+        enrich(groupAUnknown)
+    } to groupAUnknown.groupCreator.identityStore
+
+    /**
+     * Get a group message that is marked 'left' where the user is the creator.
+     * Common Group Receive Step 3.1
+     */
+    private fun getMyLeftGroupMessage() = createMessageForGroup().apply {
+        enrich(myLeftGroup)
+        fromIdentity = contactA.identity
+    } to contactA.identityStore
+
+    /**
+     * Get a group message that is marked 'left' from a group member.
+     * Common Group Receive Step 3.2
+     */
+    private fun getLeftGroupMessage() = createMessageForGroup().apply {
+        enrich(groupALeft)
+    } to groupALeft.groupCreator.identityStore
+
+    /**
+     * Get a group message that is marked 'left' from the group creator.
+     * Common Group Receive Step 3.2
+     */
+    private fun getLeftGroupMessageFromCreator() = createMessageForGroup().apply {
+        enrich(groupALeft)
+    } to groupALeft.groupCreator.identityStore
+
+    /**
+     * Get a group message from a sender that is no member of the group where the user is the
+     * creator.
+     * Common Group Receive Step 4.1
+     */
+    private fun getSenderNotMemberOfMyGroupMessage() = createMessageForGroup().apply {
+        enrich(myGroup)
+        fromIdentity = contactB.identity
+    } to contactB.identityStore
+
+    /**
+     * Get a group message from a sender that is no member of the group.
+     * Common Group Receive Step 4.2
+     */
+    private fun getSenderNotMemberMessage() = createMessageForGroup().apply {
+        enrich(groupA)
+        fromIdentity = contactB.identity
+    } to contactB.identityStore
+
+    private fun AbstractGroupMessage.enrich(group: TestGroup) {
+        apiGroupId = group.apiGroupId
+        groupCreator = group.groupCreator.identity
+        fromIdentity = group.groupCreator.identity
+        toIdentity = myContact.identity
+    }
+
+    /**
+     * Create a message box from a user with the given identity store.
+     */
+    private fun createMessageBox(
+        msg: AbstractMessage,
+        identityStore: IdentityStoreInterface,
+    ): MessageBox {
+        val nonceFactory = NonceFactory(object : NonceStoreInterface {
+            override fun exists(nonce: ByteArray) = false
+            override fun store(nonce: ByteArray) = true
+        })
+
+        val messageCoder = MessageCoder(contactStore, identityStore)
+        return messageCoder.encode(msg, nonceFactory)
+    }
+
+    private fun setMessageQueue() {
+        val messageQueue = object :
+            MessageQueue(contactStore, myContact.identityStore, serviceManager.connection) {
+            override fun enqueue(message: AbstractMessage): MessageBox {
+                mutableSentMessages.add(message)
+                return MessageBox()
+            }
+        }
+
+        replaceMessageQueue(messageQueue, serviceManager)
+        replaceMessageQueue(messageQueue, serviceManager.contactService)
+        replaceMessageQueue(messageQueue, serviceManager.messageService)
+        replaceMessageQueue(messageQueue, serviceManager.groupJoinResponseService)
+        replaceMessageQueue(messageQueue, serviceManager.outgoingGroupJoinRequestService)
+        replaceMessageQueue(messageQueue, serviceManager.groupMessagingService)
+        serviceManager.forwardSecurityMessageProcessor?.let {
+            replaceMessageQueue(messageQueue, it)
+        }
+    }
+
+    private fun replaceMessageQueue(messageQueue: MessageQueue, c: Any) {
+        val messageQueueField: Field = c.javaClass.getDeclaredField("messageQueue")
+        messageQueueField.isAccessible = true
+        messageQueueField.set(c, messageQueue)
+    }
+
+    protected fun <T> Iterable<T>.replace(original: T, new: T) =
+        map { if (it == original) new else it }
+}

+ 67 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt

@@ -0,0 +1,67 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.core.app.ActivityScenario
+import ch.threema.app.R
+import ch.threema.app.activities.HomeActivity
+import ch.threema.app.adapters.MessageListAdapter
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
+import junit.framework.TestCase
+
+/**
+ * This class provides a utility method to verify that the correct group names are displayed.
+ */
+abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupControlTest<T>() {
+
+    /**
+     * Assert that in the given scenario the expected groups are listed.
+     */
+    protected fun assertGroupConversations(
+        scenario: ActivityScenario<HomeActivity>,
+        expectedGroups: List<TestGroup>
+    ) {
+        Thread.sleep(500)
+
+        scenario.onActivity { activity ->
+            val adapter = activity.findViewById<RecyclerView>(R.id.list)?.adapter
+            assertGroups(expectedGroups, adapter as MessageListAdapter)
+        }
+    }
+
+    /**
+     * Assert that the given recycler view shows the given
+     */
+    private fun assertGroups(testGroups: List<TestGroup>, adapter: MessageListAdapter) {
+        val expectedGroupNames: Set<String> = testGroups.map { it.groupName }.toSet()
+
+        val actualGroupNames = (0 until adapter.itemCount)
+            .mapNotNull { adapter.getEntity(it) }
+            .map { it.receiver.displayName }
+            .toSet()
+
+        TestCase.assertEquals(expectedGroupNames, actualGroupNames)
+    }
+
+}

+ 333 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -0,0 +1,333 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.test.core.app.launchActivity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.threema.app.DangerousTest
+import ch.threema.app.activities.HomeActivity
+import ch.threema.app.listeners.GroupListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
+import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
+import ch.threema.storage.models.GroupModel
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import junit.framework.TestCase.fail
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests that incoming group leave messages are handled correctly.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@DangerousTest
+class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
+
+    /**
+     * Test that contact A leaving my group works as expected.
+     */
+    @Test
+    fun testValidLeaveInMyGroup() {
+        assertSuccessfulLeave(myGroup, contactA, true)
+    }
+
+    /**
+     * Test that contact B leaving groupAB works as expected.
+     */
+    @Test
+    fun testValidLeave() {
+        assertSuccessfulLeave(groupAB, contactB)
+    }
+
+    /**
+     * Test that the creator of a group cannot leave the group.
+     */
+    @Test
+    @Ignore("TODO(ANDR-2385): ignore group leave messages from group creators")
+    fun testLeaveFromSender() {
+        assertUnsuccessfulLeave(groupA, contactA)
+        assertUnsuccessfulLeave(groupB, contactB)
+    }
+
+    /**
+     * Test that a leave message of an unknown group (where I am the owner) is discarded (and does
+     * not change anything).
+     */
+    @Test
+    fun testLeaveOfMyNonExistingGroup() {
+        assertUnsuccessfulLeave(myUnknownGroup, contactA, emptyList())
+    }
+
+    /**
+     * Test that a leave message of an unknown group (where I am not the owner) is discarded and has
+     * no effect.
+     */
+    @Test
+    fun testLeaveOfNonExistingGroup() {
+        assertUnsuccessfulLeave(groupAUnknown, contactB, emptyList(), true)
+    }
+
+    /**
+     * Test that a leave message of a left group (where I am not a member anymore) is discarded (and
+     * does not change anything).
+     */
+    @Test
+    fun testLeaveOfLeftGroup() {
+        assertUnsuccessfulLeave(groupALeft, contactB, null, true)
+    }
+
+    /**
+     * Test that a leave message of a left group (where I am the owner) is discarded (and nothing is
+     * changed).
+     */
+    @Test
+    fun testLeaveOfMyLeftGroup() {
+        assertUnsuccessfulLeave(myLeftGroup, contactA)
+    }
+
+    /**
+     * Test that a leave message of a sender that is not part of the group is discarded and has no
+     * effect.
+     */
+    @Test
+    fun testLeaveOfNonMember() {
+        assertUnsuccessfulLeave(groupA, contactB)
+    }
+
+    @After
+    fun removeAllGroupListeners() {
+        GroupLeaveTracker.stopAllListeners()
+    }
+
+    override fun createMessageForGroup() = GroupLeaveMessage()
+
+    override fun testCommonGroupReceiveStep2_1() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    override fun testCommonGroupReceiveStep2_2() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    override fun testCommonGroupReceiveStep3_1() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    override fun testCommonGroupReceiveStep3_2() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    override fun testCommonGroupReceiveStep4_1() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    override fun testCommonGroupReceiveStep4_2() {
+        // The common group receive steps are not executed for group leave messages
+    }
+
+    private fun assertSuccessfulLeave(group: TestGroup, contact: TestContact, expectStateChange: Boolean = false) {
+        launchActivity<HomeActivity>()
+
+        serviceManager.groupService.resetCache(group.groupModel.id)
+
+        assertEquals(
+            group.members.map { it.identity },
+            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+
+        val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
+            .apply { start() }
+
+        // Process the group rename message
+        processMessage(createEncryptedGroupLeaveMessage(group, contact), contact.identityStore)
+
+        leaveTracker.assertMemberLeft()
+
+        leaveTracker.stop()
+
+        serviceManager.groupService.resetCache(group.groupModel.id)
+
+        assertEquals(
+            group.members.size - 1,
+            serviceManager.groupService.countMembers(group.groupModel)
+        )
+        assertEquals(
+            group.members.map { it.identity }.filter { it != contact.identity },
+            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+
+        // Assert that no message has been sent as a response to a group leave
+        assertEquals(0, sentMessages.size)
+    }
+
+    private fun assertUnsuccessfulLeave(
+        group: TestGroup,
+        contact: TestContact,
+        expectedMembers: List<String>? = null,
+        shouldSendSyncRequest: Boolean = false,
+    ) {
+        launchActivity<HomeActivity>()
+
+        val expectedMemberList = expectedMembers ?: group.members.map { it.identity }
+
+        serviceManager.groupService.resetCache(group.groupModel.id)
+
+        assertEquals(
+            expectedMemberList,
+            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+
+        val leaveTracker = GroupLeaveTracker(group, contact.identity).apply { start() }
+
+        // Process the group rename message
+        processMessage(createEncryptedGroupLeaveMessage(group, contact), contact.identityStore)
+
+        leaveTracker.assertNoMemberLeft()
+
+        leaveTracker.stop()
+
+        serviceManager.groupService.resetCache(group.groupModel.id)
+
+        assertEquals(
+            expectedMemberList,
+            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+        assertEquals(
+            expectedMemberList.size,
+            serviceManager.groupService.countMembers(group.groupModel)
+        )
+
+        if (shouldSendSyncRequest) {
+            // Should send sync request to the group creator
+            assertEquals(1, sentMessages.size)
+            val sentMessage = sentMessages.first() as GroupRequestSyncMessage
+            assertEquals(myContact.identity, sentMessage.fromIdentity)
+            assertEquals(group.groupCreator.identity, sentMessage.toIdentity)
+            assertEquals(group.apiGroupId, sentMessage.apiGroupId)
+            assertEquals(group.groupCreator.identity, sentMessage.groupCreator)
+        } else {
+            // Assert that no message has been sent as a response to the leave message
+            assertEquals(0, sentMessages.size)
+        }
+    }
+
+    private fun createEncryptedGroupLeaveMessage(group: TestGroup, contact: TestContact) =
+        createMessageForGroup().apply {
+            groupCreator = group.groupCreator.identity
+            apiGroupId = group.apiGroupId
+            fromIdentity = contact.identity
+            toIdentity = myContact.identity
+        }
+
+    private class GroupLeaveTracker(
+        private val group: TestGroup?,
+        private val leavingIdentity: String?,
+        private val expectStateChange: Boolean = false,
+    ) {
+        private var memberHasLeft = false
+
+        private val groupListener = object : GroupListener {
+            override fun onCreate(newGroupModel: GroupModel?) = fail()
+
+            override fun onRename(groupModel: GroupModel?) = fail()
+
+            override fun onUpdatePhoto(groupModel: GroupModel?) = fail()
+
+            override fun onRemove(groupModel: GroupModel?) = fail()
+
+            override fun onNewMember(
+                group: GroupModel?,
+                newIdentity: String?,
+                previousMemberCount: Int
+            ) = fail()
+
+            override fun onMemberLeave(
+                groupModel: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) {
+                assertFalse(memberHasLeft)
+                group?.let {
+                    assertArrayEquals(it.apiGroupId.groupId, groupModel?.apiGroupId?.groupId)
+                    assertEquals(it.groupCreator.identity, groupModel?.creatorIdentity)
+                    assertEquals(leavingIdentity, identity)
+                }
+                memberHasLeft = true
+            }
+
+            override fun onMemberKicked(
+                group: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) = fail()
+
+            override fun onUpdate(groupModel: GroupModel?) = fail()
+
+            override fun onLeave(groupModel: GroupModel?) = fail()
+
+            override fun onGroupStateChanged(
+                groupModel: GroupModel?,
+                oldState: Int,
+                newState: Int
+            ) {
+                if (!expectStateChange) {
+                    fail()
+                }
+            }
+        }
+
+        companion object {
+            private val groupListeners: MutableList<GroupListener> = mutableListOf()
+
+            fun stopAllListeners() {
+                for (groupListener in groupListeners) {
+                    ListenerManager.groupListeners.remove(groupListener)
+                }
+                groupListeners.clear()
+            }
+        }
+
+        fun start() {
+            ListenerManager.groupListeners.add(groupListener)
+            groupListeners.add(groupListener)
+        }
+
+        fun assertMemberLeft() {
+            assertTrue(memberHasLeft)
+        }
+
+        fun assertNoMemberLeft() {
+            assertFalse(memberHasLeft)
+        }
+
+        fun stop() {
+            ListenerManager.groupListeners.remove(groupListener)
+            groupListeners.remove(groupListener)
+        }
+    }
+
+}

+ 282 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -0,0 +1,282 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.threema.app.DangerousTest
+import ch.threema.app.listeners.GroupListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.domain.models.GroupId
+import ch.threema.domain.protocol.csp.messages.GroupRenameMessage
+import ch.threema.storage.models.GroupModel
+import junit.framework.TestCase.*
+import kotlinx.coroutines.*
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.*
+
+/**
+ * Tests that incoming group name messages are handled correctly.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@DangerousTest
+class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
+
+    override fun createMessageForGroup(): GroupRenameMessage {
+        return GroupRenameMessage().apply { groupName = "New Group Name" }
+    }
+
+    /**
+     * Tests that a (valid) group rename message really changes the group name.
+     */
+    @Test
+    fun testValidGroupRename() {
+        // Start home activity and navigate to chat section
+        val activityScenario = startScenario()
+
+        // Assert initial groups
+        assertGroupConversations(activityScenario, initialGroups)
+
+        // Create group rename message
+        val groupARenamed =
+            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed")
+
+        val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
+
+        val message = createEncryptedRenameMessage(
+            groupARenamed.groupName,
+            groupARenamed.groupCreator.identity,
+            groupARenamed.apiGroupId,
+            groupARenamed.groupCreator
+        )
+
+        // Process the group rename message
+        processMessage(message, groupARenamed.groupCreator.identityStore)
+
+        // Assert that the listeners were triggered
+        renameTracker.assertRename()
+        renameTracker.stop()
+
+        // Assert that the group name change has been processed
+        assertGroupConversations(activityScenario, initialGroups.replace(groupA, groupARenamed))
+    }
+
+    /**
+     * Check that a group rename message from a wrong sender (not the group creator, just a member)
+     * does not lead to a group name change.
+     */
+    @Test
+    fun testInvalidGroupRenameSender() {
+        // Start home activity and navigate to chat section
+        val activityScenario = startScenario()
+
+        // Assert initial groups
+        assertGroupConversations(activityScenario, initialGroups)
+
+        // Create group rename message (from wrong sender)
+        val groupARenamed =
+            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed")
+
+        val renameTracker = GroupRenameTracker(null).apply { start() }
+
+        val message = createEncryptedRenameMessage(
+            groupARenamed.groupName,
+            groupARenamed.groupCreator.identity, // Note that this will be ignored anyway
+            groupARenamed.apiGroupId,
+            contactB // Not the creator of this group!
+        )
+
+        // Process the group rename message
+        processMessage(message, contactB.identityStore)
+
+        renameTracker.assertNoRename()
+        renameTracker.stop()
+
+        assertGroupConversations(activityScenario, initialGroups)
+    }
+
+    override fun testCommonGroupReceiveStep2_1() {
+        runWithoutGroupRename { super.testCommonGroupReceiveStep2_1() }
+    }
+
+    override fun testCommonGroupReceiveStep2_2() {
+        runWithoutGroupRename { super.testCommonGroupReceiveStep2_2() }
+    }
+
+    override fun testCommonGroupReceiveStep3_1() {
+        // Don't test this step. The group rename message is always sent as creator of the group
+        // and if the sender of the message is the creator of a group owned by this user, then the
+        // message comes from this user itself - which is impossible.
+    }
+
+    override fun testCommonGroupReceiveStep3_2() {
+        runWithoutGroupRename { super.testCommonGroupReceiveStep3_2() }
+    }
+
+    override fun testCommonGroupReceiveStep4_1() {
+        // Don't test this step. The group rename message is always sent as creator of the group
+        // and therefore the sender of the message is never missing in the group. However, the group
+        // model is (very likely) not found and therefore handled in step 2.1 of the common group
+        // receive steps.
+    }
+
+    override fun testCommonGroupReceiveStep4_2() {
+        // Don't test this step. The group rename message is always sent as creator of the group
+        // and therefore the sender of the message is never missing in the group. However, the group
+        // model is (very likely) not found and therefore handled in step 2.2 of the common group
+        // receive steps.
+    }
+
+    @After
+    fun removeAllGroupListeners() {
+        GroupRenameTracker.stopAllListeners()
+    }
+
+    private fun createEncryptedRenameMessage(
+        newGroupName: String,
+        groupCreatorIdentity: String,
+        apiGroupId: GroupId,
+        fromContact: TestContact,
+    ) = GroupRenameMessage().apply {
+        groupName = newGroupName
+        groupCreator = groupCreatorIdentity
+        fromIdentity = fromContact.identity
+        setApiGroupId(apiGroupId)
+        toIdentity = myContact.identity
+    }
+
+    /**
+     * Run [processMessage] and assert that no group rename happens.
+     */
+    private fun runWithoutGroupRename(processMessage: () -> Unit) {
+        val groupRenameTracker = GroupRenameTracker(null).apply { start() }
+
+        processMessage()
+
+        groupRenameTracker.assertNoRename()
+        groupRenameTracker.stop()
+    }
+
+    private class GroupRenameTracker(private val group: TestGroup?) {
+        private var hasBeenRenamed = false
+
+        private val groupListener = object : GroupListener {
+            override fun onCreate(newGroupModel: GroupModel?) {
+                fail()
+            }
+
+            override fun onRename(groupModel: GroupModel?) {
+                assertFalse(hasBeenRenamed)
+                group?.let {
+                    assertArrayEquals(it.apiGroupId.groupId, groupModel?.apiGroupId?.groupId)
+                    assertEquals(it.groupCreator.identity, groupModel?.creatorIdentity)
+                    assertEquals(it.groupName, groupModel?.name)
+                }
+                hasBeenRenamed = true
+            }
+
+            override fun onUpdatePhoto(groupModel: GroupModel?) {
+                fail()
+            }
+
+            override fun onRemove(groupModel: GroupModel?) {
+                fail()
+            }
+
+            override fun onNewMember(
+                group: GroupModel?,
+                newIdentity: String?,
+                previousMemberCount: Int
+            ) {
+                fail()
+            }
+
+            override fun onMemberLeave(
+                group: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) {
+                fail()
+            }
+
+            override fun onMemberKicked(
+                group: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) {
+                fail()
+            }
+
+            override fun onUpdate(groupModel: GroupModel?) {
+                fail()
+            }
+
+            override fun onLeave(groupModel: GroupModel?) {
+                fail()
+            }
+
+            override fun onGroupStateChanged(
+                groupModel: GroupModel?,
+                oldState: Int,
+                newState: Int
+            ) {
+                fail()
+            }
+        }
+
+        companion object {
+            private val groupListeners: MutableList<GroupListener> = mutableListOf()
+
+            fun stopAllListeners() {
+                for (groupListener in groupListeners) {
+                    ListenerManager.groupListeners.remove(groupListener)
+                }
+                groupListeners.clear()
+            }
+        }
+
+        fun start() {
+            ListenerManager.groupListeners.add(groupListener)
+            groupListeners.add(groupListener)
+        }
+
+        fun assertRename() {
+            assertTrue(hasBeenRenamed)
+        }
+
+        fun assertNoRename() {
+            assertFalse(hasBeenRenamed)
+        }
+
+        fun stop() {
+            ListenerManager.groupListeners.remove(groupListener)
+            groupListeners.remove(groupListener)
+        }
+    }
+
+}

+ 564 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -0,0 +1,564 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.threema.app.DangerousTest
+import ch.threema.app.listeners.GroupListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
+import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
+import ch.threema.storage.models.GroupModel
+import junit.framework.TestCase
+import org.junit.After
+import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Runs different tests that verify that incoming group setup messages are handled according to the
+ * protocol.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@DangerousTest
+class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
+
+    override fun createMessageForGroup() = GroupCreateMessage()
+
+    /**
+     * Test a group setup message of an unknown group where the user is not not a member.
+     */
+    @Test
+    fun testUnknownGroupNotMember() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val setupTracker = GroupSetupTracker(
+            groupAUnknown,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = false,
+            emptyList(),
+            emptyList(),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(groupAUnknown)
+        // Remove this user from the members
+        message.members = message.members.filter { it != myContact.identity }.toTypedArray()
+        // Create message box from contact A (group creator)
+        processMessage(message, groupAUnknown.groupCreator.identityStore)
+
+        // Assert that group conversations did not appear, disappear, or change their name
+        assertGroupConversations(scenario, initialGroups)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that no action has been triggered
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test a group setup message of an unknown group that has no members.
+     */
+    @Test
+    fun testUnknownEmptyGroup() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val setupTracker = GroupSetupTracker(
+            groupAUnknown,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = false,
+            emptyList(),
+            emptyList(),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(groupAUnknown)
+        // Group is empty
+        message.members = emptyArray()
+        // Create message box from contact A (group creator)
+        processMessage(message, groupAUnknown.groupCreator.identityStore)
+
+        // Assert that group conversations did not appear, disappear, or change their name
+        assertGroupConversations(scenario, initialGroups)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that no action has been triggered
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test a group setup message of a blocked contact.
+     */
+    @Test
+    fun testBlocked() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        serviceManager.blackListService.add(contactA.identity)
+        serviceManager.blackListService.add(contactB.identity)
+
+        val setupTracker = GroupSetupTracker(
+            newAGroup,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = false,
+            emptyList(),
+            emptyList(),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(newAGroup)
+        // Create message box from contact A (group creator)
+        processMessage(message, newAGroup.groupCreator.identityStore)
+
+        // Assert that group conversations did not appear, disappear, or change their name
+        assertGroupConversations(scenario, initialGroups)
+
+        // Assert that a group leave message is sent to the created and all provided members
+        // including those that are blocked
+        assertEquals(2, sentMessages.size)
+        val first = sentMessages.first() as GroupLeaveMessage
+        assertEquals(myContact.identity, first.fromIdentity)
+        assertEquals(newAGroup.apiGroupId, first.apiGroupId)
+        assertEquals(newAGroup.groupCreator.identity, first.groupCreator)
+        val second = sentMessages.last() as GroupLeaveMessage
+        assertEquals(myContact.identity, second.fromIdentity)
+        assertEquals(newAGroup.apiGroupId, second.apiGroupId)
+        assertEquals(newAGroup.groupCreator.identity, second.groupCreator)
+        // Assert that one message is for contact A and the other for contact B
+        assertTrue(
+            (first.toIdentity == contactA.identity && second.toIdentity == contactB.identity)
+                    || (first.toIdentity == contactB.identity && second.toIdentity == contactA.identity)
+        )
+
+        // Assert that no action has been triggered
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test a group setup message of a group where the user is not a member anymore.
+     */
+    @Test
+    fun testKicked() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val setupTracker = GroupSetupTracker(
+            groupAB,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = true,
+            emptyList(),
+            listOf(myContact.identity),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(groupAB)
+        // Only contact B is a member of this group, so this user has been kicked
+        message.members = arrayOf(contactB.identity)
+        // Create message box from contact A (group creator)
+        processMessage(message, groupAB.groupCreator.identityStore)
+
+        // Assert that group conversations did not appear, disappear, or change their name
+        assertGroupConversations(scenario, initialGroups)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that the user has been kicked and the members are updated
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test a group setup message of a group where the members changed.
+     */
+    @Test
+    fun testMembersChanged() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val setupTracker = GroupSetupTracker(
+            groupAB,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = false,
+            listOf(contactC.identity),
+            listOf(contactB.identity),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(groupAB).apply {
+            // Remove contact B from group and add contact C to group
+            members = members.toList().replace(contactB.identity, contactC.identity).toTypedArray()
+        }
+        // Create message box from contact A (group creator)
+        processMessage(message, groupAB.groupCreator.identityStore)
+
+        // Assert that group conversations did not appear, disappear, or change their name
+        assertGroupConversations(scenario, initialGroups)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that the members have changed
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test a group setup message of a newly created group.
+     */
+    @Test
+    fun testNewGroup() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val newGroup = TestGroup(
+            newAGroup.apiGroupId,
+            newAGroup.groupCreator,
+            newAGroup.members,
+            // Note that this will be the group name because we only test the group setup message
+            // that is not followed by a group rename
+            "12345678, Me, ABCDEFGH",
+        )
+
+        val setupTracker = GroupSetupTracker(
+            newGroup,
+            myContact.identity,
+            expectCreate = true,
+            expectKick = false,
+            newGroup.members.map { it.identity } + newGroup.groupCreator.identity,
+            emptyList(),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(newGroup)
+        // Create message box from contact A (group creator)
+        processMessage(message, newGroup.groupCreator.identityStore)
+
+        // Assert that the new group appears in the list
+        assertGroupConversations(scenario, initialGroups + newGroup)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that the group has been created and the new members are set correctly
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    /**
+     * Test two group setup messages that remove and then add the user.
+     */
+    @Test
+    fun testRemoveJoin() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val setupTracker = GroupSetupTracker(
+            groupAB,
+            myContact.identity,
+            expectCreate = false,
+            expectKick = true,
+            listOf(myContact.identity),
+            listOf(myContact.identity),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val removeMessage = createGroupSetupMessage(groupAB)
+        // Only contact B is a member of this group, so this user has been kicked
+        removeMessage.members = arrayOf(contactB.identity)
+        // Create message box from contact A (group creator)
+        processMessage(removeMessage, groupAB.groupCreator.identityStore)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Create the group setup message (now again with this user)
+        val addMessage = createGroupSetupMessage(groupAB)
+        // Now we again include this user
+        addMessage.members = arrayOf(contactB.identity, myContact.identity)
+        // Create message box from contact A (group creator)
+        processMessage(addMessage, groupAB.groupCreator.identityStore)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that the user has been kicked and added again
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    @Test
+    fun testGroupContainingInvalidIDs() {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        val invalidMemberId = ",,,,,,,,"
+
+        val newGroup = TestGroup(
+            newAGroup.apiGroupId,
+            newAGroup.groupCreator,
+            newAGroup.members + TestContact(invalidMemberId), // Note that this ID is not valid
+            // Note that this will be the group name because we only test the group setup message
+            // that is not followed by a group rename
+            "12345678, Me, ABCDEFGH",
+        )
+
+        val setupTracker = GroupSetupTracker(
+            newGroup,
+            myContact.identity,
+            expectCreate = true,
+            expectKick = false,
+            newGroup.members.filter { it.identity != invalidMemberId }
+                .map { it.identity } + newGroup.groupCreator.identity,
+            emptyList(),
+        )
+        setupTracker.start()
+
+        // Create the group setup message
+        val message = createGroupSetupMessage(newGroup)
+        // Create message box from contact A (group creator)
+        processMessage(message, newGroup.groupCreator.identityStore)
+
+        // Assert that the new group appears in the list
+        assertGroupConversations(scenario, initialGroups + newGroup)
+
+        // Assert that no message is sent
+        assertEquals(0, sentMessages.size)
+
+        // Assert that the group has been created and the new members are set correctly
+        setupTracker.assertAllNewMembersAdded()
+        setupTracker.assertAllKickedMembersRemoved()
+        setupTracker.assertCreateLeave()
+        setupTracker.stop()
+    }
+
+    private fun createGroupSetupMessage(testGroup: TestGroup) = GroupCreateMessage().apply {
+        apiGroupId = testGroup.apiGroupId
+        groupCreator = testGroup.groupCreator.identity
+        fromIdentity = testGroup.groupCreator.identity
+        toIdentity = myContact.identity
+        members =
+            testGroup.members.map { it.identity }.filter { it != testGroup.groupCreator.identity }
+                .toTypedArray()
+    }
+
+    private class GroupSetupTracker(
+        private val group: TestGroup?,
+        private val myIdentity: String,
+        private val expectCreate: Boolean,
+        private val expectKick: Boolean,
+        private val newMembers: List<String>,
+        private val kickedMembers: List<String>,
+    ) {
+        private var hasBeenCreated = false
+        private var hasBeenKicked = false
+        private var newMembersAdded = mutableListOf<String>()
+        private var kickedMembersRemoved = mutableListOf<String>()
+
+        private val groupListener = object : GroupListener {
+            override fun onCreate(newGroupModel: GroupModel?) {
+                assertTrue(expectCreate)
+                assertFalse(hasBeenCreated)
+                group?.let {
+                    Assert.assertArrayEquals(
+                        it.apiGroupId.groupId,
+                        newGroupModel?.apiGroupId?.groupId
+                    )
+                    TestCase.assertEquals(it.groupCreator.identity, newGroupModel?.creatorIdentity)
+                }
+                hasBeenCreated = true
+            }
+
+            override fun onRename(groupModel: GroupModel?) = fail()
+
+            override fun onUpdatePhoto(groupModel: GroupModel?) = fail()
+
+            override fun onRemove(groupModel: GroupModel?) = fail()
+
+            override fun onNewMember(
+                group: GroupModel?,
+                newIdentity: String?,
+                previousMemberCount: Int
+            ) {
+                assertTrue("Did not expect member $newIdentity", newMembers.contains(newIdentity))
+                newMembersAdded.add(newIdentity!!)
+            }
+
+            override fun onMemberLeave(
+                group: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) = fail()
+
+            override fun onMemberKicked(
+                group: GroupModel?,
+                identity: String?,
+                previousMemberCount: Int
+            ) {
+                assertTrue(kickedMembers.contains(identity))
+                kickedMembersRemoved.add(identity!!)
+
+                if (identity == myIdentity) {
+                    assertTrue(expectKick)
+                    assertFalse(hasBeenKicked)
+                    hasBeenKicked = true
+                }
+            }
+
+            override fun onUpdate(groupModel: GroupModel?) {
+                // This should only be called if the receiver has been changed (a member has been
+                // added or kicked)
+                assertTrue(newMembers.isNotEmpty() || kickedMembers.isNotEmpty())
+            }
+
+            override fun onLeave(groupModel: GroupModel?) = fail()
+
+            override fun onGroupStateChanged(
+                groupModel: GroupModel?,
+                oldState: Int,
+                newState: Int
+            ) {
+            }
+        }
+
+        companion object {
+            private val groupListeners: MutableList<GroupListener> = mutableListOf()
+
+            fun stopAllListeners() {
+                for (groupListener in groupListeners) {
+                    ListenerManager.groupListeners.remove(groupListener)
+                }
+                groupListeners.clear()
+            }
+        }
+
+        fun start() {
+            ListenerManager.groupListeners.add(groupListener)
+            groupListeners.add(groupListener)
+        }
+
+        fun assertAllNewMembersAdded() {
+            assertEquals(newMembers.toSet(), newMembersAdded.toSet())
+        }
+
+        fun assertAllKickedMembersRemoved() {
+            assertEquals(kickedMembers.toSet(), kickedMembersRemoved.toSet())
+        }
+
+        fun assertCreateLeave() {
+            assertEquals(expectCreate, hasBeenCreated)
+            assertEquals(expectKick, hasBeenKicked)
+        }
+
+        fun stop() {
+            ListenerManager.groupListeners.remove(groupListener)
+            groupListeners.remove(groupListener)
+        }
+    }
+
+    @After
+    fun removeAllGroupListeners() {
+        GroupSetupTracker.stopAllListeners()
+    }
+
+    override fun testCommonGroupReceiveStep2_1() {
+        // The common group receive steps are not executed for group setup messages
+    }
+
+    override fun testCommonGroupReceiveStep2_2() {
+        // The common group receive steps are not executed for group setup messages
+    }
+
+    override fun testCommonGroupReceiveStep3_1() {
+        // The common group receive steps are not executed for group setup messages
+    }
+
+    override fun testCommonGroupReceiveStep3_2() {
+        // The common group receive steps are not executed for group setup messages
+    }
+
+    override fun testCommonGroupReceiveStep4_1() {
+        // The common group receive steps are not executed for group setup messages
+    }
+
+    override fun testCommonGroupReceiveStep4_2() {
+        // The common group receive steps are not executed for group setup messages
+    }
+}

+ 180 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt

@@ -0,0 +1,180 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.test.core.app.launchActivity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.threema.app.DangerousTest
+import ch.threema.app.activities.HomeActivity
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
+import ch.threema.domain.protocol.csp.messages.GroupDeletePhotoMessage
+import ch.threema.domain.protocol.csp.messages.GroupRenameMessage
+import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests that incoming group sync request messages are handled correctly.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@DangerousTest
+class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>() {
+
+    override fun createMessageForGroup() = GroupRequestSyncMessage()
+
+    @Test
+    fun testValidSyncRequest() {
+        assertValidGroupSyncRequest(myGroup, contactA)
+    }
+
+    @Test
+    fun testSyncRequestToMember() {
+        assertIgnoredGroupSyncRequest(groupAB, contactB)
+    }
+
+    @Test
+    fun testSyncRequestFromNonMember() {
+        assertLeftGroupSyncRequest(myGroup, contactB)
+    }
+
+    @Test
+    fun testSyncRequestFromMyself() {
+        assertIgnoredGroupSyncRequest(myGroup, myContact)
+    }
+
+    @Test
+    fun testSyncRequestToLeftGroup() {
+        assertLeftGroupSyncRequest(myLeftGroup, contactA)
+    }
+
+    override fun testCommonGroupReceiveStep2_1() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    override fun testCommonGroupReceiveStep2_2() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    override fun testCommonGroupReceiveStep3_1() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    override fun testCommonGroupReceiveStep3_2() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    override fun testCommonGroupReceiveStep4_1() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    override fun testCommonGroupReceiveStep4_2() {
+        // Common group receive steps are not executed for group sync request messages
+    }
+
+    private fun assertValidGroupSyncRequest(group: TestGroup, contact: TestContact) {
+        launchActivity<HomeActivity>()
+
+        // Create group sync request message
+        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+            fromIdentity = contact.identity
+            toIdentity = myContact.identity
+            apiGroupId = group.apiGroupId
+            groupCreator = group.groupCreator.identity
+        }
+
+        // Process sync request message
+        processMessage(groupRequestSyncMessage, contact.identityStore)
+
+        assertEquals(3, sentMessages.size)
+
+        // Check that the first sent message (setup) is correct
+        val setupMessage = sentMessages[0] as GroupCreateMessage
+        assertArrayEquals(group.members.map { it.identity }.toTypedArray(), setupMessage.members)
+        assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
+        assertEquals(contact.identity, setupMessage.toIdentity)
+        assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
+        assertEquals(group.apiGroupId, setupMessage.apiGroupId)
+
+        // Check that the second sent message (rename) is correct
+        val renameMessage = sentMessages[1] as GroupRenameMessage
+        assertEquals(group.groupName, renameMessage.groupName)
+        assertEquals(myContact.identity, renameMessage.fromIdentity)
+        assertEquals(contact.identity, renameMessage.toIdentity)
+        assertEquals(group.groupCreator.identity, renameMessage.groupCreator)
+        assertEquals(group.apiGroupId, renameMessage.apiGroupId)
+
+        assertTrue("Groups with photo are not supported for testing", group.profilePicture == null)
+
+        // Check that the third sent message (set/delete photo) is correct
+        val deletePhotoMessage = sentMessages[2] as GroupDeletePhotoMessage
+        assertEquals(myContact.identity, deletePhotoMessage.fromIdentity)
+        assertEquals(contact.identity, deletePhotoMessage.toIdentity)
+        assertEquals(group.groupCreator.identity, deletePhotoMessage.groupCreator)
+        assertEquals(group.apiGroupId, deletePhotoMessage.apiGroupId)
+    }
+
+    private fun assertIgnoredGroupSyncRequest(group: TestGroup, contact: TestContact) {
+        launchActivity<HomeActivity>()
+
+        // Create group sync request message
+        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+            fromIdentity = contact.identity
+            toIdentity = myContact.identity
+            apiGroupId = group.apiGroupId
+            groupCreator = group.groupCreator.identity
+        }
+
+        processMessage(groupRequestSyncMessage, contact.identityStore)
+
+        assertEquals(0, sentMessages.size)
+    }
+
+    private fun assertLeftGroupSyncRequest(group: TestGroup, contact: TestContact) {
+        launchActivity<HomeActivity>()
+
+        // Create group sync request message
+        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+            fromIdentity = contact.identity
+            toIdentity = myContact.identity
+            apiGroupId = group.apiGroupId
+            groupCreator = group.groupCreator.identity
+        }
+
+        processMessage(groupRequestSyncMessage, contact.identityStore)
+
+        // Check that a setup message has been sent with empty members list
+        assertEquals(1, sentMessages.size)
+        val setupMessage = sentMessages.first() as GroupCreateMessage
+        assertArrayEquals(emptyArray(), setupMessage.members)
+        assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
+        assertEquals(contact.identity, setupMessage.toIdentity)
+        assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
+        assertEquals(group.apiGroupId, setupMessage.apiGroupId)
+    }
+}

+ 40 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt

@@ -0,0 +1,40 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.groupmanagement
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.threema.app.DangerousTest
+import ch.threema.domain.protocol.csp.messages.GroupTextMessage
+import org.junit.runner.RunWith
+
+/**
+ * Tests that the common group receive steps are executed for a group text message.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+@DangerousTest
+class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
+    override fun createMessageForGroup(): GroupTextMessage {
+        return GroupTextMessage().apply { text = "Group text message" }
+    }
+}

+ 31 - 43
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java

@@ -43,7 +43,6 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ballot.BallotService;
-import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.services.group.GroupJoinResponseService;
 import ch.threema.app.services.group.IncomingGroupJoinRequestService;
 import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
@@ -59,6 +58,7 @@ import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.helpers.InMemoryNonceStore;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.MessageId;
+import ch.threema.domain.protocol.ServerAddressProvider;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.coders.MessageBox;
 import ch.threema.domain.protocol.csp.coders.MessageCoder;
@@ -78,21 +78,7 @@ public class MessageProcessorTest {
 	private final static Contact TEST_CONTACT_1 = new Contact("09BNNVR2", Utils.hexStringToByteArray("e4613bbe5408d342fdabc3edf4509d1a3aecd7cb0598773987eef8400e74c81a"));
 	private final static Contact TEST_CONTACT_2 = new Contact("0BSXZ4P8", Utils.hexStringToByteArray("dee1cd341de88f783a768941eac702951c8bbb21e836da4a43ab8f3776fc0a65"));
 
-	// Services
-	private MessageService messageService;
-	private ContactService contactService;
-	private PreferenceService preferenceService;
-	private GroupService groupService;
-	private GroupInviteService groupInviteService;
-	private GroupJoinResponseService groupJoinResponseService;
-	private IncomingGroupJoinRequestService incomingGroupJoinRequestService;
-	private IdListService blackListService;
-	private BallotService ballotService;
-	private FileService fileService;
-	private NotificationService notificationService;
-	private VoipStateService voipStateService;
 	private NonceFactory nonceFactory;
-	private GroupCallManager groupCallManager;
 
 	// Stores
 	private IdentityStoreInterface identityStore;
@@ -101,27 +87,27 @@ public class MessageProcessorTest {
 
 	// Message processor
 	private MessageProcessor messageProcessor;
-	private ForwardSecurityMessageProcessor forwardSecurityMessageProcessor;
 
 	@Before
 	public void setUp() throws Exception {
 		// Load services
+		// Services
 		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
-		this.messageService = serviceManager.getMessageService();
-		this.contactService = serviceManager.getContactService();
-		this.preferenceService = serviceManager.getPreferenceService();
-		this.groupService = serviceManager.getGroupService();
-		this.groupInviteService = serviceManager.getGroupInviteService();
-		this.groupJoinResponseService = serviceManager.getGroupJoinResponseService();
-		this.incomingGroupJoinRequestService = serviceManager.getIncomingGroupJoinRequestService();
-		this.blackListService = serviceManager.getBlackListService();
-		this.ballotService = serviceManager.getBallotService();
-		this.fileService = serviceManager.getFileService();
-		this.notificationService = serviceManager.getNotificationService();
-		this.voipStateService = serviceManager.getVoipStateService();
+		MessageService messageService = serviceManager.getMessageService();
+		ContactService contactService = serviceManager.getContactService();
+		PreferenceService preferenceService = serviceManager.getPreferenceService();
+		GroupService groupService = serviceManager.getGroupService();
+		GroupJoinResponseService groupJoinResponseService = serviceManager.getGroupJoinResponseService();
+		IncomingGroupJoinRequestService incomingGroupJoinRequestService = serviceManager.getIncomingGroupJoinRequestService();
+		IdListService blackListService = serviceManager.getBlackListService();
+		BallotService ballotService = serviceManager.getBallotService();
+		FileService fileService = serviceManager.getFileService();
+		NotificationService notificationService = serviceManager.getNotificationService();
+		VoipStateService voipStateService = serviceManager.getVoipStateService();
 		this.nonceFactory = new NonceFactory(new InMemoryNonceStore());
-		this.forwardSecurityMessageProcessor = serviceManager.getForwardSecurityMessageProcessor();
-		this.groupCallManager = serviceManager.getGroupCallManager();
+		ForwardSecurityMessageProcessor forwardSecurityMessageProcessor = serviceManager.getForwardSecurityMessageProcessor();
+		GroupCallManager groupCallManager = serviceManager.getGroupCallManager();
+		ServerAddressProvider serverAddressProvider = serviceManager.getServerAddressProviderService().getServerAddressProvider();
 
 		// Create in-memory stores
 		this.contactStore = new InMemoryContactStore();
@@ -147,21 +133,23 @@ public class MessageProcessorTest {
 
 		// Create message processor
 		this.messageProcessor = new MessageProcessor(
-			this.messageService,
-			this.contactService,
+			serviceManager,
+			messageService,
+			contactService,
 			this.identityStore,
 			this.contactStore,
-			this.preferenceService,
-			this.groupService,
-			this.groupJoinResponseService,
-			this.incomingGroupJoinRequestService,
-			this.blackListService,
-			this.ballotService,
-			this.fileService,
-			this.notificationService,
-			this.voipStateService,
-			this.forwardSecurityMessageProcessor,
-			this.groupCallManager
+			preferenceService,
+			groupService,
+			groupJoinResponseService,
+			incomingGroupJoinRequestService,
+			blackListService,
+			ballotService,
+			fileService,
+			notificationService,
+			voipStateService,
+			forwardSecurityMessageProcessor,
+			groupCallManager,
+			serverAddressProvider
 		);
 	}
 

+ 123 - 6
app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java

@@ -26,11 +26,16 @@ import android.app.ActivityManager.RunningServiceInfo;
 import android.content.Context;
 import android.util.Log;
 
+import com.neilalexander.jnacl.NaCl;
+
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.util.Date;
+import java.util.List;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.BySelector;
 import androidx.test.uiautomator.UiDevice;
@@ -38,12 +43,123 @@ import androidx.test.uiautomator.Until;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.UserService;
 import ch.threema.base.utils.Utils;
+import ch.threema.domain.helpers.InMemoryIdentityStore;
+import ch.threema.domain.models.Contact;
+import ch.threema.domain.models.GroupId;
+import ch.threema.domain.stores.IdentityStoreInterface;
+import ch.threema.storage.models.ContactModel;
+import ch.threema.storage.models.GroupModel;
 
 import static org.junit.Assert.assertNotNull;
 
 public class TestHelpers {
 	private static final String TAG = "TestHelpers";
 
+	public static final TestContact TEST_CONTACT = new TestContact(
+		"XERCUKNS",
+		Utils.hexStringToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36"),
+		Utils.hexStringToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a")
+	);
+
+	public static final class TestContact {
+		@NonNull
+		public final String identity;
+		@NonNull
+		public final byte[] publicKey;
+		@NonNull
+		public final byte[] privateKey;
+
+		public TestContact(@NonNull String identity) {
+			this.identity = identity;
+			publicKey = new byte[NaCl.PUBLICKEYBYTES];
+			privateKey = new byte[NaCl.SECRETKEYBYTES];
+
+			NaCl.genkeypair(publicKey, privateKey);
+		}
+
+		public TestContact(@NonNull String identity, @NonNull byte[] publicKey, @NonNull byte[] privateKey) {
+			this.identity = identity;
+			this.publicKey = publicKey;
+			this.privateKey = privateKey;
+		}
+
+		@NonNull
+		public Contact getContact() {
+			return new Contact(this.identity, this.publicKey);
+		}
+
+		@NonNull
+		public ContactModel getContactModel() {
+			return new ContactModel(this.identity, this.publicKey);
+		}
+
+		@NonNull
+		public IdentityStoreInterface getIdentityStore() {
+			return new InMemoryIdentityStore(
+				this.identity,
+				"",
+				this.privateKey,
+				null
+			);
+		}
+	}
+
+	public static final class TestGroup {
+		private int localGroupId = -1;
+
+		@NonNull
+		public final GroupId apiGroupId;
+
+		@NonNull
+		public final TestContact groupCreator;
+
+		@NonNull
+		public final List<TestContact> members;
+
+		@NonNull
+		public final String groupName;
+
+		@Nullable
+		public final byte[] profilePicture;
+
+		public TestGroup(
+			@NonNull GroupId apiGroupId,
+			@NonNull TestContact groupCreator,
+			@NonNull List<TestContact> members,
+			@NonNull String groupName
+		) {
+			this(apiGroupId, groupCreator, members, groupName, null);
+		}
+
+		public TestGroup(
+			@NonNull GroupId apiGroupId,
+			@NonNull TestContact groupCreator,
+			@NonNull List<TestContact> members,
+			@NonNull String groupName,
+			@Nullable byte[] profilePicture
+		) {
+			this.apiGroupId = apiGroupId;
+			this.groupCreator = groupCreator;
+			this.members = members;
+			this.groupName = groupName;
+			this.profilePicture = profilePicture;
+		}
+
+		@NonNull
+		public GroupModel getGroupModel() {
+			return new GroupModel()
+				.setApiGroupId(apiGroupId)
+				.setCreatedAt(new Date())
+				.setName(this.groupName)
+				.setCreatorIdentity(this.groupCreator.identity)
+				.setId(localGroupId);
+		}
+
+		public void setLocalGroupId(int localGroupId) {
+			this.localGroupId = localGroupId;
+		}
+	}
+
 	/**
 	 * Open the notification area and wait for the notifications to become visible.
 	 *
@@ -87,12 +203,13 @@ public class TestHelpers {
 		}
 
 		// Otherwise, create identity
-		final String identity = "XERCUKNS";
-		final byte[] publicKey = Utils.hexStringToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36");
-		final byte[] privateKey = Utils.hexStringToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a");
-		userService.restoreIdentity(identity, privateKey, publicKey);
-		Log.i(TAG, "Test identity restored: " + identity);
-		return identity;
+		userService.restoreIdentity(
+			TEST_CONTACT.identity,
+			TEST_CONTACT.privateKey,
+			TEST_CONTACT.publicKey
+		);
+		Log.i(TAG, "Test identity restored: " + TEST_CONTACT.identity);
+		return TEST_CONTACT.identity;
 	}
 
 	public static void clearLogcat() {

+ 6 - 3
app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java

@@ -48,9 +48,10 @@ import androidx.test.uiautomator.UiObject2;
 import androidx.test.uiautomator.Until;
 import ch.threema.app.R;
 import ch.threema.app.ScreenshotTakingRule;
-import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.notifications.BackgroundErrorNotification;
+import ch.threema.app.testutils.TestHelpers;
 
+import static ch.threema.app.PermissionRuleUtilsKt.getNotificationPermissionRule;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
@@ -62,7 +63,9 @@ public class BackgroundErrorNotificationTest {
 	private UiDevice mDevice;
 
 	@Rule
-	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain();
+	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain().around(
+		getNotificationPermissionRule()
+	);
 
 	@Before
 	public void getDevice() {
@@ -76,7 +79,7 @@ public class BackgroundErrorNotificationTest {
 	@SuppressWarnings("unused") // Used for manual debugging
 	private static void dumpState(@NonNull UiDevice device) throws IOException {
 		device.takeScreenshot(new File("/sdcard/screenshot.png"));
-		try (OutputStream stream = new BufferedOutputStream(new FileOutputStream(new File("/sdcard/screenshot.uix")))) {
+		try (OutputStream stream = new BufferedOutputStream(new FileOutputStream("/sdcard/screenshot.uix"))) {
 			// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
 			// method leaks a file descriptor.
 			device.dumpWindowHierarchy(stream);

+ 3 - 2
app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.java

@@ -21,7 +21,6 @@
 
 package ch.threema.logging.backend;
 
-import android.Manifest;
 import android.util.Log;
 
 import org.junit.Assert;
@@ -39,6 +38,8 @@ import androidx.test.rule.GrantPermissionRule;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.DangerousTest;
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 /**
  * Debug log file test
  */
@@ -47,7 +48,7 @@ import ch.threema.app.DangerousTest;
 public class DebugLogFileBackendTest {
 
 	@Rule
-	public GrantPermissionRule permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
 
 	@Before
 	public void disableLogfile() {

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

@@ -182,9 +182,9 @@ SUCH DAMAGE.</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
-<h2>ExoPlayer</h2>
+<h2>Gesture Views</h2>
 
-<p>Copyright (c) 2017 Google, Inc.</p>
+<p>Copyright (c) 2022 Alex Vasilkov</p>
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 

+ 30 - 4
app/src/hms/agconnect-services.json

@@ -10,11 +10,21 @@
 		"CN_back":"connect-drcn.hispace.hicloud.com",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE_back":"connect-dre.hispace.hicloud.com",
-		"RU":"connect-drru.dbankcloud.cn",
-		"RU_back":"connect-drru.hispace.hicloud.com",
+		"RU":"connect-drru.hispace.dbankcloud.ru",
+		"RU_back":"connect-drru.hispace.dbankcloud.cn",
 		"SG":"connect-dra.dbankcloud.cn",
 		"SG_back":"connect-dra.hispace.hicloud.com"
 	},
+	"websocketgw_all":{
+		"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
+		"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
+		"DE":"connect-ws-dre.hispace.dbankcloud.cn",
+		"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
+		"RU":"connect-ws-drru.hispace.dbankcloud.ru",
+		"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
+		"SG":"connect-ws-dra.hispace.dbankcloud.cn",
+		"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
+	},
 	"client":{
 		"cp_id":"5190041000024384032",
 		"product_id":"736430079244787738",
@@ -24,16 +34,32 @@
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"package_name":"ch.threema.app.hms"
 	},
+	"app_info":{
+		"app_id":"103713829",
+		"package_name":"ch.threema.app.hms"
+	},
 	"region":"DE",
-	"configuration_version":"2.0",
+	"configuration_version":"3.0",
 	"appInfos":[
 		{
 			"package_name":"ch.threema.app.hms",
-			"app_id":"103713829"
+			"client":{
+				"app_id":"103713829"
 			},
+			"app_info":{
+				"package_name":"ch.threema.app.hms",
+				"app_id":"103713829"
+			}
+		},
 		{
+			"package_name":"ch.threema.app.work.hms",
+			"client":{
+				"app_id":"103858571"
+			},
+			"app_info":{
 				"package_name":"ch.threema.app.work.hms",
 				"app_id":"103858571"
+			}
 		}
 	]
 }

+ 12 - 4
app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -23,7 +23,7 @@ package ch.threema.app.push;
 
 import android.content.Context;
 
-import com.huawei.agconnect.config.AGConnectServicesConfig;
+import com.huawei.agconnect.AGConnectOptionsBuilder;
 import com.huawei.hms.aaid.HmsInstanceId;
 
 import org.slf4j.Logger;
@@ -65,8 +65,8 @@ public class PushRegistrationWorker extends Worker {
 		String error = null;
 		if (clearToken) {
 			try {
-				// Obtain the app ID from the agconnect-service.json file.
-				String appId = AGConnectServicesConfig.fromContext(appContext).getString(APP_ID_CONFIG_FIELD);
+
+				String appId = getAppId(appContext);
 
 				// Delete the token.
 				HmsInstanceId.getInstance(appContext).deleteToken(appId, TOKEN_SCOPE);
@@ -79,8 +79,9 @@ public class PushRegistrationWorker extends Worker {
 		}
         else {
 			try {
-				String appId = AGConnectServicesConfig.fromContext(appContext).getString(APP_ID_CONFIG_FIELD);
+				String appId = getAppId(appContext);
 
+				// Note that this will only work in release builds as the app signature is tested by huawei
 				String token = HmsInstanceId.getInstance(appContext).getToken(appId, TOKEN_SCOPE);
 				logger.info("Received HMS registration token");
 				PushUtil.sendTokenToServer(appContext, appId + '|' +token, ProtocolDefines.PUSHTOKEN_TYPE_HMS);
@@ -99,4 +100,11 @@ public class PushRegistrationWorker extends Worker {
 		return Result.success();
 	}
 
+	/**
+	 * Obtain the app ID from the agconnect-service.json file.
+	 */
+	private String getAppId(Context context) {
+		return new AGConnectOptionsBuilder().build(context).getString(APP_ID_CONFIG_FIELD);
+	}
+
 }

+ 2 - 2
app/src/hms_services_based/java/ch/threema/app/push/PushService.java

@@ -24,7 +24,7 @@ package ch.threema.app.push;
 import android.content.Context;
 import android.text.format.DateUtils;
 
-import com.huawei.agconnect.config.AGConnectServicesConfig;
+import com.huawei.agconnect.AGConnectOptionsBuilder;
 import com.huawei.hms.aaid.HmsInstanceId;
 import com.huawei.hms.api.ConnectionResult;
 import com.huawei.hms.api.HuaweiMobileServicesUtil;
@@ -62,7 +62,7 @@ public class PushService extends HmsMessageService {
 	}
 
 	public static void deleteToken(Context context) {
-		String appId = AGConnectServicesConfig.fromContext(context).getString(APP_ID_CONFIG_FIELD);
+		String appId = new AGConnectOptionsBuilder().build(context).getString(APP_ID_CONFIG_FIELD);
 		try {
 			HmsInstanceId.getInstance(ThreemaApplication.getAppContext()).deleteToken(appId, TOKEN_SCOPE);
 			PushUtil.sendTokenToServer(context,"", ProtocolDefines.PUSHTOKEN_TYPE_NONE);

+ 32 - 6
app/src/hms_work/agconnect-services.json

@@ -10,11 +10,21 @@
 		"CN_back":"connect-drcn.hispace.hicloud.com",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE_back":"connect-dre.hispace.hicloud.com",
-		"RU":"connect-drru.dbankcloud.cn",
-		"RU_back":"connect-drru.hispace.hicloud.com",
+		"RU":"connect-drru.hispace.dbankcloud.ru",
+		"RU_back":"connect-drru.hispace.dbankcloud.cn",
 		"SG":"connect-dra.dbankcloud.cn",
 		"SG_back":"connect-dra.hispace.hicloud.com"
 	},
+	"websocketgw_all":{
+		"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
+		"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
+		"DE":"connect-ws-dre.hispace.dbankcloud.cn",
+		"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
+		"RU":"connect-ws-drru.hispace.dbankcloud.ru",
+		"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
+		"SG":"connect-ws-dra.hispace.dbankcloud.cn",
+		"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
+	},
 	"client":{
 		"cp_id":"5190041000024384032",
 		"product_id":"736430079244787738",
@@ -24,16 +34,32 @@
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"package_name":"ch.threema.app.work.hms"
 	},
+	"app_info":{
+		"app_id":"103858571",
+		"package_name":"ch.threema.app.work.hms"
+	},
 	"region":"DE",
-	"configuration_version":"2.0",
+	"configuration_version":"3.0",
 	"appInfos":[
 		{
-			"package_name":"ch.threema.app.hms",
-			"app_id":"103713829"
+			"package_name":"ch.threema.app.work.hms",
+			"client":{
+				"app_id":"103858571"
 			},
-		{
+			"app_info":{
 				"package_name":"ch.threema.app.work.hms",
 				"app_id":"103858571"
+			}
+		},
+		{
+			"package_name":"ch.threema.app.hms",
+			"client":{
+				"app_id":"103713829"
+			},
+			"app_info":{
+				"package_name":"ch.threema.app.hms",
+				"app_id":"103713829"
+			}
 		}
 	]
 }

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

@@ -24,8 +24,12 @@
 	<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
 
 	<!-- android.permission-group.STORAGE -->
-	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
-	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+	<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
+	<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
+	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+		android:maxSdkVersion="29"/>
+	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
+		android:maxSdkVersion="32"/>
 
 	<!-- android.permission-group.LOCATION -->
 	<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@@ -69,6 +73,9 @@
 	<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
 	<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
 
+	<!-- Permission to show notifications -->
+	<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+
 	<!-- Launcher shortcuts -->
 	<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
 
@@ -156,7 +163,6 @@
 	<application
 		android:name=".ThreemaApplication"
 		android:allowBackup="false"
-		android:extractNativeLibs="true"
 		android:hardwareAccelerated="true"
 		android:icon="@mipmap/ic_launcher"
 		android:roundIcon="@mipmap/ic_launcher"
@@ -172,7 +178,9 @@
 		android:appCategory="social"
 		android:hasFragileUserData="true"
 		tools:replace="android:supportsRtl,android:allowBackup"
-		android:dataExtractionRules="@xml/data_extraction_rules">
+		android:dataExtractionRules="@xml/data_extraction_rules"
+		android:enableOnBackInvokedCallback="true"
+		android:localeConfig="@xml/locales_config">
 		<!-- Note: The "replace" entry above should be kept to ensure that a library cannot accidentally
 		override rtl or backup support. Unfortunately the linter warning cannot be silenced. -->
 		<meta-data
@@ -795,6 +803,11 @@
 			android:name="ch.threema.app.globalsearch.GlobalSearchActivity"
 			android:theme="@style/Theme.Threema.Translucent"
 			android:windowSoftInputMode="adjustResize"/>
+		<activity
+			android:name="ch.threema.app.activities.StarredMessagesActivity"
+			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
+			android:theme="@style/Theme.Threema.StarredMessages"
+			android:windowSoftInputMode="adjustResize"/>
 		<activity
 			android:name=".locationpicker.LocationAutocompleteActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
@@ -847,13 +860,14 @@
 			android:exported="false"
 			android:excludeFromRecents="true">
 		</activity>
+		<activity
+			android:name=".activities.BackupRestoreProgressActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"
+			android:launchMode="singleTask"
+			android:exported="false">
+		</activity>
 
 		<!-- services -->
-		<service
-			android:name=".AutostartService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:enabled="true"
-			android:exported="false"/>
 		<service
 			android:name=".services.AccountAuthenticatorService"
 			android:exported="true">
@@ -889,11 +903,6 @@
 			android:name=".services.WidgetService"
 			android:permission="android.permission.BIND_REMOTEVIEWS"
 			android:exported="false"/>
-		<service
-			android:name=".services.RestrictBackgroundChangedService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:enabled="true"
-			android:exported="false"/>
 		<service
 			android:name=".jobs.ReConnectJobService"
 			android:permission="android.permission.BIND_JOB_SERVICE"/>
@@ -950,6 +959,16 @@
 			</intent-filter>
 		</service>
 
+		<!-- Store the chosen per-app locale -->
+		<service
+			android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
+			android:enabled="false"
+			android:exported="false">
+			<meta-data
+				android:name="autoStoreLocales"
+				android:value="true" />
+		</service>
+
 		<!-- broadcast receivers -->
 		<receiver
 			android:name=".receivers.AutoStartNotifyReceiver"

+ 0 - 123
app/src/main/java/ch/threema/app/AutostartService.java

@@ -1,123 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2013-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;
-
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.net.ConnectivityManager;
-import android.os.Build;
-
-import org.slf4j.Logger;
-
-import androidx.annotation.NonNull;
-import androidx.core.app.FixedJobIntentService;
-import androidx.core.app.NotificationCompat;
-import ch.threema.app.activities.HomeActivity;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.notifications.NotificationBuilderWrapper;
-import ch.threema.app.services.NotificationService;
-import ch.threema.app.services.PreferenceService;
-import ch.threema.app.services.UserService;
-import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.base.utils.LoggingUtil;
-import ch.threema.localcrypto.MasterKey;
-
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_NOTICE;
-import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
-
-public class AutostartService extends FixedJobIntentService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("AutostartService");
-	private static final int JOB_ID = 2000;
-
-	public static void enqueueWork(Context context, Intent work) {
-		enqueueWork(context, AutostartService.class, JOB_ID, work);
-	}
-
-	@Override
-	protected void onHandleWork(@NonNull Intent intent) {
-		logger.info("Processing AutoStart - start");
-
-		MasterKey masterKey = ThreemaApplication.getMasterKey();
-		if (masterKey == null) {
-			logger.error("Unable to launch app");
-			stopSelf();
-			return;
-		}
-
-		// check if masterkey needs a password and issue a notification if necessary
-		if (masterKey.isLocked()) {
-			NotificationCompat.Builder notificationCompat =
-				new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_NOTICE, null)
-					.setSmallIcon(R.drawable.ic_notification_small)
-					.setContentTitle(getString(R.string.master_key_locked))
-					.setContentText(getString(R.string.master_key_locked_notify_description))
-					.setTicker(getString(R.string.master_key_locked))
-					.setCategory(NotificationCompat.CATEGORY_SERVICE);
-
-			Intent notificationIntent = IntentDataUtil.createActionIntentHideAfterUnlock(new Intent(this, HomeActivity.class));
-			notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
-			PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PENDING_INTENT_FLAG_IMMUTABLE);
-			notificationCompat.setContentIntent(pendingIntent);
-			NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
-			notificationManager.notify(ThreemaApplication.MASTER_KEY_LOCKED_NOTIFICATION_ID, notificationCompat.build());
-		}
-
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager == null) {
-			logger.error("Service manager not available");
-			stopSelf();
-			return;
-		}
-
-		// check if background data is disabled and issue a warning
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-			ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
-			if (connMgr != null && connMgr.getRestrictBackgroundStatus() == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED) {
-				NotificationService notificationService = serviceManager.getNotificationService();
-				if (notificationService != null) {
-					notificationService.showNetworkBlockedNotification(false);
-				}
-			}
-		}
-
-		// fixes https://issuetracker.google.com/issues/36951052
-		PreferenceService preferenceService = serviceManager.getPreferenceService();
-		if (preferenceService != null) {
-			// reset feature level
-			preferenceService.setTransmittedFeatureLevel(0);
-
-			//auto fix failed sync account
-			if (preferenceService.isSyncContacts()) {
-				UserService userService = serviceManager.getUserService();
-				if (userService != null && !userService.checkAccount()) {
-					//create account
-					userService.getAccount(true);
-					userService.enableAccountAutoSync(true);
-				}
-			}
-		}
-
-		logger.info("Processing AutoStart - end");
-	}
-}

+ 146 - 132
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -39,7 +39,6 @@ import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
-import android.content.res.Configuration;
 import android.database.ContentObserver;
 import android.database.sqlite.SQLiteException;
 import android.net.ConnectivityManager;
@@ -166,6 +165,7 @@ import ch.threema.app.webclient.services.SessionAndroidService;
 import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
+import ch.threema.app.workers.AutoDeleteWorker;
 import ch.threema.app.workers.IdentityStatesWorker;
 import ch.threema.app.workers.ShareTargetUpdateWorker;
 import ch.threema.app.workers.WorkSyncWorker;
@@ -196,6 +196,7 @@ import ch.threema.storage.models.ballot.BallotModel;
 import ch.threema.storage.models.ballot.GroupBallotModel;
 import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.LinkBallotModel;
+import ch.threema.storage.models.data.status.GroupStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import ch.threema.storage.models.group.IncomingGroupJoinRequestModel;
 
@@ -282,6 +283,9 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String WORKER_THREEMA_SAFE_UPLOAD = "SafeUpload";
 	public static final String WORKER_PERIODIC_THREEMA_SAFE_UPLOAD = "PeriodicSafeUpload";
 	public static final String WORKER_CONNECTIVITY_CHANGE = "ConnectivityChange";
+	public static final String WORKER_AUTO_DELETE = "AutoDelete";
+	public static final String WORKER_AUTOSTART = "Autostart";
+	public static final String WORKER_RESTRICT_BACKGROUND_CHANGED = "RestrictBackgroundChanged";
 
 	public static final Lock onAndroidContactChangeLock = new ReentrantLock();
 
@@ -328,18 +332,22 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			try {
 				GroupService groupService = serviceManager.getGroupService();
 				MessageService messageService = serviceManager.getMessageService();
-				if (groupService != null && messageService != null) {
-					String notice = null;
+				GroupStatusDataModel.GroupStatusType type = null;
 
-					if (newState == GroupService.NOTES) {
-						notice = serviceManager.getContext().getString(R.string.status_create_notes);
-					} else if (newState == GroupService.PEOPLE && oldState != GroupService.UNDEFINED) {
-						notice = serviceManager.getContext().getString(R.string.status_create_notes_off);
-					}
+				if (newState == GroupService.NOTES) {
+					type = GroupStatusDataModel.GroupStatusType.IS_NOTES_GROUP;
+				} else if (newState == GroupService.PEOPLE && oldState != GroupService.UNDEFINED) {
+					type = GroupStatusDataModel.GroupStatusType.IS_PEOPLE_GROUP;
+				}
 
-					if (notice != null) {
-						messageService.createStatusMessage(notice, groupService.createReceiver(groupModel));
-					}
+				if (type != null) {
+					messageService.createGroupStatus(
+						groupService.createReceiver(groupModel),
+						type,
+						null,
+						null,
+						null
+					);
 				}
 			} catch (ThreemaException e) {
 				logger.error("Exception", e);
@@ -545,11 +553,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							} else {
 								logger.info("*** Device waking up");
 								if (serviceManager != null) {
-									try {
-										serviceManager.getLifetimeService().unpause();
-									} catch (Exception e) {
-										logger.error("Exception while unpausing connection", e);
-									}
+									new Thread(() -> {
+										try {
+											serviceManager.getLifetimeService().unpause();
+										} catch (Exception e) {
+											logger.error("Exception while unpausing connection", e);
+										}
+									}, "device_wakup").start();
 									isDeviceIdle = false;
 								} else {
 									logger.info("Service manager unavailable");
@@ -608,6 +618,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					getAppContext().registerReceiver(new BroadcastReceiver() {
 						@Override
 						public void onReceive(Context context, Intent intent) {
+							logger.info("Restrictions have changed. Updating workers");
+
 							AppRestrictionService.getInstance().reload();
 							try {
 								OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(true, true, null);
@@ -615,6 +627,10 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							} catch (IllegalStateException e) {
 								logger.error("Unable to schedule work sync one time work", e);
 							}
+
+							if (!AutoDeleteWorker.Companion.scheduleAutoDelete(getAppContext())) {
+								AutoDeleteWorker.Companion.cancelAutoDelete(getAppContext());
+							}
 						}
 					}, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED));
 				}
@@ -703,15 +719,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		logger.info("*** App is low on memory");
 	}
 
-	@Override
-	public void onConfigurationChanged(@NonNull Configuration newConfig) {
-		if (serviceManager != null) {
-			ConfigUtils.setLocaleOverride(getAppContext(), serviceManager.getPreferenceService());
-		}
-
-		super.onConfigurationChanged(newConfig);
-	}
-
 	@SuppressLint("SwitchIntDef")
 	@Override
 	public void onTrimMemory(int level) {
@@ -1101,12 +1108,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				// schedule shortcut update
 				if (preferenceStore.getBoolean(getAppContext().getString(R.string.preferences__direct_share))) {
 					scheduleShareTargetShortcutUpdate();
-
+				}
+				// schedule auto delete
+				if (!AutoDeleteWorker.Companion.scheduleAutoDelete(getAppContext())) {
+					AutoDeleteWorker.Companion.cancelAutoDelete(getAppContext());
 				}
 			}, "scheduleSync").start();
-
-			// setup locale override
-			ConfigUtils.setLocaleOverride(getAppContext(), serviceManager.getPreferenceService());
 		} catch (MasterKeyLockedException | SQLiteException e) {
 			logger.error("Exception opening database", e);
 		} catch (ThreemaException e) {
@@ -1243,9 +1250,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onCreate(GroupModel newGroupModel) {
 				try {
 					serviceManager.getConversationService().refresh(newGroupModel);
-					serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_create_group),
-							serviceManager.getGroupService().createReceiver(newGroupModel));
+					serviceManager.getMessageService().createGroupStatus(
+						serviceManager.getGroupService().createReceiver(newGroupModel),
+						GroupStatusDataModel.GroupStatusType.CREATED,
+						null,
+						null,
+						null
+					);
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
@@ -1255,15 +1266,19 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onRename(GroupModel groupModel) {
 				new Thread(() -> {
 					try {
-						MessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
+						GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
 						serviceManager.getConversationService().refresh(groupModel);
 						String groupName = groupModel.getName();
 						if (groupName == null) {
 							groupName = "";
 						}
-						serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_rename_group, groupName),
-							messageReceiver);
+						serviceManager.getMessageService().createGroupStatus(
+							messageReceiver,
+							GroupStatusDataModel.GroupStatusType.RENAMED,
+							null,
+							null,
+							groupName
+						);
 						ShortcutUtil.updatePinnedShortcut(messageReceiver);
 					} catch (ThreemaException e) {
 						logger.error("Exception", e);
@@ -1275,11 +1290,15 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onUpdatePhoto(GroupModel groupModel) {
 				new Thread(() -> {
 					try {
-						MessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
+						GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
 						serviceManager.getConversationService().refresh(groupModel);
-						serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_group_new_photo),
-							messageReceiver);
+						serviceManager.getMessageService().createGroupStatus(
+							messageReceiver,
+							GroupStatusDataModel.GroupStatusType.PROFILE_PICTURE_UPDATED,
+							null,
+							null,
+							null
+						);
 						ShortcutUtil.updatePinnedShortcut(messageReceiver);
 					} catch (ThreemaException e) {
 						logger.error("Exception", e);
@@ -1303,51 +1322,42 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			@Override
 			public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
-				String memberName = newIdentity;
-				ContactModel contactModel;
-				try {
-					if ((contactModel = serviceManager.getContactService().getByIdentity(newIdentity)) != null) {
-						memberName = NameUtil.getDisplayNameOrNickname(contactModel, true);
-					}
-				} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-					logger.error("Exception", e);
-				}
-
-
 				try {
-					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
+					final GroupMessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
 					final String myIdentity = serviceManager.getUserService().getIdentity();
 
-					if (receiver != null && !TestUtil.empty(myIdentity)) {
-						serviceManager.getMessageService().createStatusMessage(
-								serviceManager.getContext().getString(R.string.status_group_new_member, memberName),
-								receiver);
+					if (!TestUtil.empty(myIdentity)) {
+						serviceManager.getMessageService().createGroupStatus(
+							receiver,
+							GroupStatusDataModel.GroupStatusType.MEMBER_ADDED,
+							newIdentity,
+							null,
+							null
+						);
 
 						if ((!myIdentity.equals(group.getCreatorIdentity())) || previousMemberCount > 1) {
 							//send all open ballots to the new group member
 							BallotService ballotService = serviceManager.getBallotService();
-							if (ballotService != null) {
-								List<BallotModel> openBallots = ballotService.getBallots(new BallotService.BallotFilter() {
-									@Override
-									public MessageReceiver getReceiver() {
-										return receiver;
-									}
-
-									@Override
-									public BallotModel.State[] getStates() {
-										return new BallotModel.State[]{BallotModel.State.OPEN};
-									}
+							List<BallotModel> openBallots = ballotService.getBallots(new BallotService.BallotFilter() {
+								@Override
+								public MessageReceiver getReceiver() {
+									return receiver;
+								}
 
-									@Override
-									public boolean filter(BallotModel ballotModel) {
-										//only my ballots please
-										return ballotModel.getCreatorIdentity().equals(myIdentity);
-									}
-								});
+								@Override
+								public BallotModel.State[] getStates() {
+									return new BallotModel.State[]{BallotModel.State.OPEN};
+								}
 
-								for (BallotModel ballotModel : openBallots) {
-									ballotService.publish(receiver, ballotModel, null, newIdentity);
+								@Override
+								public boolean filter(BallotModel ballotModel) {
+									//only my ballots please
+									return ballotModel.getCreatorIdentity().equals(myIdentity);
 								}
+							});
+
+							for (BallotModel ballotModel : openBallots) {
+								ballotService.publish(receiver, ballotModel, null, newIdentity);
 							}
 						}
 					}
@@ -1367,21 +1377,32 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			@Override
 			public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
-				String memberName = identity;
-				ContactModel contactModel;
 				try {
-					if ((contactModel = serviceManager.getContactService().getByIdentity(identity)) != null) {
-						memberName = NameUtil.getDisplayNameOrNickname(contactModel, true);
-					}
-				} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-					logger.error("Exception", e);
-				}
-				try {
-					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
+					GroupService groupService = serviceManager.getGroupService();
+					final GroupMessageReceiver receiver = groupService.createReceiver(group);
+
+					serviceManager.getMessageService().createGroupStatus(
+						receiver,
+						GroupStatusDataModel.GroupStatusType.MEMBER_LEFT,
+						identity,
+						null,
+						null
+					);
 
-					serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_group_member_left, memberName),
-							receiver);
+					// Show the orphaned group status when the creator left the group and it was not
+					// this user that left the group (as creator). This listener call is also
+					// triggered when the user (as creator) dissolves the group and in that case we
+					// do not want this status to be shown.
+					if (group.getCreatorIdentity().equals(identity) && !groupService.isGroupCreator(group)) {
+						// Show a group orphaned status message when the creator leaves the group
+						serviceManager.getMessageService().createGroupStatus(
+							receiver,
+							GroupStatusDataModel.GroupStatusType.ORPHANED,
+							null,
+							null,
+							null
+						);
+					}
 
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
@@ -1403,22 +1424,16 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						logger.error("Exception", e);
 					}
 				}
-
-				String memberName = identity;
-				ContactModel contactModel;
 				try {
-					if ((contactModel = serviceManager.getContactService().getByIdentity(identity)) != null) {
-						memberName = NameUtil.getDisplayNameOrNickname(contactModel, true);
-					}
-				} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-					logger.error("Exception", e);
-				}
-				try {
-					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
-
-					serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_group_member_kicked, memberName),
-							receiver);
+					final GroupMessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
+
+					serviceManager.getMessageService().createGroupStatus(
+						receiver,
+						GroupStatusDataModel.GroupStatusType.MEMBER_KICKED,
+						identity,
+						null,
+						null
+					);
 
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
@@ -1762,8 +1777,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								/*&& BallotUtil.isMine(ballotModel, userService)*/) {
 							LinkBallotModel b = ballotService.getLinkedBallotModel(ballotModel);
 							if(b != null) {
-								String message = null;
-								MessageReceiver receiver = null;
+								GroupStatusDataModel.GroupStatusType type = null;
+								MessageReceiver<? extends AbstractMessageModel> receiver = null;
 								if (b instanceof GroupBallotModel) {
 									GroupModel groupModel = groupService.getById(((GroupBallotModel) b).getGroupId());
 
@@ -1784,40 +1799,39 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
 									// 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());
+										// On private voting, only show default update msg!
+										type = GroupStatusDataModel.GroupStatusType.RECEIVED_VOTE;
 									}
-								} else {
-
-									if (receiver != null) {
-										ContactModel votingContactModel = contactService.getByIdentity(votingIdentity);
-
-										if (isFirstVote) {
-											message = serviceManager
-													.getContext().getString(R.string.status_ballot_user_first_vote,
-															NameUtil.getDisplayName(votingContactModel),
-															ballotModel.getName());
-										} else {
-											message = serviceManager
-													.getContext().getString(R.string.status_ballot_user_modified_vote,
-															NameUtil.getDisplayName(votingContactModel),
-															ballotModel.getName());
-										}
+								} else if (receiver != null) {
+									if (isFirstVote) {
+										type = GroupStatusDataModel.GroupStatusType.FIRST_VOTE;
+									} else {
+										type = GroupStatusDataModel.GroupStatusType.MODIFIED_VOTE;
 									}
 								}
 
-								if(TestUtil.required(message, receiver)) {
-									messageService.createStatusMessage(message, receiver);
+								if (type != null && receiver instanceof GroupMessageReceiver) {
+									messageService.createGroupStatus(
+										(GroupMessageReceiver) receiver,
+										type,
+										votingIdentity,
+										ballotModel.getName(),
+										null
+									);
 								}
 
 								//now check if every participant has voted
-								if (isFirstVote && ballotService.getPendingParticipants(ballotModel.getId()).size() == 0) {
-									String ballotAllVotesMessage = serviceManager
-													.getContext().getString(R.string.status_ballot_all_votes,
-															ballotModel.getName());
-
-									messageService.createStatusMessage(ballotAllVotesMessage, receiver);
+								if (isFirstVote
+									&& ballotService.getPendingParticipants(ballotModel.getId()).isEmpty()
+									&& receiver instanceof GroupMessageReceiver
+								) {
+									messageService.createGroupStatus(
+										(GroupMessageReceiver) receiver,
+										GroupStatusDataModel.GroupStatusType.VOTES_COMPLETE,
+										null,
+										ballotModel.getName(),
+										null
+									);
 								}
 							}
 						}

+ 0 - 2
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -387,8 +387,6 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
 		super.onActivityResult(requestCode, resultCode, intent);
 
-		ConfigUtils.setLocaleOverride(this, serviceManager.getPreferenceService());
-
 		if (resultCode == RESULT_OK) {
 			String payload = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
 

+ 8 - 9
app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java

@@ -21,26 +21,27 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.services.PreferenceService.LockingMech_NONE;
+
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.TextView;
 
-import com.google.android.material.tabs.TabLayout;
-
-import org.slf4j.Logger;
-
 import androidx.appcompat.app.ActionBar;
 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.tabs.TabLayout;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.R;
-import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.BackupDataFragment;
 import ch.threema.app.services.DeadlineListService;
-import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.threemasafe.BackupThreemaSafeFragment;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
@@ -50,8 +51,6 @@ import ch.threema.app.utils.HiddenChatUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
 
-import static ch.threema.app.services.PreferenceService.LockingMech_NONE;
-
 public class BackupAdminActivity extends ThreemaToolbarActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupAdminActivity");
 
@@ -102,7 +101,7 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
 			noticeLayout.setVisibility(View.VISIBLE);
 			findViewById(R.id.close_button).setOnClickListener(v -> {
 				preferenceService.setBackupWarningDismissedTime(System.currentTimeMillis());
-				AnimationUtil.collapse(noticeLayout);
+				AnimationUtil.collapse(noticeLayout, null, true);
 			});
 		} else {
 			findViewById(R.id.notice_layout).setVisibility(View.GONE);

+ 202 - 0
app/src/main/java/ch/threema/app/activities/BackupRestoreProgressActivity.kt

@@ -0,0 +1,202 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.activities
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.view.View
+import android.widget.Button
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.backuprestore.csv.BackupService
+import ch.threema.app.backuprestore.csv.RestoreService
+import ch.threema.app.utils.ConfigUtils
+
+/**
+ * This activity is shown when the user opens Threema while a backup is being created or restored.
+ * This is useful to get a hint about the progress if notifications are not allowed or activated.
+ */
+class BackupRestoreProgressActivity : AppCompatActivity() {
+
+    private lateinit var titleTextView: TextView
+    private lateinit var infoTextView: TextView
+    private lateinit var durationDelimiter: View
+    private lateinit var durationText: TextView
+    private lateinit var backupRestoreProgress: ProgressBar
+    private lateinit var closeButton: Button
+    private lateinit var progressType: ProgressType
+
+    private enum class ProgressType {
+        BACKUP,
+        RESTORE,
+    }
+
+    private val backupReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) {
+            val progress = intent?.getIntExtra(BackupService.BACKUP_PROGRESS, -1) ?: -1
+            val maxSteps = intent?.getIntExtra(BackupService.BACKUP_PROGRESS_STEPS, -1) ?: -1
+            val progressMessage = intent?.getStringExtra(BackupService.BACKUP_PROGRESS_MESSAGE)
+            val errorMessage = intent?.getStringExtra(BackupService.BACKUP_PROGRESS_ERROR_MESSAGE)
+
+            onProgressUpdate(progress, maxSteps, progressMessage, errorMessage)
+        }
+    }
+
+    private val restoreReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) {
+            val progress = intent?.getIntExtra(RestoreService.RESTORE_PROGRESS, -1) ?: -1
+            val maxSteps = intent?.getIntExtra(RestoreService.RESTORE_PROGRESS_STEPS, -1) ?: -1
+            val progressMessage = intent?.getStringExtra(RestoreService.RESTORE_PROGRESS_MESSAGE)
+            val errorMessage = intent?.getStringExtra(RestoreService.RESTORE_PROGRESS_ERROR_MESSAGE)
+
+            onProgressUpdate(progress, maxSteps, progressMessage, errorMessage)
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        ConfigUtils.configureSystemBars(this)
+
+        setContentView(R.layout.activity_backup_restore_progress)
+
+        titleTextView = findViewById(R.id.backup_restore_info_title)
+        infoTextView = findViewById(R.id.backup_restore_info_summary)
+        durationDelimiter = findViewById(R.id.duration_delimiter)
+        durationText = findViewById(R.id.duration_text)
+        backupRestoreProgress = findViewById(R.id.backup_restore_progress)
+        closeButton = findViewById(R.id.close_button)
+
+        closeButton.setOnClickListener { finish() }
+
+        if (BackupService.isRunning()) {
+            titleTextView.text = getString(R.string.backup_data_title)
+            infoTextView.text = getString(R.string.backup_in_progress)
+            backupRestoreProgress.isIndeterminate = true
+            progressType = ProgressType.BACKUP
+        } else if (RestoreService.isRunning()) {
+            titleTextView.text = getString(R.string.restore)
+            infoTextView.text = getString(R.string.restoring_backup)
+            backupRestoreProgress.isIndeterminate = true
+            progressType = ProgressType.RESTORE
+        } else {
+            finish()
+        }
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext()).apply {
+            registerReceiver(backupReceiver, IntentFilter(BackupService.BACKUP_PROGRESS_INTENT))
+            registerReceiver(restoreReceiver, IntentFilter(RestoreService.RESTORE_PROGRESS_INTENT))
+        }
+
+        // This is necessary because we might pause the activity while backup/restore and therefore
+        // miss the broadcasts.
+        if (hasFinished()) {
+            onBackupRestoreFinished(null)
+        }
+    }
+
+    override fun onPause() {
+        super.onPause()
+
+        LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext()).apply {
+            unregisterReceiver(backupReceiver)
+            unregisterReceiver(restoreReceiver)
+        }
+    }
+
+    private fun hasFinished() = when (progressType) {
+        ProgressType.BACKUP -> !BackupService.isRunning()
+        ProgressType.RESTORE -> !RestoreService.isRunning()
+    }
+
+    private fun onProgressUpdate(progress: Int, maxSteps: Int, progressMessage: String?, errorMessage: String?) {
+        if (progress >= 0 && maxSteps > 0 && progress <= maxSteps) {
+            backupRestoreProgress.isIndeterminate = false
+            backupRestoreProgress.progress = progress
+            backupRestoreProgress.max = maxSteps
+        } else {
+            backupRestoreProgress.isIndeterminate = true
+        }
+
+        showProgressMessage(progressMessage)
+
+        if (hasFinished() || errorMessage != null) {
+            onBackupRestoreFinished(errorMessage)
+        }
+    }
+
+    private fun showProgressMessage(progressMessage: String?) {
+        if (progressMessage != null) {
+            durationDelimiter.visibility = View.VISIBLE
+            durationText.visibility = View.VISIBLE
+            durationText.text = progressMessage
+        } else {
+            durationDelimiter.visibility = View.INVISIBLE
+            durationText.visibility = View.INVISIBLE
+        }
+    }
+
+    private fun onBackupRestoreFinished(errorMessage: String?) {
+        backupRestoreProgress.visibility = View.INVISIBLE
+
+        infoTextView.text = errorMessage
+            ?: when (progressType) {
+                ProgressType.BACKUP -> getString(R.string.backup_or_restore_success_body)
+                ProgressType.RESTORE -> getString(R.string.restore_success_body)
+            }
+
+        if (errorMessage != null) {
+            closeButton.setText(R.string.close)
+            closeButton.setOnClickListener {
+                finish()
+                cancelCompleteNotification()
+            }
+        } else {
+            closeButton.setText(R.string.ipv6_restart_now)
+            closeButton.setOnClickListener {
+                ConfigUtils.recreateActivity(this)
+                cancelCompleteNotification()
+            }
+        }
+    }
+
+    private fun cancelCompleteNotification() {
+        ThreemaApplication.getServiceManager()?.notificationService?.cancel(
+            when (progressType) {
+                ProgressType.BACKUP -> BackupService.BACKUP_COMPLETION_NOTIFICATION_ID
+                ProgressType.RESTORE -> RestoreService.RESTORE_COMPLETION_NOTIFICATION_ID
+            }
+        )
+    }
+
+}

+ 25 - 8
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java

@@ -28,6 +28,7 @@ import android.view.WindowManager;
 import android.widget.FrameLayout;
 
 import androidx.annotation.NonNull;
+import androidx.core.view.WindowCompat;
 import androidx.fragment.app.FragmentManager;
 
 import org.slf4j.Logger;
@@ -65,8 +66,15 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	public void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 
+		getWindow().setAllowEnterTransitionOverlap(true);
+		getWindow().setAllowReturnTransitionOverlap(true);
 		super.onCreate(savedInstanceState);
 
+		// Tell the Window that our app is going to responsible for fitting for any system windows.
+		// This is similar to the now deprecated:
+		// view.setSystemUiVisibility(LAYOUT_STABLE | LAYOUT_FULLSCREEN | LAYOUT_FULLSCREEN)
+		WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
+
 		this.currentIntent = getIntent();
 
 		//check master key
@@ -95,14 +103,19 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 			}
 		}
 
+		boolean isHidden = checkHiddenChatLock(getIntent(), ID_HIDDEN_CHECK_ON_CREATE);
 		if (composeMessageFragment == null) {
 			// fragment no longer around
 			composeMessageFragment = new ComposeMessageFragment();
-			getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).hide(composeMessageFragment).commit();
-		}
-
-		if (!checkHiddenChatLock(getIntent(), ID_HIDDEN_CHECK_ON_CREATE)) {
-			getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
+			if (isHidden) {
+				getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).hide(composeMessageFragment).commit();
+			} else {
+				getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).commit();
+			}
+		} else {
+			if (!isHidden) {
+				getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
+			}
 		}
 		return true;
 	}
@@ -137,8 +150,12 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	}
 
 	@Override
-	public void onBackPressed() {
-		logger.debug("onBackPressed");
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (ConfigUtils.isTabletLayout()) {
 			if (messageSectionFragment != null) {
 				if (messageSectionFragment.onBackPressed()) {
@@ -155,7 +172,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 			}
 			return;
 		}
-		super.onBackPressed();
+		finish();
 	}
 
 	@Override

+ 1 - 11
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -675,7 +675,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		} else if (id == R.id.action_send_profilepic) {
 			sendProfilePic();
 		} else {
-			finishUp();
+			finish();
 		}
 		return super.onOptionsItemSelected(item);
 	}
@@ -940,16 +940,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	}
 
-	@Override
-	public void onBackPressed() {
-		finishUp();
-	}
-
-
-	private void finishUp() {
-		finish();
-	}
-
 	private void finishAndGoHome() {
 		if (isFinishing() || isDestroyed()) {
 			return;

+ 7 - 1
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -555,7 +555,13 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
+		// Intercepting back navigation is needed as this activity overrides the finish() method
 		this.finish();
 	}
 

+ 6 - 2
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -436,8 +436,12 @@ public class EnterSerialActivity extends ThreemaActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
-		// finish application
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		moveTaskToBack(true);
 		finish();
 	}

+ 5 - 1
app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java

@@ -240,9 +240,13 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 		startActivity(shareIntent);
 	}
 
+	@Override
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
 
 	@Override
-	public void onBackPressed() {
+	protected void handleOnBackPressed() {
 		done();
 	}
 

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

@@ -87,7 +87,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 			protected GroupModel doInBackground(Void... params) {
 				try {
 					Bitmap avatar = avatarFile != null ? BitmapFactory.decodeFile(avatarFile.getPath()) : null;
-					return groupService.createGroup(
+					return groupService.createGroupFromLocal(
 							groupName,
 							groupIdentities,
 							avatar

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

@@ -94,7 +94,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 			int groupId = IntentDataUtil.getGroupId(this.getIntent());
 			if(this.groupService != null && groupId > 0) {
 				this.groupModel = this.groupService.getById(groupId);
-				this.appendMembers = (this.groupModel != null && this.groupService.isGroupOwner(this.groupModel));
+				this.appendMembers = (this.groupModel != null && this.groupService.isGroupCreator(this.groupModel));
 				String[] excluded = IntentDataUtil.getContactIdentities(this.getIntent());
 				if (excluded != null && excluded.length > 0) {
 					this.excludedIdentities = new ArrayList<>(Arrays.asList(excluded));

+ 136 - 59
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -30,11 +30,11 @@ import android.content.Intent;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Color;
+import android.graphics.Paint;
 import android.graphics.PorterDuff;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.text.Editable;
-import android.text.Html;
 import android.text.TextWatcher;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -44,6 +44,7 @@ import android.widget.Toast;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.core.app.ActivityCompat;
@@ -82,6 +83,7 @@ import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.emojis.EmojiEditText;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.grouplinks.GroupLinkOverviewActivity;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
@@ -110,6 +112,7 @@ import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
@@ -123,6 +126,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private final int MODE_READONLY = 2;
 
 	private static final String DIALOG_TAG_LEAVE_GROUP = "leaveGroup";
+	private static final String DIALOG_TAG_DISSOLVE_GROUP = "dissolveGroup";
 	private static final String DIALOG_TAG_UPDATE_GROUP = "updateGroup";
 	private static final String DIALOG_TAG_QUIT = "quit";
 	private static final String DIALOG_TAG_CHOOSE_ACTION = "chooseAction";
@@ -176,7 +180,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupDetailViewModel.setAvatarFile(avatarFile1);
 			groupDetailViewModel.setIsAvatarRemoved(false);
 			hasAvatarChanges = true;
-			updateFloatingActionButton();
+			updateFloatingActionButtonAndMenu();
 		}
 
 		@Override
@@ -185,7 +189,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupDetailViewModel.setIsAvatarRemoved(true);
 			avatarEditView.setDefaultAvatar(null, groupModel);
 			hasAvatarChanges = true;
-			updateFloatingActionButton();
+			updateFloatingActionButtonAndMenu();
 		}
 	};
 
@@ -381,7 +385,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		setTitle();
 		setHasMemberChanges(false);
 
-		if (this.groupService.isGroupOwner(this.groupModel)) {
+		if (groupService.isGroupCreator(groupModel) && groupService.isGroupMember(groupModel)) {
 			operationMode = MODE_EDIT;
 			actionBar.setHomeButtonEnabled(false);
 			actionBar.setDisplayHomeAsUpEnabled(true);
@@ -399,7 +403,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 				@Override
 				public void afterTextChanged(Editable s) {
-					updateFloatingActionButton();
+					updateFloatingActionButtonAndMenu();
 				}
 			});
 		} else {
@@ -414,10 +418,25 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupNameEditText.setPadding(0, 0, 0, 0);
 
 			floatingActionButton.setVisibility(View.GONE);
+
+			// If the user is not a member of the group, then display the group name with strike
+			// through style
+			if (!groupService.isGroupMember(groupModel)) {
+				// Get the paint flags and add the strike through flag
+				int paintFlags = groupNameEditText.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG;
+				groupNameEditText.setPaintFlags(paintFlags);
+			}
 		}
 
 		groupDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
-		setupAdapter();
+
+		try {
+			setupAdapter();
+		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+			logger.error("Could not setup group detail adapter", e);
+			finish();
+			return;
+		}
 
 		Fragment dialogFragment = getSupportFragmentManager().findFragmentByTag(DIALOG_TAG_CHANGE_GROUP_DESC);
 		if (dialogFragment instanceof GroupDescEditDialog) {
@@ -444,7 +463,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		collapsingToolbar.setContentScrimColor(color);
 		collapsingToolbar.setStatusBarScrimColor(color);
 
-		updateFloatingActionButton();
+		updateFloatingActionButtonAndMenu();
 
 		if (toolbar.getNavigationIcon() != null) {
 			toolbar.getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
@@ -455,13 +474,25 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		ListenerManager.contactListeners.add(this.contactListener);
 	}
 
-	private void setupAdapter() {
-		this.groupDetailAdapter = new GroupDetailAdapter(this, this.groupModel, groupDetailViewModel);
+	private void setupAdapter() throws MasterKeyLockedException, FileSystemNotPresentException {
+		Runnable onCloneGroupRunnable = null;
+		if (groupService.isOrphanedGroup(groupModel) && groupService.getOtherMemberCount(groupModel) > 0) {
+			onCloneGroupRunnable = this::showCloneDialog;
+		}
+
+		this.groupDetailAdapter = new GroupDetailAdapter(
+			this,
+			this.groupModel,
+			groupDetailViewModel,
+			serviceManager,
+			onCloneGroupRunnable
+		);
+
 		this.groupDetailAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
 			@Override
 			public void onChanged() {
 				super.onChanged();
-				updateFloatingActionButton();
+				updateFloatingActionButtonAndMenu();
 			}
 		});
 		this.groupDetailAdapter.setOnClickListener(this);
@@ -520,7 +551,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 	private void setHasMemberChanges(boolean hasChanges) {
 		this.hasMemberChanges = hasChanges;
-		updateFloatingActionButton();
+		updateFloatingActionButtonAndMenu();
 	}
 
 	@Override
@@ -543,6 +574,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	public boolean onPrepareOptionsMenu(Menu menu) {
 		MenuItem groupSyncMenu = menu.findItem(R.id.menu_resync);
 		MenuItem leaveGroupMenu = menu.findItem(R.id.menu_leave_group);
+		MenuItem dissolveGroupMenu = menu.findItem(R.id.menu_dissolve_group);
 		MenuItem deleteGroupMenu = menu.findItem(R.id.menu_delete_group);
 		MenuItem cloneMenu = menu.findItem(R.id.menu_clone_group);
 		MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
@@ -556,21 +588,32 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (groupModel != null) {
 			GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
 			groupCallMenu.setVisible(GroupCallUtilKt.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges() && call == null);
-			leaveGroupMenu.setVisible(true);
+
+			boolean isMember = groupService.isGroupMember(groupModel);
+			boolean isCreator = groupService.isGroupCreator(groupModel);
+			boolean hasOtherMembers = groupService.getOtherMemberCount(groupModel) > 0;
+
+			// The clone menu only makes sense if at least one other member is present
+			cloneMenu.setVisible(hasOtherMembers);
+
+			// The leave option is only available for members
+			leaveGroupMenu.setVisible(isMember && !isCreator);
+
+			// The dissolve option is only available for the creator (if it is not yet dissolved)
+			dissolveGroupMenu.setVisible(isMember && isCreator && hasOtherMembers);
+
+			// The delete option is always available
 			deleteGroupMenu.setVisible(true);
-			if (groupService.isGroupOwner(this.groupModel)) {
-				// MODE_EDIT
-				groupSyncMenu.setVisible(true);
-				if (ConfigUtils.supportsGroupLinks()) {
-					groupLinkMenu.setVisible(true);
-				}
-			}
+
+			// The group sync option is only available for the creator when other members are in the group
+			groupSyncMenu.setVisible(isCreator && isMember && hasOtherMembers);
+
+			// The group link menu is only available for the creator and if enabled in configuration
+			groupLinkMenu.setVisible(isCreator && isMember && ConfigUtils.supportsGroupLinks());
 
 			mediaGalleryMenu.setVisible(!hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel)));
-		}
 
-		if (operationMode != MODE_READONLY) {
-			menu.findItem(R.id.action_send_message).setVisible(false);
+			menu.findItem(R.id.action_send_message).setVisible(!hasChanges());
 		}
 
 		return super.onPrepareOptionsMenu(menu);
@@ -596,13 +639,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (itemId == android.R.id.home) {
 			onBackPressed();
 			return true;
-		}
-		else if (itemId == R.id.menu_group_links_manage) {
+		} else if (itemId == R.id.menu_group_links_manage) {
 			Intent groupLinkOverviewIntent = new Intent(this, GroupLinkOverviewActivity.class);
 			groupLinkOverviewIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 			startActivityForResult(groupLinkOverviewIntent, ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS);
-		}
-		else if (itemId == R.id.action_send_message) {
+		} else if (itemId == R.id.action_send_message) {
 			if (groupModel != null) {
 				Intent intent = new Intent(this, ComposeMessageActivity.class);
 				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
@@ -611,37 +652,55 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				startActivity(intent);
 				finish();
 			}
-		}
-		else if (itemId == R.id.menu_resync) {
+		} else if (itemId == R.id.menu_resync) {
 			this.syncGroup();
-		}
-		else if (itemId == R.id.menu_leave_group) {
-			int leaveMessageRes = operationMode == MODE_READONLY ? R.string.really_leave_group_message : R.string.really_leave_group_admin_message;
-
+		} else if (itemId == R.id.menu_leave_group) {
 			GenericAlertDialog.newInstance(
 				R.string.action_leave_group,
-				Html.fromHtml(getString(leaveMessageRes)),
+				R.string.really_leave_group_message,
 				R.string.ok,
-				R.string.cancel)
-				.show(getSupportFragmentManager(), DIALOG_TAG_LEAVE_GROUP);
-		}
-		else if (itemId == R.id.menu_delete_group) {
+				R.string.cancel
+			).show(getSupportFragmentManager(), DIALOG_TAG_LEAVE_GROUP);
+		} else if (itemId == R.id.menu_dissolve_group) {
+			GenericAlertDialog.newInstance(
+				R.string.action_dissolve_group,
+				getString(R.string.really_dissolve_group),
+				R.string.ok,
+				R.string.cancel
+			).show(getSupportFragmentManager(), DIALOG_TAG_DISSOLVE_GROUP);
+		} else if (itemId == R.id.menu_delete_group) {
+			@StringRes int title;
+			@StringRes int description;
+			boolean isGroupCreator = groupService.isGroupCreator(groupModel);
+			boolean isGroupMember = groupService.isGroupMember(groupModel);
+			if (isGroupCreator && isGroupMember) {
+				// Group creator and still member
+				title = R.string.action_dissolve_and_delete_group;
+				description = R.string.delete_my_group_message;
+			} else if (isGroupMember) {
+				// Just a member
+				title = R.string.action_leave_and_delete_group;
+				description = R.string.delete_group_message;
+			} else {
+				// Not even a member anymore
+				title = R.string.action_delete_group;
+				description = R.string.delete_left_group_message;
+			}
+
 			GenericAlertDialog.newInstance(
-				R.string.action_delete_group,
-				groupService.isGroupOwner(groupModel) ? R.string.delete_my_group_message : R.string.delete_group_message,
+				title,
+				description,
 				R.string.ok,
 				R.string.cancel)
 				.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_GROUP);
-		}
-		else if (itemId == R.id.menu_clone_group) {
+		} else if (itemId == R.id.menu_clone_group) {
 			GenericAlertDialog.newInstance(
 				R.string.action_clone_group,
 				R.string.clone_group_message,
 				R.string.yes,
 				R.string.no)
 				.show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP_CONFIRM);
-		}
-		else if (itemId == R.id.menu_gallery) {
+		} else if (itemId == R.id.menu_gallery) {
 			if (groupId > 0 && !hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel))) {
 				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
@@ -657,8 +716,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		new LeaveGroupAsyncTask(groupModel, groupService, this, null, this::finish).execute();
 	}
 
+	private void dissolveGroupAndQuit() {
+		groupService.dissolveGroupFromLocal(groupModel);
+		finish();
+	}
+
 	private void deleteGroupAndQuit() {
-		if (groupService.isGroupOwner(groupModel)) {
+		if (groupService.isGroupCreator(groupModel)) {
 			new DeleteMyGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
 		} else {
 			new DeleteGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
@@ -680,7 +744,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				try {
 					Bitmap avatar = groupService.getAvatar(groupModel, true, false);
 
-					model = groupService.createGroup(
+					model = groupService.createGroupFromLocal(
 							newGroupName,
 							groupService.getGroupIdentities(groupModel),
 							avatar);
@@ -760,7 +824,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		groupModel.setGroupDesc(groupDetailViewModel.getGroupDesc());
 		groupModel.setGroupDescTimestamp(groupDetailViewModel.getGroupDescTimestamp());
 
-
 		new AsyncTask<Void, Void, GroupModel>() {
 			@Override
 			protected void onPreExecute() {
@@ -812,6 +875,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}.execute();
 	}
 
+	private void showCloneDialog() {
+		TextEntryDialog.newInstance(
+				R.string.action_clone_group,
+				R.string.name,
+				R.string.ok,
+				R.string.cancel,
+				groupModel.getName(),
+				0,
+				0)
+			.show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP);
+	}
+
 	@Override
 	public void onActivityResult(int requestCode, int resultCode, Intent data) {
 		if (resultCode == Activity.RESULT_OK) {
@@ -821,7 +896,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				sortGroupMembers();
 				setHasMemberChanges(true);
 			}
-			else if (this.groupService.isGroupOwner(this.groupModel) && requestCode == ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS) {
+			else if (this.groupService.isGroupCreator(this.groupModel) && requestCode == ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS) {
 				// make sure we reset the default link switch if the default link was deleted
 				groupDetailAdapter.notifyDataSetChanged();
 			}
@@ -926,6 +1001,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			case DIALOG_TAG_LEAVE_GROUP:
 				leaveGroupAndQuit();
 				break;
+			case DIALOG_TAG_DISSOLVE_GROUP:
+				dissolveGroupAndQuit();
+				break;
 			case DIALOG_TAG_DELETE_GROUP:
 				deleteGroupAndQuit();
 				break;
@@ -933,15 +1011,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				saveGroupSettings();
 				break;
 			case DIALOG_TAG_CLONE_GROUP_CONFIRM:
-				TextEntryDialog.newInstance(
-						R.string.action_clone_group,
-						R.string.name,
-						R.string.ok,
-						R.string.cancel,
-						groupModel.getName(),
-						0,
-						0)
-						.show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP);
+				showCloneDialog();
 				break;
 			default:
 				break;
@@ -949,7 +1019,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	public void handleOnBackPressed() {
 		if (this.operationMode == MODE_EDIT && hasChanges()) {
 			GenericAlertDialog.newInstance(
 					R.string.leave,
@@ -988,7 +1063,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		return !editedGroupNameText.equals(currentGroupName);
 	}
 
-	private void updateFloatingActionButton() {
+	private void updateFloatingActionButtonAndMenu() {
 		if (this.groupService == null ||
 			this.groupDetailAdapter == null) {
 			logger.error("Required instances not available");
@@ -999,11 +1074,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			return;
 		}
 
-		if (this.groupService.isGroupOwner(this.groupModel) && hasChanges()) {
+		if (this.groupService.isGroupCreator(this.groupModel) && hasChanges()) {
 			this.floatingActionButton.show();
 		} else {
 			this.floatingActionButton.hide();
 		}
+
+		invalidateOptionsMenu();
 	}
 
 	private void navigateHome() {

+ 149 - 29
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -24,6 +24,7 @@ package ch.threema.app.activities;
 import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
 import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
 
+import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.BroadcastReceiver;
@@ -41,7 +42,10 @@ import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
+import android.text.SpannableString;
+import android.text.Spanned;
 import android.text.format.DateUtils;
+import android.text.style.TextAppearanceSpan;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
@@ -50,12 +54,15 @@ import android.view.Window;
 import android.widget.ImageView;
 import android.widget.Toast;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.widget.AppCompatImageView;
+import androidx.core.app.ActivityCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.lifecycle.LifecycleOwner;
@@ -63,6 +70,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 
 import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.badge.BadgeDrawable;
+import com.google.android.material.badge.ExperimentalBadgeUtils;
 import com.google.android.material.bottomnavigation.BottomNavigationView;
 import com.google.android.material.shape.MaterialShapeDrawable;
 
@@ -70,9 +78,11 @@ import org.slf4j.Logger;
 
 import java.io.File;
 import java.lang.ref.WeakReference;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
 
@@ -82,6 +92,8 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.wizard.WizardBaseActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.archive.ArchiveActivity;
+import ch.threema.app.backuprestore.csv.BackupService;
+import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SMSVerificationDialog;
@@ -135,6 +147,7 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
@@ -177,6 +190,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private static final String DIALOG_TAG_FINISH_UP = "fup";
 	private static final String DIALOG_TAG_THREEMA_CHANNEL_VERIFY = "cvf";
 	private static final String DIALOG_TAG_UPDATING = "updating";
+	private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "pwconf";
 
 	private static final String FRAGMENT_TAG_MESSAGES = "0";
 	private static final String FRAGMENT_TAG_CONTACTS = "1";
@@ -192,7 +206,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private MaterialToolbar toolbar;
 	private View connectionIndicator;
 	private View noticeSMSLayout;
-	OngoingCallNoticeView ongoingCallNotice;
+	private OngoingCallNoticeView ongoingCallNotice;
+	private static long starredMessagesCount = 0L;
 
 	private ServiceManager serviceManager;
 	private NotificationService notificationService;
@@ -210,6 +225,19 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	private final List<AbstractMessageModel> unsentMessages = new LinkedList<>();
 
+	private final ActivityResultLauncher<String> notificationPermissionLauncher =
+		registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+			if (!Boolean.TRUE.equals(isGranted)) {
+				// Show permission rationale only once a week
+				long current = System.currentTimeMillis();
+				long last = preferenceService.getLastNotificationRationaleShown();
+				if (current - last >= DateUtils.WEEK_IN_MILLIS) {
+					showNotificationPermissionRationale();
+					preferenceService.setLastNotificationRationaleShown(current);
+				}
+			}
+		});
+
 	private BroadcastReceiver checkLicenseBroadcastReceiver = null;
 	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
 		@Override
@@ -318,6 +346,25 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
+	private static class UpdateStarredMessagesTask extends AsyncTask<Void, Void, Long> {
+		@Override
+		protected Long doInBackground(Void... voids) {
+			MessageService messageService;
+			try {
+				messageService = ThreemaApplication.getServiceManager().getMessageService();
+				return messageService.countStarredMessages();
+			} catch (Exception e) {
+				logger.error("Unable to count starred messages", e);
+				return 0L;
+			}
+		}
+
+		@Override
+		protected void onPostExecute(Long count) {
+			starredMessagesCount = count;
+		}
+	}
+
 	private final ConnectionStateListener connectionStateListener = (connectionState, address) -> updateConnectionIndicator(connectionState);
 
 	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, UnsentMessageAction action) {
@@ -364,7 +411,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onVerified() {
 			RuntimeUtil.runOnUiThread(() -> {
 				if (noticeSMSLayout != null) {
-					AnimationUtil.collapse(noticeSMSLayout);
+					AnimationUtil.collapse(noticeSMSLayout, null, true);
 				}
 			});
 		}
@@ -373,7 +420,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onVerificationStarted() {
 			RuntimeUtil.runOnUiThread(() -> {
 				if (noticeSMSLayout != null) {
-					AnimationUtil.expand(noticeSMSLayout);
+					AnimationUtil.expand(noticeSMSLayout, null, true);
 				}
 			});
 		}
@@ -412,7 +459,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 		@Override
 		public void onModifiedAll() {
-
+			updateBottomNavigation();
 		}
 	};
 
@@ -518,17 +565,22 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	};
 
 	@Override
+	@ExperimentalBadgeUtils
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 
 		final boolean isAppStart = savedInstanceState == null;
 
-		AnimationUtil.setupTransitions(this.getApplicationContext(), getWindow());
-
 		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 
+		if (BackupService.isRunning() || RestoreService.isRunning()) {
+			startActivity(new Intent(this, BackupRestoreProgressActivity.class));
+			finish();
+			return;
+		}
+
 		//check master key
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 
@@ -547,38 +599,38 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						if (ConfigUtils.isWorkRestricted()) {
 							// update configuration
 							final ThreemaSafeMDMConfig newConfig = ThreemaSafeMDMConfig.getInstance();
-							ThreemaSafeService threemaSafeService = null;
-							try {
-								threemaSafeService = serviceManager.getThreemaSafeService();
-							} catch (Exception e) {
-								//
-							}
+							ThreemaSafeService threemaSafeService = getThreemaSafeService();
 
 							if (threemaSafeService != null) {
 								if (newConfig.hasChanged(preferenceService)) {
-									// dispose of old backup, if any
-									try {
-										threemaSafeService.deleteBackup();
-										threemaSafeService.setEnabled(false);
-									} catch (Exception e) {
-										// ignore
-									}
-
-									preferenceService.setThreemaSafeServerInfo(newConfig.getServerInfo());
-
 									if (newConfig.isBackupForced()) {
 										if (newConfig.isSkipBackupPasswordEntry()) {
 											// enable with given password
-											enableSafe(threemaSafeService, newConfig, null);
+											byte[] newMasterKey = threemaSafeService.deriveMasterKey(newConfig.getPassword(), newConfig.getIdentity());
+											byte[] oldMasterKey = preferenceService.getThreemaSafeMasterKey();
+
+											// show warning dialog only when password was changed
+											if (Arrays.equals(newMasterKey, oldMasterKey)) {
+												reconfigureSafe(threemaSafeService, newConfig);
+												enableSafe(threemaSafeService, newConfig, null);
+											} else {
+												GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.threema_safe, R.string.safe_managed_new_password_confirm, R.string.accept, R.string.real_not_now);
+												dialog.setData(newConfig);
+												dialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_PRESET_CONFIRM);
+											}
 										} else if (threemaSafeService.getThreemaSafeMasterKey() != null && threemaSafeService.getThreemaSafeMasterKey().length > 0) {
 											// no password has been given by admin but a master key from a previous backup exists
 											// -> create a new backup with existing password
+											reconfigureSafe(threemaSafeService, newConfig);
 											enableSafe(threemaSafeService, newConfig, threemaSafeService.getThreemaSafeMasterKey());
 										} else {
+											reconfigureSafe(threemaSafeService, newConfig);
 											threemaSafeService.launchForcedPasswordDialog(this, true);
 											finish();
 											return;
 										}
+									} else {
+										reconfigureSafe(threemaSafeService, newConfig);
 									}
 								} else {
 									if (newConfig.isBackupForced() && !preferenceService.getThreemaSafeEnabled()) {
@@ -602,6 +654,37 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				}
 			}
 		}
+
+		if (isAppStart) {
+			ConfigUtils.requestNotificationPermission(this, notificationPermissionLauncher);
+
+			if (serviceManager != null) {
+				LocaleUtil.switchToAndroidXPerAppLanguageSelection(this, serviceManager.getPreferenceService());
+			}
+		}
+	}
+
+	private void reconfigureSafe(@NonNull ThreemaSafeService threemaSafeService, @NonNull ThreemaSafeMDMConfig newConfig) {
+		// dispose of old backup, if any
+		try {
+			threemaSafeService.deleteBackup();
+			threemaSafeService.setEnabled(false);
+		} catch (Exception e) {
+			// ignore
+		}
+
+		if (preferenceService != null) {
+			preferenceService.setThreemaSafeServerInfo(newConfig.getServerInfo());
+		}
+	}
+
+	private ThreemaSafeService getThreemaSafeService() {
+		try {
+			return serviceManager.getThreemaSafeService();
+		} catch (Exception e) {
+			//
+		}
+		return null;
 	}
 
 	@Override
@@ -653,7 +736,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						// To not show the same dialog twice, it is only shown if the previous version
 						// is prior to the first version that used this dialog.
 						// Use the version code of the first version where this dialog should be shown.
-						if (previous < 903) { // do not show to users of previous release candidate
+						if (previous < 925) { // do not show to users of previous release candidate
 							Intent intent = new Intent(this, WhatsNewActivity.class);
 							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
@@ -666,7 +749,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	@SuppressLint("StaticFieldLeak")
-	private void enableSafe(ThreemaSafeService threemaSafeService, ThreemaSafeMDMConfig mdmConfig, final byte[] masterkeyPreset) {
+	private void enableSafe(@NonNull ThreemaSafeService threemaSafeService, ThreemaSafeMDMConfig mdmConfig, final byte[] masterkeyPreset) {
 		new AsyncTask<Void, Void, byte[]>() {
 			@Override
 			protected byte[] doInBackground(Void... voids) {
@@ -957,9 +1040,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			return;
 		}
 
-		// set custom locale
-		ConfigUtils.setLocaleOverride(this, preferenceService);
-
 		// set up content
 		setContentView(R.layout.activity_home);
 
@@ -1288,7 +1368,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	@SuppressLint("StaticFieldLeak")
 	private void reallyCancelVerify() {
-		AnimationUtil.collapse(noticeSMSLayout);
+		AnimationUtil.collapse(noticeSMSLayout, null, true);
 		new AsyncTask<Void, Void, Void>() {
 			@Override
 			protected Void doInBackground(Void... params) {
@@ -1348,6 +1428,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			intent = new Intent(this, ArchiveActivity.class);
 		} else if (id == R.id.globalsearch) {
 			intent = new Intent(this, GlobalSearchActivity.class);
+		} else if (id == R.id.starred_messages) {
+			intent = new Intent(this, StarredMessagesActivity.class);
 		}
 
 		if (intent != null) {
@@ -1465,6 +1547,21 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				webclientMenuItem.setVisible(!(webDisabled));
 			}
 
+			MenuItem starredMessagesItem = menu.findItem(R.id.starred_messages);
+			if (starredMessagesItem != null) {
+				String starredMessagesString = getString(R.string.starred_messages);
+				if (starredMessagesString != null) {
+					if (starredMessagesCount > 0) {
+						TextAppearanceSpan textAppearanceSpan = new TextAppearanceSpan(getApplicationContext(), R.style.Threema_TextAppearance_StarredMessages_Count);
+						String starredMessagesCountString = starredMessagesCount > 99 ? "99+" : Long.toString(starredMessagesCount);
+						SpannableString spannableString = new SpannableString(String.format(Locale.US, starredMessagesString + "   %s", starredMessagesCountString));
+						spannableString.setSpan(textAppearanceSpan, spannableString.length() - starredMessagesCountString.length(), spannableString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+						starredMessagesItem.setTitle(spannableString);
+					} else {
+						starredMessagesItem.setTitle(starredMessagesString);
+					}
+				}
+			}
 			return true;
 		}
 		return false;
@@ -1565,6 +1662,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			case DIALOG_TAG_THREEMA_CHANNEL_VERIFY:
 				addThreemaChannel();
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				ThreemaSafeService threemaSafeService = getThreemaSafeService();
+				if (threemaSafeService != null) {
+					reconfigureSafe(threemaSafeService, (ThreemaSafeMDMConfig) data);
+					enableSafe(threemaSafeService, (ThreemaSafeMDMConfig) data, null);
+				}
+				break;
 			default:
 				break;
 		}
@@ -1581,6 +1685,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			case DIALOG_TAG_SERIAL_LOCKED:
 				finish();
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				/* configuration change deferred */
+				break;
 			default:
 				break;
 		}
@@ -1619,6 +1726,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		super.onResume();
 
 		showMainContent();
+
+		try {
+			new UpdateStarredMessagesTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+		} catch (RejectedExecutionException ignored) {}
 	}
 
 	@Override
@@ -1822,6 +1933,15 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
+	private void showNotificationPermissionRationale() {
+		if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
+			ConfigUtils.showPermissionRationale(
+				this,
+				findViewById(R.id.main_content),
+				getString(R.string.post_notification_permission_rationale, getString(R.string.app_name)));
+		}
+	}
+
 	@Override
 	protected void onSaveInstanceState(@NonNull Bundle outState) {
 		try {

+ 26 - 9
app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java

@@ -331,7 +331,12 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (hasChanges()) {
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
 					R.string.discard_changes_title,
@@ -770,7 +775,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 			@Override
 			protected void onPreExecute() {
-				GenericProgressDialog.newInstance(-1, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_BLUR_FACES);
+				GenericProgressDialog.newInstance(0, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_BLUR_FACES);
 			}
 
 			@Override
@@ -1255,7 +1260,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			protected void onPreExecute() {
 				super.onPreExecute();
 
-				GenericProgressDialog.newInstance(R.string.draw, R.string.saving_media).show(getSupportFragmentManager(), DIALOG_TAG_SAVING_IMAGE);
+				String message = String.format(ConfigUtils.getSafeQuantityString(ImagePaintActivity.this, R.plurals.saving_media, 1, 1));
+				String title = getString(R.string.draw);
+				GenericProgressDialog.newInstance(title, message).show(getSupportFragmentManager(), DIALOG_TAG_SAVING_IMAGE);
 			}
 
 			@Override
@@ -1305,6 +1312,19 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		}
 
 		captionEditText = findViewById(R.id.caption_edittext);
+		captionEditText.setOnClickListener(v -> {
+			if (emojiPicker != null) {
+				if (emojiPicker.isShown()) {
+					if (ConfigUtils.isLandscape(this) &&
+						!ConfigUtils.isTabletLayout()) {
+						emojiPicker.hide();
+					} else {
+						openSoftKeyboard(emojiPicker, captionEditText);
+						runOnSoftKeyboardOpen(() -> emojiPicker.hide());
+					}
+				}
+			}
+		});
 
 		SendButton sendButton = findViewById(R.id.send_button);
 		sendButton.setEnabled(true);
@@ -1382,7 +1402,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
-		emojiButton.attach(this.emojiPicker, preferenceService.isFullscreenIme());
+		emojiButton.attach(this.emojiPicker);
 		emojiPicker.setEmojiKeyListener(emojiKeyListener);
 
 		ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.image_paint_root).getRootView(), (v, insets) -> {
@@ -1424,14 +1444,11 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 				if (emojiPicker.isShown()) {
 					logger.info("EmojiPicker currently shown. Closing.");
 					if (ConfigUtils.isLandscape(this) &&
-						!ConfigUtils.isTabletLayout() &&
-						preferenceService.isFullscreenIme()) {
+						!ConfigUtils.isTabletLayout()) {
 						emojiPicker.hide();
 					} else {
 						openSoftKeyboard(emojiPicker, captionEditText);
-						if (getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY) {
-							emojiPicker.hide();
-						}
+						runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 					}
 				} else {
 					emojiPicker.show(loadStoredSoftKeyboardHeight());

+ 6 - 1
app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java

@@ -189,7 +189,12 @@ public class ImagePaintKeyboardActivity extends ThreemaToolbarActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		cancel();
 	}
 }

+ 20 - 11
app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java

@@ -285,7 +285,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 		chipGroup.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGE_DISAPPEARING|LayoutTransition.CHANGE_APPEARING|LayoutTransition.APPEARING|LayoutTransition.DISAPPEARING);
 
 		contentTypeNames = getResources().getStringArray(R.array.media_gallery_spinner);
-		Arrays.fill(checkedContentTypes, true);
+		preferenceService.getMediaGalleryContentTypes(checkedContentTypes);
 
 		gridLayoutManager = new GridLayoutManager(this, ConfigUtils.isLandscape(this) ? 5 : 3);
 		recyclerView = findViewById(R.id.item_list);
@@ -327,16 +327,16 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 				.setThumbDrawable(Objects.requireNonNull(thumbDrawable))
 				.setTrackDrawable(Objects.requireNonNull(AppCompatResources.getDrawable(this, R.drawable.fastscroll_track_media)))
 				.setPopupStyle(thumbScrollerPopupStyle)
-				.setPopupTextProvider(position -> {
-						int firstVisible = gridLayoutManager.findFirstCompletelyVisibleItemPosition();
-						if (firstVisible >= 0) {
-							AbstractMessageModel item = mediaGalleryAdapter.getItemAtPosition(firstVisible);
-							if (item != null) {
-								return LocaleUtil.formatDateRelative(item.getCreatedAt().getTime());
-							}
+				.setPopupTextProvider((view, position) -> {
+					int firstVisible = gridLayoutManager.findFirstCompletelyVisibleItemPosition();
+					if (firstVisible >= 0) {
+						AbstractMessageModel item = mediaGalleryAdapter.getItemAtPosition(firstVisible);
+						if (item != null) {
+							return LocaleUtil.formatDateRelative(item.getCreatedAt().getTime());
 						}
-						return getString(R.string.unknown);
-					})
+					}
+					return MediaGalleryActivity.this.getString(R.string.unknown);
+				})
 				.build();
 		}
 
@@ -479,7 +479,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 			@Override
 			protected void onPreExecute() {
 				if (selectedMessages.size() > 10) {
-					CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_messages, 0, R.string.cancel, selectedMessages.size());
+					CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_messages, R.string.cancel, selectedMessages.size());
 					dialog.setOnCancelListener((dialog1, which) -> cancelled = true);
 					dialog.show(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA);
 				}
@@ -668,12 +668,15 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 					}
 					if (chipGroup.getChildCount() == 0) {
 						finish();
+					} else {
+						updatePrefs();
 					}
 				});
 				chipGroup.addView(chip);
 			}
 		}
 		updateFilter();
+		updatePrefs();
 	}
 
 	private void updateFilter() {
@@ -682,6 +685,12 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
+	private void updatePrefs() {
+		if (preferenceService != null) {
+			preferenceService.setMediaGalleryContentTypes(checkedContentTypes);
+		}
+	}
+
 	private @Nullable int[] getMediaContentTypeArray() {
 		int[] contentTypeList = new int[checkedContentTypes.length];
 		int n = 0;

+ 22 - 27
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -261,6 +261,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 				public int[] contentTypes() {
 					return filter;
 				}
+
+				@Override
+				public int[] displayTags() {
+					return null;
+				}
 			});
 		} catch (Exception x) {
 			logger.error("Exception", x);
@@ -268,7 +273,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			return false;
 		}
 
-
 		if (intent.getBooleanExtra(EXTRA_ID_REVERSE_ORDER, false)) {
 			// reverse order
 			Collections.reverse(messageModels);
@@ -581,15 +585,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
-	@Override
-	public void onBackPressed() {
-		//if in zoom mode,
-		MediaViewFragment f = this.getCurrentFragment();
-		if (f == null || f.inquireClose()) {
-			super.onBackPressed();
-		}
-	}
-
 	@Override
 	public void onSaveInstanceState(@NonNull Bundle outState) {
 		// fixes https://code.google.com/p/android/issues/detail?id=19917
@@ -655,15 +650,15 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 	public static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
 
 		private final MediaViewerActivity a;
-		private final FragmentManager mFragmentManager;
-		private final SparseArray<Fragment> mFragments;
-		private FragmentTransaction mCurTransaction;
+		private final FragmentManager fragmentManager;
+		private final SparseArray<Fragment> fragments;
+		private FragmentTransaction curTransaction;
 
 		public ScreenSlidePagerAdapter(MediaViewerActivity a, FragmentManager fm) {
 			super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
 			this.a = a;
-			mFragmentManager = fm;
-			mFragments = new SparseArray<>();
+			fragmentManager = fm;
+			fragments = new SparseArray<>();
 		}
 
 		@NonNull
@@ -671,11 +666,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		@Override
 		public Object instantiateItem(@NonNull ViewGroup container, int position) {
 			Fragment fragment = getItem(position);
-			if (mCurTransaction == null) {
-				mCurTransaction = mFragmentManager.beginTransaction();
+			if (curTransaction == null) {
+				curTransaction = fragmentManager.beginTransaction();
 			}
-			mCurTransaction.add(container.getId(), fragment, "fragment:" + position);
-			mFragments.put(position, fragment);
+			curTransaction.add(container.getId(), fragment, "fragment:" + position);
+			fragments.put(position, fragment);
 			return fragment;
 		}
 
@@ -770,11 +765,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
 			logger.debug("destroyItem " + position);
 
-			if (mCurTransaction == null) {
-				mCurTransaction = mFragmentManager.beginTransaction();
+			if (curTransaction == null) {
+				curTransaction = fragmentManager.beginTransaction();
 			}
-			mCurTransaction.detach(mFragments.get(position));
-			mFragments.remove(position);
+			curTransaction.detach(fragments.get(position));
+			fragments.remove(position);
 
 			if (position >= 0 && position < a.fragments.length) {
 				if (TestUtil.required(a.fragments[position])) {
@@ -789,10 +784,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 		@Override
 		public void finishUpdate(@NonNull ViewGroup container) {
-			if (mCurTransaction != null) {
-				mCurTransaction.commitAllowingStateLoss();
-				mCurTransaction = null;
-				mFragmentManager.executePendingTransactions();
+			if (curTransaction != null) {
+				curTransaction.commitAllowingStateLoss();
+				curTransaction = null;
+				fragmentManager.executePendingTransactions();
 			}
 		}
 

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

@@ -165,7 +165,7 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 				noticeText.setText(getNotice());
 				noticeLayout.setVisibility(View.VISIBLE);
 
-				findViewById(R.id.close_button).setOnClickListener(v -> AnimationUtil.collapse(noticeLayout));
+				findViewById(R.id.close_button).setOnClickListener(v -> AnimationUtil.collapse(noticeLayout, null,true));
 			}
 		}
 

+ 6 - 1
app/src/main/java/ch/threema/app/activities/NotificationsActivity.java

@@ -405,7 +405,12 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		onDone();
 	}
 

+ 6 - 1
app/src/main/java/ch/threema/app/activities/PinLockActivity.java

@@ -151,7 +151,12 @@ public class PinLockActivity extends ThreemaActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		quit();
 	}
 

+ 6 - 1
app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.java

@@ -106,7 +106,12 @@ public class ProfilePicRecipientsActivity extends MemberChooseActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		this.menuNext(getSelectedContacts());
 	}
 }

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

@@ -307,7 +307,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			}
 
 			if (hasMedia) {
-				if (!ConfigUtils.requestStoragePermissions(this, null, REQUEST_READ_EXTERNAL_STORAGE)) {
+				if (!ConfigUtils.requestReadStoragePermission(this, null, REQUEST_READ_EXTERNAL_STORAGE)) {
 					return;
 				}
 			}
@@ -998,7 +998,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 	@UiThread
 	private void forwardMessages(final MessageReceiver[] messageReceivers, final Intent intent, boolean keepOriginalCaptions) {
-		CancelableHorizontalProgressDialog.newInstance(R.string.sending_messages, 0, 0, originalMessageModels.size()).show(getSupportFragmentManager(), DIALOG_TAG_MULTISEND);
+		CancelableHorizontalProgressDialog.newInstance(R.string.sending_messages, 0, originalMessageModels.size()).show(getSupportFragmentManager(), DIALOG_TAG_MULTISEND);
 
 		forwardSingleMessage(messageReceivers, 0, intent, keepOriginalCaptions);
 	}

+ 26 - 25
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java

@@ -21,6 +21,23 @@
 
 package ch.threema.app.activities;
 
+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;
+import static ch.threema.app.services.PreferenceService.VideoSize_MEDIUM;
+import static ch.threema.app.services.PreferenceService.VideoSize_ORIGINAL;
+import static ch.threema.app.services.PreferenceService.VideoSize_SEND_AS_FILE;
+import static ch.threema.app.services.PreferenceService.VideoSize_SMALL;
+import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
+import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_CAM;
+import static ch.threema.app.ui.MediaItem.TYPE_VIDEO;
+import static ch.threema.app.ui.MediaItem.TYPE_VIDEO_CAM;
+import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_ADAPTER;
+import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_ALL;
+import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_BOTH_ADAPTERS;
+import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_PREVIEW_ADAPTER;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
@@ -29,7 +46,6 @@ import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
-import android.content.res.Configuration;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
 import android.media.MediaMetadataRetriever;
@@ -118,25 +134,6 @@ 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;
-import static ch.threema.app.services.PreferenceService.VideoSize_MEDIUM;
-import static ch.threema.app.services.PreferenceService.VideoSize_ORIGINAL;
-import static ch.threema.app.services.PreferenceService.VideoSize_SEND_AS_FILE;
-import static ch.threema.app.services.PreferenceService.VideoSize_SMALL;
-import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
-import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_CAM;
-import static ch.threema.app.ui.MediaItem.TYPE_VIDEO;
-import static ch.threema.app.ui.MediaItem.TYPE_VIDEO_CAM;
-import static ch.threema.app.utils.BitmapUtil.FLIP_HORIZONTAL;
-import static ch.threema.app.utils.BitmapUtil.FLIP_VERTICAL;
-import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_ADAPTER;
-import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_ALL;
-import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_BOTH_ADAPTERS;
-import static ch.threema.app.utils.MediaAdapterManagerKt.NOTIFY_PREVIEW_ADAPTER;
-
 public class SendMediaActivity extends ThreemaToolbarActivity implements
 	GenericAlertDialog.DialogClickListener,
 	ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
@@ -397,7 +394,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 			this.emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 			this.emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
-			emojiButton.attach(this.emojiPicker, true);
+			emojiButton.attach(this.emojiPicker);
 			this.emojiPicker.setEmojiKeyListener(new EmojiPicker.EmojiKeyListener() {
 				@Override
 				public void onBackspaceClick() {
@@ -423,6 +420,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 							emojiPicker.hide();
 						} else {
 							openSoftKeyboard(emojiPicker, captionEditText);
+							runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 						}
 					}
 				}
@@ -522,9 +520,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 						emojiPicker.hide();
 					} else {
 						openSoftKeyboard(emojiPicker, captionEditText);
-						if (getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY) {
-							emojiPicker.hide();
-						}
+						runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 					}
 				} else {
 					emojiPicker.show(loadStoredSoftKeyboardHeight());
@@ -1105,7 +1101,12 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (emojiPicker != null && emojiPicker.isShown()) {
 			emojiPicker.hide();
 		} else if (captionEditText.isMentionPopupShowing()) {

+ 365 - 0
app/src/main/java/ch/threema/app/activities/StarredMessagesActivity.kt

@@ -0,0 +1,365 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2019-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.activities
+
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.view.ActionMode
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.LinearLayoutManager
+import ch.threema.app.R
+import ch.threema.app.collections.Functional
+import ch.threema.app.collections.IPredicateNonNull
+import ch.threema.app.dialogs.GenericAlertDialog
+import ch.threema.app.dialogs.SelectorDialog
+import ch.threema.app.globalsearch.GlobalSearchAdapter
+import ch.threema.app.globalsearch.GlobalSearchViewModel
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.DeadlineListService
+import ch.threema.app.services.GroupService
+import ch.threema.app.services.MessageService
+import ch.threema.app.services.MessageServiceImpl.FILTER_CHATS
+import ch.threema.app.services.MessageServiceImpl.FILTER_GROUPS
+import ch.threema.app.services.MessageServiceImpl.FILTER_INCLUDE_ARCHIVED
+import ch.threema.app.services.MessageServiceImpl.FILTER_STARRED_ONLY
+import ch.threema.app.services.PreferenceService
+import ch.threema.app.ui.EmptyRecyclerView
+import ch.threema.app.ui.EmptyView
+import ch.threema.app.ui.SelectorDialogItem
+import ch.threema.app.ui.ThreemaSearchView
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.EditTextUtil
+import ch.threema.app.utils.IntentDataUtil
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.GroupMessageModel
+import ch.threema.storage.models.data.DisplayTag.DISPLAY_TAG_NONE
+import com.bumptech.glide.Glide
+import com.google.android.material.search.SearchBar
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class StarredMessagesActivity : ThreemaToolbarActivity(), SearchView.OnQueryTextListener, SelectorDialog.SelectorDialogClickListener, GenericAlertDialog.DialogClickListener {
+    private val STARRED_MESSSAGES_SEARCH_QUERY_TIMEOUT_MS: Long = 500
+    private var chatsAdapter: GlobalSearchAdapter? = null
+    private var chatsViewModel: GlobalSearchViewModel? = null
+    private var searchView: ThreemaSearchView? = null
+    private var searchBar: SearchBar? = null
+    private var hiddenChatsListService: DeadlineListService? = null
+    private var contactService: ContactService? = null
+    private var messageService: MessageService? = null
+    private var groupService: GroupService? = null
+    private var sortMenuItem: MenuItem? = null
+    private var removeStarsMenuItem: MenuItem? = null
+    private var actionMode: ActionMode? = null
+    private var sortOrder = PreferenceService.StarredMessagesSortOrder_DATE_DESCENDING
+    private var queryText: String? = null
+    private val queryHandler = Handler(Looper.getMainLooper())
+    private val queryTask = Runnable {
+        chatsViewModel?.onQueryChanged(queryText, filterFlags, true, sortOrder == PreferenceService.StarredMessagesSortOrder_DATE_ASCENDING)
+        chatsAdapter?.onQueryChanged(queryText)
+    }
+    private val showMessageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _: ActivityResult ->
+        // starred status may have changed when returning from ComposeMessageFragment
+        chatsViewModel?.onDataChanged()
+    }
+
+    override fun onQueryTextSubmit(query: String): Boolean {
+        return true
+    }
+
+    override fun onQueryTextChange(newText: String?): Boolean {
+        queryText = newText
+        queryHandler.removeCallbacksAndMessages(null)
+        if (queryText?.isNotEmpty() == true) {
+            queryHandler.postDelayed(queryTask, STARRED_MESSSAGES_SEARCH_QUERY_TIMEOUT_MS)
+        } else {
+            chatsViewModel?.onQueryChanged(null, filterFlags, true, sortOrder == PreferenceService.StarredMessagesSortOrder_DATE_ASCENDING)
+            chatsAdapter?.onQueryChanged(null)
+        }
+        return true
+    }
+
+    override fun getLayoutResource(): Int {
+        return R.layout.activity_starred_messages
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        try {
+            contactService = serviceManager.contactService
+            groupService = serviceManager.groupService
+            messageService = serviceManager.messageService
+            hiddenChatsListService = serviceManager.hiddenChatsListService
+        } catch (e: Exception) {
+            logger.error("Exception", e)
+            finish()
+        }
+    }
+
+    override fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (!super.initActivity(savedInstanceState)) {
+            return false
+        }
+
+        sortOrder = preferenceService?.starredMessagesSortOrder ?: PreferenceService.StarredMessagesSortOrder_DATE_DESCENDING
+
+        if (supportActionBar != null) {
+            searchBar = toolbar as SearchBar
+            searchBar?.let { bar ->
+                bar.setNavigationOnClickListener {
+                    searchView?.let {
+                        if (it.isIconified) {
+                            finish()
+                        } else {
+                            it.isIconified = true
+                        }
+                    }
+                }
+                bar.setOnClickListener { searchView?.isIconified = false }
+                ConfigUtils.adjustSearchBarTextViewMargin(this, bar)
+            }
+        }
+        chatsAdapter = GlobalSearchAdapter(
+            this,
+            Glide.with(this),
+            R.layout.item_starred_messages,
+            50
+        )
+        chatsAdapter?.setOnClickItemListener(object : GlobalSearchAdapter.OnClickItemListener {
+            override fun onClick(messageModel: AbstractMessageModel?, itemView: View, position: Int) {
+                if (actionMode != null) {
+                    chatsAdapter?.toggleChecked(position)
+                    if ((chatsAdapter?.checkedItemsCount ?: 0)  > 0) {
+                        actionMode?.invalidate()
+                    } else {
+                        actionMode?.finish()
+                    }
+                } else {
+                    showMessage(messageModel)
+                }
+            }
+
+            override fun onLongClick(
+                messageModel: AbstractMessageModel?,
+                itemView: View,
+                position: Int
+            ): Boolean {
+                actionMode?.finish()
+                chatsAdapter?.toggleChecked(position)
+                if ((chatsAdapter?.checkedItemsCount ?: 0) > 0) {
+                    actionMode = startSupportActionMode(actionModeCallback)
+                }
+                return true
+            }
+        })
+        val recyclerView = findViewById<EmptyRecyclerView>(R.id.recycler_chats)
+        recyclerView.layoutManager = LinearLayoutManager(this)
+        recyclerView.itemAnimator = DefaultItemAnimator()
+        val emptyView = EmptyView(this, ConfigUtils.getActionBarSize(this))
+        emptyView.setup(R.string.no_starred_messages, R.drawable.ic_star_golden_24dp)
+        (recyclerView.parent.parent as ViewGroup).addView(emptyView)
+        recyclerView.emptyView = emptyView
+        emptyView.setLoading(true)
+        recyclerView.adapter = chatsAdapter
+
+        chatsViewModel = ViewModelProvider(this)[GlobalSearchViewModel::class.java]
+        chatsViewModel?.messageModels?.observe(this) {
+            emptyView.setLoading(false)
+            if (it.isNotEmpty()) {
+                chatsAdapter?.setMessageModels(Functional.filter(it, IPredicateNonNull(fun(messageModel: AbstractMessageModel): Boolean {
+                    return if (messageModel is GroupMessageModel) {
+                        messageModel.groupId > 0
+                    } else {
+                        messageModel.identity != null
+                    }
+                })))
+            } else {
+                chatsAdapter?.setMessageModels(it)
+            }
+            removeStarsMenuItem?.isVisible = it.isNotEmpty() && searchView?.isIconified ?: false
+        }
+        onQueryTextChange(null)
+        return true
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        super.onCreateOptionsMenu(menu)
+        menuInflater.inflate(R.menu.action_starred_messages_search, menu)
+        val searchMenuItem = menu.findItem(R.id.menu_action_search)
+        searchView = searchMenuItem.actionView as ThreemaSearchView?
+        searchView?.let {
+            if (ConfigUtils.isLandscape(this)) {
+                it.maxWidth = Int.MAX_VALUE
+            }
+
+            ConfigUtils.adjustSearchViewPadding(it)
+            it.queryHint = getString(R.string.hint_filter_list)
+            it.setOnQueryTextListener(this)
+            it.setOnSearchClickListener {
+                searchBar?.hint = ""
+                sortMenuItem?.isVisible = false
+                removeStarsMenuItem?.isVisible = false
+            }
+            // Show the hint of the search bar again when the search view is closed
+            it.setOnCloseListener {
+                searchBar?.setHint(R.string.starred_messages)
+                sortMenuItem?.isVisible = true
+                removeStarsMenuItem?.isVisible = (((chatsAdapter?.itemCount ?: 0) > 0))
+                false
+            }
+        }
+        if (searchView == null) {
+            searchMenuItem.isVisible = false
+        }
+        sortMenuItem = menu.findItem(R.id.menu_action_sort)
+        sortMenuItem?.setOnMenuItemClickListener {
+            showSortingSelector()
+            false
+        }
+
+        removeStarsMenuItem = menu.findItem(R.id.menu_remove_stars)
+        removeStarsMenuItem?.setOnMenuItemClickListener {
+            GenericAlertDialog.newInstance(R.string.remove_all_stars, R.string.really_remove_all_stars, R.string.yes, R.string.no).show(supportFragmentManager, "rem")
+            false
+        }
+        removeStarsMenuItem?.isVisible = (((chatsAdapter?.itemCount ?: 0) > 0))
+        return true
+    }
+
+    private fun showSortingSelector() {
+        val selectorDialog = SelectorDialog.newInstance(
+                getString(R.string.sort_by),
+                arrayListOf(
+                        SelectorDialogItem(getString(R.string.newest_first), R.drawable.ic_arrow_downward),
+                        SelectorDialogItem(getString(R.string.oldest_first), R.drawable.ic_arrow_upward)
+                ),
+                getString(R.string.cancel))
+        try {
+            selectorDialog.show(supportFragmentManager, DIALOG_TAG_SORT_BY)
+        } catch (e: IllegalStateException) {
+            logger.error("Exception", e)
+        }
+    }
+
+    private fun showMessage(messageModel: AbstractMessageModel?) {
+        if (messageModel == null) {
+            return
+        }
+        if (searchView != null) {
+            EditTextUtil.hideSoftKeyboard(searchView)
+        }
+        showMessageLauncher.launch(IntentDataUtil.getJumpToMessageIntent(this, messageModel))
+    }
+
+    private fun removeStar(checkedItems: MutableList<AbstractMessageModel>?) {
+        if (checkedItems != null) {
+            lifecycleScope.launch(Dispatchers.IO) {
+                checkedItems.forEach {
+                    it.displayTags = DISPLAY_TAG_NONE
+                    messageService?.save(it)
+
+                }
+
+                ListenerManager.messageListeners.handle { listener -> listener.onModified(checkedItems) }
+                checkedItems.clear()
+
+                withContext(Dispatchers.Main) {
+                    actionMode?.finish()
+                    chatsViewModel?.onDataChanged()
+                }
+            }
+        }
+    }
+
+    private fun removeAllStars() {
+        lifecycleScope.launch(Dispatchers.IO) {
+            messageService?.unstarAllMessages()
+            withContext(Dispatchers.Main) {
+                chatsViewModel?.onDataChanged()
+            }
+        }
+    }
+
+    private val actionModeCallback = object : ActionMode.Callback {
+        override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
+            mode?.menuInflater?.inflate(R.menu.action_starred_messages, menu)
+
+            return true
+        }
+
+        override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
+            val checked: Int = chatsAdapter?.checkedItemsCount ?: 0
+            if (checked > 0) {
+                mode?.title = checked.toString()
+                return true
+            }
+            return false
+        }
+
+        override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
+            return if (R.id.menu_remove_star == item?.itemId) {
+                removeStar(chatsAdapter?.checkedItems)
+                true
+            } else {
+                false
+            }
+        }
+
+        override fun onDestroyActionMode(mode: ActionMode?) {
+            chatsAdapter?.clearCheckedItems()
+            actionMode = null
+        }
+    }
+
+    override fun onClick(tag: String, which: Int, data: Any?) {
+        if (DIALOG_TAG_SORT_BY == tag) {
+            sortOrder = which
+            preferenceService?.starredMessagesSortOrder = sortOrder
+            onQueryTextChange(queryText)
+        }
+    }
+
+    override fun onYes(tag: String?, data: Any?) {
+        removeAllStars()
+    }
+
+    companion object {
+        private val logger = LoggingUtil.getThreemaLogger("StarredMessagesActivity")
+        private const val DIALOG_TAG_SORT_BY = "sortBy"
+        private const val filterFlags = FILTER_STARRED_ONLY or FILTER_GROUPS or FILTER_CHATS or FILTER_INCLUDE_ARCHIVED
+    }
+}
+
+

+ 98 - 47
app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java

@@ -31,9 +31,13 @@ import android.view.View;
 import android.widget.ArrayAdapter;
 import android.widget.Button;
 import android.widget.FrameLayout;
+import android.widget.ImageButton;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.appcompat.app.ActionBar;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+
 import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
@@ -45,24 +49,25 @@ import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.TimeUnit;
 
-import androidx.appcompat.app.ActionBar;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.DeleteIdentityAsyncTask;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.ui.LongToast;
 import ch.threema.app.utils.AppRestrictionUtil;
+import ch.threema.app.utils.AutoDeleteUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.workers.AutoDeleteWorker;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
@@ -77,20 +82,20 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 	private static final String DELETE_MESSAGES_PROGRESS_TAG = "delmsgs";
 	private static final String DIALOG_TAG_DELETE_ID = "delid";
 	private static final String DIALOG_TAG_REALLY_DELETE = "rlydelete";
+	private static final String DIALOG_TAG_SET_AUTO_DELETE = "autodelete";
 
 	private FileService fileService;
 	private MessageService messageService;
 	private ConversationService conversationService;
-	private UserService userService;
 	private TextView totalView, usageView, freeView, messageView, inuseView;
-	private MaterialAutoCompleteTextView timeSpinner, messageTimeSpinner;
-	private Button deleteButton, messageDeleteButton;
 	private CircularProgressIndicator progressBar;
 	private boolean isCancelled, isMessageDeleteCancelled;
-	private int selectedSpinnerItem, selectedMessageSpinnerItem;
+	private int selectedSpinnerItem, selectedMessageSpinnerItem, selectedKeepMessageSpinnerItem;
+	private MaterialAutoCompleteTextView keepMessagesSpinner;
 	private FrameLayout storageFull, storageThreema, storageEmpty;
 	private CoordinatorLayout coordinatorLayout;
-	private int[] dayValues = {730, 365, 183, 92, 31, 7, 0};
+	private final int[] dayValues = {730, 365, 183, 92, 31, 7, 0};
+	private final int[] keepMessagesValues = {0, 365, 183, 92, 31, 7};
 
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
@@ -102,18 +107,19 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			actionBar.setTitle(R.string.storage_management);
 		}
 
+		UserService userService;
 		try {
 			this.fileService = serviceManager.getFileService();
 			this.messageService = serviceManager.getMessageService();
 			this.conversationService = serviceManager.getConversationService();
-			this.userService = serviceManager.getUserService();
+			userService = serviceManager.getUserService();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 			finish();
 			return;
 		}
 
-		if (!this.userService.hasIdentity()) {
+		if (!userService.hasIdentity()) {
 			Toast.makeText(this, "Nothing to delete!", Toast.LENGTH_SHORT).show();
 			finish();
 			return;
@@ -125,14 +131,18 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 		freeView = findViewById(R.id.free_view);
 		inuseView = findViewById(R.id.in_use_view);
 		messageView = findViewById(R.id.num_messages_view);
-		timeSpinner = findViewById(R.id.time_spinner);
-		messageTimeSpinner = findViewById(R.id.time_spinner_messages);
-		deleteButton = findViewById(R.id.delete_button);
-		messageDeleteButton = findViewById(R.id.delete_button_messages);
+		MaterialAutoCompleteTextView timeSpinner = findViewById(R.id.time_spinner);
+		keepMessagesSpinner = findViewById(R.id.keep_messages_spinner);
+		MaterialAutoCompleteTextView messageTimeSpinner = findViewById(R.id.time_spinner_messages);
+		Button deleteButton = findViewById(R.id.delete_button);
+		Button messageDeleteButton = findViewById(R.id.delete_button_messages);
 		storageFull = findViewById(R.id.storage_full);
 		storageThreema = findViewById(R.id.storage_threema);
 		storageEmpty = findViewById(R.id.storage_empty);
 		progressBar = findViewById(R.id.progressbar);
+		ImageButton autoDeleteInfo = findViewById(R.id.auto_delete_info);
+		autoDeleteInfo.setOnClickListener(v -> SimpleStringAlertDialog.newInstance(R.string.delete_automatically, R.string.autodelete_explain).show(getSupportFragmentManager(), "autoDel"));
+
 		selectedSpinnerItem = 0;
 		selectedMessageSpinnerItem = 0;
 		((TextView) findViewById(R.id.used_by_threema)).setText(getString(R.string.storage_threema, getString(R.string.app_name)));
@@ -185,12 +195,54 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			selectedMessageSpinnerItem = position;
 		});
 
-		storageFull.post(new Runnable() {
-			@Override
-			public void run() {
-				updateStorageDisplay();
+		Integer days = ConfigUtils.isWorkRestricted()
+			? AppRestrictionUtil.getKeepMessagesDays(this)
+			: null;
+		if (days != null) {
+			findViewById(R.id.keep_messages_spinner_layout).setEnabled(false);
+			keepMessagesSpinner.setEnabled(false);
+			findViewById(R.id.disabled_by_policy).setVisibility(View.VISIBLE);
+			if (days <= 0) {
+				keepMessagesSpinner.setText(getString(R.string.forever));
+			} else {
+				keepMessagesSpinner.setText(getString(R.string.number_of_days, days));
 			}
-		});
+		} else {
+			selectedKeepMessageSpinnerItem = 0;
+			days = preferenceService.getAutoDeleteDays();
+			for (int i = keepMessagesValues.length - 1; i > 0; i--) {
+				if (keepMessagesValues[i] <= days) {
+					selectedKeepMessageSpinnerItem = i;
+				} else {
+					break;
+				}
+			}
+
+			final ArrayAdapter<CharSequence> keepMessagesAdapter = ArrayAdapter.createFromResource(this, R.array.keep_messages_timeout, android.R.layout.simple_spinner_dropdown_item);
+			keepMessagesSpinner.setAdapter(keepMessagesAdapter);
+			keepMessagesSpinner.setText(keepMessagesAdapter.getItem(selectedKeepMessageSpinnerItem), false);
+			keepMessagesSpinner.setOnItemClickListener((parent, view, position, id) -> {
+				if (position != selectedKeepMessageSpinnerItem) {
+					int selectedDays = keepMessagesValues[position];
+					if (selectedDays > 0) {
+						GenericAlertDialog dialog = GenericAlertDialog.newInstance(
+							R.string.delete_automatically,
+							getString(R.string.autodelete_confirm, keepMessagesSpinner.getText()),
+							R.string.yes,
+							R.string.no);
+						dialog.setData(position);
+						dialog.show(getSupportFragmentManager(), DIALOG_TAG_SET_AUTO_DELETE);
+					} else {
+						selectedKeepMessageSpinnerItem = position;
+						preferenceService.setAutoDeleteDays(selectedDays);
+						LongToast.makeText(StorageManagementActivity.this, R.string.autodelete_disabled, Toast.LENGTH_LONG).show();
+						AutoDeleteWorker.Companion.cancelAutoDelete(ThreemaApplication.getAppContext());
+					}
+				}
+			});
+		}
+
+		storageFull.post(this::updateStorageDisplay);
 	}
 
 	@SuppressLint("StaticFieldLeak")
@@ -248,31 +300,15 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				finish();
-				return true;
+		if (item.getItemId() == android.R.id.home) {
+			finish();
+			return true;
 		}
 		return super.onOptionsItemSelected(item);
 	}
 
-	/**
-	 * TODO: replace with Date.before
-	 *
-	 * @param d1
-	 * @param d2
-	 * @return
-	 */
-	private long getDifferenceDays(Date d1, Date d2) {
-		if (d1 != null && d2 != null) {
-			long diff = d2.getTime() - d1.getTime();
-			return TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS);
-		}
-		return 0;
-	}
-
 	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMessages(final int days) {
+	private void deleteMessages(final int days) {
 		final Date today = new Date();
 
 		new AsyncTask<Void, Integer, Void>() {
@@ -281,7 +317,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			@Override
 			protected void onPreExecute() {
 				isMessageDeleteCancelled = false;
-				CancelableHorizontalProgressDialog.newInstance(R.string.delete_message, 0, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG);
+				CancelableHorizontalProgressDialog.newInstance(R.string.delete_message, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG);
 			}
 
 			@Override
@@ -317,7 +353,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 							postedDate = messageModel.getCreatedAt();
 						}
 
-						if (days == 0 || (postedDate != null && getDifferenceDays(postedDate, today) > days)) {
+						if (days == 0 || (postedDate != null && AutoDeleteUtil.getDifferenceDays(postedDate, today) > days)) {
 							messageService.remove(messageModel, true);
 							delCount++;
 						}
@@ -345,11 +381,10 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			}
 		}.execute();
 
-		return false;
 	}
 
 	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMediaFiles(final int days) {
+	private void deleteMediaFiles(final int days) {
 		final Date today = new Date();
 		final MessageService.MessageFilter messageFilter = new MessageService.MessageFilter() {
 			@Override
@@ -379,6 +414,11 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			public int[] contentTypes() {
 				return null;
 			}
+
+			@Override
+			public int[] displayTags() {
+				return null;
+			}
 		};
 
 		new AsyncTask<Void, Integer, Void>() {
@@ -387,7 +427,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			@Override
 			protected void onPreExecute() {
 				isCancelled = false;
-				CancelableHorizontalProgressDialog.newInstance(R.string.delete_data, 0, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_PROGRESS_TAG);
+				CancelableHorizontalProgressDialog.newInstance(R.string.delete_data, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_PROGRESS_TAG);
 			}
 
 			@Override
@@ -421,7 +461,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 							postedDate = messageModel.getCreatedAt();
 						}
 
-						if (days == 0 || (postedDate != null && getDifferenceDays(postedDate, today) > days)) {
+						if (days == 0 || (postedDate != null && AutoDeleteUtil.getDifferenceDays(postedDate, today) > days)) {
 							if (fileService.removeMessageFiles(messageModel, false)) {
 								delCount++;
 							}
@@ -450,7 +490,6 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			}
 		}.execute();
 
-		return false;
 	}
 
 	@Override
@@ -474,12 +513,24 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 					System.exit(0);
 				}
 			}).execute();
+		} else if (DIALOG_TAG_SET_AUTO_DELETE.equals(tag)) {
+			if (data != null) {
+				int spinnerItemPosition = (int) data;
+				int selectedDays = keepMessagesValues[spinnerItemPosition];
+				selectedKeepMessageSpinnerItem = spinnerItemPosition;
+				LongToast.makeText(StorageManagementActivity.this, R.string.autodelete_activated, Toast.LENGTH_LONG).show();
+				preferenceService.setAutoDeleteDays(selectedDays);
+				AutoDeleteWorker.Companion.scheduleAutoDelete(ThreemaApplication.getAppContext());
+			}
 		}
 	}
 
+
 	@Override
 	public void onNo(String tag, Object data) {
-
+		if (DIALOG_TAG_SET_AUTO_DELETE.equals(tag)) {
+			keepMessagesSpinner.setText(((ArrayAdapter<CharSequence>)keepMessagesSpinner.getAdapter()).getItem(selectedKeepMessageSpinnerItem), false);
+		}
 	}
 
 	@Override

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

@@ -235,7 +235,7 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 	@Override
 	public void onYes(String tag, Object data) {
 		if (LinkifyUtil.DIALOG_TAG_CONFIRM_LINK.equals(tag)) {
-			LinkifyUtil.getInstance().openLink(this, (Uri) data);
+			LinkifyUtil.getInstance().openLink((Uri) data, null, this);
 		}
 	}
 

+ 3 - 1
app/src/main/java/ch/threema/app/activities/ThreemaActivity.java

@@ -21,6 +21,7 @@
 
 package ch.threema.app.activities;
 
+import android.content.Intent;
 import android.widget.Toast;
 
 import org.slf4j.Logger;
@@ -87,7 +88,8 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 		}
 
 		if (BackupService.isRunning() || RestoreService.isRunning()) {
-			Toast.makeText(this, "Backup or restore in progress. Try Again later.", Toast.LENGTH_LONG).show();
+			Intent intent = new Intent(this, BackupRestoreProgressActivity.class);
+			startActivity(intent);
 			finish();
 		}
 

+ 35 - 1
app/src/main/java/ch/threema/app/activities/ThreemaAppCompatActivity.java

@@ -25,10 +25,12 @@ import static android.content.res.Configuration.UI_MODE_NIGHT_YES;
 import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO;
 import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES;
 
+import android.content.Intent;
 import android.content.res.Configuration;
 import android.os.Bundle;
 import android.widget.Toast;
 
+import androidx.activity.OnBackPressedCallback;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
@@ -56,12 +58,23 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 
 		savedDayNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
 		ConfigUtils.setCurrentDayNightMode(savedDayNightMode == UI_MODE_NIGHT_YES ? MODE_NIGHT_YES : MODE_NIGHT_NO);
+
+		// Enable the on back pressed callback if the activity uses custom back navigation
+		if (enableOnBackPressedCallback()) {
+			getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+				@Override
+				public void handleOnBackPressed() {
+					ThreemaAppCompatActivity.this.handleOnBackPressed();
+				}
+			});
+		}
 	}
 
 	@Override
 	protected void onResume() {
 		if (BackupService.isRunning() || RestoreService.isRunning()) {
-			Toast.makeText(this,  R.string.backup_restore_in_progress, Toast.LENGTH_LONG).show();
+			Intent intent = new Intent(this, BackupRestoreProgressActivity.class);
+			startActivity(intent);
 			finish();
 		}
 		try {
@@ -90,4 +103,25 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 		}
 		super.onConfigurationChanged(newConfig);
 	}
+
+	/**
+	 * If an activity overrides this and returns {@code true}, then a lifecycle-aware on back
+	 * pressed callback is added that calls {@link #handleOnBackPressed()} when the back button has
+	 * been pressed.
+	 *
+	 * @return {@code true} if the back navigation should be intercepted, {@code false} otherwise
+	 */
+	protected boolean enableOnBackPressedCallback() {
+		return false;
+	}
+
+	/**
+	 * Handle an on back pressed event. This method gets only called when the on back pressed
+	 * callback is registered. This only happens when {@link #enableOnBackPressedCallback()} returns
+	 * {@code true}. Note that this method is lifecycle aware, i.e., it is not called if the
+	 * activity has been destroyed.
+	 */
+	protected void handleOnBackPressed() {
+		// Nothing to do here
+	}
 }

+ 3 - 20
app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java

@@ -24,7 +24,6 @@ package ch.threema.app.activities;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.res.Configuration;
-import android.content.res.Resources;
 import android.os.Bundle;
 import android.view.View;
 import android.widget.EditText;
@@ -32,6 +31,7 @@ import android.widget.Toast;
 
 import androidx.annotation.LayoutRes;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.widget.Toolbar;
 import androidx.preference.PreferenceManager;
@@ -93,16 +93,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 		super.onPause();
 	}
 
-	@Override
-	protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) {
-		super.onApplyThemeResource(theme, resid, first);
-	}
-
-	@Override
-	protected void onNewIntent(Intent intent) {
-		super.onNewIntent(intent);
-	}
-
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
@@ -156,7 +146,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	 * @param savedInstanceState the bundle provided to onCreate()
 	 * @return true on success, false otherwise
 	 */
-	protected boolean initActivity(Bundle savedInstanceState) {
+	protected boolean initActivity(@Nullable Bundle savedInstanceState) {
 		logger.debug("initActivity");
 
 		@LayoutRes int layoutResource = getLayoutResource();
@@ -177,7 +167,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 		// hide contents in app switcher and inhibit screenshots
 		ConfigUtils.setScreenshotsAllowed(this, preferenceService, lockAppService);
-		ConfigUtils.setLocaleOverride(this, preferenceService);
 
 		if (layoutResource != 0) {
 			logger.debug("setContentView");
@@ -342,13 +331,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 	@UiThread
 	public void openSoftKeyboard(@NonNull final EmojiPicker emojiPicker, @NonNull final EditText messageText) {
-		runOnSoftKeyboardOpen(() -> {
-			emojiPicker.hide();
-		});
-		messageText.post(() -> {
-			messageText.requestFocus();
-			EditTextUtil.showSoftKeyboard(messageText);
-		});
+		EditTextUtil.focusWindowAndShowSoftKeyboard(messageText);
 	}
 
 	public boolean isSoftKeyboardOpen() {

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

@@ -204,8 +204,8 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 
 							// ServiceManager (and thus LifetimeService) are now available
 							// Trigger a connection
+							final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 							new Thread(() -> {
-								final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 								if (serviceManager != null) {
 									final LifetimeService lifetimeService = serviceManager.getLifetimeService();
 									if (lifetimeService != null) {

+ 6 - 1
app/src/main/java/ch/threema/app/activities/WhatsNew2Activity.java

@@ -58,7 +58,12 @@ public class WhatsNew2Activity extends ThreemaAppCompatActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		Intent intent = new Intent(WhatsNew2Activity.this, WhatsNewActivity.class);
 		intent.putExtra(EXTRA_NO_ANIMATION, true);
 		startActivity(intent);

+ 6 - 1
app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java

@@ -260,7 +260,12 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		setResult(RESULT_CANCELED);
 		finish();
 	}

+ 6 - 1
app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java

@@ -408,7 +408,12 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		setResult(RESULT_OK);
 		finish();
 	}

+ 6 - 1
app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java

@@ -368,7 +368,12 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		setResult(RESULT_OK);
 		finish();
 	}

+ 7 - 2
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java

@@ -195,10 +195,15 @@ public class BallotWizardActivity extends ThreemaActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		int currentItem = pager.getCurrentItem();
 		if (currentItem == 0) {
-			super.onBackPressed();
+			finish();
 		} else {
 			pager.setCurrentItem(currentItem - 1);
 		}

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

@@ -71,7 +71,12 @@ public abstract class WizardBackgroundActivity extends ThreemaAppCompatActivity
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		// catch back key
 	}
 

+ 31 - 8
app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java

@@ -21,6 +21,7 @@
 
 package ch.threema.app.activities.wizard;
 
+import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
@@ -32,12 +33,13 @@ import android.text.Html;
 import android.text.method.LinkMovementMethod;
 import android.view.View;
 import android.widget.TextView;
-import android.widget.Toast;
 
 import org.slf4j.Logger;
 
 import java.io.File;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.core.content.ContextCompat;
@@ -80,6 +82,15 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 	private FileService fileService;
 	private UserService userService;
 	private PreferenceService preferenceService;
+	private File backupFile;
+	private String backupPassword;
+
+	private final ActivityResultLauncher<String> permissionLauncher =
+		registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+			// Restore backup even if permission is not granted as we do not strictly require the
+			// notification permission.
+			startRestore();
+		});
 
 	@Override
 	protected void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
@@ -157,12 +168,12 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 		findViewById(R.id.cancel).setOnClickListener(v -> finish());
 	}
 
-	public void restoreSafe() {
+	private void restoreSafe() {
 		startActivity(new Intent(this, WizardSafeRestoreActivity.class));
 		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 	}
 
-	public void restoreIDExport(String backupString, String backupPassword) {
+	private void restoreIDExport(String backupString, String backupPassword) {
 		Intent intent = new Intent(this, WizardIDRestoreActivity.class);
 
 		if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
@@ -252,6 +263,16 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 		dialogFragment.show(getSupportFragmentManager(), "restorePW");
 	}
 
+	private void startRestore() {
+		Intent intent = new Intent(this, RestoreService.class);
+		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_FILE, backupFile);
+		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_PASSWORD, backupPassword);
+		ContextCompat.startForegroundService(this, intent);
+
+		setResult(Activity.RESULT_OK);
+		finish();
+	}
+
 	@UiThread
 	private void showNoInternetDialog(File file) {
 		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.menu_restore, R.string.new_wizard_need_internet, R.string.retry, R.string.cancel);
@@ -286,11 +307,13 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 	// start password dialog callbacks
 	@Override
 	public void onYes(String tag, String text, boolean isChecked, Object data) {
-		Intent intent = new Intent(this, RestoreService.class);
-		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_FILE, (File) data);
-		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_PASSWORD, text);
-		ContextCompat.startForegroundService(this, intent);
-		finish();
+		this.backupFile = (File) data;
+		this.backupPassword = text;
+
+		// If the permission is already granted, then start the restore directly
+		if (ConfigUtils.requestNotificationPermission(this, permissionLauncher)) {
+			startRestore();
+		}
 	}
 
 	@Override

+ 40 - 8
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -125,6 +125,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 
 	public static final boolean DEFAULT_SYNC_CONTACTS = false;
 	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
+	private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "pwPreset";
 
 	private static int lastPage = 0;
 	private ParallaxViewPager viewPager;
@@ -275,9 +276,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	private void setupConfig() {
 		safeConfig = ThreemaSafeMDMConfig.getInstance();
 
+		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
+		viewPager.addOnPageChangeListener(this);
+
 		if (ConfigUtils.isWorkRestricted()) {
 			if (isSafeEnabled()) {
-				safePassword = safeConfig.getPassword();
+				if (isSafeForced()) {
+					safePassword = safeConfig.getPassword();
+				}
 				safeServerInfo = safeConfig.getServerInfo();
 			}
 
@@ -331,11 +337,16 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 			isSyncContacts = false;
 		}
 
-		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
-		viewPager.addOnPageChangeListener(this);
-
 		presetMobile = this.userService.getLinkedMobile();
 		presetEmail = this.userService.getLinkedEmail();
+
+		if (ConfigUtils.isWorkRestricted()) {
+			// confirm the use of a managed password
+			if (!safeConfig.isBackupDisabled() && safeConfig.isBackupPasswordPreset()) {
+				WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_managed_password_confirm, R.string.accept, R.string.real_not_now, WizardDialog.Highlight.NONE);
+				wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_PRESET_CONFIRM);
+			}
+		}
 	}
 
 	/**
@@ -526,8 +537,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				linkPhone();
 			}
 		} else {
-			WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_info_sync_contacts_dialog, R.string.yes, R.string.no, null);
-			wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_ENABLE);
+			if (this.skipWizard) {
+				isSyncContacts = false;
+				this.serviceManager.getPreferenceService().setSyncContacts(false);
+				linkPhone();
+			} else {
+				WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_info_sync_contacts_dialog, R.string.yes, R.string.no, null);
+				wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_ENABLE);
+			}
 		}
 	}
 
@@ -630,6 +647,11 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 		return !safeConfig.isBackupDisabled();
 	}
 
+	@Override
+	public boolean isSafeForced() {
+		return safeConfig.isBackupForced();
+	}
+
 	@Override
 	public String getSafePassword() {
 		return this.safePassword;
@@ -656,7 +678,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	}
 
 	/**
-	 * Return wether the identity was just created
+	 * Return whether the identity was just created
 	 * @return true if it's a new identity, false if the identity was restored
 	 */
 	public boolean isNewIdentity() {
@@ -674,6 +696,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				break;
 			case DIALOG_TAG_PASSWORD_BAD:
 			case DIALOG_TAG_THREEMA_SAFE:
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
 				break;
 			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
 			case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
@@ -702,11 +725,20 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				this.serviceManager.getPreferenceService().setSyncContacts(false);
 				linkPhone();
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				finish();
+				System.exit(0);
+				break;
 		}
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (prevButton != null && prevButton.getVisibility() == View.VISIBLE) {
 			prevPage();
 		}

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

@@ -154,8 +154,9 @@ public class WizardFingerPrintActivity extends WizardBackgroundActivity implemen
 	}
 
 	@Override
-	public void onBackPressed() {
-		finish();
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
 	}
 
 	@Override

+ 3 - 2
app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java

@@ -210,8 +210,9 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 	}
 
 	@Override
-	public void onBackPressed() {
-		finish();
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
 	}
 
 	@TargetApi(Build.VERSION_CODES.M)

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

@@ -21,10 +21,14 @@
 
 package ch.threema.app.activities.wizard;
 
+import android.Manifest;
+import android.app.Activity;
 import android.app.AlertDialog;
+import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.graphics.drawable.AnimationDrawable;
+import android.os.Build;
 import android.os.Bundle;
 import android.text.SpannableStringBuilder;
 import android.text.Spanned;
@@ -36,10 +40,16 @@ import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.PrivacyPolicyActivity;
 import ch.threema.app.activities.SimpleWebViewActivity;
+import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -52,6 +62,29 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 
 	private AnimationDrawable frameAnimation;
 
+	private final ActivityResultLauncher<Void> backupResult = registerForActivityResult(new ActivityResultContract<>() {
+		@NonNull
+		@Override
+		public Intent createIntent(@NonNull Context context, Void v) {
+			return new Intent(WizardIntroActivity.this, WizardBackupRestoreActivity.class);
+		}
+
+		@Override
+		public Boolean parseResult(int resultCode, @Nullable Intent intent) {
+			return resultCode == Activity.RESULT_OK;
+		}
+	}, (ActivityResultCallback<Boolean>) result -> {
+		if (Boolean.TRUE.equals(result) &&
+			(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+				|| ConfigUtils.isPermissionGranted(WizardIntroActivity.this, Manifest.permission.POST_NOTIFICATIONS))
+		) {
+			// When the backup is being restored and notifications can be shown, then exit the intro
+			// activity. Otherwise the activity is resumed and if a backup is being restored, the
+			// BackupRestoreProgressActivity is shown.
+			finish();
+		}
+	});
+
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
@@ -120,6 +153,15 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 		isContactSyncSettingConflict();
 	}
 
+	@Override
+	protected void onResume() {
+		super.onResume();
+
+		if (RestoreService.isRunning()) {
+			finish();
+		}
+	}
+
 	public void setupThreema(View view) {
 		if (isContactSyncSettingConflict()) {
 			return;
@@ -142,7 +184,7 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 			return;
 		}
 
-		startActivity(new Intent(this, WizardBackupRestoreActivity.class));
+		backupResult.launch(null);
 		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 	}
 
@@ -157,6 +199,12 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 		}
 	}
 
+	@Override
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
+	}
+
 	/**
 	 * Checks whether th_contact_sync conflicts with user restriction DISALLOW_MODIFY_ACCOUNTS.
 	 * If it conflicts, it shows an information dialog.

+ 58 - 18
app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java

@@ -24,6 +24,7 @@ package ch.threema.app.activities.wizard;
 import android.annotation.SuppressLint;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.os.Build;
 import android.text.InputFilter;
 import android.text.InputType;
 import android.view.View;
@@ -36,11 +37,14 @@ import org.slf4j.Logger;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
+import ch.threema.app.dialogs.WizardDialog;
 import ch.threema.app.dialogs.WizardSafeSearchPhoneDialog;
 import ch.threema.app.threemasafe.ThreemaSafeAdvancedDialog;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
@@ -59,6 +63,7 @@ import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class WizardSafeRestoreActivity extends WizardBackgroundActivity implements PasswordEntryDialog.PasswordEntryDialogClickListener,
 	WizardSafeSearchPhoneDialog.WizardSafeSearchPhoneDialogCallback,
+	WizardDialog.WizardDialogCallback,
 	ThreemaSafeAdvancedDialog.WizardDialogCallback {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardSafeRestoreActivity");
 
@@ -67,6 +72,7 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	private static final String DIALOG_TAG_FORGOT_ID = "li";
 	private static final String DIALOG_TAG_ADVANCED = "adv";
 	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
+	private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "safe_pw_preset";
 
 	private ThreemaSafeService threemaSafeService;
 
@@ -74,6 +80,13 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	ThreemaSafeMDMConfig safeMDMConfig;
 	ThreemaSafeServerInfo serverInfo = new ThreemaSafeServerInfo();
 
+	private final ActivityResultLauncher<String> notificationPermissionLauncher =
+		registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+			// Restore backup even if permission is not granted as we do not strictly require the
+			// notification permission.
+			doSafeRestore();
+		});
+
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
@@ -122,15 +135,19 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 			}
 		});
 
-		findViewById(R.id.safe_restore_button).setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				if (identityEditText != null && identityEditText.getText() != null && identityEditText.getText().toString().length() == ProtocolDefines.IDENTITY_LEN) {
-					doSafeRestore();
-				} else {
-					SimpleStringAlertDialog.newInstance(R.string.safe_restore, R.string.invalid_threema_id).show(getSupportFragmentManager(), "");
+		findViewById(R.id.safe_restore_button).setOnClickListener(v -> {
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+				// Ask for notification permission
+				if (!ConfigUtils.requestNotificationPermission(WizardSafeRestoreActivity.this, notificationPermissionLauncher)) {
+					return;
 				}
 			}
+
+			if (identityEditText != null && identityEditText.getText() != null && identityEditText.getText().toString().length() == ProtocolDefines.IDENTITY_LEN) {
+				doSafeRestore();
+			} else {
+				SimpleStringAlertDialog.newInstance(R.string.safe_restore, R.string.invalid_threema_id).show(getSupportFragmentManager(), "");
+			}
 		});
 
 		Button advancedOptions = findViewById(R.id.advanced_options);
@@ -265,14 +282,9 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 							() -> {
 								// On fail
 								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
-								RuntimeUtil.runOnUiThread(() -> LongToast.makeText(WizardSafeRestoreActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
-								logger.info("Unable to post work request for fetch2");
-								try {
-									userService.removeIdentity();
-								} catch (Exception e) {
-									logger.error("Unable to remove identity", e);
-								}
-								finishAndRemoveTask();
+								RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardSafeRestoreActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
+								logger.info("Unable to post work request for fetch2 or preset password was denied");
+								removeIdentity();
 							});
 					} else {
 						onSuccessfulRestore();
@@ -287,7 +299,25 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 		}.execute();
 	}
 
+	private void removeIdentity() {
+		try {
+			userService.removeIdentity();
+		} catch (Exception e) {
+			logger.error("Unable to remove identity", e);
+		}
+		finishAndRemoveTask();
+	}
+
 	private void onSuccessfulRestore() {
+		if (safeMDMConfig.isBackupPasswordPreset()) {
+			WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_managed_password_confirm, R.string.accept, R.string.real_not_now, WizardDialog.Highlight.NONE);
+			wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_PRESET_CONFIRM);
+		} else {
+			scheduleAppRestart();
+		}
+	}
+
+	private void scheduleAppRestart() {
 		SimpleStringAlertDialog.newInstance(R.string.restore_success_body, R.string.android_backup_restart_threema,
 			true).show(getSupportFragmentManager(), "d");
 		try {
@@ -299,8 +329,9 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	}
 
 	@Override
-	public void onBackPressed() {
-		finish();
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
 	}
 
 	@Override
@@ -325,9 +356,18 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 		this.serverInfo = serverInfo;
 	}
 
+	@Override
+	public void onYes(String tag, Object data) {
+		if (DIALOG_TAG_PASSWORD_PRESET_CONFIRM.equals(tag)) {
+			scheduleAppRestart();
+		}
+	}
+
 	@Override
 	public void onNo(String tag) {
-		if (safeMDMConfig.isRestoreDisabled()) {
+		if (DIALOG_TAG_PASSWORD_PRESET_CONFIRM.equals(tag)) {
+			removeIdentity();
+		} else if (safeMDMConfig.isRestoreDisabled()) {
 			finish();
 		}
 	}

+ 5 - 0
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -65,6 +65,7 @@ import ch.threema.app.adapters.decorators.FileChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.FirstUnreadChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ForwardSecurityStatusChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.GroupCallStatusDataChatAdapterDecorator;
+import ch.threema.app.adapters.decorators.GroupStatusAdapterDecorator;
 import ch.threema.app.adapters.decorators.ImageChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.LocationChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.StatusChatAdapterDecorator;
@@ -526,6 +527,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.groupAckThumbsUpImage = itemView.findViewById(R.id.groupack_thumbsup);
 					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
+					holder.starredIcon = itemView.findViewById(R.id.star_icon);
 
 					((ViewGroup) holder.groupAckContainer).getLayoutTransition().enableTransitionType(LayoutTransition.DISAPPEARING|LayoutTransition.APPEARING);
 				}
@@ -609,6 +611,9 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				case FORWARD_SECURITY_STATUS:
 					decorator = new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					break;
+				case GROUP_STATUS:
+					decorator = new GroupStatusAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					break;
 					// Fallback to text chat adapter
 				default:
 					if (messageModel.isStatusMessage()) {

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

@@ -244,7 +244,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			}
 			itemHolder.nameView.setText(groupName);
 
-			if (groupService.isGroupOwner(groupModel)) {
+			if (groupService.isGroupCreator(groupModel)) {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_outline);
 			} else {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_filled);

+ 111 - 23
app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java

@@ -25,6 +25,7 @@ import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSE
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.EXPANDED;
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
 
+import android.annotation.SuppressLint;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.text.Layout;
@@ -35,25 +36,29 @@ import android.widget.ImageView;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.widget.AppCompatImageButton;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.recyclerview.widget.RecyclerView;
 
+import com.google.android.material.button.MaterialButton;
 import com.google.android.material.chip.Chip;
 import com.google.android.material.materialswitch.MaterialSwitch;
 
 import org.slf4j.Logger;
 
 import java.io.IOException;
+import java.util.LinkedList;
 import java.util.List;
 
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.dialogs.ShowOnceDialog;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
 import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.GroupDetailViewModel;
@@ -65,6 +70,7 @@ import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
+import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
@@ -77,13 +83,15 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 	private static final int TYPE_HEADER = 0;
 	private static final int TYPE_ITEM = 1;
 
-	private boolean meIsGroupAdmin = false;
+	private boolean isGroupEditable = false;
 
 	private final Context context;
-	private ContactService contactService;
-	private GroupInviteService groupInviteService;
+	private final ContactService contactService;
+	private final GroupService groupService;
+	private final GroupInviteService groupInviteService;
 	private final GroupModel groupModel;
 	private GroupInviteModel defaultGroupInviteModel;
+	private final @Nullable Runnable onCloneGroupRunnable;
 	private List<ContactModel> contactModels; // Cached copy of group members
 	private OnGroupDetailsClickListener onClickListener;
 	private final GroupDetailViewModel groupDetailViewModel;
@@ -120,6 +128,9 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		private final TextView expandButton;
 		public final TextView groupDescText;
 		public final SectionHeaderView groupDescChangedDate;
+		public final View groupNoticeView;
+		public final TextView groupNoticeTextView;
+		public final MaterialButton groupNoticeCloneButton;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -146,6 +157,41 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 					}
 				}
 			});
+			this.groupNoticeView = itemView.findViewById(R.id.group_notice_view);
+			this.groupNoticeTextView = itemView.findViewById(R.id.group_notice);
+			this.groupNoticeCloneButton = itemView.findViewById(R.id.clone_button);
+
+			boolean isOrphanedGroup = groupService.isOrphanedGroup(groupModel);
+			boolean isCreator = groupService.isGroupCreator(groupModel);
+			boolean isMember = groupService.isGroupMember(groupModel);
+			boolean hasOtherMembers = groupService.getOtherMemberCount(groupModel) > 0;
+
+			if (isOrphanedGroup) {
+				// Show orphaned group notice
+				this.groupNoticeView.setVisibility(View.VISIBLE);
+				this.groupNoticeTextView.setText(R.string.group_orphaned_notice);
+
+				// If we have a clone runnable and other members in the group, we also show the
+				// clone button.
+				if (hasOtherMembers && onCloneGroupRunnable != null) {
+					this.groupNoticeCloneButton.setOnClickListener(v -> onCloneGroupRunnable.run());
+				} else {
+					this.groupNoticeCloneButton.setVisibility(View.GONE);
+				}
+			} else if (!isCreator && !isMember) {
+				// Show empty group notice without the clone button
+				this.groupNoticeView.setVisibility(View.VISIBLE);
+				this.groupNoticeTextView.setText(R.string.group_not_a_member_notice);
+				this.groupNoticeCloneButton.setVisibility(View.GONE);
+			} else if (isCreator && !isMember) {
+				// Show notice that this group has been dissolved
+				this.groupNoticeView.setVisibility(View.VISIBLE);
+				this.groupNoticeTextView.setText(R.string.group_dissolved_notice);
+				this.groupNoticeCloneButton.setVisibility(View.GONE);
+			} else {
+				// Don't show any notice
+				this.groupNoticeView.setVisibility(View.GONE);
+			}
 
 			this.expandButton = itemView.findViewById(R.id.expand_group_desc_text);
 			this.groupDescChangedDate = itemView.findViewById(R.id.group_desc_changed_date);
@@ -167,22 +213,41 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 	}
 
-	public GroupDetailAdapter(Context context, GroupModel groupModel, GroupDetailViewModel groupDetailViewModel) {
+	/**
+	 * Create the adapter to display the group details.
+	 *
+	 * @param context              the context
+	 * @param groupModel           the group model of the group
+	 * @param groupDetailViewModel the group detail view model
+	 * @param serviceManager       the service manager
+	 * @param onCloneGroupRunnable the runnable that is called when the group is cloned. Note that
+	 *                             this runnable should be set for orphaned groups as it is needed
+	 *                             to display the clone button. For non-orphaned groups this has no
+	 *                             effect and is not needed.
+	 * @throws MasterKeyLockedException      when the master key is locked
+	 * @throws FileSystemNotPresentException when the file system is not present
+	 */
+	public GroupDetailAdapter(
+		Context context,
+		GroupModel groupModel,
+		GroupDetailViewModel groupDetailViewModel,
+		@NonNull ServiceManager serviceManager,
+		@Nullable Runnable onCloneGroupRunnable
+	) throws MasterKeyLockedException, FileSystemNotPresentException {
 		this.context = context;
 		this.groupModel = groupModel;
 		this.groupDetailViewModel = groupDetailViewModel;
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-
-		try {
-			this.contactService = serviceManager.getContactService();
-			this.groupInviteService = serviceManager.getGroupInviteService();
-		} catch (Exception e) {
-			logger.error("Exception, failed to get required services", e);
-		}
+		this.onCloneGroupRunnable = onCloneGroupRunnable;
+		this.contactService = serviceManager.getContactService();
+		this.groupService = serviceManager.getGroupService();
+		this.groupInviteService = serviceManager.getGroupInviteService();
 	}
 
+	@SuppressLint("NotifyDataSetChanged")
 	public void setContactModels(List<ContactModel> newContactModels) {
-		this.contactModels = newContactModels;
+		// Get the contact models that should be displayed. Note that in groups where the user is
+		// the creator but has left the group, the creator should still be shown in the list.
+		this.contactModels = getContactModelsFromMembers(newContactModels);
 		notifyDataSetChanged();
 	}
 
@@ -225,8 +290,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 			ContactModel ownerContactModel = contactService.getByIdentity(groupModel.getCreatorIdentity());
 
-			// check if the ID is the owner of the group
-			meIsGroupAdmin = groupModel.getCreatorIdentity().equals(contactService.getMe().getIdentity());
+			isGroupEditable = groupService.isGroupCreator(groupModel) && groupService.isGroupMember(groupModel);
 
 			if (ConfigUtils.supportGroupDescription()) {
 				initGroupDescriptionSection();
@@ -245,13 +309,13 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 				headerHolder.linkContainerView.setVisibility(View.GONE);
 			}
 
-			boolean addMembersViewVisibility = meIsGroupAdmin;
+			boolean addMembersViewVisibility = isGroupEditable
+				&& contactModels != null && contactModels.size() < BuildConfig.MAX_GROUP_SIZE;
 
-			if (contactModels != null) {
+			if (contactModels != null && !contactModels.isEmpty()) {
 				headerHolder.groupMembersTitleView.setText(ConfigUtils.getSafeQuantityString(context, R.plurals.number_of_group_members, contactModels.size(), contactModels.size()));
-				if (contactModels.size() >= BuildConfig.MAX_GROUP_SIZE) {
-					addMembersViewVisibility = false;
-				}
+			} else {
+				headerHolder.groupMembersTitleView.setVisibility(View.GONE);
 			}
 
 			headerHolder.addMembersView.setVisibility(addMembersViewVisibility ? View.VISIBLE : View.GONE);
@@ -406,6 +470,30 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		}
 	}
 
+	@NonNull
+	private List<ContactModel> getContactModelsFromMembers(@NonNull List<ContactModel> members) {
+		boolean containsCreator = false;
+		for (ContactModel member : members) {
+			if (groupModel.getCreatorIdentity().equals(member.getIdentity())) {
+				containsCreator = true;
+				break;
+			}
+		}
+		ContactModel me = contactService.getMe();
+
+		// Show me in group details if I am the creator and the group is left. Only show me, when
+		// the group is not empty.
+		if (!containsCreator
+				&& me.getIdentity().equals(groupModel.getCreatorIdentity())
+				&& groupService.countMembers(groupModel) > 0
+		) {
+			List<ContactModel> creatorWithMembers = new LinkedList<>(members);
+			creatorWithMembers.add(0, me);
+			return creatorWithMembers;
+		}
+		return members;
+	}
+
 	/**
 	 * Display the group desc timestamp
 	 */
@@ -442,7 +530,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescText.setVisibility(View.VISIBLE);
 		headerHolder.groupDescText.setText(groupDetailViewModel.getGroupDesc());
 		LinkifyUtil.getInstance().linkifyText(headerHolder.groupDescText, true);
-		if (meIsGroupAdmin) {
+		if (isGroupEditable) {
 			headerHolder.changeGroupDescButton.setVisibility(View.VISIBLE);
 		} else {
 			headerHolder.changeGroupDescButton.setVisibility(View.GONE);
@@ -459,7 +547,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
 		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
 		headerHolder.expandButton.setText(R.string.add_group_description);
-		if (meIsGroupAdmin) {
+		if (isGroupEditable) {
 			headerHolder.expandButton.setVisibility(View.VISIBLE);
 		} else {
 			headerHolder.expandButton.setVisibility(View.GONE);

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

@@ -126,7 +126,7 @@ public class GroupListAdapter extends FilterableListAdapter {
 		AdapterUtil.styleGroup(holder.nameView, groupService, groupModel);
 
 		holder.subjectView.setText(this.groupService.getMembersString(groupModel));
- 		holder.roleView.setImageResource(groupService.isGroupOwner(groupModel)
+ 		holder.roleView.setImageResource(groupService.isGroupCreator(groupModel)
 		    ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline)
 		    : R.drawable.ic_group_filled);
 

+ 1 - 1
app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.kt

@@ -102,7 +102,7 @@ class MediaGalleryAdapter(
         var messageId = 0
 
         init {
-            imageView = itemView.findViewById(R.id.image_view)
+            imageView = itemView.findViewById(R.id.thumbnail_view)
             gifContainerView = itemView.findViewById(R.id.gif_marker_container)
             videoContainerView = itemView.findViewById(R.id.video_marker_container)
             videoDuration = itemView.findViewById(R.id.video_duration_text)

+ 1 - 2
app/src/main/java/ch/threema/app/adapters/MediaGalleryRepository.kt

@@ -26,7 +26,6 @@ import android.os.AsyncTask
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.activities.MediaGalleryActivity
 import ch.threema.app.activities.MediaGalleryActivity.*
 import ch.threema.app.messagereceiver.MessageReceiver
 import ch.threema.app.services.MessageService
@@ -34,7 +33,6 @@ import ch.threema.app.services.MessageService.MessageFilter
 import ch.threema.base.ThreemaException
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.MessageType
-import ch.threema.storage.models.data.MessageContentsType
 
 class MediaGalleryRepository {
     private var abstractMessageModels: MutableLiveData<List<AbstractMessageModel?>?>? = null
@@ -86,6 +84,7 @@ class MediaGalleryRepository {
             override fun onlyDownloaded(): Boolean { return false }
             override fun types(): Array<MessageType>? { return null }
             override fun contentTypes(): IntArray { return getContentTypes() }
+            override fun displayTags(): IntArray? { return null }
         }
     }
 

+ 3 - 2
app/src/main/java/ch/threema/app/adapters/MessageListAdapterItem.kt

@@ -47,7 +47,7 @@ class MessageListAdapterItem(
     private val mutedChatsListService: DeadlineListService,
     private val mentionOnlyChatsListService: DeadlineListService,
     private val ringtoneService: RingtoneService,
-    hiddenChatsListService: DeadlineListService
+    private val hiddenChatsListService: DeadlineListService
 ) {
 
     val group: GroupModel? = conversationModel.group
@@ -61,7 +61,8 @@ class MessageListAdapterItem(
     private val uniqueId = conversationModel.receiver?.uniqueIdString ?: ""
     val uid: String = conversationModel.uid
 
-    val isHidden = hiddenChatsListService.has(uniqueId)
+    val isHidden: Boolean
+        get() = hiddenChatsListService.has(uniqueId)
     val isPinTagged = conversationModel.isPinTagged
     val isTyping = conversationModel.isTyping
 

+ 8 - 6
app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt

@@ -242,6 +242,8 @@ class MessageListViewHolder(
 
         val draft = messageListAdapterItem.getDraft()
 
+        val isHidden = messageListAdapterItem.isHidden
+
         // Initialize subject
         subjectView.visibility = VISIBLE
         subjectView.text = params.emojiMarkupUtil.formatBodyTextString(
@@ -250,7 +252,7 @@ class MessageListViewHolder(
             100
         )
 
-        groupMemberName.text = if (messageListAdapterItem.isHidden) "" else messageListAdapterItem.latestMessageGroupMemberName
+        groupMemberName.text = if (isHidden) "" else messageListAdapterItem.latestMessageGroupMemberName
 
         if (draft != null) {
             initializeDraft()
@@ -264,9 +266,9 @@ class MessageListViewHolder(
 
         initializeMuteAppearance(messageListAdapterItem)
 
-        initializeHiddenAppearance(messageListAdapterItem.isHidden)
+        initializeHiddenAppearance(isHidden)
 
-        initializeDeliveryView(messageListAdapterItem, messageListAdapterItem.isHidden, draft != null)
+        initializeDeliveryView(messageListAdapterItem, isHidden, draft != null)
 
         initializeGroupCallIndicator(messageListAdapterItem)
 
@@ -281,7 +283,7 @@ class MessageListViewHolder(
             requestManager
         )
 
-        updateTypingIndicator(messageListAdapterItem)
+        updateTypingIndicator(messageListAdapterItem.isTyping, isHidden)
 
         if (params.isTablet) {
             // handle selection in multi-pane mode
@@ -469,8 +471,8 @@ class MessageListViewHolder(
         }
     }
 
-    private fun updateTypingIndicator(messageListAdapterItem: MessageListAdapterItem) {
-        val isTypingIndicatorHidden = !messageListAdapterItem.isTyping || messageListAdapterItem.isHidden
+    private fun updateTypingIndicator(isTyping: Boolean, isHidden: Boolean) {
+        val isTypingIndicatorHidden = !isTyping || isHidden
         latestMessageContainer.visibility = if (isTypingIndicatorHidden) VISIBLE else GONE
         typingContainer.visibility = if (isTypingIndicatorHidden) GONE else VISIBLE
     }

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

@@ -133,7 +133,7 @@ public class RecentListAdapter extends FilterableListAdapter {
 		if(conversationModel.isGroupConversation()) {
 			fromtext = NameUtil.getDisplayName(groupModel, this.groupService);
 			subjecttext = groupService.getMembersString(groupModel);
-			holder.groupView.setImageResource(groupService.isGroupOwner(groupModel) ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline) : R.drawable.ic_group_filled);
+			holder.groupView.setImageResource(groupService.isGroupCreator(groupModel) ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline) : R.drawable.ic_group_filled);
 		}
 		else if(conversationModel.isDistributionListConversation()) {
 			fromtext = NameUtil.getDisplayName(distributionListModel, this.distributionListService);

+ 1 - 1
app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt

@@ -61,7 +61,7 @@ class SendMediaPreviewAdapter(
     annotation class ViewType
 
     open class SendMediaHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-        val imageView: ImageView = itemView.findViewById(R.id.image_view)
+        val imageView: ImageView = itemView.findViewById(R.id.thumbnail_view)
     }
 
     class SendMediaItemHolder(itemView: View): SendMediaHolder(itemView) {

+ 4 - 1
app/src/main/java/ch/threema/app/adapters/decorators/AdapterDecorator.java

@@ -28,17 +28,20 @@ import android.widget.ListView;
 
 import androidx.annotation.AnyThread;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
 import ch.threema.app.utils.RuntimeUtil;
 
 abstract class AdapterDecorator {
+	@NonNull
 	private final Context context;
 	private transient ListView inListView = null;
 
-	protected AdapterDecorator(Context context) {
+	protected AdapterDecorator(@NonNull Context context) {
 		this.context = context;
 	}
 
+	@NonNull
 	protected Context getContext() {
 		return this.context;
 	}

+ 14 - 6
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -22,7 +22,6 @@
 package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
-import android.content.res.ColorStateList;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.text.Spannable;
@@ -30,8 +29,6 @@ import android.text.TextUtils;
 import android.view.MotionEvent;
 import android.view.View;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.content.res.AppCompatResources;
@@ -72,6 +69,7 @@ import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
+import ch.threema.storage.models.data.DisplayTag;
 
 abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ChatAdapterDecorator");
@@ -259,9 +257,11 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		}
 	}
 
-	public ChatAdapterDecorator(Context context,
-	                            AbstractMessageModel messageModel,
-	                            Helper helper) {
+	public ChatAdapterDecorator(
+		@NonNull Context context,
+		AbstractMessageModel messageModel,
+		Helper helper
+	) {
 		super(context);
 		this.messageModel = messageModel;
 		this.helper = helper;
@@ -392,6 +392,10 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 				holder.datePrefixIcon.setVisibility(durationS > 0L ? View.VISIBLE : View.GONE);
 			}
 
+			if (holder.starredIcon != null) {
+				holder.starredIcon.setVisibility((messageModel.getDisplayTags() & DisplayTag.DISPLAY_TAG_STARRED) == DisplayTag.DISPLAY_TAG_STARRED ? View.VISIBLE : View.GONE);
+			}
+
 			stateBitmapUtil.setStateDrawable(getContext(), messageModel, holder.deliveredIndicator, true);
 			stateBitmapUtil.setGroupAckCount(messageModel, holder);
 		}
@@ -462,6 +466,10 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		return helper.getUserService();
 	}
 
+	protected ContactService getContactService() {
+		return helper.getContactService();
+	}
+
 	protected void setOnClickListener(final View.OnClickListener onViewClickListener, View view) {
 		if (view != null) {
 			view.setOnClickListener(v -> {

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

@@ -29,7 +29,7 @@ import ch.threema.app.utils.TestUtil
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel.ForwardSecurityStatusType
 
-class ForwardSecurityStatusChatAdapterDecorator(context: Context?, messageModel: AbstractMessageModel?, helper: Helper?) : ChatAdapterDecorator(context, messageModel, helper) {
+class ForwardSecurityStatusChatAdapterDecorator(context: Context, messageModel: AbstractMessageModel?, helper: Helper?) : ChatAdapterDecorator(context, messageModel, helper) {
     override fun configureChatMessage(holder: ComposeMessageHolder, position: Int) {
         val statusDataModel = messageModel.forwardSecurityStatusData ?: return
         var body: String? = null

+ 110 - 0
app/src/main/java/ch/threema/app/adapters/decorators/GroupStatusAdapterDecorator.kt

@@ -0,0 +1,110 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.adapters.decorators
+
+import android.content.Context
+import ch.threema.app.R
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder
+import ch.threema.app.utils.TestUtil
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.data.status.GroupStatusDataModel
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.CREATED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.FIRST_VOTE
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.GROUP_DESCRIPTION_CHANGED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.IS_NOTES_GROUP
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.IS_PEOPLE_GROUP
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.MEMBER_ADDED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.MEMBER_KICKED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.MEMBER_LEFT
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.MODIFIED_VOTE
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.ORPHANED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.PROFILE_PICTURE_UPDATED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.RECEIVED_VOTE
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.RENAMED
+import ch.threema.storage.models.data.status.GroupStatusDataModel.GroupStatusType.VOTES_COMPLETE
+
+class GroupStatusAdapterDecorator(
+    context: Context,
+    messageModel: AbstractMessageModel,
+    helper: Helper?
+) : ChatAdapterDecorator(context, messageModel, helper) {
+
+    override fun configureChatMessage(holder: ComposeMessageHolder, position: Int) {
+        val statusDataModel = messageModel.groupStatusDataModel ?: return
+        val statusText = getStatusText(statusDataModel, userService, contactService, context)
+        if (showHide(holder.bodyTextView, !TestUtil.empty(statusText))) {
+            holder.bodyTextView.text = statusText
+        }
+        setOnClickListener({
+            // no action on onClick
+        }, holder.messageBlockView)
+    }
+
+    companion object {
+        /**
+         * Get the display name of the identity contained in the group status data model.
+         */
+        private fun getDisplayName(
+            statusDataModel: GroupStatusDataModel,
+            userService: UserService?,
+            contactService: ContactService?,
+            context: Context,
+        ): String {
+            val identity = statusDataModel.identity ?: return ""
+            // Get the me representation directly from strings to get it in the current language
+            if (userService?.isMe(identity) == true) return context.getString(R.string.me_myself_and_i)
+            if (contactService == null) return identity
+            val contactModel = contactService.getByIdentity(identity) ?: return identity
+            val contactMessageReceiver = contactService.createReceiver(contactModel) ?: return identity
+            return contactMessageReceiver.displayName ?: identity
+        }
+
+        fun getStatusText(
+            statusDataModel: GroupStatusDataModel,
+            userService: UserService?,
+            contactService: ContactService?,
+            context: Context
+        ): String {
+            val displayName = getDisplayName(statusDataModel, userService, contactService, context)
+            val ballotName = statusDataModel.ballotName ?: ""
+            val newGroupName = statusDataModel.newGroupName ?: ""
+            return when (statusDataModel.statusType) {
+                CREATED -> context.getString(R.string.status_create_group)
+                RENAMED -> context.getString(R.string.status_rename_group, newGroupName)
+                PROFILE_PICTURE_UPDATED -> context.getString(R.string.status_group_new_photo)
+                MEMBER_ADDED -> context.getString(R.string.status_group_new_member, displayName)
+                MEMBER_LEFT -> context.getString(R.string.status_group_member_left, displayName)
+                MEMBER_KICKED -> context.getString(R.string.status_group_member_kicked, displayName)
+                IS_NOTES_GROUP -> context.getString(R.string.status_create_notes)
+                IS_PEOPLE_GROUP -> context.getString(R.string.status_create_notes_off)
+                FIRST_VOTE -> context.getString(R.string.status_ballot_user_first_vote, displayName, ballotName)
+                MODIFIED_VOTE -> context.getString(R.string.status_ballot_user_modified_vote, displayName, ballotName)
+                RECEIVED_VOTE -> context.getString(R.string.status_ballot_voting_changed, ballotName)
+                VOTES_COMPLETE -> context.getString(R.string.status_ballot_all_votes, ballotName)
+                GROUP_DESCRIPTION_CHANGED -> "" // TODO(ANDR-2386)
+                ORPHANED -> context.getString(R.string.status_orphaned_group)
+            }
+        }
+    }
+}

+ 13 - 5
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -68,6 +68,7 @@ import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
+import ch.threema.storage.models.GroupModel;
 
 public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, SearchView.OnQueryTextListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ArchiveActivity");
@@ -260,11 +261,16 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 	}
 
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (actionMode != null) {
 			actionMode.finish();
 		} else {
-			super.onBackPressed();
+			finish();
 		}
 	}
 
@@ -291,9 +297,11 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		String confirmText = ConfigUtils.getSafeQuantityString(this, R.plurals.really_delete_thread_message, num, num) + " " + getString(R.string.messages_cannot_be_recovered);
 		String reallyDeleteThreadText = getResources().getString(num > 1 ? R.string.really_delete_multiple_threads : R.string.really_delete_thread);
 
-		if (num == 1 && checkedItems.get(0).isGroupConversation()) {
-			if (groupService.isGroupMember(checkedItems.get(0).getGroup())) {
-				if (groupService.isGroupOwner(checkedItems.get(0).getGroup())) {
+		ConversationModel conversationModel = checkedItems.get(0);
+		if (num == 1 && conversationModel.isGroupConversation()) {
+			GroupModel groupModel = conversationModel.getGroup();
+			if (groupModel != null && groupService.isGroupMember(groupModel)) {
+				if (groupService.isGroupCreator(groupModel)) {
 					confirmText = getString(R.string.delete_my_group_message);
 				} else {
 					confirmText = getString(R.string.delete_group_message);

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/DeleteContactAsyncTask.java

@@ -67,7 +67,7 @@ public class DeleteContactAsyncTask extends AsyncTask<Void, Integer, Integer> {
 
 	@Override
 	protected void onPreExecute() {
-		CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_contact, 0, R.string.cancel, contacts.size());
+		CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_contact, R.string.cancel, contacts.size());
 		dialog.setOnCancelListener((dialog1, which) -> cancelled = true);
 		dialog.show(fragmentManager, DIALOG_TAG_DELETE_CONTACT);
 

+ 2 - 2
app/src/main/java/ch/threema/app/asynctasks/DeleteConversationsAsyncTask.java

@@ -85,7 +85,7 @@ public class DeleteConversationsAsyncTask extends AsyncTask<Void, Integer, Integ
 
 	@Override
 	protected void onPreExecute() {
-		CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_thread, R.string.please_wait, R.string.cancel, conversationModels.size());
+		CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_thread, R.string.cancel, conversationModels.size());
 		dialog.setOnCancelListener(new DialogInterface.OnClickListener() {
 			@Override
 			public void onClick(DialogInterface dialog, int which) {
@@ -112,7 +112,7 @@ public class DeleteConversationsAsyncTask extends AsyncTask<Void, Integer, Integ
 			conversationService.clear(conversationModel);
 
 			if (conversationModel.isGroupConversation()) {
-				groupService.leaveGroup(conversationModel.getGroup());
+				groupService.leaveGroupFromLocal(conversationModel.getGroup());
 				groupService.remove(conversationModel.getGroup());
 			} else if (conversationModel.isDistributionListConversation()) {
 				distributionListService.remove(conversationModel.getDistributionList());

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/DeleteGroupAsyncTask.java

@@ -62,7 +62,7 @@ public class DeleteGroupAsyncTask extends AsyncTask<Void, Void, Void> {
 
 	@Override
 	protected Void doInBackground(Void... params) {
-		groupService.leaveGroup(groupModel);
+		groupService.leaveGroupFromLocal(groupModel);
 		groupService.remove(groupModel);
 		return null;
 	}

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/DeleteMyGroupAsyncTask.java

@@ -63,7 +63,7 @@ public class DeleteMyGroupAsyncTask extends AsyncTask<Void, Void, Void> {
 
 	@Override
 	protected Void doInBackground(Void... params) {
-		groupService.removeAllMembersAndLeave(groupModel);
+		groupService.dissolveGroupFromLocal(groupModel);
 		groupService.remove(groupModel);
 		return null;
 	}

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java

@@ -75,7 +75,7 @@ public class EmptyChatAsyncTask extends AsyncTask<Void, Integer, Integer> {
 	protected void onPreExecute() {
 		if (!quiet) {
 			isCancelled = false;
-			CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.emptying_chat, 0, R.string.cancel, 100);
+			CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.emptying_chat, R.string.cancel, 100);
 			dialog.setOnCancelListener((dialog1, which) -> isCancelled = true);
 			dialog.show(fragmentManager, DIALOG_TAG_EMPTYING_CHAT);
 		}

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/LeaveGroupAsyncTask.java

@@ -63,7 +63,7 @@ public class LeaveGroupAsyncTask extends AsyncTask<Void, Void, Void> {
 
 	@Override
 	protected Void doInBackground(Void... params) {
-		groupService.leaveGroup(groupModel);
+		groupService.leaveGroupFromLocal(groupModel);
 		return null;
 	}
 

+ 82 - 29
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -21,6 +21,10 @@
 
 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.app.Notification;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
@@ -62,6 +66,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Set;
 
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -113,22 +118,25 @@ import ch.threema.storage.models.data.media.AudioDataModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 import ch.threema.storage.models.data.media.VideoDataModel;
 
-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 BackupService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupService");
 
+	public static final String BACKUP_PROGRESS_INTENT = "backup_progress_intent";
+	public static final String BACKUP_PROGRESS = "backup_progress";
+	public static final String BACKUP_PROGRESS_STEPS = "backup_progress_steps";
+	public static final String BACKUP_PROGRESS_MESSAGE = "backup_progress_message";
+	public static final String BACKUP_PROGRESS_ERROR_MESSAGE = "backup_progress_error_message";
+
 	private static final int MEDIA_STEP_FACTOR = 9;
 	private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
 	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
+	private static final int NONCES_PER_STEP = 50;
 
 	private static final String EXTRA_ID_CANCEL = "cnc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
 
 	private static final int BACKUP_NOTIFICATION_ID = 991772;
-	private static final int BACKUP_COMPLETION_NOTIFICATION_ID = 991773;
+	public static final int BACKUP_COMPLETION_NOTIFICATION_ID = 991773;
 	private static final long FILE_SETTLE_DELAY = 5000;
 
 	private static final String INCOMPLETE_BACKUP_FILENAME_PREFIX = "INCOMPLETE-";
@@ -399,6 +407,9 @@ public class BackupService extends Service {
 
 			if (this.config.backupNonces()) {
 				progress += 1;
+				long nonceCount = this.databaseNonceStore.getCount();
+				long nonceProgress = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
+				progress += nonceProgress;
 			}
 
 			logger.debug("Calculated steps " + progress);
@@ -482,14 +493,21 @@ public class BackupService extends Service {
 		int p = (int) (100d / (double) this.processSteps * (double) this.currentProgressStep);
 		if (p > this.latestPercentStep) {
 			this.latestPercentStep = p;
-			updatePersistentNotification(latestPercentStep, 100);
+			String timeRemaining = getRemainingTimeText(latestPercentStep, 100);
+			updatePersistentNotification(latestPercentStep, 100, timeRemaining);
+			LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+				.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+					.putExtra(BACKUP_PROGRESS, latestPercentStep)
+					.putExtra(BACKUP_PROGRESS_STEPS, 100)
+					.putExtra(BACKUP_PROGRESS_MESSAGE, timeRemaining)
+				);
 		}
 	}
 
 	private void removeBackupFile(DocumentFile zipFile) {
 		//remove zip file
 		if (zipFile != null && zipFile.exists()) {
-			logger.debug( "remove " + zipFile.getUri());
+			logger.debug("remove {}", zipFile.getUri());
 			zipFile.delete();
 		}
 	}
@@ -563,7 +581,8 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_READ_AT,
-			Tags.TAG_GROUP_MESSAGE_STATES
+			Tags.TAG_GROUP_MESSAGE_STATES,
+			Tags.TAG_MESSAGE_DISPLAY_TAGS
 		};
 
 		// Iterate over all contacts. Then backup every contact with the corresponding messages.
@@ -654,6 +673,7 @@ public class BackupService extends Service {
 										.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, messageModel.getQuotedMessageId())
 										.write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
 										.write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
+										.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, messageModel.getDisplayTags())
 										.write();
 								}
 
@@ -728,31 +748,32 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_READ_AT,
 			Tags.TAG_GROUP_MESSAGE_STATES,
+			Tags.TAG_MESSAGE_DISPLAY_TAGS
 		};
 
 		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
 			@Override
-			public boolean sortingByDate() {
+			public boolean sortByDate() {
 				return false;
 			}
 
 			@Override
-			public boolean sortingByName() {
+			public boolean sortByName() {
 				return false;
 			}
 
 			@Override
-			public boolean sortingAscending() {
+			public boolean sortAscending() {
 				return false;
 			}
 
 			@Override
-			public boolean withDeleted() {
+			public boolean includeDeletedGroups() {
 				return true;
 			}
 
 			@Override
-			public boolean withDeserted() {
+			public boolean includeLeftGroups() {
 				return true;
 			}
 		};
@@ -827,6 +848,7 @@ public class BackupService extends Service {
 									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
 									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
 									.write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
+									.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, groupMessageModel.getDisplayTags())
 									.write();
 
 								if (MessageUtil.hasDataFile(groupMessageModel)) {
@@ -1082,8 +1104,15 @@ public class BackupService extends Service {
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
 		) {
-			for (String nonce : databaseNonceStore.getAllHashedNonces()) {
-				csvWriter.createRow().write(Tags.TAG_NONCES, nonce).write();
+			List<String> nonces = databaseNonceStore.getAllHashedNonces();
+			for (int i = 0; i < nonces.size(); i += NONCES_PER_STEP) {
+				int chunkEnd = Math.min(i + NONCES_PER_STEP, nonces.size());
+				for (int n = i; n < chunkEnd; n++) {
+					csvWriter.createRow().write(Tags.TAG_NONCES, nonces.get(n)).write();
+				}
+				if (!next("Backup nonce")) {
+					return;
+				}
 			}
 		}
 		ZipUtil.addZipStream(
@@ -1272,7 +1301,7 @@ public class BackupService extends Service {
 				if (is != null) {
 					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid(), false);
 				} else {
-					logger.debug( "Can't add media for message " + messageModel.getUid() + " (" + messageModel.getPostedAt().toString() + "): missing file");
+					logger.debug("Can't add media for message {} ({}): missing file", messageModel.getUid(), messageModel.getPostedAt());
 					// try to save thumbnail if media is missing
 					saveThumbnail = true;
 				}
@@ -1289,7 +1318,7 @@ public class BackupService extends Service {
 			return true;
 		} catch (Exception x) {
 			//do not abort, its only a media :-)
-			logger.debug( "Can't add media for message " + messageModel.getUid() + " (" + messageModel.getPostedAt().toString() + "): " + x.getMessage());
+			logger.debug( "Can't add media for message {} ({}): {}", messageModel.getUid(), messageModel.getPostedAt(), x.getMessage());
 			return false;
 		}
 	}
@@ -1329,6 +1358,11 @@ public class BackupService extends Service {
 		} else {
 			logger.error("Backup failed: {}", message);
 			showBackupErrorNotification(message);
+
+			// Send broadcast so that the BackupRestoreProgressActivity can display the message
+			LocalBroadcastManager.getInstance(this).sendBroadcast(
+				new Intent().putExtra(BACKUP_PROGRESS_ERROR_MESSAGE, message)
+			);
 		}
 
 		//try to reopen connection
@@ -1349,12 +1383,18 @@ public class BackupService extends Service {
 
 		isRunning = false;
 
-		//ConfigUtils.scheduleAppRestart(getApplicationContext(), getApplicationContext().getResources().getString(R.string.ipv6_restart_now));
+		// Send broadcast to indicate that the backup has been completed
+		LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+			.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+				.putExtra(BACKUP_PROGRESS, 100)
+				.putExtra(BACKUP_PROGRESS_STEPS, 100)
+			);
+
 		stopSelf();
 	}
 
 	private void showPersistentNotification() {
-		logger.debug( "showPersistentNotification");
+		logger.debug("showPersistentNotification");
 
 		Intent cancelIntent = new Intent(this, BackupService.class);
 		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
@@ -1378,14 +1418,11 @@ public class BackupService extends Service {
 		startForeground(BACKUP_NOTIFICATION_ID, notification);
 	}
 
-	private void updatePersistentNotification(int currentStep, int steps) {
-		logger.debug( "updatePersistentNoti " + currentStep + " of " + steps);
+	private void updatePersistentNotification(int currentStep, int steps, String timeRemaining) {
+		logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
 
-		if (currentStep != 0) {
-			final long millisPassed = System.currentTimeMillis() - startTime;
-			final long millisRemaining = millisPassed * steps / currentStep - millisPassed + FILE_SETTLE_DELAY;
-			String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
-			notificationBuilder.setContentText(String.format(getString(R.string.time_remaining), timeRemaining));
+		if (timeRemaining != null) {
+			notificationBuilder.setContentText(timeRemaining);
 		}
 
 		notificationBuilder.setProgress(steps, currentStep, false);
@@ -1395,6 +1432,13 @@ public class BackupService extends Service {
 		}
 	}
 
+	private String getRemainingTimeText(int currentStep, int steps) {
+		final long millisPassed = System.currentTimeMillis() - startTime;
+		final long millisRemaining = millisPassed * steps / currentStep - millisPassed + FILE_SETTLE_DELAY;
+		String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
+		return String.format(getString(R.string.time_remaining), timeRemaining);
+	}
+
 	private void cancelPersistentNotification() {
 		if (notificationManager != null) {
 			notificationManager.cancel(BACKUP_NOTIFICATION_ID);
@@ -1436,7 +1480,7 @@ public class BackupService extends Service {
 	}
 
 	private void showBackupSuccessNotification() {
-		logger.debug( "showBackupSuccess");
+		logger.debug("showBackupSuccess");
 
 		String text;
 
@@ -1486,12 +1530,21 @@ public class BackupService extends Service {
 	private void safeStopSelf() {
 		Notification notification = new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS, null)
 			.setContentTitle("")
-			.setContentText("").
-				build();
+			.setContentText("")
+			.build();
 
 		startForeground(BACKUP_NOTIFICATION_ID, notification);
 		stopForeground(true);
 		isRunning = false;
+
+		// Send broadcast after isRunning has been set to false to indicate that there is no backup
+		// in progress anymore
+		LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+			.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+				.putExtra(BACKUP_PROGRESS, 100)
+				.putExtra(BACKUP_PROGRESS_STEPS, 100)
+			);
+
 		stopSelf();
 	}
 

+ 92 - 33
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -64,6 +64,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -123,6 +124,12 @@ import ch.threema.storage.models.data.media.FileDataModel;
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
 
+	public static final String RESTORE_PROGRESS_INTENT = "restore_progress_intent";
+	public static final String RESTORE_PROGRESS = "restore_progress";
+	public static final String RESTORE_PROGRESS_STEPS = "restore_progress_steps";
+	public static final String RESTORE_PROGRESS_MESSAGE = "restore_progress_message";
+	public static final String RESTORE_PROGRESS_ERROR_MESSAGE = "restore_progress_error_message";
+
 	public static final String EXTRA_RESTORE_BACKUP_FILE = "file";
 	public static final String EXTRA_RESTORE_BACKUP_PASSWORD = "pwd";
 	private static final int MAX_THUMBNAIL_SIZE_BYTES = 5 * 1024 * 1024; // do not restore thumbnails that are bigger than 5 MB
@@ -164,6 +171,7 @@ public class RestoreService extends Service {
 	private static final int STEP_SIZE_MESSAGES = 1; // per message
 	private static final int STEP_SIZE_GROUP_AVATARS = 50;
 	private static final int STEP_SIZE_MEDIA = 25; // per media file
+	private static final int NONCES_PER_STEP = 50;
 
 	private long stepSizeTotal = (long) STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GROUP_AVATARS;
 
@@ -404,7 +412,7 @@ public class RestoreService extends Service {
 			//
 			// The connection will be resumed in {@link onFinished}.
 			final ThreemaConnection connection = serviceManager.getConnection();
-			if (connection != null && connection.isRunning()) {
+			if (connection.isRunning()) {
 				connection.stop();
 			}
 
@@ -489,8 +497,9 @@ public class RestoreService extends Service {
 
 				// Restore nonces
 				logger.info("Restoring nonces");
-				if (!restoreNonces(fileHeaders)) {
-					logger.error("Restoring nonces failed");
+				int nonceCount = restoreNonces(fileHeaders);
+				if (nonceCount < 0) {
+					logger.error("Restoring nonces failed ({})", nonceCount);
 					//continue anyway!
 				}
 
@@ -539,7 +548,9 @@ public class RestoreService extends Service {
 				preferenceService.setProfilePicUploadData(null);
 
 				if (!writeToDb) {
-					stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES)  + ((long) mediaCount * STEP_SIZE_MEDIA);
+					stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES);
+					stepSizeTotal += ((long) mediaCount * STEP_SIZE_MEDIA);
+					stepSizeTotal += (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
 				}
 			}
 
@@ -633,7 +644,7 @@ public class RestoreService extends Service {
 		return true;
 	}
 
-	private boolean restoreNonces(List<FileHeader> fileHeaders) throws IOException {
+	private int restoreNonces(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
 		FileHeader nonceFileHeader = null;
 		for (FileHeader fileHeader : fileHeaders) {
 			String fileName = fileHeader.getFileName();
@@ -644,33 +655,44 @@ public class RestoreService extends Service {
 		}
 		if (nonceFileHeader == null) {
 			logger.info("Nonce file header is null");
-			return false;
+			return -1;
 		}
 
-		if (this.writeToDb) {
-			try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
-			     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-			     CSVReader csvReader = new CSVReader(inputStreamReader, false)
-			) {
-				boolean success = true;
-				CSVRow row;
-				while ((row = csvReader.readNextRow()) != null) {
-					try {
-						// Note that currently there is only one nonce per row, and therefore we do
-						// not need to read them as array. However, this gives us the flexibility to
-						// backup several nonces in one row (as we have done in 5.1-alpha3)
-						String[] nonces = row.getStrings(Tags.TAG_NONCES);
+		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
+		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+		     CSVReader csvReader = new CSVReader(inputStreamReader, false)
+		) {
+			int nonceProgressCount = 0;
+			int nonceCount = 0;
+			boolean success = true;
+			CSVRow row;
+			while ((row = csvReader.readNextRow()) != null) {
+				try {
+					// Note that currently there is only one nonce per row, and therefore we do
+					// not need to read them as array. However, this gives us the flexibility to
+					// backup several nonces in one row (as we have done in 5.1-alpha3)
+					String[] nonces = row.getStrings(Tags.TAG_NONCES);
+					nonceCount += nonces.length;
+					if (writeToDb) {
 						success &= databaseNonceStore.insertHashedNonces(nonces);
-					} catch (ThreemaException e) {
-						logger.error("Could not insert nonces");
-						return false;
+						nonceProgressCount += nonces.length;
+						if (nonceProgressCount >= NONCES_PER_STEP) {
+							long increment = nonceProgressCount / NONCES_PER_STEP;
+							updateProgress(increment);
+							nonceProgressCount -= increment * NONCES_PER_STEP;
+						}
 					}
+				} catch (ThreemaException e) {
+					logger.error("Could not insert nonces");
+					return -1;
 				}
-				return success;
+			}
+			if (success) {
+				return nonceCount;
+			} else {
+				return -1;
 			}
 		}
-
-		return true;
 	}
 
 	/**
@@ -985,7 +1007,7 @@ public class RestoreService extends Service {
 					}
 
 					if (!groupModel.isDeleted()) {
-						if (groupService.isGroupOwner(groupModel)) {
+						if (groupService.isGroupCreator(groupModel)) {
 							groupService.sendSync(groupModel);
 						} else {
 							groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId().toString())));
@@ -1554,6 +1576,9 @@ public class RestoreService extends Service {
 		} else if (typeAsString.equals(MessageType.GROUP_CALL_STATUS.name())) {
 			messageType = MessageType.GROUP_CALL_STATUS;
 			messageContentsType = MessageContentsType.GROUP_CALL_STATUS;
+		} else if (typeAsString.equals(MessageType.GROUP_STATUS.name())) {
+			messageType = MessageType.GROUP_STATUS;
+			messageContentsType = MessageContentsType.GROUP_STATUS;
 		}
 		messageModel.setType(messageType);
 		messageModel.setMessageContentsType(messageContentsType);
@@ -1581,6 +1606,15 @@ public class RestoreService extends Service {
 				messageModel.setQuotedMessageId(quotedMessageId);
 			}
 		}
+
+		if(restoreSettings.getVersion() >= 20) {
+			if (!(messageModel instanceof DistributionListMessageModel)) {
+				Integer displayTags = row.getInteger(Tags.TAG_MESSAGE_DISPLAY_TAGS);
+				if (displayTags != null) {
+					messageModel.setDisplayTags(displayTags);
+				}
+			}
+		}
 	}
 	private MessageModel createMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
 		MessageModel messageModel = new MessageModel();
@@ -1707,7 +1741,14 @@ public class RestoreService extends Service {
 		int p = (int) (100d / (double) this.progressSteps * (double) this.currentProgressStep);
 		if (p > this.latestPercentStep) {
 			this.latestPercentStep = p;
-			updatePersistentNotification(latestPercentStep, 100, false);
+			String timeRemaining = getRemainingTimeText(latestPercentStep, 100);
+			updatePersistentNotification(latestPercentStep, 100, false, timeRemaining);
+			LocalBroadcastManager.getInstance(this)
+				.sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
+					.putExtra(RESTORE_PROGRESS, latestPercentStep)
+					.putExtra(RESTORE_PROGRESS_STEPS, 100)
+					.putExtra(RESTORE_PROGRESS_MESSAGE, timeRemaining)
+				);
 		}
 	}
 
@@ -1739,6 +1780,14 @@ public class RestoreService extends Service {
 
 			isRunning = false;
 
+			// Send broadcast after isRunning has been set to false to indicate that there is no
+			// backup being restored anymore
+			LocalBroadcastManager.getInstance(this)
+				.sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
+					.putExtra(RESTORE_PROGRESS, 100)
+					.putExtra(RESTORE_PROGRESS_STEPS, 100)
+				);
+
 			if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
 				ConfigUtils.scheduleAppRestart(getApplicationContext(), 2 * (int) DateUtils.SECOND_IN_MILLIS, getApplicationContext().getResources().getString(R.string.ipv6_restart_now));
 			}
@@ -1746,6 +1795,11 @@ public class RestoreService extends Service {
 		} else {
 			showRestoreErrorNotification(message);
 
+			// Send broadcast so that the BackupRestoreProgressActivity can display the message
+			LocalBroadcastManager.getInstance(this).sendBroadcast(
+				new Intent(RESTORE_PROGRESS_INTENT).putExtra(RESTORE_PROGRESS_ERROR_MESSAGE, message)
+			);
+
 			new DeleteIdentityAsyncTask(null, () -> {
 				isRunning = false;
 
@@ -1777,13 +1831,10 @@ public class RestoreService extends Service {
 		return notificationBuilder.build();
 	}
 
-	private void updatePersistentNotification(int currentStep, int steps, boolean indeterminate) {
-		logger.debug("updatePersistentNoti {} of {}", currentStep, steps);
+	private void updatePersistentNotification(int currentStep, int steps, boolean indeterminate, @Nullable String timeRemaining) {
+		logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
 
-		if (currentStep != 0) {
-			final long millisPassed = System.currentTimeMillis() - startTime;
-			final long millisRemaining = millisPassed * steps / currentStep - millisPassed;
-			String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
+		if (timeRemaining != null) {
 			notificationBuilder.setContentText(String.format(getString(R.string.time_remaining), timeRemaining));
 		}
 
@@ -1791,6 +1842,14 @@ public class RestoreService extends Service {
 		notificationManager.notify(RESTORE_NOTIFICATION_ID, notificationBuilder.build());
 	}
 
+	private String getRemainingTimeText(int currentStep, int steps) {
+		final long millisPassed = System.currentTimeMillis() - startTime;
+		final long millisRemaining = millisPassed * steps / currentStep - millisPassed;
+		String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
+		return String.format(getString(R.string.time_remaining), timeRemaining);
+	}
+
+
 	private void cancelPersistentNotification() {
 		notificationManager.cancel(RESTORE_NOTIFICATION_ID);
 	}

+ 3 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java

@@ -39,8 +39,10 @@ public class RestoreSettings {
 	 * 17: group message states (ack / dec) and group descriptions
 	 * 18: contact forward security flag
 	 * 19: add random contact id
+	 * 20: add message display type (starred etc.)
+	 * 21: refactored group status messages
 	 */
-	public static final int CURRENT_VERSION = 19;
+	public static final int CURRENT_VERSION = 21;
 	private int version;
 
 	public RestoreSettings(int version) {

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

@@ -97,6 +97,7 @@ public abstract class Tags {
 	public static final String TAG_MESSAGE_IS_QUEUED = "isqueued";
 	public static final String TAG_MESSAGE_CAPTION = "caption";
 	public static final String TAG_MESSAGE_QUOTED_MESSAGE_ID = "quoted_message_apiid";
+	public static final String TAG_MESSAGE_DISPLAY_TAGS = "display_tags";
 
 	public static final String TAG_DISTRIBUTION_LIST_ID = "id";
 	public static final String TAG_DISTRIBUTION_LIST_NAME = "distribution_list_name";

Some files were not shown because too many files changed in this diff