Threema 2 лет назад
Родитель
Сommit
a59da1542c
100 измененных файлов с 4369 добавлено и 820 удалено
  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>
 <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>
 <h2>ez-vcard</h2>
 
 
 <p>Copyright (c) 2012-2021, Michael Angstadt</p>
 <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,
     of the authors and should not be interpreted as representing official policies,
     either expressed or implied, of the FreeBSD Project.</p>
     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>
 <h2>Jackson JSON-processor</h2>
 
 
 <p>Copyright (c) 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>
 <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.ArtProfileKt
 import com.android.tools.profgen.ArtProfileSerializer
 import com.android.tools.profgen.ArtProfileSerializer
 import com.android.tools.profgen.DexFile
 import com.android.tools.profgen.DexFile
+import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 
 
 plugins {
 plugins {
     id 'org.sonarqube'
     id 'org.sonarqube'
@@ -17,8 +18,9 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 }
 
 
 // version codes
 // version codes
-def app_version = "5.1.4"
+def app_version = "5.2"
 def beta_suffix = "" // with leading dash
 def beta_suffix = "" // with leading dash
+def defaultVersionCode = 930
 
 
 /**
 /**
  * Return the git hash, if git is installed.
  * Return the git hash, if git is installed.
@@ -86,17 +88,19 @@ android {
     //       make sure to adjust them in `scripts/Dockerfile` and
     //       make sure to adjust them in `scripts/Dockerfile` and
     //       `.gitlab-ci.yml` as well!
     //       `.gitlab-ci.yml` as well!
     compileSdkVersion 33
     compileSdkVersion 33
-    buildToolsVersion '33.0.0'
-    ndkVersion '25.1.8937393'
+    buildToolsVersion '33.0.2'
+    ndkVersion '26.0.10792818'
 
 
     defaultConfig {
     defaultConfig {
+        // https://developer.android.com/training/testing/espresso/setup#analytics
+        testInstrumentationRunnerArguments notAnnotation: 'ch.threema.app.TestFastlaneOnly,ch.threema.app.DangerousTest', disableAnalytics: 'true'
         minSdkVersion 21
         minSdkVersion 21
         //noinspection OldTargetApi
         //noinspection OldTargetApi
-        targetSdkVersion 31
+        targetSdkVersion 33
         vectorDrawables.useSupportLibrary = true
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
         testApplicationId 'ch.threema.app.test'
-        versionCode 922
+        versionCode defaultVersionCode
         versionName "${app_version}${beta_suffix}"
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
         // 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", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-%h.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-%h.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.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", "ONPREM_ID_PREFIX", "\"O\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
         buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
         buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
@@ -157,26 +162,50 @@ android {
         }
         }
 
 
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
         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 {
     splits {
         abi {
         abi {
             enable true
             enable true
             reset()
             reset()
-            include 'armeabi-v7a', 'x86', "arm64-v8a", "x86_64"
+            include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
             exclude 'armeabi', 'mips', 'mips64'
             exclude 'armeabi', 'mips', 'mips64'
             universalApk true
             universalApk true
         }
         }
     }
     }
 
 
     // Assign different version code for each output
     // 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 ->
     android.applicationVariants.all { variant ->
         variant.outputs.each { output ->
         variant.outputs.each { output ->
+            def abi = output.getFilter("ABI")
             output.versionCodeOverride =
             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 }
                     outputs.upToDateWhen { false }
                     exceptionFormat = 'full'
                     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
             // 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
             // Android platform APIs (unless you mock Android dependencies yourself or with a testing
@@ -602,12 +635,12 @@ android {
 
 
     java {
     java {
         toolchain {
         toolchain {
-            languageVersion.set(JavaLanguageVersion.of(11))
+            languageVersion.set(JavaLanguageVersion.of(17))
         }
         }
     }
     }
 
 
     kotlin {
     kotlin {
-        jvmToolchain(11)
+        jvmToolchain(17)
     }
     }
 
 
     androidResources {
     androidResources {
@@ -687,6 +720,7 @@ dependencies {
     }
     }
 
 
     implementation project(':domain')
     implementation project(':domain')
+    implementation project(path: ':task-manager')
 
 
     implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
     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.apache.commons:commons-text:1.10.0'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.28'
     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 '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.googlecode.ez-vcard:ez-vcard:0.11.3'
+    implementation 'com.alexvasilkov:gesture-views:2.8.3'
 
 
     // AndroidX / Jetpack support libraries
     // AndroidX / Jetpack support libraries
     implementation "androidx.preference:preference-ktx:1.2.1"
     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.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation 'androidx.work:work-runtime-ktx:2.8.1'
     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.activity:activity-ktx:1.7.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     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-process:2.6.2"
     implementation "androidx.lifecycle:lifecycle-common-java8:2.6.2"
     implementation "androidx.lifecycle:lifecycle-common-java8:2.6.2"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
     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.sharetarget:sharetarget:1.2.0"
     implementation 'androidx.room:room-runtime:2.5.2'
     implementation 'androidx.room:room-runtime:2.5.2'
     kapt 'androidx.room:room-compiler:2.5.2'
     kapt 'androidx.room:room-compiler:2.5.2'
@@ -802,7 +837,7 @@ dependencies {
     androidTestImplementation 'androidx.test:runner:1.4.0', {
     androidTestImplementation 'androidx.test:runner:1.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         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', {
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.appcompat', module: 'appcompat'
         exclude group: 'androidx.appcompat', module: 'appcompat'
@@ -816,6 +851,8 @@ dependencies {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.annotation', module: 'annotation'
     }
     }
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
     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
     // Google Play Services and related libraries
     def googleDependencies = [
     def googleDependencies = [
@@ -823,7 +860,7 @@ dependencies {
         'com.google.android.gms:play-services-base:18.1.0': [],
         'com.google.android.gms:play-services-base:18.1.0': [],
 
 
         // Firebase push
         // 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-core'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
@@ -880,8 +917,14 @@ dependencies {
         hmsImplementation(dependency) { excludes.each { exclude it } }
         hmsImplementation(dependency) { excludes.each { exclude it } }
         hms_workImplementation(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 {
 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_CFLAGS     += -DANDROID -DHAVE_CONFIG_H -DANDROID_TARGET_ARCH="$(TARGET_ARCH)"
 LOCAL_LDFLAGS    += -lc -llog
 LOCAL_LDFLAGS    += -lc -llog
 
 
+LOCAL_LDFLAGS    += -Wl,--build-id=none  # Reproducible builds
+
 include $(BUILD_SHARED_LIBRARY)
 include $(BUILD_SHARED_LIBRARY)
 
 
 # libnacl
 # 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.c
 LOCAL_SRC_FILES  += $(LOCAL_PATH)/nacl/smult_donna-c64.c
 LOCAL_SRC_FILES  += $(LOCAL_PATH)/nacl/smult_donna-c64.c
 
 
+LOCAL_LDFLAGS    += -Wl,--build-id=none  # Reproducible builds
+
 include $(BUILD_SHARED_LIBRARY)
 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
 -dontobfuscate
 -verbose
 -verbose
 
 
--keepattributes EnclosingMethod,InnerClasses,Exceptions,*Annotation*,SourceFile,LineNumberTable
+-keepattributes EnclosingMethod,InnerClasses,Exceptions,*Annotation*,SourceFile,LineNumberTable,Signature
 
 
 -keeppackagenames ch.threema.**
 -keeppackagenames ch.threema.**
 -keeppackagenames org.saltyrtc.**
 -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 android.util.Log;
 
 
-import org.junit.Rule;
 import org.junit.rules.RuleChain;
 import org.junit.rules.RuleChain;
 import org.junit.rules.TestWatcher;
 import org.junit.rules.TestWatcher;
 import org.junit.runner.Description;
 import org.junit.runner.Description;
@@ -35,9 +34,10 @@ import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStream;
 
 
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.platform.app.InstrumentationRegistry;
-import androidx.test.rule.GrantPermissionRule;
 import androidx.test.uiautomator.UiDevice;
 import androidx.test.uiautomator.UiDevice;
 
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 /**
 /**
  * When a test fails, take a screenshot.
  * When a test fails, take a screenshot.
  *
  *
@@ -50,14 +50,10 @@ public class ScreenshotTakingRule extends TestWatcher {
 
 
 	public static RuleChain getRuleChain() {
 	public static RuleChain getRuleChain() {
 		return RuleChain
 		return RuleChain
-			.outerRule(GrantPermissionRule.grant(
-				"android.permission.READ_EXTERNAL_STORAGE",
-				"android.permission.WRITE_EXTERNAL_STORAGE"
-			))
+			.outerRule(getReadWriteExternalStoragePermissionRule())
 			.around(new ScreenshotTakingRule());
 			.around(new ScreenshotTakingRule());
 	}
 	}
 
 
-	@SuppressWarnings("ResultOfMethodCallIgnored")
 	@Override
 	@Override
 	protected void failed(Throwable e, Description description) {
 	protected void failed(Throwable e, Description description) {
 		final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
 		final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
@@ -76,7 +72,7 @@ public class ScreenshotTakingRule extends TestWatcher {
 
 
 		// Dump UI state
 		// Dump UI state
 		try {
 		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)
 				// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
 				// method leaks a file descriptor.
 				// method leaks a file descriptor.
 				device.dumpWindowHierarchy(stream);
 				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;
 package ch.threema.app.backuprestore.csv;
 
 
-import android.Manifest;
 import android.content.Context;
 import android.content.Context;
 import android.content.Intent;
 import android.content.Intent;
 import android.os.Build;
 import android.os.Build;
@@ -73,6 +72,8 @@ import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import java8.util.stream.StreamSupport;
 import java8.util.stream.StreamSupport;
 
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 @RunWith(AndroidJUnit4.class)
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @LargeTest
 @DangerousTest // Deletes data and possibly identity
 @DangerousTest // Deletes data and possibly identity
@@ -94,7 +95,7 @@ public class BackupServiceTest {
     private @NonNull BallotService ballotService;
     private @NonNull BallotService ballotService;
 
 
 	@Rule
 	@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.
 	 * 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.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ballot.BallotService;
 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.GroupJoinResponseService;
 import ch.threema.app.services.group.IncomingGroupJoinRequestService;
 import ch.threema.app.services.group.IncomingGroupJoinRequestService;
 import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
 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.helpers.InMemoryNonceStore;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.MessageId;
 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.ProtocolDefines;
 import ch.threema.domain.protocol.csp.coders.MessageBox;
 import ch.threema.domain.protocol.csp.coders.MessageBox;
 import ch.threema.domain.protocol.csp.coders.MessageCoder;
 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_1 = new Contact("09BNNVR2", Utils.hexStringToByteArray("e4613bbe5408d342fdabc3edf4509d1a3aecd7cb0598773987eef8400e74c81a"));
 	private final static Contact TEST_CONTACT_2 = new Contact("0BSXZ4P8", Utils.hexStringToByteArray("dee1cd341de88f783a768941eac702951c8bbb21e836da4a43ab8f3776fc0a65"));
 	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 NonceFactory nonceFactory;
-	private GroupCallManager groupCallManager;
 
 
 	// Stores
 	// Stores
 	private IdentityStoreInterface identityStore;
 	private IdentityStoreInterface identityStore;
@@ -101,27 +87,27 @@ public class MessageProcessorTest {
 
 
 	// Message processor
 	// Message processor
 	private MessageProcessor messageProcessor;
 	private MessageProcessor messageProcessor;
-	private ForwardSecurityMessageProcessor forwardSecurityMessageProcessor;
 
 
 	@Before
 	@Before
 	public void setUp() throws Exception {
 	public void setUp() throws Exception {
 		// Load services
 		// Load services
+		// Services
 		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
 		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.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
 		// Create in-memory stores
 		this.contactStore = new InMemoryContactStore();
 		this.contactStore = new InMemoryContactStore();
@@ -147,21 +133,23 @@ public class MessageProcessorTest {
 
 
 		// Create message processor
 		// Create message processor
 		this.messageProcessor = new MessageProcessor(
 		this.messageProcessor = new MessageProcessor(
-			this.messageService,
-			this.contactService,
+			serviceManager,
+			messageService,
+			contactService,
 			this.identityStore,
 			this.identityStore,
 			this.contactStore,
 			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.content.Context;
 import android.util.Log;
 import android.util.Log;
 
 
+import com.neilalexander.jnacl.NaCl;
+
 import java.io.BufferedReader;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.InputStreamReader;
+import java.util.Date;
+import java.util.List;
 
 
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.BySelector;
 import androidx.test.uiautomator.BySelector;
 import androidx.test.uiautomator.UiDevice;
 import androidx.test.uiautomator.UiDevice;
@@ -38,12 +43,123 @@ import androidx.test.uiautomator.Until;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.UserService;
 import ch.threema.base.utils.Utils;
 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;
 import static org.junit.Assert.assertNotNull;
 
 
 public class TestHelpers {
 public class TestHelpers {
 	private static final String TAG = "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.
 	 * Open the notification area and wait for the notifications to become visible.
 	 *
 	 *
@@ -87,12 +203,13 @@ public class TestHelpers {
 		}
 		}
 
 
 		// Otherwise, create identity
 		// 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() {
 	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 androidx.test.uiautomator.Until;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ScreenshotTakingRule;
 import ch.threema.app.ScreenshotTakingRule;
-import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.notifications.BackgroundErrorNotification;
 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.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertNull;
@@ -62,7 +63,9 @@ public class BackgroundErrorNotificationTest {
 	private UiDevice mDevice;
 	private UiDevice mDevice;
 
 
 	@Rule
 	@Rule
-	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain();
+	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain().around(
+		getNotificationPermissionRule()
+	);
 
 
 	@Before
 	@Before
 	public void getDevice() {
 	public void getDevice() {
@@ -76,7 +79,7 @@ public class BackgroundErrorNotificationTest {
 	@SuppressWarnings("unused") // Used for manual debugging
 	@SuppressWarnings("unused") // Used for manual debugging
 	private static void dumpState(@NonNull UiDevice device) throws IOException {
 	private static void dumpState(@NonNull UiDevice device) throws IOException {
 		device.takeScreenshot(new File("/sdcard/screenshot.png"));
 		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)
 			// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
 			// method leaks a file descriptor.
 			// method leaks a file descriptor.
 			device.dumpWindowHierarchy(stream);
 			device.dumpWindowHierarchy(stream);

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

@@ -21,7 +21,6 @@
 
 
 package ch.threema.logging.backend;
 package ch.threema.logging.backend;
 
 
-import android.Manifest;
 import android.util.Log;
 import android.util.Log;
 
 
 import org.junit.Assert;
 import org.junit.Assert;
@@ -39,6 +38,8 @@ import androidx.test.rule.GrantPermissionRule;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.DangerousTest;
 import ch.threema.app.DangerousTest;
 
 
+import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
+
 /**
 /**
  * Debug log file test
  * Debug log file test
  */
  */
@@ -47,7 +48,7 @@ import ch.threema.app.DangerousTest;
 public class DebugLogFileBackendTest {
 public class DebugLogFileBackendTest {
 
 
 	@Rule
 	@Rule
-	public GrantPermissionRule permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
 
 
 	@Before
 	@Before
 	public void disableLogfile() {
 	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>
 <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>
 <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",
 		"CN_back":"connect-drcn.hispace.hicloud.com",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE_back":"connect-dre.hispace.hicloud.com",
 		"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":"connect-dra.dbankcloud.cn",
 		"SG_back":"connect-dra.hispace.hicloud.com"
 		"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":{
 	"client":{
 		"cp_id":"5190041000024384032",
 		"cp_id":"5190041000024384032",
 		"product_id":"736430079244787738",
 		"product_id":"736430079244787738",
@@ -24,16 +34,32 @@
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"package_name":"ch.threema.app.hms"
 		"package_name":"ch.threema.app.hms"
 	},
 	},
+	"app_info":{
+		"app_id":"103713829",
+		"package_name":"ch.threema.app.hms"
+	},
 	"region":"DE",
 	"region":"DE",
-	"configuration_version":"2.0",
+	"configuration_version":"3.0",
 	"appInfos":[
 	"appInfos":[
 		{
 		{
 			"package_name":"ch.threema.app.hms",
 			"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",
 				"package_name":"ch.threema.app.work.hms",
 				"app_id":"103858571"
 				"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 android.content.Context;
 
 
-import com.huawei.agconnect.config.AGConnectServicesConfig;
+import com.huawei.agconnect.AGConnectOptionsBuilder;
 import com.huawei.hms.aaid.HmsInstanceId;
 import com.huawei.hms.aaid.HmsInstanceId;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -65,8 +65,8 @@ public class PushRegistrationWorker extends Worker {
 		String error = null;
 		String error = null;
 		if (clearToken) {
 		if (clearToken) {
 			try {
 			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.
 				// Delete the token.
 				HmsInstanceId.getInstance(appContext).deleteToken(appId, TOKEN_SCOPE);
 				HmsInstanceId.getInstance(appContext).deleteToken(appId, TOKEN_SCOPE);
@@ -79,8 +79,9 @@ public class PushRegistrationWorker extends Worker {
 		}
 		}
         else {
         else {
 			try {
 			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);
 				String token = HmsInstanceId.getInstance(appContext).getToken(appId, TOKEN_SCOPE);
 				logger.info("Received HMS registration token");
 				logger.info("Received HMS registration token");
 				PushUtil.sendTokenToServer(appContext, appId + '|' +token, ProtocolDefines.PUSHTOKEN_TYPE_HMS);
 				PushUtil.sendTokenToServer(appContext, appId + '|' +token, ProtocolDefines.PUSHTOKEN_TYPE_HMS);
@@ -99,4 +100,11 @@ public class PushRegistrationWorker extends Worker {
 		return Result.success();
 		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.content.Context;
 import android.text.format.DateUtils;
 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.aaid.HmsInstanceId;
 import com.huawei.hms.api.ConnectionResult;
 import com.huawei.hms.api.ConnectionResult;
 import com.huawei.hms.api.HuaweiMobileServicesUtil;
 import com.huawei.hms.api.HuaweiMobileServicesUtil;
@@ -62,7 +62,7 @@ public class PushService extends HmsMessageService {
 	}
 	}
 
 
 	public static void deleteToken(Context context) {
 	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 {
 		try {
 			HmsInstanceId.getInstance(ThreemaApplication.getAppContext()).deleteToken(appId, TOKEN_SCOPE);
 			HmsInstanceId.getInstance(ThreemaApplication.getAppContext()).deleteToken(appId, TOKEN_SCOPE);
 			PushUtil.sendTokenToServer(context,"", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
 			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",
 		"CN_back":"connect-drcn.hispace.hicloud.com",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE":"connect-dre.dbankcloud.cn",
 		"DE_back":"connect-dre.hispace.hicloud.com",
 		"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":"connect-dra.dbankcloud.cn",
 		"SG_back":"connect-dra.hispace.hicloud.com"
 		"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":{
 	"client":{
 		"cp_id":"5190041000024384032",
 		"cp_id":"5190041000024384032",
 		"product_id":"736430079244787738",
 		"product_id":"736430079244787738",
@@ -24,16 +34,32 @@
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
 		"package_name":"ch.threema.app.work.hms"
 		"package_name":"ch.threema.app.work.hms"
 	},
 	},
+	"app_info":{
+		"app_id":"103858571",
+		"package_name":"ch.threema.app.work.hms"
+	},
 	"region":"DE",
 	"region":"DE",
-	"configuration_version":"2.0",
+	"configuration_version":"3.0",
 	"appInfos":[
 	"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",
 				"package_name":"ch.threema.app.work.hms",
 				"app_id":"103858571"
 				"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"/>
 	<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
 
 
 	<!-- android.permission-group.STORAGE -->
 	<!-- 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 -->
 	<!-- android.permission-group.LOCATION -->
 	<uses-permission android:name="android.permission.ACCESS_FINE_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_BIOMETRIC"/>
 	<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
 	<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
 
 
+	<!-- Permission to show notifications -->
+	<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+
 	<!-- Launcher shortcuts -->
 	<!-- Launcher shortcuts -->
 	<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
 	<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
 
 
@@ -156,7 +163,6 @@
 	<application
 	<application
 		android:name=".ThreemaApplication"
 		android:name=".ThreemaApplication"
 		android:allowBackup="false"
 		android:allowBackup="false"
-		android:extractNativeLibs="true"
 		android:hardwareAccelerated="true"
 		android:hardwareAccelerated="true"
 		android:icon="@mipmap/ic_launcher"
 		android:icon="@mipmap/ic_launcher"
 		android:roundIcon="@mipmap/ic_launcher"
 		android:roundIcon="@mipmap/ic_launcher"
@@ -172,7 +178,9 @@
 		android:appCategory="social"
 		android:appCategory="social"
 		android:hasFragileUserData="true"
 		android:hasFragileUserData="true"
 		tools:replace="android:supportsRtl,android:allowBackup"
 		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
 		<!-- 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. -->
 		override rtl or backup support. Unfortunately the linter warning cannot be silenced. -->
 		<meta-data
 		<meta-data
@@ -795,6 +803,11 @@
 			android:name="ch.threema.app.globalsearch.GlobalSearchActivity"
 			android:name="ch.threema.app.globalsearch.GlobalSearchActivity"
 			android:theme="@style/Theme.Threema.Translucent"
 			android:theme="@style/Theme.Threema.Translucent"
 			android:windowSoftInputMode="adjustResize"/>
 			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
 		<activity
 			android:name=".locationpicker.LocationAutocompleteActivity"
 			android:name=".locationpicker.LocationAutocompleteActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:theme="@style/Theme.Threema.WithToolbar"
@@ -847,13 +860,14 @@
 			android:exported="false"
 			android:exported="false"
 			android:excludeFromRecents="true">
 			android:excludeFromRecents="true">
 		</activity>
 		</activity>
+		<activity
+			android:name=".activities.BackupRestoreProgressActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"
+			android:launchMode="singleTask"
+			android:exported="false">
+		</activity>
 
 
 		<!-- services -->
 		<!-- services -->
-		<service
-			android:name=".AutostartService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:enabled="true"
-			android:exported="false"/>
 		<service
 		<service
 			android:name=".services.AccountAuthenticatorService"
 			android:name=".services.AccountAuthenticatorService"
 			android:exported="true">
 			android:exported="true">
@@ -889,11 +903,6 @@
 			android:name=".services.WidgetService"
 			android:name=".services.WidgetService"
 			android:permission="android.permission.BIND_REMOTEVIEWS"
 			android:permission="android.permission.BIND_REMOTEVIEWS"
 			android:exported="false"/>
 			android:exported="false"/>
-		<service
-			android:name=".services.RestrictBackgroundChangedService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:enabled="true"
-			android:exported="false"/>
 		<service
 		<service
 			android:name=".jobs.ReConnectJobService"
 			android:name=".jobs.ReConnectJobService"
 			android:permission="android.permission.BIND_JOB_SERVICE"/>
 			android:permission="android.permission.BIND_JOB_SERVICE"/>
@@ -950,6 +959,16 @@
 			</intent-filter>
 			</intent-filter>
 		</service>
 		</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 -->
 		<!-- broadcast receivers -->
 		<receiver
 		<receiver
 			android:name=".receivers.AutoStartNotifyReceiver"
 			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.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
 import android.content.pm.PackageManager;
-import android.content.res.Configuration;
 import android.database.ContentObserver;
 import android.database.ContentObserver;
 import android.database.sqlite.SQLiteException;
 import android.database.sqlite.SQLiteException;
 import android.net.ConnectivityManager;
 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.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
 import ch.threema.app.webclient.state.WebClientSessionState;
+import ch.threema.app.workers.AutoDeleteWorker;
 import ch.threema.app.workers.IdentityStatesWorker;
 import ch.threema.app.workers.IdentityStatesWorker;
 import ch.threema.app.workers.ShareTargetUpdateWorker;
 import ch.threema.app.workers.ShareTargetUpdateWorker;
 import ch.threema.app.workers.WorkSyncWorker;
 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.GroupBallotModel;
 import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.LinkBallotModel;
 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.data.status.VoipStatusDataModel;
 import ch.threema.storage.models.group.IncomingGroupJoinRequestModel;
 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_THREEMA_SAFE_UPLOAD = "SafeUpload";
 	public static final String WORKER_PERIODIC_THREEMA_SAFE_UPLOAD = "PeriodicSafeUpload";
 	public static final String WORKER_PERIODIC_THREEMA_SAFE_UPLOAD = "PeriodicSafeUpload";
 	public static final String WORKER_CONNECTIVITY_CHANGE = "ConnectivityChange";
 	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();
 	public static final Lock onAndroidContactChangeLock = new ReentrantLock();
 
 
@@ -328,18 +332,22 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			try {
 			try {
 				GroupService groupService = serviceManager.getGroupService();
 				GroupService groupService = serviceManager.getGroupService();
 				MessageService messageService = serviceManager.getMessageService();
 				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) {
 			} catch (ThreemaException e) {
 				logger.error("Exception", e);
 				logger.error("Exception", e);
@@ -545,11 +553,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							} else {
 							} else {
 								logger.info("*** Device waking up");
 								logger.info("*** Device waking up");
 								if (serviceManager != null) {
 								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;
 									isDeviceIdle = false;
 								} else {
 								} else {
 									logger.info("Service manager unavailable");
 									logger.info("Service manager unavailable");
@@ -608,6 +618,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					getAppContext().registerReceiver(new BroadcastReceiver() {
 					getAppContext().registerReceiver(new BroadcastReceiver() {
 						@Override
 						@Override
 						public void onReceive(Context context, Intent intent) {
 						public void onReceive(Context context, Intent intent) {
+							logger.info("Restrictions have changed. Updating workers");
+
 							AppRestrictionService.getInstance().reload();
 							AppRestrictionService.getInstance().reload();
 							try {
 							try {
 								OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(true, true, null);
 								OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(true, true, null);
@@ -615,6 +627,10 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							} catch (IllegalStateException e) {
 							} catch (IllegalStateException e) {
 								logger.error("Unable to schedule work sync one time work", 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));
 					}, 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");
 		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")
 	@SuppressLint("SwitchIntDef")
 	@Override
 	@Override
 	public void onTrimMemory(int level) {
 	public void onTrimMemory(int level) {
@@ -1101,12 +1108,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				// schedule shortcut update
 				// schedule shortcut update
 				if (preferenceStore.getBoolean(getAppContext().getString(R.string.preferences__direct_share))) {
 				if (preferenceStore.getBoolean(getAppContext().getString(R.string.preferences__direct_share))) {
 					scheduleShareTargetShortcutUpdate();
 					scheduleShareTargetShortcutUpdate();
-
+				}
+				// schedule auto delete
+				if (!AutoDeleteWorker.Companion.scheduleAutoDelete(getAppContext())) {
+					AutoDeleteWorker.Companion.cancelAutoDelete(getAppContext());
 				}
 				}
 			}, "scheduleSync").start();
 			}, "scheduleSync").start();
-
-			// setup locale override
-			ConfigUtils.setLocaleOverride(getAppContext(), serviceManager.getPreferenceService());
 		} catch (MasterKeyLockedException | SQLiteException e) {
 		} catch (MasterKeyLockedException | SQLiteException e) {
 			logger.error("Exception opening database", e);
 			logger.error("Exception opening database", e);
 		} catch (ThreemaException e) {
 		} catch (ThreemaException e) {
@@ -1243,9 +1250,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onCreate(GroupModel newGroupModel) {
 			public void onCreate(GroupModel newGroupModel) {
 				try {
 				try {
 					serviceManager.getConversationService().refresh(newGroupModel);
 					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) {
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 					logger.error("Exception", e);
 				}
 				}
@@ -1255,15 +1266,19 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onRename(GroupModel groupModel) {
 			public void onRename(GroupModel groupModel) {
 				new Thread(() -> {
 				new Thread(() -> {
 					try {
 					try {
-						MessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
+						GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
 						serviceManager.getConversationService().refresh(groupModel);
 						serviceManager.getConversationService().refresh(groupModel);
 						String groupName = groupModel.getName();
 						String groupName = groupModel.getName();
 						if (groupName == null) {
 						if (groupName == null) {
 							groupName = "";
 							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);
 						ShortcutUtil.updatePinnedShortcut(messageReceiver);
 					} catch (ThreemaException e) {
 					} catch (ThreemaException e) {
 						logger.error("Exception", e);
 						logger.error("Exception", e);
@@ -1275,11 +1290,15 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onUpdatePhoto(GroupModel groupModel) {
 			public void onUpdatePhoto(GroupModel groupModel) {
 				new Thread(() -> {
 				new Thread(() -> {
 					try {
 					try {
-						MessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
+						GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
 						serviceManager.getConversationService().refresh(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);
 						ShortcutUtil.updatePinnedShortcut(messageReceiver);
 					} catch (ThreemaException e) {
 					} catch (ThreemaException e) {
 						logger.error("Exception", e);
 						logger.error("Exception", e);
@@ -1303,51 +1322,42 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 
 			@Override
 			@Override
 			public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
 			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 {
 				try {
-					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
+					final GroupMessageReceiver receiver = serviceManager.getGroupService().createReceiver(group);
 					final String myIdentity = serviceManager.getUserService().getIdentity();
 					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) {
 						if ((!myIdentity.equals(group.getCreatorIdentity())) || previousMemberCount > 1) {
 							//send all open ballots to the new group member
 							//send all open ballots to the new group member
 							BallotService ballotService = serviceManager.getBallotService();
 							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
 			@Override
 			public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 			public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
-				String memberName = identity;
-				ContactModel contactModel;
 				try {
 				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 ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
 					ballotService.removeVotes(receiver, identity);
@@ -1403,22 +1424,16 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						logger.error("Exception", e);
 						logger.error("Exception", e);
 					}
 					}
 				}
 				}
-
-				String memberName = identity;
-				ContactModel contactModel;
 				try {
 				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 ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
 					ballotService.removeVotes(receiver, identity);
@@ -1762,8 +1777,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								/*&& BallotUtil.isMine(ballotModel, userService)*/) {
 								/*&& BallotUtil.isMine(ballotModel, userService)*/) {
 							LinkBallotModel b = ballotService.getLinkedBallotModel(ballotModel);
 							LinkBallotModel b = ballotService.getLinkedBallotModel(ballotModel);
 							if(b != null) {
 							if(b != null) {
-								String message = null;
-								MessageReceiver receiver = null;
+								GroupStatusDataModel.GroupStatusType type = null;
+								MessageReceiver<? extends AbstractMessageModel> receiver = null;
 								if (b instanceof GroupBallotModel) {
 								if (b instanceof GroupBallotModel) {
 									GroupModel groupModel = groupService.getById(((GroupBallotModel) b).getGroupId());
 									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) {
 								if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
 									// Only show status message for first vote from a voter on private voting
 									// Only show status message for first vote from a voter on private voting
 									if (isFirstVote) {
 									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
 								//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) {
 	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
 		super.onActivityResult(requestCode, resultCode, intent);
 		super.onActivityResult(requestCode, resultCode, intent);
 
 
-		ConfigUtils.setLocaleOverride(this, serviceManager.getPreferenceService());
-
 		if (resultCode == RESULT_OK) {
 		if (resultCode == RESULT_OK) {
 			String payload = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
 			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;
 package ch.threema.app.activities;
 
 
+import static ch.threema.app.services.PreferenceService.LockingMech_NONE;
+
 import android.content.Intent;
 import android.content.Intent;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.view.MenuItem;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.View;
 import android.widget.TextView;
 import android.widget.TextView;
 
 
-import com.google.android.material.tabs.TabLayout;
-
-import org.slf4j.Logger;
-
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.ActionBar;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 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.R;
-import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.BackupDataFragment;
 import ch.threema.app.fragments.BackupDataFragment;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.DeadlineListService;
-import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.threemasafe.BackupThreemaSafeFragment;
 import ch.threema.app.threemasafe.BackupThreemaSafeFragment;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
 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.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 
-import static ch.threema.app.services.PreferenceService.LockingMech_NONE;
-
 public class BackupAdminActivity extends ThreemaToolbarActivity {
 public class BackupAdminActivity extends ThreemaToolbarActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupAdminActivity");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupAdminActivity");
 
 
@@ -102,7 +101,7 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
 			noticeLayout.setVisibility(View.VISIBLE);
 			noticeLayout.setVisibility(View.VISIBLE);
 			findViewById(R.id.close_button).setOnClickListener(v -> {
 			findViewById(R.id.close_button).setOnClickListener(v -> {
 				preferenceService.setBackupWarningDismissedTime(System.currentTimeMillis());
 				preferenceService.setBackupWarningDismissedTime(System.currentTimeMillis());
-				AnimationUtil.collapse(noticeLayout);
+				AnimationUtil.collapse(noticeLayout, null, true);
 			});
 			});
 		} else {
 		} else {
 			findViewById(R.id.notice_layout).setVisibility(View.GONE);
 			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 android.widget.FrameLayout;
 
 
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.core.view.WindowCompat;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentManager;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -65,8 +66,15 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	public void onCreate(Bundle savedInstanceState) {
 	public void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 		logger.debug("onCreate");
 
 
+		getWindow().setAllowEnterTransitionOverlap(true);
+		getWindow().setAllowReturnTransitionOverlap(true);
 		super.onCreate(savedInstanceState);
 		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();
 		this.currentIntent = getIntent();
 
 
 		//check master key
 		//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) {
 		if (composeMessageFragment == null) {
 			// fragment no longer around
 			// fragment no longer around
 			composeMessageFragment = new ComposeMessageFragment();
 			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;
 		return true;
 	}
 	}
@@ -137,8 +150,12 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
-		logger.debug("onBackPressed");
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (ConfigUtils.isTabletLayout()) {
 		if (ConfigUtils.isTabletLayout()) {
 			if (messageSectionFragment != null) {
 			if (messageSectionFragment != null) {
 				if (messageSectionFragment.onBackPressed()) {
 				if (messageSectionFragment.onBackPressed()) {
@@ -155,7 +172,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 			}
 			}
 			return;
 			return;
 		}
 		}
-		super.onBackPressed();
+		finish();
 	}
 	}
 
 
 	@Override
 	@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) {
 		} else if (id == R.id.action_send_profilepic) {
 			sendProfilePic();
 			sendProfilePic();
 		} else {
 		} else {
-			finishUp();
+			finish();
 		}
 		}
 		return super.onOptionsItemSelected(item);
 		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() {
 	private void finishAndGoHome() {
 		if (isFinishing() || isDestroyed()) {
 		if (isFinishing() || isDestroyed()) {
 			return;
 			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
 	@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();
 		this.finish();
 	}
 	}
 
 

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

@@ -436,8 +436,12 @@ public class EnterSerialActivity extends ThreemaActivity {
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
-		// finish application
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		moveTaskToBack(true);
 		moveTaskToBack(true);
 		finish();
 		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);
 		startActivity(shareIntent);
 	}
 	}
 
 
+	@Override
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected void handleOnBackPressed() {
 		done();
 		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) {
 			protected GroupModel doInBackground(Void... params) {
 				try {
 				try {
 					Bitmap avatar = avatarFile != null ? BitmapFactory.decodeFile(avatarFile.getPath()) : null;
 					Bitmap avatar = avatarFile != null ? BitmapFactory.decodeFile(avatarFile.getPath()) : null;
-					return groupService.createGroup(
+					return groupService.createGroupFromLocal(
 							groupName,
 							groupName,
 							groupIdentities,
 							groupIdentities,
 							avatar
 							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());
 			int groupId = IntentDataUtil.getGroupId(this.getIntent());
 			if(this.groupService != null && groupId > 0) {
 			if(this.groupService != null && groupId > 0) {
 				this.groupModel = this.groupService.getById(groupId);
 				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());
 				String[] excluded = IntentDataUtil.getContactIdentities(this.getIntent());
 				if (excluded != null && excluded.length > 0) {
 				if (excluded != null && excluded.length > 0) {
 					this.excludedIdentities = new ArrayList<>(Arrays.asList(excluded));
 					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.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.BitmapFactory;
 import android.graphics.Color;
 import android.graphics.Color;
+import android.graphics.Paint;
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuff;
 import android.os.AsyncTask;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.Editable;
-import android.text.Html;
 import android.text.TextWatcher;
 import android.text.TextWatcher;
 import android.view.Menu;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.MenuItem;
@@ -44,6 +44,7 @@ import android.widget.Toast;
 
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.core.app.ActivityCompat;
 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.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.emojis.EmojiEditText;
 import ch.threema.app.emojis.EmojiEditText;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.grouplinks.GroupLinkOverviewActivity;
 import ch.threema.app.grouplinks.GroupLinkOverviewActivity;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 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.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
 
 
@@ -123,6 +126,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private final int MODE_READONLY = 2;
 	private final int MODE_READONLY = 2;
 
 
 	private static final String DIALOG_TAG_LEAVE_GROUP = "leaveGroup";
 	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_UPDATE_GROUP = "updateGroup";
 	private static final String DIALOG_TAG_QUIT = "quit";
 	private static final String DIALOG_TAG_QUIT = "quit";
 	private static final String DIALOG_TAG_CHOOSE_ACTION = "chooseAction";
 	private static final String DIALOG_TAG_CHOOSE_ACTION = "chooseAction";
@@ -176,7 +180,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupDetailViewModel.setAvatarFile(avatarFile1);
 			groupDetailViewModel.setAvatarFile(avatarFile1);
 			groupDetailViewModel.setIsAvatarRemoved(false);
 			groupDetailViewModel.setIsAvatarRemoved(false);
 			hasAvatarChanges = true;
 			hasAvatarChanges = true;
-			updateFloatingActionButton();
+			updateFloatingActionButtonAndMenu();
 		}
 		}
 
 
 		@Override
 		@Override
@@ -185,7 +189,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupDetailViewModel.setIsAvatarRemoved(true);
 			groupDetailViewModel.setIsAvatarRemoved(true);
 			avatarEditView.setDefaultAvatar(null, groupModel);
 			avatarEditView.setDefaultAvatar(null, groupModel);
 			hasAvatarChanges = true;
 			hasAvatarChanges = true;
-			updateFloatingActionButton();
+			updateFloatingActionButtonAndMenu();
 		}
 		}
 	};
 	};
 
 
@@ -381,7 +385,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		setTitle();
 		setTitle();
 		setHasMemberChanges(false);
 		setHasMemberChanges(false);
 
 
-		if (this.groupService.isGroupOwner(this.groupModel)) {
+		if (groupService.isGroupCreator(groupModel) && groupService.isGroupMember(groupModel)) {
 			operationMode = MODE_EDIT;
 			operationMode = MODE_EDIT;
 			actionBar.setHomeButtonEnabled(false);
 			actionBar.setHomeButtonEnabled(false);
 			actionBar.setDisplayHomeAsUpEnabled(true);
 			actionBar.setDisplayHomeAsUpEnabled(true);
@@ -399,7 +403,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 
 				@Override
 				@Override
 				public void afterTextChanged(Editable s) {
 				public void afterTextChanged(Editable s) {
-					updateFloatingActionButton();
+					updateFloatingActionButtonAndMenu();
 				}
 				}
 			});
 			});
 		} else {
 		} else {
@@ -414,10 +418,25 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupNameEditText.setPadding(0, 0, 0, 0);
 			groupNameEditText.setPadding(0, 0, 0, 0);
 
 
 			floatingActionButton.setVisibility(View.GONE);
 			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));
 		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);
 		Fragment dialogFragment = getSupportFragmentManager().findFragmentByTag(DIALOG_TAG_CHANGE_GROUP_DESC);
 		if (dialogFragment instanceof GroupDescEditDialog) {
 		if (dialogFragment instanceof GroupDescEditDialog) {
@@ -444,7 +463,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		collapsingToolbar.setContentScrimColor(color);
 		collapsingToolbar.setContentScrimColor(color);
 		collapsingToolbar.setStatusBarScrimColor(color);
 		collapsingToolbar.setStatusBarScrimColor(color);
 
 
-		updateFloatingActionButton();
+		updateFloatingActionButtonAndMenu();
 
 
 		if (toolbar.getNavigationIcon() != null) {
 		if (toolbar.getNavigationIcon() != null) {
 			toolbar.getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
 			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);
 		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() {
 		this.groupDetailAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
 			@Override
 			@Override
 			public void onChanged() {
 			public void onChanged() {
 				super.onChanged();
 				super.onChanged();
-				updateFloatingActionButton();
+				updateFloatingActionButtonAndMenu();
 			}
 			}
 		});
 		});
 		this.groupDetailAdapter.setOnClickListener(this);
 		this.groupDetailAdapter.setOnClickListener(this);
@@ -520,7 +551,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 
 	private void setHasMemberChanges(boolean hasChanges) {
 	private void setHasMemberChanges(boolean hasChanges) {
 		this.hasMemberChanges = hasChanges;
 		this.hasMemberChanges = hasChanges;
-		updateFloatingActionButton();
+		updateFloatingActionButtonAndMenu();
 	}
 	}
 
 
 	@Override
 	@Override
@@ -543,6 +574,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	public boolean onPrepareOptionsMenu(Menu menu) {
 	public boolean onPrepareOptionsMenu(Menu menu) {
 		MenuItem groupSyncMenu = menu.findItem(R.id.menu_resync);
 		MenuItem groupSyncMenu = menu.findItem(R.id.menu_resync);
 		MenuItem leaveGroupMenu = menu.findItem(R.id.menu_leave_group);
 		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 deleteGroupMenu = menu.findItem(R.id.menu_delete_group);
 		MenuItem cloneMenu = menu.findItem(R.id.menu_clone_group);
 		MenuItem cloneMenu = menu.findItem(R.id.menu_clone_group);
 		MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
 		MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
@@ -556,21 +588,32 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (groupModel != null) {
 		if (groupModel != null) {
 			GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
 			GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
 			groupCallMenu.setVisible(GroupCallUtilKt.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges() && call == null);
 			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);
 			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)));
 			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);
 		return super.onPrepareOptionsMenu(menu);
@@ -596,13 +639,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (itemId == android.R.id.home) {
 		if (itemId == android.R.id.home) {
 			onBackPressed();
 			onBackPressed();
 			return true;
 			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);
 			Intent groupLinkOverviewIntent = new Intent(this, GroupLinkOverviewActivity.class);
 			groupLinkOverviewIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 			groupLinkOverviewIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 			startActivityForResult(groupLinkOverviewIntent, ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS);
 			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) {
 			if (groupModel != null) {
 				Intent intent = new Intent(this, ComposeMessageActivity.class);
 				Intent intent = new Intent(this, ComposeMessageActivity.class);
 				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
 				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
@@ -611,37 +652,55 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				startActivity(intent);
 				startActivity(intent);
 				finish();
 				finish();
 			}
 			}
-		}
-		else if (itemId == R.id.menu_resync) {
+		} else if (itemId == R.id.menu_resync) {
 			this.syncGroup();
 			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(
 			GenericAlertDialog.newInstance(
 				R.string.action_leave_group,
 				R.string.action_leave_group,
-				Html.fromHtml(getString(leaveMessageRes)),
+				R.string.really_leave_group_message,
 				R.string.ok,
 				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(
 			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.ok,
 				R.string.cancel)
 				R.string.cancel)
 				.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_GROUP);
 				.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_GROUP);
-		}
-		else if (itemId == R.id.menu_clone_group) {
+		} else if (itemId == R.id.menu_clone_group) {
 			GenericAlertDialog.newInstance(
 			GenericAlertDialog.newInstance(
 				R.string.action_clone_group,
 				R.string.action_clone_group,
 				R.string.clone_group_message,
 				R.string.clone_group_message,
 				R.string.yes,
 				R.string.yes,
 				R.string.no)
 				R.string.no)
 				.show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP_CONFIRM);
 				.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))) {
 			if (groupId > 0 && !hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel))) {
 				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 				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();
 		new LeaveGroupAsyncTask(groupModel, groupService, this, null, this::finish).execute();
 	}
 	}
 
 
+	private void dissolveGroupAndQuit() {
+		groupService.dissolveGroupFromLocal(groupModel);
+		finish();
+	}
+
 	private void deleteGroupAndQuit() {
 	private void deleteGroupAndQuit() {
-		if (groupService.isGroupOwner(groupModel)) {
+		if (groupService.isGroupCreator(groupModel)) {
 			new DeleteMyGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
 			new DeleteMyGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
 		} else {
 		} else {
 			new DeleteGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
 			new DeleteGroupAsyncTask(groupModel, groupService, this, null, this::navigateHome).execute();
@@ -680,7 +744,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				try {
 				try {
 					Bitmap avatar = groupService.getAvatar(groupModel, true, false);
 					Bitmap avatar = groupService.getAvatar(groupModel, true, false);
 
 
-					model = groupService.createGroup(
+					model = groupService.createGroupFromLocal(
 							newGroupName,
 							newGroupName,
 							groupService.getGroupIdentities(groupModel),
 							groupService.getGroupIdentities(groupModel),
 							avatar);
 							avatar);
@@ -760,7 +824,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		groupModel.setGroupDesc(groupDetailViewModel.getGroupDesc());
 		groupModel.setGroupDesc(groupDetailViewModel.getGroupDesc());
 		groupModel.setGroupDescTimestamp(groupDetailViewModel.getGroupDescTimestamp());
 		groupModel.setGroupDescTimestamp(groupDetailViewModel.getGroupDescTimestamp());
 
 
-
 		new AsyncTask<Void, Void, GroupModel>() {
 		new AsyncTask<Void, Void, GroupModel>() {
 			@Override
 			@Override
 			protected void onPreExecute() {
 			protected void onPreExecute() {
@@ -812,6 +875,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}.execute();
 		}.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
 	@Override
 	public void onActivityResult(int requestCode, int resultCode, Intent data) {
 	public void onActivityResult(int requestCode, int resultCode, Intent data) {
 		if (resultCode == Activity.RESULT_OK) {
 		if (resultCode == Activity.RESULT_OK) {
@@ -821,7 +896,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				sortGroupMembers();
 				sortGroupMembers();
 				setHasMemberChanges(true);
 				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
 				// make sure we reset the default link switch if the default link was deleted
 				groupDetailAdapter.notifyDataSetChanged();
 				groupDetailAdapter.notifyDataSetChanged();
 			}
 			}
@@ -926,6 +1001,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			case DIALOG_TAG_LEAVE_GROUP:
 			case DIALOG_TAG_LEAVE_GROUP:
 				leaveGroupAndQuit();
 				leaveGroupAndQuit();
 				break;
 				break;
+			case DIALOG_TAG_DISSOLVE_GROUP:
+				dissolveGroupAndQuit();
+				break;
 			case DIALOG_TAG_DELETE_GROUP:
 			case DIALOG_TAG_DELETE_GROUP:
 				deleteGroupAndQuit();
 				deleteGroupAndQuit();
 				break;
 				break;
@@ -933,15 +1011,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				saveGroupSettings();
 				saveGroupSettings();
 				break;
 				break;
 			case DIALOG_TAG_CLONE_GROUP_CONFIRM:
 			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;
 				break;
 			default:
 			default:
 				break;
 				break;
@@ -949,7 +1019,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	public void handleOnBackPressed() {
 		if (this.operationMode == MODE_EDIT && hasChanges()) {
 		if (this.operationMode == MODE_EDIT && hasChanges()) {
 			GenericAlertDialog.newInstance(
 			GenericAlertDialog.newInstance(
 					R.string.leave,
 					R.string.leave,
@@ -988,7 +1063,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		return !editedGroupNameText.equals(currentGroupName);
 		return !editedGroupNameText.equals(currentGroupName);
 	}
 	}
 
 
-	private void updateFloatingActionButton() {
+	private void updateFloatingActionButtonAndMenu() {
 		if (this.groupService == null ||
 		if (this.groupService == null ||
 			this.groupDetailAdapter == null) {
 			this.groupDetailAdapter == null) {
 			logger.error("Required instances not available");
 			logger.error("Required instances not available");
@@ -999,11 +1074,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			return;
 			return;
 		}
 		}
 
 
-		if (this.groupService.isGroupOwner(this.groupModel) && hasChanges()) {
+		if (this.groupService.isGroupCreator(this.groupModel) && hasChanges()) {
 			this.floatingActionButton.show();
 			this.floatingActionButton.show();
 		} else {
 		} else {
 			this.floatingActionButton.hide();
 			this.floatingActionButton.hide();
 		}
 		}
+
+		invalidateOptionsMenu();
 	}
 	}
 
 
 	private void navigateHome() {
 	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.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
 import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
 import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
 
 
+import android.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.app.Activity;
 import android.content.BroadcastReceiver;
 import android.content.BroadcastReceiver;
@@ -41,7 +42,10 @@ import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Handler;
+import android.text.SpannableString;
+import android.text.Spanned;
 import android.text.format.DateUtils;
 import android.text.format.DateUtils;
+import android.text.style.TextAppearanceSpan;
 import android.view.Menu;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.View;
@@ -50,12 +54,15 @@ import android.view.Window;
 import android.widget.ImageView;
 import android.widget.ImageView;
 import android.widget.Toast;
 import android.widget.Toast;
 
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.AnyThread;
 import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.widget.AppCompatImageView;
 import androidx.appcompat.widget.AppCompatImageView;
+import androidx.core.app.ActivityCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.lifecycle.LifecycleOwner;
 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.appbar.MaterialToolbar;
 import com.google.android.material.badge.BadgeDrawable;
 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.bottomnavigation.BottomNavigationView;
 import com.google.android.material.shape.MaterialShapeDrawable;
 import com.google.android.material.shape.MaterialShapeDrawable;
 
 
@@ -70,9 +78,11 @@ import org.slf4j.Logger;
 
 
 import java.io.File;
 import java.io.File;
 import java.lang.ref.WeakReference;
 import java.lang.ref.WeakReference;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.Date;
 import java.util.LinkedList;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
 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.WizardBaseActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.archive.ArchiveActivity;
 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.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SMSVerificationDialog;
 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.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 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_FINISH_UP = "fup";
 	private static final String DIALOG_TAG_THREEMA_CHANNEL_VERIFY = "cvf";
 	private static final String DIALOG_TAG_THREEMA_CHANNEL_VERIFY = "cvf";
 	private static final String DIALOG_TAG_UPDATING = "updating";
 	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_MESSAGES = "0";
 	private static final String FRAGMENT_TAG_CONTACTS = "1";
 	private static final String FRAGMENT_TAG_CONTACTS = "1";
@@ -192,7 +206,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private MaterialToolbar toolbar;
 	private MaterialToolbar toolbar;
 	private View connectionIndicator;
 	private View connectionIndicator;
 	private View noticeSMSLayout;
 	private View noticeSMSLayout;
-	OngoingCallNoticeView ongoingCallNotice;
+	private OngoingCallNoticeView ongoingCallNotice;
+	private static long starredMessagesCount = 0L;
 
 
 	private ServiceManager serviceManager;
 	private ServiceManager serviceManager;
 	private NotificationService notificationService;
 	private NotificationService notificationService;
@@ -210,6 +225,19 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 
 	private final List<AbstractMessageModel> unsentMessages = new LinkedList<>();
 	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 BroadcastReceiver checkLicenseBroadcastReceiver = null;
 	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
 	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
 		@Override
 		@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 final ConnectionStateListener connectionStateListener = (connectionState, address) -> updateConnectionIndicator(connectionState);
 
 
 	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, UnsentMessageAction action) {
 	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, UnsentMessageAction action) {
@@ -364,7 +411,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onVerified() {
 		public void onVerified() {
 			RuntimeUtil.runOnUiThread(() -> {
 			RuntimeUtil.runOnUiThread(() -> {
 				if (noticeSMSLayout != null) {
 				if (noticeSMSLayout != null) {
-					AnimationUtil.collapse(noticeSMSLayout);
+					AnimationUtil.collapse(noticeSMSLayout, null, true);
 				}
 				}
 			});
 			});
 		}
 		}
@@ -373,7 +420,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onVerificationStarted() {
 		public void onVerificationStarted() {
 			RuntimeUtil.runOnUiThread(() -> {
 			RuntimeUtil.runOnUiThread(() -> {
 				if (noticeSMSLayout != null) {
 				if (noticeSMSLayout != null) {
-					AnimationUtil.expand(noticeSMSLayout);
+					AnimationUtil.expand(noticeSMSLayout, null, true);
 				}
 				}
 			});
 			});
 		}
 		}
@@ -412,7 +459,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 
 		@Override
 		@Override
 		public void onModifiedAll() {
 		public void onModifiedAll() {
-
+			updateBottomNavigation();
 		}
 		}
 	};
 	};
 
 
@@ -518,17 +565,22 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	};
 	};
 
 
 	@Override
 	@Override
+	@ExperimentalBadgeUtils
 	protected void onCreate(Bundle savedInstanceState) {
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 		logger.debug("onCreate");
 
 
 		final boolean isAppStart = savedInstanceState == null;
 		final boolean isAppStart = savedInstanceState == null;
 
 
-		AnimationUtil.setupTransitions(this.getApplicationContext(), getWindow());
-
 		ConfigUtils.configureSystemBars(this);
 		ConfigUtils.configureSystemBars(this);
 
 
 		super.onCreate(savedInstanceState);
 		super.onCreate(savedInstanceState);
 
 
+		if (BackupService.isRunning() || RestoreService.isRunning()) {
+			startActivity(new Intent(this, BackupRestoreProgressActivity.class));
+			finish();
+			return;
+		}
+
 		//check master key
 		//check master key
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 
 
@@ -547,38 +599,38 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						if (ConfigUtils.isWorkRestricted()) {
 						if (ConfigUtils.isWorkRestricted()) {
 							// update configuration
 							// update configuration
 							final ThreemaSafeMDMConfig newConfig = ThreemaSafeMDMConfig.getInstance();
 							final ThreemaSafeMDMConfig newConfig = ThreemaSafeMDMConfig.getInstance();
-							ThreemaSafeService threemaSafeService = null;
-							try {
-								threemaSafeService = serviceManager.getThreemaSafeService();
-							} catch (Exception e) {
-								//
-							}
+							ThreemaSafeService threemaSafeService = getThreemaSafeService();
 
 
 							if (threemaSafeService != null) {
 							if (threemaSafeService != null) {
 								if (newConfig.hasChanged(preferenceService)) {
 								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.isBackupForced()) {
 										if (newConfig.isSkipBackupPasswordEntry()) {
 										if (newConfig.isSkipBackupPasswordEntry()) {
 											// enable with given password
 											// 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) {
 										} 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
 											// no password has been given by admin but a master key from a previous backup exists
 											// -> create a new backup with existing password
 											// -> create a new backup with existing password
+											reconfigureSafe(threemaSafeService, newConfig);
 											enableSafe(threemaSafeService, newConfig, threemaSafeService.getThreemaSafeMasterKey());
 											enableSafe(threemaSafeService, newConfig, threemaSafeService.getThreemaSafeMasterKey());
 										} else {
 										} else {
+											reconfigureSafe(threemaSafeService, newConfig);
 											threemaSafeService.launchForcedPasswordDialog(this, true);
 											threemaSafeService.launchForcedPasswordDialog(this, true);
 											finish();
 											finish();
 											return;
 											return;
 										}
 										}
+									} else {
+										reconfigureSafe(threemaSafeService, newConfig);
 									}
 									}
 								} else {
 								} else {
 									if (newConfig.isBackupForced() && !preferenceService.getThreemaSafeEnabled()) {
 									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
 	@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
 						// 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.
 						// is prior to the first version that used this dialog.
 						// Use the version code of the first version where this dialog should be shown.
 						// 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);
 							Intent intent = new Intent(this, WhatsNewActivity.class);
 							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
@@ -666,7 +749,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 	}
 
 
 	@SuppressLint("StaticFieldLeak")
 	@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[]>() {
 		new AsyncTask<Void, Void, byte[]>() {
 			@Override
 			@Override
 			protected byte[] doInBackground(Void... voids) {
 			protected byte[] doInBackground(Void... voids) {
@@ -957,9 +1040,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			return;
 			return;
 		}
 		}
 
 
-		// set custom locale
-		ConfigUtils.setLocaleOverride(this, preferenceService);
-
 		// set up content
 		// set up content
 		setContentView(R.layout.activity_home);
 		setContentView(R.layout.activity_home);
 
 
@@ -1288,7 +1368,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 
 	@SuppressLint("StaticFieldLeak")
 	@SuppressLint("StaticFieldLeak")
 	private void reallyCancelVerify() {
 	private void reallyCancelVerify() {
-		AnimationUtil.collapse(noticeSMSLayout);
+		AnimationUtil.collapse(noticeSMSLayout, null, true);
 		new AsyncTask<Void, Void, Void>() {
 		new AsyncTask<Void, Void, Void>() {
 			@Override
 			@Override
 			protected Void doInBackground(Void... params) {
 			protected Void doInBackground(Void... params) {
@@ -1348,6 +1428,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			intent = new Intent(this, ArchiveActivity.class);
 			intent = new Intent(this, ArchiveActivity.class);
 		} else if (id == R.id.globalsearch) {
 		} else if (id == R.id.globalsearch) {
 			intent = new Intent(this, GlobalSearchActivity.class);
 			intent = new Intent(this, GlobalSearchActivity.class);
+		} else if (id == R.id.starred_messages) {
+			intent = new Intent(this, StarredMessagesActivity.class);
 		}
 		}
 
 
 		if (intent != null) {
 		if (intent != null) {
@@ -1465,6 +1547,21 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				webclientMenuItem.setVisible(!(webDisabled));
 				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 true;
 		}
 		}
 		return false;
 		return false;
@@ -1565,6 +1662,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			case DIALOG_TAG_THREEMA_CHANNEL_VERIFY:
 			case DIALOG_TAG_THREEMA_CHANNEL_VERIFY:
 				addThreemaChannel();
 				addThreemaChannel();
 				break;
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				ThreemaSafeService threemaSafeService = getThreemaSafeService();
+				if (threemaSafeService != null) {
+					reconfigureSafe(threemaSafeService, (ThreemaSafeMDMConfig) data);
+					enableSafe(threemaSafeService, (ThreemaSafeMDMConfig) data, null);
+				}
+				break;
 			default:
 			default:
 				break;
 				break;
 		}
 		}
@@ -1581,6 +1685,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			case DIALOG_TAG_SERIAL_LOCKED:
 			case DIALOG_TAG_SERIAL_LOCKED:
 				finish();
 				finish();
 				break;
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				/* configuration change deferred */
+				break;
 			default:
 			default:
 				break;
 				break;
 		}
 		}
@@ -1619,6 +1726,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		super.onResume();
 		super.onResume();
 
 
 		showMainContent();
 		showMainContent();
+
+		try {
+			new UpdateStarredMessagesTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+		} catch (RejectedExecutionException ignored) {}
 	}
 	}
 
 
 	@Override
 	@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
 	@Override
 	protected void onSaveInstanceState(@NonNull Bundle outState) {
 	protected void onSaveInstanceState(@NonNull Bundle outState) {
 		try {
 		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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (hasChanges()) {
 		if (hasChanges()) {
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
 					R.string.discard_changes_title,
 					R.string.discard_changes_title,
@@ -770,7 +775,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 
 			@Override
 			@Override
 			protected void onPreExecute() {
 			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
 			@Override
@@ -1255,7 +1260,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			protected void onPreExecute() {
 			protected void onPreExecute() {
 				super.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
 			@Override
@@ -1305,6 +1312,19 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		}
 		}
 
 
 		captionEditText = findViewById(R.id.caption_edittext);
 		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 sendButton = findViewById(R.id.send_button);
 		sendButton.setEnabled(true);
 		sendButton.setEnabled(true);
@@ -1382,7 +1402,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 
 		emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 		emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
 		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
-		emojiButton.attach(this.emojiPicker, preferenceService.isFullscreenIme());
+		emojiButton.attach(this.emojiPicker);
 		emojiPicker.setEmojiKeyListener(emojiKeyListener);
 		emojiPicker.setEmojiKeyListener(emojiKeyListener);
 
 
 		ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.image_paint_root).getRootView(), (v, insets) -> {
 		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()) {
 				if (emojiPicker.isShown()) {
 					logger.info("EmojiPicker currently shown. Closing.");
 					logger.info("EmojiPicker currently shown. Closing.");
 					if (ConfigUtils.isLandscape(this) &&
 					if (ConfigUtils.isLandscape(this) &&
-						!ConfigUtils.isTabletLayout() &&
-						preferenceService.isFullscreenIme()) {
+						!ConfigUtils.isTabletLayout()) {
 						emojiPicker.hide();
 						emojiPicker.hide();
 					} else {
 					} else {
 						openSoftKeyboard(emojiPicker, captionEditText);
 						openSoftKeyboard(emojiPicker, captionEditText);
-						if (getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY) {
-							emojiPicker.hide();
-						}
+						runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 					}
 					}
 				} else {
 				} else {
 					emojiPicker.show(loadStoredSoftKeyboardHeight());
 					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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		cancel();
 		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);
 		chipGroup.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGE_DISAPPEARING|LayoutTransition.CHANGE_APPEARING|LayoutTransition.APPEARING|LayoutTransition.DISAPPEARING);
 
 
 		contentTypeNames = getResources().getStringArray(R.array.media_gallery_spinner);
 		contentTypeNames = getResources().getStringArray(R.array.media_gallery_spinner);
-		Arrays.fill(checkedContentTypes, true);
+		preferenceService.getMediaGalleryContentTypes(checkedContentTypes);
 
 
 		gridLayoutManager = new GridLayoutManager(this, ConfigUtils.isLandscape(this) ? 5 : 3);
 		gridLayoutManager = new GridLayoutManager(this, ConfigUtils.isLandscape(this) ? 5 : 3);
 		recyclerView = findViewById(R.id.item_list);
 		recyclerView = findViewById(R.id.item_list);
@@ -327,16 +327,16 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 				.setThumbDrawable(Objects.requireNonNull(thumbDrawable))
 				.setThumbDrawable(Objects.requireNonNull(thumbDrawable))
 				.setTrackDrawable(Objects.requireNonNull(AppCompatResources.getDrawable(this, R.drawable.fastscroll_track_media)))
 				.setTrackDrawable(Objects.requireNonNull(AppCompatResources.getDrawable(this, R.drawable.fastscroll_track_media)))
 				.setPopupStyle(thumbScrollerPopupStyle)
 				.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();
 				.build();
 		}
 		}
 
 
@@ -479,7 +479,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 			@Override
 			@Override
 			protected void onPreExecute() {
 			protected void onPreExecute() {
 				if (selectedMessages.size() > 10) {
 				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.setOnCancelListener((dialog1, which) -> cancelled = true);
 					dialog.show(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA);
 					dialog.show(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA);
 				}
 				}
@@ -668,12 +668,15 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements
 					}
 					}
 					if (chipGroup.getChildCount() == 0) {
 					if (chipGroup.getChildCount() == 0) {
 						finish();
 						finish();
+					} else {
+						updatePrefs();
 					}
 					}
 				});
 				});
 				chipGroup.addView(chip);
 				chipGroup.addView(chip);
 			}
 			}
 		}
 		}
 		updateFilter();
 		updateFilter();
+		updatePrefs();
 	}
 	}
 
 
 	private void updateFilter() {
 	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() {
 	private @Nullable int[] getMediaContentTypeArray() {
 		int[] contentTypeList = new int[checkedContentTypes.length];
 		int[] contentTypeList = new int[checkedContentTypes.length];
 		int n = 0;
 		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() {
 				public int[] contentTypes() {
 					return filter;
 					return filter;
 				}
 				}
+
+				@Override
+				public int[] displayTags() {
+					return null;
+				}
 			});
 			});
 		} catch (Exception x) {
 		} catch (Exception x) {
 			logger.error("Exception", x);
 			logger.error("Exception", x);
@@ -268,7 +273,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			return false;
 			return false;
 		}
 		}
 
 
-
 		if (intent.getBooleanExtra(EXTRA_ID_REVERSE_ORDER, false)) {
 		if (intent.getBooleanExtra(EXTRA_ID_REVERSE_ORDER, false)) {
 			// reverse order
 			// reverse order
 			Collections.reverse(messageModels);
 			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
 	@Override
 	public void onSaveInstanceState(@NonNull Bundle outState) {
 	public void onSaveInstanceState(@NonNull Bundle outState) {
 		// fixes https://code.google.com/p/android/issues/detail?id=19917
 		// 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 {
 	public static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
 
 
 		private final MediaViewerActivity a;
 		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) {
 		public ScreenSlidePagerAdapter(MediaViewerActivity a, FragmentManager fm) {
 			super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
 			super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
 			this.a = a;
 			this.a = a;
-			mFragmentManager = fm;
-			mFragments = new SparseArray<>();
+			fragmentManager = fm;
+			fragments = new SparseArray<>();
 		}
 		}
 
 
 		@NonNull
 		@NonNull
@@ -671,11 +666,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		@Override
 		@Override
 		public Object instantiateItem(@NonNull ViewGroup container, int position) {
 		public Object instantiateItem(@NonNull ViewGroup container, int position) {
 			Fragment fragment = getItem(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;
 			return fragment;
 		}
 		}
 
 
@@ -770,11 +765,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
 		public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
 			logger.debug("destroyItem " + position);
 			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 (position >= 0 && position < a.fragments.length) {
 				if (TestUtil.required(a.fragments[position])) {
 				if (TestUtil.required(a.fragments[position])) {
@@ -789,10 +784,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 
 		@Override
 		@Override
 		public void finishUpdate(@NonNull ViewGroup container) {
 		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());
 				noticeText.setText(getNotice());
 				noticeLayout.setVisibility(View.VISIBLE);
 				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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		onDone();
 		onDone();
 	}
 	}
 
 

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

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

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

@@ -106,7 +106,12 @@ public class ProfilePicRecipientsActivity extends MemberChooseActivity {
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		this.menuNext(getSelectedContacts());
 		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 (hasMedia) {
-				if (!ConfigUtils.requestStoragePermissions(this, null, REQUEST_READ_EXTERNAL_STORAGE)) {
+				if (!ConfigUtils.requestReadStoragePermission(this, null, REQUEST_READ_EXTERNAL_STORAGE)) {
 					return;
 					return;
 				}
 				}
 			}
 			}
@@ -998,7 +998,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 
 	@UiThread
 	@UiThread
 	private void forwardMessages(final MessageReceiver[] messageReceivers, final Intent intent, boolean keepOriginalCaptions) {
 	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);
 		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;
 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.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.app.Activity;
@@ -29,7 +46,6 @@ import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Context;
 import android.content.Intent;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.pm.PackageManager;
-import android.content.res.Configuration;
 import android.graphics.Color;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuff;
 import android.media.MediaMetadataRetriever;
 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.domain.protocol.csp.messages.file.FileData;
 import ch.threema.localcrypto.MasterKeyLockedException;
 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
 public class SendMediaActivity extends ThreemaToolbarActivity implements
 	GenericAlertDialog.DialogClickListener,
 	GenericAlertDialog.DialogClickListener,
 	ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
 	ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
@@ -397,7 +394,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 
 			this.emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 			this.emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
 			this.emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
 			this.emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
-			emojiButton.attach(this.emojiPicker, true);
+			emojiButton.attach(this.emojiPicker);
 			this.emojiPicker.setEmojiKeyListener(new EmojiPicker.EmojiKeyListener() {
 			this.emojiPicker.setEmojiKeyListener(new EmojiPicker.EmojiKeyListener() {
 				@Override
 				@Override
 				public void onBackspaceClick() {
 				public void onBackspaceClick() {
@@ -423,6 +420,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 							emojiPicker.hide();
 							emojiPicker.hide();
 						} else {
 						} else {
 							openSoftKeyboard(emojiPicker, captionEditText);
 							openSoftKeyboard(emojiPicker, captionEditText);
+							runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 						}
 						}
 					}
 					}
 				}
 				}
@@ -522,9 +520,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 						emojiPicker.hide();
 						emojiPicker.hide();
 					} else {
 					} else {
 						openSoftKeyboard(emojiPicker, captionEditText);
 						openSoftKeyboard(emojiPicker, captionEditText);
-						if (getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY) {
-							emojiPicker.hide();
-						}
+						runOnSoftKeyboardOpen(() -> emojiPicker.hide());
 					}
 					}
 				} else {
 				} else {
 					emojiPicker.show(loadStoredSoftKeyboardHeight());
 					emojiPicker.show(loadStoredSoftKeyboardHeight());
@@ -1105,7 +1101,12 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (emojiPicker != null && emojiPicker.isShown()) {
 		if (emojiPicker != null && emojiPicker.isShown()) {
 			emojiPicker.hide();
 			emojiPicker.hide();
 		} else if (captionEditText.isMentionPopupShowing()) {
 		} 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.ArrayAdapter;
 import android.widget.Button;
 import android.widget.Button;
 import android.widget.FrameLayout;
 import android.widget.FrameLayout;
+import android.widget.ImageButton;
 import android.widget.TextView;
 import android.widget.TextView;
 import android.widget.Toast;
 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.progressindicator.CircularProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
@@ -45,24 +49,25 @@ import java.util.Date;
 import java.util.Iterator;
 import java.util.Iterator;
 import java.util.List;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.DeleteIdentityAsyncTask;
 import ch.threema.app.asynctasks.DeleteIdentityAsyncTask;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.ui.LongToast;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
+import ch.threema.app.utils.AutoDeleteUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.workers.AutoDeleteWorker;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
 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 DELETE_MESSAGES_PROGRESS_TAG = "delmsgs";
 	private static final String DIALOG_TAG_DELETE_ID = "delid";
 	private static final String DIALOG_TAG_DELETE_ID = "delid";
 	private static final String DIALOG_TAG_REALLY_DELETE = "rlydelete";
 	private static final String DIALOG_TAG_REALLY_DELETE = "rlydelete";
+	private static final String DIALOG_TAG_SET_AUTO_DELETE = "autodelete";
 
 
 	private FileService fileService;
 	private FileService fileService;
 	private MessageService messageService;
 	private MessageService messageService;
 	private ConversationService conversationService;
 	private ConversationService conversationService;
-	private UserService userService;
 	private TextView totalView, usageView, freeView, messageView, inuseView;
 	private TextView totalView, usageView, freeView, messageView, inuseView;
-	private MaterialAutoCompleteTextView timeSpinner, messageTimeSpinner;
-	private Button deleteButton, messageDeleteButton;
 	private CircularProgressIndicator progressBar;
 	private CircularProgressIndicator progressBar;
 	private boolean isCancelled, isMessageDeleteCancelled;
 	private boolean isCancelled, isMessageDeleteCancelled;
-	private int selectedSpinnerItem, selectedMessageSpinnerItem;
+	private int selectedSpinnerItem, selectedMessageSpinnerItem, selectedKeepMessageSpinnerItem;
+	private MaterialAutoCompleteTextView keepMessagesSpinner;
 	private FrameLayout storageFull, storageThreema, storageEmpty;
 	private FrameLayout storageFull, storageThreema, storageEmpty;
 	private CoordinatorLayout coordinatorLayout;
 	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
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 	public void onCreate(Bundle savedInstanceState) {
@@ -102,18 +107,19 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			actionBar.setTitle(R.string.storage_management);
 			actionBar.setTitle(R.string.storage_management);
 		}
 		}
 
 
+		UserService userService;
 		try {
 		try {
 			this.fileService = serviceManager.getFileService();
 			this.fileService = serviceManager.getFileService();
 			this.messageService = serviceManager.getMessageService();
 			this.messageService = serviceManager.getMessageService();
 			this.conversationService = serviceManager.getConversationService();
 			this.conversationService = serviceManager.getConversationService();
-			this.userService = serviceManager.getUserService();
+			userService = serviceManager.getUserService();
 		} catch (Exception e) {
 		} catch (Exception e) {
 			logger.error("Exception", e);
 			logger.error("Exception", e);
 			finish();
 			finish();
 			return;
 			return;
 		}
 		}
 
 
-		if (!this.userService.hasIdentity()) {
+		if (!userService.hasIdentity()) {
 			Toast.makeText(this, "Nothing to delete!", Toast.LENGTH_SHORT).show();
 			Toast.makeText(this, "Nothing to delete!", Toast.LENGTH_SHORT).show();
 			finish();
 			finish();
 			return;
 			return;
@@ -125,14 +131,18 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 		freeView = findViewById(R.id.free_view);
 		freeView = findViewById(R.id.free_view);
 		inuseView = findViewById(R.id.in_use_view);
 		inuseView = findViewById(R.id.in_use_view);
 		messageView = findViewById(R.id.num_messages_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);
 		storageFull = findViewById(R.id.storage_full);
 		storageThreema = findViewById(R.id.storage_threema);
 		storageThreema = findViewById(R.id.storage_threema);
 		storageEmpty = findViewById(R.id.storage_empty);
 		storageEmpty = findViewById(R.id.storage_empty);
 		progressBar = findViewById(R.id.progressbar);
 		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;
 		selectedSpinnerItem = 0;
 		selectedMessageSpinnerItem = 0;
 		selectedMessageSpinnerItem = 0;
 		((TextView) findViewById(R.id.used_by_threema)).setText(getString(R.string.storage_threema, getString(R.string.app_name)));
 		((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;
 			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")
 	@SuppressLint("StaticFieldLeak")
@@ -248,31 +300,15 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 
 
 	@Override
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
 	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);
 		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")
 	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMessages(final int days) {
+	private void deleteMessages(final int days) {
 		final Date today = new Date();
 		final Date today = new Date();
 
 
 		new AsyncTask<Void, Integer, Void>() {
 		new AsyncTask<Void, Integer, Void>() {
@@ -281,7 +317,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			@Override
 			@Override
 			protected void onPreExecute() {
 			protected void onPreExecute() {
 				isMessageDeleteCancelled = false;
 				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
 			@Override
@@ -317,7 +353,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 							postedDate = messageModel.getCreatedAt();
 							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);
 							messageService.remove(messageModel, true);
 							delCount++;
 							delCount++;
 						}
 						}
@@ -345,11 +381,10 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			}
 			}
 		}.execute();
 		}.execute();
 
 
-		return false;
 	}
 	}
 
 
 	@SuppressLint("StaticFieldLeak")
 	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMediaFiles(final int days) {
+	private void deleteMediaFiles(final int days) {
 		final Date today = new Date();
 		final Date today = new Date();
 		final MessageService.MessageFilter messageFilter = new MessageService.MessageFilter() {
 		final MessageService.MessageFilter messageFilter = new MessageService.MessageFilter() {
 			@Override
 			@Override
@@ -379,6 +414,11 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			public int[] contentTypes() {
 			public int[] contentTypes() {
 				return null;
 				return null;
 			}
 			}
+
+			@Override
+			public int[] displayTags() {
+				return null;
+			}
 		};
 		};
 
 
 		new AsyncTask<Void, Integer, Void>() {
 		new AsyncTask<Void, Integer, Void>() {
@@ -387,7 +427,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			@Override
 			@Override
 			protected void onPreExecute() {
 			protected void onPreExecute() {
 				isCancelled = false;
 				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
 			@Override
@@ -421,7 +461,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 							postedDate = messageModel.getCreatedAt();
 							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)) {
 							if (fileService.removeMessageFiles(messageModel, false)) {
 								delCount++;
 								delCount++;
 							}
 							}
@@ -450,7 +490,6 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			}
 			}
 		}.execute();
 		}.execute();
 
 
-		return false;
 	}
 	}
 
 
 	@Override
 	@Override
@@ -474,12 +513,24 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 					System.exit(0);
 					System.exit(0);
 				}
 				}
 			}).execute();
 			}).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
 	@Override
 	public void onNo(String tag, Object data) {
 	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
 	@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
 	@Override
 	public void onYes(String tag, Object data) {
 	public void onYes(String tag, Object data) {
 		if (LinkifyUtil.DIALOG_TAG_CONFIRM_LINK.equals(tag)) {
 		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;
 package ch.threema.app.activities;
 
 
+import android.content.Intent;
 import android.widget.Toast;
 import android.widget.Toast;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -87,7 +88,8 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 		}
 		}
 
 
 		if (BackupService.isRunning() || RestoreService.isRunning()) {
 		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();
 			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_NO;
 import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES;
 import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES;
 
 
+import android.content.Intent;
 import android.content.res.Configuration;
 import android.content.res.Configuration;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.widget.Toast;
 import android.widget.Toast;
 
 
+import androidx.activity.OnBackPressedCallback;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.app.AppCompatActivity;
@@ -56,12 +58,23 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 
 
 		savedDayNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
 		savedDayNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
 		ConfigUtils.setCurrentDayNightMode(savedDayNightMode == UI_MODE_NIGHT_YES ? MODE_NIGHT_YES : MODE_NIGHT_NO);
 		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
 	@Override
 	protected void onResume() {
 	protected void onResume() {
 		if (BackupService.isRunning() || RestoreService.isRunning()) {
 		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();
 			finish();
 		}
 		}
 		try {
 		try {
@@ -90,4 +103,25 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 		}
 		}
 		super.onConfigurationChanged(newConfig);
 		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.DialogInterface;
 import android.content.Intent;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.content.res.Configuration;
-import android.content.res.Resources;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.view.View;
 import android.view.View;
 import android.widget.EditText;
 import android.widget.EditText;
@@ -32,6 +31,7 @@ import android.widget.Toast;
 
 
 import androidx.annotation.LayoutRes;
 import androidx.annotation.LayoutRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.annotation.UiThread;
 import androidx.appcompat.widget.Toolbar;
 import androidx.appcompat.widget.Toolbar;
 import androidx.preference.PreferenceManager;
 import androidx.preference.PreferenceManager;
@@ -93,16 +93,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 		super.onPause();
 		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
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 		logger.debug("onCreate");
@@ -156,7 +146,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	 * @param savedInstanceState the bundle provided to onCreate()
 	 * @param savedInstanceState the bundle provided to onCreate()
 	 * @return true on success, false otherwise
 	 * @return true on success, false otherwise
 	 */
 	 */
-	protected boolean initActivity(Bundle savedInstanceState) {
+	protected boolean initActivity(@Nullable Bundle savedInstanceState) {
 		logger.debug("initActivity");
 		logger.debug("initActivity");
 
 
 		@LayoutRes int layoutResource = getLayoutResource();
 		@LayoutRes int layoutResource = getLayoutResource();
@@ -177,7 +167,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 
 		// hide contents in app switcher and inhibit screenshots
 		// hide contents in app switcher and inhibit screenshots
 		ConfigUtils.setScreenshotsAllowed(this, preferenceService, lockAppService);
 		ConfigUtils.setScreenshotsAllowed(this, preferenceService, lockAppService);
-		ConfigUtils.setLocaleOverride(this, preferenceService);
 
 
 		if (layoutResource != 0) {
 		if (layoutResource != 0) {
 			logger.debug("setContentView");
 			logger.debug("setContentView");
@@ -342,13 +331,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 
 	@UiThread
 	@UiThread
 	public void openSoftKeyboard(@NonNull final EmojiPicker emojiPicker, @NonNull final EditText messageText) {
 	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() {
 	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
 							// ServiceManager (and thus LifetimeService) are now available
 							// Trigger a connection
 							// Trigger a connection
+							final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 							new Thread(() -> {
 							new Thread(() -> {
-								final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 								if (serviceManager != null) {
 								if (serviceManager != null) {
 									final LifetimeService lifetimeService = serviceManager.getLifetimeService();
 									final LifetimeService lifetimeService = serviceManager.getLifetimeService();
 									if (lifetimeService != null) {
 									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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		Intent intent = new Intent(WhatsNew2Activity.this, WhatsNewActivity.class);
 		Intent intent = new Intent(WhatsNew2Activity.this, WhatsNewActivity.class);
 		intent.putExtra(EXTRA_NO_ANIMATION, true);
 		intent.putExtra(EXTRA_NO_ANIMATION, true);
 		startActivity(intent);
 		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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		setResult(RESULT_CANCELED);
 		setResult(RESULT_CANCELED);
 		finish();
 		finish();
 	}
 	}

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

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

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

@@ -195,10 +195,15 @@ public class BallotWizardActivity extends ThreemaActivity {
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		int currentItem = pager.getCurrentItem();
 		int currentItem = pager.getCurrentItem();
 		if (currentItem == 0) {
 		if (currentItem == 0) {
-			super.onBackPressed();
+			finish();
 		} else {
 		} else {
 			pager.setCurrentItem(currentItem - 1);
 			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
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		// catch back key
 		// 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;
 package ch.threema.app.activities.wizard;
 
 
+import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Context;
 import android.content.Intent;
 import android.content.Intent;
@@ -32,12 +33,13 @@ import android.text.Html;
 import android.text.method.LinkMovementMethod;
 import android.text.method.LinkMovementMethod;
 import android.view.View;
 import android.view.View;
 import android.widget.TextView;
 import android.widget.TextView;
-import android.widget.Toast;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 
 
 import java.io.File;
 import java.io.File;
 
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.annotation.UiThread;
 import androidx.core.content.ContextCompat;
 import androidx.core.content.ContextCompat;
@@ -80,6 +82,15 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 	private FileService fileService;
 	private FileService fileService;
 	private UserService userService;
 	private UserService userService;
 	private PreferenceService preferenceService;
 	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
 	@Override
 	protected void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
 	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());
 		findViewById(R.id.cancel).setOnClickListener(v -> finish());
 	}
 	}
 
 
-	public void restoreSafe() {
+	private void restoreSafe() {
 		startActivity(new Intent(this, WizardSafeRestoreActivity.class));
 		startActivity(new Intent(this, WizardSafeRestoreActivity.class));
 		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 		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);
 		Intent intent = new Intent(this, WizardIDRestoreActivity.class);
 
 
 		if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
 		if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
@@ -252,6 +263,16 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 		dialogFragment.show(getSupportFragmentManager(), "restorePW");
 		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
 	@UiThread
 	private void showNoInternetDialog(File file) {
 	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);
 		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
 	// start password dialog callbacks
 	@Override
 	@Override
 	public void onYes(String tag, String text, boolean isChecked, Object data) {
 	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
 	@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;
 	public static final boolean DEFAULT_SYNC_CONTACTS = false;
 	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
 	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 static int lastPage = 0;
 	private ParallaxViewPager viewPager;
 	private ParallaxViewPager viewPager;
@@ -275,9 +276,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	private void setupConfig() {
 	private void setupConfig() {
 		safeConfig = ThreemaSafeMDMConfig.getInstance();
 		safeConfig = ThreemaSafeMDMConfig.getInstance();
 
 
+		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
+		viewPager.addOnPageChangeListener(this);
+
 		if (ConfigUtils.isWorkRestricted()) {
 		if (ConfigUtils.isWorkRestricted()) {
 			if (isSafeEnabled()) {
 			if (isSafeEnabled()) {
-				safePassword = safeConfig.getPassword();
+				if (isSafeForced()) {
+					safePassword = safeConfig.getPassword();
+				}
 				safeServerInfo = safeConfig.getServerInfo();
 				safeServerInfo = safeConfig.getServerInfo();
 			}
 			}
 
 
@@ -331,11 +337,16 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 			isSyncContacts = false;
 			isSyncContacts = false;
 		}
 		}
 
 
-		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
-		viewPager.addOnPageChangeListener(this);
-
 		presetMobile = this.userService.getLinkedMobile();
 		presetMobile = this.userService.getLinkedMobile();
 		presetEmail = this.userService.getLinkedEmail();
 		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();
 				linkPhone();
 			}
 			}
 		} else {
 		} 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();
 		return !safeConfig.isBackupDisabled();
 	}
 	}
 
 
+	@Override
+	public boolean isSafeForced() {
+		return safeConfig.isBackupForced();
+	}
+
 	@Override
 	@Override
 	public String getSafePassword() {
 	public String getSafePassword() {
 		return this.safePassword;
 		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
 	 * @return true if it's a new identity, false if the identity was restored
 	 */
 	 */
 	public boolean isNewIdentity() {
 	public boolean isNewIdentity() {
@@ -674,6 +696,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				break;
 				break;
 			case DIALOG_TAG_PASSWORD_BAD:
 			case DIALOG_TAG_PASSWORD_BAD:
 			case DIALOG_TAG_THREEMA_SAFE:
 			case DIALOG_TAG_THREEMA_SAFE:
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
 				break;
 				break;
 			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
 			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
 			case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
 			case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
@@ -702,11 +725,20 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				this.serviceManager.getPreferenceService().setSyncContacts(false);
 				this.serviceManager.getPreferenceService().setSyncContacts(false);
 				linkPhone();
 				linkPhone();
 				break;
 				break;
+			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+				finish();
+				System.exit(0);
+				break;
 		}
 		}
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (prevButton != null && prevButton.getVisibility() == View.VISIBLE) {
 		if (prevButton != null && prevButton.getVisibility() == View.VISIBLE) {
 			prevPage();
 			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
 	@Override
-	public void onBackPressed() {
-		finish();
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
 	}
 	}
 
 
 	@Override
 	@Override

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

@@ -210,8 +210,9 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 	}
 	}
 
 
 	@Override
 	@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)
 	@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;
 package ch.threema.app.activities.wizard;
 
 
+import android.Manifest;
+import android.app.Activity;
 import android.app.AlertDialog;
 import android.app.AlertDialog;
+import android.content.Context;
 import android.content.DialogInterface;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.Intent;
 import android.graphics.drawable.AnimationDrawable;
 import android.graphics.drawable.AnimationDrawable;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.text.SpannableStringBuilder;
 import android.text.SpannableStringBuilder;
 import android.text.Spanned;
 import android.text.Spanned;
@@ -36,10 +40,16 @@ import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.PrivacyPolicyActivity;
 import ch.threema.app.activities.PrivacyPolicyActivity;
 import ch.threema.app.activities.SimpleWebViewActivity;
 import ch.threema.app.activities.SimpleWebViewActivity;
+import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -52,6 +62,29 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 
 
 	private AnimationDrawable frameAnimation;
 	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
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 	protected void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 		super.onCreate(savedInstanceState);
@@ -120,6 +153,15 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 		isContactSyncSettingConflict();
 		isContactSyncSettingConflict();
 	}
 	}
 
 
+	@Override
+	protected void onResume() {
+		super.onResume();
+
+		if (RestoreService.isRunning()) {
+			finish();
+		}
+	}
+
 	public void setupThreema(View view) {
 	public void setupThreema(View view) {
 		if (isContactSyncSettingConflict()) {
 		if (isContactSyncSettingConflict()) {
 			return;
 			return;
@@ -142,7 +184,7 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 			return;
 			return;
 		}
 		}
 
 
-		startActivity(new Intent(this, WizardBackupRestoreActivity.class));
+		backupResult.launch(null);
 		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 		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.
 	 * Checks whether th_contact_sync conflicts with user restriction DISALLOW_MODIFY_ACCOUNTS.
 	 * If it conflicts, it shows an information dialog.
 	 * 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.annotation.SuppressLint;
 import android.os.AsyncTask;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Bundle;
+import android.os.Build;
 import android.text.InputFilter;
 import android.text.InputFilter;
 import android.text.InputType;
 import android.text.InputType;
 import android.view.View;
 import android.view.View;
@@ -36,11 +37,14 @@ import org.slf4j.Logger;
 import java.io.FileNotFoundException;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.IOException;
 
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
+import ch.threema.app.dialogs.WizardDialog;
 import ch.threema.app.dialogs.WizardSafeSearchPhoneDialog;
 import ch.threema.app.dialogs.WizardSafeSearchPhoneDialog;
 import ch.threema.app.threemasafe.ThreemaSafeAdvancedDialog;
 import ch.threema.app.threemasafe.ThreemaSafeAdvancedDialog;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 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,
 public class WizardSafeRestoreActivity extends WizardBackgroundActivity implements PasswordEntryDialog.PasswordEntryDialogClickListener,
 	WizardSafeSearchPhoneDialog.WizardSafeSearchPhoneDialogCallback,
 	WizardSafeSearchPhoneDialog.WizardSafeSearchPhoneDialogCallback,
+	WizardDialog.WizardDialogCallback,
 	ThreemaSafeAdvancedDialog.WizardDialogCallback {
 	ThreemaSafeAdvancedDialog.WizardDialogCallback {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardSafeRestoreActivity");
 	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_FORGOT_ID = "li";
 	private static final String DIALOG_TAG_ADVANCED = "adv";
 	private static final String DIALOG_TAG_ADVANCED = "adv";
 	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
 	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
+	private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "safe_pw_preset";
 
 
 	private ThreemaSafeService threemaSafeService;
 	private ThreemaSafeService threemaSafeService;
 
 
@@ -74,6 +80,13 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	ThreemaSafeMDMConfig safeMDMConfig;
 	ThreemaSafeMDMConfig safeMDMConfig;
 	ThreemaSafeServerInfo serverInfo = new ThreemaSafeServerInfo();
 	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
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(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);
 		Button advancedOptions = findViewById(R.id.advanced_options);
@@ -265,14 +282,9 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 							() -> {
 							() -> {
 								// On fail
 								// On fail
 								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
 								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 {
 					} else {
 						onSuccessfulRestore();
 						onSuccessfulRestore();
@@ -287,7 +299,25 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 		}.execute();
 		}.execute();
 	}
 	}
 
 
+	private void removeIdentity() {
+		try {
+			userService.removeIdentity();
+		} catch (Exception e) {
+			logger.error("Unable to remove identity", e);
+		}
+		finishAndRemoveTask();
+	}
+
 	private void onSuccessfulRestore() {
 	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,
 		SimpleStringAlertDialog.newInstance(R.string.restore_success_body, R.string.android_backup_restart_threema,
 			true).show(getSupportFragmentManager(), "d");
 			true).show(getSupportFragmentManager(), "d");
 		try {
 		try {
@@ -299,8 +329,9 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
-		finish();
+	protected boolean enableOnBackPressedCallback() {
+		// Override the behavior of WizardBackgroundActivity to allow normal back navigation
+		return false;
 	}
 	}
 
 
 	@Override
 	@Override
@@ -325,9 +356,18 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 		this.serverInfo = serverInfo;
 		this.serverInfo = serverInfo;
 	}
 	}
 
 
+	@Override
+	public void onYes(String tag, Object data) {
+		if (DIALOG_TAG_PASSWORD_PRESET_CONFIRM.equals(tag)) {
+			scheduleAppRestart();
+		}
+	}
+
 	@Override
 	@Override
 	public void onNo(String tag) {
 	public void onNo(String tag) {
-		if (safeMDMConfig.isRestoreDisabled()) {
+		if (DIALOG_TAG_PASSWORD_PRESET_CONFIRM.equals(tag)) {
+			removeIdentity();
+		} else if (safeMDMConfig.isRestoreDisabled()) {
 			finish();
 			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.FirstUnreadChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ForwardSecurityStatusChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ForwardSecurityStatusChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.GroupCallStatusDataChatAdapterDecorator;
 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.ImageChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.LocationChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.LocationChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.StatusChatAdapterDecorator;
 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.groupAckThumbsUpImage = itemView.findViewById(R.id.groupack_thumbsup);
 					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
 					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);
 					((ViewGroup) holder.groupAckContainer).getLayoutTransition().enableTransitionType(LayoutTransition.DISAPPEARING|LayoutTransition.APPEARING);
 				}
 				}
@@ -609,6 +611,9 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				case FORWARD_SECURITY_STATUS:
 				case FORWARD_SECURITY_STATUS:
 					decorator = new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					decorator = new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					break;
 					break;
+				case GROUP_STATUS:
+					decorator = new GroupStatusAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					break;
 					// Fallback to text chat adapter
 					// Fallback to text chat adapter
 				default:
 				default:
 					if (messageModel.isStatusMessage()) {
 					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);
 			itemHolder.nameView.setText(groupName);
 
 
-			if (groupService.isGroupOwner(groupModel)) {
+			if (groupService.isGroupCreator(groupModel)) {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_outline);
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_outline);
 			} else {
 			} else {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_filled);
 				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.EXPANDED;
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
 
 
+import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Bitmap;
 import android.text.Layout;
 import android.text.Layout;
@@ -35,25 +36,29 @@ import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.TextView;
 
 
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.widget.AppCompatImageButton;
 import androidx.appcompat.widget.AppCompatImageButton;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.recyclerview.widget.RecyclerView;
 
 
+import com.google.android.material.button.MaterialButton;
 import com.google.android.material.chip.Chip;
 import com.google.android.material.chip.Chip;
 import com.google.android.material.materialswitch.MaterialSwitch;
 import com.google.android.material.materialswitch.MaterialSwitch;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 
 
 import java.io.IOException;
 import java.io.IOException;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.List;
 
 
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.ShowOnceDialog;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
 import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.GroupDetailViewModel;
 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.app.utils.NameUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 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.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 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_HEADER = 0;
 	private static final int TYPE_ITEM = 1;
 	private static final int TYPE_ITEM = 1;
 
 
-	private boolean meIsGroupAdmin = false;
+	private boolean isGroupEditable = false;
 
 
 	private final Context context;
 	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 final GroupModel groupModel;
 	private GroupInviteModel defaultGroupInviteModel;
 	private GroupInviteModel defaultGroupInviteModel;
+	private final @Nullable Runnable onCloneGroupRunnable;
 	private List<ContactModel> contactModels; // Cached copy of group members
 	private List<ContactModel> contactModels; // Cached copy of group members
 	private OnGroupDetailsClickListener onClickListener;
 	private OnGroupDetailsClickListener onClickListener;
 	private final GroupDetailViewModel groupDetailViewModel;
 	private final GroupDetailViewModel groupDetailViewModel;
@@ -120,6 +128,9 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		private final TextView expandButton;
 		private final TextView expandButton;
 		public final TextView groupDescText;
 		public final TextView groupDescText;
 		public final SectionHeaderView groupDescChangedDate;
 		public final SectionHeaderView groupDescChangedDate;
+		public final View groupNoticeView;
+		public final TextView groupNoticeTextView;
+		public final MaterialButton groupNoticeCloneButton;
 
 
 		public HeaderHolder(View view) {
 		public HeaderHolder(View view) {
 			super(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.expandButton = itemView.findViewById(R.id.expand_group_desc_text);
 			this.groupDescChangedDate = itemView.findViewById(R.id.group_desc_changed_date);
 			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.context = context;
 		this.groupModel = groupModel;
 		this.groupModel = groupModel;
 		this.groupDetailViewModel = groupDetailViewModel;
 		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) {
 	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();
 		notifyDataSetChanged();
 	}
 	}
 
 
@@ -225,8 +290,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 
 			ContactModel ownerContactModel = contactService.getByIdentity(groupModel.getCreatorIdentity());
 			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()) {
 			if (ConfigUtils.supportGroupDescription()) {
 				initGroupDescriptionSection();
 				initGroupDescriptionSection();
@@ -245,13 +309,13 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 				headerHolder.linkContainerView.setVisibility(View.GONE);
 				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()));
 				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);
 			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
 	 * Display the group desc timestamp
 	 */
 	 */
@@ -442,7 +530,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescText.setVisibility(View.VISIBLE);
 		headerHolder.groupDescText.setVisibility(View.VISIBLE);
 		headerHolder.groupDescText.setText(groupDetailViewModel.getGroupDesc());
 		headerHolder.groupDescText.setText(groupDetailViewModel.getGroupDesc());
 		LinkifyUtil.getInstance().linkifyText(headerHolder.groupDescText, true);
 		LinkifyUtil.getInstance().linkifyText(headerHolder.groupDescText, true);
-		if (meIsGroupAdmin) {
+		if (isGroupEditable) {
 			headerHolder.changeGroupDescButton.setVisibility(View.VISIBLE);
 			headerHolder.changeGroupDescButton.setVisibility(View.VISIBLE);
 		} else {
 		} else {
 			headerHolder.changeGroupDescButton.setVisibility(View.GONE);
 			headerHolder.changeGroupDescButton.setVisibility(View.GONE);
@@ -459,7 +547,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
 		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
 		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
 		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
 		headerHolder.expandButton.setText(R.string.add_group_description);
 		headerHolder.expandButton.setText(R.string.add_group_description);
-		if (meIsGroupAdmin) {
+		if (isGroupEditable) {
 			headerHolder.expandButton.setVisibility(View.VISIBLE);
 			headerHolder.expandButton.setVisibility(View.VISIBLE);
 		} else {
 		} else {
 			headerHolder.expandButton.setVisibility(View.GONE);
 			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);
 		AdapterUtil.styleGroup(holder.nameView, groupService, groupModel);
 
 
 		holder.subjectView.setText(this.groupService.getMembersString(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)
 		    ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline)
 		    : R.drawable.ic_group_filled);
 		    : 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
         var messageId = 0
 
 
         init {
         init {
-            imageView = itemView.findViewById(R.id.image_view)
+            imageView = itemView.findViewById(R.id.thumbnail_view)
             gifContainerView = itemView.findViewById(R.id.gif_marker_container)
             gifContainerView = itemView.findViewById(R.id.gif_marker_container)
             videoContainerView = itemView.findViewById(R.id.video_marker_container)
             videoContainerView = itemView.findViewById(R.id.video_marker_container)
             videoDuration = itemView.findViewById(R.id.video_duration_text)
             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.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.MutableLiveData
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.activities.MediaGalleryActivity
 import ch.threema.app.activities.MediaGalleryActivity.*
 import ch.threema.app.activities.MediaGalleryActivity.*
 import ch.threema.app.messagereceiver.MessageReceiver
 import ch.threema.app.messagereceiver.MessageReceiver
 import ch.threema.app.services.MessageService
 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.base.ThreemaException
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.MessageType
-import ch.threema.storage.models.data.MessageContentsType
 
 
 class MediaGalleryRepository {
 class MediaGalleryRepository {
     private var abstractMessageModels: MutableLiveData<List<AbstractMessageModel?>?>? = null
     private var abstractMessageModels: MutableLiveData<List<AbstractMessageModel?>?>? = null
@@ -86,6 +84,7 @@ class MediaGalleryRepository {
             override fun onlyDownloaded(): Boolean { return false }
             override fun onlyDownloaded(): Boolean { return false }
             override fun types(): Array<MessageType>? { return null }
             override fun types(): Array<MessageType>? { return null }
             override fun contentTypes(): IntArray { return getContentTypes() }
             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 mutedChatsListService: DeadlineListService,
     private val mentionOnlyChatsListService: DeadlineListService,
     private val mentionOnlyChatsListService: DeadlineListService,
     private val ringtoneService: RingtoneService,
     private val ringtoneService: RingtoneService,
-    hiddenChatsListService: DeadlineListService
+    private val hiddenChatsListService: DeadlineListService
 ) {
 ) {
 
 
     val group: GroupModel? = conversationModel.group
     val group: GroupModel? = conversationModel.group
@@ -61,7 +61,8 @@ class MessageListAdapterItem(
     private val uniqueId = conversationModel.receiver?.uniqueIdString ?: ""
     private val uniqueId = conversationModel.receiver?.uniqueIdString ?: ""
     val uid: String = conversationModel.uid
     val uid: String = conversationModel.uid
 
 
-    val isHidden = hiddenChatsListService.has(uniqueId)
+    val isHidden: Boolean
+        get() = hiddenChatsListService.has(uniqueId)
     val isPinTagged = conversationModel.isPinTagged
     val isPinTagged = conversationModel.isPinTagged
     val isTyping = conversationModel.isTyping
     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 draft = messageListAdapterItem.getDraft()
 
 
+        val isHidden = messageListAdapterItem.isHidden
+
         // Initialize subject
         // Initialize subject
         subjectView.visibility = VISIBLE
         subjectView.visibility = VISIBLE
         subjectView.text = params.emojiMarkupUtil.formatBodyTextString(
         subjectView.text = params.emojiMarkupUtil.formatBodyTextString(
@@ -250,7 +252,7 @@ class MessageListViewHolder(
             100
             100
         )
         )
 
 
-        groupMemberName.text = if (messageListAdapterItem.isHidden) "" else messageListAdapterItem.latestMessageGroupMemberName
+        groupMemberName.text = if (isHidden) "" else messageListAdapterItem.latestMessageGroupMemberName
 
 
         if (draft != null) {
         if (draft != null) {
             initializeDraft()
             initializeDraft()
@@ -264,9 +266,9 @@ class MessageListViewHolder(
 
 
         initializeMuteAppearance(messageListAdapterItem)
         initializeMuteAppearance(messageListAdapterItem)
 
 
-        initializeHiddenAppearance(messageListAdapterItem.isHidden)
+        initializeHiddenAppearance(isHidden)
 
 
-        initializeDeliveryView(messageListAdapterItem, messageListAdapterItem.isHidden, draft != null)
+        initializeDeliveryView(messageListAdapterItem, isHidden, draft != null)
 
 
         initializeGroupCallIndicator(messageListAdapterItem)
         initializeGroupCallIndicator(messageListAdapterItem)
 
 
@@ -281,7 +283,7 @@ class MessageListViewHolder(
             requestManager
             requestManager
         )
         )
 
 
-        updateTypingIndicator(messageListAdapterItem)
+        updateTypingIndicator(messageListAdapterItem.isTyping, isHidden)
 
 
         if (params.isTablet) {
         if (params.isTablet) {
             // handle selection in multi-pane mode
             // 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
         latestMessageContainer.visibility = if (isTypingIndicatorHidden) VISIBLE else GONE
         typingContainer.visibility = if (isTypingIndicatorHidden) GONE else VISIBLE
         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()) {
 		if(conversationModel.isGroupConversation()) {
 			fromtext = NameUtil.getDisplayName(groupModel, this.groupService);
 			fromtext = NameUtil.getDisplayName(groupModel, this.groupService);
 			subjecttext = groupService.getMembersString(groupModel);
 			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()) {
 		else if(conversationModel.isDistributionListConversation()) {
 			fromtext = NameUtil.getDisplayName(distributionListModel, this.distributionListService);
 			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
     annotation class ViewType
 
 
     open class SendMediaHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
     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) {
     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.AnyThread;
 
 
+import androidx.annotation.NonNull;
 import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
 import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 
 
 abstract class AdapterDecorator {
 abstract class AdapterDecorator {
+	@NonNull
 	private final Context context;
 	private final Context context;
 	private transient ListView inListView = null;
 	private transient ListView inListView = null;
 
 
-	protected AdapterDecorator(Context context) {
+	protected AdapterDecorator(@NonNull Context context) {
 		this.context = context;
 		this.context = context;
 	}
 	}
 
 
+	@NonNull
 	protected Context getContext() {
 	protected Context getContext() {
 		return this.context;
 		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;
 package ch.threema.app.adapters.decorators;
 
 
 import android.content.Context;
 import android.content.Context;
-import android.content.res.ColorStateList;
 import android.graphics.Bitmap;
 import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.Color;
 import android.text.Spannable;
 import android.text.Spannable;
@@ -30,8 +29,6 @@ import android.text.TextUtils;
 import android.view.MotionEvent;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.View;
 
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import androidx.appcompat.content.res.AppCompatResources;
 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.DistributionListMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.MessageType;
+import ch.threema.storage.models.data.DisplayTag;
 
 
 abstract public class ChatAdapterDecorator extends AdapterDecorator {
 abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ChatAdapterDecorator");
 	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);
 		super(context);
 		this.messageModel = messageModel;
 		this.messageModel = messageModel;
 		this.helper = helper;
 		this.helper = helper;
@@ -392,6 +392,10 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 				holder.datePrefixIcon.setVisibility(durationS > 0L ? View.VISIBLE : View.GONE);
 				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.setStateDrawable(getContext(), messageModel, holder.deliveredIndicator, true);
 			stateBitmapUtil.setGroupAckCount(messageModel, holder);
 			stateBitmapUtil.setGroupAckCount(messageModel, holder);
 		}
 		}
@@ -462,6 +466,10 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		return helper.getUserService();
 		return helper.getUserService();
 	}
 	}
 
 
+	protected ContactService getContactService() {
+		return helper.getContactService();
+	}
+
 	protected void setOnClickListener(final View.OnClickListener onViewClickListener, View view) {
 	protected void setOnClickListener(final View.OnClickListener onViewClickListener, View view) {
 		if (view != null) {
 		if (view != null) {
 			view.setOnClickListener(v -> {
 			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.AbstractMessageModel
 import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel.ForwardSecurityStatusType
 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) {
     override fun configureChatMessage(holder: ComposeMessageHolder, position: Int) {
         val statusDataModel = messageModel.forwardSecurityStatusData ?: return
         val statusDataModel = messageModel.forwardSecurityStatusData ?: return
         var body: String? = null
         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.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.ConversationModel;
+import ch.threema.storage.models.GroupModel;
 
 
 public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, SearchView.OnQueryTextListener {
 public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, SearchView.OnQueryTextListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ArchiveActivity");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ArchiveActivity");
@@ -260,11 +261,16 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onBackPressed() {
+	protected boolean enableOnBackPressedCallback() {
+		return true;
+	}
+
+	@Override
+	protected void handleOnBackPressed() {
 		if (actionMode != null) {
 		if (actionMode != null) {
 			actionMode.finish();
 			actionMode.finish();
 		} else {
 		} 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 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);
 		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);
 					confirmText = getString(R.string.delete_my_group_message);
 				} else {
 				} else {
 					confirmText = getString(R.string.delete_group_message);
 					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
 	@Override
 	protected void onPreExecute() {
 	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.setOnCancelListener((dialog1, which) -> cancelled = true);
 		dialog.show(fragmentManager, DIALOG_TAG_DELETE_CONTACT);
 		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
 	@Override
 	protected void onPreExecute() {
 	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() {
 		dialog.setOnCancelListener(new DialogInterface.OnClickListener() {
 			@Override
 			@Override
 			public void onClick(DialogInterface dialog, int which) {
 			public void onClick(DialogInterface dialog, int which) {
@@ -112,7 +112,7 @@ public class DeleteConversationsAsyncTask extends AsyncTask<Void, Integer, Integ
 			conversationService.clear(conversationModel);
 			conversationService.clear(conversationModel);
 
 
 			if (conversationModel.isGroupConversation()) {
 			if (conversationModel.isGroupConversation()) {
-				groupService.leaveGroup(conversationModel.getGroup());
+				groupService.leaveGroupFromLocal(conversationModel.getGroup());
 				groupService.remove(conversationModel.getGroup());
 				groupService.remove(conversationModel.getGroup());
 			} else if (conversationModel.isDistributionListConversation()) {
 			} else if (conversationModel.isDistributionListConversation()) {
 				distributionListService.remove(conversationModel.getDistributionList());
 				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
 	@Override
 	protected Void doInBackground(Void... params) {
 	protected Void doInBackground(Void... params) {
-		groupService.leaveGroup(groupModel);
+		groupService.leaveGroupFromLocal(groupModel);
 		groupService.remove(groupModel);
 		groupService.remove(groupModel);
 		return null;
 		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
 	@Override
 	protected Void doInBackground(Void... params) {
 	protected Void doInBackground(Void... params) {
-		groupService.removeAllMembersAndLeave(groupModel);
+		groupService.dissolveGroupFromLocal(groupModel);
 		groupService.remove(groupModel);
 		groupService.remove(groupModel);
 		return null;
 		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() {
 	protected void onPreExecute() {
 		if (!quiet) {
 		if (!quiet) {
 			isCancelled = false;
 			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.setOnCancelListener((dialog1, which) -> isCancelled = true);
 			dialog.show(fragmentManager, DIALOG_TAG_EMPTYING_CHAT);
 			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
 	@Override
 	protected Void doInBackground(Void... params) {
 	protected Void doInBackground(Void... params) {
-		groupService.leaveGroup(groupModel);
+		groupService.leaveGroupFromLocal(groupModel);
 		return null;
 		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;
 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.Notification;
 import android.app.NotificationManager;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.app.PendingIntent;
@@ -62,6 +66,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Set;
 import java.util.Set;
 
 
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 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.FileDataModel;
 import ch.threema.storage.models.data.media.VideoDataModel;
 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 {
 public class BackupService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupService");
 	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 = 9;
 	private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
 	private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
 	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
 	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";
 	private static final String EXTRA_ID_CANCEL = "cnc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
 
 
 	private static final int BACKUP_NOTIFICATION_ID = 991772;
 	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 long FILE_SETTLE_DELAY = 5000;
 
 
 	private static final String INCOMPLETE_BACKUP_FILENAME_PREFIX = "INCOMPLETE-";
 	private static final String INCOMPLETE_BACKUP_FILENAME_PREFIX = "INCOMPLETE-";
@@ -399,6 +407,9 @@ public class BackupService extends Service {
 
 
 			if (this.config.backupNonces()) {
 			if (this.config.backupNonces()) {
 				progress += 1;
 				progress += 1;
+				long nonceCount = this.databaseNonceStore.getCount();
+				long nonceProgress = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
+				progress += nonceProgress;
 			}
 			}
 
 
 			logger.debug("Calculated steps " + progress);
 			logger.debug("Calculated steps " + progress);
@@ -482,14 +493,21 @@ public class BackupService extends Service {
 		int p = (int) (100d / (double) this.processSteps * (double) this.currentProgressStep);
 		int p = (int) (100d / (double) this.processSteps * (double) this.currentProgressStep);
 		if (p > this.latestPercentStep) {
 		if (p > this.latestPercentStep) {
 			this.latestPercentStep = p;
 			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) {
 	private void removeBackupFile(DocumentFile zipFile) {
 		//remove zip file
 		//remove zip file
 		if (zipFile != null && zipFile.exists()) {
 		if (zipFile != null && zipFile.exists()) {
-			logger.debug( "remove " + zipFile.getUri());
+			logger.debug("remove {}", zipFile.getUri());
 			zipFile.delete();
 			zipFile.delete();
 		}
 		}
 	}
 	}
@@ -563,7 +581,8 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
 			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_READ_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.
 		// 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_QUOTED_MESSAGE_ID, messageModel.getQuotedMessageId())
 										.write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
 										.write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
 										.write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
 										.write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
+										.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, messageModel.getDisplayTags())
 										.write();
 										.write();
 								}
 								}
 
 
@@ -728,31 +748,32 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
 			Tags.TAG_MESSAGE_READ_AT,
 			Tags.TAG_MESSAGE_READ_AT,
 			Tags.TAG_GROUP_MESSAGE_STATES,
 			Tags.TAG_GROUP_MESSAGE_STATES,
+			Tags.TAG_MESSAGE_DISPLAY_TAGS
 		};
 		};
 
 
 		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
 		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
 			@Override
 			@Override
-			public boolean sortingByDate() {
+			public boolean sortByDate() {
 				return false;
 				return false;
 			}
 			}
 
 
 			@Override
 			@Override
-			public boolean sortingByName() {
+			public boolean sortByName() {
 				return false;
 				return false;
 			}
 			}
 
 
 			@Override
 			@Override
-			public boolean sortingAscending() {
+			public boolean sortAscending() {
 				return false;
 				return false;
 			}
 			}
 
 
 			@Override
 			@Override
-			public boolean withDeleted() {
+			public boolean includeDeletedGroups() {
 				return true;
 				return true;
 			}
 			}
 
 
 			@Override
 			@Override
-			public boolean withDeserted() {
+			public boolean includeLeftGroups() {
 				return true;
 				return true;
 			}
 			}
 		};
 		};
@@ -827,6 +848,7 @@ public class BackupService extends Service {
 									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
 									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
 									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
 									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
 									.write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
 									.write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
+									.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, groupMessageModel.getDisplayTags())
 									.write();
 									.write();
 
 
 								if (MessageUtil.hasDataFile(groupMessageModel)) {
 								if (MessageUtil.hasDataFile(groupMessageModel)) {
@@ -1082,8 +1104,15 @@ public class BackupService extends Service {
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
 			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(
 		ZipUtil.addZipStream(
@@ -1272,7 +1301,7 @@ public class BackupService extends Service {
 				if (is != null) {
 				if (is != null) {
 					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid(), false);
 					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid(), false);
 				} else {
 				} 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
 					// try to save thumbnail if media is missing
 					saveThumbnail = true;
 					saveThumbnail = true;
 				}
 				}
@@ -1289,7 +1318,7 @@ public class BackupService extends Service {
 			return true;
 			return true;
 		} catch (Exception x) {
 		} catch (Exception x) {
 			//do not abort, its only a media :-)
 			//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;
 			return false;
 		}
 		}
 	}
 	}
@@ -1329,6 +1358,11 @@ public class BackupService extends Service {
 		} else {
 		} else {
 			logger.error("Backup failed: {}", message);
 			logger.error("Backup failed: {}", message);
 			showBackupErrorNotification(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
 		//try to reopen connection
@@ -1349,12 +1383,18 @@ public class BackupService extends Service {
 
 
 		isRunning = false;
 		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();
 		stopSelf();
 	}
 	}
 
 
 	private void showPersistentNotification() {
 	private void showPersistentNotification() {
-		logger.debug( "showPersistentNotification");
+		logger.debug("showPersistentNotification");
 
 
 		Intent cancelIntent = new Intent(this, BackupService.class);
 		Intent cancelIntent = new Intent(this, BackupService.class);
 		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
 		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
@@ -1378,14 +1418,11 @@ public class BackupService extends Service {
 		startForeground(BACKUP_NOTIFICATION_ID, notification);
 		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);
 		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() {
 	private void cancelPersistentNotification() {
 		if (notificationManager != null) {
 		if (notificationManager != null) {
 			notificationManager.cancel(BACKUP_NOTIFICATION_ID);
 			notificationManager.cancel(BACKUP_NOTIFICATION_ID);
@@ -1436,7 +1480,7 @@ public class BackupService extends Service {
 	}
 	}
 
 
 	private void showBackupSuccessNotification() {
 	private void showBackupSuccessNotification() {
-		logger.debug( "showBackupSuccess");
+		logger.debug("showBackupSuccess");
 
 
 		String text;
 		String text;
 
 
@@ -1486,12 +1530,21 @@ public class BackupService extends Service {
 	private void safeStopSelf() {
 	private void safeStopSelf() {
 		Notification notification = new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS, null)
 		Notification notification = new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS, null)
 			.setContentTitle("")
 			.setContentTitle("")
-			.setContentText("").
-				build();
+			.setContentText("")
+			.build();
 
 
 		startForeground(BACKUP_NOTIFICATION_ID, notification);
 		startForeground(BACKUP_NOTIFICATION_ID, notification);
 		stopForeground(true);
 		stopForeground(true);
 		isRunning = false;
 		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();
 		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.List;
 import java.util.Map;
 import java.util.Map;
 
 
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
@@ -123,6 +124,12 @@ import ch.threema.storage.models.data.media.FileDataModel;
 public class RestoreService extends Service {
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
 	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_FILE = "file";
 	public static final String EXTRA_RESTORE_BACKUP_PASSWORD = "pwd";
 	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
 	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_MESSAGES = 1; // per message
 	private static final int STEP_SIZE_GROUP_AVATARS = 50;
 	private static final int STEP_SIZE_GROUP_AVATARS = 50;
 	private static final int STEP_SIZE_MEDIA = 25; // per media file
 	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;
 	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}.
 			// The connection will be resumed in {@link onFinished}.
 			final ThreemaConnection connection = serviceManager.getConnection();
 			final ThreemaConnection connection = serviceManager.getConnection();
-			if (connection != null && connection.isRunning()) {
+			if (connection.isRunning()) {
 				connection.stop();
 				connection.stop();
 			}
 			}
 
 
@@ -489,8 +497,9 @@ public class RestoreService extends Service {
 
 
 				// Restore nonces
 				// Restore nonces
 				logger.info("Restoring 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!
 					//continue anyway!
 				}
 				}
 
 
@@ -539,7 +548,9 @@ public class RestoreService extends Service {
 				preferenceService.setProfilePicUploadData(null);
 				preferenceService.setProfilePicUploadData(null);
 
 
 				if (!writeToDb) {
 				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;
 		return true;
 	}
 	}
 
 
-	private boolean restoreNonces(List<FileHeader> fileHeaders) throws IOException {
+	private int restoreNonces(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
 		FileHeader nonceFileHeader = null;
 		FileHeader nonceFileHeader = null;
 		for (FileHeader fileHeader : fileHeaders) {
 		for (FileHeader fileHeader : fileHeaders) {
 			String fileName = fileHeader.getFileName();
 			String fileName = fileHeader.getFileName();
@@ -644,33 +655,44 @@ public class RestoreService extends Service {
 		}
 		}
 		if (nonceFileHeader == null) {
 		if (nonceFileHeader == null) {
 			logger.info("Nonce file header is 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);
 						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 (!groupModel.isDeleted()) {
-						if (groupService.isGroupOwner(groupModel)) {
+						if (groupService.isGroupCreator(groupModel)) {
 							groupService.sendSync(groupModel);
 							groupService.sendSync(groupModel);
 						} else {
 						} else {
 							groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId().toString())));
 							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())) {
 		} else if (typeAsString.equals(MessageType.GROUP_CALL_STATUS.name())) {
 			messageType = MessageType.GROUP_CALL_STATUS;
 			messageType = MessageType.GROUP_CALL_STATUS;
 			messageContentsType = MessageContentsType.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.setType(messageType);
 		messageModel.setMessageContentsType(messageContentsType);
 		messageModel.setMessageContentsType(messageContentsType);
@@ -1581,6 +1606,15 @@ public class RestoreService extends Service {
 				messageModel.setQuotedMessageId(quotedMessageId);
 				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 {
 	private MessageModel createMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
 		MessageModel messageModel = new MessageModel();
 		MessageModel messageModel = new MessageModel();
@@ -1707,7 +1741,14 @@ public class RestoreService extends Service {
 		int p = (int) (100d / (double) this.progressSteps * (double) this.currentProgressStep);
 		int p = (int) (100d / (double) this.progressSteps * (double) this.currentProgressStep);
 		if (p > this.latestPercentStep) {
 		if (p > this.latestPercentStep) {
 			this.latestPercentStep = p;
 			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;
 			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) {
 			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));
 				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 {
 		} else {
 			showRestoreErrorNotification(message);
 			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, () -> {
 			new DeleteIdentityAsyncTask(null, () -> {
 				isRunning = false;
 				isRunning = false;
 
 
@@ -1777,13 +1831,10 @@ public class RestoreService extends Service {
 		return notificationBuilder.build();
 		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));
 			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());
 		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() {
 	private void cancelPersistentNotification() {
 		notificationManager.cancel(RESTORE_NOTIFICATION_ID);
 		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
 	 * 17: group message states (ack / dec) and group descriptions
 	 * 18: contact forward security flag
 	 * 18: contact forward security flag
 	 * 19: add random contact id
 	 * 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;
 	private int version;
 
 
 	public RestoreSettings(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_IS_QUEUED = "isqueued";
 	public static final String TAG_MESSAGE_CAPTION = "caption";
 	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_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_ID = "id";
 	public static final String TAG_DISTRIBUTION_LIST_NAME = "distribution_list_name";
 	public static final String TAG_DISTRIBUTION_LIST_NAME = "distribution_list_name";

Некоторые файлы не были показаны из-за большого количества измененных файлов