Kaynağa Gözat

Version 5.1

Threema 2 yıl önce
ebeveyn
işleme
33bed7f7c9
100 değiştirilmiş dosya ile 4614 ekleme ve 3138 silme
  1. 2 2
      app/assets/license.html
  2. 51 44
      app/build.gradle
  3. 2 2
      app/proguard-project.txt
  4. 4 2
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  5. 3 3
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java
  6. 0 5
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  7. 18 7
      app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java
  8. 21 23
      app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  9. 27 6
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  10. 30 9
      app/src/main/AndroidManifest.xml
  11. 2 1
      app/src/main/java/ch/threema/app/NamedFileProvider.java
  12. 92 77
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  13. 0 1
      app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java
  14. 0 1
      app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java
  15. 4 5
      app/src/main/java/ch/threema/app/activities/AboutActivity.java
  16. 0 4
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  17. 24 0
      app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java
  18. 0 5
      app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java
  19. 7 23
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  20. 53 70
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  21. 120 78
      app/src/main/java/ch/threema/app/activities/CropImageActivity.java
  22. 97 83
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  23. 6 27
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  24. 4 3
      app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.java
  25. 29 21
      app/src/main/java/ch/threema/app/activities/EditSendContactActivity.kt
  26. 3 2
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  27. 0 5
      app/src/main/java/ch/threema/app/activities/ExportIDActivity.java
  28. 26 19
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  29. 11 14
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  30. 108 80
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  31. 1 1
      app/src/main/java/ch/threema/app/activities/GroupEditActivity.java
  32. 131 131
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  33. 15 19
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  34. 384 58
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  35. 4 3
      app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java
  36. 7 20
      app/src/main/java/ch/threema/app/activities/LicenseActivity.java
  37. 15 21
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  38. 429 403
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  39. 22 27
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  40. 202 73
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  41. 36 73
      app/src/main/java/ch/threema/app/activities/NotificationsActivity.java
  42. 457 0
      app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt
  43. 1 7
      app/src/main/java/ch/threema/app/activities/PinLockActivity.java
  44. 2 2
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.java
  45. 2 10
      app/src/main/java/ch/threema/app/activities/QRCodeZoomActivity.java
  46. 83 22
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  47. 2 24
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  48. 4 3
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  49. 58 47
      app/src/main/java/ch/threema/app/activities/SimpleWebViewActivity.java
  50. 2 6
      app/src/main/java/ch/threema/app/activities/StopPassphraseServiceActivity.java
  51. 2 2
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  52. 18 66
      app/src/main/java/ch/threema/app/activities/SupportActivity.java
  53. 30 41
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  54. 0 22
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  55. 35 16
      app/src/main/java/ch/threema/app/activities/ThreemaAppCompatActivity.java
  56. 1 1
      app/src/main/java/ch/threema/app/activities/ThreemaPushNotificationInfoActivity.kt
  57. 20 18
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  58. 23 22
      app/src/main/java/ch/threema/app/activities/UnlockMasterKeyActivity.java
  59. 1 1
      app/src/main/java/ch/threema/app/activities/WhatsNew2Activity.java
  60. 5 5
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java
  61. 29 21
      app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java
  62. 24 12
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  63. 15 20
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java
  64. 33 14
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  65. 4 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  66. 4 9
      app/src/main/java/ch/threema/app/activities/wizard/WizardIntroActivity.java
  67. 2 5
      app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java
  68. 6 5
      app/src/main/java/ch/threema/app/adapters/BottomSheetListAdapter.java
  69. 102 36
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  70. 44 74
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  71. 5 3
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  72. 7 1
      app/src/main/java/ch/threema/app/adapters/DistributionListAdapter.java
  73. 28 0
      app/src/main/java/ch/threema/app/adapters/FilterResultsListener.java
  74. 37 52
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  75. 8 1
      app/src/main/java/ch/threema/app/adapters/GroupListAdapter.java
  76. 0 297
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java
  77. 340 0
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.kt
  78. 107 0
      app/src/main/java/ch/threema/app/adapters/MediaGalleryRepository.kt
  79. 0 92
      app/src/main/java/ch/threema/app/adapters/MediaGallerySpinnerAdapter.java
  80. 56 0
      app/src/main/java/ch/threema/app/adapters/MediaGalleryViewModel.kt
  81. 2 2
      app/src/main/java/ch/threema/app/adapters/MentionSelectorAdapter.java
  82. 112 525
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  83. 178 0
      app/src/main/java/ch/threema/app/adapters/MessageListAdapterItem.kt
  84. 496 0
      app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt
  85. 10 2
      app/src/main/java/ch/threema/app/adapters/RecentListAdapter.java
  86. 7 1
      app/src/main/java/ch/threema/app/adapters/UserListAdapter.java
  87. 5 4
      app/src/main/java/ch/threema/app/adapters/ballot/BallotOverviewListAdapter.java
  88. 7 5
      app/src/main/java/ch/threema/app/adapters/ballot/BallotVoteListAdapter.java
  89. 6 3
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  90. 58 68
      app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java
  91. 51 35
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  92. 12 17
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  93. 1 0
      app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt
  94. 5 6
      app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java
  95. 9 3
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  96. 29 12
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  97. 2 1
      app/src/main/java/ch/threema/app/adapters/decorators/VoipStatusDataChatAdapterDecorator.java
  98. 25 39
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  99. 9 3
      app/src/main/java/ch/threema/app/archive/ArchiveAdapter.java
  100. 3 3
      app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java

+ 2 - 2
app/assets/license.html

@@ -10,8 +10,8 @@
         body {
         font-family: Helvetica, Arial, sans-serif;
         font-size: 11px;
-        margin: 0;
-        padding: 8px 16px;
+        margin: 0 0;
+        padding: 8px 8px;
         line-height: 1.3em;
         }
 

+ 51 - 44
app/build.gradle

@@ -17,7 +17,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "5.0.6"
+def app_version = "5.1"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -96,7 +96,7 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 800
+        versionCode 906
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -131,6 +131,7 @@ android {
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
         buildConfigField "String", "ONPREM_ID_PREFIX", "\"O\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
+        buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
@@ -201,6 +202,7 @@ android {
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
+            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
@@ -248,14 +250,15 @@ android {
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
+            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
-            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
+            buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
             manifestPlaceholders = [
                 uriScheme       : "threemawork",
-                actionUrl       : "work.threema.ch",
+                actionUrl       : "work.test.threema.ch",
             ]
         }
         onprem {
@@ -348,6 +351,7 @@ android {
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
+            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
@@ -588,14 +592,14 @@ android {
         targetCompatibility JavaVersion.VERSION_11
     }
 
-    kotlinOptions {
-        jvmTarget = "11"
+    java {
+        toolchain {
+            languageVersion.set(JavaLanguageVersion.of(11))
+        }
     }
 
     kotlin {
-        jvmToolchain {
-            languageVersion.set(JavaLanguageVersion.of(11))
-        }
+        jvmToolchain(11)
     }
 
     androidResources {
@@ -676,57 +680,60 @@ dependencies {
 
     implementation project(':domain')
 
-    implementation 'net.zetetic:android-database-sqlcipher:4.5.2'
+    implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
     implementation 'net.sf.opencsv:opencsv:2.3'
-    implementation 'net.lingala.zip4j:zip4j:2.11.4'
+    implementation 'net.lingala.zip4j:zip4j:2.11.5'
     implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.3'
     // commons-io >2.6 requires android 8
     implementation 'commons-io:commons-io:2.6'
-    implementation 'org.apache.commons:commons-text:1.9'
+    implementation 'org.apache.commons:commons-text:1.10.0'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
     implementation 'com.github.CanHub:Android-Image-Cropper:4.3.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
-    implementation 'me.zhanghai.android.fastscroll:library:1.1.8'
+    implementation 'me.zhanghai.android.fastscroll:library:1.2.0'
     implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3'
 
     // AndroidX / Jetpack support libraries
     implementation "androidx.preference:preference-ktx:1.2.0"
-    implementation 'androidx.recyclerview:recyclerview:1.2.1'
+    implementation 'androidx.recyclerview:recyclerview:1.3.0'
     implementation 'androidx.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
-    implementation 'androidx.appcompat:appcompat:1.5.1'
+    implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
-    implementation 'androidx.work:work-runtime-ktx:2.7.1'
-    implementation 'androidx.fragment:fragment-ktx:1.5.4'
-    implementation 'androidx.activity:activity-ktx:1.6.1'
-    implementation 'androidx.sqlite:sqlite:2.1.0'
+    implementation 'androidx.work:work-runtime-ktx:2.8.1'
+    implementation 'androidx.fragment:fragment-ktx:1.5.7'
+    implementation 'androidx.activity:activity-ktx:1.7.2'
+    implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.1.0"
-    implementation "androidx.camera:camera-lifecycle:1.1.0"
-    implementation "androidx.camera:camera-view:1.1.0"
+    implementation "androidx.camera:camera-camera2:1.3.0-beta01"
+    implementation "androidx.camera:camera-lifecycle:1.3.0-beta01"
+    implementation "androidx.camera:camera-view:1.3.0-beta01"
+    implementation 'androidx.camera:camera-video:1.3.0-beta01'
+    implementation "androidx.media:media:1.6.0"
+    implementation 'androidx.media3:media3-exoplayer:1.0.2'
+    implementation 'androidx.media3:media3-ui:1.0.2'
+    implementation "androidx.media3:media3-session:1.0.2"
     implementation 'androidx.multidex:multidex:2.0.1'
-    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-service:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-process:2.5.1"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-service:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-process:2.6.1"
+    implementation "androidx.lifecycle:lifecycle-common-java8:2.6.1"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
-    implementation "androidx.paging:paging-runtime:3.1.1"
+    implementation "androidx.paging:paging-runtime-ktx:3.1.1"
     implementation "androidx.sharetarget:sharetarget:1.2.0"
-    implementation 'androidx.room:room-runtime:2.4.3'
-    kapt 'androidx.room:room-compiler:2.4.3'
+    implementation 'androidx.room:room-runtime:2.5.1'
+    kapt 'androidx.room:room-compiler:2.5.1'
 
-    implementation 'com.google.android.material:material:1.7.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.5'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.5'
+    implementation 'com.google.android.material:material:1.9.0'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.57' // make sure to update this in domain's build.gradle as well
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.7' // make sure to update this in domain's build.gradle as well
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
@@ -747,11 +754,13 @@ dependencies {
     }
 
     // Glide components
+    // Glide 4.15+ does not work on API 21
     implementation 'com.github.bumptech.glide:glide:4.14.2'
     kapt 'com.github.bumptech.glide:compiler:4.14.2'
+    annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
 
     // kotlin
-    implementation 'androidx.core:core-ktx:1.9.0'
+    implementation 'androidx.core:core-ktx:1.10.1'
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
 
@@ -775,7 +784,7 @@ dependencies {
     testImplementation 'com.tngtech.archunit:archunit-junit4:0.18.0'
 
     androidTestImplementation(testFixtures(project(":domain")))
-    androidTestImplementation 'androidx.test:rules:1.4.0'
+    androidTestImplementation 'androidx.test:rules:1.5.0'
     androidTestImplementation 'tools.fastlane:screengrab:2.1.1', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
@@ -785,7 +794,7 @@ dependencies {
     androidTestImplementation 'androidx.test:runner:1.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.appcompat', module: 'appcompat'
@@ -793,6 +802,7 @@ dependencies {
         exclude group: 'com.google.android.material', module: 'material'
         exclude group: 'androidx.recyclerview', module: 'recyclerview'
         exclude(group: 'org.checkerframework', module: 'checker')
+        exclude module: "protobuf-lite"
     }
     androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
@@ -802,13 +812,10 @@ dependencies {
     // Google Play Services and related libraries
     def googleDependencies = [
         // Play services
-        'com.google.android.gms:play-services-base:16.1.0': [],
+        'com.google.android.gms:play-services-base:18.1.0': [],
 
         // Firebase push
-        //
-        // Note: Do not upgrade to a higher version of firebase-messaging,
-        //       as we do not want the Firebase Installations API in our app
-        'com.google.firebase:firebase-messaging:20.1.0': [
+        'com.google.firebase:firebase-messaging:23.1.1': [
             [group: 'com.google.firebase', module: 'firebase-core'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],

+ 2 - 2
app/proguard-project.txt

@@ -86,8 +86,8 @@ public static <fields>;
 }
 
 # SQLCipher
--keep,includedescriptorclasses class net.sqlcipher.** { *; }
--keep,includedescriptorclasses interface net.sqlcipher.** { *; }
+-keep,includedescriptorclasses class net.zetetic.database.sqlcipher.** { *; }
+-keep,includedescriptorclasses interface net.zetetic.database.sqlcipher.** { *; }
 
 # for zxing
 -keep class com.google.zxing.client.android.camera.open.**

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

@@ -221,7 +221,8 @@ public class BackupServiceTest {
 			.setBackupAvatars(false)
 			.setBackupMedia(false)
 			.setBackupThumbnails(false)
-			.setBackupVideoAndFiles(false));
+			.setBackupVideoAndFiles(false)
+			.setBackupNonces(false));
 
 		try {
 			final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{ "settings", "identity" });
@@ -274,7 +275,8 @@ public class BackupServiceTest {
             .setBackupAvatars(false)
             .setBackupMedia(false)
             .setBackupThumbnails(false)
-            .setBackupVideoAndFiles(false));
+            .setBackupVideoAndFiles(false)
+	        .setBackupNonces(false));
 
         try {
             final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{

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

@@ -185,7 +185,7 @@ public class MessageProcessorTest {
 		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
 		boxmsg.setNonce(this.nonceFactory.next());
 		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertFalse(result.processed);
+		Assert.assertFalse(result.wasProcessed());
 		final String logs = this.getLogs();
 		ThreemaAssert.assertContains(logs, "BadMessageException: Message is not for own identity, cannot decode");
 	}
@@ -202,7 +202,7 @@ public class MessageProcessorTest {
 		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
 		boxmsg.setNonce(this.nonceFactory.next());
 		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertFalse(result.processed);
+		Assert.assertFalse(result.wasProcessed());
 		final String logs = this.getLogs();
 		ThreemaAssert.assertContains(logs, "ch.threema.domain.protocol.csp.messages.BadMessageException: Decryption of message from");
 	}
@@ -233,7 +233,7 @@ public class MessageProcessorTest {
 
 		// Process message
 		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertTrue(result.processed);
+		Assert.assertTrue(result.wasProcessed());
 
 		// Assert log messages
 		final String logs = this.getLogs();

+ 0 - 5
app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java

@@ -232,11 +232,6 @@ public class GroupInviteServiceTest {
 				return null;
 			}
 
-			@Override
-			public boolean isTyping(String toIdentity, boolean isTyping) {
-				return false;
-			}
-
 			@Override
 			public boolean restoreIdentity(String backupString, String password) throws Exception {
 				return false;

+ 18 - 7
app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java

@@ -29,9 +29,11 @@ import org.junit.Test;
 import java.nio.charset.StandardCharsets;
 
 import androidx.test.core.app.ApplicationProvider;
+import ch.threema.app.ThreemaApplication;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.fs.DHSessionId;
 import ch.threema.domain.helpers.DummyUsers;
+import ch.threema.domain.protocol.csp.messages.BadMessageException;
 import ch.threema.domain.stores.DHSessionStoreException;
 
 public class SQLDHSessionStoreTest {
@@ -47,7 +49,12 @@ public class SQLDHSessionStoreTest {
 	@Before
 	public void setup() {
 		tempDbFileName = "threema-fs-test-" + System.currentTimeMillis() + ".db";
-		store = new SQLDHSessionStore(ApplicationProvider.getApplicationContext(), DATABASE_KEY, tempDbFileName);
+		store = new SQLDHSessionStore(
+			ApplicationProvider.getApplicationContext(),
+			DATABASE_KEY,
+			tempDbFileName,
+			ThreemaApplication.requireServiceManager().getUpdateSystemService()
+		);
 	}
 
 	@After
@@ -57,7 +64,7 @@ public class SQLDHSessionStoreTest {
 		ApplicationProvider.getApplicationContext().deleteDatabase(tempDbFileName);
 	}
 
-	public void createSessions() {
+	public void createSessions() throws BadMessageException {
 		// Alice is the initiator (= us)
 		this.initiatorDHSession = new DHSession(
 			DummyUsers.getContactForUser(DummyUsers.BOB),
@@ -67,6 +74,7 @@ public class SQLDHSessionStoreTest {
 		// Bob gets an init message from Alice with her ephemeral public key
 		this.responderDHSession = new DHSession(
 			this.initiatorDHSession.getId(),
+			DHSession.SUPPORTED_VERSION_RANGE,
 			this.initiatorDHSession.getMyEphemeralPublicKey(),
 			DummyUsers.getContactForUser(DummyUsers.ALICE),
 			DummyUsers.getIdentityStoreForUser(DummyUsers.BOB)
@@ -74,7 +82,7 @@ public class SQLDHSessionStoreTest {
 	}
 
 	@Test
-	public void testStoreInitiatorSession() throws DHSessionStoreException, DHSession.MissingEphemeralPrivateKeyException {
+	public void testStoreInitiatorSession() throws DHSessionStoreException, DHSession.MissingEphemeralPrivateKeyException, BadMessageException {
 		// Assume that we are Alice = the initiator, and Bob is the responder
 		createSessions();
 
@@ -97,6 +105,7 @@ public class SQLDHSessionStoreTest {
 
 		// Now Bob sends his ephemeral public key back to Alice
 		this.initiatorDHSession.processAccept(
+			DHSession.SUPPORTED_VERSION_RANGE,
 			this.responderDHSession.getMyEphemeralPublicKey(),
 			DummyUsers.getContactForUser(DummyUsers.BOB),
 			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
@@ -118,7 +127,7 @@ public class SQLDHSessionStoreTest {
 	}
 
 	@Test
-	public void testStoreResponderSession() throws DHSessionStoreException {
+	public void testStoreResponderSession() throws DHSessionStoreException, BadMessageException {
 		// Assume that we are Bob = the responder
 		createSessions();
 
@@ -143,7 +152,7 @@ public class SQLDHSessionStoreTest {
 	}
 
 	@Test
-	public void testDiscardRatchet() throws DHSessionStoreException {
+	public void testDiscardRatchet() throws DHSessionStoreException, BadMessageException {
 		// Assume that we are Bob = the responder
 		createSessions();
 
@@ -172,7 +181,7 @@ public class SQLDHSessionStoreTest {
 	}
 
 	@Test
-	public void testRaceCondition() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException {
+	public void testRaceCondition() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
 		// Repeat the test several times, as random session IDs are involved
 		for (int i = 0; i < NUM_RANDOM_RUNS; i++) {
 			if (i > 0) {
@@ -183,7 +192,7 @@ public class SQLDHSessionStoreTest {
 		}
 	}
 
-	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException {
+	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
 		createSessions();
 
 		// Alice stores the session that she initiated (still in 2DH mode)
@@ -198,6 +207,7 @@ public class SQLDHSessionStoreTest {
 		// Alice gets the Init for Bob's new session first and processes it
 		DHSession raceResponderDHSession = new DHSession(
 			raceInitiatorDHSession.getId(),
+			DHSession.SUPPORTED_VERSION_RANGE,
 			raceInitiatorDHSession.getMyEphemeralPublicKey(),
 			DummyUsers.getContactForUser(DummyUsers.BOB),
 			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
@@ -207,6 +217,7 @@ public class SQLDHSessionStoreTest {
 
 		// Alice then processes the Accept from Bob and stores the session
 		this.initiatorDHSession.processAccept(
+			DHSession.SUPPORTED_VERSION_RANGE,
 			this.responderDHSession.getMyEphemeralPublicKey(),
 			DummyUsers.getContactForUser(DummyUsers.BOB),
 			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)

+ 21 - 23
app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -23,16 +23,16 @@ package ch.threema.app.push;
 
 import android.content.Context;
 
-import com.google.firebase.iid.FirebaseInstanceId;
-
-import org.slf4j.Logger;
-
-import java.io.IOException;
-
 import androidx.annotation.NonNull;
 import androidx.work.Data;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
+
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.messaging.FirebaseMessaging;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.utils.PushUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
@@ -61,23 +61,25 @@ public class PushRegistrationWorker extends Worker {
 		final boolean withCallback = workerFlags.getBoolean(PushService.EXTRA_WITH_CALLBACK, false);
 		logger.debug("doWork FCM registration clear {} withCallback {}", clearToken, withCallback);
 
-		if (clearToken) {
-			String error = null;
-			try {
-				FirebaseInstanceId.getInstance().deleteInstanceId();
-				PushUtil.sendTokenToServer(appContext, "", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
-			} catch (IOException | ThreemaException e) {
-				logger.error("Exception", e);
-				error = e.getMessage();
-			}
+		FirebaseApp.initializeApp(appContext);
 
+		if (clearToken) {
+			String error = PushService.deleteToken(appContext);
 			if (withCallback) {
 				PushUtil.signalRegistrationFinished(error, true);
 			}
 		} else {
-			FirebaseInstanceId.getInstance().getInstanceId()
-				.addOnSuccessListener(instanceIdResult -> {
-					String token = instanceIdResult.getToken();
+			FirebaseMessaging.getInstance().getToken()
+				.addOnCompleteListener(task -> {
+					if (!task.isSuccessful()) {
+						logger.error("Unable to get token", task.getException());
+						if (withCallback) {
+							PushUtil.signalRegistrationFinished(task.getException() != null ? task.getException().getMessage() : "unknown", clearToken);
+						}
+						return;
+					}
+
+					String token = task.getResult();
 					logger.info("Received FCM registration token");
 					String error = null;
 					try {
@@ -89,11 +91,7 @@ public class PushRegistrationWorker extends Worker {
 					if (withCallback) {
 						PushUtil.signalRegistrationFinished(error, clearToken);
 					}
-				}).addOnFailureListener(e -> {
-				if (withCallback) {
-					PushUtil.signalRegistrationFinished(e.getMessage(), clearToken);
-				}
-			});
+				});
 		}
 		// required by the Worker interface but is not used for any error handling in the push registration process
 		return Result.success();

+ 27 - 6
app/src/google_services_based/java/ch/threema/app/push/PushService.java

@@ -24,19 +24,22 @@ package ch.threema.app.push;
 import android.content.Context;
 import android.text.format.DateUtils;
 
+import androidx.annotation.NonNull;
+
 import com.google.android.gms.common.ConnectionResult;
 import com.google.android.gms.common.GoogleApiAvailability;
-import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.android.gms.tasks.Tasks;
+import com.google.firebase.installations.FirebaseInstallations;
+import com.google.firebase.messaging.FirebaseMessaging;
 import com.google.firebase.messaging.FirebaseMessagingService;
 import com.google.firebase.messaging.RemoteMessage;
 
 import org.slf4j.Logger;
 
-import java.io.IOException;
 import java.util.Date;
 import java.util.Map;
+import java.util.concurrent.ExecutionException;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.ThreemaException;
@@ -59,13 +62,31 @@ public class PushService extends FirebaseMessagingService {
 		}
 	}
 
-	public static void deleteToken(Context context) {
+	public static String deleteToken(Context context) {
 		try {
-			FirebaseInstanceId.getInstance().deleteInstanceId();
+			FirebaseMessaging.getInstance().deleteToken();
+			Tasks.await(FirebaseInstallations.getInstance().delete());
 			PushUtil.sendTokenToServer(context,"", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
-		} catch (IOException | ThreemaException e) {
+		} catch (ThreemaException | ExecutionException | InterruptedException e) {
 			logger.warn("Could not delete FCM token", e);
+			return e.getMessage();
 		}
+		return null;
+	}
+
+	@Override
+	public void onDeletedMessages() {
+		logger.info("Too many messages stored on the Firebase server. Messages have been dropped.");
+	}
+
+	@Override
+	public void onMessageSent(@NonNull String msgId) {
+		logger.info("onMessageSent called for message id: {}", msgId);
+	}
+
+	@Override
+	public void onSendError(@NonNull String msgId, @NonNull Exception exception) {
+		logger.info("onSendError called for message id: {} exception: {}", msgId, exception);
 	}
 
 	@Override

+ 30 - 9
app/src/main/AndroidManifest.xml

@@ -153,7 +153,6 @@
 		</intent>
 	</queries>
 
-
 	<application
 		android:name=".ThreemaApplication"
 		android:allowBackup="false"
@@ -358,7 +357,7 @@
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:launchMode="singleTop"
 			android:parentActivityName=".activities.HomeActivity"
-			android:theme="@style/Theme.Threema.WithToolbarAndCheck">
+			android:theme="@style/Theme.Threema.WithToolbar">
 		</activity>
 		<activity
 			android:name=".activities.ExportIDActivity"
@@ -418,6 +417,7 @@
 		</activity>
 		<activity
 			android:name=".activities.ServerMessageActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"
 			android:label="server_message"/>
 		<activity
 			android:name=".activities.SendMediaActivity"
@@ -447,7 +447,7 @@
 			android:name=".activities.GroupDetailActivity"
 			android:theme="@style/Theme.Threema.TransparentStatusbar"
 			android:configChanges="uiMode"
-			android:windowSoftInputMode="stateHidden" />
+			android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
 		<activity
 			android:name=".qrscanner.activity.BaseQrScannerActivity"
 			android:theme="@style/Theme.Threema.Translucent"/>
@@ -554,7 +554,7 @@
 		<activity
 			android:name=".activities.ImagePaintActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-			android:theme="@style/Theme.Threema.LowProfile"
+			android:theme="@style/Theme.Threema.MediaViewer"
 			android:windowSoftInputMode="stateHidden|adjustResize"/>
 
 		<!-- Webclient activities -->
@@ -710,13 +710,13 @@
 			android:theme="@style/Theme.Threema.Translucent"/>
 		<activity
 			android:name=".voicemessage.VoiceRecorderActivity"
-			android:clearTaskOnLaunch="true"
+			android:theme="@style/Theme.Threema.Translucent"
 			android:configChanges="orientation|keyboardHidden|screenSize|uiMode"
-			android:theme="@style/Theme.Threema.VoiceRecorder"
+			android:clearTaskOnLaunch="true"
 			android:windowSoftInputMode="stateAlwaysHidden"/>
 		<activity
 			android:name=".locationpicker.LocationPickerActivity"
-			android:theme="@style/Theme.LocationPicker"
+			android:theme="@style/Theme.Threema.WithToolbar"
 			android:configChanges="orientation|keyboardHidden|screenSize|uiMode"
 			/>
 		<activity
@@ -797,7 +797,7 @@
 			android:windowSoftInputMode="adjustResize"/>
 		<activity
 			android:name=".locationpicker.LocationAutocompleteActivity"
-			android:theme="@style/Theme.LocationPicker"
+			android:theme="@style/Theme.Threema.WithToolbar"
 			android:windowSoftInputMode="adjustResize" />
 		<activity
 			android:name=".archive.ArchiveActivity"
@@ -807,7 +807,7 @@
 		<activity
 			android:name=".activities.MapActivity"
 			android:configChanges="orientation|keyboardHidden|screenSize|uiMode"
-			android:theme="@style/Theme.LocationPicker"
+			android:theme="@style/Theme.Threema.WithToolbar"
 			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.VIEW" />
@@ -836,9 +836,17 @@
 			android:windowSoftInputMode="stateAlwaysHidden" />
 		<activity
 			android:name=".activities.ThreemaPushNotificationInfoActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"
 			android:launchMode="singleTask"
 			android:taskAffinity=""
 			android:excludeFromRecents="true" />
+		<activity
+			android:name=".activities.PermissionRequestActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"
+			android:launchMode="singleTop"
+			android:exported="false"
+			android:excludeFromRecents="true">
+		</activity>
 
 		<!-- services -->
 		<service
@@ -931,6 +939,16 @@
 			android:exported="true"
 			tools:node="merge">
 		</service>
+		<service
+			android:name=".services.VoiceMessagePlayerService"
+			android:foregroundServiceType="mediaPlayback"
+			android:exported="true">
+			<intent-filter>
+				<action android:name="androidx.media3.session.MediaSessionService"/>
+				<action android:name="androidx.media3.session.MediaLibraryService"/>
+				<action android:name="android.media.browse.MediaBrowserService" />
+			</intent-filter>
+		</service>
 
 		<!-- broadcast receivers -->
 		<receiver
@@ -968,6 +986,9 @@
 			android:name=".receivers.ReSendMessagesBroadcastReceiver"
 			android:exported="false">
 		</receiver>
+		<receiver
+			android:name=".receivers.CancelResendMessagesBroadcastReceiver"
+			android:exported="false" />
 		<receiver android:name=".receivers.FetchMessagesBroadcastReceiver"/>
 		<receiver android:name=".voip.receivers.VoipMediaButtonReceiver"
 			android:exported="true">

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

@@ -89,6 +89,7 @@ public class NamedFileProvider extends FileProvider {
 		mStrategy = getPathStrategy(context, info.authority);
 	}
 
+	@NonNull
 	@Override
 	public Cursor query(@NonNull final Uri uri, String[] projection, final String selection,
 	                    final String[] selectionArgs, final String sortOrder) {
@@ -141,7 +142,7 @@ public class NamedFileProvider extends FileProvider {
 	 *            {@code <provider>} element in your app's manifest.
 	 * @param file A {@link File} pointing to the filename for which you want a
 	 * <code>content</code> {@link Uri}.
-	 * @param filename File name to be used for this file. Will be provided to consumers in the DISPLAY_NAME xolumn
+	 * @param filename File name to be used for this file. Will be provided to consumers in the DISPLAY_NAME column
 	 * @return A content URI for the file.
 	 * @throws IllegalArgumentException When the given {@link File} is outside
 	 * the paths supported by the provider.

+ 92 - 77
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -21,6 +21,11 @@
 
 package ch.threema.app;
 
+import static android.app.NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED;
+import static android.app.NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED;
+import static android.app.NotificationManager.EXTRA_BLOCKED_STATE;
+import static android.app.NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID;
+
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 import android.app.Activity;
@@ -36,6 +41,7 @@ import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.database.ContentObserver;
+import android.database.sqlite.SQLiteException;
 import android.net.ConnectivityManager;
 import android.os.Build;
 import android.os.Environment;
@@ -66,10 +72,10 @@ import androidx.work.WorkManager;
 
 import com.datatheorem.android.trustkit.TrustKit;
 import com.datatheorem.android.trustkit.reporting.BackgroundReporter;
+import com.google.android.material.color.DynamicColors;
+import com.google.android.material.color.DynamicColorsOptions;
 import com.mapbox.mapboxsdk.Mapbox;
 
-import net.sqlcipher.database.SQLiteException;
-
 import org.slf4j.Logger;
 
 import java.io.BufferedReader;
@@ -79,6 +85,7 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.net.Inet6Address;
 import java.net.InetSocketAddress;
+import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -91,7 +98,6 @@ import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
 import ch.threema.app.backuprestore.csv.BackupService;
-import ch.threema.app.exceptions.DatabaseMigrationFailedException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.grouplinks.IncomingGroupJoinRequestListener;
 import ch.threema.app.listeners.BallotVoteListener;
@@ -113,6 +119,7 @@ import ch.threema.app.push.PushService;
 import ch.threema.app.receivers.ConnectivityChangeReceiver;
 import ch.threema.app.receivers.PinningFailureReportBroadcastReceiver;
 import ch.threema.app.receivers.RestrictBackgroundChangedReceiver;
+import ch.threema.app.receivers.ShortcutAddedReceiver;
 import ch.threema.app.routines.OnFirstConnectRoutine;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.AppRestrictionService;
@@ -170,11 +177,13 @@ import ch.threema.domain.models.AppVersion;
 import ch.threema.domain.protocol.csp.connection.ConnectionState;
 import ch.threema.domain.protocol.csp.connection.ConnectionStateListener;
 import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
+import ch.threema.domain.stores.DHSessionStoreInterface;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.logging.backend.DebugLogFileBackend;
+import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
-import ch.threema.storage.NonceDatabaseBlobService;
+import ch.threema.storage.SQLDHSessionStore;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
@@ -190,13 +199,6 @@ import ch.threema.storage.models.ballot.LinkBallotModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import ch.threema.storage.models.group.IncomingGroupJoinRequestModel;
 
-import static android.app.NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED;
-import static android.app.NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED;
-import static android.app.NotificationManager.EXTRA_BLOCKED_STATE;
-import static android.app.NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID;
-import static ch.threema.app.services.PreferenceService.Theme_DARK;
-import static ch.threema.app.services.PreferenceService.Theme_LIGHT;
-
 public class ThreemaApplication extends MultiDexApplication implements DefaultLifecycleObserver {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ThreemaApplication");
 
@@ -230,6 +232,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String INTENT_DATA_PIN = "ppin";
 	public static final String INTENT_DATA_HIDE_RECENTS = "hiderec";
 	public static final String INTENT_ACTION_FORWARD = "ch.threema.app.intent.FORWARD";
+	public static final String INTENT_ACTION_SHORTCUT_ADDED = "ch.threema.app.intent.SHORTCUT_ADDED";
 
 	public static final String CONFIRM_TAG_CLOSE_BALLOT = "cb";
 
@@ -259,7 +262,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String PHONE_LINKED_PLACEHOLDER = "***";
 	public static final String EMAIL_LINKED_PLACEHOLDER = "***@***";
 
-	private static final String ACTIVITY_CONNECTION_TAG = "threemaApplication";
+	public static final String ACTIVITY_CONNECTION_TAG = "threemaApplication";
 	private static final long ACTIVITY_CONNECTION_LIFETIME = 60000;
 
 	public static final int MAX_BLOB_SIZE_MB = 100;
@@ -292,6 +295,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	private static Date lastLoggedIn;
 	private static boolean isDeviceIdle;
 	private static boolean ipv6 = false;
+	public static boolean isResumed = false;
+
 	private static HashMap<String, String> messageDrafts = new HashMap<>();
 	private static HashMap<String, String> quoteDrafts = new HashMap<>();
 
@@ -366,6 +371,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 		super.onCreate();
 
+		applyDynamicColorsIfEnabled();
+
 		// always log database migration
 		setupLogging(null);
 
@@ -444,15 +451,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 						logger.info("master key is missing or does not match. rename database files.");
 
-						File databaseFile = getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME);
-						if (databaseFile.exists()) {
-							File databaseBackup = new File(databaseFile.getPath() + ".backup");
-							if (!databaseFile.renameTo(databaseBackup)) {
-								FileUtil.deleteFileOrWarn(databaseFile, "threema database", logger);
-							}
-						}
-
-						databaseFile = getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
+						File databaseFile = getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
 						if (databaseFile.exists()) {
 							File databaseBackup = new File(databaseFile.getPath() + ".backup");
 							if (!databaseFile.renameTo(databaseBackup)) {
@@ -460,14 +459,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							}
 						}
 
-						databaseFile = getAppContext().getDatabasePath(NonceDatabaseBlobService.DATABASE_NAME);
+						databaseFile = getAppContext().getDatabasePath(DatabaseNonceStore.DATABASE_NAME_V4);
 						if (databaseFile.exists()) {
-							FileUtil.deleteFileOrWarn(databaseFile, "nonce database", logger);
+							FileUtil.deleteFileOrWarn(databaseFile, "nonce4 database", logger);
 						}
 
-						databaseFile = getAppContext().getDatabasePath(NonceDatabaseBlobService.DATABASE_NAME_V4);
+						databaseFile = getAppContext().getDatabasePath(SQLDHSessionStore.DATABASE_NAME);
 						if (databaseFile.exists()) {
-							FileUtil.deleteFileOrWarn(databaseFile, "nonce4 database", logger);
+							FileUtil.deleteFileOrWarn(databaseFile, "sql dh session database", logger);
 						}
 
 						//remove all settings!
@@ -490,6 +489,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 					if (!masterKey.isLocked()) {
 						reset();
+					} else {
+						setupDayNightMode();
 					}
 				} catch (IOException e) {
 					logger.error("IOException", e);
@@ -615,12 +616,32 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					}, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED));
 				}
 
+				// register a receiver for shortcuts that have been added to the launcher
+				registerReceiver(new ShortcutAddedReceiver(), new IntentFilter(INTENT_ACTION_SHORTCUT_ADDED));
+
 				// Start the Threema Push Service (if enabled in config)
 				ThreemaPushService.tryStart(logger, getAppContext());
 			}
 		}
 	}
 
+	private void applyDynamicColorsIfEnabled() {
+		if (DynamicColors.isDynamicColorAvailable()) {
+			SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
+			if (sharedPreferences != null && sharedPreferences.getBoolean("pref_dynamic_color", false)) {
+				DynamicColorsOptions dynamicColorsOptions = new DynamicColorsOptions.Builder().setPrecondition(new DynamicColors.Precondition() {
+					@Override
+					public boolean shouldApplyDynamicColors(@NonNull Activity activity, int theme) {
+						SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext());
+						return sharedPreferences != null && sharedPreferences.getBoolean("pref_dynamic_color", false);
+					}
+				}).build();
+
+				DynamicColors.applyToActivitiesIfAvailable(this, dynamicColorsOptions);
+			}
+		}
+	}
+
 	@Override
 	public void onStart(@NonNull LifecycleOwner owner) {
 		logger.info("*** Lifecycle: App now visible");
@@ -648,6 +669,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	@Override
 	public void onResume(@NonNull LifecycleOwner owner) {
 		logger.info("*** Lifecycle: App now resumed");
+		isResumed = true;
+
 		if (serviceManager != null && serviceManager.getLifetimeService() != null) {
 			serviceManager.getLifetimeService().acquireConnection(ACTIVITY_CONNECTION_TAG);
 		}
@@ -656,6 +679,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	@Override
 	public void onPause(@NonNull LifecycleOwner owner) {
 		logger.info("*** Lifecycle: App now paused");
+		isResumed = false;
+
 		if (serviceManager != null && serviceManager.getLifetimeService() != null) {
 			serviceManager.getLifetimeService().releaseConnectionLinger(ACTIVITY_CONNECTION_TAG, ACTIVITY_CONNECTION_LIFETIME);
 		}
@@ -831,16 +856,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			logger.error("Exception", e);
 		}
 
-		// set default theme depending on app type
-		if (prefs != null) {
-			if (TestUtil.empty(prefs.getString(getAppContext().getString(R.string.preferences__theme), null))) {
-				prefs.edit().putString(getAppContext().getString(R.string.preferences__theme), String.valueOf(
-					ConfigUtils.isWorkBuild() ?
-						Theme_DARK:
-						Theme_LIGHT)
-				).apply();
-			}
-		}
+		setupDayNightMode();
+	}
+
+	/**
+	 * Setup day / night theme for application depending on preferences
+	 */
+	private static void setupDayNightMode() {
+		AppCompatDelegate.setDefaultNightMode(ConfigUtils.getAppThemePrefs());
 	}
 
 	private static void setupLogging(PreferenceStore preferenceStore) {
@@ -888,35 +911,25 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			// Make database key from master key
 			String databaseKey = "x\"" + Utils.byteArrayToHexString(masterKey.getKey()) + "\"";
 
-			// Migrate database to v4 format if necessary
-			int sqlcipherVersion = 4;
-			try {
-				DatabaseServiceNew.tryMigrateToV4(getAppContext(), databaseKey);
-			} catch (DatabaseMigrationFailedException m) {
-				logger.error("Exception", m);
-				Toast.makeText(getAppContext(), "Database migration failed. Please free some space on your internal memory.", Toast.LENGTH_LONG).show();
-				sqlcipherVersion = 3;
-			}
-
 			UpdateSystemService updateSystemService = new UpdateSystemServiceImpl();
 
-			DatabaseServiceNew databaseServiceNew = new DatabaseServiceNew(getAppContext(), databaseKey, updateSystemService, sqlcipherVersion);
+			System.loadLibrary("sqlcipher");
+			DatabaseServiceNew databaseServiceNew = new DatabaseServiceNew(getAppContext(), databaseKey, updateSystemService);
 			databaseServiceNew.executeNull();
 
-			// Migrate nonce database to unencrypted DB
-			int nonceSqlcipherVersion = 4;
-
-			// do not attempt a nonce DB migration if the main DB is still on version 3
-			if (sqlcipherVersion == 4) {
-				try {
-					NonceDatabaseBlobService.tryMigrateToV4(getAppContext(), databaseKey);
-				} catch (DatabaseMigrationFailedException m) {
-					logger.error("Exception", m);
-					Toast.makeText(getAppContext(), "Nonce database migration failed. Please free some space on your internal memory.", Toast.LENGTH_LONG).show();
-					nonceSqlcipherVersion = 3;
+			// We create the DH session store here and execute a null operation on it to prevent
+			// the app from being launched when the database is downgraded.
+			DHSessionStoreInterface dhSessionStore = new SQLDHSessionStore(context, masterKey.getKey(), updateSystemService);
+			try {
+				dhSessionStore.executeNull();
+			} catch (Exception e) {
+				logger.error("Could not execute a statement on the database", e);
+				// The database file seems to be corrupt, therefore we delete the file
+				File databaseFile = getAppContext().getDatabasePath(SQLDHSessionStore.DATABASE_NAME);
+				if (databaseFile.exists()) {
+					FileUtil.deleteFileOrWarn(databaseFile, "sql dh session database", logger);
 				}
-			} else {
-				nonceSqlcipherVersion = 3;
+				dhSessionStore = new SQLDHSessionStore(context, masterKey.getKey(), updateSystemService);
 			}
 
 			logger.info("*** App launched");
@@ -935,7 +948,9 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							}
 
 							if (exitInfo.getTimestamp() > timestampOfLastLog) {
-								logger.info(String.format(Locale.US, "*** App last exited at %s with reason: %d, description: %s", DateUtils.formatDateTime(getAppContext(), exitInfo.getTimestamp(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME), exitInfo.getReason(), exitInfo.getDescription()));
+								SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US);
+								logger.info(String.format(Locale.US, "*** App last exited at %s with reason: %d, description: %s", simpleDateFormat.format(exitInfo.getTimestamp()),
+									exitInfo.getReason(), exitInfo.getDescription()));
 								if (exitInfo.getReason() == ApplicationExitInfo.REASON_ANR) {
 									try {
 										InputStream traceInputStream = exitInfo.getTraceInputStream();
@@ -968,12 +983,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			IdentityStore identityStore = new IdentityStore(preferenceStore);
 
-			NonceDatabaseBlobService nonceDatabaseBlobService = new NonceDatabaseBlobService(getAppContext(), masterKey, nonceSqlcipherVersion, identityStore);
-			logger.info("Nonce count: " + nonceDatabaseBlobService.getCount());
+			DatabaseNonceStore databaseNonceStore = new DatabaseNonceStore(getAppContext(), identityStore);
+			databaseNonceStore.executeNull();
+			logger.info("Nonce count: {}", databaseNonceStore.getCount());
 
 			final ThreemaConnection connection = new ThreemaConnection(
 					identityStore,
-					new NonceFactory(nonceDatabaseBlobService),
+					new NonceFactory(databaseNonceStore),
 					null,
 					getIPv6());
 
@@ -1000,6 +1016,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			serviceManager = new ServiceManager(
 					connection,
 					databaseServiceNew,
+					dhSessionStore,
 					identityStore,
 					preferenceStore,
 					masterKey,
@@ -1007,7 +1024,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			);
 
 			connection.setServerAddressProvider(serviceManager.getServerAddressProviderService().getServerAddressProvider());
-
 			connection.setDeviceCookieManager(new DeviceCookieManagerImpl(serviceManager));
 
 			// get application restrictions
@@ -1070,7 +1086,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			SessionWakeUpServiceImpl.getInstance().processPendingWakeupsAsync();
 
 			// start threema safe scheduler
-			serviceManager.getThreemaSafeService().scheduleUpload();
+			serviceManager.getThreemaSafeService().schedulePeriodicUpload();
 
 			new Thread(() -> {
 				// schedule work synchronization
@@ -1088,10 +1104,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			// setup locale override
 			ConfigUtils.setLocaleOverride(getAppContext(), serviceManager.getPreferenceService());
-		} catch (MasterKeyLockedException e) {
-			logger.error("Exception", e);
-		} catch (SQLiteException e) {
-			logger.error("Exception", e);
+		} catch (MasterKeyLockedException | SQLiteException e) {
+			logger.error("Exception opening database", e);
 		} catch (ThreemaException e) {
 			// no identity
 			logger.info("No valid identity.");
@@ -1157,7 +1171,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					.setInitialDelay(1000, TimeUnit.MILLISECONDS)
 					.build();
 
-				workManager.enqueueUniquePeriodicWork(WORKER_IDENTITY_STATES_PERIODIC_NAME, ExistingPeriodicWorkPolicy.REPLACE, workRequest);
+				workManager.enqueueUniquePeriodicWork(WORKER_IDENTITY_STATES_PERIODIC_NAME, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, workRequest);
 				return true;
 			}
 		} catch (IllegalStateException e) {
@@ -1177,7 +1191,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		try {
 			WorkManager workManager = WorkManager.getInstance(context);
 			ExistingPeriodicWorkPolicy policy = WorkManagerUtil.shouldScheduleNewWorkManagerInstance(workManager, WORKER_PERIODIC_WORK_SYNC, schedulePeriodMs) ?
-				ExistingPeriodicWorkPolicy.REPLACE :
+				ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE :
 				ExistingPeriodicWorkPolicy.KEEP;
 			logger.info("{}: {} existing periodic work", WORKER_PERIODIC_WORK_SYNC, policy);
 			PeriodicWorkRequest workRequest = WorkSyncWorker.Companion.buildPeriodicWorkRequest(schedulePeriodMs);
@@ -1217,7 +1231,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					.setInitialDelay(3, TimeUnit.MINUTES)
 					.build();
 
-				workManager.enqueueUniquePeriodicWork(WORKER_SHARE_TARGET_UPDATE, ExistingPeriodicWorkPolicy.REPLACE, workRequest);
+				workManager.enqueueUniquePeriodicWork(WORKER_SHARE_TARGET_UPDATE, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, workRequest);
 			} else {
 				logger.debug("Reusing existing worker");
 			}
@@ -1539,7 +1553,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			@Override
 			public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {
-				//ingore
+				// Ignore
+			}
+
+			@Override
+			public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
+				// Ignore
 			}
 
 			private void showConversationNotification(AbstractMessageModel newMessage, boolean updateExisting) {
@@ -2149,10 +2168,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		return appVersion;
 	}
 
-	public static int getFeatureLevel() {
-		return 3;
-	}
-
 	public static Context getAppContext() {
 		return ThreemaApplication.context;
 	}

+ 0 - 1
app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java

@@ -114,7 +114,6 @@ public class LocationMessageSendAction extends SendAction {
 					sendSingleMessage(resolvedReceivers[receiverIndex], location, poiName, this);
 				} else {
 					actionHandler.onCompleted();
-					messageService.sendProfilePicture(resolvedReceivers);
 				}
 			}
 		});

+ 0 - 1
app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java

@@ -105,7 +105,6 @@ public class TextMessageSendAction extends SendAction {
 				}
 			}
 			actionHandler.onCompleted();
-			messageService.sendProfilePicture(resolvedReceivers);
 			return true;
 		}
 		return false;

+ 4 - 5
app/src/main/java/ch/threema/app/activities/AboutActivity.java

@@ -29,11 +29,11 @@ import android.widget.Toast;
 
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
-import ch.threema.app.preference.SettingsActivity;
 import ch.threema.app.utils.AnimationUtil;
 
 public class AboutActivity extends ThreemaToolbarActivity {
 
+	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
@@ -55,6 +55,7 @@ public class AboutActivity extends ThreemaToolbarActivity {
 		}
 	}
 
+	@Override
 	public int getLayoutResource() {
 		return R.layout.activity_about;
 	}
@@ -62,10 +63,8 @@ public class AboutActivity extends ThreemaToolbarActivity {
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				finish();
-				break;
+		if (item.getItemId() == android.R.id.home) {
+			finish();
 		}
 		return false;
 	}

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

@@ -106,10 +106,6 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			return;
 		}
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			setTheme(R.style.Theme_Threema_Translucent_Dark);
-		}
-
 		super.onCreate(savedInstanceState);
 
 		supportRequestWindowFeature(Window.FEATURE_NO_TITLE);

+ 24 - 0
app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java

@@ -24,6 +24,8 @@ package ch.threema.app.activities;
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.MenuItem;
+import android.view.View;
+import android.widget.TextView;
 
 import com.google.android.material.tabs.TabLayout;
 
@@ -35,10 +37,13 @@ import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.R;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.BackupDataFragment;
 import ch.threema.app.services.DeadlineListService;
+import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.threemasafe.BackupThreemaSafeFragment;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
+import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.HiddenChatUtil;
@@ -73,6 +78,13 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
 			return;
 		}
 
+		if (ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid()) {
+			logger.debug("Not licensed.");
+			this.finish();
+			System.exit(0);
+			return;
+		}
+
 		ActionBar actionBar = getSupportActionBar();
 		if (actionBar != null) {
 			actionBar.setDisplayHomeAsUpEnabled(true);
@@ -84,6 +96,18 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
 		viewPager.setAdapter(new BackupAdminPagerAdapter(getSupportFragmentManager()));
 		tabLayout.setupWithViewPager(viewPager);
 
+		if (preferenceService.getBackupWarningDismissedTime() == 0L) {
+			((TextView) findViewById(R.id.notice_text)).setText(R.string.backup_explain_text);
+			final View noticeLayout = findViewById(R.id.notice_layout);
+			noticeLayout.setVisibility(View.VISIBLE);
+			findViewById(R.id.close_button).setOnClickListener(v -> {
+				preferenceService.setBackupWarningDismissedTime(System.currentTimeMillis());
+				AnimationUtil.collapse(noticeLayout);
+			});
+		} else {
+			findViewById(R.id.notice_layout).setVisibility(View.GONE);
+		}
+
 		// recover lock state after rotation
 		if (savedInstanceState != null) {
 			isUnlocked = savedInstanceState.getBoolean(BUNDLE_IS_UNLOCKED, false);

+ 0 - 5
app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java

@@ -43,7 +43,6 @@ import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.SystemScreenLockService;
 import ch.threema.app.utils.BiometricUtil;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -64,10 +63,6 @@ public class BiometricLockActivity extends ThreemaAppCompatActivity {
 	public void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			setTheme(R.style.Theme_Threema_BiometricUnlock_Dark);
-		}
-
 		super.onCreate(savedInstanceState);
 
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();

+ 7 - 23
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java

@@ -23,22 +23,20 @@ package ch.threema.app.activities;
 
 import android.content.Intent;
 import android.content.res.Configuration;
-import android.media.AudioManager;
 import android.os.Bundle;
 import android.view.WindowManager;
 import android.widget.FrameLayout;
 
-import org.slf4j.Logger;
-
 import androidx.annotation.NonNull;
 import androidx.fragment.app.FragmentManager;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.fragments.MessageSectionFragment;
-import ch.threema.app.listeners.MessagePlayerListener;
-import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.preference.SettingsActivity;
 import ch.threema.app.services.DeadlineListService;
@@ -47,7 +45,6 @@ import ch.threema.app.utils.HiddenChatUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKey;
-import ch.threema.storage.models.AbstractMessageModel;
 
 public class ComposeMessageActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ComposeMessageActivity");
@@ -64,16 +61,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	private final String COMPOSE_FRAGMENT_TAG = "compose_message_fragment";
 	private final String MESSAGES_FRAGMENT_TAG = "message_section_fragment";
 
-	private final MessagePlayerListener messagePlayerListener = new MessagePlayerListener() {
-		@Override
-		public void onAudioStreamChanged(int newStreamType) {
-			setVolumeControlStream(newStreamType == AudioManager.STREAM_VOICE_CALL ? AudioManager.STREAM_VOICE_CALL : AudioManager.USE_DEFAULT_STREAM_TYPE);
-		}
-
-		@Override
-		public void onAudioPlayEnded(AbstractMessageModel messageModel) { }
-	};
-
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
@@ -82,8 +69,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 		this.currentIntent = getIntent();
 
-		ListenerManager.messagePlayerListener.add(this.messagePlayerListener);
-
 		//check master key
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 
@@ -164,7 +149,9 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 		if (composeMessageFragment != null) {
 			if (!composeMessageFragment.onBackPressed()) {
 				finish();
-				overridePendingTransition(0, 0);
+				if (ConfigUtils.isTabletLayout()) {
+					overridePendingTransition(0, 0);
+				}
 			}
 			return;
 		}
@@ -174,9 +161,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	@Override
 	public void onDestroy() {
 		logger.debug("onDestroy");
-
-		ListenerManager.messagePlayerListener.remove(this.messagePlayerListener);
-
 		super.onDestroy();
 	}
 
@@ -255,7 +239,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	}
 
 	private boolean checkHiddenChatLock(Intent intent, int requestCode) {
-		MessageReceiver messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(getApplicationContext(), intent);
+		MessageReceiver<?> messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(getApplicationContext(), intent);
 
 		if (messageReceiver != null) {
 			if (serviceManager != null) {

+ 53 - 70
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -74,7 +74,6 @@ import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
-import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DeadlineListService;
@@ -84,7 +83,6 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.QRCodeService;
 import ch.threema.app.services.QRCodeServiceImpl;
-import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.ResumePauseHandler;
 import ch.threema.app.ui.TooltipPopup;
@@ -147,13 +145,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private List<GroupModel> groupList;
 	private boolean isDisabledProfilePicReleaseSettings = false;
 	private View workIcon;
-	private final ActivityResultLauncher<String> readPhoneStatePermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
-		if (!isGranted && !ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_PHONE_STATE)) {
-			ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.read_phone_state_short_message);
-		} else {
-			VoipUtil.initiateCall(this, contact, false, null, null);
-		}
-	});
 
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
@@ -380,6 +371,12 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				}
 			}
 		}
+
+		logger.info(
+			"DH session state with contact {}: {}",
+			contact.getIdentity(),
+			contactService.getForwardSecurityState(contact)
+		);
 	}
 
 	private void onCreateLocal() {
@@ -412,7 +409,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			location[0] += workIcon.getWidth() / 2;
 			location[1] += workIcon.getHeight();
 
-			final TooltipPopup workTooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_work_hint_shown, R.layout.popup_tooltip_top_left_work, this, new Intent(this, WorkExplainActivity.class));
+			final TooltipPopup workTooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_work_hint_shown, this, new Intent(this, WorkExplainActivity.class), R.drawable.ic_badge_work_24dp);
 			workTooltipPopup.show(this, workIcon, getString(R.string.tooltip_work_hint), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_LEFT, location, 0);
 
 			final AppBarLayout appBarLayout = findViewById(R.id.appbar);
@@ -627,71 +624,58 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case R.id.action_send_message:
-				if (identity != null) {
-					Intent intent = new Intent(this, ComposeMessageActivity.class);
-					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-					intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
-					intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
-					startActivity(intent);
-					finish();
-				}
-				break;
-			case R.id.action_remove_contact:
-				removeContact();
-				break;
-			case R.id.action_scan_id:
-				if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
-					scanQR();
-				}
-				break;
-			case R.id.menu_threema_call:
-				VoipUtil.initiateCall(this, contact, false, null, readPhoneStatePermissionLauncher);
-				break;
-			case R.id.action_block_contact:
-				if (this.blackListIdentityService != null && this.blackListIdentityService.has(this.contact.getIdentity())) {
-					blockContact();
-				} else {
-					GenericAlertDialog.newInstance(R.string.block_contact, R.string.really_block_contact, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_CONFIRM_BLOCK);
-				}
-				break;
-			case R.id.action_share_contact:
-				ShareUtil.shareContact(this, contact);
-				break;
-			case R.id.menu_gallery:
-				if (!hiddenChatsListService.has(contactService.getUniqueIdString(contact))) {
-					Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
-					mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
-					startActivity(mediaGalleryIntent);
-				}
-				break;
-			case R.id.action_add_profilepic_recipient:
-				if (!profilePicRecipientsService.has(contact.getIdentity())) {
-					profilePicRecipientsService.add(contact.getIdentity());
-				} else {
-					profilePicRecipientsService.remove(contact.getIdentity());
-				}
-				updateProfilepicMenu();
-				break;
-			case R.id.action_send_profilepic:
-				sendProfilePic();
-				break;
-			default:
-				finishUp();
+		final int id = item.getItemId();
+		if (id == R.id.action_send_message){
+			if (identity != null) {
+				Intent intent = new Intent(this, ComposeMessageActivity.class);
+				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+				intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
+				intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
+				startActivity(intent);
+				finish();
+			}
+		} else if (id == R.id.action_remove_contact) {
+			removeContact();
+		} else if (id == R.id.action_scan_id) {
+			if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
+				scanQR();
+			}
+		} else if (id == R.id.menu_threema_call) {
+			VoipUtil.initiateCall(this, contact, false, null);
+		} else if (id == R.id.action_block_contact) {
+			if (this.blackListIdentityService != null && this.blackListIdentityService.has(this.contact.getIdentity())) {
+				blockContact();
+			} else {
+				GenericAlertDialog.newInstance(R.string.block_contact, R.string.really_block_contact, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_CONFIRM_BLOCK);
+			}
+		} else if (id == R.id.action_share_contact) {
+			ShareUtil.shareContact(this, contact);
+		} else if (id == R.id.menu_gallery) {
+			if (!hiddenChatsListService.has(contactService.getUniqueIdString(contact))) {
+				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
+				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
+				startActivity(mediaGalleryIntent);
+			}
+		} else if (id == R.id.action_add_profilepic_recipient) {
+			if (!profilePicRecipientsService.has(contact.getIdentity())) {
+				profilePicRecipientsService.add(contact.getIdentity());
+			} else {
+				profilePicRecipientsService.remove(contact.getIdentity());
+			}
+			updateProfilepicMenu();
+		} else if (id == R.id.action_send_profilepic) {
+			sendProfilePic();
+		} else {
+			finishUp();
 		}
 		return super.onOptionsItemSelected(item);
 	}
 
 	private void sendProfilePic() {
-		contact.setProfilePicSentDate(new Date(0));
-		contactService.save(contact);
-
 		new AsyncTask<Void, Void, Boolean>() {
 			@Override
 			protected Boolean doInBackground(Void... params) {
-				MessageReceiver messageReceiver = contactService.createReceiver(contact);
-				return messageService.sendProfilePicture(new MessageReceiver[]{messageReceiver});
+				return messageService.sendProfilePicture(contact);
 			}
 
 			@Override
@@ -731,10 +715,10 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			switch (preferenceService.getProfilePicRelease()) {
 				case PreferenceService.PROFILEPIC_RELEASE_EVERYONE:
 					this.profilePicItem.setVisible(false);
-					this.profilePicSendItem.setVisible(ContactUtil.canReceiveProfilePics(contact));
+					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrChannelContact(contact));
 					break;
 				case PreferenceService.PROFILEPIC_RELEASE_SOME:
-					if (ContactUtil.canReceiveProfilePics(contact)) {
+					if (!ContactUtil.isEchoEchoOrChannelContact(contact)) {
 						if (profilePicRecipientsService != null && profilePicRecipientsService.has(contact.getIdentity())) {
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
 							profilePicItem.setIcon(R.drawable.ic_person_remove_outline);
@@ -962,6 +946,5 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			return;
 		}
 		navigateUpTo(new Intent(this, HomeActivity.class));
-		overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
 	}
 }

+ 120 - 78
app/src/main/java/ch/threema/app/activities/CropImageActivity.java

@@ -22,15 +22,28 @@
 package ch.threema.app.activities;
 
 import android.content.Intent;
+import android.content.SharedPreferences;
 import android.graphics.Bitmap;
+import android.graphics.Rect;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 import android.provider.MediaStore;
+import android.view.MenuItem;
 import android.view.View;
+import android.view.ViewTreeObserver;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.core.view.ViewCompat;
+import androidx.preference.PreferenceManager;
 
 import com.canhub.cropper.CropImageView;
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.color.DynamicColors;
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+
+import java.util.Collections;
 
-import androidx.appcompat.widget.Toolbar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.utils.BitmapUtil;
@@ -44,106 +57,104 @@ public class CropImageActivity extends ThreemaToolbarActivity {
 	public static final String EXTRA_MAX_Y = "my";
 	public static final String EXTRA_OVAL = "oval";
 	public static final String FORCE_DARK_THEME = "darkTheme";
-
+	public static final String EXTRA_ADDITIONAL_ORIENTATION = "additional_rotation";
+	public static final String EXTRA_ADDITIONAL_FLIP = "additional_flip";
 	public static final int REQUEST_CROP = 7732;
 
-	private int aspectX;
-	private int aspectY;
-	private int orientation, flip;
-
-	// Output image size
-	private int maxX;
-	private int maxY;
-
-	private boolean oval = false;
-
-	private Uri sourceUri;
-	private Uri saveUri;
-
-	private boolean isSaving;
-
+	private int aspectX, aspectY, orientation, flip, additionalOrientation, additionalFlip, maxX, maxY;
+	private boolean oval = false, isSaving;
+	private Uri sourceUri, saveUri;
 	private CropImageView imageView;
+	private View contentView;
 
 	@Override
-	public void onCreate(Bundle icicle) {
+	public void onCreate(Bundle savedInstanceState) {
 		Intent intent = getIntent();
 		Bundle extras = intent.getExtras();
 
 		if (extras != null && extras.getBoolean(FORCE_DARK_THEME, false)) {
-			ConfigUtils.configureActivityTheme(this, ConfigUtils.THEME_DARK);
+			setTheme(R.style.Theme_Threema_MediaViewer);
+			SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
+			if (sharedPreferences != null && sharedPreferences.getBoolean("pref_dynamic_color", false)) {
+				DynamicColors.applyToActivityIfAvailable(this);
+			}
 		} else {
-			ConfigUtils.configureActivityTheme(this);
+			ConfigUtils.configureSystemBars(this);
 		}
 
-		super.onCreate(icicle);
+		super.onCreate(savedInstanceState);
 
-		Toolbar toolbar = findViewById(R.id.crop_toolbar);
+		MaterialToolbar toolbar = findViewById(R.id.crop_toolbar);
 		setSupportActionBar(toolbar);
+		ActionBar actionBar = getSupportActionBar();
+		actionBar.setDisplayHomeAsUpEnabled(true);
 
-		View cancelActionView = findViewById(R.id.action_cancel);
-		cancelActionView.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				setResult(RESULT_CANCELED);
-				finish();
-			}
-		});
-		View doneActionView = findViewById(R.id.action_done);
-		doneActionView.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				onSaveClicked();
-			}
-		});
+		ExtendedFloatingActionButton doneActionView = findViewById(R.id.floating);
+		doneActionView.setOnClickListener(v -> onSaveClicked());
 
 		setupFromIntent();
 
 		imageView = findViewById(R.id.crop_image);
-		imageView.setOnSetImageUriCompleteListener(new CropImageView.OnSetImageUriCompleteListener() {
-			@Override
-			public void onSetImageUriComplete(CropImageView view, Uri uri, Exception error) {
-				if (error == null && uri != null) {
-					BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(CropImageActivity.this, uri);
-					int exifFlip = exifOrientation.getFlip();
-					int exifRotation = 0;
-
-					// Bug Workaround: CropImageView accounts for exif rotation but NOT if there's also a flip
-					if ((exifFlip & BitmapUtil.FLIP_HORIZONTAL) == BitmapUtil.FLIP_HORIZONTAL) {
-						view.flipImageHorizontally();
-						exifRotation = (int) exifOrientation.getRotation();
-					}
-					if ((exifFlip & BitmapUtil.FLIP_VERTICAL) == BitmapUtil.FLIP_VERTICAL) {
-						view.flipImageVertically();
-						exifRotation = (int) exifOrientation.getRotation();
-					}
-					if (exifRotation != 0) {
-						view.rotateImage(exifRotation);
-					}
-
-					// non-exif
-					if ((flip & BitmapUtil.FLIP_HORIZONTAL) == BitmapUtil.FLIP_HORIZONTAL) {
-						view.flipImageHorizontally();
-					}
-					if ((flip & BitmapUtil.FLIP_VERTICAL) == BitmapUtil.FLIP_VERTICAL) {
-						view.flipImageVertically();
-					}
-					if (orientation != 0) {
-						view.rotateImage(orientation);
-					}
-
-					if (aspectX != 0 && aspectY != 0) {
-						view.setAspectRatio(aspectX, aspectY);
-						view.setFixedAspectRatio(true);
-					}
+		imageView.setOnSetImageUriCompleteListener((view, uri, error) -> {
+			if (error == null && uri != null) {
+				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(CropImageActivity.this, uri);
+				int exifFlip = exifOrientation.getFlip();
+				int exifRotation = 0;
+
+				// Bug Workaround: CropImageView accounts for exif rotation but NOT if there's also a flip
+				if ((exifFlip & BitmapUtil.FLIP_HORIZONTAL) == BitmapUtil.FLIP_HORIZONTAL) {
+					view.flipImageHorizontally();
+					exifRotation = (int) exifOrientation.getRotation();
+				}
+				if ((exifFlip & BitmapUtil.FLIP_VERTICAL) == BitmapUtil.FLIP_VERTICAL) {
+					view.flipImageVertically();
+					exifRotation = (int) exifOrientation.getRotation();
+				}
+				if (exifRotation != 0) {
+					view.rotateImage(exifRotation);
+				}
+
+				// non-exif
+				if ((flip & BitmapUtil.FLIP_HORIZONTAL) == BitmapUtil.FLIP_HORIZONTAL) {
+					view.flipImageHorizontally();
+				}
+				if ((flip & BitmapUtil.FLIP_VERTICAL) == BitmapUtil.FLIP_VERTICAL) {
+					view.flipImageVertically();
+				}
+				if (orientation != 0) {
+					view.rotateImage(orientation);
+				}
+
+				// Additional flip and rotation
+				if ((additionalFlip & BitmapUtil.FLIP_HORIZONTAL) == BitmapUtil.FLIP_HORIZONTAL) {
+					view.flipImageHorizontally();
+				}
+				if ((additionalFlip & BitmapUtil.FLIP_VERTICAL) == BitmapUtil.FLIP_VERTICAL) {
+					view.flipImageVertically();
+				}
+				if (additionalOrientation != 0) {
+					view.rotateImage(additionalOrientation);
+				}
+
+				if (aspectX != 0 && aspectY != 0) {
+					view.setAspectRatio(aspectX, aspectY);
+					view.setFixedAspectRatio(true);
 				}
 			}
 		});
-		imageView.setImageUriAsync(sourceUri);
-		imageView.setCropShape(oval ? CropImageView.CropShape.OVAL : CropImageView.CropShape.RECTANGLE);
-		imageView.setOnCropImageCompleteListener(new CropImageView.OnCropImageCompleteListener() {
+		if (savedInstanceState == null) {
+			imageView.setCropShape(oval ? CropImageView.CropShape.OVAL : CropImageView.CropShape.RECTANGLE);
+			imageView.setImageUriAsync(sourceUri);
+		}
+		imageView.setOnCropImageCompleteListener((view, result) -> cropCompleted());
+
+		contentView = findViewById(android.R.id.content);
+		ViewTreeObserver treeObserver = contentView.getViewTreeObserver();
+		treeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
 			@Override
-			public void onCropImageComplete(CropImageView view, CropImageView.CropResult result) {
-				cropCompleted();
+			public void onGlobalLayout() {
+				contentView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+				excludeGestures();
 			}
 		});
 	}
@@ -153,6 +164,15 @@ public class CropImageActivity extends ThreemaToolbarActivity {
 		return R.layout.activity_crop;
 	}
 
+	@Override
+	public boolean onOptionsItemSelected(MenuItem item) {
+		if (item.getItemId() == android.R.id.home) {
+			setResult(RESULT_CANCELED);
+			finish();
+			return true;
+		}
+		return super.onOptionsItemSelected(item);
+	}
 	private void cropCompleted() {
 		setResult(RESULT_OK, new Intent().putExtra(MediaStore.EXTRA_OUTPUT, saveUri));
 		finish();
@@ -171,6 +191,8 @@ public class CropImageActivity extends ThreemaToolbarActivity {
 			saveUri = extras.getParcelable(MediaStore.EXTRA_OUTPUT);
 			orientation = extras.getInt(ThreemaApplication.EXTRA_ORIENTATION, 0);
 			flip = extras.getInt(ThreemaApplication.EXTRA_FLIP, BitmapUtil.FLIP_NONE);
+			additionalOrientation = extras.getInt(EXTRA_ADDITIONAL_ORIENTATION, 0);
+			additionalFlip = extras.getInt(EXTRA_ADDITIONAL_FLIP, BitmapUtil.FLIP_NONE);
 		}
 
 		sourceUri = intent.getData();
@@ -188,6 +210,26 @@ public class CropImageActivity extends ThreemaToolbarActivity {
 			imageView.croppedImageAsync(Bitmap.CompressFormat.PNG, 100, 0, 0, CropImageView.RequestSizeOptions.NONE, saveUri);
 		}
 	}
+
+	private void excludeGestures() {
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+			return;
+		}
+
+		int maxHeight = getResources().getDimensionPixelSize(R.dimen.gesture_exclusion_max_height);
+		Rect drawingRect = new Rect();
+		imageView.getDrawingRect(drawingRect);
+
+		int y = 0;
+		int realHeight = drawingRect.height();
+		if (realHeight > maxHeight) {
+			y = (realHeight - maxHeight) / 2;
+			realHeight = maxHeight;
+		}
+
+		Rect exclusionRect = new Rect(0, y, getResources().getDimensionPixelSize(R.dimen.gesture_exclusion_border_width), y + realHeight);
+		ViewCompat.setSystemGestureExclusionRects(imageView, Collections.singletonList(exclusionRect));
+	}
 }
 
 

+ 97 - 83
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -21,12 +21,13 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
+
+import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
 import android.content.Intent;
-import android.content.res.ColorStateList;
 import android.content.res.Configuration;
 import android.net.Uri;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.SystemClock;
@@ -37,30 +38,31 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import com.google.android.material.chip.Chip;
-import com.google.android.material.chip.ChipGroup;
-import com.google.android.material.progressindicator.LinearProgressIndicator;
-
-import org.slf4j.Logger;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
-import java.util.List;
-
 import androidx.annotation.ColorInt;
 import androidx.annotation.IntDef;
+import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.SearchView;
-import androidx.appcompat.widget.Toolbar;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.Observer;
 import androidx.paging.LivePagedListBuilder;
 import androidx.paging.PagedList;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+import com.google.android.material.search.SearchBar;
+
+import org.slf4j.Logger;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
 import ch.threema.app.R;
 import ch.threema.app.adapters.DirectoryAdapter;
 import ch.threema.app.asynctasks.AddContactAsyncTask;
@@ -79,8 +81,6 @@ import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
 import ch.threema.domain.protocol.api.work.WorkOrganization;
 
-import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
-
 public class DirectoryActivity extends ThreemaToolbarActivity implements ThreemaSearchView.OnQueryTextListener, MultiChoiceSelectorDialog.SelectorDialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("DirectoryActivity");
 
@@ -107,6 +107,8 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 	private Menu menu;
 	private MenuItem searchMenuItem;
 	private LinearProgressIndicator progressIndicator;
+	private SearchBar searchBar;
+	private ThreemaSearchView searchView;
 
 	private List<WorkDirectoryCategory> categoryList = new ArrayList<>();
 	private final List<WorkDirectoryCategory> checkedCategories = new ArrayList<>();
@@ -126,34 +128,18 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		}
 	};
 
-	final SearchView.OnQueryTextListener queryTextListener = new SearchView.OnQueryTextListener() {
-		@Override
-		public boolean onQueryTextChange(String newText) {
-			queryText = newText;
-			queryHandler.removeCallbacks(queryTask);
-			queryHandler.postDelayed(queryTask, QUERY_TIMEOUT);
-			return true;
-		}
-
-		@Override
-		public boolean onQueryTextSubmit(String query) {
-			return true;
-		}
-	};
-
 	@Override
 	public boolean onQueryTextSubmit(String query) {
-		// Do something
+		// Do nothing
 		return true;
 	}
 
-	@SuppressLint("StaticFieldLeak")
 	@Override
 	public boolean onQueryTextChange(String newText) {
+		showResultsLayout();
 		queryText = newText;
 		queryHandler.removeCallbacks(queryTask);
 		queryHandler.postDelayed(queryTask, QUERY_TIMEOUT);
-
 		return true;
 	}
 
@@ -166,16 +152,28 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 	protected boolean initActivity(Bundle savedInstanceState) {
 		if (!super.initActivity(savedInstanceState)) {
 			return false;
-		};
+		}
 
 		ActionBar actionBar = getSupportActionBar();
 		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			Toolbar toolbar = getToolbar();
-			if (toolbar != null) {
-				actionBar.setTitle(null);
-				toolbar.setTitle(R.string.directory_title);
-			}
+			searchBar = (SearchBar) getToolbar();
+			searchBar.setNavigationOnClickListener(v -> {
+				if (searchView != null) {
+					if (searchView.isIconified()) {
+						finish();
+					} else {
+						searchView.setIconified(true);
+					}
+				}
+			});
+			searchBar.setOnClickListener(v -> {
+				if (searchView != null) {
+					searchView.setIconified(false);
+				}
+			});
+			ConfigUtils.adjustSearchBarTextViewMargin(this, searchBar);
+
+			updateToolbarTitle(getString(R.string.directory_title));
 		}
 
 		try {
@@ -198,18 +196,20 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		WorkOrganization workOrganization = preferenceService.getWorkOrganization();
 		if (workOrganization != null && !TestUtil.empty(workOrganization.getName())) {
 			logger.info("Organization: " + workOrganization.getName());
-			getToolbar().setTitle(workOrganization.getName());
+			updateToolbarTitle(workOrganization.getName());
 		}
 
 		sortByFirstName = preferenceService.isContactListSortingFirstName();
 
 		chipGroup = findViewById(R.id.chip_group);
+		chipGroup.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGE_DISAPPEARING|LayoutTransition.CHANGE_APPEARING);
+
 		emptyTextView = findViewById(R.id.empty_text);
 		progressIndicator = findViewById(R.id.progress_bar);
 		progressIndicator.setVisibility(View.GONE);
 
-		categorySpanColor = ConfigUtils.getColorFromAttribute(this, R.attr.mention_background);
-		categorySpanTextColor = ConfigUtils.getColorFromAttribute(this, R.attr.mention_text_color);
+		categorySpanColor = getResources().getColor(R.color.mention_background);
+		categorySpanTextColor = ConfigUtils.getColorFromAttribute(this, R.attr.colorOnBackground);
 
 		recyclerView = this.findViewById(R.id.recycler);
 		recyclerView.setHasFixedSize(true);
@@ -257,8 +257,6 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		});
 
 		recyclerView.setAdapter(directoryAdapter);
-
-		findViewById(R.id.search_container).setOnClickListener(v ->	showResultsLayout());
 		return true;
 	}
 
@@ -306,13 +304,29 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 		getMenuInflater().inflate(R.menu.activity_directory, menu);
 
-		searchMenuItem = menu.findItem(R.id.menu_search_directory);
+		searchMenuItem = menu.findItem(R.id.menu_action_search);
 		if (searchMenuItem != null) {
-			SearchView searchView = (SearchView) searchMenuItem.getActionView();
-			if (searchView != null) {
-				searchView.setQueryHint(getString(R.string.directory_search));
-				searchView.setOnQueryTextListener(queryTextListener);
-
+			this.searchView = (ThreemaSearchView) this.searchMenuItem.getActionView();
+			if (this.searchView != null) {
+				ConfigUtils.adjustSearchViewPadding(searchView);
+				this.searchView.setQueryHint(getString(R.string.directory_search));
+				this.searchView.setOnQueryTextListener(this);
+				if (this.searchBar != null) {
+					this.searchBar.post(() -> {
+						try {
+							int[] locationCategoryIcon = new int[2];
+							int[] locationTextView = new int[2];
+							searchBar.findViewById(R.id.menu_category).getLocationInWindow(locationCategoryIcon);
+							searchBar.getTextView().getLocationInWindow(locationTextView);
+							searchView.setMaxWidth(locationCategoryIcon[0] - locationTextView[0]);
+						} catch (Exception e) {
+							logger.debug("Unable to patch searchview");
+						}
+					});
+				}
+				this.searchMenuItem.expandActionView();
+			} else {
+				this.searchMenuItem.setVisible(false);
 			}
 		}
 
@@ -324,14 +338,11 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				this.finish();
-				return true;
-			case R.id.menu_category:
-				selectCategories();
-				break;
-
+		if (item.getItemId() == android.R.id.home) {
+			this.finish();
+			return true;
+		} else if (item.getItemId() == R.id.menu_category) {
+			selectCategories();
 		}
 		return super.onOptionsItemSelected(item);
 	}
@@ -342,7 +353,6 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		intent.setData((Uri.parse("foobar://" + SystemClock.elapsedRealtime())));
 		IntentDataUtil.append(identity, intent);
 		startActivity(intent);
-		overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
 	}
 
 	private void launchContact(final WorkDirectoryContact workDirectoryContact, final int position) {
@@ -411,7 +421,16 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		int i = 0;
 		for (WorkDirectoryCategory category : categoryList) {
 			categoryNames[i] = category.getName();
-			categoryChecked[i] = checkedCategories.contains(category);
+
+			categoryChecked[i] = false;
+			if (category.id != null) {
+				for (WorkDirectoryCategory checkedCategory : checkedCategories) {
+					if (category.id.equals(checkedCategory.id)) {
+						categoryChecked[i] = true;
+						break;
+					}
+				}
+			}
 			i++;
 		}
 
@@ -428,28 +447,11 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 			if (!TextUtils.isEmpty(checkedCategory.name)) {
 				activeCategories++;
 
-				Chip chip = new Chip(this);
-				if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-					chip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
-				} else {
-					chip.setTextSize(14);
-				}
-
-				ColorStateList foregroundColor, backgroundColor;
-				if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.textColorPrimary));
-					backgroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
-				} else {
-					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
-					backgroundColor = foregroundColor.withAlpha(getResources().getInteger(R.integer.chip_alpha));
-				}
-
-				chip.setTextColor(foregroundColor);
-				chip.setChipBackgroundColor(backgroundColor);
+				Chip chip = (Chip) getLayoutInflater().inflate(
+					R.layout.chip_directory, null, false
+				);
 				chip.setText(checkedCategory.name);
-				chip.setCloseIconVisible(true);
 				chip.setTag(checkedCategory.id);
-				chip.setCloseIconTint(foregroundColor);
 				chip.setOnCloseIconClickListener(new View.OnClickListener() {
 					@Override
 					public void onClick(View v) {
@@ -465,6 +467,11 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 								}
 							}
 						}
+
+						if (checkedCategories.size() == 0) {
+							chipGroup.setVisibility(View.GONE);
+							showIntroLayout();
+						}
 					}
 				});
 
@@ -504,6 +511,13 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		}
 	}
 
+	@MainThread
+	protected void updateToolbarTitle(String title) {
+		if (searchBar != null) {
+			searchBar.setHint(title);
+		}
+	}
+
 	@Override
 	public void onYes(String tag, boolean[] checkedItems) {
 		checkedCategories.clear();

+ 6 - 27
app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java

@@ -35,19 +35,20 @@ import android.text.format.DateUtils;
 import android.view.Gravity;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 import static ch.threema.app.fragments.BackupDataFragment.REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS;
+import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
 
 /**
  * Guides user through the process of disabling battery optimization energy saving option.
@@ -92,7 +93,7 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
 	protected void onCreate(@Nullable Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || isIgnoringBatteryOptimizations(this)) {
+		if (isIgnoringBatteryOptimizations(this)) {
 			setResult(RESULT_OK);
 			finish();
 			return;
@@ -100,7 +101,7 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
 
 		Intent intent = getIntent();
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK || intent.getBooleanExtra(EXTRA_WIZARD, false)) {
+		if (ConfigUtils.isTheDarkSide(this) || intent.getBooleanExtra(EXTRA_WIZARD, false)) {
 			setTheme(R.style.Theme_Threema_Translucent_Dark);
 		}
 
@@ -136,28 +137,6 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
 		dialog.show(getSupportFragmentManager(), DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS);
 	}
 
-	/**
-	 * Try to find out whether battery optimizations are already disabled for our app.
-	 * If this fails (e.g. on devices older than Android M), `true` will be returned.
-	 */
-	public static boolean isIgnoringBatteryOptimizations(@NonNull Context context) {
-		// App is always whitelisted in unit tests
-		if (RuntimeUtil.isInTest()) {
-			return true;
-		}
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-			final PowerManager powerManager = (PowerManager) context.getApplicationContext().getSystemService(POWER_SERVICE);
-			try {
-				return powerManager.isIgnoringBatteryOptimizations(context.getPackageName());
-			} catch (Exception e) {
-				logger.error("Exception while checking if battery optimization is disabled", e);
-				// don't care about buggy phones not implementing this API
-				return true;
-			}
-		}
-		return true;
-	}
-
 	@TargetApi(Build.VERSION_CODES.M)
 	@Override
 	public void onYes(String tag, Object data) {

+ 4 - 3
app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.java

@@ -25,9 +25,10 @@ import android.content.Intent;
 import android.os.Bundle;
 import android.widget.Toast;
 
+import androidx.annotation.MainThread;
+
 import java.util.List;
 
-import androidx.annotation.MainThread;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.TextEntryDialog;
@@ -95,8 +96,8 @@ public class DistributionListAddActivity extends MemberChooseActivity implements
 	}
 
 	@Override
-	protected boolean getAddNextButton() {
-		return true;
+	protected int getMode() {
+		return MODE_NEW_DISTRIBUTION_LIST;
 	}
 
 	@Override

+ 29 - 21
app/src/main/java/ch/threema/app/activities/EditSendContactActivity.kt

@@ -27,6 +27,7 @@ import android.content.Intent
 import android.content.res.Configuration.ORIENTATION_LANDSCAPE
 import android.content.res.Configuration.ORIENTATION_PORTRAIT
 import android.graphics.Rect
+import android.graphics.drawable.Drawable
 import android.net.Uri
 import android.os.Bundle
 import android.os.Handler
@@ -37,7 +38,6 @@ import android.view.ViewGroup
 import android.view.ViewTreeObserver
 import android.widget.EditText
 import android.widget.LinearLayout
-import android.widget.ProgressBar
 import androidx.annotation.IdRes
 import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.core.widget.NestedScrollView
@@ -46,13 +46,15 @@ import androidx.lifecycle.ViewModelProvider
 import ch.threema.app.R
 import ch.threema.app.mediaattacher.ContactEditViewModel
 import ch.threema.app.ui.VCardPropertyView
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.VCardExtractor
 import ch.threema.base.utils.LoggingUtil
+import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.appbar.MaterialToolbar
 import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
 import com.google.android.material.floatingactionbutton.FloatingActionButton
+import com.google.android.material.progressindicator.CircularProgressIndicator
+import com.google.android.material.shape.MaterialShapeDrawable
 import ezvcard.property.StructuredName
 
 private val logger = LoggingUtil.getThreemaLogger("EditSendContactActivity")
@@ -65,16 +67,18 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
 
     private lateinit var viewModel: ContactEditViewModel
     private lateinit var toolbar: MaterialToolbar
+    private lateinit var appBarLayout: AppBarLayout
+    private lateinit var bottomSheet: View
     private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
         // set status bar color
-        window.statusBarColor = ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_collapsed)
+        window.statusBarColor = resources.getColor(R.color.attach_status_bar_color_collapsed)
 
         toolbar = findViewById(R.id.toolbar_contact)
-
+        appBarLayout = findViewById(R.id.appbar_layout_contact)
         viewModel = ViewModelProvider(this)[ContactEditViewModel::class.java]
 
         // Finish activity when chat activity (in "background") is clicked
@@ -82,7 +86,7 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
                 .parent as ViewGroup).setOnClickListener { cancelAndFinish() }
 
         // Finish activity when bottom sheet gets hidden and adapt status bar color on expand/drag
-        val bottomSheet = findViewById<View>(R.id.bottom_sheet)
+        bottomSheet = findViewById<View>(R.id.bottom_sheet)
         bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet).apply {
             addBottomSheetCallback(object : BottomSheetCallback() {
 
@@ -117,8 +121,7 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
             override fun onGlobalLayout() {
                 rootCoordinator.viewTreeObserver.removeOnGlobalLayoutListener(this)
 
-                val topMargin = toolbar.height - resources.getDimensionPixelSize(R.dimen.drag_handle_height) -
-                        resources.getDimensionPixelSize(R.dimen.drag_handle_topbottom_margin)
+                val topMargin = toolbar.height - resources.getDimensionPixelSize(R.dimen.drag_handle_height)
 
                 val bottomSheetContainer = findViewById<CoordinatorLayout>(R.id.bottom_sheet_coordinator)
                 val bottomSheetContainerLayoutParams = bottomSheetContainer.layoutParams as CoordinatorLayout.LayoutParams
@@ -190,7 +193,7 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
             }
 
             // Hide progress bar
-            findViewById<ProgressBar>(R.id.progress_bar_parsing).visibility = View.GONE
+            findViewById<CircularProgressIndicator>(R.id.progress_bar_parsing).visibility = View.GONE
 
             // Send the possibly modified VCard as file
             findViewById<FloatingActionButton>(R.id.send_contact).apply {
@@ -219,16 +222,21 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
      * Shows the toolbar and adapts the status bar color.
      */
     private fun onBottomSheetExpand() {
-        toolbar.animation?.cancel()
-        toolbar.alpha = 0f
-        toolbar.visibility = View.VISIBLE
-        toolbar.animate().alpha(1f).setDuration(100).setListener(object : AnimatorListenerAdapter() {
+        appBarLayout.animation?.cancel()
+        appBarLayout.alpha = 0f
+        appBarLayout.visibility = View.VISIBLE
+        appBarLayout.animate().alpha(1f).setDuration(100).setListener(object : AnimatorListenerAdapter() {
             override fun onAnimationEnd(animation: Animator) {
-                toolbar.visibility = View.VISIBLE
+                appBarLayout.visibility = View.VISIBLE
             }
         })
-        toolbar.postDelayed({
-            window.statusBarColor = ConfigUtils.getColorFromAttribute(this@EditSendContactActivity, R.attr.attach_status_bar_color_expanded)
+        appBarLayout.postDelayed({
+            val background: Drawable = bottomSheet.getBackground()
+            if (background is MaterialShapeDrawable) {
+                window.statusBarColor = background.resolvedTintColor
+            } else {
+                window.statusBarColor = resources.getColor(R.color.attach_status_bar_color_expanded)
+            }
         }, 100)
     }
 
@@ -236,17 +244,17 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
      * Hides the toolbar and adapts the status bar color.
      */
     private fun onBottomSheetCollapse() {
-        toolbar.animation?.cancel()
-        toolbar.alpha = 1f
-        toolbar.animate().alpha(0f).setDuration(100).setListener(object : AnimatorListenerAdapter() {
+        appBarLayout.animation?.cancel()
+        appBarLayout.alpha = 1f
+        appBarLayout.animate().alpha(0f).setDuration(100).setListener(object : AnimatorListenerAdapter() {
             override fun onAnimationStart(animation: Animator) {}
             override fun onAnimationEnd(animation: Animator) {
-                toolbar.visibility = View.INVISIBLE
-                window.statusBarColor = ConfigUtils.getColorFromAttribute(this@EditSendContactActivity, R.attr.attach_status_bar_color_collapsed)
+                appBarLayout.visibility = View.INVISIBLE
+                window.statusBarColor = resources.getColor(R.color.attach_status_bar_color_collapsed)
             }
 
             override fun onAnimationCancel(animation: Animator) {
-                window.statusBarColor = ConfigUtils.getColorFromAttribute(this@EditSendContactActivity, R.attr.attach_status_bar_color_collapsed)
+                window.statusBarColor = resources.getColor(R.color.attach_status_bar_color_collapsed)
             }
 
             override fun onAnimationRepeat(animation: Animator) {}

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

@@ -37,13 +37,14 @@ import android.view.KeyEvent;
 import android.view.View;
 import android.widget.Button;
 import android.widget.EditText;
-import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
 import androidx.core.text.HtmlCompat;
 
+import com.google.android.material.button.MaterialButton;
+
 import org.slf4j.Logger;
 
 import ch.threema.app.BuildConfig;
@@ -76,7 +77,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 	private static final String DIALOG_TAG_CHECKING = "check";
 	private TextView stateTextView, privateExplainText = null;
 	private EditText licenseKeyOrUsernameText, passwordText, serverText;
-	private ImageView unlockButton;
+	private MaterialButton unlockButton;
 	private Button loginButton;
 	private LicenseService licenseService;
 	private PreferenceService preferenceService;

+ 0 - 5
app/src/main/java/ch/threema/app/activities/ExportIDActivity.java

@@ -37,7 +37,6 @@ import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.ThreemaException;
@@ -56,10 +55,6 @@ public class ExportIDActivity extends AppCompatActivity implements PasswordEntry
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			setTheme(R.style.Theme_Threema_Translucent_Dark);
-		}
-
 		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		preferenceService = serviceManager.getPreferenceService();
 		userService = serviceManager.getUserService();

+ 26 - 19
app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java

@@ -40,13 +40,15 @@ import android.widget.ImageView;
 import android.widget.ScrollView;
 import android.widget.TextView;
 
-import java.io.ByteArrayOutputStream;
-
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.Toolbar;
 import androidx.core.app.NavUtils;
 import androidx.lifecycle.LifecycleOwner;
+
+import com.google.android.material.appbar.MaterialToolbar;
+
+import java.io.ByteArrayOutputStream;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -62,7 +64,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 
 	private Bitmap qrcodeBitmap;
 	private WebView printWebView;
-	private Toolbar toolbar;
+	private MaterialToolbar toolbar;
 	private TooltipPopup tooltipPopup;
 
 	private String identity, backupData;
@@ -81,9 +83,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 
 		actionBar.setDisplayHomeAsUpEnabled(true);
 		actionBar.setTitle("");
-		if (ConfigUtils.getAppTheme(this) != ConfigUtils.THEME_DARK) {
-			actionBar.setHomeAsUpIndicator(R.drawable.ic_check);
-		}
+		actionBar.setHomeAsUpIndicator(R.drawable.ic_check);
 
 		this.backupData = this.getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP);
 		this.identity = this.getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_CONTACT);
@@ -114,14 +114,25 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 		Bitmap bmpScaled = Bitmap.createScaledBitmap(qrcodeBitmap, px, px, false);
 		bmpScaled.setDensity(Bitmap.DENSITY_NONE);
 		imageView.setImageBitmap(bmpScaled);
+		if (ConfigUtils.isTheDarkSide(this)) {
+			ConfigUtils.invertColors(imageView);
+		}
+
 		imageView.setOnClickListener(v -> new QRCodePopup(ExportIDResultActivity.this, getWindow().getDecorView(), ExportIDResultActivity.this).show(v, backupData, QRCodeServiceImpl.QR_TYPE_ID_EXPORT));
 	}
 
 	private void showTooltip() {
 		if (!preferenceService.getIsExportIdTooltipShown()) {
 			getToolbar().postDelayed(() -> {
-				tooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_export_id_shown, R.layout.popup_tooltip_top_right, this);
-				tooltipPopup.show(this, getToolbar(), getString(R.string.tooltip_export_id), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, 5000);
+
+				View menuItemView = findViewById(R.id.menu_backup_share);
+				int[] location = new int[2];
+				menuItemView.getLocationOnScreen(location);
+				location[0] += menuItemView.getWidth() / 2;
+				location[1] += menuItemView.getHeight();
+
+				tooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_export_id_shown, this);
+				tooltipPopup.show(this, menuItemView, getString(R.string.tooltip_export_id), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, location, 5000);
 			}, 1000);
 		}
 	}
@@ -207,16 +218,12 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				done();
-				return true;
-			case R.id.menu_print:
-				printBitmap(qrcodeBitmap);
-				break;
-			case R.id.menu_backup_share:
-				shareId();
-				break;
+		if (item.getItemId() == android.R.id.home) {
+			done();
+		} else if (item.getItemId() == R.id.menu_print) {
+			printBitmap(qrcodeBitmap);
+		} else if (item.getItemId() == R.id.menu_backup_share) {
+			shareId();
 		}
 		return super.onOptionsItemSelected(item);
 	}

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

@@ -25,12 +25,13 @@ import android.content.Intent;
 import android.os.Bundle;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -81,8 +82,8 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 	}
 
 	@Override
-	protected boolean getAddNextButton() {
-		return true;
+	protected int getMode() {
+		return appendMembers ? MODE_ADD_TO_GROUP : MODE_NEW_GROUP;
 	}
 
 	@Override
@@ -113,7 +114,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 		initList();
 
 		if (!appendMembers) {
-			ShowOnceDialog.newInstance(R.string.title_select_contacts, R.string.note_group_howto ).show(getSupportFragmentManager(), DIALOG_TAG_NOTE_GROUP_HOWTO);
+			ShowOnceDialog.newInstance(R.string.title_addgroup, R.string.note_group_howto, 0).show(getSupportFragmentManager(), DIALOG_TAG_NOTE_GROUP_HOWTO);
 		}
 	}
 
@@ -132,7 +133,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 			createOrUpdateGroup(Collections.emptyList());
 		} else {
 			// Adding group members to new group (none selected)
-			GenericAlertDialog.newInstance(R.string.title_addgroup, R.string.group_create_no_members, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_NO_MEMBERS);
+			GenericAlertDialog.newInstance(R.string.title_addgroup, R.string.group_create_no_members, R.string.yes, R.string.no, 0).show(getSupportFragmentManager(), DIALOG_TAG_NO_MEMBERS);
 		}
 	}
 
@@ -155,19 +156,15 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 	@Override
 	public void onActivityResult(int requestCode, int resultCode, Intent data) {
 		super.onActivityResult(requestCode, resultCode, data);
-		switch (requestCode) {
-			case ThreemaActivity.ACTIVITY_ID_GROUP_ADD:
-				if (resultCode != RESULT_CANCELED) {
-					finish();
-				}
-				break;
-			default:
-				break;
+		if (requestCode == ThreemaActivity.ACTIVITY_ID_GROUP_ADD) {
+			if (resultCode != RESULT_CANCELED) {
+				finish();
+			}
 		}
 	}
 
 	@Override
-	protected void onSaveInstanceState(Bundle outState) {
+	public void onSaveInstanceState(@NonNull Bundle outState) {
 		super.onSaveInstanceState(outState);
 		outState.putStringArrayList(BUNDLE_EXISTING_MEMBERS, this.excludedIdentities);
 	}

+ 108 - 80
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -21,7 +21,9 @@
 
 package ch.threema.app.activities;
 
-import android.Manifest;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
+
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
@@ -33,20 +35,17 @@ import android.os.AsyncTask;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.Html;
+import android.text.TextWatcher;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.WindowManager;
-import android.widget.LinearLayout;
 import android.widget.Toast;
 
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.menu.MenuBuilder;
-import androidx.appcompat.widget.Toolbar;
 import androidx.core.app.ActivityCompat;
 import androidx.core.app.ActivityOptionsCompat;
 import androidx.fragment.app.Fragment;
@@ -57,6 +56,7 @@ import androidx.recyclerview.widget.RecyclerView;
 
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.appbar.CollapsingToolbarLayout;
+import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
 
 import org.slf4j.Logger;
@@ -64,10 +64,10 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.Date;
 import java.util.List;
 
-import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.GroupDetailAdapter;
@@ -113,9 +113,6 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
-import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
-import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
-
 public class GroupDetailActivity extends GroupEditActivity implements SelectorDialog.SelectorDialogClickListener,
 	GenericAlertDialog.DialogClickListener,
 	TextEntryDialog.TextEntryDialogClickListener,
@@ -143,8 +140,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private static final int SELECTOR_OPTION_CALL = 2;
 	private static final int SELECTOR_OPTION_REMOVE = 3;
 
-	// services
-	private LicenseService licenseService;
 	private GroupInviteService groupInviteService;
 	private DeviceService deviceService;
 	private IdListService blackListIdentityService;
@@ -155,22 +150,14 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private GroupDetailAdapter groupDetailAdapter;
 
 	private EmojiEditText groupNameEditText;
-	private CollapsingToolbarLayout collapsingToolbar;
 	private ResumePauseHandler resumePauseHandler;
 	private AvatarEditView avatarEditView;
 	private ExtendedFloatingActionButton floatingActionButton;
 
-	private final ActivityResultLauncher<String> readPhoneStatePermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
-		if (!isGranted && !ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_PHONE_STATE)) {
-			ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.read_phone_state_short_message);
-		}
-		// Note that the call cannot be started from here if the permission has just been granted.
-	});
-
 	private String myIdentity;
 	private int operationMode;
 	private int groupId;
-	private boolean hasMemberChanges = false;
+	private boolean hasMemberChanges = false, hasAvatarChanges = false;
 
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
@@ -188,6 +175,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		public void onAvatarSet(File avatarFile1) {
 			groupDetailViewModel.setAvatarFile(avatarFile1);
 			groupDetailViewModel.setIsAvatarRemoved(false);
+			hasAvatarChanges = true;
+			updateFloatingActionButton();
 		}
 
 		@Override
@@ -195,6 +184,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			groupDetailViewModel.setAvatarFile(null);
 			groupDetailViewModel.setIsAvatarRemoved(true);
 			avatarEditView.setDefaultAvatar(null, groupModel);
+			hasAvatarChanges = true;
+			updateFloatingActionButton();
 		}
 	};
 
@@ -269,7 +260,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		@Override
 		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 			if (identity.equals(myIdentity)) {
-				finishUp();
+				finish();
 			} else {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 			}
@@ -278,7 +269,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		@Override
 		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
 			if (identity.equals(myIdentity)) {
-				finishUp();
+				finish();
 			} else {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 			}
@@ -303,7 +294,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 		final ActionBar actionBar = getSupportActionBar();
 		if (actionBar == null) {
-			finishUp();
+			finish();
 			return;
 		}
 
@@ -312,35 +303,36 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		this.resumePauseHandler = ResumePauseHandler.getByActivity(this, this);
 		this.groupDetailViewModel = new ViewModelProvider(this).get(GroupDetailViewModel.class);
 
-		final Toolbar toolbar = findViewById(R.id.toolbar);
-		LinearLayout doneButton = toolbar.findViewById(R.id.action_done);
+		final MaterialToolbar toolbar = findViewById(R.id.toolbar);
 		this.avatarEditView = findViewById(R.id.avatar_edit_view);
-		this.collapsingToolbar = findViewById(R.id.collapsing_toolbar);
+		CollapsingToolbarLayout collapsingToolbar = findViewById(R.id.collapsing_toolbar);
 		this.floatingActionButton = findViewById(R.id.floating);
 		RecyclerView groupDetailRecyclerView = findViewById(R.id.group_members_list);
-		this.collapsingToolbar.setTitle(" ");
+		collapsingToolbar.setTitle(" ");
 		this.groupNameEditText = findViewById(R.id.group_title);
 
+		// services
+		LicenseService<?> licenseService;
 		try {
 			this.deviceService = serviceManager.getDeviceService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
-			this.licenseService = serviceManager.getLicenseService();
+			licenseService = serviceManager.getLicenseService();
 			this.groupInviteService = serviceManager.getGroupInviteService();
 			this.groupCallManager = serviceManager.getGroupCallManager();
 		} catch (ThreemaException e) {
 			logger.error("Exception, could not get required services", e);
-			finishUp();
+			finish();
 			return;
 		}
 
-		if (this.deviceService == null || this.blackListIdentityService == null || this.licenseService == null) {
-			finishUp();
+		if (this.deviceService == null || this.blackListIdentityService == null || licenseService == null) {
+			finish();
 			return;
 		}
 
 		groupId = getIntent().getIntExtra(ThreemaApplication.INTENT_DATA_GROUP, 0);
 		if (this.groupId == 0) {
-			finishUp();
+			finish();
 		}
 		this.groupModel = groupService.getById(this.groupId);
 
@@ -387,28 +379,41 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 		this.sortGroupMembers();
 		setTitle();
+		setHasMemberChanges(false);
 
 		if (this.groupService.isGroupOwner(this.groupModel)) {
 			operationMode = MODE_EDIT;
-			doneButton.setOnClickListener(v -> saveGroupSettings());
+			actionBar.setHomeButtonEnabled(false);
+			actionBar.setDisplayHomeAsUpEnabled(true);
+
 			floatingActionButton.setOnClickListener(v -> {
-				Intent intent = new Intent(GroupDetailActivity.this, GroupAddActivity.class);
-				IntentDataUtil.append(groupModel, intent);
-				IntentDataUtil.append(groupDetailViewModel.getGroupContacts(), intent);
-				startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
+				saveGroupSettings();
 			});
 			groupNameEditText.setMaxByteSize(GroupModel.GROUP_NAME_MAX_LENGTH_BYTES);
+			groupNameEditText.addTextChangedListener(new TextWatcher() {
+				@Override
+				public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+				@Override
+				public void onTextChanged(CharSequence s, int start, int before, int count) {}
+
+				@Override
+				public void afterTextChanged(Editable s) {
+					updateFloatingActionButton();
+				}
+			});
 		} else {
 			operationMode = MODE_READONLY;
-			doneButton.setVisibility(View.GONE);
+			actionBar.setHomeButtonEnabled(false);
+			actionBar.setDisplayHomeAsUpEnabled(true);
 
 			groupNameEditText.setFocusable(false);
 			groupNameEditText.setClickable(false);
 			groupNameEditText.setFocusableInTouchMode(false);
 			groupNameEditText.setBackground(null);
+			groupNameEditText.setPadding(0, 0, 0, 0);
 
-			floatingActionButton.hide();
-			actionBar.setDisplayHomeAsUpEnabled(true);
+			floatingActionButton.setVisibility(View.GONE);
 		}
 
 		groupDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
@@ -426,12 +431,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 		groupDetailRecyclerView.setAdapter(this.groupDetailAdapter);
 
-		final Observer<List<ContactModel>> groupMemberObserver = new Observer<List<ContactModel>>() {
-			@Override
-			public void onChanged(List<ContactModel> groupMembers) {
-				// Update the UI
-				groupDetailAdapter.setContactModels(groupMembers);
-			}
+		final Observer<List<ContactModel>> groupMemberObserver = groupMembers -> {
+			// Update the UI
+			groupDetailAdapter.setContactModels(groupMembers);
 		};
 
 		// Observe the LiveData, passing in this activity as the LifecycleOwner and the observer.
@@ -487,19 +489,40 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private void sortGroupMembers() {
 		final boolean isSortingFirstName = preferenceService.isContactListSortingFirstName();
 		List<ContactModel> contactModels = groupDetailViewModel.getGroupContacts();
-		Collections.sort(contactModels, (model1, model2) -> ContactUtil.getSafeNameString(model1, isSortingFirstName).compareTo(
-			ContactUtil.getSafeNameString(model2, isSortingFirstName)
-		));
+		Collections.sort(contactModels, new Comparator<ContactModel>() {
+			@Override
+			public int compare(ContactModel model1, ContactModel model2) {
+				return ContactUtil.getSafeNameString(model1, isSortingFirstName).compareTo(
+					ContactUtil.getSafeNameString(model2, isSortingFirstName)
+				);
+			}
+		});
+
+		if (contactModels.size() > 1 && groupModel.getCreatorIdentity() != null) {
+			for (ContactModel currentMember : contactModels) {
+				if (groupModel.getCreatorIdentity().equals(currentMember.getIdentity())) {
+					contactModels.remove(currentMember);
+					contactModels.add(0, currentMember);
+					break;
+				}
+			}
+		}
+
 		groupDetailViewModel.setGroupContacts(contactModels);
 	}
 
 	private void removeMemberFromGroup(final ContactModel contactModel) {
 		if (contactModel != null) {
 			this.groupDetailViewModel.removeGroupContact(contactModel);
-			this.hasMemberChanges = true;
+			setHasMemberChanges(true);
 		}
 	}
 
+	private void setHasMemberChanges(boolean hasChanges) {
+		this.hasMemberChanges = hasChanges;
+		updateFloatingActionButton();
+	}
+
 	@Override
 	public void onPause() {
 		super.onPause();
@@ -571,14 +594,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	public boolean onOptionsItemSelected(MenuItem item) {
 		int itemId = item.getItemId();
 		if (itemId == android.R.id.home) {
-			finishUp();
+			onBackPressed();
 			return true;
 		}
 		else if (itemId == R.id.menu_group_links_manage) {
 			Intent groupLinkOverviewIntent = new Intent(this, GroupLinkOverviewActivity.class);
 			groupLinkOverviewIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 			startActivityForResult(groupLinkOverviewIntent, ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS);
-
 		}
 		else if (itemId == R.id.action_send_message) {
 			if (groupModel != null) {
@@ -632,7 +654,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private void leaveGroupAndQuit() {
-		new LeaveGroupAsyncTask(groupModel, groupService, this, null, this::finishUp).execute();
+		new LeaveGroupAsyncTask(groupModel, groupService, this, null, this::finish).execute();
 	}
 
 	private void deleteGroupAndQuit() {
@@ -681,7 +703,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
 					intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, newModel.getId());
 					startActivity(intent);
-					finishUp();
+					finish();
 				} else {
 					Toast.makeText(GroupDetailActivity.this, getString(R.string.error_creating_group) + ": " + getString(R.string.internet_connection_required), Toast.LENGTH_LONG).show();
 				}
@@ -696,6 +718,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		startActivity(intent);
 	}
 
+	private void addNewMembers() {
+		Intent intent = new Intent(GroupDetailActivity.this, GroupAddActivity.class);
+		IntentDataUtil.append(groupModel, intent);
+		IntentDataUtil.append(groupDetailViewModel.getGroupContacts(), intent);
+		startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
+	}
+
 	private void syncGroup() {
 		if(this.groupService != null) {
 			GenericProgressDialog.newInstance(R.string.resync_group, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_RESYNC_GROUP);
@@ -747,7 +776,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				}
 
 				@NonNull String newGroupName = groupDetailViewModel.getGroupName() != null ?
-					groupDetailViewModel.getGroupName() : "";
+					groupDetailViewModel.getGroupName().trim() : "";
 				@NonNull String oldGroupName = groupModel.getName() != null ?
 					groupModel.getName() : "";
 
@@ -775,7 +804,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_UPDATE_GROUP, true);
 
 				if (newModel != null) {
-					finishUp();
+					finish();
 				} else {
 					SimpleStringAlertDialog.newInstance(R.string.updating_group, getString(R.string.error_creating_group) + ": " + getString(R.string.internet_connection_required)).show(getSupportFragmentManager(), "er");
 				}
@@ -790,7 +819,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				// some users were added
 				groupDetailViewModel.addGroupContacts(IntentDataUtil.getContactIdentities(data));
 				sortGroupMembers();
-				this.hasMemberChanges = true;
+				setHasMemberChanges(true);
 			}
 			else if (this.groupService.isGroupOwner(this.groupModel) && requestCode == ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS) {
 				// make sure we reset the default link switch if the default link was deleted
@@ -835,13 +864,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					break;
 				case SELECTOR_OPTION_CHAT:
 					showConversation(selectorInfo.contactModel.getIdentity());
-					finishUp();
+					finish();
 					break;
 				case SELECTOR_OPTION_REMOVE:
 					removeMemberFromGroup(selectorInfo.contactModel);
 					break;
 				case SELECTOR_OPTION_CALL:
-					VoipUtil.initiateCall(this, selectorInfo.contactModel, false, null, readPhoneStatePermissionLauncher);
+					VoipUtil.initiateCall(this, selectorInfo.contactModel, false, null);
 					break;
 				default:
 					break;
@@ -891,10 +920,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		// do nothing
 	}
 
-
-	@Override
-	public void onNeutral(String tag) {}
-
 	@Override
 	public void onYes(String tag, Object data) {
 		switch(tag) {
@@ -927,14 +952,15 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	public void onBackPressed() {
 		if (this.operationMode == MODE_EDIT && hasChanges()) {
 			GenericAlertDialog.newInstance(
-					R.string.save_changes,
+					R.string.leave,
 					R.string.save_group_changes,
 					R.string.yes,
 					R.string.no,
-				false)
+					R.string.cancel,
+				0)
 					.show(getSupportFragmentManager(), DIALOG_TAG_QUIT);
 		} else {
-			finishUp();
+			finish();
 		}
 	}
 
@@ -942,7 +968,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	public void onNo(String tag, Object data) {
 		switch(tag) {
 			case DIALOG_TAG_QUIT:
-				finishUp();
+				finish();
 				break;
 			default:
 				break;
@@ -950,7 +976,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private boolean hasChanges() {
-		return hasMemberChanges || hasGroupNameChanges();
+		return hasMemberChanges || hasGroupNameChanges() || hasAvatarChanges;
 	}
 
 	private boolean hasGroupNameChanges() {
@@ -963,24 +989,21 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private void updateFloatingActionButton() {
-		if (this.floatingActionButton == null ||
-			this.groupService == null ||
+		if (this.groupService == null ||
 			this.groupDetailAdapter == null) {
-			logger.error("Exception, could not update floating actions button, required instances not available");
+			logger.error("Required instances not available");
 			return;
 		}
 
-		if (this.groupService.isGroupOwner(this.groupModel)) {
-			if (this.groupDetailAdapter.getItemCount() > BuildConfig.MAX_GROUP_SIZE) {
-				this.floatingActionButton.hide();
-			} else {
-				this.floatingActionButton.show();
-			}
+		if (this.floatingActionButton == null) {
+			return;
 		}
-	}
 
-	private void finishUp() {
-		finish();
+		if (this.groupService.isGroupOwner(this.groupModel) && hasChanges()) {
+			this.floatingActionButton.show();
+		} else {
+			this.floatingActionButton.hide();
+		}
 	}
 
 	private void navigateHome() {
@@ -1067,6 +1090,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		showGroupDescEditDialog();
 	}
 
+	@Override
+	public void onAddMembersClick(View v) {
+		addNewMembers();
+	}
+
 	// hide keyboard on older devices after ok clicked when group description changed
 	public void hideKeyboard() {
 		getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);

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

@@ -69,7 +69,7 @@ public abstract class GroupEditActivity extends ThreemaToolbarActivity {
 		final int inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME;
 		ContactEditDialog.newInstance(
 					R.string.edit_name,
-					R.string.name,
+					R.string.group_name,
 					-1,
 					inputType,
 					avatarFile,

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

@@ -21,6 +21,9 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
+import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
+
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.BroadcastReceiver;
@@ -35,6 +38,7 @@ import android.graphics.drawable.Drawable;
 import android.net.ConnectivityManager;
 import android.net.Uri;
 import android.os.AsyncTask;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.text.format.DateUtils;
@@ -42,8 +46,8 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.Window;
 import android.widget.ImageView;
-import android.widget.LinearLayout;
 import android.widget.Toast;
 
 import androidx.annotation.AnyThread;
@@ -52,22 +56,22 @@ import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.widget.AppCompatImageView;
-import androidx.appcompat.widget.Toolbar;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 
+import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.bottomnavigation.BottomNavigationView;
+import com.google.android.material.shape.MaterialShapeDrawable;
 
 import org.slf4j.Logger;
 
 import java.io.File;
 import java.lang.ref.WeakReference;
-import java.util.ArrayList;
 import java.util.Date;
-import java.util.Iterator;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
@@ -132,7 +136,6 @@ import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.groupcall.GroupCallManager;
@@ -151,8 +154,6 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 
-import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
-
 public class HomeActivity extends ThreemaAppCompatActivity implements
 	SMSVerificationDialog.SMSVerificationDialogCallback,
 	GenericAlertDialog.DialogClickListener,
@@ -188,9 +189,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	private ActionBar actionBar;
 	private boolean isLicenseCheckStarted = false, isInitialized = false, isWhatsNewShown = false, isUpdating = false;
-	private Toolbar toolbar;
+	private MaterialToolbar toolbar;
 	private View connectionIndicator;
-	private LinearLayout noticeLayout;
+	private View noticeSMSLayout;
 	OngoingCallNoticeView ongoingCallNotice;
 
 	private ServiceManager serviceManager;
@@ -202,7 +203,12 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private ConversationService conversationService;
 	private GroupCallManager groupCallManager;
 
-	private final ArrayList<AbstractMessageModel> unsentMessages = new ArrayList<>();
+	private enum UnsentMessageAction {
+		ADD,
+		REMOVE,
+	}
+
+	private final List<AbstractMessageModel> unsentMessages = new LinkedList<>();
 
 	private BroadcastReceiver checkLicenseBroadcastReceiver = null;
 	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
@@ -314,51 +320,51 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	private final ConnectionStateListener connectionStateListener = (connectionState, address) -> updateConnectionIndicator(connectionState);
 
-	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, boolean add) {
+	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, UnsentMessageAction action) {
 		int numCurrentUnsent = unsentMessages.size();
 
 		synchronized (unsentMessages) {
 			String uid = modifiedMessageModel.getUid();
 
-			Iterator<AbstractMessageModel> iterator = unsentMessages.iterator();
-			while (iterator.hasNext()) {
-				AbstractMessageModel unsentMessage = iterator.next();
+			// Check whether the message model with the same uid is already in the list or not
+			AbstractMessageModel containedMessageModel = null;
+			for (AbstractMessageModel unsentMessage : unsentMessages) {
 				if (TestUtil.compare(unsentMessage.getUid(), uid)) {
-					iterator.remove();
+					containedMessageModel = unsentMessage;
+					break;
 				}
 			}
 
-			if (add) {
-				unsentMessages.add(modifiedMessageModel);
+			switch (action) {
+				case ADD:
+					// Only add the message model if it is not yet in the list
+					if (containedMessageModel == null) {
+						unsentMessages.add(modifiedMessageModel);
+					}
+					break;
+				case REMOVE:
+					// Remove message model if it is in the list
+					if (containedMessageModel != null) {
+						unsentMessages.remove(containedMessageModel);
+					}
+					break;
 			}
 
 			int numNewUnsent = unsentMessages.size();
 
-			if (notificationService != null && !(numCurrentUnsent == 0 && numNewUnsent == 0)) {
+			// Update the notification if there was a change
+			if (notificationService != null && numCurrentUnsent != numNewUnsent) {
 				notificationService.showUnsentMessageNotification(unsentMessages);
 			}
 		}
 	}
 
-	/**
-	 * Notify the user about the unsent message that are kept in {@link #unsentMessages} and also
-	 * the message passed as argument. The passed message is not kept in the unsent messages and
-	 * therefore is shown only once in a notification.
-	 *
-	 * @param msg the unsent message that should be shown in the notification
-	 */
-	private void notifyUnsentMessages(@NonNull AbstractMessageModel msg) {
-		List<AbstractMessageModel> allUnsentMessages = new ArrayList<>(unsentMessages);
-		allUnsentMessages.add(msg);
-		notificationService.showUnsentMessageNotification(allUnsentMessages);
-	}
-
 	private final SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
 		@Override
 		public void onVerified() {
 			RuntimeUtil.runOnUiThread(() -> {
-				if (noticeLayout != null) {
-					AnimationUtil.collapse(noticeLayout);
+				if (noticeSMSLayout != null) {
+					AnimationUtil.collapse(noticeSMSLayout);
 				}
 			});
 		}
@@ -366,8 +372,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		@Override
 		public void onVerificationStarted() {
 			RuntimeUtil.runOnUiThread(() -> {
-				if (noticeLayout != null) {
-					AnimationUtil.expand(noticeLayout);
+				if (noticeSMSLayout != null) {
+					AnimationUtil.expand(noticeSMSLayout);
 				}
 			});
 		}
@@ -425,15 +431,11 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 					switch (modifiedMessageModel.getState()) {
 						case SENDFAILED:
-							updateUnsentMessagesList(modifiedMessageModel, true);
-							break;
 						case FS_KEY_MISMATCH:
-							// Only notify and don't keep in unsentMessages to prevent that the
-							// notification is shown every time a message is sent
-							notifyUnsentMessages(modifiedMessageModel);
+							updateUnsentMessagesList(modifiedMessageModel, UnsentMessageAction.ADD);
 							break;
 						default:
-							updateUnsentMessagesList(modifiedMessageModel, false);
+							updateUnsentMessagesList(modifiedMessageModel, UnsentMessageAction.REMOVE);
 							break;
 					}
 				}
@@ -442,13 +444,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 		@Override
 		public void onRemoved(AbstractMessageModel removedMessageModel) {
-			updateUnsentMessagesList(removedMessageModel, false);
+			updateUnsentMessagesList(removedMessageModel, UnsentMessageAction.REMOVE);
 		}
 
 		@Override
 		public void onRemoved(List<AbstractMessageModel> removedMessageModels) {
 			for (AbstractMessageModel removedMessageModel: removedMessageModels) {
-				updateUnsentMessagesList(removedMessageModel, false);
+				updateUnsentMessagesList(removedMessageModel, UnsentMessageAction.REMOVE);
 			}
 		}
 
@@ -456,6 +458,11 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {
 			//do nothing
 		}
+
+		@Override
+		public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
+			updateUnsentMessagesList(messageModel, UnsentMessageAction.REMOVE);
+		}
 	};
 
 	private final AppIconListener appIconListener = () -> RuntimeUtil.runOnUiThread(this::updateAppLogo);
@@ -518,7 +525,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 		AnimationUtil.setupTransitions(this.getApplicationContext(), getWindow());
 
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 
@@ -646,7 +653,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						// To not show the same dialog twice, it is only shown if the previous version
 						// is prior to the first version that used this dialog.
 						// Use the version code of the first version where this dialog should be shown.
-						if (previous < 776) { // 776 => Threema v5.0
+						if (previous < 903) { // do not show to users of previous release candidate
 							Intent intent = new Intent(this, WhatsNewActivity.class);
 							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
@@ -675,7 +682,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 					threemaSafeService.storeMasterKey(masterkey);
 					preferenceService.setThreemaSafeServerInfo(mdmConfig.getServerInfo());
 					threemaSafeService.setEnabled(true);
-					threemaSafeService.uploadNow(HomeActivity.this, true);
+					threemaSafeService.uploadNow(true);
 				} else {
 					Toast.makeText(HomeActivity.this, R.string.safe_error_preparing, Toast.LENGTH_LONG).show();
 				}
@@ -934,9 +941,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private void initMainActivity(@Nullable Bundle savedInstanceState) {
 		final boolean isAppStart = savedInstanceState == null;
 
-		//refresh StateBitmapUtil
-		StateBitmapUtil.getInstance().refresh();
-
 		// licensing
 		checkApp();
 
@@ -976,9 +980,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		//init custom icon
 		updateAppLogo();
 
-		// reset accent color
-		ConfigUtils.resetAccentColor(this);
-
 		actionBar.setDisplayShowTitleEnabled(false);
 		actionBar.setDisplayUseLogoEnabled(false);
 
@@ -1003,7 +1004,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			// If a non-libre build of Threema cannot find push services, fall back to Threema Push
 			if (!BuildFlavor.forceThreemaPush() && !PushService.servicesInstalled(this)) {
 				this.enableThreemaPush();
-				if (!ConfigUtils.isBlackBerry() && !ConfigUtils.isAmazonDevice() && !ConfigUtils.isWorkBuild()) {
+				if (!ConfigUtils.isAmazonDevice() && !ConfigUtils.isWorkBuild()) {
 					RuntimeUtil.runOnUiThread(() -> {
 						// Show "push not available" dialog
 						int title = R.string.push_not_available_title;
@@ -1017,7 +1018,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			if (BuildFlavor.forceThreemaPush()) {
 				preferenceService.setUseThreemaPush(true);
 
-				if (!DisableBatteryOptimizationsActivity.isIgnoringBatteryOptimizations(this)) {
+				if (!isIgnoringBatteryOptimizations(this)) {
 					final Intent intent = new Intent(this, DisableBatteryOptimizationsActivity.class);
 					intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_NAME, getString(R.string.threema_push));
 					intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_CONFIRM, true);
@@ -1027,11 +1028,11 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 
 		this.mainContent = findViewById(R.id.main_content);
-		this.noticeLayout = findViewById(R.id.notice_layout);
-		findViewById(R.id.notice_button_enter_code).setOnClickListener(v -> SMSVerificationDialog.newInstance(userService.getLinkedMobile(true)).show(getSupportFragmentManager(), DIALOG_TAG_VERIFY_CODE));
-		findViewById(R.id.notice_button_cancel).setOnClickListener(v -> GenericAlertDialog.newInstance(R.string.verify_title, R.string.really_cancel_verify, R.string.yes, R.string.no)
+		this.noticeSMSLayout = findViewById(R.id.notice_sms_layout);
+		findViewById(R.id.notice_sms_button_enter_code).setOnClickListener(v -> SMSVerificationDialog.newInstance(userService.getLinkedMobile(true)).show(getSupportFragmentManager(), DIALOG_TAG_VERIFY_CODE));
+		findViewById(R.id.notice_sms_button_cancel).setOnClickListener(v -> GenericAlertDialog.newInstance(R.string.verify_title, R.string.really_cancel_verify, R.string.yes, R.string.no)
 			.show(getSupportFragmentManager(), DIALOG_TAG_CANCEL_VERIFY));
-		this.noticeLayout.setVisibility(
+		this.noticeSMSLayout.setVisibility(
 			userService.getMobileLinkingState() == UserService.LinkingState_PENDING ?
 				View.VISIBLE : View.GONE);
 
@@ -1128,30 +1129,44 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 			Fragment currentFragment = getSupportFragmentManager().findFragmentByTag(currentFragmentTag);
 			if (currentFragment != null) {
-				switch (item.getItemId()) {
-					case R.id.contacts:
-						if (!FRAGMENT_TAG_CONTACTS.equals(currentFragmentTag)) {
-							getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(contactsFragment).commit();
-							currentFragmentTag = FRAGMENT_TAG_CONTACTS;
-						}
-						return true;
-					case R.id.messages:
-						if (!FRAGMENT_TAG_MESSAGES.equals(currentFragmentTag)) {
-							getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(messagesFragment).commit();
-							currentFragmentTag = FRAGMENT_TAG_MESSAGES;
-						}
-						return true;
-					case R.id.my_profile:
-						if (!FRAGMENT_TAG_PROFILE.equals(currentFragmentTag)) {
-							getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(profileFragment).commit();
-							currentFragmentTag = FRAGMENT_TAG_PROFILE;
-						}
-						return true;
+				if (item.getItemId() == R.id.contacts) {
+					if (!FRAGMENT_TAG_CONTACTS.equals(currentFragmentTag)) {
+						getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(contactsFragment).commit();
+						currentFragmentTag = FRAGMENT_TAG_CONTACTS;
+					}
+					return true;
+				} else if (item.getItemId() == R.id.messages) {
+					if (!FRAGMENT_TAG_MESSAGES.equals(currentFragmentTag)) {
+						getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(messagesFragment).commit();
+						currentFragmentTag = FRAGMENT_TAG_MESSAGES;
+					}
+					return true;
+				} else if (item.getItemId() == R.id.my_profile) {
+					if (!FRAGMENT_TAG_PROFILE.equals(currentFragmentTag)) {
+						getSupportFragmentManager().beginTransaction().setCustomAnimations(R.anim.fast_fade_in, R.anim.fast_fade_out, R.anim.fast_fade_in, R.anim.fast_fade_out).hide(currentFragment).show(profileFragment).commit();
+						currentFragmentTag = FRAGMENT_TAG_PROFILE;
+					}
+					return true;
 				}
 			}
 			return false;
 		});
-		this.bottomNavigationView.post(() -> bottomNavigationView.setSelectedItemId(initialItemId));
+		this.bottomNavigationView.post(() -> {
+			bottomNavigationView.setSelectedItemId(initialItemId);
+		});
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			Drawable background = bottomNavigationView.getBackground();
+			if (background instanceof MaterialShapeDrawable) {
+				int color = ((MaterialShapeDrawable) background).getResolvedTintColor();
+				Window window = getWindow();
+				if (window != null) {
+					window.setNavigationBarColor(color);
+					if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+						window.setNavigationBarContrastEnforced(false);
+					}
+				}
+			}
+		}
 
 		updateBottomNavigation();
 
@@ -1273,7 +1288,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	@SuppressLint("StaticFieldLeak")
 	private void reallyCancelVerify() {
-		AnimationUtil.collapse(noticeLayout);
+		AnimationUtil.collapse(noticeSMSLayout);
 		new AsyncTask<Void, Void, Void>() {
 			@Override
 			protected Void doInBackground(Void... params) {
@@ -1302,55 +1317,41 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
 		Intent intent = null;
-
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				showQRPopup();
-				return true;
-			case R.id.menu_lock:
-				lockAppService.lock();
-				return true;
-			case R.id.menu_new_group:
-				intent = new Intent(this, GroupAddActivity.class);
-				break;
-			case R.id.menu_new_distribution_list:
-				intent = new Intent(this, DistributionListAddActivity.class);
-				break;
-			case R.id.group_requests:
-				intent = new Intent(this, OutgoingGroupRequestActivity.class);
-				break;
-			case R.id.my_backups:
-				intent = new Intent(this, BackupAdminActivity.class);
-				break;
-			case R.id.webclient:
-				intent = new Intent(this, SessionsActivity.class);
-				break;
-			case R.id.scanner:
-				intent = new Intent(this, BaseQrScannerActivity.class);
-				break;
-			case R.id.help:
-				intent = new Intent(this, SupportActivity.class);
-				break;
-			case R.id.settings:
-				AnimationUtil.startActivityForResult(this, null, new Intent(this, SettingsActivity.class), ThreemaActivity.ACTIVITY_ID_SETTINGS);
-				break;
-			case R.id.directory:
-				intent = new Intent(this, DirectoryActivity.class);
-				break;
-			case R.id.threema_channel:
-				confirmThreemaChannel();
-				break;
-			case R.id.archived:
-				intent = new Intent(this, ArchiveActivity.class);
-				break;
-			case R.id.globalsearch:
-				intent = new Intent(this, GlobalSearchActivity.class);
-			default:
-				break;
+		final int id = item.getItemId();
+		if (id == android.R.id.home) {
+			showQRPopup();
+			return true;
+		} else if (id == R.id.menu_lock) {
+			lockAppService.lock();
+			return true;
+		} else if (id == R.id.menu_new_group) {
+			intent = new Intent(this, GroupAddActivity.class);
+		} else if (id == R.id.menu_new_distribution_list) {
+			intent = new Intent(this, DistributionListAddActivity.class);
+		} else if (id == R.id.group_requests) {
+			intent = new Intent(this, OutgoingGroupRequestActivity.class);
+		} else if (id == R.id.my_backups) {
+			intent = new Intent(this, BackupAdminActivity.class);
+		} else if (id == R.id.webclient) {
+			intent = new Intent(this, SessionsActivity.class);
+		} else if (id == R.id.scanner) {
+			intent = new Intent(this, BaseQrScannerActivity.class);
+		} else if (id == R.id.help) {
+			intent = new Intent(this, SupportActivity.class);
+		} else if (id == R.id.settings) {
+			startActivityForResult(new Intent(this, SettingsActivity.class), ThreemaActivity.ACTIVITY_ID_SETTINGS);
+		} else if (id == R.id.directory) {
+			intent = new Intent(this, DirectoryActivity.class);
+		} else if (id == R.id.threema_channel) {
+			confirmThreemaChannel();
+		} else if (id == R.id.archived) {
+			intent = new Intent(this, ArchiveActivity.class);
+		} else if (id == R.id.globalsearch) {
+			intent = new Intent(this, GlobalSearchActivity.class);
 		}
 
 		if (intent != null) {
-			AnimationUtil.startActivity(this, null, intent);
+			startActivity(intent);
 		}
 
 		return super.onOptionsItemSelected(item);
@@ -1366,7 +1367,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		layoutParams.height = ConfigUtils.getActionBarSize(this) / 3;
 		toolbarLogoMain.setLayoutParams(layoutParams);
 		toolbarLogoMain.setImageResource(R.drawable.logo_main);
-		toolbarLogoMain.setColorFilter(ConfigUtils.getColorFromAttribute(this, android.R.attr.textColorSecondary),
+		toolbarLogoMain.setColorFilter(ConfigUtils.getColorFromAttribute(this, R.attr.colorOnSurface),
 			PorterDuff.Mode.SRC_IN);
 		toolbarLogoMain.setContentDescription(getString(R.string.logo));
 		toolbarLogoMain.setOnClickListener(v -> {
@@ -1409,7 +1410,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 					privateChatToggleMenuItem.setIcon(R.drawable.ic_outline_visibility_off);
 					privateChatToggleMenuItem.setTitle(R.string.title_hide_private_chats);
 				}
-				ConfigUtils.themeMenuItem(privateChatToggleMenuItem, ConfigUtils.getColorFromAttribute(this, R.attr.textColorSecondary));
+				ConfigUtils.tintMenuItem(this, privateChatToggleMenuItem, R.attr.colorOnSurface);
 			}
 
 			Boolean addDisabled;
@@ -1461,7 +1462,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 			MenuItem webclientMenuItem = menu.findItem(R.id.webclient);
 			if (webclientMenuItem != null) {
-				webclientMenuItem.setVisible(!(webDisabled || ConfigUtils.isBlackBerry()));
+				webclientMenuItem.setVisible(!(webDisabled));
 			}
 
 			return true;
@@ -1718,8 +1719,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 		File customAppIcon = null;
 		try {
-			customAppIcon = serviceManager.getFileService()
-				.getAppLogo(ConfigUtils.getAppTheme(this));
+			customAppIcon = serviceManager.getFileService().getAppLogo(ConfigUtils.getAppThemeSettingFromDayNightMode(ConfigUtils.getCurrentDayNightMode(this)));
 		} catch (FileSystemNotPresentException e) {
 			logger.error("Exception", e);
 		}
@@ -1816,7 +1816,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	private void confirmThreemaChannel() {
 		if (contactService.getByIdentity(THREEMA_CHANNEL_IDENTITY) == null) {
-			GenericAlertDialog.newInstance(R.string.threema_channel, R.string.threema_channel_intro, R.string.ok, R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_THREEMA_CHANNEL_VERIFY);
+			GenericAlertDialog.newInstance(R.string.threema_channel, R.string.threema_channel_intro, R.string.ok, R.string.cancel, 0).show(getSupportFragmentManager(), DIALOG_TAG_THREEMA_CHANNEL_VERIFY);
 		} else {
 			launchThreemaChannelChat();
 		}

+ 15 - 19
app/src/main/java/ch/threema/app/activities/IdentityListActivity.java

@@ -29,12 +29,6 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 
-import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.ActionMode;
 import androidx.fragment.app.DialogFragment;
@@ -42,6 +36,13 @@ import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.DividerItemDecoration;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.IdentityListAdapter;
@@ -53,7 +54,6 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
@@ -266,8 +266,6 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
 		@Override
 		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
 			mode.getMenuInflater().inflate(R.menu.action_identity_list, menu);
-			ConfigUtils.themeMenu(menu, ConfigUtils.getColorFromAttribute(IdentityListActivity.this, R.attr.colorAccent));
-
 			return true;
 		}
 
@@ -279,17 +277,15 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
 
 		@Override
 		public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
-			switch (item.getItemId()) {
-				case R.id.menu_identity_remove:
-					IdentityListAdapter.Entity selectedEntity = adapter.getSelected();
-					if (selectedEntity != null) {
-						removeIdentity(selectedEntity.getText());
-					}
-					mode.finish();
-					return true;
-				default:
-					return false;
+			if (item.getItemId() == R.id.menu_identity_remove) {
+				IdentityListAdapter.Entity selectedEntity = adapter.getSelected();
+				if (selectedEntity != null) {
+					removeIdentity(selectedEntity.getText());
+				}
+				mode.finish();
+				return true;
 			}
+			return false;
 		}
 
 		@Override

+ 384 - 58
app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java

@@ -21,7 +21,12 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.utils.BitmapUtil.FLIP_HORIZONTAL;
+import static ch.threema.app.utils.BitmapUtil.FLIP_NONE;
+import static ch.threema.app.utils.BitmapUtil.FLIP_VERTICAL;
+
 import android.annotation.SuppressLint;
+import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
@@ -36,7 +41,9 @@ import android.graphics.drawable.Drawable;
 import android.media.FaceDetector;
 import android.net.Uri;
 import android.os.AsyncTask;
+import android.os.Build;
 import android.os.Bundle;
+import android.provider.MediaStore;
 import android.view.KeyEvent;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -45,13 +52,24 @@ import android.view.ViewGroup;
 import android.view.ViewStub;
 import android.widget.FrameLayout;
 import android.widget.ImageView;
-import android.widget.ProgressBar;
 import android.widget.Toast;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.content.ContextCompat;
+import androidx.core.view.ViewCompat;
+
 import com.android.colorpicker.ColorPickerDialog;
 import com.android.colorpicker.ColorPickerSwatch;
 import com.getkeepsafe.taptargetview.TapTarget;
 import com.getkeepsafe.taptargetview.TapTargetView;
+import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -66,18 +84,14 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.LinkedList;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.content.ContextCompat;
-import androidx.core.view.ViewCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -91,15 +105,20 @@ import ch.threema.app.motionviews.FaceItem;
 import ch.threema.app.motionviews.viewmodel.Font;
 import ch.threema.app.motionviews.viewmodel.Layer;
 import ch.threema.app.motionviews.viewmodel.TextLayer;
+import ch.threema.app.motionviews.widget.ActionEntity;
+import ch.threema.app.motionviews.widget.CropEntity;
 import ch.threema.app.motionviews.widget.FaceBlurEntity;
 import ch.threema.app.motionviews.widget.FaceEmojiEntity;
 import ch.threema.app.motionviews.widget.FaceEntity;
+import ch.threema.app.motionviews.widget.FlipEntity;
 import ch.threema.app.motionviews.widget.ImageEntity;
 import ch.threema.app.motionviews.widget.MotionEntity;
 import ch.threema.app.motionviews.widget.MotionView;
 import ch.threema.app.motionviews.widget.PathEntity;
+import ch.threema.app.motionviews.widget.RotationEntity;
 import ch.threema.app.motionviews.widget.TextEntity;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.FileService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
@@ -122,8 +141,6 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.GroupModel;
 
-import static ch.threema.app.utils.BitmapUtil.FLIP_NONE;
-
 public class ImagePaintActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ImagePaintActivity");
 
@@ -163,31 +180,79 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	private static final int STROKE_MODE_PENCIL = 1;
 	private static final int MAX_FACES = 16;
 
+	private static final int ANIMATION_DURATION_MS = 200;
+
+	private static final Set<Class<? extends ActionEntity>> allowedActionsEntitiesToCrop = new HashSet<>();
+	static {
+		allowedActionsEntitiesToCrop.add(RotationEntity.class);
+		allowedActionsEntitiesToCrop.add(FlipEntity.class);
+		allowedActionsEntitiesToCrop.add(CropEntity.class);
+	}
+
 	private ImageView imageView;
 	private PaintView paintView;
 	private MotionView motionView;
 	private FrameLayout imageFrame;
 	private LockableScrollView scrollView;
 	private ComposeEditText captionEditText;
-	private ProgressBar progressBar;
+	private CircularProgressIndicator progressBar;
 	private EmojiPicker emojiPicker;
 
-	private int orientation, exifOrientation, flip, exifFlip, clipWidth, clipHeight;
+	private int clipWidth, clipHeight;
 
 	private File inputFile;
 	private Uri imageUri, outputUri;
+	private MediaItem mediaItem;
 
 	@ColorInt private int penColor, backgroundColor;
 
-	private MenuItem undoItem, drawParentItem, paintItem, pencilItem, blurFacesItem;
+	private MenuItem undoItem, drawParentItem, paintItem, pencilItem, blurFacesItem, cropItem;
 	private Drawable brushIcon, pencilIcon;
 	private PaintSelectionPopup paintSelectionPopup;
-	private final ArrayList<MotionEntity> undoHistory = new ArrayList<>();
+	private final Deque<ActionEntity> undoHistory = new LinkedList<>();
+	private long lastAnimationStart = 0;
+	private final MediaItem.Orientation currentOrientation = new MediaItem.Orientation();
 	private boolean saveSemaphore = false;
 	private int strokeMode = STROKE_MODE_BRUSH;
 	private ActivityMode activityMode = ActivityMode.EDIT_IMAGE;
 	private int groupId = -1;
 	private final ExecutorService threadPoolExecutor = Executors.newSingleThreadExecutor();
+	private File cropFile;
+
+	private final ActivityResultLauncher<Intent> cropResultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			if (result.getResultCode() == Activity.RESULT_OK
+				&& activityMode == ActivityMode.IMAGE_REPLY
+				&& cropFile != null
+				&& mediaItem != null
+			) {
+				// Add crop entity to undo history
+				undoHistory.push(new CropEntity(
+					mediaItem.getUri(),
+					new MediaItem.Orientation(mediaItem.getRotation(), mediaItem.getFlip()))
+				);
+
+				imageUri = Uri.fromFile(cropFile);
+				mediaItem.setUri(imageUri);
+				// As the image is saved with the current orientation applied, we need to apply the
+				// inverse orientation to get it in the original orientation.
+				MediaItem.Orientation inverseOrientation = currentOrientation.getInverse();
+				// As the flip is applied before the rotation, we may need to swap the flips,
+				// because a horizontal flip on a 90 or 270 rotated image is a vertical flip.
+				if (inverseOrientation.getRotation() == 90 || inverseOrientation.getRotation() == 270) {
+					inverseOrientation = getSwappedFlips(inverseOrientation);
+				}
+				mediaItem.setRotation(inverseOrientation.getRotation());
+				mediaItem.setFlip(inverseOrientation.getFlip());
+
+				resetViewOrientation(imageView);
+				resetViewOrientation(motionView);
+				resetViewOrientation(paintView);
+
+				loadImage(this::applyCurrentOrientation);
+				invalidateOptionsMenu();
+			}
+		});
 
 	/**
 	 * Returns an intent to start the activity for editing a picture. The edited picture is stored
@@ -280,7 +345,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	}
 
 	private boolean hasChanges() {
-		return undoHistory.size() > 0;
+		return !undoHistory.isEmpty();
 	}
 
 	@Override
@@ -311,7 +376,17 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			@Override
 			protected Bitmap doInBackground(Void... params) {
 				try {
-					return BitmapFactory.decodeStream(getAssets().open(stickerPath));
+					Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open(stickerPath));
+					boolean isFlippedHorizontally = isFlippedHorizontally();
+					boolean isFlippedVertically = isFlippedVertically();
+					float rotation = imageView.getRotation();
+					if (isFlippedHorizontally || isFlippedVertically || rotation != 0) {
+						Matrix matrix = new Matrix();
+						matrix.postRotate(-rotation);
+						matrix.postScale(isFlippedHorizontally ? -1 : 1, isFlippedVertically ? -1 : 1);
+						bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+					}
+					return bitmap;
 				} catch (IOException e) {
 					logger.error("Exception", e);
 					return null;
@@ -345,6 +420,16 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		textLayer.setFont(font);
 		textLayer.setText(text);
+		textLayer.setRotationInDegrees(-imageView.getRotation());
+		int rotation = (int) imageView.getRotation() % 360;
+		if (rotation < 0) {
+			rotation += 360;
+		}
+		if (rotation == 90 || rotation == 270) {
+			textLayer.setFlipped(imageView.getScaleY() < 0);
+		} else {
+			textLayer.setFlipped(imageView.getScaleX() < 0);
+		}
 
 		TextEntity textEntity = new TextEntity(textLayer, motionView.getWidth(),
 				motionView.getHeight());
@@ -362,7 +447,11 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		groupId = intent.getIntExtra(EXTRA_GROUP_ID, -1);
 
-		MediaItem mediaItem = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+			mediaItem = intent.getParcelableExtra(Intent.EXTRA_STREAM, MediaItem.class);
+		} else {
+			mediaItem = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+		}
 
 		try {
 			String activityModeOrdinal = intent.getStringExtra(EXTRA_ACTIVITY_MODE);
@@ -373,13 +462,6 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			return;
 		}
 
-		if (mediaItem != null) {
-			this.orientation = mediaItem.getRotation();
-			this.flip = mediaItem.getFlip();
-			this.exifOrientation = mediaItem.getExifRotation();
-			this.exifFlip = mediaItem.getExifFlip();
-		}
-
 		this.outputUri = intent.getParcelableExtra(ThreemaApplication.EXTRA_OUTPUT_FILE);
 
 		setSupportActionBar(getToolbar());
@@ -391,6 +473,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		}
 
 		actionBar.setDisplayHomeAsUpEnabled(activityMode == ActivityMode.EDIT_IMAGE);
+		actionBar.setHomeAsUpIndicator(R.drawable.ic_check);
 		actionBar.setTitle("");
 
 		this.paintView = findViewById(R.id.paint_view);
@@ -431,14 +514,11 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 			@Override
 			public void onAdded() {
-				undoHistory.add(new PathEntity());
+				undoHistory.push(new PathEntity());
 			}
 
 			@Override
 			public void onDeleted() {
-				if (undoHistory.size() > 0) {
-					undoHistory.remove(undoHistory.size() - 1);
-				}
 			}
 		});
 
@@ -455,7 +535,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 			@Override
 			public void onAdded(MotionEntity entity) {
-				undoHistory.add(entity);
+				undoHistory.push(entity);
 			}
 
 			@SuppressLint("UseValueOf")
@@ -615,16 +695,18 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		});
 	}
 
-	private void loadImage() {
+	private void loadImage(@Nullable Runnable onLoaded) {
 		BitmapWorkerTaskParams bitmapParams = new BitmapWorkerTaskParams();
 		bitmapParams.imageUri = this.imageUri;
 		bitmapParams.width = this.imageFrame.getWidth();
 		bitmapParams.height = this.scrollView.getHeight();
 		bitmapParams.contentResolver = getContentResolver();
-		bitmapParams.orientation = this.orientation;
-		bitmapParams.flip = this.flip;
-		bitmapParams.exifOrientation = this.exifOrientation;
-		bitmapParams.exifFlip = this.exifFlip;
+		if (mediaItem != null) {
+			bitmapParams.orientation = mediaItem.getRotation();
+			bitmapParams.flip = mediaItem.getFlip();
+			bitmapParams.exifOrientation = mediaItem.getExifRotation();
+			bitmapParams.exifFlip = mediaItem.getExifFlip();
+		}
 
 		logger.debug("screen height: {}", bitmapParams.height);
 
@@ -650,6 +732,10 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 					resizeView(paintView, clipWidth, clipHeight);
 					resizeView(motionView, clipWidth, clipHeight);
 				}
+
+				if (onLoaded != null) {
+					onLoaded.run();
+				}
 			}
 		}.execute(bitmapParams);
 	}
@@ -707,6 +793,14 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 				options.inPreferredConfig = Bitmap.Config.ARGB_8888;
 				options.inJustDecodeBounds = false;
 
+				int orientation = 0, flip = FLIP_NONE, exifOrientation = 0, exifFlip = FLIP_NONE;
+				if (mediaItem != null) {
+					orientation = mediaItem.getRotation();
+					flip = mediaItem.getFlip();
+					exifOrientation = mediaItem.getExifRotation();
+					exifFlip = mediaItem.getExifFlip();
+				}
+
 				try (InputStream data = getContentResolver().openInputStream(imageUri)) {
 					if (data != null) {
 						orgBitmap = BitmapFactory.decodeStream(new BufferedInputStream(data), null, options);
@@ -828,26 +922,38 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			drawParentItem.setIcon(brushIcon);
 		}
 
-		ConfigUtils.themeMenuItem(drawParentItem, Color.WHITE);
-		ConfigUtils.themeMenuItem(paintItem, Color.WHITE);
-		ConfigUtils.themeMenuItem(pencilItem, Color.WHITE);
+		ConfigUtils.tintMenuItem(this, drawParentItem, R.attr.colorOnSurface);
+		ConfigUtils.tintMenuItem(this, paintItem, R.attr.colorOnSurface);
+		ConfigUtils.tintMenuItem(this, pencilItem, R.attr.colorOnSurface);
 
 		if (motionView.getSelectedEntity() == null) {
 			// no selected entities => draw mode or neutral mode
 			if (paintView.getActive()) {
 				if (this.strokeMode == STROKE_MODE_PENCIL) {
-					ConfigUtils.themeMenuItem(pencilItem, this.penColor);
+					ConfigUtils.tintMenuItem(pencilItem, this.penColor);
 					drawParentItem.setIcon(pencilIcon);
-					ConfigUtils.themeMenuItem(drawParentItem, this.penColor);
+					ConfigUtils.tintMenuItem(drawParentItem, this.penColor);
 				} else {
-					ConfigUtils.themeMenuItem(paintItem, this.penColor);
+					ConfigUtils.tintMenuItem(paintItem, this.penColor);
 					drawParentItem.setIcon(brushIcon);
-					ConfigUtils.themeMenuItem(drawParentItem, this.penColor);
+					ConfigUtils.tintMenuItem(drawParentItem, this.penColor);
 				}
 			}
 		}
-		undoItem.setVisible(undoHistory.size() > 0);
+		undoItem.setVisible(hasChanges());
 		blurFacesItem.setVisible(activityMode != ActivityMode.DRAWING && motionView.getEntitiesCount() == 0);
+
+		if (activityMode == ActivityMode.IMAGE_REPLY) {
+			// Cropping is currently not possible when the image already has been edited. However,
+			// if the image has only been rotated or flipped, it is still possible to crop it.
+			cropItem.setVisible(true);
+			for (ActionEntity action : undoHistory) {
+				if (!allowedActionsEntitiesToCrop.contains(action.getClass())) {
+					cropItem.setVisible(false);
+					break;
+				}
+			}
+		}
 		return true;
 	}
 
@@ -865,8 +971,15 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		if (activityMode == ActivityMode.DRAWING) {
 			menu.findItem(R.id.item_background).setVisible(true);
+		} else if (activityMode == ActivityMode.IMAGE_REPLY) {
+			menu.findItem(R.id.item_flip).setVisible(true);
+			menu.findItem(R.id.item_rotate).setVisible(true);
+			cropItem = menu.findItem(R.id.item_crop);
+			cropItem.setVisible(true);
 		}
 
+		ConfigUtils.addIconsToOverflowMenu(this, menu);
+
 		return true;
 	}
 
@@ -876,7 +989,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		int id = item.getItemId();
 		if (id == android.R.id.home) {
-			if (undoHistory.size() > 0) {
+			if (hasChanges()) {
 				item.setEnabled(false);
 				renderImage();
 			} else {
@@ -913,6 +1026,18 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			blurFaces(true);
 		} else if (id == R.id.item_background) {
 			chooseBackgroundColor();
+		} else if (id == R.id.item_flip) {
+			if (lastAnimationStart + ANIMATION_DURATION_MS < System.currentTimeMillis()) {
+				flip();
+				lastAnimationStart = System.currentTimeMillis();
+			}
+		} else if (id == R.id.item_rotate) {
+			if (lastAnimationStart + ANIMATION_DURATION_MS < System.currentTimeMillis()) {
+				rotate();
+				lastAnimationStart = System.currentTimeMillis();
+			}
+		} else if (id == R.id.item_crop) {
+			crop();
 		}
 		return false;
 	}
@@ -923,17 +1048,18 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			if (getToolbar() != null) {
 				getToolbar().postDelayed(() -> {
 					final View v = findViewById(R.id.item_face);
+					final @ColorInt int textColor = ConfigUtils.getColorFromAttribute(this, R.attr.colorOnPrimary);
 					try {
 						TapTargetView.showFor(this,
 							TapTarget.forView(v, getString(R.string.face_blur_tooltip_title), getString(R.string.face_blur_tooltip_text))
-								.outerCircleColor(R.color.dark_accent)      // Specify a color for the outer circle
+								.outerCircleColorInt(ConfigUtils.getColorFromAttribute(this, R.attr.colorPrimary)) // Specify a color for the outer circle
 								.outerCircleAlpha(0.96f)            // Specify the alpha amount for the outer circle
 								.targetCircleColor(android.R.color.white)   // Specify a color for the target circle
 								.titleTextSize(24)                  // Specify the size (in sp) of the title text
-								.titleTextColor(android.R.color.white)      // Specify the color of the title text
+								.titleTextColorInt(textColor)      // Specify the color of the title text
 								.descriptionTextSize(18)            // Specify the size (in sp) of the description text
-								.descriptionTextColor(android.R.color.white)  // Specify the color of the description text
-								.textColor(android.R.color.white)            // Specify a color for both the title and description text
+								.descriptionTextColorInt(textColor)  // Specify the color of the description text
+								.textColorInt(textColor)            // Specify a color for both the title and description text
 								.textTypeface(Typeface.SANS_SERIF)  // Specify a typeface for the text
 								.dimColor(android.R.color.black)            // If set, will dim behind the view with 30% opacity of the given color
 								.drawShadow(true)                   // Whether to draw a drop shadow or not
@@ -984,16 +1110,23 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	}
 
 	private void undo() {
-		if (undoHistory.size() > 0) {
-			MotionEntity entity = undoHistory.get(undoHistory.size() - 1);
+		if (hasChanges() && lastAnimationStart + ANIMATION_DURATION_MS + 100 < System.currentTimeMillis()) {
+			ActionEntity entity = undoHistory.pop();
 
 			motionView.unselectEntity();
 			if (entity instanceof PathEntity) {
 				paintView.undo();
-			} else {
-				motionView.deleteEntity(entity);
+			} else if (entity instanceof RotationEntity) {
+				undoRotate();
+			} else if (entity instanceof FlipEntity) {
+				undoFlip();
+			} else if (entity instanceof CropEntity) {
+				undoCrop((CropEntity) entity);
+			} else if (entity instanceof MotionEntity) {
+				motionView.deleteEntity((MotionEntity) entity);
 			}
 			invalidateOptionsMenu();
+			lastAnimationStart = System.currentTimeMillis();
 		}
 	}
 
@@ -1040,7 +1173,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 				// Otherwise we can remove this listener and load the image
 				if (imageFrame.getMinimumHeight() <= scrollView.getHeight()) {
 					scrollView.removeOnLayoutChangeListener(this);
-					loadImage();
+					loadImage(null);
 				}
 			}
 		});
@@ -1109,10 +1242,12 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		BitmapWorkerTaskParams bitmapParams = new BitmapWorkerTaskParams();
 		bitmapParams.imageUri = this.imageUri;
 		bitmapParams.contentResolver = getContentResolver();
-		bitmapParams.orientation = this.orientation;
-		bitmapParams.flip = this.flip;
-		bitmapParams.exifOrientation = this.exifOrientation;
-		bitmapParams.exifFlip = this.exifFlip;
+		if (mediaItem != null) {
+			bitmapParams.orientation = mediaItem.getRotation();
+			bitmapParams.flip = mediaItem.getFlip();
+			bitmapParams.exifOrientation = mediaItem.getExifRotation();
+			bitmapParams.exifFlip = mediaItem.getExifFlip();
+		}
 		bitmapParams.mutable = true;
 
 		new BitmapWorkerTask(null) {
@@ -1137,7 +1272,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 							File output = new File(outputUri.getPath());
 
 							FileOutputStream outputStream = new FileOutputStream(output);
-							params[0].compress(Bitmap.CompressFormat.PNG, 100, outputStream);
+							Matrix matrix = currentOrientation.getTransformationMatrix();
+							Bitmap transformed = Bitmap.createBitmap(params[0], 0, 0, params[0].getWidth(), params[0].getHeight(), matrix, true);
+							transformed.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
 							outputStream.flush();
 							outputStream.close();
 						} catch (Exception e) {
@@ -1307,6 +1444,195 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		return emojiPicker != null && emojiPicker.isShown();
 	}
 
+	private void flip() {
+		flipViewsAnimated();
+		if (hasChanges() && undoHistory.peek() instanceof FlipEntity) {
+			// Remove the previous flip action instead of creating two consecutive flip actions
+			undoHistory.pop();
+		} else {
+			undoHistory.push(new FlipEntity());
+		}
+		invalidateOptionsMenu();
+	}
+
+	private void undoFlip() {
+		flipViewsAnimated();
+	}
+
+	private void flipViewsAnimated() {
+		int previousFlip = currentOrientation.getFlip();
+		currentOrientation.flip();
+		int currentFlip = currentOrientation.getFlip();
+
+		flipViewAnimated(imageView, previousFlip, currentFlip);
+		flipViewAnimated(motionView, previousFlip, currentFlip);
+		flipViewAnimated(paintView, previousFlip, currentFlip);
+	}
+
+	private void flipViewAnimated(@NonNull View view, int previousFlip, int newFlip) {
+		if ((previousFlip & FLIP_HORIZONTAL) != (newFlip & FLIP_HORIZONTAL)) {
+			flipViewHorizontalAnimated(view);
+		}
+		if ((previousFlip & FLIP_VERTICAL) != (newFlip & FLIP_VERTICAL)) {
+			flipViewVerticalAnimated(view);
+		}
+	}
+
+	private void flipViewHorizontalAnimated(@NonNull View view) {
+		view.animate().scaleX(view.getScaleX() * -1f).setDuration(ANIMATION_DURATION_MS).start();
+	}
+
+	private void flipViewVerticalAnimated(@NonNull View view) {
+		view.animate().scaleY(view.getScaleY() * -1f).setDuration(ANIMATION_DURATION_MS).start();
+	}
+
+	private void resetViewOrientation(@NonNull View view) {
+		view.setScaleX(1f);
+		view.setScaleY(1f);
+		view.setRotation(0f);
+	}
+
+	private boolean isFlippedHorizontally() {
+		return imageView.getScaleX() < 0f;
+	}
+
+	private boolean isFlippedVertically() {
+		return imageView.getScaleY() < 0f;
+	}
+
+	private void rotate() {
+		rotateBy(-90);
+		undoHistory.push(new RotationEntity());
+		invalidateOptionsMenu();
+	}
+
+	private void undoRotate() {
+		rotateBy(90);
+	}
+
+	private void rotateBy(int degrees) {
+		// Rotate views
+		currentOrientation.rotateBy(degrees);
+
+		rotateViewAnimated(imageView, degrees);
+		rotateViewAnimated(motionView, degrees);
+		rotateViewAnimated(paintView, degrees);
+	}
+
+	private void rotateViewAnimated(@NonNull View view, int degrees) {
+		int rotation = ((int) view.getRotation()) % 360;
+		if (rotation < 0) {
+			rotation += 360;
+		}
+		boolean invertedDimensions = rotation == 90 || rotation == 270;
+		float newWidth = invertedDimensions ? view.getWidth() : view.getHeight();
+		float newHeight = invertedDimensions ? view.getHeight() : view.getWidth();
+		float scale = getTargetScale(newWidth, newHeight);
+		float xScaleNormalized = view.getScaleX() < 0 ? -1 : 1;
+		float yScaleNormalized = view.getScaleY() < 0 ? -1 : 1;
+
+		view.animate()
+			.rotationBy(degrees)
+			.scaleX(xScaleNormalized * scale)
+			.scaleY(yScaleNormalized * scale)
+			.setDuration(ANIMATION_DURATION_MS)
+			.start();
+	}
+
+	private float getTargetScale(float width, float height) {
+		float parentWidth = scrollView.getWidth();
+		float parentHeight = scrollView.getHeight();
+		return Math.min(parentWidth / width, parentHeight / height);
+	}
+
+	private void crop() {
+		try {
+			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+			if (serviceManager == null) {
+				logger.error("Service manager is null");
+				return;
+			}
+			FileService fileService = serviceManager.getFileService();
+			cropFile = fileService.createTempFile(".crop", ".png");
+
+			Intent intent = new Intent(this, CropImageActivity.class);
+			intent.setData(imageUri);
+			intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(cropFile));
+			// The rotation and flip to load the image 'correctly'
+			intent.putExtra(ThreemaApplication.EXTRA_ORIENTATION, mediaItem.getRotation());
+			intent.putExtra(ThreemaApplication.EXTRA_FLIP, mediaItem.getFlip());
+			// The rotation and flip that has been applied in the image paint activity
+			intent.putExtra(CropImageActivity.EXTRA_ADDITIONAL_ORIENTATION, currentOrientation.getRotation());
+			intent.putExtra(CropImageActivity.EXTRA_ADDITIONAL_FLIP, currentOrientation.getFlip());
+			intent.putExtra(CropImageActivity.FORCE_DARK_THEME, true);
+
+			cropResultLauncher.launch(intent);
+		} catch (FileSystemNotPresentException | IOException e) {
+			logger.debug("Unable to create temp file for crop");
+		}
+	}
+
+	private void undoCrop(@NonNull CropEntity cropEntity) {
+		imageView.setAlpha(0f);
+		motionView.setAlpha(0f);
+		paintView.setAlpha(0f);
+
+		resetViewOrientation(imageView);
+		resetViewOrientation(motionView);
+		resetViewOrientation(paintView);
+
+		imageUri = cropEntity.getLastUri();
+		mediaItem.setUri(imageUri);
+		mediaItem.setRotation(cropEntity.getOrientation().getRotation());
+		mediaItem.setFlip(cropEntity.getOrientation().getFlip());
+		loadImage(() -> {
+			applyCurrentOrientation();
+			animateFadeIn(imageView);
+			animateFadeIn(motionView);
+			animateFadeIn(paintView);
+		});
+	}
+
+	private void animateFadeIn(@NonNull View view) {
+		view.animate()
+			.alpha(1f)
+			.setDuration(ANIMATION_DURATION_MS)
+			.start();
+	}
+
+	private MediaItem.Orientation getSwappedFlips(@NonNull MediaItem.Orientation orientation) {
+		MediaItem.Orientation swappedOrientation = new MediaItem.Orientation(orientation.getRotation(), FLIP_NONE);
+		boolean isHorizontalFlip = orientation.isHorizontalFlip();
+		boolean isVerticalFlip = orientation.isVerticalFlip();
+		swappedOrientation.setFlip(
+			(isHorizontalFlip ? FLIP_VERTICAL : FLIP_NONE)
+				| (isVerticalFlip ? FLIP_HORIZONTAL : FLIP_NONE)
+		);
+		return swappedOrientation;
+	}
+
+	private void applyCurrentOrientation() {
+		imageView.setRotation(currentOrientation.getRotation());
+		motionView.setRotation(currentOrientation.getRotation());
+		paintView.setRotation(currentOrientation.getRotation());
+
+		float scaleX = currentOrientation.isHorizontalFlip() ? -1 : 1;
+		float scaleY = currentOrientation.isVerticalFlip() ? -1 : 1;
+
+		boolean inverted = currentOrientation.getRotation() == 90 || currentOrientation.getRotation() == 270;
+		float width = inverted ? imageView.getHeight() : imageView.getWidth();
+		float height = inverted ? imageView.getWidth() : imageView.getHeight();
+		float scale = getTargetScale(width, height);
+
+		imageView.setScaleX(scaleX * scale);
+		imageView.setScaleY(scaleY * scale);
+		motionView.setScaleX(scaleX * scale);
+		motionView.setScaleY(scaleY * scale);
+		paintView.setScaleX(scaleX * scale);
+		paintView.setScaleY(scaleY * scale);
+	}
+
+
 	@Override
 	public void onSaveInstanceState(@NonNull Bundle outState) {
 		super.onSaveInstanceState(outState);

+ 4 - 3
app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java

@@ -36,12 +36,13 @@ import android.view.ViewTreeObserver;
 import android.view.inputmethod.EditorInfo;
 import android.widget.TextView;
 
-import java.util.Objects;
-
 import androidx.annotation.ColorInt;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.content.res.AppCompatResources;
 import androidx.appcompat.widget.AppCompatEditText;
+
+import java.util.Objects;
+
 import ch.threema.app.R;
 import ch.threema.app.motionviews.widget.TextEntity;
 import ch.threema.app.utils.ConfigUtils;
@@ -69,7 +70,7 @@ public class ImagePaintKeyboardActivity extends ThreemaToolbarActivity {
 		}
 
 		Drawable checkDrawable = AppCompatResources.getDrawable(this, R.drawable.ic_check);
-		Objects.requireNonNull(checkDrawable).setColorFilter(ConfigUtils.getColorFromAttribute(this, R.attr.textColorPrimary), PorterDuff.Mode.SRC_IN);
+		Objects.requireNonNull(checkDrawable).setColorFilter(ConfigUtils.getColorFromAttribute(this, R.attr.colorOnBackground), PorterDuff.Mode.SRC_IN);
 		actionBar.setDisplayHomeAsUpEnabled(true);
 		actionBar.setHomeAsUpIndicator(checkDrawable);
 		actionBar.setTitle("");

+ 7 - 20
app/src/main/java/ch/threema/app/activities/LicenseActivity.java

@@ -28,32 +28,19 @@ import android.webkit.WebView;
 
 import ch.threema.app.R;
 
-public class LicenseActivity extends ThreemaToolbarActivity {
-	public void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		ActionBar actionBar = getSupportActionBar();
-		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			actionBar.setTitle(R.string.os_licenses);
-		}
-
-		final WebView webView = findViewById(R.id.license_webview);
-		webView.loadUrl("file:///android_asset/license.html");
+public class LicenseActivity extends SimpleWebViewActivity {
+	@Override
+	protected int getWebViewTitle() {
+		return R.string.os_licenses;
 	}
 
 	@Override
-	public int getLayoutResource() {
-		return R.layout.activity_license;
+	protected String getWebViewUrl() {
+		return "file:///android_asset/license.html";
 	}
 
 	@Override
-	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				finish();
-				break;
-		}
+	protected boolean requiresConnection() {
 		return false;
 	}
 }

+ 15 - 21
app/src/main/java/ch/threema/app/activities/MapActivity.java

@@ -21,11 +21,6 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LNG;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_PROVIDER;
-
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.ActivityNotFoundException;
@@ -40,7 +35,6 @@ import android.location.LocationManager;
 import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
-import android.os.StrictMode;
 import android.provider.Settings;
 import android.view.View;
 import android.view.WindowManager;
@@ -55,7 +49,7 @@ import androidx.core.view.OnApplyWindowInsetsListener;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
 
-import com.google.android.material.chip.Chip;
+import com.google.android.material.button.MaterialButton;
 import com.mapbox.mapboxsdk.annotations.IconFactory;
 import com.mapbox.mapboxsdk.annotations.MarkerOptions;
 import com.mapbox.mapboxsdk.camera.CameraUpdate;
@@ -92,6 +86,11 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.data.LocationDataModel;
 
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LNG;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_PROVIDER;
+
 public class MapActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("MapActivity");
 
@@ -126,17 +125,12 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		if (BuildConfig.DEBUG) {
-			StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
-					.detectAll()
-					.penaltyLog()
-					.build());
-		}
-
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		setContentView(R.layout.activity_map);
 
+		ConfigUtils.configureTransparentStatusBar(this);
+
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
 		getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
@@ -209,14 +203,14 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 	private void initUi() {
 		findViewById(R.id.coordinator).setVisibility(View.VISIBLE);
 		findViewById(R.id.center_map).setOnClickListener((it -> zoomToCenter()));
-		Chip openChip = findViewById(R.id.open_chip);
-		Chip shareChip = findViewById(R.id.share_chip);
+		MaterialButton openButton = findViewById(R.id.open_button);
+		MaterialButton shareButton = findViewById(R.id.share_location_button);
 		if (isShowingExternalLocation) {
-			shareChip.setOnClickListener((it -> shareLocation()));
-			openChip.setVisibility(View.GONE);
+			shareButton.setOnClickListener((it -> shareLocation()));
+			openButton.setVisibility(View.GONE);
 		} else {
-			openChip.setOnClickListener((it -> openExternal()));
-			shareChip.setVisibility(View.GONE);
+			openButton.setOnClickListener((it -> openExternal()));
+			shareButton.setVisibility(View.GONE);
 		}
 		TextView locationName = findViewById(R.id.location_name);
 		TextView locationCoordinates = findViewById(R.id.location_coordinates);

+ 429 - 403
app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java

@@ -21,52 +21,62 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.fragments.ComposeMessageFragment.SCROLLBUTTON_VIEW_TIMEOUT;
+import static ch.threema.app.utils.RecyclerViewUtil.thumbScrollerPopupStyle;
 
 import android.Manifest;
+import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
-import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
-import android.content.res.TypedArray;
+import android.graphics.Outline;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
-import android.os.Handler;
-import android.util.SparseBooleanArray;
-import android.view.ActionMode;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
-import android.widget.AbsListView;
-import android.widget.AdapterView;
-import android.widget.FrameLayout;
-import android.widget.ProgressBar;
-import android.widget.TextView;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.widget.Toast;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
-
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.appcompat.view.ActionMode;
+import androidx.core.content.res.ResourcesCompat;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.GridLayoutManager;
+
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
 
 import org.slf4j.Logger;
 
 import java.io.File;
-import java.sql.SQLException;
 import java.util.ArrayList;
-import java.util.Date;
+import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.MediaGalleryAdapter;
-import ch.threema.app.adapters.MediaGallerySpinnerAdapter;
-import ch.threema.app.cache.ThumbnailCache;
+import ch.threema.app.adapters.MediaGalleryViewModel;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
+import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.dialogs.MultiChoiceSelectorDialog;
+import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.ContactService;
@@ -74,9 +84,9 @@ import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
-import ch.threema.app.ui.FastScrollGridView;
-import ch.threema.app.utils.AnimationUtil;
+import ch.threema.app.ui.MediaGridItemDecoration;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
@@ -92,27 +102,28 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
-import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.MessageContentsType;
-
-public class MediaGalleryActivity extends ThreemaToolbarActivity implements AdapterView.OnItemClickListener, ActionBar.OnNavigationListener, GenericAlertDialog.DialogClickListener, FastScrollGridView.ScrollListener {
+import me.zhanghai.android.fastscroll.FastScroller;
+import me.zhanghai.android.fastscroll.FastScrollerBuilder;
+
+public class MediaGalleryActivity extends ThreemaToolbarActivity implements
+	MediaGalleryAdapter.OnClickItemListener,
+	GenericAlertDialog.DialogClickListener,
+	MultiChoiceSelectorDialog.SelectorDialogClickListener,
+	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("MediaGalleryActivity");
 
-	private ThumbnailCache<?> thumbnailCache = null;
+	private static final String DIALOG_TAG_DECRYPTING_MESSAGES = "dialog_decrypting_messages";
+
+	private MessageReceiver<?> messageReceiver;
 	private MediaGalleryAdapter mediaGalleryAdapter;
-	private MessageReceiver messageReceiver;
-	private String actionBarTitle;
-	private SpinnerMessageFilter spinnerMessageFilter;
-	private MediaGallerySpinnerAdapter spinnerAdapter;
-	private List<AbstractMessageModel> values;
-	private FastScrollGridView gridView;
-	private EmptyView emptyView;
-	private TypedArray mediaTypeArray;
-	private int currentType;
+	protected MediaGalleryViewModel mediaGalleryViewModel;
+	protected GridLayoutManager gridLayoutManager;
+	private EmptyRecyclerView recyclerView;
+	protected FastScroller fastScroller;
+	private ChipGroup chipGroup;
 	private ActionMode actionMode = null;
 	private AbstractMessageModel initialMessageModel = null;
-	private TextView dateTextView;
-	private FrameLayout dateView;
 
 	public FileService fileService;
 	public MessageService messageService;
@@ -120,86 +131,121 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	public GroupService groupService;
 	public DistributionListService distributionListService;
 
-	private final Handler dateViewHandler = new Handler();
-	private final Runnable dateViewTask = () -> RuntimeUtil.runOnUiThread(() -> {
-		if (dateView != null && dateView.getVisibility() == View.VISIBLE) {
-			AnimationUtil.slideOutAnimation(dateView, false, 1f, null);
-		}
-	});
-
-	private static final int TYPE_ALL = 0;
-	private static final int TYPE_IMAGE = 1;
-	private static final int TYPE_VIDEO = 2;
-	private static final int TYPE_AUDIO = 3;
-	private static final int TYPE_FILE = 4;
+	public final static int[] contentTypes = {
+		MessageContentsType.IMAGE,
+		MessageContentsType.GIF,
+		MessageContentsType.VIDEO,
+		MessageContentsType.VOICE_MESSAGE,
+		MessageContentsType.AUDIO,
+		MessageContentsType.FILE
+	};
+	private boolean[] checkedContentTypes = new boolean[contentTypes.length];
+	private String[] contentTypeNames;
 
 	private static final String DELETE_MESSAGES_CONFIRM_TAG = "reallydelete";
 	private static final String DIALOG_TAG_DELETING_MEDIA = "dmm";
-
+	private static final String DIALOG_TAG_TYPE_SELECTOR = "contentType";
 	private static final int PERMISSION_REQUEST_SAVE_MESSAGE = 88;
 
-	private static class SpinnerMessageFilter implements MessageService.MessageFilter {
-		private @MessageContentsType int[] filter = null;
-
-		public void setFilterByType(int spinnerMessageType) {
-			switch (spinnerMessageType) {
-				case TYPE_ALL:
-					this.filter = new int[]{MessageContentsType.IMAGE, MessageContentsType.VIDEO, MessageContentsType.AUDIO, MessageContentsType.FILE, MessageContentsType.GIF, MessageContentsType.VOICE_MESSAGE};
-					break;
-				case TYPE_IMAGE:
-					this.filter = new int[]{MessageContentsType.IMAGE};
-					break;
-				case TYPE_VIDEO:
-					this.filter = new int[]{MessageContentsType.VIDEO, MessageContentsType.GIF};
-					break;
-				case TYPE_AUDIO:
-					this.filter = new int[]{MessageContentsType.AUDIO, MessageContentsType.VOICE_MESSAGE};
-					break;
-				case TYPE_FILE:
-					this.filter = new int[]{MessageContentsType.FILE};
-					break;
-				default:
-					break;
-			}
-		}
-
+	public class MediaGalleryAction implements ActionMode.Callback {
 		@Override
-		public long getPageSize() {
-			return 0;
+		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+			mode.getMenuInflater().inflate(R.menu.action_media_gallery, menu);
+			if (AppRestrictionUtil.isShareMediaDisabled(MediaGalleryActivity.this)) {
+				menu.findItem(R.id.menu_message_save).setVisible(false);
+			}
+			return true;
 		}
 
 		@Override
-		public Integer getPageReferenceId() {
-			return null;
-		}
+		public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+			final int checked = mediaGalleryAdapter.getCheckedItemsCount();
+			menu.findItem(R.id.menu_show_in_chat).setVisible(checked == 1);
+			menu.findItem(R.id.menu_share).setVisible(selectedItemsCanBeShared());
 
-		@Override
-		public boolean withStatusMessages() {
+			if (checked > 0) {
+				mode.setTitle(Integer.toString(checked));
+				return true;
+			}
 			return false;
 		}
 
 		@Override
-		public boolean withUnsaved() {
+		public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+			int itemId = item.getItemId();
+			if (itemId == R.id.menu_message_discard) {
+				discardMessages();
+				return true;
+			} else if (itemId == R.id.menu_message_save) {
+				saveMessages();
+				return true;
+			} else if (itemId == R.id.menu_share) {
+				shareMessages();
+				return true;
+			} else if (itemId == R.id.menu_show_in_chat) {
+				showInChat();
+				return true;
+			} else if (itemId == R.id.menu_select_all) {
+				selectAllMessages();
+				return true;
+			}
 			return false;
 		}
 
 		@Override
-		public boolean onlyUnread() {
-			return false;
+		public void onDestroyActionMode(ActionMode mode) {
+			mediaGalleryAdapter.clearCheckedItems();
+			actionMode = null;
 		}
 
-		@Override
-		public boolean onlyDownloaded() {
-			return true;
-		}
+		@SuppressLint("StaticFieldLeak")
+		private void shareMessages() {
+			//noinspection deprecation
+			new AsyncTask<Void, Void, Void>() {
+				@Override
+				@Deprecated
+				protected void onPreExecute() {
+					GenericProgressDialog.newInstance(R.string.decoding_message, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DECRYPTING_MESSAGES);
+				}
 
-		@Override
-		public MessageType[] types() { return null; }
+				@Override
+				protected Void doInBackground(Void... voids) {
+					fileService.loadDecryptedMessageFiles(mediaGalleryAdapter.getCheckedItems(), new FileService.OnDecryptedFilesComplete() {
+						@Override
+						public void complete(ArrayList<Uri> uris) {
+							shareMediaMessages(uris);
+						}
 
-		@Override
-		@MessageContentsType
-		public int[] contentTypes() {
-			return this.filter;
+						@Override
+						public void error(String message) {
+							RuntimeUtil.runOnUiThread(() -> Toast.makeText(MediaGalleryActivity.this, message, Toast.LENGTH_LONG).show());
+						}
+					});
+					return null;
+				}
+
+				@Override
+				@Deprecated
+				protected void onPostExecute(Void aVoid) {
+					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DECRYPTING_MESSAGES, true);
+				}
+			}.execute();
+		}
+
+		private void shareMediaMessages(List<Uri> uris) {
+			List<AbstractMessageModel> selectedMessages = mediaGalleryAdapter.getCheckedItems();
+			if (uris.size() == 1) {
+				ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(
+					getString(R.string.share_media),
+					R.string.add_caption_hint, selectedMessages.get(0).getCaption(),
+					R.string.label_continue, R.string.cancel, true);
+				alertDialog.setData(uris);
+				alertDialog.show(getSupportFragmentManager(), null);
+			} else {
+				messageService.shareMediaMessages(MediaGalleryActivity.this,
+					new ArrayList<>(selectedMessages),
+					new ArrayList<>(uris), null);
+			}
 		}
 	}
 
@@ -217,82 +263,15 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	protected boolean initActivity(Bundle savedInstanceState) {
 		logger.debug("initActivity");
 
-		// set font size according to user preferences
-		getTheme().applyStyle(preferenceService.getFontStyle(), true);
-
 		if (!super.initActivity(savedInstanceState)) {
 			return false;
 		}
 
-		if (!this.requiredInstances()) {
-			this.finish();
+		if (!requiredInstances()) {
+			finish();
 			return false;
 		}
 
-		currentType = TYPE_ALL;
-
-		this.gridView = findViewById(R.id.item_list);
-		this.gridView.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE_MODAL);
-		this.gridView.setMultiChoiceModeListener(new AbsListView.MultiChoiceModeListener() {
-			@Override
-			public void onItemCheckedStateChanged(android.view.ActionMode mode, int position, long id, boolean checked) {
-				final int count = gridView.getCheckedItemCount();
-				if (count > 0) {
-					mode.setTitle(Integer.toString(count));
-				}
-				if (actionMode != null) {
-					actionMode.getMenu().findItem(R.id.menu_show_in_chat).setVisible(count == 1);
-				}
-			}
-
-			@Override
-			public boolean onCreateActionMode(android.view.ActionMode mode, Menu menu) {
-				mode.getMenuInflater().inflate(R.menu.action_media_gallery, menu);
-				actionMode = mode;
-
-				ConfigUtils.themeMenu(menu, ConfigUtils.getColorFromAttribute(MediaGalleryActivity.this, R.attr.colorAccent));
-
-				if (AppRestrictionUtil.isShareMediaDisabled(MediaGalleryActivity.this)) {
-					menu.findItem(R.id.menu_message_save).setVisible(false);
-				}
-
-				return true;
-			}
-
-			@Override
-			public boolean onPrepareActionMode(android.view.ActionMode mode, Menu menu) {
-				mode.setTitle(Integer.toString(gridView.getCheckedItemCount()));
-				return false;
-			}
-
-			@Override
-			public boolean onActionItemClicked(android.view.ActionMode mode, MenuItem item) {
-				switch (item.getItemId()) {
-					case R.id.menu_message_discard:
-						discardMessages();
-						return true;
-					case R.id.menu_message_save:
-						saveMessages();
-						return true;
-					case R.id.menu_show_in_chat:
-						showInChat();
-						return true;
-					default:
-						return false;
-				}
-			}
-
-			@Override
-			public void onDestroyActionMode(android.view.ActionMode mode) {
-				actionMode = null;
-			}
-		});
-		this.gridView.setOnItemClickListener(this);
-		this.gridView.setNumColumns(ConfigUtils.isLandscape(this) ? 5 : 3);
-		this.gridView.setScrollListener(this);
-
-		processIntent(getIntent());
-
 		ActionBar actionBar = getSupportActionBar();
 		if (actionBar == null) {
 			logger.debug("no action bar");
@@ -300,54 +279,97 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 			return false;
 		}
 		actionBar.setDisplayHomeAsUpEnabled(true);
-		actionBar.setDisplayShowTitleEnabled(false);
+		actionBar.setTitle(processIntent(getIntent()));
 
-		// add text view if contact list is empty
-		this.mediaTypeArray = getResources().obtainTypedArray(R.array.media_gallery_spinner);
-		this.spinnerAdapter = new MediaGallerySpinnerAdapter(
-				actionBar.getThemedContext(), getResources().getStringArray(R.array.media_gallery_spinner),
-				this.actionBarTitle);
+		chipGroup = findViewById(R.id.chip_group);
+		chipGroup.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGE_DISAPPEARING|LayoutTransition.CHANGE_APPEARING|LayoutTransition.APPEARING|LayoutTransition.DISAPPEARING);
 
-		actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
-		actionBar.setListNavigationCallbacks(spinnerAdapter, this);
-		actionBar.setSelectedNavigationItem(this.currentType);
+		contentTypeNames = getResources().getStringArray(R.array.media_gallery_spinner);
+		Arrays.fill(checkedContentTypes, true);
 
-		this.spinnerMessageFilter = new SpinnerMessageFilter();
-		this.spinnerMessageFilter.setFilterByType(this.currentType);
-		this.thumbnailCache = new ThumbnailCache<Integer>(null);
+		gridLayoutManager = new GridLayoutManager(this, ConfigUtils.isLandscape(this) ? 5 : 3);
+		recyclerView = findViewById(R.id.item_list);
 
-		FrameLayout frameLayout = findViewById(R.id.frame_parent);
-
-		this.emptyView = new EmptyView(this);
-		this.emptyView.setColorsInt(ConfigUtils.getColorFromAttribute(this, android.R.attr.windowBackground), ConfigUtils.getColorFromAttribute(this, R.attr.textColorPrimary));
-		this.emptyView.setup(getString(R.string.no_media_found_generic));
+		final int borderSize = (int) ((float) getResources().getDimensionPixelSize(R.dimen.grid_spacing) * 1.5F);
+		final int cornerRadius = getResources().getDimensionPixelSize(R.dimen.cardview_border_radius);
+		final ViewOutlineProvider viewOutlineProvider = new ViewOutlineProvider() {
+			@Override
+			public void getOutline(View view, Outline outline) {
+				outline.setRoundRect(borderSize, 0, view.getWidth() - borderSize, view.getHeight() + cornerRadius , cornerRadius);;
+			}
+		};
+
+		recyclerView.setOutlineProvider(viewOutlineProvider);
+		recyclerView.setClipToOutline(true);
+		recyclerView.setLayoutManager(gridLayoutManager);
+		recyclerView.addItemDecoration(new MediaGridItemDecoration(getResources().getDimensionPixelSize(R.dimen.grid_spacing)));
+
+		EmptyView emptyView = new EmptyView(this);
+		emptyView.setColorsInt(ConfigUtils.getColorFromAttribute(this, android.R.attr.colorBackground), ConfigUtils.getColorFromAttribute(this, R.attr.colorOnBackground));
+		emptyView.setup(getString(R.string.no_media_found_generic));
+		((ViewGroup) recyclerView.getParent()).addView(emptyView);
+		recyclerView.setEmptyView(emptyView);
+		mediaGalleryAdapter = new MediaGalleryAdapter(this, this, messageReceiver, gridLayoutManager.getSpanCount());
+		recyclerView.setAdapter(mediaGalleryAdapter);
+
+		final Observer<List<AbstractMessageModel>> messageObserver = abstractMessageModels -> {
+			mediaGalleryAdapter.setItems(abstractMessageModels);
+			if (actionMode != null) {
+				actionMode.invalidate();
+			}
+		};
+
+		if (fastScroller == null) {
+			Drawable thumbDrawable = ResourcesCompat.getDrawable(getResources(), R.drawable.ic_thumbscroller, getTheme());
+			fastScroller = new FastScrollerBuilder(recyclerView)
+				.setThumbDrawable(Objects.requireNonNull(thumbDrawable))
+				.setTrackDrawable(Objects.requireNonNull(AppCompatResources.getDrawable(this, R.drawable.fastscroll_track_media)))
+				.setPopupStyle(thumbScrollerPopupStyle)
+				.setPopupTextProvider(position -> {
+						int firstVisible = gridLayoutManager.findFirstCompletelyVisibleItemPosition();
+						if (firstVisible >= 0) {
+							AbstractMessageModel item = mediaGalleryAdapter.getItemAtPosition(firstVisible);
+							if (item != null) {
+								return LocaleUtil.formatDateRelative(item.getCreatedAt().getTime());
+							}
+						}
+						return getString(R.string.unknown);
+					})
+				.build();
+		}
 
-		frameLayout.addView(this.emptyView);
-		this.gridView.setEmptyView(this.emptyView);
+		MediaGalleryViewModel.MediaGalleryViewModelFactory viewModelFactory = new MediaGalleryViewModel.MediaGalleryViewModelFactory(messageReceiver);
+		mediaGalleryViewModel = new ViewModelProvider(this, viewModelFactory).get(MediaGalleryViewModel.class);
+		mediaGalleryViewModel.getAbstractMessageModels().observe(this, messageObserver);
+		mediaGalleryViewModel.setFilter(null);
 
-		this.dateView = findViewById(R.id.date_separator_container);
-		this.dateTextView = findViewById(R.id.text_view);
+		refreshChipGroup();
 
-		if (savedInstanceState == null || mediaGalleryAdapter == null) {
-			setupAdapters(this.currentType, true);
+		if (initialMessageModel != null) {
+			recyclerView.post(() -> {
+				for (int position = 0; position < mediaGalleryAdapter.getItemCount(); position++) {
+					AbstractMessageModel messageModel = mediaGalleryAdapter.getItemAtPosition(position);
+					if (messageModel != null && messageModel.getId() == initialMessageModel.getId()) {
+						gridLayoutManager.scrollToPosition(position);
+						break;
+					}
+				}
+				initialMessageModel = null;
+			});
 		}
-
 		return true;
 	}
 
 	private void showInChat() {
-		if (getSelectedMessages().size() != 1) {
+		if (mediaGalleryAdapter.getCheckedItemsCount() != 1) {
 			return;
 		}
-		AnimationUtil.startActivityForResult(this, null, IntentDataUtil.getJumpToMessageIntent(this, getSelectedMessages().get(0)), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+		startActivityForResult(IntentDataUtil.getJumpToMessageIntent(this, mediaGalleryAdapter.getCheckedItemAt(0)), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
 		finish();
 	}
 
 	@Override
 	protected void onDestroy() {
-		if (this.thumbnailCache != null) {
-			this.thumbnailCache.flush();
-		}
 		super.onDestroy();
 	}
 
@@ -355,29 +377,28 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	public boolean onCreateOptionsMenu(Menu menu) {
 		super.onCreateOptionsMenu(menu);
 
-		// Inflate the menu; this adds items to the action bar if it is present.
 		getMenuInflater().inflate(R.menu.activity_media_gallery, menu);
 		return true;
 	}
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				finish();
-				break;
-			case R.id.menu_message_select_all:
-				selectAllMessages();
-				break;
+		int itemId = item.getItemId();
+		if (itemId == android.R.id.home) {
+			finish();
+		} else if (itemId == R.id.menu_message_filter) {
+			MultiChoiceSelectorDialog.newInstance(getString(R.string.select), contentTypeNames, checkedContentTypes).show(getSupportFragmentManager(), DIALOG_TAG_TYPE_SELECTOR);
 		}
 		return true;
 	}
 
-	private void processIntent(Intent intent) {
+	private @Nullable String processIntent(Intent intent) {
+		String actionBarTitle;
+
 		if (intent.hasExtra(ThreemaApplication.INTENT_DATA_GROUP)) {
 			int groupId = intent.getIntExtra(ThreemaApplication.INTENT_DATA_GROUP, 0);
-			GroupModel groupModel = this.groupService.getById(groupId);
-			messageReceiver = this.groupService.createReceiver(groupModel);
+			GroupModel groupModel = groupService.getById(groupId);
+			messageReceiver = groupService.createReceiver(groupModel);
 			actionBarTitle = groupModel.getName();
 		} else if (intent.hasExtra(ThreemaApplication.INTENT_DATA_DISTRIBUTION_LIST)) {
 			DistributionListModel distributionListModel = distributionListService.getById(intent.getLongExtra(ThreemaApplication.INTENT_DATA_DISTRIBUTION_LIST, 0));
@@ -392,8 +413,8 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 			if (identity == null) {
 				finish();
 			}
-			ContactModel contactModel = this.contactService.getByIdentity(identity);
-			messageReceiver = this.contactService.createReceiver(contactModel);
+			ContactModel contactModel = contactService.getByIdentity(identity);
+			messageReceiver = contactService.createReceiver(contactModel);
 			actionBarTitle = NameUtil.getDisplayNameOrNickname(contactModel, true);
 		}
 
@@ -404,136 +425,17 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		if (type != null && id != 0) {
 			initialMessageModel = messageService.getMessageModelFromId(id, type);
 		}
-	}
-
-	private void setupAdapters(int newType, boolean force) {
-		if (this.currentType != newType || force) {
-			this.values = this.getMessages(this.messageReceiver);
-			if (this.values == null || this.values.isEmpty()) {
-				if (this.emptyView != null) {
-					if (newType == TYPE_ALL) {
-						this.emptyView.setup(getString(R.string.no_media_found_generic));
-					} else {
-						this.emptyView.setup(String.format(getString(R.string.no_media_found), getString(this.mediaTypeArray.getResourceId(newType, -1))));
-					}
-				}
-			}
-
-			this.mediaGalleryAdapter = new MediaGalleryAdapter(
-					this,
-					values,
-					this.fileService,
-					this.thumbnailCache
-			);
-
-			this.gridView.setAdapter(this.mediaGalleryAdapter);
-			if (initialMessageModel != null) {
-				this.gridView.post(new Runnable() {
-					@Override
-					public void run() {
-						for(int position = 0; position < values.size(); position++) {
-							if (values.get(position).getId() == initialMessageModel.getId()) {
-								gridView.setSelection(position);
-								break;
-							}
-						}
-						initialMessageModel = null;
-					}
-				});
-			}
-		}
-		this.currentType = newType;
-		resetSpinnerAdapter(newType);
-	}
-
-	private void resetSpinnerAdapter(int type) {
-		if (this.spinnerAdapter != null && this.mediaTypeArray != null && this.values != null) {
-			this.spinnerAdapter.setSubtitle(getString(this.mediaTypeArray.getResourceId(type, -1)) + " (" + this.values.size() + ")");
-			this.spinnerAdapter.notifyDataSetChanged();
-		}
-	}
-
-	private List<AbstractMessageModel> getMessages(MessageReceiver<AbstractMessageModel> receiver) {
-		List<AbstractMessageModel> values = null;
-		try {
-			values = receiver.loadMessages(this.spinnerMessageFilter);
-		} catch (SQLException e) {
-			logger.error("Exception", e);
-		}
-		return values;
-	}
-
-	@Override
-	public void onItemClick(AdapterView<?> parent, final View view, int position, long id) {
-			final AbstractMessageModel m = this.mediaGalleryAdapter.getItem(position);
-			ProgressBar progressBar = view.findViewById(R.id.progress_decoding);
-
-			switch (mediaGalleryAdapter.getItemViewType(position)) {
-				case MediaGalleryAdapter.TYPE_IMAGE:
-					// internal viewer
-					showInMediaFragment(m, view);
-					break;
-				case MediaGalleryAdapter.TYPE_VIDEO:
-					showInMediaFragment(m, view);
-					break;
-				case MediaGalleryAdapter.TYPE_AUDIO:
-					showInMediaFragment(m, view);
-					break;
-				case MediaGalleryAdapter.TYPE_FILE:
-					if (m!= null && (FileUtil.isImageFile(m.getFileData()) || FileUtil.isVideoFile(m.getFileData()) || FileUtil.isAudioFile(m.getFileData()))) {
-						showInMediaFragment(m, view);
-					} else {
-						decodeAndShowFile(m, view, progressBar);
-					}
-					break;
-			}
-	}
-
-	@Override
-	public boolean onNavigationItemSelected(int itemPosition, long itemId) {
-		this.spinnerMessageFilter.setFilterByType(itemPosition);
-		setupAdapters(itemPosition, false);
-
-		return true;
-	}
-
-	@Override
-	public void onScroll(int firstVisibleItem) {
-		if (this.mediaGalleryAdapter != null) {
-			if (dateView.getVisibility() != View.VISIBLE && mediaGalleryAdapter != null && mediaGalleryAdapter.getCount() > 0) {
-				AnimationUtil.slideInAnimation(dateView, false, 200);
-			}
-
-			dateViewHandler.removeCallbacks(dateViewTask);
-			dateViewHandler.postDelayed(dateViewTask, SCROLLBUTTON_VIEW_TIMEOUT);
 
-			try {
-				final AbstractMessageModel messageModel = this.mediaGalleryAdapter.getItem(firstVisibleItem);
-				if (messageModel != null) {
-					final Date createdAt = messageModel.getCreatedAt();
-					if (createdAt != null) {
-						dateView.post(() -> {
-							dateTextView.setText(LocaleUtil.formatDateRelative(createdAt.getTime()));
-						});
-					}
-				}
-			} catch (IndexOutOfBoundsException ignore) {}
-		}
+		return actionBarTitle;
 	}
 
 	private void selectAllMessages() {
-		if (gridView != null) {
-			if (gridView.getCount() == gridView.getCheckedItemCount()) {
-				if (actionMode != null) {
+		if (mediaGalleryAdapter != null) {
+			mediaGalleryAdapter.selectAll();
+			if (actionMode != null) {
+				if (mediaGalleryAdapter.getCheckedItemsCount() == 0) {
 					actionMode.finish();
-				}
-			} else {
-				for (int i = 0; i < gridView.getCount(); i++) {
-					if (currentType == TYPE_ALL || mediaGalleryAdapter.getItemViewType(i) == currentType) {
-						gridView.setItemChecked(i, true);
-					}
-				}
-				if (actionMode != null) {
+				} else {
 					actionMode.invalidate();
 				}
 			}
@@ -541,7 +443,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	}
 
 	private void discardMessages() {
-		List<AbstractMessageModel> selectedMessages = getSelectedMessages();
+		List<AbstractMessageModel> selectedMessages = mediaGalleryAdapter.getCheckedItems();
 		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.really_delete_message_title, String.format(getString(R.string.really_delete_media), selectedMessages.size()), R.string.delete_message, R.string.cancel);
 		dialog.setData(selectedMessages);
 		dialog.show(getSupportFragmentManager(), DELETE_MESSAGES_CONFIRM_TAG);
@@ -549,49 +451,42 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 
 	private void saveMessages() {
 		if (ConfigUtils.requestWriteStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
-			fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
+			fileService.saveMedia(this, recyclerView, new CopyOnWriteArrayList<>(mediaGalleryAdapter.getCheckedItems()), true);
 			actionMode.finish();
 		}
 	}
 
-	private List<AbstractMessageModel> getSelectedMessages() {
-		List<AbstractMessageModel> selectedMessages = new ArrayList<>();
-		SparseBooleanArray checkedItems = gridView.getCheckedItemPositions();
-
-		final int size = checkedItems.size();
-		for (int i = 0; i < size; i++) {
-			final int index = checkedItems.keyAt(i);
+	@Override
+	public void onYes(String tag, Object data, String text) {
+		List<Uri> uris = (List<Uri>) data;
+		messageService.shareMediaMessages(this,
+			new ArrayList<>(mediaGalleryAdapter.getCheckedItems()),
+			new ArrayList<>(uris), text);
+	}
 
-			if (checkedItems.valueAt(i)) {
-				selectedMessages.add(mediaGalleryAdapter.getItem(index));
-			}
-		}
-		return selectedMessages;
+	@Override
+	public void onNo(String tag) {
+		// Nothing to do here
 	}
 
 	@SuppressLint("StaticFieldLeak")
 	private void reallyDiscardMessages(final CopyOnWriteArrayList<AbstractMessageModel> selectedMessages) {
-		new AsyncTask<Void, Integer, Integer>() {
+		new AsyncTask<Void, Integer, List<AbstractMessageModel>>() {
 			boolean cancelled = false;
 
 			@Override
 			protected void onPreExecute() {
 				if (selectedMessages.size() > 10) {
 					CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_messages, 0, R.string.cancel, selectedMessages.size());
-					dialog.setOnCancelListener(new DialogInterface.OnClickListener() {
-						@Override
-						public void onClick(DialogInterface dialog, int which) {
-							cancelled = true;
-						}
-					});
+					dialog.setOnCancelListener((dialog1, which) -> cancelled = true);
 					dialog.show(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA);
 				}
 			}
 
 			@Override
-			protected Integer doInBackground(Void... params) {
+			protected List<AbstractMessageModel> doInBackground(Void... params) {
 				int i = 0;
-				int deleted = 0;
+				List<AbstractMessageModel> deletedMessages = new ArrayList<>();
 				Iterator<AbstractMessageModel> checkedItemsIterator = selectedMessages.iterator();
 				while (checkedItemsIterator.hasNext() && !cancelled) {
 					publishProgress(i++);
@@ -599,31 +494,25 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 						final AbstractMessageModel messageModel = checkedItemsIterator.next();
 
 						if (messageModel != null) {
+							deletedMessages.add(messageModel);
 							messageService.remove(messageModel);
-							deleted++;
-						 	RuntimeUtil.runOnUiThread(new Runnable() {
-								@Override
-								public void run() {
-									mediaGalleryAdapter.remove(messageModel);
-								}
-							});
 						}
 					} catch (Exception e) {
 						logger.error("Exception", e);
 					}
 				}
-				return deleted;
+				return deletedMessages;
 			}
 
 			@Override
-			protected void onPostExecute(Integer deletedMessages) {
+			protected void onPostExecute(List<AbstractMessageModel> deletedMessages) {
+				mediaGalleryAdapter.removeItems(deletedMessages);
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA, true);
-				String text = ConfigUtils.getSafeQuantityString(gridView.getContext(), R.plurals.message_deleted, deletedMessages, deletedMessages);
-				Snackbar.make(gridView, text, Snackbar.LENGTH_LONG).show();
+				String text = ConfigUtils.getSafeQuantityString(recyclerView.getContext(), R.plurals.message_deleted, deletedMessages.size(), deletedMessages.size());
+				Snackbar.make(recyclerView, text, Snackbar.LENGTH_LONG).show();
 				if (actionMode != null) {
 					actionMode.finish();
 				}
-				resetSpinnerAdapter(currentType);
 			}
 
 			@Override
@@ -633,56 +522,49 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		}.execute();
 	}
 
-	@Override
-	public void onYes(String tag, Object data) {
-		reallyDiscardMessages(new CopyOnWriteArrayList<>((ArrayList< AbstractMessageModel>) data));
-	}
-
-	@Override
-	public void onNo(String tag, Object data) {
-	}
-
 	@Override
 	public void onConfigurationChanged(@NonNull Configuration newConfig) {
 		final int topmost;
-		if (this.gridView != null) {
-			View topChild = this.gridView.getChildAt(0);
-			if (topChild != null) {
-				if (topChild.getTop() < 0) {
-					topmost = this.gridView.getFirstVisiblePosition() + 1;
-				} else {
-					topmost = this.gridView.getFirstVisiblePosition();
-				}
-			} else {
-				topmost = 0;
-			}
+		if (gridLayoutManager != null) {
+			topmost = gridLayoutManager.findFirstCompletelyVisibleItemPosition();
 		} else {
 			topmost = 0;
 		}
 
 		super.onConfigurationChanged(newConfig);
 
-		if (this.gridView != null) {
-			this.gridView.post(() -> {
-				gridView.setNumColumns(ConfigUtils.isLandscape(MediaGalleryActivity.this) ? 5 : 3);
-				gridView.setSelection(topmost);
+		if (recyclerView != null) {
+			recyclerView.post(() -> {
+				if (gridLayoutManager != null) {
+					gridLayoutManager.setSpanCount(ConfigUtils.isLandscape(MediaGalleryActivity.this) ? 5 : 3);
+					gridLayoutManager.scrollToPosition(topmost);
+				}
 			});
 		}
 	}
 
-	private void hideProgressBar(final ProgressBar progressBar) {
+	@Override
+	public void onBackPressed() {
+		if (actionMode != null) {
+			actionMode.finish();
+		} else {
+			super.onBackPressed();
+		}
+	}
+
+	private void hideProgressBar(final CircularProgressIndicator progressBar) {
 		if (progressBar != null) {
 		 	RuntimeUtil.runOnUiThread(() -> progressBar.setVisibility(View.GONE));
 		}
 	}
 
-	private void showProgressBar(final ProgressBar progressBar) {
+	private void showProgressBar(final CircularProgressIndicator progressBar) {
 		if (progressBar != null) {
 		 	RuntimeUtil.runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE));
 		}
 	}
 
-	public void decodeAndShowFile(final AbstractMessageModel m, final View v, final ProgressBar progressBar) {
+	public void decryptAndShow(final AbstractMessageModel m, final View v, final CircularProgressIndicator progressBar) {
 		showProgressBar(progressBar);
 		fileService.loadDecryptedMessageFile(m, new FileService.OnDecryptedFileComplete() {
 			@Override
@@ -706,18 +588,18 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		IntentDataUtil.append(m, intent);
 		intent.putExtra(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, true);
 		intent.putExtra(MediaViewerActivity.EXTRA_ID_REVERSE_ORDER, false);
-		intent.putExtra(MediaViewerActivity.EXTRA_FILTER, this.spinnerMessageFilter.contentTypes());
-		AnimationUtil.startActivityForResult(this, v, intent, ACTIVITY_ID_MEDIA_VIEWER);
+		intent.putExtra(MediaViewerActivity.EXTRA_FILTER, getMediaContentTypeArray());
+		startActivityForResult(intent, ACTIVITY_ID_MEDIA_VIEWER);
 	}
 
 	@Override
 	protected boolean checkInstances() {
 		return TestUtil.required(
-				this.fileService,
-				this.messageService,
-				this.groupService,
-				this.distributionListService,
-				this.contactService
+				fileService,
+				messageService,
+				groupService,
+				distributionListService,
+				contactService
 		) && super.checkInstances();
 	}
 
@@ -728,11 +610,11 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		if (serviceManager != null) {
 			try {
-				this.fileService = serviceManager.getFileService();
-				this.messageService = serviceManager.getMessageService();
-				this.groupService = serviceManager.getGroupService();
-				this.distributionListService = serviceManager.getDistributionListService();
-				this.contactService = serviceManager.getContactService();
+				fileService = serviceManager.getFileService();
+				messageService = serviceManager.getMessageService();
+				groupService = serviceManager.getGroupService();
+				distributionListService = serviceManager.getDistributionListService();
+				contactService = serviceManager.getContactService();
 			} catch (Exception e) {
 				LogUtil.exception(e, this);
 			}
@@ -748,19 +630,163 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
 			switch (requestCode) {
 				case PERMISSION_REQUEST_SAVE_MESSAGE:
-					fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
+					fileService.saveMedia(this, recyclerView, new CopyOnWriteArrayList<>(mediaGalleryAdapter.getCheckedItems()), true);
 					break;
 			}
 		} else {
 			switch (requestCode) {
 				case PERMISSION_REQUEST_SAVE_MESSAGE:
 					if (!shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
-						ConfigUtils.showPermissionRationale(this, gridView, R.string.permission_storage_required);
+						ConfigUtils.showPermissionRationale(this, recyclerView, R.string.permission_storage_required);
 					}
 					break;
 			}
 		}
 		actionMode.finish();
 	}
+
+	private void refreshChipGroup() {
+		chipGroup.removeAllViews();
+		for (int i = 0; i < checkedContentTypes.length; i++) {
+			if (checkedContentTypes[i]) {
+				Chip chip = (Chip) getLayoutInflater().inflate(
+					R.layout.chip_directory, null, false
+				);
+				chip.setText(contentTypeNames[i]);
+				chip.setTag(contentTypes[i]);
+				chip.setOnCloseIconClickListener(v -> {
+					int contentType = (int) v.getTag();
+					for (int j = 0; j < checkedContentTypes.length; j++) {
+						if (contentType == contentTypes[j]) {
+							checkedContentTypes[j] = false;
+							chipGroup.removeView(v);
+							updateFilter();
+							break;
+						}
+					}
+					if (chipGroup.getChildCount() == 0) {
+						finish();
+					}
+				});
+				chipGroup.addView(chip);
+			}
+		}
+		updateFilter();
+	}
+
+	private void updateFilter() {
+		if (mediaGalleryAdapter != null && mediaGalleryViewModel != null) {
+			mediaGalleryViewModel.setFilter(getMediaContentTypeArray());
+		}
+	}
+
+	private @Nullable int[] getMediaContentTypeArray() {
+		int[] contentTypeList = new int[checkedContentTypes.length];
+		int n = 0;
+		for(int i = 0; i < checkedContentTypes.length; i++) {
+			if (checkedContentTypes[i]) {
+				contentTypeList[n++] = contentTypes[i];
+			}
+		}
+
+		return n > 0 ? Arrays.copyOfRange(contentTypeList, 0, n) : null;
+	}
+
+	@Override
+	public void onYes(String tag, Object data) {
+		reallyDiscardMessages(new CopyOnWriteArrayList<>((ArrayList< AbstractMessageModel>) data));
+	}
+
+	@Override
+	public void onYes(String tag, boolean[] checkedItems) {
+		int n = 0;
+		for (boolean checkedItem: checkedItems) {
+			if (checkedItem) {
+				n++;
+				break;
+			}
+		}
+
+		if (n == 0) {
+			finish();
+			return;
+		}
+
+		checkedContentTypes = checkedItems;
+		refreshChipGroup();
+	}
+
+	@Override
+	public void onClick(@Nullable AbstractMessageModel messageModel, @Nullable View view, int position) {
+		if (actionMode != null) {
+			mediaGalleryAdapter.toggleChecked(position);
+			if (mediaGalleryAdapter.getCheckedItemsCount() > 0) {
+				if (actionMode != null) {
+					actionMode.invalidate();
+				}
+			} else {
+				actionMode.finish();
+			}
+		} else {
+			if (messageModel != null) {
+				if (view != null) {
+					CircularProgressIndicator progressBar = view.findViewById(R.id.progress_decoding);
+
+					switch (messageModel.getMessageContentsType()) {
+						case MessageContentsType.IMAGE:
+						case MessageContentsType.VIDEO:
+						case MessageContentsType.VOICE_MESSAGE:
+						case MessageContentsType.AUDIO:
+						case MessageContentsType.GIF:
+							showInMediaFragment(messageModel, view);
+							break;
+						case MessageContentsType.FILE:
+							if ((FileUtil.isImageFile(messageModel.getFileData()) || FileUtil.isVideoFile(messageModel.getFileData()) || FileUtil.isAudioFile(messageModel.getFileData()))) {
+								showInMediaFragment(messageModel, view);
+							} else {
+								decryptAndShow(messageModel, view, progressBar);
+							}
+							break;
+						default:
+							break;
+					}
+				}
+			}
+		}
+	}
+
+	@Override
+	public boolean onLongClick(@Nullable AbstractMessageModel messageModel, @Nullable View itemView, int position) {
+		if (actionMode != null) {
+			actionMode.finish();
+		}
+		mediaGalleryAdapter.toggleChecked(position);
+		if (mediaGalleryAdapter.getCheckedItemsCount() > 0) {
+			actionMode = startSupportActionMode(new MediaGalleryAction());
+		}
+		return true;
+	}
+
+	/**
+	 * Check that no more than {@link ComposeMessageFragment#MAX_FORWARDABLE_ITEMS} are selected,
+	 * that all media is downloaded, and that sharing media is allowed.
+	 *
+	 * @return true if the items can be shared, false otherwise
+	 */
+	private boolean selectedItemsCanBeShared() {
+		if (AppRestrictionUtil.isShareMediaDisabled(MediaGalleryActivity.this)) {
+			return false;
+		}
+		if (mediaGalleryAdapter.getCheckedItemsCount() > ComposeMessageFragment.MAX_FORWARDABLE_ITEMS) {
+			return false;
+		}
+		for (AbstractMessageModel message : mediaGalleryAdapter.getCheckedItems()) {
+			if (!message.isAvailable()) {
+				return false;
+			}
+		}
+		return true;
+	}
+
 }
 

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

@@ -21,7 +21,6 @@
 
 package ch.threema.app.activities;
 
-
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.Intent;
@@ -48,7 +47,9 @@ import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.core.app.ActivityCompat;
+import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
@@ -69,6 +70,7 @@ import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.fragments.mediaviews.AudioViewFragment;
 import ch.threema.app.fragments.mediaviews.FileViewFragment;
+import ch.threema.app.fragments.mediaviews.GifViewFragment;
 import ch.threema.app.fragments.mediaviews.ImageViewFragment;
 import ch.threema.app.fragments.mediaviews.MediaPlayerViewFragment;
 import ch.threema.app.fragments.mediaviews.MediaViewFragment;
@@ -78,7 +80,6 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.ui.LockableViewPager;
-import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
@@ -96,7 +97,6 @@ import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.MessageContentsType;
 
-
 public class MediaViewerActivity extends ThreemaToolbarActivity implements
 	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
 
@@ -109,9 +109,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 	public static final String EXTRA_ID_IMMEDIATE_PLAY = "play";
 	public static final String EXTRA_ID_REVERSE_ORDER = "reverse";
 	public static final String EXTRA_FILTER = "filter";
+	public static final String EXTRA_IS_VOICEMESSAGE = "vm";
 
 	private LockableViewPager pager;
-
 	private File currentMediaFile;
 	private ActionBar actionBar;
 
@@ -172,24 +172,29 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		this.actionBar.setTitle(" ");
 
 		ViewCompat.setOnApplyWindowInsetsListener(getToolbar(), (v, insets) -> {
+			Insets systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+
 			FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) v.getLayoutParams();
-			lp.topMargin = insets.getSystemWindowInsetTop();
-			lp.leftMargin = insets.getSystemWindowInsetLeft();
-			lp.rightMargin = insets.getSystemWindowInsetRight();
+			lp.topMargin = systemInsets.top;
+			lp.leftMargin = systemInsets.left;
+			lp.rightMargin = systemInsets.right;
 			v.setLayoutParams(lp);
 
 			return insets;
 		});
-		getToolbar().setTitleTextAppearance(this, R.style.TextAppearance_MediaViewer_Title);
-		getToolbar().setSubtitleTextAppearance(this, R.style.TextAppearance_MediaViewer_SubTitle);
+		getToolbar().setTitleTextAppearance(this, R.style.Threema_TextAppearance_MediaViewer_Title);
+		getToolbar().setSubtitleTextAppearance(this, R.style.Threema_TextAppearance_MediaViewer_SubTitle);
 
 		this.caption = findViewById(R.id.caption);
 
 		this.captionContainer = findViewById(R.id.caption_container);
 		ViewCompat.setOnApplyWindowInsetsListener(this.captionContainer, (v, insets) -> {
-			FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) v.getLayoutParams();
-			params.setMargins(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom() + getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_bottom));
-			v.setLayoutParams(params);
+			Insets systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+			FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) v.getLayoutParams();
+			lp.leftMargin =	systemInsets.left + getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_horizontal);
+			lp.rightMargin = systemInsets.right + getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_horizontal);
+			lp.bottomMargin = systemInsets.bottom + getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_bottom);
+			v.setLayoutParams(lp);
 
 			return insets;
 		});
@@ -501,7 +506,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		if (messageModel == null) {
 			return;
 		}
-		AnimationUtil.startActivityForResult(this, null, IntentDataUtil.getJumpToMessageIntent(this, messageModel), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+		startActivityForResult(IntentDataUtil.getJumpToMessageIntent(this, messageModel), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
 		finish();
 	}
 
@@ -606,7 +611,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void attachAdapter() {
-		//reset adapter!
 		PagerAdapter pageAdapter = new ScreenSlidePagerAdapter(this, getSupportFragmentManager());
 		this.pager.setAdapter(pageAdapter);
 		this.pager.setCurrentItem(this.currentPosition);
@@ -696,7 +700,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 						break;
 					case FILE:
 						String mimeType = messageModel.getFileData().getMimeType();
-						if (MimeUtil.isImageFile(mimeType)) {
+						if (MimeUtil.isGifFile(mimeType)) {
+							f = new GifViewFragment();
+						} else if (MimeUtil.isImageFile(mimeType)) {
 							f = new ImageViewFragment();
 						} else if (MimeUtil.isVideoFile(mimeType)) {
 							f = new VideoViewFragment();
@@ -704,6 +710,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 							if (MimeUtil.isMidiFile(mimeType) || MimeUtil.isFlacFile(mimeType)) {
 								f = new MediaPlayerViewFragment();
 							} else {
+								args.putBoolean(EXTRA_IS_VOICEMESSAGE, messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE);
 								f = new AudioViewFragment();
 							}
 						} else {
@@ -720,18 +727,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 				args.putInt("position", position);
 				f.setArguments(args);
 
-				//lock page if media is open (image open = zoom)
-				f.setOnMediaOpenListener(new MediaViewFragment.OnMediaOpenListener() {
-					@Override
-					public void closed() {
-						a.pager.lock(false);
-					}
-
-					@Override
-					public void open() {
-						a.pager.lock(true);
-					}
-				});
 				f.setOnImageLoaded(new MediaViewFragment.OnMediaLoadListener() {
 					@Override
 					public void decrypting() {

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

@@ -22,36 +22,41 @@
 package ch.threema.app.activities;
 
 import android.os.Bundle;
+import android.text.TextUtils;
 import android.util.SparseArray;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
 import android.widget.TextView;
 
-import com.google.android.material.appbar.AppBarLayout;
-import com.google.android.material.snackbar.Snackbar;
-import com.google.android.material.tabs.TabLayout;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
+import androidx.annotation.IntDef;
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.widget.SearchView;
-import androidx.appcompat.widget.Toolbar;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.core.view.WindowInsetsControllerCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.fragment.app.ListFragment;
 import androidx.viewpager.widget.ViewPager;
+
+import com.google.android.material.appbar.AppBarLayout;
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+import com.google.android.material.search.SearchBar;
+import com.google.android.material.snackbar.Snackbar;
+import com.google.android.material.tabs.TabLayout;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
 import ch.threema.app.R;
 import ch.threema.app.adapters.FilterableListAdapter;
 import ch.threema.app.fragments.MemberListFragment;
@@ -65,6 +70,7 @@ import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.SnackbarUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 
 abstract public class MemberChooseActivity extends ThreemaToolbarActivity implements SearchView.OnQueryTextListener, MemberListFragment.SelectionListener {
@@ -72,6 +78,15 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 	private final static int FRAGMENT_WORK_USERS = 1;
 	private final static int NUM_FRAGMENTS = 2;
 
+	@Retention(RetentionPolicy.SOURCE)
+	@IntDef({MODE_NEW_GROUP, MODE_ADD_TO_GROUP, MODE_NEW_DISTRIBUTION_LIST, MODE_PROFILE_PIC_RECIPIENTS})
+	public @interface MemberChooseMode {}
+	protected final static int MODE_NEW_GROUP = 1;
+	protected final static int MODE_ADD_TO_GROUP = 2;
+	protected final static int MODE_NEW_DISTRIBUTION_LIST = 3;
+	protected final static int MODE_PROFILE_PIC_RECIPIENTS = 4;
+	private static final String BUNDLE_QUERY_TEXT = "query";
+
 	private MemberChoosePagerAdapter memberChoosePagerAdapter;
 	private MenuItem searchMenuItem;
 	private ThreemaSearchView searchView;
@@ -80,10 +95,13 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 	protected ArrayList<String> excludedIdentities = new ArrayList<>();
 	protected ArrayList<String> preselectedIdentities = new ArrayList<>();
 
-	private ViewPager viewPager;
+	private TabLayout tabLayout;
 	private final ArrayList<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
 	private Snackbar snackbar;
 	private View rootView;
+	private SearchBar searchBar;
+	private ExtendedFloatingActionButton floatingActionButton;
+	private String queryText;
 
 	@Override
 	public boolean onQueryTextSubmit(String query) {
@@ -93,15 +111,18 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 
 	@Override
 	public boolean onQueryTextChange(String newText) {
-		int currentItem = viewPager.getCurrentItem();
-		Fragment fragment = memberChoosePagerAdapter.getRegisteredFragment(currentItem);
-
-		if (fragment != null) {
-			FilterableListAdapter listAdapter = ((MemberListFragment) fragment).getAdapter();
+		int itemCount = memberChoosePagerAdapter.getCount();
 
-			// adapter can be null if it has not been initialized yet (runs in different thread)
-			if (listAdapter == null) return false;
-			listAdapter.getFilter().filter(newText);
+		// apply filter to all adapters
+		for (int currentItem = 0; currentItem < itemCount; currentItem++) {
+			Fragment fragment = memberChoosePagerAdapter.getRegisteredFragment(currentItem);
+			if (fragment != null) {
+				FilterableListAdapter listAdapter = ((MemberListFragment) fragment).getAdapter();
+				// adapter can be null if it has not been initialized yet (runs in different thread)
+				if (listAdapter != null) {
+					listAdapter.getFilter().filter(newText);
+				}
+			}
 		}
 		return true;
 	}
@@ -119,27 +140,48 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 		// add notice, if desired
 		ActionBar actionBar = getSupportActionBar();
 		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			Toolbar toolbar = getToolbar();
-			if (toolbar != null) {
-				actionBar.setTitle(null);
-			}
+			searchBar = (SearchBar) getToolbar();
+			searchBar.setNavigationOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					if (searchView.isIconified()) {
+						goHome();
+					} else {
+						searchView.setIconified(true);
+					}
+				}
+			});
+			searchBar.setOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					searchView.setIconified(false);
+				}
+			});
+			ConfigUtils.adjustSearchBarTextViewMargin(this, searchBar);
+
 			if (getNotice() != 0) {
+				final View noticeLayout = findViewById(R.id.notice_layout);
 				final TextView noticeText = findViewById(R.id.notice_text);
-				final LinearLayout noticeLayout = findViewById(R.id.notice_layout);
 				noticeText.setText(getNotice());
 				noticeLayout.setVisibility(View.VISIBLE);
 
-				ImageView closeButton = findViewById(R.id.close_button);
-				closeButton.setOnClickListener(new View.OnClickListener() {
-					@Override
-					public void onClick(View v) {
-						AnimationUtil.collapse(noticeLayout);
-					}
-				});
+				findViewById(R.id.close_button).setOnClickListener(v -> AnimationUtil.collapse(noticeLayout));
 			}
 		}
 
+		this.floatingActionButton = findViewById(R.id.floating);
+
+		if (getMode() == MODE_PROFILE_PIC_RECIPIENTS) {
+			floatingActionButton.hide();
+		} else {
+			this.floatingActionButton.setOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					menuNext(getSelectedContacts());
+				}
+			});
+		}
+
 		this.rootView = findViewById(R.id.coordinator);
 
 		try {
@@ -153,15 +195,19 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 
 	@MainThread
 	protected void updateToolbarTitle(@StringRes int title, @StringRes int subtitle) {
-		getToolbar().setTitle(title);
-		getToolbar().setSubtitle(subtitle);
+		if (searchBar != null) {
+			searchBar.setHint(subtitle);
+		}
+		if (searchView != null) {
+			searchView.setQueryHint(getString(subtitle));
+		}
 	}
 
 	protected void initList() {
-		final TabLayout tabLayout = findViewById(R.id.sliding_tabs);
+		tabLayout = findViewById(R.id.sliding_tabs);
 		tabs.clear();
 
-		viewPager = findViewById(R.id.pager);
+		ViewPager viewPager = findViewById(R.id.pager);
 		if (viewPager == null || tabLayout == null) {
 			finish();
 			return;
@@ -176,13 +222,13 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 
 		if (ConfigUtils.isWorkBuild()) {
 			tabLayout.addTab(tabLayout.newTab()
-				.setIcon(ConfigUtils.getThemedDrawable(this, R.drawable.ic_work_outline))
+				.setIcon(R.drawable.ic_work_outline)
 				.setContentDescription(R.string.title_tab_work_users));
 			tabs.add(FRAGMENT_WORK_USERS);
 		}
 
 		tabLayout.addTab(tabLayout.newTab()
-			.setIcon(ConfigUtils.getThemedDrawable(this, R.drawable.ic_person_outline))
+			.setIcon(R.drawable.ic_person_outline)
 			.setContentDescription(R.string.title_tab_users));
 
 		tabs.add(FRAGMENT_USERS);
@@ -201,34 +247,79 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 		viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
 			@Override
 			public void onPageSelected(int position) {
-				if (searchMenuItem != null) {
-					searchMenuItem.collapseActionView();
-					if (searchView != null) {
-						searchView.setQuery("", false);
+				if (searchView != null) {
+					if (searchMenuItem != null) {
+						CharSequence query = searchView.getQuery();
+						if (TestUtil.empty(query)) {
+							invalidateOptionsMenu();
+							if (searchMenuItem.isActionViewExpanded()) {
+								searchMenuItem.collapseActionView();
+								onQueryTextChange(null);
+							}
+							searchView.setQuery("", false);
+							queryText = null;
+						} else {
+							searchMenuItem.getActionView().post(new Runnable() {
+								@Override
+								public void run() {
+									if (!searchMenuItem.isActionViewExpanded()) {
+										searchMenuItem.expandActionView();
+									}
+									searchView.setQuery(query, true);
+								}
+							});
+							queryText = query.toString();
+						}
 					}
 				}
-				invalidateOptionsMenu();
 			}
 		});
 	}
 
+	@Override
+	protected void onCreate(Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		if (savedInstanceState != null) {
+			queryText = savedInstanceState.getString(BUNDLE_QUERY_TEXT, null);
+		}
+	}
+
 	@Override
 	public boolean onCreateOptionsMenu(Menu menu) {
 		super.onCreateOptionsMenu(menu);
 		// Inflate the menu; this adds items to the action bar if it is present.
-		getMenuInflater().inflate(R.menu.activity_member_choose, menu);
-
-		if (!getAddNextButton()) {
-			MenuItem checkItem = menu.findItem(R.id.menu_next);
-			checkItem.setVisible(false);
-		}
+		getMenuInflater().inflate(R.menu.action_compose_message_search, menu);
 
-		this.searchMenuItem = menu.findItem(R.id.menu_search_messages);
+		this.searchMenuItem = menu.findItem(R.id.menu_action_search);
 		this.searchView = (ThreemaSearchView) this.searchMenuItem.getActionView();
+		if (ConfigUtils.isLandscape(this)) {
+			this.searchView.setMaxWidth(Integer.MAX_VALUE);
+		}
 
 		if (this.searchView != null) {
-			this.searchView.setQueryHint(getString(R.string.hint_filter_list));
+			ConfigUtils.adjustSearchViewPadding(searchView);
+			this.searchView.setQueryHint(getString(R.string.title_select_contacts));
 			this.searchView.setOnQueryTextListener(this);
+			// Hide the hint of the search bar when the search view is opened to prevent it from
+			// appearing on some devices
+			this.searchView.setOnSearchClickListener(v -> {
+				if (this.searchBar != null) {
+					this.searchBar.setHint("");
+				}
+			});
+			// Show the hint of the search bar again when the search view is closed
+			this.searchView.setOnCloseListener(() -> {
+				if (this.searchBar != null) {
+					this.searchBar.setHint(R.string.title_select_contacts);
+				}
+				return false;
+			});
+			if (!TestUtil.empty(queryText)) {
+				this.searchMenuItem.expandActionView();
+				this.searchView.setIconified(false);
+				this.searchView.setQuery(queryText, true);
+			}
 		} else {
 			this.searchMenuItem.setVisible(false);
 		}
@@ -240,26 +331,25 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 	public boolean onOptionsItemSelected(MenuItem item) {
 		switch (item.getItemId()) {
 			case android.R.id.home:
-				if (getAddNextButton()) {
-					finish();
-					return true;
-				}
-				/* fallthrough */
-			case R.id.menu_next:
-				RuntimeUtil.runOnUiThread(new Runnable() {
-					@Override
-					public void run() {
-						if (searchView != null) {
-							new WindowInsetsControllerCompat(getWindow(), searchView).hide(WindowInsetsCompat.Type.ime());
-						}
-						menuNext(getSelectedContacts());
-					}
-				});
+				goHome();
 				return true;
 		}
 		return super.onOptionsItemSelected(item);
 	}
 
+	private void goHome() {
+		if (getMode() != MODE_PROFILE_PIC_RECIPIENTS) {
+			finish();
+		} else {
+			RuntimeUtil.runOnUiThread(() -> {
+				if (searchView != null) {
+					new WindowInsetsControllerCompat(getWindow(), searchView).hide(WindowInsetsCompat.Type.ime());
+				}
+				menuNext(getSelectedContacts());
+			});
+		}
+	}
+
 	protected List<ContactModel> getSelectedContacts() {
 		Set<ContactModel> contacts = new HashSet<>();
 		MemberListFragment fragment;
@@ -282,6 +372,7 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 			super(fm);
 		}
 
+		@NonNull
 		@Override
 		public Fragment getItem(int position) {
 
@@ -324,14 +415,14 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 
 		@NonNull
 		@Override
-		public Object instantiateItem(ViewGroup container, int position) {
+		public Object instantiateItem(@NonNull ViewGroup container, int position) {
 			Fragment fragment = (Fragment) super.instantiateItem(container, position);
 			registeredFragments.put(position, fragment);
 			return fragment;
 		}
 
 		@Override
-		public void destroyItem(ViewGroup container, int position, Object object) {
+		public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
 			registeredFragments.remove(position);
 			super.destroyItem(container, position, object);
 		}
@@ -348,18 +439,30 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 		if (contacts.size() > 0) {
 			if (snackbar == null) {
 				snackbar = SnackbarUtil.make(rootView, "", Snackbar.LENGTH_INDEFINITE, 4);
-				snackbar.setBackgroundTint(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
 				snackbar.getView().getLayoutParams().width = AppBarLayout.LayoutParams.MATCH_PARENT;
 			}
-			snackbar.setTextColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorOnSecondary));
 			snackbar.setText(getMemberNames());
 			if (!snackbar.isShown()) {
 				snackbar.show();
 			}
+			if (getMode() == MODE_NEW_GROUP || getMode() == MODE_ADD_TO_GROUP || getMode() == MODE_NEW_DISTRIBUTION_LIST) {
+				if (!floatingActionButton.isShown()) {
+					floatingActionButton.show();
+				}
+			}
 		} else {
 			if (snackbar != null && snackbar.isShown()) {
 				snackbar.dismiss();
 			}
+			if (getMode() == MODE_NEW_GROUP) {
+				if (!floatingActionButton.isShown()) {
+					floatingActionButton.show();
+				}
+			} else {
+				if (floatingActionButton.isShown()) {
+					floatingActionButton.hide();
+				}
+			}
 		}
 	}
 
@@ -374,8 +477,34 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 		return builder.toString();
 	}
 
+	public void onQueryResultChanged(ListFragment listFragment, int count) {
+		int tabPosition = memberChoosePagerAdapter.registeredFragments.indexOfValue(listFragment);
+		TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
+
+		if (tab != null) {
+			if (count > 0) {
+				tab.getOrCreateBadge().setNumber(count);
+				tab.getBadge().setBackgroundColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorPrimary));
+				tab.getBadge().setVisible(true);
+			} else {
+				if (tab.getBadge() != null) {
+					tab.getBadge().setVisible(false);
+				}
+			}
+		}
+	}
+
+	@Override
+	public void onSaveInstanceState(@NonNull Bundle outState) {
+		if (searchView != null) {
+			CharSequence query = searchView.getQuery();
+			outState.putString(BUNDLE_QUERY_TEXT, TextUtils.isEmpty(query) ? null : query.toString());
+		}
+		super.onSaveInstanceState(outState);
+	}
 
-	protected abstract boolean getAddNextButton();
+	@MemberChooseMode
+	protected abstract int getMode();
 
 	@MainThread
 	protected abstract void initData(Bundle savedInstanceState);

+ 36 - 73
app/src/main/java/ch/threema/app/activities/NotificationsActivity.java

@@ -24,7 +24,6 @@ package ch.threema.app.activities;
 import android.app.Activity;
 import android.content.ActivityNotFoundException;
 import android.content.Intent;
-import android.graphics.PorterDuff;
 import android.media.RingtoneManager;
 import android.net.Uri;
 import android.os.Bundle;
@@ -33,7 +32,6 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.view.Window;
 import android.widget.Button;
-import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.ScrollView;
 import android.widget.TextView;
@@ -42,6 +40,9 @@ import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.UiThread;
 import androidx.appcompat.widget.AppCompatRadioButton;
+
+import com.google.android.material.button.MaterialButton;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.RingtoneSelectorDialog;
@@ -75,7 +76,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 			radioSilentExceptMentions,
 			radioSoundCustom,
 			radioSoundNone;
-	private ImageButton plusButton, minusButton, settingsButton;
+	private MaterialButton settingsButton, plusButton, minusButton;
 	private ScrollView parentLayout;
 	protected RingtoneService ringtoneService;
 	protected ContactService contactService;
@@ -110,10 +111,6 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 			return;
 		}
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			setTheme(R.style.Theme_Threema_CircularReveal_Dark);
-		}
-
 		super.onCreate(savedInstanceState);
 
 		supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
@@ -152,16 +149,6 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 			animCenterLocation = savedInstanceState.getIntArray(BUNDLE_ANIMATION_CENTER);
 		}
 
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			plusButton.setImageDrawable(ConfigUtils.getThemedDrawable(this, R.drawable.ic_add_circle_black_24dp));
-			minusButton.setImageDrawable(ConfigUtils.getThemedDrawable(this, R.drawable.ic_remove_circle_black_24dp));
-			settingsButton.setImageDrawable(ConfigUtils.getThemedDrawable(this, R.drawable.ic_settings_outline_24dp));
-		} else {
-			plusButton.setColorFilter(getResources().getColor(R.color.text_color_secondary), PorterDuff.Mode.SRC_IN);
-			minusButton.setColorFilter(getResources().getColor(R.color.text_color_secondary), PorterDuff.Mode.SRC_IN);
-			settingsButton.setColorFilter(getResources().getColor(R.color.text_color_secondary), PorterDuff.Mode.SRC_IN);
-		}
-
 		parentLayout.setOnClickListener(v -> {
 			// ignore clicks
 		});
@@ -227,16 +214,6 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 	abstract void notifySettingsChanged();
 
 	protected void enablePlusMinus(boolean enable) {
-		int filter;
-
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			filter = enable ? ConfigUtils.getPrimaryColor() : getResources().getColor(R.color.material_grey_600);
-		} else {
-			filter = enable ? getResources().getColor(R.color.text_color_secondary) : getResources().getColor(R.color.material_grey_300);
-		}
-
-		plusButton.setColorFilter(filter, PorterDuff.Mode.SRC_IN);
-		minusButton.setColorFilter(filter, PorterDuff.Mode.SRC_IN);
 		plusButton.setEnabled(enable);
 		minusButton.setEnabled(enable);
 	}
@@ -310,52 +287,38 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 
 	@Override
 	public void onClick(View v) {
-		switch (v.getId()) {
-			case R.id.radio_sound_default:
-				ringtoneService.removeCustomRingtone(this.uid);
-				break;
-			case R.id.radio_sound_custom:
-			case R.id.text_sound:
-				pickRingtone(this.uid);
-				break;
-			case R.id.radio_sound_none:
-				ringtoneService.setRingtone(this.uid, null);
-				break;
-			case R.id.radio_silent_off:
-				mutedChatsListService.remove(this.uid);
-				mentionOnlyChatListService.remove(this.uid);
-				break;
-			case R.id.radio_silent_unlimited:
-				mutedChatsListService.add(this.uid, DeadlineListService.DEADLINE_INDEFINITE);
-				mentionOnlyChatListService.remove(this.uid);
-				break;
-			case R.id.radio_silent_limited:
-				if (mutedIndex < 0) {
-					mutedIndex = 0;
-				}
-				mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
-				mentionOnlyChatListService.remove(this.uid);
-				break;
-			case R.id.radio_silent_except_mentions:
-				mentionOnlyChatListService.add(uid, DeadlineListService.DEADLINE_INDEFINITE);
-				mutedChatsListService.remove(uid);
-				break;
-			case R.id.duration_plus:
-				mutedIndex = Math.min(mutedIndex + 1, muteValues.length - 1);
-				mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
-				break;
-			case R.id.duration_minus:
-				mutedIndex = Math.max(mutedIndex - 1, 0);
-				mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
-				break;
-			case R.id.prefs_button:
-				Intent intent = new Intent(this, SettingsActivity.class);
-				intent.putExtra(SettingsActivity.EXTRA_SHOW_NOTIFICATION_FRAGMENT, true);
-				ringtoneSettingsLauncher.launch(intent);
-				overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
-				break;
-			default:
-				break;
+		final int id = v.getId();
+		if (id == R.id.radio_sound_default) {
+			ringtoneService.removeCustomRingtone(this.uid);
+		} else if (id == R.id.radio_sound_custom || id == R.id.text_sound) {
+			pickRingtone(this.uid);
+		} else if (id == R.id.radio_sound_none) {
+			ringtoneService.setRingtone(this.uid, null);
+		} else if (id == R.id.radio_silent_off) {
+			mutedChatsListService.remove(this.uid);
+			mentionOnlyChatListService.remove(this.uid);
+		} else if (id == R.id.radio_silent_unlimited) {
+			mutedChatsListService.add(this.uid, DeadlineListService.DEADLINE_INDEFINITE);
+			mentionOnlyChatListService.remove(this.uid);
+		} else if (id == R.id.radio_silent_limited) {
+			if (mutedIndex < 0) {
+				mutedIndex = 0;
+			}
+			mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
+			mentionOnlyChatListService.remove(this.uid);
+		} else if (id == R.id.radio_silent_except_mentions) {
+			mentionOnlyChatListService.add(uid, DeadlineListService.DEADLINE_INDEFINITE);
+			mutedChatsListService.remove(uid);
+		} else if (id == R.id.duration_plus) {
+			mutedIndex = Math.min(mutedIndex + 1, muteValues.length - 1);
+			mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
+		} else if (id == R.id.duration_minus) {
+			mutedIndex = Math.max(mutedIndex - 1, 0);
+			mutedChatsListService.add(this.uid, muteValues[mutedIndex] * DateUtils.HOUR_IN_MILLIS + System.currentTimeMillis());
+		} else if (id == R.id.prefs_button) {
+			Intent intent = new Intent(this, SettingsActivity.class);
+			intent.putExtra(SettingsActivity.EXTRA_SHOW_NOTIFICATION_FRAGMENT, true);
+			ringtoneSettingsLauncher.launch(intent);
 		}
 		refreshSettings();
 		notifySettingsChanged();

+ 457 - 0
app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt

@@ -0,0 +1,457 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.LinearLayout.LayoutParams
+import android.widget.Space
+import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.preference.PreferenceManager
+import ch.threema.app.BuildConfig
+import ch.threema.app.R
+import ch.threema.app.activities.PermissionRequestActivity.Companion.INTENT_PERMISSION_REQUESTS
+import ch.threema.app.ui.PermissionIconView
+import ch.threema.app.ui.PermissionIconView.PermissionIconState
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.PermissionRequest
+import ch.threema.base.utils.LoggingUtil
+
+private val logger = LoggingUtil.getThreemaLogger("PermissionRequestActivity")
+
+/**
+ * This activity guides the user through the permission requests. This activity finishes with
+ * [Activity.RESULT_OK] if all the given (required) permissions have been granted. If the activity
+ * finishes with [Activity.RESULT_CANCELED], then at least one required permission is not yet given.
+ *
+ * The permission requests can be added to the intent as a list of [PermissionRequest] with the key
+ * [INTENT_PERMISSION_REQUESTS].
+ */
+class PermissionRequestActivity : ThreemaActivity() {
+
+    companion object {
+        const val INTENT_PERMISSION_REQUESTS = "permission_requests_extra"
+    }
+
+    private lateinit var preferences: SharedPreferences
+
+    private val permissionStates: MutableList<Pair<PermissionState, PermissionIconView>> =
+        ArrayList()
+
+    private lateinit var permissionIconViewContainer: LinearLayout
+
+    private lateinit var permissionTitleTextView: TextView
+    private lateinit var permissionDescriptionTextView: TextView
+    private lateinit var permissionSettingsExplanation: TextView
+
+    private lateinit var permissionGrantButton: Button
+    private lateinit var permissionGrantSettingsButton: Button
+    private lateinit var permissionContinueButton: Button
+    private lateinit var permissionIgnoreButton: Button
+    private lateinit var permissionSkipButton: Button
+
+    private var currentPosition = 0
+
+    private val requestPermissionLauncher =
+        registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+            logger.info("Permission result received: {}", isGranted)
+
+            // Note that at this point 'goToSettings' is not yet true if the user just denied
+            // the permission request. In this case the view will be updated to explain the next
+            // steps the user needs to perform.
+            if (!isGranted && getCurrentPermissionState().goToSettings) {
+                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+                intent.data = Uri.parse("package:" + BuildConfig.APPLICATION_ID)
+                startActivity(intent)
+            }
+
+            updatePermissionStates()
+            if (updateCurrentPositionOrLeave()) {
+                updateView(true)
+            }
+        }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        ConfigUtils.configureSystemBars(this)
+
+        super.onCreate(savedInstanceState)
+
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+            finishWithSuccess()
+            return
+        }
+
+        setContentView(R.layout.activity_permission_request)
+
+        preferences = PreferenceManager.getDefaultSharedPreferences(this)
+
+        permissionIconViewContainer = findViewById(R.id.permission_progress)
+
+        permissionTitleTextView = findViewById(R.id.permission_title)
+        permissionDescriptionTextView = findViewById(R.id.permission_description)
+        permissionSettingsExplanation = findViewById(R.id.permission_settings_explanation)
+
+        permissionGrantButton = findViewById(R.id.grant_permission)
+        permissionGrantSettingsButton = findViewById(R.id.grant_permission_settings)
+        permissionContinueButton = findViewById(R.id.permission_continue)
+        permissionIgnoreButton = findViewById(R.id.ignore_permission)
+        permissionSkipButton = findViewById(R.id.skip_permission)
+
+        permissionIgnoreButton.setOnClickListener {
+            val currentPermissionState = getCurrentPermissionState()
+            if (currentPermissionState.ignorePermissionPreference == null) {
+                logger.error("Permission ignore button should not be shown for permissions without preference")
+                updateView(true)
+                return@setOnClickListener
+            }
+
+            logger.info("Save do-not-ask again setting for {}", currentPermissionState.title)
+            preferences.edit().putBoolean(currentPermissionState.ignorePermissionPreference, true)
+                .apply()
+
+            currentPermissionState.asked = true
+
+            if (updateCurrentPositionOrLeave()) {
+                updateView(true)
+            }
+        }
+
+        permissionGrantButton.setOnClickListener {
+            val currentPermission = getCurrentPermissionState().permission
+            logger.info("Request permission {}", currentPermission)
+            requestPermissionLauncher.launch(currentPermission)
+        }
+
+        permissionGrantSettingsButton.setOnClickListener {
+            val currentPermission = getCurrentPermissionState().permission
+            logger.info("Request permission {} (via settings)", currentPermission)
+            requestPermissionLauncher.launch(currentPermission)
+        }
+
+        permissionContinueButton.setOnClickListener {
+            if (updateCurrentPositionOrLeave()) {
+                updateView(true)
+            }
+        }
+
+        permissionSkipButton.setOnClickListener {
+            getCurrentPermissionState().asked = true
+            if (updateCurrentPositionOrLeave()) {
+                updateView(true)
+            }
+        }
+
+        permissionSkipButton.text =
+            getString(R.string.use_threema_without_this_permission, getString(R.string.app_name))
+
+        initializePermissionRequests()
+
+        logger.info("Initialized PermissionRequestActivity for the following permission requests")
+        logPermissionStates()
+    }
+
+    override fun onStart() {
+        super.onStart()
+
+        updatePermissionStates()
+        if (updateCurrentPositionOrLeave()) {
+            updateView(false)
+        }
+    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onBackPressed() {
+        if (getFirstPendingPermissionStatePosition() == null) {
+            finishWithSuccess()
+        } else {
+            finishWithoutSuccess()
+        }
+    }
+
+    private fun initializePermissionRequests() {
+        val requests = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            intent.getParcelableArrayListExtra(
+                INTENT_PERMISSION_REQUESTS,
+                PermissionRequest::class.java
+            )
+        } else {
+            @Suppress("DEPRECATION")
+            intent.getParcelableArrayListExtra(INTENT_PERMISSION_REQUESTS)
+        }
+
+        if (requests == null) {
+            logger.error("No permission requests in intent")
+            finish()
+            return
+        }
+
+        // Only create permission states for permissions that are required on the current API level.
+        for (i in requests.filter { it.permission.isRequired() }.indices) {
+            val request = requests[i]
+            val view = createPermissionView(this, request)
+            view.setOnClickListener {
+                currentPosition = i
+                updateView(true)
+            }
+            permissionStates.add(PermissionState(request, preferences, this) to view)
+        }
+
+        initializePermissionViews()
+    }
+
+    private fun createPermissionView(
+        context: Context,
+        request: PermissionRequest
+    ): PermissionIconView {
+        val view = PermissionIconView(context)
+        view.setIcon(request.icon)
+        return view
+    }
+
+    private fun initializePermissionViews() {
+        permissionStates.map { it.second }.forEach { iconView ->
+            permissionIconViewContainer.addView(createSpaceView())
+            permissionIconViewContainer.addView(iconView)
+        }
+        permissionIconViewContainer.addView(createSpaceView())
+    }
+
+    private fun createSpaceView(): Space {
+        return Space(this).also {
+            it.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
+        }
+    }
+
+    /**
+     * Updates the texts and buttons based on [currentPosition].
+     */
+    private fun updateView(animate: Boolean) {
+        val permissionState = getCurrentPermissionState()
+        permissionTitleTextView.text = permissionState.title
+        permissionDescriptionTextView.text = permissionState.description
+        permissionSettingsExplanation.text =
+            getString(R.string.permission_enable_in_settings_rationale, permissionState.title)
+
+        permissionSkipButton.visibility = visibleOrGone(shouldShowSkipButton(permissionState))
+
+        permissionIgnoreButton.visibility = visibleOrGone(shouldShowIgnoreButton(permissionState))
+
+        for ((state, view) in permissionStates) {
+            view.setHighlighted(state == permissionState, animate)
+            view.updateBadge(getBadgeState(state))
+        }
+
+        permissionSettingsExplanation.visibility =
+            visibleOrInvisible(shouldShowGoToSettingsExplanation(permissionState))
+
+        when {
+            permissionState.granted -> {
+                permissionGrantButton.visibility = View.INVISIBLE
+                permissionGrantSettingsButton.visibility = View.INVISIBLE
+                permissionContinueButton.visibility = View.VISIBLE
+            }
+            permissionState.goToSettings -> {
+                permissionGrantButton.visibility = View.INVISIBLE
+                permissionGrantSettingsButton.visibility = View.VISIBLE
+                permissionContinueButton.visibility = View.INVISIBLE
+            }
+            else -> {
+                permissionGrantButton.visibility = View.VISIBLE
+                permissionGrantSettingsButton.visibility = View.INVISIBLE
+                permissionContinueButton.visibility = View.INVISIBLE
+            }
+        }
+
+        permissionGrantButton.text = when {
+            // If the permission is already granted, then continue to the next permission
+            permissionState.granted -> getString(R.string.next)
+            // Note that this does not work for 'Ask every time'-permissions as we cannot properly
+            // detect their state in advance. Therefore the user may still see a permission dialog
+            // without needing to go to the settings.
+            permissionState.goToSettings -> getString(R.string.grant_permission_settings)
+            // Otherwise show default grant permission text
+            else -> getString(R.string.grant_permission)
+        }
+    }
+
+    /**
+     * Get the current permission based on [currentPosition].
+     */
+    private fun getCurrentPermissionState(): PermissionState =
+        permissionStates[currentPosition].first
+
+    /**
+     * Get the position of the first permission that still needs user action. This can be a required
+     * permission that has not yet been granted or an optional permission that has not yet been
+     * granted or denied.
+     *
+     * @return the position of the pending permission, or null if all have been handled
+     */
+    private fun getFirstPendingPermissionStatePosition(): Int? {
+        return permissionStates
+            .withIndex()
+            .firstOrNull {
+                val request = it.value.first
+                val requiredAndNotGranted = !request.optional && !request.granted
+                val optionalNotGrantedAndNotAsked =
+                    request.optional && !request.granted && !request.asked
+                requiredAndNotGranted || optionalNotGrantedAndNotAsked
+            }?.index
+    }
+
+    /**
+     * Updates the [currentPosition] based on the current state of the permission. Note that the
+     * permission states may need to be updated first with [updatePermissionStates].
+     *
+     * If all required permissions are given and the optional permissions have been granted or
+     * rejected, then the activity gets finished.
+     *
+     * @return true if the position has been updated, false if the activity will be finished
+     */
+    private fun updateCurrentPositionOrLeave(): Boolean {
+        val firstPendingPosition = getFirstPendingPermissionStatePosition()
+        return if (firstPendingPosition != null) {
+            currentPosition = firstPendingPosition
+            true
+        } else {
+            finishWithSuccess()
+            false
+        }
+    }
+
+    /**
+     * Updates the [permissionStates] regarding the [PermissionState.granted] and
+     * [PermissionState.goToSettings].
+     */
+    private fun updatePermissionStates() {
+        for ((request, _) in permissionStates) {
+            request.granted =
+                ContextCompat.checkSelfPermission(this, request.permission) == PERMISSION_GRANTED
+            request.goToSettings =
+                !ActivityCompat.shouldShowRequestPermissionRationale(this, request.permission)
+
+            // Reset the permission ignore preference if the permission has been granted anyway.
+            // This means that the permission will be requested again, once the user denies the
+            // permission.
+            if (request.ignorePermissionPreference != null && request.granted) {
+                preferences.edit().putBoolean(request.ignorePermissionPreference, false).apply()
+            }
+        }
+    }
+
+    private fun finishWithSuccess() {
+        logger.info("All required permissions are granted")
+        logPermissionStates()
+        setResult(RESULT_OK)
+        finish()
+    }
+
+    private fun finishWithoutSuccess() {
+        logger.info("Some required permissions are not granted")
+        logPermissionStates()
+        setResult(RESULT_CANCELED)
+        finish()
+    }
+
+    private fun logPermissionStates() {
+        for (permission in permissionStates.map { it.first }) {
+            logger.info(
+                "Permission '{}': granted={}, redirectToSettings={}",
+                permission.permission,
+                permission.granted,
+                permission.goToSettings
+            )
+        }
+    }
+
+    private fun shouldShowSkipButton(permissionState: PermissionState) =
+        !permissionState.granted && permissionState.optional
+
+    private fun shouldShowIgnoreButton(permissionState: PermissionState) =
+        !permissionState.granted && permissionState.optional && permissionState.ignorePermissionPreference != null
+
+    private fun shouldShowGoToSettingsExplanation(permissionState: PermissionState) =
+        !permissionState.granted && permissionState.goToSettings
+
+    private fun getBadgeState(permissionState: PermissionState): PermissionIconState =
+        if (permissionState.granted) {
+            PermissionIconState.GRANTED
+        } else if (permissionState.asked && permissionState.optional) {
+            PermissionIconState.OPTIONAL_AND_DENIED
+        } else {
+            PermissionIconState.REQUIRED_OR_UNDECIDED
+        }
+
+    private fun visibleOrInvisible(visible: Boolean): Int = if (visible) {
+        View.VISIBLE
+    } else {
+        View.INVISIBLE
+    }
+
+    private fun visibleOrGone(visible: Boolean): Int = if (visible) {
+        View.VISIBLE
+    } else {
+        View.GONE
+    }
+
+    /**
+     * The current state of the permission request.
+     */
+    private data class PermissionState(
+        val permission: String,                     // the permission string
+        val title: String,                          // the name of the permission
+        val description: String,                    // the explanation of the permission
+        var goToSettings: Boolean,                  // true if the user (is likely) redirected to the settings
+        var granted: Boolean,                       // true if the permission is granted
+        var asked: Boolean,                         // true if the user has granted or denied this permission
+        var optional: Boolean,                      // true if this permission is optional
+        val ignorePermissionPreference: String?,    // the 'never-ask-again'-preference (if nonnull)
+    ) {
+        constructor(
+            permissionRequest: PermissionRequest,
+            preferences: SharedPreferences,
+            context: Context
+        ) : this(
+            permissionRequest.permission.getPermissionString(),
+            permissionRequest.permission.getPermissionName(context),
+            permissionRequest.description,
+            false,
+            false,
+            preferences.getBoolean(permissionRequest.permissionIgnorePreference, false),
+            permissionRequest.optional,
+            permissionRequest.permissionIgnorePreference
+        )
+    }
+}

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

@@ -21,21 +21,17 @@
 
 package ch.threema.app.activities;
 
-import android.content.res.Configuration;
 import android.os.Bundle;
 import android.os.CountDownTimer;
 import android.os.Handler;
 import android.os.SystemClock;
 import android.text.InputFilter;
 import android.text.InputType;
-import android.view.KeyEvent;
 import android.view.WindowManager;
 import android.view.inputmethod.EditorInfo;
 import android.widget.Button;
 import android.widget.TextView;
 
-import androidx.annotation.NonNull;
-
 import org.slf4j.Logger;
 
 import java.security.MessageDigest;
@@ -53,8 +49,6 @@ import ch.threema.base.utils.LoggingUtil;
 
 public class PinLockActivity extends ThreemaActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("PinLockActivity");
-
-	private static final String KEY_NUM_WRONG_CONFIRM_ATTEMPTS = "num_wrong_attempts";
 	private static final long ERROR_MESSAGE_TIMEOUT = 3000;
 	private static final int FAILED_ATTEMPTS_BEFORE_TIMEOUT = 3;
 	private static final long FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS = 1000L;
@@ -79,7 +73,7 @@ public class PinLockActivity extends ThreemaActivity {
 
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
 
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		isCheckOnly = getIntent().getBooleanExtra(ThreemaApplication.INTENT_DATA_CHECK_ONLY, false);
 		pinPreset = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_PIN);

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

@@ -65,8 +65,8 @@ public class ProfilePicRecipientsActivity extends MemberChooseActivity {
 	}
 
 	@Override
-	protected boolean getAddNextButton() {
-		return false;
+	protected int getMode() {
+		return MODE_PROFILE_PIC_RECIPIENTS;
 	}
 
 	@Override

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

@@ -26,14 +26,14 @@ import android.view.View;
 
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.view.ViewCompat;
-import ch.threema.app.R;
+
 import ch.threema.app.services.QRCodeServiceImpl;
 import ch.threema.app.ui.QRCodePopup;
 
 import static ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ANY;
 
 /***
- * Activity displaying QR Code popup. Used by Launcher shortcut
+ * Activity displaying QR Code popup
  */
 public class QRCodeZoomActivity extends AppCompatActivity {
 	QRCodePopup qrPopup = null;
@@ -73,12 +73,4 @@ public class QRCodeZoomActivity extends AppCompatActivity {
 
 		super.onDestroy();
 	}
-
-	@Override
-	protected void onPause() {
-		super.onPause();
-		if (isFinishing()) {
-			overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
-		}
-	}
 }

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

@@ -21,6 +21,11 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_IMAGES;
+import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
+import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
+import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.ClipData;
@@ -46,7 +51,6 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.ProgressBar;
 import android.widget.Toast;
 
 import androidx.annotation.AnyThread;
@@ -65,6 +69,8 @@ import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 
+import com.google.android.material.progressindicator.CircularProgressIndicator;
+import com.google.android.material.search.SearchBar;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.tabs.TabLayout;
 
@@ -106,6 +112,7 @@ import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SingleToast;
 import ch.threema.app.ui.ThreemaSearchView;
@@ -131,11 +138,6 @@ import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.LocationDataModel;
 import java8.util.concurrent.CompletableFuture;
 
-import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_IMAGES;
-import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
-import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
-import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
-
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	CancelableHorizontalProgressDialog.ProgressDialogClickListener,
 	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
@@ -160,10 +162,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	private static final int REQUEST_READ_EXTERNAL_STORAGE = 1;
 	private static final String BUNDLE_QUERY_TEXT = "query";
 
-	private ViewPager viewPager;
+	private TabLayout tabLayout;
 	private UserGroupPagerAdapter userGroupPagerAdapter;
 	private MenuItem searchMenuItem;
 	private ThreemaSearchView searchView;
+	private SearchBar searchBar;
 
 	private boolean hideUi, hideRecents, multiSelect, multiSelectIdentities;
 	private String captionText, queryText;
@@ -250,7 +253,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			return false;
 		};
 
-		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		UserService userService;
 		try {
 			this.contactService = serviceManager.getContactService();
@@ -265,8 +267,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			return false;
 		}
 
-		if (!userService.hasIdentity()) {
-			ConfigUtils.recreateActivity(this);
+		if (!userService.hasIdentity() || (ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid())) {
+			logger.debug("No identity or not licensed");
+			finish();
+			System.exit(0);
 		}
 
 		onNewIntent(getIntent());
@@ -275,11 +279,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void setupUI() {
-		final TabLayout tabLayout = findViewById(R.id.sliding_tabs);
+		tabLayout = findViewById(R.id.sliding_tabs);
 		final ActionBar actionBar = getSupportActionBar();
-		final ProgressBar progressBar = findViewById(R.id.progress_sending);
+		final CircularProgressIndicator progressBar = findViewById(R.id.progress_sending);
 
-		viewPager = findViewById(R.id.pager);
+		ViewPager viewPager = findViewById(R.id.pager);
 		if (viewPager == null || tabLayout == null) {
 			finish();
 			return;
@@ -387,11 +391,30 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			if (actionBar != null) {
 				actionBar.setDisplayHomeAsUpEnabled(true);
 				actionBar.setTitle(R.string.title_choose_recipient);
+				searchBar = (SearchBar) getToolbar();
+				searchBar.setNavigationOnClickListener(new View.OnClickListener() {
+					@Override
+					public void onClick(View v) {
+						if (searchView.isIconified()) {
+							finish();
+						} else {
+							searchView.setIconified(true);
+						}
+					}
+				});
+				searchBar.setOnClickListener(new View.OnClickListener() {
+					@Override
+					public void onClick(View v) {
+						searchView.setIconified(false);
+					}
+				});
+
+				ConfigUtils.adjustSearchBarTextViewMargin(this, searchBar);
 			}
 
 			if (!hideRecents && !conversationService.hasConversations()) {
 				//no conversation? show users tab as default
-				this.viewPager.setCurrentItem(tabs.indexOf(FRAGMENT_USERS), true);
+				viewPager.setCurrentItem(tabs.indexOf(FRAGMENT_USERS), true);
 			}
 
 			if (searchMenuItem != null) {
@@ -826,18 +849,35 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	public boolean onCreateOptionsMenu(Menu menu) {
 		super.onCreateOptionsMenu(menu);
 		// Inflate the menu; this adds items to the action bar if it is present.
-		getMenuInflater().inflate(R.menu.activity_recipientlist, menu);
+		getMenuInflater().inflate(R.menu.action_compose_message_search, menu);
 
-		this.searchMenuItem = menu.findItem(R.id.menu_search_messages);
+		this.searchMenuItem = menu.findItem(R.id.menu_action_search);
 		this.searchView = (ThreemaSearchView) this.searchMenuItem.getActionView();
+		if (ConfigUtils.isLandscape(this)) {
+			this.searchView.setMaxWidth(Integer.MAX_VALUE);
+		}
 
-		if (this.searchView != null) {
-			this.searchView.setQueryHint(getString(R.string.hint_filter_list));
+		if (this.searchView != null && !hideUi) {
+			ConfigUtils.adjustSearchViewPadding(searchView);
+			this.searchView.setQueryHint(getString(R.string.title_choose_recipient));
 			this.searchView.setOnQueryTextListener(this);
-			if (hideUi) {
-				this.searchMenuItem.setVisible(false);
-			} else if (!TestUtil.empty(queryText)) {
+			// Hide the hint of the search bar when the search view is opened to prevent it from
+			// appearing on some devices
+			this.searchView.setOnSearchClickListener(v -> {
+				if (this.searchBar != null) {
+					this.searchBar.setHint("");
+				}
+			});
+			// Show the hint of the search bar again when the search view is closed
+			this.searchView.setOnCloseListener(() -> {
+				if (this.searchBar != null) {
+					this.searchBar.setHint(R.string.title_choose_recipient);
+				}
+				return false;
+			});
+			if (!TestUtil.empty(queryText)) {
 				this.searchMenuItem.expandActionView();
+				this.searchView.setIconified(false);
 				this.searchView.setQuery(queryText, true);
 			}
 		} else {
@@ -1033,7 +1073,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 					ThreemaDialogFragment alertDialog;
 					if (!expandable) {
-						alertDialog = TextWithCheckboxDialog.newInstance(getString(R.string.really_forward, recipientName), hasCaptions ? R.string.forward_captions : 0, R.string.send, R.string.cancel);
+						alertDialog = TextWithCheckboxDialog.newInstance(getString(R.string.forward_message),
+							getString(R.string.really_forward, recipientName),
+							hasCaptions ? R.string.forward_captions : 0,
+							R.string.send,
+							R.string.cancel);
 					} else {
 						alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_forward, recipientName), R.string.add_caption_hint, presetCaption, R.string.send, R.string.cancel, true);
 					}
@@ -1155,6 +1199,23 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
+	public void onQueryResultChanged(RecipientListFragment recipientListFragment, int count) {
+		int tabPosition = userGroupPagerAdapter.registeredFragments.indexOfValue(recipientListFragment);
+		TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
+
+		if (tab != null) {
+			if (count > 0) {
+				tab.getOrCreateBadge().setNumber(count);
+				tab.getBadge().setBackgroundColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorPrimary));
+				tab.getBadge().setVisible(true);
+			} else {
+				if (tab.getBadge() != null) {
+					tab.getBadge().setVisible(false);
+				}
+			}
+		}
+	}
+
 	public class UserGroupPagerAdapter extends FragmentPagerAdapter {
 		// these globals are not persistent across orientation changes (at least in Android <= 4.1)!
 		SparseArray<Fragment> registeredFragments = new SparseArray<Fragment>();

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

@@ -552,7 +552,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void showSettingsDropDown(final View view, final @NonNull MediaItem mediaItem) {
-		Context contextWrapper = new ContextThemeWrapper(this, R.style.Threema_PopupMenuStyle);
+		Context contextWrapper = new ContextThemeWrapper(this, R.style.Threema_PopupMenuStyle_SendMedia);
 		PopupMenu popup = new PopupMenu(contextWrapper, view);
 
 		if (mediaItem.getType() == TYPE_IMAGE) {
@@ -681,7 +681,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		try {
 			startActivityForResult(cameraIntent, requestCode);
-			overridePendingTransition(0, 0);
 		} catch (ActivityNotFoundException e) {
 			logger.error("Exception", e);
 			finish();
@@ -797,7 +796,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void prepareFlip() {
-		flip(mediaAdapterManager.getCurrentItem());
+		mediaAdapterManager.getCurrentItem().flip();
 		mediaAdapterManager.updateCurrent(NOTIFY_BOTH_ADAPTERS);
 	}
 
@@ -809,27 +808,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		return super.onOptionsItemSelected(item);
 	}
 
-	private void flip(@NonNull MediaItem item) {
-		int currentFlip = item.getFlip();
-
-		if (item.getRotation() == 90 || item.getRotation() == 270) {
-			if ((currentFlip & FLIP_VERTICAL) == FLIP_VERTICAL) {
-				// clear vertical flag
-				currentFlip &= ~FLIP_VERTICAL;
-			} else {
-				currentFlip |= FLIP_VERTICAL;
-			}
-		} else {
-			if ((currentFlip & FLIP_HORIZONTAL) == FLIP_HORIZONTAL) {
-				// clear horizontal flag
-				currentFlip &= ~FLIP_HORIZONTAL;
-			} else {
-				currentFlip |= FLIP_HORIZONTAL;
-			}
-		}
-		mediaAdapterManager.getCurrentItem().setFlip(currentFlip);
-	}
-
 	@SuppressLint("StaticFieldLeak")
 	private void addItemsByMediaItem(List<MediaItem> incomingMediaItems, boolean prepend) {
 		if (incomingMediaItems.size() > 0) {

+ 4 - 3
app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java

@@ -26,12 +26,13 @@ import android.text.method.LinkMovementMethod;
 import android.view.MenuItem;
 import android.widget.TextView;
 
-import org.slf4j.Logger;
-
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.core.text.HtmlCompat;
 import androidx.lifecycle.ViewModelProvider;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
@@ -51,7 +52,7 @@ public class ServerMessageActivity extends ThreemaActivity {
 
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 

+ 58 - 47
app/src/main/java/ch/threema/app/activities/SimpleWebViewActivity.java

@@ -21,61 +21,84 @@
 
 package ch.threema.app.activities;
 
-import android.content.res.Configuration;
+import android.content.Intent;
 import android.net.ConnectivityManager;
 import android.os.Bundle;
-import android.view.MenuItem;
 import android.view.View;
 import android.webkit.WebChromeClient;
 import android.webkit.WebView;
-import android.widget.ProgressBar;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
-import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.view.WindowCompat;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.utils.ConfigUtils;
 
+/**
+ * Warning! Do not start an Activity extending this class from an application context!
+ */
 public abstract class SimpleWebViewActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
+
+	public static final String FORCE_DARK_THEME = "darkTheme";
 	private static final String DIALOG_TAG_NO_CONNECTION = "nc";
-	private ProgressBar progressBar;
+	private LinearProgressIndicator progressBar;
 	private WebView webView;
 
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		ActionBar actionBar = getSupportActionBar();
-		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			actionBar.setTitle(getWebViewTitle());
+		WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
+
+		MaterialToolbar toolbar = findViewById(R.id.material_toolbar);
+		toolbar.setNavigationOnClickListener(view -> finish());
+		toolbar.setTitle(getWebViewTitle());
+
+		Intent intent = getIntent();
+		Bundle extras = intent.getExtras();
+		final boolean darkThemeForced;
+
+		if (extras != null && extras.getBoolean(FORCE_DARK_THEME, false)) {
+			darkThemeForced = true;
+			if (getConnectionIndicator() != null) {
+				// hide connection indicator when launched from wizard
+				getConnectionIndicator().setVisibility(View.INVISIBLE);
+			}
+		} else {
+			darkThemeForced = false;
+		}
+
+		if (!ConfigUtils.isTheDarkSide(this)) {
+			if (darkThemeForced) {
+				getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+			}
 		}
 
 		progressBar = findViewById(R.id.progress);
 		webView = findViewById(R.id.simple_webview);
-		webView.getSettings().setJavaScriptEnabled(false);
-		webView.setWebChromeClient(new WebChromeClient() {
-			@Override
-			public void onProgressChanged(WebView view, int newProgress) {
-				if (newProgress >= 99) {
-					progressBar.setVisibility(View.INVISIBLE);
-				} else {
-					progressBar.setProgress(newProgress);
+		webView.getSettings().setJavaScriptEnabled(requiresJavaScript());
+
+		if (requiresConnection()) {
+			webView.setWebChromeClient(new WebChromeClient() {
+				@Override
+				public void onProgressChanged(WebView view, int newProgress) {
+					if (newProgress >= 99) {
+						progressBar.setVisibility(View.INVISIBLE);
+					} else {
+						progressBar.setProgress(newProgress);
+					}
 				}
-			}
-		});
-
-		checkConnection();
-	}
-
-	@Override
-	protected boolean initActivity(Bundle savedInstanceState) {
-		boolean result = super.initActivity(savedInstanceState);
+			});
+			checkConnection();
+		} else {
+			progressBar.setVisibility(View.GONE);
 
-		if (getConnectionIndicator() != null) {
-			getConnectionIndicator().setVisibility(View.INVISIBLE);
+			loadWebView();
 		}
-		return result;
 	}
 
 	private void loadWebView() {
@@ -95,23 +118,6 @@ public abstract class SimpleWebViewActivity extends ThreemaToolbarActivity imple
 		return R.layout.activity_simple_webview;
 	}
 
-	@Override
-	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				finish();
-				break;
-		}
-		return false;
-	}
-
-	@Override
-	public void onConfigurationChanged(@NonNull Configuration newConfig) {
-		super.onConfigurationChanged(newConfig);
-
-		ConfigUtils.adjustToolbar(this, getToolbar());
-	}
-
 	@Override
 	public void onYes(String tag, Object data) {
 		checkConnection();
@@ -124,4 +130,9 @@ public abstract class SimpleWebViewActivity extends ThreemaToolbarActivity imple
 
 	protected abstract @StringRes int getWebViewTitle();
 	protected abstract String getWebViewUrl();
+	protected boolean requiresConnection() {
+		return true;
+	}
+	protected boolean requiresJavaScript() { return false; }
 }
+

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

@@ -75,14 +75,10 @@ public class StopPassphraseServiceActivity extends Activity {
 
 				masterKey.lock();
 				PassphraseService.stop(this);
-				ConfigUtils.scheduleAppRestart(this, 2000, getString(R.string.passphrase_locked));
+				ConfigUtils.scheduleAppRestart(this, 2000, null);
 			}
 		}
 
-		if (Build.VERSION.SDK_INT >= 21) {
-			finishAndRemoveTask();
-		} else {
-			finish();
-		}
+		finishAndRemoveTask();
 	}
 }

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

@@ -31,10 +31,10 @@ import android.view.View;
 import android.widget.ArrayAdapter;
 import android.widget.Button;
 import android.widget.FrameLayout;
-import android.widget.ProgressBar;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 
@@ -85,7 +85,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 	private TextView totalView, usageView, freeView, messageView, inuseView;
 	private MaterialAutoCompleteTextView timeSpinner, messageTimeSpinner;
 	private Button deleteButton, messageDeleteButton;
-	private ProgressBar progressBar;
+	private CircularProgressIndicator progressBar;
 	private boolean isCancelled, isMessageDeleteCancelled;
 	private int selectedSpinnerItem, selectedMessageSpinnerItem;
 	private FrameLayout storageFull, storageThreema, storageEmpty;

+ 18 - 66
app/src/main/java/ch/threema/app/activities/SupportActivity.java

@@ -21,23 +21,11 @@
 
 package ch.threema.app.activities;
 
-
-import android.annotation.SuppressLint;
-import android.content.res.Configuration;
-import android.os.Bundle;
-import android.view.MenuItem;
-import android.view.View;
-import android.webkit.WebChromeClient;
-import android.webkit.WebView;
-import android.widget.ProgressBar;
-
 import org.slf4j.Logger;
 
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LocaleUtil;
@@ -45,64 +33,26 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.UrlUtil;
 import ch.threema.base.utils.LoggingUtil;
 
-public class SupportActivity extends ThreemaToolbarActivity {
+public class SupportActivity extends SimpleWebViewActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("SupportActivity");
-	private ProgressBar progressBar;
-
-	@SuppressLint("SetJavaScriptEnabled")
-	public void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		ActionBar actionBar = getSupportActionBar();
-		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			actionBar.setTitle(R.string.support);
-		}
-
-		progressBar = findViewById(R.id.progress);
-
-		WebView wv = findViewById(R.id.simple_webview);
-		wv.getSettings().setJavaScriptEnabled(true);
-		wv.setWebChromeClient(new WebChromeClient() {
-			@Override
-			public void onProgressChanged(WebView view, int newProgress) {
-				if (newProgress >= 99) {
-					progressBar.setVisibility(View.INVISIBLE);
-				} else {
-					progressBar.setProgress(newProgress);
-			}
-		}
-	});
 
-		wv.loadUrl(getURL());
-	}
-
-	public int getLayoutResource() {
-		return R.layout.activity_simple_webview;
+	@Override
+	protected boolean requiresConnection() {
+		return true;
 	}
 
 	@Override
-	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-//				ActivityCompat.finishAfterTransition(this);
-				finish();
-				break;
-		}
-		return false;
+	protected boolean requiresJavaScript() {
+		return true;
 	}
 
-	private String getIdentity() {
-		try {
-			return URLEncoder.encode(serviceManager.getUserService().getIdentity(), LocaleUtil.UTF8_ENCODING);
-		} catch (UnsupportedEncodingException e) {
-			logger.error("Encoding exception", e);
-		}
-		return "";
+	@Override
+	protected int getWebViewTitle() {
+		return R.string.support;
 	}
 
-	private String getURL() {
-		//try to load the custom url!
+	@Override
+	protected String getWebViewUrl() {
 		String baseURL = null;
 
 		if(ConfigUtils.isWorkBuild()) {
@@ -118,10 +68,12 @@ public class SupportActivity extends ThreemaToolbarActivity {
 			+ "&identity=" + getIdentity();
 	}
 
-	@Override
-	public void onConfigurationChanged(@NonNull Configuration newConfig) {
-		super.onConfigurationChanged(newConfig);
-
-		ConfigUtils.adjustToolbar(this, getToolbar());
+	private String getIdentity() {
+		try {
+			return URLEncoder.encode(serviceManager.getUserService().getIdentity(), LocaleUtil.UTF8_ENCODING);
+		} catch (UnsupportedEncodingException e) {
+			logger.error("Encoding exception", e);
+		}
+		return "";
 	}
 }

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

@@ -22,7 +22,6 @@
 package ch.threema.app.activities;
 
 import android.content.Intent;
-import android.graphics.PorterDuff;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
@@ -34,22 +33,20 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
 
+import androidx.annotation.ColorInt;
+import androidx.annotation.LayoutRes;
+
+import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.card.MaterialCardView;
 
 import org.slf4j.Logger;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.LayoutRes;
-import androidx.appcompat.widget.Toolbar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.emojis.EmojiConversationTextView;
-import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
-import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
@@ -61,7 +58,7 @@ import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 
-public class TextChatBubbleActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener {
+public class TextChatBubbleActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("TextChatBubbleActivity");
 
 	private static final int CONTEXT_MENU_FORWARD = 600;
@@ -78,7 +75,12 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 		@Override
 		public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
 			menu.removeGroup(CONTEXT_MENU_GROUP);
-			menu.add(CONTEXT_MENU_GROUP, CONTEXT_MENU_FORWARD, 200, R.string.forward_text);
+			try {
+				menu.add(CONTEXT_MENU_GROUP, CONTEXT_MENU_FORWARD, 200, R.string.forward_text);
+			} catch (Exception e) {
+				// some MIUI device crash when attempting to add a context menu
+				logger.error("Error adding context menu (Xiaomi?)", e);
+			}
 			return true;
 		}
 
@@ -120,61 +122,48 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 	};
 
 	@Override
-	public void onCreate(Bundle savedInstanceState) {
-		logger.debug("onCreate");
+	protected boolean initActivity(Bundle savedInstanceState) {
+		getTheme().applyStyle(ThreemaApplication.getServiceManager().getPreferenceService().getFontStyle(), true);
+
+		if (!super.initActivity(savedInstanceState)) {
+			return false;
+		}
 
 		MessageService messageService;
-		PreferenceService preferenceService;
-		LockAppService lockAppService;
 		MessageReceiver<? extends AbstractMessageModel> messageReceiver;
 		@LayoutRes int footerLayout;
 		@ColorInt int color;
 		String title;
 
-		ConfigUtils.configureActivityTheme(this);
-
-		super.onCreate(savedInstanceState);
-
 		try {
-			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 			messageService = serviceManager.getMessageService();
-			preferenceService = serviceManager.getPreferenceService();
-			lockAppService = serviceManager.getLockAppService();
 		} catch (Exception e) {
 			finish();
-			return;
+			return false;
 		}
 
-		// set font size according to user preferences
-		getTheme().applyStyle(ThreemaApplication.getServiceManager().getPreferenceService().getFontStyle(), true);
-		// hide contents in app switcher and inhibit screenshots
-		ConfigUtils.setScreenshotsAllowed(this, preferenceService, lockAppService);
-		ConfigUtils.setLocaleOverride(this, preferenceService);
-
-		setContentView(R.layout.activity_text_chat_bubble);
-
 		AbstractMessageModel messageModel = IntentDataUtil.getAbstractMessageModel(getIntent(), messageService);
 		try {
 			messageReceiver = messageService.getMessageReceiver(messageModel);
 		} catch (ThreemaException e) {
 			logger.error("Exception", e);
 			finish();
-			return;
+			return false;
 		}
 
 		if (messageModel.isOutbox()) {
 			// send
-			color = ConfigUtils.getColorFromAttribute(this, R.attr.bubble_send);
+			color = ConfigUtils.getColorFromAttribute(this, R.attr.colorSecondaryContainer);
 			title = getString(R.string.threema_message_to, messageReceiver.getDisplayName());
 			footerLayout = R.layout.conversation_bubble_footer_send;
 		} else {
 			// recv
-			color = ConfigUtils.getColorFromAttribute(this, R.attr.bubble_recv);
+			color = getResources().getColor(R.color.bubble_receive);
 			title = getString(R.string.threema_message_from, messageReceiver.getDisplayName());
 			footerLayout = R.layout.conversation_bubble_footer_recv;
 		}
 
-		Toolbar toolbar = findViewById(R.id.toolbar);
+		MaterialToolbar toolbar = findViewById(R.id.material_toolbar);
 		toolbar.setNavigationOnClickListener(view -> finish());
 		toolbar.setOnMenuItemClickListener(item -> {
 			if (item.isChecked()) {
@@ -192,13 +181,6 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 
 		ConfigUtils.addIconsToOverflowMenu(this, toolbar.getMenu());
 
-		// TODO: replace with "toolbarNavigationButtonStyle" attribute in theme as soon as all Toolbars have been switched to Material Components
-		toolbar.getNavigationIcon().setColorFilter(getResources().getColor(
-			ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK ?
-				R.color.dark_text_color_primary :
-				R.color.text_color_secondary),
-			PorterDuff.Mode.SRC_IN);
-
 		MaterialCardView cardView = findViewById(R.id.card_view);
 		cardView.setCardBackgroundColor(color);
 
@@ -213,7 +195,7 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 		((TextView) footerView.findViewById(R.id.date_view)).setText(s != null ? s : "");
 
 		// display message status
-		StateBitmapUtil.getInstance().setStateDrawable(messageModel, findViewById(R.id.delivered_indicator), true);
+		StateBitmapUtil.getInstance().setStateDrawable(this, messageModel, findViewById(R.id.delivered_indicator), true);
 
 		// mock a composemessageholder
 		ComposeMessageHolder holder = new ComposeMessageHolder();
@@ -236,6 +218,13 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 				finish();
 			}
 		});
+
+		return true;
+	}
+
+	@Override
+	public int getLayoutResource() {
+		return R.layout.activity_text_chat_bubble;
 	}
 
 	private void setText(AbstractMessageModel messageModel) {

+ 0 - 22
app/src/main/java/ch/threema/app/activities/ThreemaActivity.java

@@ -21,19 +21,15 @@
 
 package ch.threema.app.activities;
 
-import android.content.Intent;
-import android.os.Handler;
 import android.widget.Toast;
 
 import org.slf4j.Logger;
 
-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.services.UserService;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.voip.activities.CallActivity;
 import ch.threema.base.utils.LoggingUtil;
 
 public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
@@ -158,22 +154,4 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 		}
 		return this.myIdentity;
 	}
-
-	@Override
-	public void startActivityForResult(Intent intent, int requestCode) {
-		super.startActivityForResult(intent, requestCode);
-		overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
-	}
-
-	@Override
-	public void startActivity(Intent intent) {
-		super.startActivity(intent);
-		overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
-	}
-
-	@Override
-	public void finish() {
-		super.finish();
-		overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
-	}
 }

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

@@ -21,13 +21,20 @@
 
 package ch.threema.app.activities;
 
+import static android.content.res.Configuration.UI_MODE_NIGHT_YES;
+import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO;
+import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES;
+
 import android.content.res.Configuration;
+import android.os.Bundle;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
+
+import org.slf4j.Logger;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.backuprestore.csv.BackupService;
@@ -41,25 +48,21 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ThreemaAppCompatActivity");
 
+	protected int savedDayNightMode;
+
+	@Override
+	protected void onCreate(@Nullable Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		savedDayNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
+		ConfigUtils.setCurrentDayNightMode(savedDayNightMode == UI_MODE_NIGHT_YES ? MODE_NIGHT_YES : MODE_NIGHT_NO);
+	}
+
 	@Override
 	protected void onResume() {
 		if (BackupService.isRunning() || RestoreService.isRunning()) {
 			Toast.makeText(this,  R.string.backup_restore_in_progress, Toast.LENGTH_LONG).show();
 			finish();
-		} else {
-			if (ConfigUtils.refreshDeviceTheme(this)) {
-				ConfigUtils.recreateActivity(this);
-
-				// Reset avatar cache on theme change so that the default avatars are loaded with the correct (themed) color
-				ServiceManager sm = ThreemaApplication.getServiceManager();
-				if (sm != null) {
-					try {
-						sm.getAvatarCacheService().clear();
-					} catch (FileSystemNotPresentException e) {
-						logger.error("Couldn't get avatar cache service to reset cached avatars", e);
-					}
-				}
-			}
 		}
 		try {
 			super.onResume();
@@ -69,6 +72,22 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
 
 	@Override
 	public void onConfigurationChanged(@NonNull Configuration newConfig) {
+		int newDayNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
+		if (savedDayNightMode != newDayNightMode) {
+			savedDayNightMode = newDayNightMode;
+			ConfigUtils.setCurrentDayNightMode(newDayNightMode == UI_MODE_NIGHT_YES ? MODE_NIGHT_YES : MODE_NIGHT_NO);
+
+			// Reset avatar cache on theme change so that the default avatars are loaded with the correct (themed) color
+			ServiceManager sm = ThreemaApplication.getServiceManager();
+			if (sm != null) {
+				try {
+					sm.getAvatarCacheService().clear();
+				} catch (FileSystemNotPresentException e) {
+					logger.error("Couldn't get avatar cache service to reset cached avatars", e);
+				}
+			}
+			recreate();
+		}
 		super.onConfigurationChanged(newConfig);
 	}
 }

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

@@ -36,7 +36,7 @@ class ThreemaPushNotificationInfoActivity : ThreemaActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         logger.debug("onCreate")
 
-        ConfigUtils.configureActivityTheme(this)
+        ConfigUtils.configureSystemBars(this)
 
         super.onCreate(savedInstanceState)
 

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

@@ -30,6 +30,13 @@ import android.view.View;
 import android.widget.EditText;
 import android.widget.Toast;
 
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import androidx.appcompat.widget.Toolbar;
+import androidx.preference.PreferenceManager;
+
+import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import org.slf4j.Logger;
@@ -38,11 +45,6 @@ import java.net.InetSocketAddress;
 import java.util.HashSet;
 import java.util.Set;
 
-import androidx.annotation.LayoutRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.UiThread;
-import androidx.appcompat.widget.Toolbar;
-import androidx.preference.PreferenceManager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.wizard.WizardIntroActivity;
@@ -93,8 +95,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 	@Override
 	protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) {
-		// TODO
-
 		super.onApplyThemeResource(theme, resid, first);
 	}
 
@@ -107,7 +107,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
 
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 		resetKeyboard();
 
 		super.onCreate(savedInstanceState);
@@ -183,10 +183,15 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 			logger.debug("setContentView");
 
 			setContentView(getLayoutResource());
+
 			this.toolbar = findViewById(R.id.toolbar);
 			if (toolbar != null) {
 				setSupportActionBar(toolbar);
 			}
+			AppBarLayout appBarLayout = findViewById(R.id.appbar);
+			if (appBarLayout != null) {
+				appBarLayout.addLiftOnScrollListener((elevation, backgroundColor) -> getWindow().setStatusBarColor(backgroundColor));
+			}
 
 			connectionIndicator = findViewById(R.id.connection_indicator);
 		}
@@ -249,7 +254,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	private static final String LANDSCAPE_HEIGHT = "kbd_landscape_height";
 	private final Set<OnSoftKeyboardChangedListener> softKeyboardChangedListeners = new HashSet<>();
 	private boolean softKeyboardOpen = false;
-	private int minKeyboardHeight, minEmojiPickerHeight;
+	private int minEmojiPickerHeight;
 
 	public interface OnSoftKeyboardChangedListener {
 		void onKeyboardHidden();
@@ -283,14 +288,11 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	}
 
 	public void onSoftKeyboardOpened(int softKeyboardHeight) {
-		logger.debug("Potential keyboard height = " + softKeyboardHeight + " Min = " + minKeyboardHeight);
-
-		if (softKeyboardHeight >= minKeyboardHeight) {
-			logger.debug("Soft keyboard open detected");
+		logger.debug("Soft keyboard open detected");
 
+		if (!this.softKeyboardOpen) {
 			this.softKeyboardOpen = true;
 			saveSoftKeyboardHeight(softKeyboardHeight);
-
 			notifySoftKeyboardShown();
 		}
 	}
@@ -298,9 +300,10 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	public void onSoftKeyboardClosed() {
 		logger.debug("Soft keyboard closed");
 
-		this.softKeyboardOpen = false;
-
-		notifySoftKeyboardHidden();
+		if (this.softKeyboardOpen) {
+			this.softKeyboardOpen = false;
+			notifySoftKeyboardHidden();
+		}
 	}
 
 	public void runOnSoftKeyboardClose(final Runnable runnable) {
@@ -388,7 +391,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	}
 
 	public void resetKeyboard() {
-		minKeyboardHeight = getResources().getDimensionPixelSize(R.dimen.min_keyboard_height);
 		minEmojiPickerHeight = getResources().getDimensionPixelSize(R.dimen.min_emoji_keyboard_height);
 
 		removeAllListeners();

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

@@ -24,7 +24,6 @@ package ch.threema.app.activities;
 import android.app.NotificationManager;
 import android.content.Context;
 import android.content.res.Configuration;
-import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.graphics.PorterDuff;
 import android.os.Bundle;
@@ -34,16 +33,17 @@ import android.view.KeyEvent;
 import android.view.WindowManager;
 import android.view.inputmethod.EditorInfo;
 import android.widget.EditText;
-import android.widget.ImageView;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+
+import com.google.android.material.button.MaterialButton;
 import com.google.android.material.textfield.TextInputLayout;
 
 import org.slf4j.Logger;
 
 import java.util.Arrays;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -69,11 +69,13 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 	// Views
 	private ThreemaTextInputEditText passphraseText;
 	private TextInputLayout passphraseLayout;
-	private ImageView unlockButton;
+	private MaterialButton unlockButton;
 
 	private final MasterKey masterKey = ThreemaApplication.getMasterKey();
 
 	public void onCreate(Bundle savedInstanceState) {
+		ConfigUtils.configureSystemBars(this);
+
 		super.onCreate(savedInstanceState);
 
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
@@ -81,7 +83,7 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 		setContentView(R.layout.activity_unlock_masterkey);
 
 		TextView infoText = findViewById(R.id.unlock_info);
-		TypedArray array = getTheme().obtainStyledAttributes(new int[]{android.R.attr.textColorSecondary});
+		TypedArray array = getTheme().obtainStyledAttributes(new int[]{R.attr.colorOnSurface});
 		infoText.getCompoundDrawables()[0].setColorFilter(array.getColor(0, -1), PorterDuff.Mode.SRC_IN);
 		array.recycle();
 
@@ -114,15 +116,6 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 		unlockButton.setEnabled(false);
 	}
 
-	@Override
-	protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) {
-		if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-			theme.applyStyle(R.style.Theme_Threema_WithToolbar_Dark, true);
-		} else {
-			super.onApplyThemeResource(theme, resid, first);
-		}
-	}
-
 	@Override
 	protected void onResume() {
 		super.onResume();
@@ -201,14 +194,6 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 						RuntimeUtil.runOnUiThread(() -> {
 							ThreemaApplication.reset();
 
-							new Thread(() -> {
-								// Trigger a connection now, as there was no identity before the master key was unlocked
-								final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-								if (serviceManager != null) {
-									final LifetimeService lifetimeService = serviceManager.getLifetimeService();
-									lifetimeService.ensureConnection();
-								}
-							}).start();
 
 							// Cancel all notifications...if any
 							NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
@@ -217,6 +202,22 @@ public class UnlockMasterKeyActivity extends ThreemaActivity {
 							// Show persistent notification
 							PassphraseService.start(UnlockMasterKeyActivity.this.getApplicationContext());
 
+							// ServiceManager (and thus LifetimeService) are now available
+							// Trigger a connection
+							new Thread(() -> {
+								final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+								if (serviceManager != null) {
+									final LifetimeService lifetimeService = serviceManager.getLifetimeService();
+									if (lifetimeService != null) {
+										if (ThreemaApplication.isResumed) {
+											lifetimeService.acquireConnection(ThreemaApplication.ACTIVITY_CONNECTION_TAG);
+										} else {
+											lifetimeService.ensureConnection();
+										}
+									}
+								}
+							}).start();
+
 							// Start ThreemaPush service (which could not be started without an unlocked passphrase)
 							ThreemaPushService.tryStart(logger, getApplicationContext());
 

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

@@ -35,7 +35,7 @@ import static ch.threema.app.activities.WhatsNewActivity.EXTRA_NO_ANIMATION;
 public class WhatsNew2Activity extends ThreemaAppCompatActivity {
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 

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

@@ -27,7 +27,6 @@ import android.view.View;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -38,15 +37,16 @@ public class WhatsNewActivity extends ThreemaAppCompatActivity {
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 
 		setContentView(R.layout.activity_whatsnew);
 
-		// TODO(ANDR-2065): Replace with correct placeholders `getString(R.string.app_name)` instead of "Threema"
-		((TextView) findViewById(R.id.whatsnew_title)).setText(getString(R.string.whatsnew_title, "Threema"));
-		((TextView) findViewById(R.id.whatsnew_body)).setText(Html.fromHtml(getString(R.string.whatsnew_headline, "Threema")));
+		String appName = getString(R.string.app_name);
+
+		((TextView) findViewById(R.id.whatsnew_title)).setText(getString(R.string.whatsnew_title, appName));
+		((TextView) findViewById(R.id.whatsnew_body)).setText(Html.fromHtml(getString(R.string.whatsnew_headline, appName)));
 
 		findViewById(R.id.next_text).setOnClickListener(v -> {
 /*			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));

+ 29 - 21
app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java

@@ -30,11 +30,14 @@ import android.widget.AbsListView;
 import android.widget.AdapterView;
 import android.widget.ListView;
 
+import androidx.appcompat.app.ActionBar;
+
+import com.google.android.material.appbar.AppBarLayout;
+
 import org.slf4j.Logger;
 
 import java.util.List;
 
-import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.activities.ThreemaToolbarActivity;
 import ch.threema.app.adapters.ballot.BallotOverviewListAdapter;
@@ -61,14 +64,11 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 	private String myIdentity;
 
 	private BallotOverviewListAdapter listAdapter = null;
-	private List<BallotModel> ballots;
 	private ListView listView;
 
-	private BallotListener ballotListener = new BallotListener() {
+	private final BallotListener ballotListener = new BallotListener() {
 		@Override
-		public void onClosed(BallotModel ballotModel) {
-
-		}
+		public void onClosed(BallotModel ballotModel) {}
 
 		@Override
 		public void onModified(BallotModel ballotModel) {
@@ -78,13 +78,11 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 		@Override
 		public void onCreated(BallotModel ballotModel) {
 			RuntimeUtil.runOnUiThread(() -> updateList());
-
 		}
 
 		@Override
 		public void onRemoved(BallotModel ballotModel) {
 			RuntimeUtil.runOnUiThread(() -> updateList());
-
 		}
 
 		@Override
@@ -109,6 +107,18 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 		emptyView.setup(R.string.ballot_no_ballots_yet);
 		((ViewGroup) listView.getParent()).addView(emptyView);
 		listView.setEmptyView(emptyView);
+		final AppBarLayout appBarLayout = findViewById(R.id.appbar);
+		appBarLayout.setLiftable(true);
+		listView.setOnScrollListener(new AbsListView.OnScrollListener() {
+			@Override
+			public void onScrollStateChanged(AbsListView view, int scrollState) {}
+
+			@Override
+			public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+				boolean isAtTop = firstVisibleItem == 0 && (view.getChildCount() == 0 || view.getChildAt(0).getTop() == 0);
+				appBarLayout.setLifted(!isAtTop);
+			}
+		});
 
 		ActionBar actionBar = getSupportActionBar();
 		if (actionBar != null) {
@@ -154,9 +164,9 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 		}
 
 		try {
-			this.ballots = this.ballotService.getBallots(new BallotService.BallotFilter() {
+			List<BallotModel> ballots = this.ballotService.getBallots(new BallotService.BallotFilter() {
 				@Override
-				public MessageReceiver getReceiver() {
+				public MessageReceiver<?> getReceiver() {
 					return null;
 				}
 
@@ -171,9 +181,9 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 				}
 			});
 
-			if (this.ballots != null) {
+			if (ballots != null) {
 				this.listAdapter = new BallotOverviewListAdapter(this,
-						this.ballots,
+					ballots,
 						this.ballotService,
 						this.contactService);
 
@@ -192,17 +202,15 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
 			return;
 		}
 
-		if(listAdapter != null) {
-			BallotModel b = listAdapter.getItem(position);
+		BallotModel b = listAdapter.getItem(position);
 
-			if(b != null) {
-				Intent resultIntent = this.getIntent();
-				//append ballot
-				IntentDataUtil.append(b, this.getIntent());
+		if(b != null) {
+			Intent resultIntent = this.getIntent();
+			//append ballot
+			IntentDataUtil.append(b, this.getIntent());
 
-				setResult(RESULT_OK, resultIntent);
-				finish();
-			}
+			setResult(RESULT_OK, resultIntent);
+			finish();
 		}
 	}
 

+ 24 - 12
app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java

@@ -32,13 +32,16 @@ import android.widget.AbsListView;
 import android.widget.AdapterView;
 import android.widget.ListView;
 
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.view.ActionMode;
+
+import com.google.android.material.appbar.AppBarLayout;
+
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
 
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.view.ActionMode;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaToolbarActivity;
@@ -78,7 +81,7 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 	private GroupService groupService;
 	private String myIdentity;
 
-	private MessageReceiver messageReceiver;
+	private MessageReceiver<?> messageReceiver;
 	private BallotOverviewListAdapter listAdapter = null;
 	private List<BallotModel> ballots;
 	private ListView listView;
@@ -169,6 +172,19 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 		((ViewGroup) listView.getParent()).addView(emptyView);
 		listView.setEmptyView(emptyView);
 
+		final AppBarLayout appBarLayout = findViewById(R.id.appbar);
+		appBarLayout.setLiftable(true);
+		listView.setOnScrollListener(new AbsListView.OnScrollListener() {
+			@Override
+			public void onScrollStateChanged(AbsListView view, int scrollState) {}
+
+			@Override
+			public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+				boolean isAtTop = firstVisibleItem == 0 && (view.getChildCount() == 0 || view.getChildAt(0).getTop() == 0);
+				appBarLayout.setLifted(!isAtTop);
+			}
+		});
+
 		Intent receivedIntent = getIntent();
 
 		this.messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(this, receivedIntent);
@@ -465,9 +481,6 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 		@Override
 		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
 			mode.getMenuInflater().inflate(R.menu.action_ballot_overview, menu);
-
-			ConfigUtils.themeMenu(menu, ConfigUtils.getColorFromAttribute(BallotOverviewActivity.this, R.attr.colorAccent));
-
 			return true;
 		}
 
@@ -492,13 +505,12 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 				return false;
 			}
 
-			switch (item.getItemId()) {
-				case R.id.menu_ballot_remove:
-					removeSelectedBallots();
-					return true;
-				default:
-					return false;
+			if (item.getItemId() == R.id.menu_ballot_remove) {
+				removeSelectedBallots();
+				return true;
 			}
+
+			return false;
 		}
 
 		@Override

+ 15 - 20
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java

@@ -26,21 +26,22 @@ import android.content.Intent;
 import android.content.res.Configuration;
 import android.os.Bundle;
 import android.view.View;
-import android.widget.Button;
-import android.widget.ImageView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+
+import com.google.android.material.button.MaterialButton;
+
 import org.slf4j.Logger;
 
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentStatePagerAdapter;
-import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaActivity;
@@ -75,9 +76,8 @@ public class BallotWizardActivity extends ThreemaActivity {
 	private GroupService groupService;
 	private String identity;
 	private StepPagerStrip stepPagerStrip;
-	private ImageView nextButton, copyButton, prevButton;
-	private Button nextText;
-	private MessageReceiver receiver;
+	private MaterialButton nextButton, copyButton, prevButton;
+	private MessageReceiver<?> receiver;
 
 	private final List<BallotChoiceModel> ballotChoiceModelList = new ArrayList<>();
 	private String ballotTitle;
@@ -96,7 +96,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
-		ConfigUtils.configureActivityTheme(this);
+		ConfigUtils.configureSystemBars(this);
 
 		super.onCreate(savedInstanceState);
 
@@ -119,9 +119,6 @@ public class BallotWizardActivity extends ThreemaActivity {
 		nextButton = findViewById(R.id.next_page_button);
 		nextButton.setOnClickListener(v -> nextPage());
 
-		nextText = findViewById(R.id.next_text);
-		nextText.setOnClickListener(v -> nextPage());
-
 		pager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
 			@Override
 			public void onPageScrolled(int i, float v, int i2) {}
@@ -136,17 +133,15 @@ public class BallotWizardActivity extends ThreemaActivity {
 				}
 				if (position == 1) {
 					if (checkTitle()) {
-						nextButton.setVisibility(View.GONE);
 						prevButton.setVisibility(View.VISIBLE);
-						nextText.setVisibility(View.VISIBLE);
+						nextButton.setText(R.string.finish);
 						copyButton.setVisibility(View.GONE);
 					} else {
 						position = 0;
 					}
 				} else {
-					nextButton.setVisibility(View.VISIBLE);
 					prevButton.setVisibility(View.GONE);
-					nextText.setVisibility(View.GONE);
+					nextButton.setText(R.string.next);
 					copyButton.setVisibility(View.VISIBLE);
 				}
 				stepPagerStrip.setCurrentPage(position);
@@ -175,7 +170,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 	 * @param fragment
 	 */
 	@Override
-	public void onAttachFragment(Fragment fragment) {
+	public void onAttachFragment(@NonNull Fragment fragment) {
 		super.onAttachFragment(fragment);
 
 		if(fragment instanceof BallotWizardFragment) {
@@ -272,7 +267,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 		return this.ballotAssessment;
 	}
 
-	private class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
+	private static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
 		public ScreenSlidePagerAdapter(FragmentManager fm) {
 			super(fm);
 		}

+ 33 - 14
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -22,11 +22,12 @@
 package ch.threema.app.activities.wizard;
 
 import static ch.threema.app.ThreemaApplication.PHONE_LINKED_PLACEHOLDER;
-import static ch.threema.app.ThreemaApplication.WORKER_WORK_SYNC;
 
+import android.Manifest;
 import android.accounts.Account;
 import android.annotation.SuppressLint;
 import android.content.Context;
+import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.os.AsyncTask;
 import android.os.Bundle;
@@ -35,8 +36,6 @@ import android.text.TextUtils;
 import android.util.Patterns;
 import android.view.View;
 import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.TextView;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
@@ -45,11 +44,10 @@ import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.viewpager.widget.ViewPager;
-import androidx.work.ExistingWorkPolicy;
 import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkInfo;
 import androidx.work.WorkManager;
 
+import com.google.android.material.button.MaterialButton;
 import com.google.i18n.phonenumbers.NumberParseException;
 import com.google.i18n.phonenumbers.PhoneNumberUtil;
 import com.google.i18n.phonenumbers.Phonenumber;
@@ -111,12 +109,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardBaseActivity");
 
+	public static final String EXTRA_NEW_IDENTITY_CREATED = "newIdentity";
 	private static final String DIALOG_TAG_USE_ID_AS_NICKNAME = "nd";
 	private static final String DIALOG_TAG_INVALID_ENTRY = "ie";
 	private static final String DIALOG_TAG_USE_ANONYMOUSLY = "ano";
 	private static final String DIALOG_TAG_THREEMA_SAFE = "sd";
 	private static final String DIALOG_TAG_PASSWORD_BAD = "pwb";
 	private static final String DIALOG_TAG_SYNC_CONTACTS_ENABLE = "scen";
+	private static final String DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE = "scmer";
 
 	private static final int PERMISSION_REQUEST_READ_CONTACTS = 2;
 	private static final int NUM_PAGES = 5;
@@ -128,9 +128,8 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 
 	private static int lastPage = 0;
 	private ParallaxViewPager viewPager;
-	private ImageView prevButton, nextButton;
+	private MaterialButton prevButton, nextButton;
 	private Button finishButton;
-	private TextView nextText;
 	private StepPagerStrip stepPagerStrip;
 	private String nickname, email, number, prefix, presetMobile, presetEmail, safePassword;
 	private ThreemaSafeServerInfo safeServerInfo = new ThreemaSafeServerInfo();
@@ -141,7 +140,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	private LocaleService localeService;
 	private PreferenceService preferenceService;
 	private ThreemaSafeService threemaSafeService;
-	private boolean errorRaised = false;
+	private boolean errorRaised = false, isNewIdentity = false;
 	private WizardFragment4 fragment4;
 
 	private final Handler finishHandler = new Handler();
@@ -224,10 +223,12 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 				threemaSafeService = serviceManager.getThreemaSafeService();
 			}
 		} catch (Exception e) {
+			logger.error("Exception", e);
 			finish();
 			return;
 		}
 		if (userService == null || localeService == null || preferenceService == null) {
+			logger.error("Required services not available.");
 			finish();
 			return;
 		}
@@ -251,9 +252,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 			}
 		});
 
-		nextText = findViewById(R.id.next_text);
-		nextText.setOnClickListener(this);
-
 		stepPagerStrip = findViewById(R.id.strip);
 		stepPagerStrip.setPageCount(NUM_PAGES);
 		stepPagerStrip.setCurrentPage(WizardFragment0.PAGE_ID);
@@ -262,6 +260,11 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 		viewPager.addLayer(findViewById(R.id.layer0));
 		viewPager.addLayer(findViewById(R.id.layer1));
 
+		Intent intent = getIntent();
+		if (intent != null) {
+			isNewIdentity = intent.getBooleanExtra(EXTRA_NEW_IDENTITY_CREATED, false);
+		}
+
 		if (ConfigUtils.isWorkBuild()) {
 			performWorkSync();
 		} else {
@@ -413,7 +416,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	public void onPageSelected(int position) {
 		prevButton.setVisibility(position == WizardFragment0.PAGE_ID ? View.GONE : View.VISIBLE);
 		nextButton.setVisibility(position == NUM_PAGES - 1 ? View.GONE : View.VISIBLE);
-		nextText.setVisibility(View.GONE);
 
 		stepPagerStrip.setCurrentPage(position);
 
@@ -511,7 +513,15 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 
 		if (this.userCannotChangeContactSync) {
 			if (this.isSyncContacts) {
-				requestContactSyncPermission();
+				if (ConfigUtils.isPermissionGranted(this, Manifest.permission.READ_CONTACTS)) {
+					// Permission already granted, therefore continue by linking the phone
+					linkPhone();
+				} else {
+					// If permission is not yet granted, show a dialog to inform that contact sync
+					// has been force enabled by the administrator
+					WizardDialog wizardDialog = WizardDialog.newInstance(R.string.contact_sync_mdm_rationale, R.string.ok);
+					wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE);
+				}
 			} else {
 				linkPhone();
 			}
@@ -645,6 +655,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 		return this.skipWizard;
 	}
 
+	/**
+	 * Return wether the identity was just created
+	 * @return true if it's a new identity, false if the identity was restored
+	 */
+	public boolean isNewIdentity() {
+		return isNewIdentity;
+	}
+
 	@Override
 	public void onYes(String tag, Object data) {
 		switch (tag) {
@@ -658,6 +676,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 			case DIALOG_TAG_THREEMA_SAFE:
 				break;
 			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
+			case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
 				requestContactSyncPermission();
 				break;
 		}
@@ -979,7 +998,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 						threemaSafeService.storeMasterKey(masterkey);
 						preferenceService.setThreemaSafeServerInfo(safeServerInfo);
 						threemaSafeService.setEnabled(true);
-						threemaSafeService.uploadNow(WizardBaseActivity.this, true);
+						threemaSafeService.uploadNow(true);
 					} else {
 						Toast.makeText(WizardBaseActivity.this, R.string.safe_error_preparing, Toast.LENGTH_LONG).show();
 					}

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

@@ -129,7 +129,10 @@ public class WizardFingerPrintActivity extends WizardBackgroundActivity implemen
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_CREATE_ID, true);
 
 				if (TestUtil.empty(errorString)) {
-					startActivity(new Intent(WizardFingerPrintActivity.this, WizardBaseActivity.class));
+					Intent intent = new Intent(WizardFingerPrintActivity.this, WizardBaseActivity.class);
+					intent.putExtra(WizardBaseActivity.EXTRA_NEW_IDENTITY_CREATED, true);
+					startActivity(intent);
+
 					overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 					finish();
 				} else {

+ 4 - 9
app/src/main/java/ch/threema/app/activities/wizard/WizardIntroActivity.java

@@ -39,6 +39,7 @@ import android.widget.TextView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.PrivacyPolicyActivity;
+import ch.threema.app.activities.SimpleWebViewActivity;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -101,8 +102,9 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 			builder.setSpan(new ClickableSpan() {
 				@Override
 				public void onClick(View widget) {
-					ConfigUtils.setAppTheme(ConfigUtils.THEME_DARK);
-					startActivityForResult(new Intent(WizardIntroActivity.this, PrivacyPolicyActivity.class), ACTIVITY_RESULT_PRIVACY_POLICY);
+					Intent intent = new Intent(WizardIntroActivity.this, PrivacyPolicyActivity.class);
+					intent.putExtra(SimpleWebViewActivity.FORCE_DARK_THEME, true);
+					startActivityForResult(intent, ACTIVITY_RESULT_PRIVACY_POLICY);
 				}
 			}, index, index + privacyPolicy.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
 			privacyPolicyExplainText.setText(builder);
@@ -155,13 +157,6 @@ public class WizardIntroActivity extends WizardBackgroundActivity {
 		}
 	}
 
-	@Override
-	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-		super.onActivityResult(requestCode, resultCode, data);
-
-		ConfigUtils.resetAppTheme();
-	}
-
 	/**
 	 * Checks whether th_contact_sync conflicts with user restriction DISALLOW_MODIFY_ACCOUNTS.
 	 * If it conflicts, it shows an information dialog.

+ 2 - 5
app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java

@@ -288,17 +288,14 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	}
 
 	private void onSuccessfulRestore() {
-		SimpleStringAlertDialog.newInstance(R.string.restore_success_body,
-			Build.VERSION.SDK_INT <= Build.VERSION_CODES.P ?
-				R.string.android_backup_restart_threema :
-				R.string.safe_backup_tap_to_restart,
+		SimpleStringAlertDialog.newInstance(R.string.restore_success_body, R.string.android_backup_restart_threema,
 			true).show(getSupportFragmentManager(), "d");
 		try {
 			serviceManager.startConnection();
 		} catch (ThreemaException e) {
 			logger.error("Exception", e);
 		}
-		ConfigUtils.scheduleAppRestart(getApplicationContext(), 3000, getApplicationContext().getString(R.string.ipv6_restart_now));
+		ConfigUtils.scheduleAppRestart(getApplicationContext(), 3000, null);
 	}
 
 	@Override

+ 6 - 5
app/src/main/java/ch/threema/app/adapters/BottomSheetListAdapter.java

@@ -30,10 +30,11 @@ import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
 import android.widget.TextView;
 
-import java.util.List;
-
 import androidx.annotation.NonNull;
 import androidx.appcompat.widget.AppCompatImageView;
+
+import java.util.List;
+
 import ch.threema.app.R;
 import ch.threema.app.ui.BottomSheetItem;
 import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
@@ -57,7 +58,7 @@ public class BottomSheetListAdapter extends ArrayAdapter<BottomSheetItem> {
 		this.selectedItem = selectedItem;
 		this.layoutInflater = LayoutInflater.from(context);
 
-		TypedArray typedArray = context.getTheme().obtainStyledAttributes(new int[]{R.attr.textColorSecondary});
+		TypedArray typedArray = context.getTheme().obtainStyledAttributes(new int[]{R.attr.colorOnSurface});
 		this.regularColor = typedArray.getColor(0, 0);
 		typedArray.recycle();
 	}
@@ -92,8 +93,8 @@ public class BottomSheetListAdapter extends ArrayAdapter<BottomSheetItem> {
 		holder.textView.setText(item.getTitle());
 
 		if (position == selectedItem) {
-			holder.textView.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
-			holder.imageView.setColorFilter(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent), PorterDuff.Mode.SRC_IN);
+			holder.textView.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorPrimary));
+			holder.imageView.setColorFilter(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorPrimary), PorterDuff.Mode.SRC_IN);
 		} else {
 			holder.textView.setTextColor(regularColor);
 			holder.imageView.setColorFilter(regularColor);

+ 102 - 36
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -21,9 +21,8 @@
 
 package ch.threema.app.adapters;
 
-import android.annotation.SuppressLint;
+import android.animation.LayoutTransition;
 import android.content.Context;
-import android.graphics.drawable.Drawable;
 import android.text.TextUtils;
 import android.util.SparseIntArray;
 import android.view.LayoutInflater;
@@ -40,7 +39,12 @@ import androidx.annotation.LayoutRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
+import androidx.appcompat.content.res.AppCompatResources;
 import androidx.fragment.app.Fragment;
+import androidx.media3.session.MediaController;
+
+import com.google.android.material.shape.ShapeAppearanceModel;
+import com.google.common.util.concurrent.ListenableFuture;
 
 import org.slf4j.Logger;
 
@@ -94,8 +98,11 @@ import ch.threema.storage.models.DateSeparatorMessageModel;
 import ch.threema.storage.models.FirstUnreadMessageModel;
 import ch.threema.storage.models.MessageType;
 
+import static ch.threema.domain.protocol.csp.messages.file.FileData.RENDERING_DEFAULT;
+
 public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ComposeMessageAdapter");
+	public static final int MIN_CONSTRAINT_LENGTH = 2;
 
 	private final List<AbstractMessageModel> values;
 	private final ChatAdapterDecorator.Helper decoratorHelper;
@@ -115,7 +122,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 
 	private int firstUnreadPos = -1, unreadMessagesCount;
 	private final Context context;
-
+	private final ShapeAppearanceModel shapeAppearanceModelReceiveTop, shapeAppearanceModelReceiveMiddle, shapeAppearanceModelReceiveBottom, shapeAppearanceModelSendTop, shapeAppearanceModelSendMiddle, shapeAppearanceModelSendBottom, shapeAppearanceModelSingle;
 	private final LayoutInflater layoutInflater;
 
 	@Retention(RetentionPolicy.SOURCE)
@@ -187,7 +194,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		void longClick(View view, int position, AbstractMessageModel messageModel);
 		boolean touch(View view, MotionEvent motionEvent, AbstractMessageModel messageModel);
 		void avatarClick(View view, int position, AbstractMessageModel messageModel);
-		void onSearchResultsUpdate(int searchResultsIndex, int searchResultsSize);
+		void onSearchResultsUpdate(int searchResultsIndex, int searchResultsSize, int queryLength);
 		void onSearchInProgress(boolean inProgress);
 	}
 
@@ -208,20 +215,15 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			ThumbnailCache<?> thumbnailCache,
 			int thumbnailWidth,
 			Fragment fragment,
-			int unreadMessagesCount) {
+			int unreadMessagesCount,
+			ListenableFuture<MediaController> mediaControllerFuture) {
 		super(context, R.layout.conversation_list_item_send, values);
 
 		this.context = context;
 		this.values = values;
 		this.listView = listView;
 		this.layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-		int regularColor;
-		if (ConfigUtils.getAppTheme(context) != ConfigUtils.THEME_LIGHT) {
-			regularColor = context.getResources().getColor(R.color.dark_text_color_secondary);
-		} else {
-			regularColor = context.getResources().getColor(R.color.text_color_secondary);
-		}
-		Drawable stopwatchIcon = ConfigUtils.getThemedDrawable(getContext(), R.drawable.ic_av_timer_grey600_18dp);
+		int regularColor = ConfigUtils.getColorFromAttribute(context, R.attr.colorOnSurface);
 		int maxBubbleTextLength = context.getResources().getInteger(R.integer.max_bubble_text_length);
 		int maxQuoteTextLength = context.getResources().getInteger(R.integer.max_quote_text_length);
 		this.resultMapIndex = 0;
@@ -245,14 +247,55 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				thumbnailWidth,
 				fragment,
 				regularColor,
-				stopwatchIcon,
 				maxBubbleTextLength,
-				maxQuoteTextLength
+				maxQuoteTextLength,
+				mediaControllerFuture
 		);
 
+		int cornerRadius = context.getResources().getDimensionPixelSize(R.dimen.chat_bubble_border_radius),
+			cornerRadiusSharp = context.getResources().getDimensionPixelSize(R.dimen.chat_bubble_border_radius_sharp);
 		this.bubblePaddingLeftRight = getContext().getResources().getDimensionPixelSize(R.dimen.chat_bubble_container_padding_left_right);
 		this.bubblePaddingBottom = getContext().getResources().getDimensionPixelSize(R.dimen.chat_bubble_container_padding_bottom);
 		this.bubblePaddingBottomGrouped = getContext().getResources().getDimensionPixelSize(R.dimen.chat_bubble_container_padding_bottom_grouped);
+		this.shapeAppearanceModelReceiveTop = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadius)
+			.setTopRightCornerSize(cornerRadius)
+			.setBottomLeftCornerSize(cornerRadiusSharp)
+			.setBottomRightCornerSize(cornerRadius)
+			.build();
+		this.shapeAppearanceModelReceiveMiddle = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadiusSharp)
+			.setTopRightCornerSize(cornerRadius)
+			.setBottomLeftCornerSize(cornerRadiusSharp)
+			.setBottomRightCornerSize(cornerRadius)
+			.build();
+		this.shapeAppearanceModelReceiveBottom = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadiusSharp)
+			.setTopRightCornerSize(cornerRadius)
+			.setBottomLeftCornerSize(cornerRadius)
+			.setBottomRightCornerSize(cornerRadius)
+			.build();
+		this.shapeAppearanceModelSendTop = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadius)
+			.setTopRightCornerSize(cornerRadius)
+			.setBottomLeftCornerSize(cornerRadius)
+			.setBottomRightCornerSize(cornerRadiusSharp)
+			.build();
+		this.shapeAppearanceModelSendMiddle = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadius)
+			.setTopRightCornerSize(cornerRadiusSharp)
+			.setBottomLeftCornerSize(cornerRadius)
+			.setBottomRightCornerSize(cornerRadiusSharp)
+			.build();
+		this.shapeAppearanceModelSendBottom = new ShapeAppearanceModel.Builder()
+			.setTopLeftCornerSize(cornerRadius)
+			.setTopRightCornerSize(cornerRadiusSharp)
+			.setBottomLeftCornerSize(cornerRadius)
+			.setBottomRightCornerSize(cornerRadius)
+			.build();
+		this.shapeAppearanceModelSingle = new ShapeAppearanceModel.Builder()
+			.setAllCornerSizes(cornerRadius)
+			.build();
 	}
 
 	/**
@@ -430,7 +473,6 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	}
 
 	@NonNull
-	@SuppressLint("WrongViewCast")
 	@Override
 	public View getView(final int position, View convertView, ViewGroup parent) {
 		View itemView = convertView;
@@ -455,6 +497,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				holder.messageBlockView = itemView.findViewById(R.id.message_block);
 				holder.footerView = itemView.findViewById(R.id.indicator_container);
 				holder.dateView = itemView.findViewById(R.id.date_view);
+				holder.datePrefixIcon = itemView.findViewById(R.id.date_prefix_icon);
 
 				if (isUserMessage(itemType)) {
 					holder.senderView = itemView.findViewById(R.id.group_sender_view);
@@ -481,6 +524,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.groupAckThumbsUpImage = itemView.findViewById(R.id.groupack_thumbsup);
 					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
+
+					((ViewGroup) holder.groupAckContainer).getLayoutTransition().enableTransitionType(LayoutTransition.DISAPPEARING|LayoutTransition.APPEARING);
 				}
 				itemView.setTag(holder);
 			}
@@ -497,18 +542,16 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			if (isUserMessage(itemType)) {
 				itemView.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT, 0));
 				if (messageModel.isOutbox()) {
-					holder.messageBlockView.setBackgroundResource(R.drawable.bubble_send_selector);
+					holder.messageBlockView.setCardBackgroundColor(AppCompatResources.getColorStateList(context, R.color.bubble_send_colorstatelist));
 				} else {
-					holder.messageBlockView.setBackgroundResource(R.drawable.bubble_recv_selector);
+					holder.messageBlockView.setCardBackgroundColor(AppCompatResources.getColorStateList(context, R.color.bubble_receive_colorstatelist));
 				}
-
 			} else {
 				itemView.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, 0));
 			}
 		}
 		holder.position = position;
 
-		final boolean showAvatar = adjustMarginsForMessageGrouping(holder, itemView, itemType);
 		final ChatAdapterDecorator decorator;
 
 		if (itemType == TYPE_FIRST_UNREAD) {
@@ -516,6 +559,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			decorator = new FirstUnreadChatAdapterDecorator(this.context, messageModel, this.decoratorHelper, unreadMessagesCount);
 		}
 		else {
+			final boolean showAvatar = adjustMarginsForMessageGrouping(holder, itemView, itemType, messageModel);
+
 			switch (messageType) {
 				case STATUS:
 					decorator = new StatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
@@ -571,13 +616,14 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					}
 			}
 
-			if(groupId > 0) {
+			if (groupId > 0) {
 				decorator.setGroupMessage(groupId, this.identityColors);
 				decorator.setGroupedMessage(showAvatar);
 			}
 
-			if(this.onClickListener != null) {
-				final View v = itemView;
+			if (this.onClickListener != null) {
+				final View v = holder.messageBlockView;
+
 				decorator.setOnClickElement(messageModel12 -> onClickListener.click(v, position, messageModel12));
 
 				decorator.setOnLongClickElement(messageModel13 -> onClickListener.longClick(v, position, messageModel13));
@@ -617,13 +663,11 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	 * @param itemType The Type the item is representing
 	 * @return true if it's the first item in a group, false if it's a consecutive iitem
 	 */
-	private boolean adjustMarginsForMessageGrouping(ComposeMessageHolder holder, View itemView, @ItemType int itemType) {
-		boolean isFirstItemInGroup = true;
+	private boolean adjustMarginsForMessageGrouping(ComposeMessageHolder holder, View itemView, @ItemType int itemType, @NonNull AbstractMessageModel currentItem) {
+		boolean isFirstItemInGroup = true, hasPreviousItem = false, hasNextItem = false;
 
 		if (itemView != null) {
 			int paddingBottom = bubblePaddingBottom;
-			AbstractMessageModel currentItem = values.get(holder.position);
-
 			if (isUserMessage(itemType)) {
 				if (values.size() > holder.position + 1) {
 					AbstractMessageModel nextItem = values.get(holder.position + 1);
@@ -631,25 +675,25 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					if (isUserMessage(getItemType(nextItem))) {
 						if (isConsecutiveItem(currentItem, nextItem)) {
 							paddingBottom = bubblePaddingBottomGrouped;
+							hasNextItem = true;
 						}
 					}
 				}
+
 				if (holder.position > 0) {
 					AbstractMessageModel previousItem = values.get(holder.position - 1);
 
 					if (isUserMessage(getItemType(previousItem))) {
 						if (isConsecutiveItem(currentItem, previousItem)) {
-							if (currentItem.isOutbox()) {
-								holder.messageBlockView.setBackgroundResource(R.drawable.bubble_send_selector_no_arrow);
-							} else {
-								holder.messageBlockView.setBackgroundResource(R.drawable.bubble_recv_selector_no_arrow);
-							}
 							isFirstItemInGroup = false;
+							hasPreviousItem = true;
 						}
 					}
 				}
 			}
 
+			holder.messageBlockView.setShapeAppearanceModel(getShapeAppearanceForBubble(currentItem.isOutbox(), hasPreviousItem, hasNextItem));
+
 			if (itemView.getPaddingBottom() != paddingBottom) {
 				itemView.setPadding(bubblePaddingLeftRight, 0, bubblePaddingLeftRight, paddingBottom);
 			}
@@ -657,6 +701,28 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		return isFirstItemInGroup;
 	}
 
+	/**
+	 * Return the ShapeAppearanceModel that fits the combination of parameters
+	 * @param isOutbox true if the user is the sender of the message(s), false if he is the receiver
+	 * @param hasPreviousItem true if there is a consecutive message previous to this
+	 * @param hasNextItem true if there is a consecutive message after this
+	 * @return a ShapeAppearanceModel that fits the situation
+	 */
+	private ShapeAppearanceModel getShapeAppearanceForBubble(boolean isOutbox, boolean hasPreviousItem, boolean hasNextItem) {
+		if (hasPreviousItem) {
+			if (hasNextItem) {
+				return isOutbox ? shapeAppearanceModelSendMiddle : shapeAppearanceModelReceiveMiddle;
+			}
+			return isOutbox ? shapeAppearanceModelSendBottom : shapeAppearanceModelReceiveBottom;
+		}
+
+		if (hasNextItem) {
+			return isOutbox ? shapeAppearanceModelSendTop : shapeAppearanceModelReceiveTop;
+		}
+
+		return shapeAppearanceModelSingle;
+	}
+
 	/**
 	 * Detect if the provided two messageModels are from the same sender
 	 * @param firstModel AbstractMessageModel of first item
@@ -711,7 +777,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			resultMapIndex = 0;
 			searchUpdate();
 
-			if (constraint == null || constraint.length() < 2) {
+			if (constraint == null || constraint.length() < MIN_CONSTRAINT_LENGTH) {
 				// no filtering
 				filterString = null;
 			} else {
@@ -776,7 +842,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 								if (quoteType != QuoteUtil.QUOTE_TYPE_NONE) {
 									QuoteUtil.QuoteContent quoteContent = QuoteUtil.getQuoteContent(
 										messageModel,
-										decoratorHelper.getMessageReceiver().getType(),
+										decoratorHelper.getMessageReceiver(),
 										false,
 										decoratorHelper.getThumbnailCache(),
 										getContext(),
@@ -799,8 +865,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 						} else if (messageModel.getType() == MessageType.FILE) {
 							String searchString = "";
 
-							if (!MimeUtil.isImageFile(messageModel.getFileData().getMimeType()) && !TestUtil.empty(messageModel.getFileData().getFileName())) {
-								// do not index filename for images and GIFs - as it's not visible in the UI
+							if (messageModel.getFileData().getRenderingType() == RENDERING_DEFAULT && !TestUtil.empty(messageModel.getFileData().getFileName())) {
+								// do not index filename for RENDERING_MEDIA or RENDERING_STICKER as it's not visible in the UI
 								searchString += messageModel.getFileData().getFileName();
 							}
 
@@ -929,7 +995,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	private void searchUpdate() {
 		int size = resultMap.size();
 
-		onClickListener.onSearchResultsUpdate(size > 0 ? resultMapIndex + 1 : 0, resultMap.size());
+		onClickListener.onSearchResultsUpdate(size > 0 ? resultMapIndex + 1 : 0, resultMap.size(), currentConstraint.length());
 	}
 
 	public void resetMatchPosition() {

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

@@ -30,11 +30,8 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
-import android.widget.CheckBox;
-import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
-import android.widget.RelativeLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
@@ -43,23 +40,19 @@ import androidx.annotation.StringRes;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.recyclerview.widget.RecyclerView;
 
-import com.google.android.material.checkbox.MaterialCheckBox;
-import com.google.android.material.chip.Chip;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.materialswitch.MaterialSwitch;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 
 import org.slf4j.Logger;
 
-import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.ExecutionException;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.PublicKeyDialog;
-import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.routines.UpdateFeatureLevelRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
@@ -68,13 +61,10 @@ import ch.threema.app.services.UserService;
 import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.protocol.ThreemaFeature;
-import ch.threema.domain.protocol.api.APIConnector;
+import ch.threema.protobuf.csp.e2e.fs.Terminate;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
-import java8.util.concurrent.CompletableFuture;
 
 public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactDetailAdapter");
@@ -87,7 +77,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private GroupService groupService;
 	private UserService userService;
 	private PreferenceService preferenceService;
-	private APIConnector apiConnector;
 	private IdListService excludeFromSyncListService;
 	private IdListService blackListIdentityService;
 	private final ContactModel contactModel;
@@ -111,16 +100,15 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	public class HeaderHolder extends RecyclerView.ViewHolder {
 		private final VerificationLevelImageView verificationLevelImageView;
 		private final TextView threemaIdView;
-		private final CheckBox synchronize;
+		private final MaterialSwitch synchronize;
 		private final View nicknameContainer, synchronizeContainer;
 		private final ImageView syncSourceIcon;
 		private final TextView publicNickNameView;
 		private final LinearLayout groupMembershipTitle;
 		private final MaterialAutoCompleteTextView readReceiptsSpinner, typingIndicatorsSpinner;
-		private final MaterialCheckBox forwardSecurityCheckbox;
-		private final RelativeLayout forwardSecurityContainer;
-		private final Chip clearForwardSecurityButton;
-		private final ImageButton forwardSecurityInfo;
+		private final View clearForwardSecuritySection;
+		private final MaterialButton clearForwardSecurityButton;
+		private int onThreemaIDClickCount = 0;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -136,10 +124,8 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			this.syncSourceIcon = itemView.findViewById(R.id.sync_source_icon);
 			this.readReceiptsSpinner = itemView.findViewById(R.id.read_receipts_spinner);
 			this.typingIndicatorsSpinner = itemView.findViewById(R.id.typing_indicators_spinner);
-			this.forwardSecurityCheckbox = itemView.findViewById(R.id.forward_security_enabled);
+			this.clearForwardSecuritySection = itemView.findViewById(R.id.clear_forward_security_section);
 			this.clearForwardSecurityButton = itemView.findViewById(R.id.clear_forward_security);
-			this.forwardSecurityContainer = itemView.findViewById(R.id.forward_security_container);
-			this.forwardSecurityInfo = itemView.findViewById(R.id.forward_security_info);
 
 			verificationLevelIconView.setOnClickListener(v -> {
 				if (onClickListener != null) {
@@ -147,15 +133,33 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				}
 			});
 
-
-			itemView.findViewById(R.id.threema_id).setOnLongClickListener(ignored -> {
+			threemaIdView.setOnLongClickListener(ignored -> {
 				String identity = contactModel.getIdentity();
 				copyTextToClipboard(identity, R.string.contact_details_id_copied);
 				return true;
 			});
 
+			// When clicking ten times on the Threema ID, the clear forward security session button
+			// becomes visible.
+			threemaIdView.setOnClickListener(v -> {
+				onThreemaIDClickCount++;
+				if (onThreemaIDClickCount >= 10) {
+					onThreemaIDClickCount = 0;
+					clearForwardSecuritySection.setVisibility(View.VISIBLE);
+					clearForwardSecurityButton.setOnClickListener(clearButton -> {
+						try {
+							ThreemaApplication.requireServiceManager()
+								.getForwardSecurityMessageProcessor()
+								.clearAndTerminateAllSessions(contactModel, Terminate.Cause.RESET);
+							Toast.makeText(clearButton.getContext(), R.string.forward_security_cleared, Toast.LENGTH_LONG).show();
+						} catch (Exception e) {
+							Toast.makeText(clearButton.getContext(), e.getMessage(), Toast.LENGTH_LONG).show();
+						}
+					});
+				}
+			});
 
-			itemView.findViewById(R.id.public_nickname).setOnLongClickListener(ignored -> {
+			publicNickNameView.setOnLongClickListener(ignored -> {
 				String nickname = contactModel.getPublicNickName();
 				copyTextToClipboard(nickname, R.string.contact_details_nickname_copied);
 				return true;
@@ -182,16 +186,15 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		this.context = context;
 		this.values = values;
 		this.contactModel = contactModel;
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
 		try {
+			ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
 			this.contactService = serviceManager.getContactService();
 			this.groupService = serviceManager.getGroupService();
 			this.userService = serviceManager.getUserService();
 			this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.preferenceService = serviceManager.getPreferenceService();
-			this.apiConnector = serviceManager.getAPIConnector();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}
@@ -257,19 +260,28 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			headerHolder.verificationLevelImageView.setContactModel(contactModel);
 			headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
 
-			if (preferenceService.isSyncContacts() && contactModel.getAndroidContactLookupKey() != null &&
-				ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS)) {
+			boolean isSyncExcluded = excludeFromSyncListService.has(contactModel.getIdentity());
+
+			if (preferenceService.isSyncContacts()
+				&& (contactModel.getAndroidContactLookupKey() != null || isSyncExcluded)
+				&& ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS)
+			) {
 				headerHolder.synchronizeContainer.setVisibility(View.VISIBLE);
 
-				Drawable icon = AndroidContactUtil.getInstance().getAccountIcon(contactModel);
+				Drawable icon = null;
+				try {
+					icon = AndroidContactUtil.getInstance().getAccountIcon(contactModel);
+				} catch (SecurityException e) {
+					logger.error("Could not access android account icon", e);
+				}
 				if (icon != null) {
 					headerHolder.syncSourceIcon.setImageDrawable(icon);
 					headerHolder.syncSourceIcon.setVisibility(View.VISIBLE);
 				} else {
-					headerHolder.syncSourceIcon.setVisibility(View.INVISIBLE);
+					headerHolder.syncSourceIcon.setVisibility(View.GONE);
 				}
 
-				headerHolder.synchronize.setChecked(excludeFromSyncListService.has(contactModel.getIdentity()));
+				headerHolder.synchronize.setChecked(isSyncExcluded);
 				headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
 					if (isChecked) {
 						excludeFromSyncListService.add(contactModel.getIdentity());
@@ -317,48 +329,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				contactModel.setTypingIndicators(position12);
 				contactService.save(contactModel);
 			});
-
-			if (ConfigUtils.isForwardSecurityEnabled()) {
-				headerHolder.forwardSecurityInfo.setOnClickListener(v -> SimpleStringAlertDialog.newInstance(R.string.forward_security_mode, R.string.forward_security_explanation).show(((AppCompatActivity) context).getSupportFragmentManager(), "fsinfo"));
-
-				if (ThreemaFeature.canForwardSecurity(contactModel.getFeatureMask())) {
-					headerHolder.forwardSecurityCheckbox.setEnabled(true);
-				} else {
-					try {
-						UpdateFeatureLevelRoutine.removeTimeCache(contactModel);
-						CompletableFuture
-							.runAsync(new UpdateFeatureLevelRoutine(
-								contactService,
-								apiConnector,
-								Collections.singletonList(this.contactModel)
-							))
-							.thenRun(() -> RuntimeUtil.runOnUiThread(() -> {
-								headerHolder.forwardSecurityCheckbox.setEnabled(ThreemaFeature.canForwardSecurity(contactModel.getFeatureMask()));
-							}))
-							.get();
-					} catch (InterruptedException | ExecutionException e) {
-						logger.warn("Unable to fetch feature mask");
-					}
-				}
-				headerHolder.forwardSecurityCheckbox.setChecked(contactModel.isForwardSecurityEnabled());
-				headerHolder.forwardSecurityCheckbox.setOnCheckedChangeListener((compoundButton, b) -> {
-					contactModel.setForwardSecurityEnabled(b);
-					contactService.save(contactModel);
-				});
-				headerHolder.forwardSecurityContainer.setVisibility(View.VISIBLE);
-
-				if (ConfigUtils.isTestBuild()) {
-					headerHolder.clearForwardSecurityButton.setOnClickListener(view -> {
-						try {
-							ThreemaApplication.getServiceManager().getDHSessionStore().deleteAllDHSessions(userService.getIdentity(), contactModel.getIdentity());
-							Toast.makeText(this.context, R.string.forward_security_cleared, Toast.LENGTH_LONG).show();
-						} catch (Exception e) {
-							Toast.makeText(this.context, e.getMessage(), Toast.LENGTH_LONG).show();
-						}
-					});
-					headerHolder.clearForwardSecurityButton.setVisibility(View.VISIBLE);
-				}
-			}
 		}
 	}
 

+ 5 - 3
app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java

@@ -48,6 +48,7 @@ import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
 
 import ch.threema.app.R;
@@ -63,7 +64,6 @@ import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ContactUtil;
-import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
@@ -543,14 +543,16 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		HashSet<ContactModel> contacts = new HashSet<>();
 		ContactModel contactModel;
 
-		for (int position: checkedItems) {
+		Iterator<Integer> iterator = checkedItems.iterator();
+		while (iterator.hasNext()) {
+			int position = iterator.next();
 			try {
 				contactModel = ovalues.get(position);
 				if (contactModel != null) {
 					contacts.add(contactModel);
 				}
 			} catch (IndexOutOfBoundsException e) {
-				checkedItems.remove(position);
+				iterator.remove();
 			}
 		}
 		return contacts;

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

@@ -43,6 +43,7 @@ import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.CheckableConstraintLayout;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.DistributionListModel;
 
 public class DistributionListAdapter extends FilterableListAdapter {
@@ -51,14 +52,16 @@ public class DistributionListAdapter extends FilterableListAdapter {
 	private List<DistributionListModel> ovalues;
 	private DistributionListFilter groupListFilter;
 	private final DistributionListService distributionListService;
+	private final FilterResultsListener filterResultsListener;
 
-	public DistributionListAdapter(Context context, List<DistributionListModel> values, List<Integer> checkedItems, DistributionListService distributionListService) {
+	public DistributionListAdapter(Context context, List<DistributionListModel> values, List<Integer> checkedItems, DistributionListService distributionListService, FilterResultsListener filterResultsListener) {
 		super(context, R.layout.item_distribution_list, (List<Object>) (Object) values);
 
 		this.context = context;
 		this.values = values;
 		this.ovalues = values;
 		this.distributionListService = distributionListService;
+		this.filterResultsListener = filterResultsListener;
 
 		if (checkedItems != null && checkedItems.size() > 0) {
 			// restore checked items
@@ -161,6 +164,9 @@ public class DistributionListAdapter extends FilterableListAdapter {
 		@Override
 		protected void publishResults(CharSequence constraint, FilterResults results) {
 			values = (List<DistributionListModel>) results.values;
+			if (filterResultsListener != null) {
+				filterResultsListener.onResultsAvailable(TestUtil.empty(constraint) ? 0 : results.count);
+			}
 			notifyDataSetChanged();
 		}
 

+ 28 - 0
app/src/main/java/ch/threema/app/adapters/FilterResultsListener.java

@@ -0,0 +1,28 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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;
+
+import androidx.annotation.MainThread;
+
+public interface FilterResultsListener {
+	@MainThread void onResultsAvailable(int count);
+}

+ 37 - 52
app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java

@@ -21,6 +21,10 @@
 
 package ch.threema.app.adapters;
 
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.EXPANDED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
+
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.text.Layout;
@@ -28,19 +32,21 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ImageView;
-import android.widget.LinearLayout;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.AppCompatImageButton;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.chip.Chip;
+import com.google.android.material.materialswitch.MaterialSwitch;
+
 import org.slf4j.Logger;
 
 import java.io.IOException;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.appcompat.widget.AppCompatImageButton;
-import androidx.appcompat.widget.SwitchCompat;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -64,10 +70,6 @@ import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 import java8.util.Optional;
 
-import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
-import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.EXPANDED;
-import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
-
 public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	public enum GroupDescState {NONE, COLLAPSED, EXPANDED}
 	private static final Logger logger = LoggingUtil.getThreemaLogger("GroupDetailAdapter");
@@ -75,7 +77,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 	private static final int TYPE_HEADER = 0;
 	private static final int TYPE_ITEM = 1;
 
-	private boolean isGroupAdmin = false;
+	private boolean meIsGroupAdmin = false;
 
 	private final Context context;
 	private ContactService contactService;
@@ -92,6 +94,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		public final View view;
 		public final TextView nameView, idView;
 		public final AvatarView avatarView;
+		public final Chip adminChip;
 
 		public ItemHolder(View view) {
 			super(view);
@@ -99,19 +102,16 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			this.nameView = itemView.findViewById(R.id.group_name);
 			this.avatarView = itemView.findViewById(R.id.avatar_view);
 			this.idView = itemView.findViewById(R.id.threemaid);
+			this.adminChip = itemView.findViewById(R.id.admin_chip);
 		}
 	}
 
 	public class HeaderHolder extends RecyclerView.ViewHolder {
 		private final SectionHeaderView groupMembersTitleView;
-		private final LinearLayout groupOwnerContainerView;
-		private final AvatarView ownerAvatarView;
-		private final TextView ownerName;
-		private final TextView ownerThreemaId;
-		private final SectionHeaderView ownerNameTitle;
+		private final View addMembersView;
 		private final ConstraintLayout linkContainerView;
 		private final SectionHeaderView groupLinkTitle;
-		private final SwitchCompat linkEnableSwitch;
+		private final MaterialSwitch linkEnableSwitch;
 		private final TextView linkString;
 		private final AppCompatImageButton linkResetButton;
 		private final AppCompatImageButton linkShareButton;
@@ -126,11 +126,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 			// items in object
 			this.groupMembersTitleView = itemView.findViewById(R.id.group_members_title);
-			this.groupOwnerContainerView = itemView.findViewById(R.id.group_owner_container);
-			this.ownerAvatarView = itemView.findViewById(R.id.avatar_view);
-			this.ownerName = itemView.findViewById(R.id.group_name);
-			this.ownerThreemaId = itemView.findViewById(R.id.threemaid);
-			this.ownerNameTitle = itemView.findViewById(R.id.group_owner_title);
+			this.addMembersView = itemView.findViewById(R.id.add_member);
 			this.linkContainerView = itemView.findViewById(R.id.group_link_container);
 			this.groupLinkTitle = itemView.findViewById(R.id.group_link_header);
 			this.linkEnableSwitch = itemView.findViewById(R.id.group_link_switch);
@@ -218,25 +214,19 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			AdapterUtil.styleContact(itemHolder.nameView, contactModel);
 			itemHolder.avatarView.setImageBitmap(avatar);
 			itemHolder.avatarView.setBadgeVisible(contactService.showBadge(contactModel));
-			itemHolder.view.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					onClickListener.onGroupMemberClick(v, contactModel);
-				}
-			});
+			itemHolder.view.setOnClickListener(v -> onClickListener.onGroupMemberClick(v, contactModel));
+
+			boolean isAdmin = contactModel.getIdentity().equals(groupModel.getCreatorIdentity());
+			itemHolder.adminChip.setVisibility(isAdmin ? View.VISIBLE: View.GONE);
+			itemHolder.idView.setVisibility(isAdmin ? View.GONE : View.VISIBLE);
 		} else {
 			this.headerHolder = (HeaderHolder) holder;
-			headerHolder.groupOwnerContainerView.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					onClickListener.onGroupOwnerClick(v, groupModel.getCreatorIdentity());
-				}
-			});
+			headerHolder.addMembersView.setOnClickListener(v -> onClickListener.onAddMembersClick(v));
 
 			ContactModel ownerContactModel = contactService.getByIdentity(groupModel.getCreatorIdentity());
 
 			// check if the ID is the owner of the group
-			isGroupAdmin = groupModel.getCreatorIdentity().equals(contactService.getMe().getIdentity());
+			meIsGroupAdmin = groupModel.getCreatorIdentity().equals(contactService.getMe().getIdentity());
 
 			if (ConfigUtils.supportGroupDescription()) {
 				initGroupDescriptionSection();
@@ -245,12 +235,6 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			}
 
 			if (ownerContactModel != null) {
-				Bitmap bitmap = contactService.getAvatar(ownerContactModel, false);
-
-				headerHolder.ownerAvatarView.setImageBitmap(bitmap);
-				headerHolder.ownerThreemaId.setText(ownerContactModel.getIdentity());
-				headerHolder.ownerName.setText(NameUtil.getDisplayNameOrNickname(ownerContactModel, true));
-
 				if (!ConfigUtils.supportsGroupLinks() || ownerContactModel != contactService.getMe()) {
 					headerHolder.linkContainerView.setVisibility(View.GONE);
 				}
@@ -258,19 +242,19 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 					initGroupLinkSection();
 				}
 			} else {
-				// creator is no longer around / has been revoked
-				headerHolder.ownerAvatarView.setImageBitmap(contactService.getDefaultAvatar(null, false, false));
-				headerHolder.ownerThreemaId.setText(groupModel.getCreatorIdentity());
-				headerHolder.ownerName.setText(R.string.invalid_threema_id);
+				headerHolder.linkContainerView.setVisibility(View.GONE);
 			}
-			headerHolder.ownerNameTitle.setText(context.getString(R.string.add_group_owner) +
-					" (" + LocaleUtil.formatTimeStampString(context, groupModel.getCreatedAt().getTime(), false) +
-					")");
+
+			boolean addMembersViewVisibility = meIsGroupAdmin;
 
 			if (contactModels != null) {
-				headerHolder.groupMembersTitleView.setText(context.getString(R.string.add_group_members_list) +
-					" (" + contactModels.size() + "/" + BuildConfig.MAX_GROUP_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;
+				}
 			}
+
+			headerHolder.addMembersView.setVisibility(addMembersViewVisibility ? View.VISIBLE : View.GONE);
 		}
 	}
 
@@ -458,7 +442,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescText.setVisibility(View.VISIBLE);
 		headerHolder.groupDescText.setText(groupDetailViewModel.getGroupDesc());
 		LinkifyUtil.getInstance().linkifyText(headerHolder.groupDescText, true);
-		if (isGroupAdmin) {
+		if (meIsGroupAdmin) {
 			headerHolder.changeGroupDescButton.setVisibility(View.VISIBLE);
 		} else {
 			headerHolder.changeGroupDescButton.setVisibility(View.GONE);
@@ -475,7 +459,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
 		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
 		headerHolder.expandButton.setText(R.string.add_group_description);
-		if (isGroupAdmin) {
+		if (meIsGroupAdmin) {
 			headerHolder.expandButton.setVisibility(View.VISIBLE);
 		} else {
 			headerHolder.expandButton.setVisibility(View.GONE);
@@ -499,5 +483,6 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		void onResetLinkClick();
 		void onShareLinkClick();
 		void onGroupDescriptionEditClick();
+		void onAddMembersClick(View v);
 	}
 }

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

@@ -42,6 +42,7 @@ import ch.threema.app.ui.CheckableConstraintLayout;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.storage.models.GroupModel;
 
@@ -51,14 +52,17 @@ public class GroupListAdapter extends FilterableListAdapter {
 	private List<GroupModel> ovalues;
 	private GroupListFilter groupListFilter;
 	private final GroupService groupService;
+	private final FilterResultsListener filterResultsListener;
 
-	public GroupListAdapter(Context context, List<GroupModel> values, List<Integer> checkedItems, GroupService groupService) {
+
+	public GroupListAdapter(Context context, List<GroupModel> values, List<Integer> checkedItems, GroupService groupService, FilterResultsListener filterResultsListener) {
 		super(context, R.layout.item_group_list, (List<Object>) (Object) values);
 
 		this.context = context;
 		this.values = values;
 		this.ovalues = values;
 		this.groupService = groupService;
+		this.filterResultsListener = filterResultsListener;
 
 		if (checkedItems != null && checkedItems.size() > 0) {
 			// restore checked items
@@ -168,6 +172,9 @@ public class GroupListAdapter extends FilterableListAdapter {
 		@Override
 		protected void publishResults(CharSequence constraint, FilterResults results) {
 			values = (List<GroupModel>) results.values;
+			if (filterResultsListener != null) {
+				filterResultsListener.onResultsAvailable(TestUtil.empty(constraint) ? 0 : results.count);
+			}
 			notifyDataSetChanged();
 		}
 

+ 0 - 297
app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java

@@ -1,297 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-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;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.PorterDuff;
-import android.os.AsyncTask;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.ImageView;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import org.slf4j.Logger;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.RejectedExecutionException;
-
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import ch.threema.app.R;
-import ch.threema.app.cache.ThumbnailCache;
-import ch.threema.app.services.FileService;
-import ch.threema.app.ui.ControllerView;
-import ch.threema.app.ui.SquareImageView;
-import ch.threema.app.ui.listitemholder.AbstractListItemHolder;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.StringConversionUtil;
-import ch.threema.base.utils.LoggingUtil;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.MessageType;
-import ch.threema.storage.models.data.MessageContentsType;
-
-import static ch.threema.storage.models.data.MessageContentsType.AUDIO;
-import static ch.threema.storage.models.data.MessageContentsType.FILE;
-import static ch.threema.storage.models.data.MessageContentsType.GIF;
-import static ch.threema.storage.models.data.MessageContentsType.IMAGE;
-import static ch.threema.storage.models.data.MessageContentsType.VIDEO;
-import static ch.threema.storage.models.data.MessageContentsType.VOICE_MESSAGE;
-
-public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("MediaGalleryAdapter");
-
-	private final List<AbstractMessageModel> values;
-	private final FileService fileService;
-	private final ThumbnailCache thumbnailCache;
-	private final LayoutInflater layoutInflater;
-	private final List<Integer> brokenThumbnails = new ArrayList<Integer>();
-	@ColorInt private final int foregroundColor;
-
-	public static final int TYPE_NONE = 0;
-	public static final int TYPE_IMAGE = 1;
-	public static final int TYPE_VIDEO = 2;
-	public static final int TYPE_AUDIO = 3;
-	public static final int TYPE_FILE = 4;
-	public static final int TYPE_MAX_COUNT = TYPE_FILE + 1;
-
-	public MediaGalleryAdapter(
-			Context context,
-			List<AbstractMessageModel> values,
-			FileService fileService,
-			ThumbnailCache thumbnailCache) {
-		super(context, R.layout.item_media_gallery, values);
-
-		this.values = values;
-		this.fileService = fileService;
-		this.thumbnailCache = thumbnailCache;
-		this.layoutInflater = LayoutInflater.from(context);
-		this.foregroundColor = ConfigUtils.getColorFromAttribute(context, R.attr.textColorSecondary);
-	}
-
-	private static class MediaGalleryHolder extends AbstractListItemHolder {
-		public ImageView imageView;
-		public ControllerView playButton;
-		public ProgressBar progressBar;
-		public TextView topTextView;
-		public View textContainerView;
-		public int messageId;
-	}
-
-	@Override
-	public int getItemViewType(int position) {
-		final AbstractMessageModel m = this.getItem(position);
-		return this.getType(m);
-	}
-
-	private int getType(AbstractMessageModel m) {
-		if (m != null) {
-			if (!m.isStatusMessage()) {
-				switch (m.getMessageContentsType()) {
-					case IMAGE:
-						return TYPE_IMAGE;
-					case GIF:
-					case VIDEO:
-						return TYPE_VIDEO;
-					case AUDIO:
-					case VOICE_MESSAGE:
-						return TYPE_AUDIO;
-					case FILE:
-						return TYPE_FILE;
-				}
-			}
-		}
-		return TYPE_NONE;
-	}
-
-	@Override
-	public int getViewTypeCount() {
-		return TYPE_MAX_COUNT;
-	}
-
-	private Bitmap getBitmap(AbstractMessageModel messageModel) {
-		Bitmap thumbnail;
-
-		try {
-			thumbnail = fileService.getMessageThumbnailBitmap(messageModel, thumbnailCache);
-		} catch (Exception e) {
-			logger.error("Exception", e);
-			thumbnail = null;
-		}
-
-		return thumbnail;
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	private void loadThumbnailBitmap(final int position, MediaGalleryHolder holder, final AbstractMessageModel messageModel) {
-		//do nothing!
-		holder.imageView.setImageBitmap(null);
-		synchronized (brokenThumbnails) {
-			if(this.brokenThumbnails.contains(messageModel.getId())) {
-				return;
-			}
-		}
-
-		//load new one by async task
-		try {
-			new AsyncTask<MediaGalleryHolder, Void, Bitmap>() {
-				private MediaGalleryHolder holder;
-
-				@Override
-				protected Bitmap doInBackground(MediaGalleryHolder... params) {
-					this.holder = params[0];
-
-
-					if (position != holder.position) {
-						cancel(true);
-						return null;
-					}
-					return MediaGalleryAdapter.this.getBitmap(messageModel);
-				}
-
-				@Override
-				protected void onPostExecute(Bitmap thumbnail) {
-					if (position == holder.position) {
-						if (holder.imageView != null) {
-							boolean broken = false;
-
-							if (thumbnail != null && !thumbnail.isRecycled()) {
-								holder.textContainerView.setVisibility(View.GONE);
-								holder.imageView.setImageBitmap(thumbnail);
-								holder.imageView.clearColorFilter();
-								holder.imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
-							} else {
-								if (messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
-									holder.imageView.setScaleType(ImageView.ScaleType.CENTER);
-									holder.imageView.setImageResource(R.drawable.ic_keyboard_voice_outline);
-									holder.imageView.setColorFilter(foregroundColor, PorterDuff.Mode.SRC_IN);
-									holder.topTextView.setText(StringConversionUtil.secondsToString(
-										messageModel.getType() == MessageType.FILE ?
-											messageModel.getFileData().getDurationSeconds():
-											messageModel.getAudioData().getDuration(), false));
-									holder.textContainerView.setVisibility(View.VISIBLE);
-								} else if (messageModel.getType() == MessageType.FILE) {
-									// try default avatar for mime type
-									thumbnail = fileService.getDefaultMessageThumbnailBitmap(getContext(), messageModel, null, messageModel.getFileData().getMimeType(), false);
-									holder.topTextView.setText(messageModel.getFileData().getFileName());
-									holder.textContainerView.setVisibility(View.VISIBLE);
-									if (thumbnail != null) {
-										holder.imageView.setScaleType(ImageView.ScaleType.CENTER);
-										holder.imageView.setImageBitmap(thumbnail);
-										holder.imageView.setColorFilter(foregroundColor, PorterDuff.Mode.SRC_IN);
-									} else {
-										broken = true;
-									}
-								} else {
-									holder.textContainerView.setVisibility(View.GONE);
-									broken = true;
-								}
-							}
-							updateBrokenThumbnailFlags(messageModel.getId(), broken);
-						}
-					}
-				}
-			}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, holder);
-		}
-
-		catch (RejectedExecutionException x) {
-			//thread pool is full load by non thread
-			Bitmap thumbnail = this.getBitmap(messageModel);
-
-			if (thumbnail != null && !thumbnail.isRecycled()) {
-				holder.imageView.setImageBitmap(thumbnail);
-			}
-		}
-	}
-
-	private void updateBrokenThumbnailFlags(Integer id, boolean broken) {
-		synchronized (brokenThumbnails) {
-			if(broken) {
-				this.brokenThumbnails.add(id);
-			}
-			else {
-				this.brokenThumbnails.remove(id);
-			}
-		}
-	}
-
-	@NonNull
-	@Override
-	public View getView(final int position, View convertView, ViewGroup parent) {
-		View itemView = convertView;
-		MediaGalleryHolder holder;
-
-		if (convertView == null) {
-			holder = new MediaGalleryHolder();
-
-			// This a new view we inflate the new layout
-			itemView = layoutInflater.inflate(R.layout.item_media_gallery, parent, false);
-
-			SquareImageView imageView = itemView.findViewById(R.id.image_view);
-			ControllerView playButton = itemView.findViewById(R.id.play_button);
-			ProgressBar progressBar = itemView.findViewById(R.id.progress_decoding);
-			TextView topTextView = itemView.findViewById(R.id.text_filename);
-			View textContainerView = itemView.findViewById(R.id.filename_container);
-
-			holder.imageView = imageView;
-			holder.playButton = playButton;
-			holder.progressBar = progressBar;
-			holder.topTextView = topTextView;
-			holder.textContainerView = textContainerView;
-			holder.messageId = 0;
-
-			itemView.setTag(holder);
-		} else {
-			holder = (MediaGalleryHolder) itemView.getTag();
-		}
-
-		final AbstractMessageModel messageModel = values.get(position);
-
-		holder.position = position;
-
-		if (holder.messageId != messageModel.getId()) {
-			// do not load contents again if it's unchanged
-			this.loadThumbnailBitmap(position, holder, messageModel);
-			if (this.brokenThumbnails.contains(messageModel.getId())) {
-				holder.playButton.setBroken();
-				holder.playButton.setVisibility(View.VISIBLE);
-			} else {
-				if (this.getType(messageModel) == TYPE_VIDEO || (this.getType(messageModel) == TYPE_FILE && FileUtil.isVideoFile(messageModel.getFileData()))) {
-					holder.playButton.setPlay();
-					holder.playButton.setVisibility(View.VISIBLE);
-				} else {
-					holder.playButton.setHidden();
-				}
-			}
-			holder.progressBar.setVisibility(View.GONE);
-		}
-		holder.messageId = messageModel.getId();
-
-		return itemView;
-	}
-}

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

@@ -0,0 +1,340 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Outline
+import android.graphics.PorterDuff
+import android.util.SparseBooleanArray
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewOutlineProvider
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.core.util.forEach
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.messagereceiver.MessageReceiver
+import ch.threema.app.services.FileService
+import ch.threema.app.ui.CheckableFrameLayout
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.IconUtil
+import ch.threema.app.utils.StringConversionUtil
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.MessageType
+import ch.threema.storage.models.data.MessageContentsType
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.load.resource.bitmap.BitmapTransitionOptions
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.google.android.material.imageview.ShapeableImageView
+
+class MediaGalleryAdapter(
+    private val context: Context,
+    clickListener: OnClickItemListener,
+    messageReceiver: MessageReceiver<*>,
+    columnCount: Int
+) :
+    RecyclerView.Adapter<MediaGalleryAdapter.MediaGalleryHolder>() {
+    private val clickListener: OnClickItemListener
+    private val columnCount: Int
+    private val messageReceiver: MessageReceiver<*>
+    private val checkedItems = SparseBooleanArray()
+    private var messageModels : MutableList<AbstractMessageModel>? = null
+    private val inflater: LayoutInflater = LayoutInflater.from(context)
+
+    @ColorInt
+    private val foregroundColor: Int
+    private val fileService: FileService?
+
+    private val viewOutlineProvider: ViewOutlineProvider
+
+    init {
+        this.clickListener = clickListener
+        this.columnCount = columnCount
+        this.messageReceiver = messageReceiver;
+        this.foregroundColor = ConfigUtils.getColorFromAttribute(context, R.attr.colorOnBackground);
+        this.fileService = ThreemaApplication.getServiceManager()?.fileService
+
+        val cornerRadius: Int = context.resources.getDimensionPixelSize(R.dimen.media_gallery_container_radius)
+        this.viewOutlineProvider = object : ViewOutlineProvider() {
+            override fun getOutline(view: View, outline: Outline) {
+                outline.setRoundRect(0, 0, view.width, view.height, cornerRadius.toFloat())
+            }
+        }
+    }
+
+    class MediaGalleryHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        var imageView: ShapeableImageView? = null
+        var videoContainerView: View? = null
+        var gifContainerView: View? = null
+        var videoDuration: TextView? = null
+        var vmContainerView: View? = null
+        var vmDuration: TextView? = null
+        var topTextView: TextView? = null
+        var textContainerView: View? = null
+        var messageId = 0
+
+        init {
+            imageView = itemView.findViewById(R.id.image_view)
+            gifContainerView = itemView.findViewById(R.id.gif_marker_container)
+            videoContainerView = itemView.findViewById(R.id.video_marker_container)
+            videoDuration = itemView.findViewById(R.id.video_duration_text)
+            vmContainerView = itemView.findViewById(R.id.voicemessage_marker_container)
+            vmDuration = itemView.findViewById(R.id.voicemessage_duration_text)
+            topTextView = itemView.findViewById(R.id.text_filename)
+            textContainerView = itemView.findViewById(R.id.filename_container)
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaGalleryHolder {
+        val itemView: View = inflater.inflate(R.layout.item_media_gallery, parent, false)
+        val holder = MediaGalleryHolder(itemView)
+        holder.vmContainerView?.outlineProvider = viewOutlineProvider
+        holder.videoContainerView?.outlineProvider = viewOutlineProvider
+        holder.gifContainerView?.outlineProvider = viewOutlineProvider
+        holder.textContainerView?.outlineProvider = viewOutlineProvider
+        holder.vmContainerView?.clipToOutline = true
+        holder.videoContainerView?.clipToOutline = true
+        holder.gifContainerView?.clipToOutline = true
+        holder.textContainerView?.clipToOutline = true
+
+        return holder
+    }
+
+    override fun onBindViewHolder(holder: MediaGalleryHolder, position: Int) {
+        messageModels?.let {
+            val messageModel: AbstractMessageModel = it[position]
+
+            if (holder.messageId != messageModel.id) {
+                val placeholderIcon : Int = if (messageModel.messageContentsType == MessageContentsType.VOICE_MESSAGE) {
+                    R.drawable.ic_keyboard_voice_outline;
+                } else if (messageModel.type == MessageType.FILE) {
+                    IconUtil.getMimeIcon(messageModel.fileData.mimeType)
+                } else {
+                    IconUtil.getMimeIcon("application/x-error")
+                }
+
+                // do not load contents again if it's unchanged
+                Glide.with(context)
+                    .asBitmap()
+                    .load(messageModel)
+                    .transition(BitmapTransitionOptions.withCrossFade())
+                    .centerCrop()
+                    .error(placeholderIcon)
+                    .addListener(object : RequestListener<Bitmap?> {
+                        override fun onLoadFailed(
+                            e: GlideException?,
+                            model: Any?,
+                            target: Target<Bitmap?>?,
+                            isFirstResource: Boolean
+                        ): Boolean {
+                            decorateItem(holder, messageModel)
+                            return false
+                        }
+
+                        override fun onResourceReady(
+                            resource: Bitmap?,
+                            model: Any?,
+                            target: Target<Bitmap?>?,
+                            dataSource: DataSource?,
+                            isFirstResource: Boolean
+                        ): Boolean {
+                            holder.textContainerView?.visibility = View.GONE
+                            holder.vmContainerView?.visibility = View.GONE
+                            holder.imageView?.clearColorFilter()
+                            holder.imageView?.scaleType = ImageView.ScaleType.CENTER_CROP
+
+                            if (messageModel.messageContentsType == MessageContentsType.GIF) {
+                                holder.gifContainerView?.visibility = View.VISIBLE
+                            } else {
+                                holder.gifContainerView?.visibility = View.GONE
+                            }
+
+                            if (messageModel.messageContentsType == MessageContentsType.VIDEO) {
+                                val duration: Long = if (messageModel.type == MessageType.VIDEO) {
+                                    messageModel.videoData.duration.toLong()
+                                } else if (messageModel.type == MessageType.FILE) {
+                                    messageModel.fileData.durationSeconds
+                                } else {
+                                    0
+                                }
+
+                                if (duration > 0) {
+                                    holder.videoDuration?.text = StringConversionUtil.secondsToString(duration, false)
+                                    holder.videoDuration?.visibility = View.VISIBLE
+                                } else {
+                                    holder.videoDuration?.visibility = View.GONE
+                                }
+                                holder.videoContainerView?.visibility = View.VISIBLE
+                            } else {
+                                holder.videoContainerView?.visibility = View.GONE
+                            }
+
+                            return false
+                        }
+
+                    })
+                    .into(holder.imageView!!)
+            }
+            holder.messageId = messageModel.id
+            (holder.itemView as CheckableFrameLayout).isChecked = checkedItems.get(position)
+
+            holder.itemView.setOnClickListener { v: View? -> clickListener.onClick(messageModel, holder.itemView, holder.absoluteAdapterPosition) }
+            holder.itemView.setOnLongClickListener { clickListener.onLongClick(messageModel, holder.itemView, holder.absoluteAdapterPosition) }
+        }
+    }
+
+    private fun decorateItem(holder: MediaGalleryHolder, messageModel: AbstractMessageModel) {
+        holder.imageView?.scaleType = ImageView.ScaleType.CENTER
+        holder.imageView?.setColorFilter(foregroundColor, PorterDuff.Mode.SRC_IN)
+        holder.videoContainerView?.visibility = View.GONE
+        holder.gifContainerView?.visibility = View.GONE
+
+        if (messageModel.messageContentsType == MessageContentsType.VOICE_MESSAGE) {
+            val duration: Long = if (messageModel.type == MessageType.FILE) {
+                messageModel.fileData.durationSeconds
+            } else if (messageModel.type == MessageType.VOICEMESSAGE){
+                messageModel.audioData.duration.toLong()
+            } else {
+                0
+            }
+            holder.vmDuration?.text = StringConversionUtil.secondsToString(duration, false)
+            holder.vmContainerView?.visibility = View.VISIBLE
+            holder.textContainerView?.visibility = View.GONE
+        } else if (messageModel.type == MessageType.FILE) {
+            holder.topTextView?.text = messageModel.fileData.fileName
+            holder.textContainerView?.visibility = View.VISIBLE
+            holder.vmContainerView?.visibility = View.GONE
+        } else {
+            holder.textContainerView?.visibility = View.GONE
+            holder.vmContainerView?.visibility = View.GONE
+        }
+    }
+
+    override fun getItemCount(): Int {
+        return messageModels?.size ?: 0
+    }
+
+    fun getItemAtPosition(position: Int): AbstractMessageModel? {
+        return messageModels?.get(position)
+    }
+
+    override fun getItemId(position: Int): Long {
+        return position.toLong()
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun setItems(items: MutableList<AbstractMessageModel>?) {
+        messageModels = items
+        notifyDataSetChanged()
+    }
+
+    fun toggleChecked(pos: Int) {
+        if (checkedItems[pos, false]) {
+            checkedItems.delete(pos)
+        } else {
+            checkedItems.put(pos, true)
+        }
+        notifyItemChanged(pos)
+    }
+
+    fun clearCheckedItems() {
+        var itemsToClear: Array<Int> = arrayOf()
+
+        checkedItems.forEach { position, isChecked ->
+            if (isChecked) {
+                itemsToClear += position
+            }
+        }
+        checkedItems.clear()
+        itemsToClear.forEach { position -> notifyItemChanged(position) }
+    }
+
+    fun selectAll() {
+        messageModels?.let {
+            if (checkedItems.size() == it.size) {
+                clearCheckedItems()
+            } else {
+                for (i in it.indices) {
+                    checkedItems.put(i, true)
+                    notifyItemChanged(i)
+                }
+            }
+        }
+    }
+
+    fun getCheckedItemsCount(): Int {
+        return checkedItems.size()
+    }
+
+    fun getCheckedItems(): List<AbstractMessageModel> {
+        val items: MutableList<AbstractMessageModel> = ArrayList(checkedItems.size())
+        checkedItems.forEach { key, _ ->
+            messageModels?.let {
+                items.add(it[key])
+            }
+        }
+        return items
+    }
+
+    /**
+     * get specified checked item. returns null if out of range or no data available
+     */
+    fun getCheckedItemAt(i: Int): AbstractMessageModel? {
+        if (i >= 0 && i < checkedItems.size()) {
+            messageModels?.let {
+                return it[checkedItems.keyAt(i)];
+            }
+        }
+        return null
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun removeItems(deletedMessages: List<AbstractMessageModel>) {
+        checkedItems.clear()
+
+        if (deletedMessages.size == 1) {
+            messageModels?.let {
+                val deletedMessage = deletedMessages.get(0)
+                val index = it.indexOf(deletedMessage)
+                it.remove(deletedMessage)
+                notifyItemRemoved(index)
+            }
+        } else {
+            messageModels?.removeAll(deletedMessages)
+            notifyDataSetChanged()
+        }
+    }
+
+    interface OnClickItemListener {
+        fun onClick(messageModel: AbstractMessageModel?, view: View?, position: Int)
+        fun onLongClick(messageModel: AbstractMessageModel?, itemView: View?, position: Int): Boolean
+    }
+}

+ 107 - 0
app/src/main/java/ch/threema/app/adapters/MediaGalleryRepository.kt

@@ -0,0 +1,107 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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
+
+import android.annotation.SuppressLint
+import android.os.AsyncTask
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.activities.MediaGalleryActivity
+import ch.threema.app.activities.MediaGalleryActivity.*
+import ch.threema.app.messagereceiver.MessageReceiver
+import ch.threema.app.services.MessageService
+import ch.threema.app.services.MessageService.MessageFilter
+import ch.threema.base.ThreemaException
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.MessageType
+import ch.threema.storage.models.data.MessageContentsType
+
+class MediaGalleryRepository {
+    private var abstractMessageModels: MutableLiveData<List<AbstractMessageModel?>?>? = null
+    private var messageService: MessageService? = null
+    private var messageReceiver: MessageReceiver<*>? = null
+    private var filter: IntArray? = null
+
+    init {
+        val serviceManager = ThreemaApplication.getServiceManager()
+        if (serviceManager != null) {
+            messageService = null
+            try {
+                messageService = serviceManager.messageService
+                abstractMessageModels = object : MutableLiveData<List<AbstractMessageModel?>?>() {
+                    override fun getValue(): List<AbstractMessageModel?>? {
+                        return messageReceiver?.loadMessages(getMessageFilter())
+                    }
+                }
+            } catch (e: ThreemaException) {
+                //
+            }
+        }
+    }
+
+    fun getAbstractMessageModels(): LiveData<List<AbstractMessageModel?>?>? {
+        return abstractMessageModels
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    fun onDataChanged() {
+        object : AsyncTask<String?, Void?, Void?>() {
+            @Deprecated("Deprecated in Java")
+            override fun doInBackground(vararg params: String?): Void? {
+                abstractMessageModels?.postValue(
+                    messageReceiver?.loadMessages(getMessageFilter())
+                )
+                return null
+            }
+        }.execute()
+    }
+
+    private fun getMessageFilter() : MessageFilter {
+        return object : MessageFilter {
+            override fun getPageSize(): Long { return 0 }
+            override fun getPageReferenceId(): Int { return 0 }
+            override fun withStatusMessages(): Boolean { return false }
+            override fun withUnsaved(): Boolean { return true }
+            override fun onlyUnread(): Boolean { return false }
+            override fun onlyDownloaded(): Boolean { return false }
+            override fun types(): Array<MessageType>? { return null }
+            override fun contentTypes(): IntArray { return getContentTypes() }
+        }
+    }
+
+    fun getContentTypes() : IntArray {
+        if (filter == null) {
+            return contentTypes
+        }
+        return filter as IntArray
+    }
+
+    fun setFilter(contentTypes: IntArray?) {
+        filter = contentTypes
+    }
+
+    fun setMessageReceiver(messageReceiver: MessageReceiver<*>) {
+        this.messageReceiver = messageReceiver
+    }
+}
+

+ 0 - 92
app/src/main/java/ch/threema/app/adapters/MediaGallerySpinnerAdapter.java

@@ -1,92 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-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;
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.TextView;
-
-import ch.threema.app.R;
-
-public class MediaGallerySpinnerAdapter extends ArrayAdapter<String> {
-
-	Context context;
-	String[] values;
-	String titleText;
-	LayoutInflater inflater;
-	String subtitle;
-
-	public MediaGallerySpinnerAdapter(Context context, String[] values, String titleText) {
-		super(context, R.layout.spinner_media_gallery, values);
-
-		this.context = context;
-		this.values = values;
-		this.titleText = titleText;
-		this.inflater = (LayoutInflater) context
-				.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-		this.subtitle = "";
-	}
-
-	@Override
-	public View getView(int position, View convertView, ViewGroup parent) {
-		if (convertView == null) {
-			convertView = inflater.inflate(R.layout.spinner_media_gallery, null);
-		}
-		TextView title = convertView.findViewById(R.id.title);
-		TextView subtitleView = convertView.findViewById(R.id.subtitle_text);
-		title.setText(this.titleText);
-		subtitleView.setText(subtitle);
-		return convertView;
-
-	}
-
-	@Override
-	public View getDropDownView(int position, View convertView, ViewGroup parent) {
-		if (convertView == null) {
-			convertView = inflater.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false);
-		}
-		((TextView) convertView).setText(this.values[position]);
-		return convertView;
-	}
-
-	@Override
-	public int getCount() {
-		return this.values.length;
-	}
-
-	@Override
-	public String getItem(int position) {
-		return null;
-	}
-
-	@Override
-	public long getItemId(int position) {
-		return 0;
-	}
-
-	public void setSubtitle(String subtitle) {
-		this.subtitle = subtitle;
-	}
-}

+ 56 - 0
app/src/main/java/ch/threema/app/adapters/MediaGalleryViewModel.kt

@@ -0,0 +1,56 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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
+
+import androidx.annotation.Keep
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import ch.threema.app.messagereceiver.MessageReceiver
+import ch.threema.storage.models.AbstractMessageModel
+
+class MediaGalleryViewModel @Keep constructor(messageReceiver: MessageReceiver<*>) : ViewModel() {
+    private val repository: MediaGalleryRepository = MediaGalleryRepository()
+    private var messageModels: LiveData<List<AbstractMessageModel?>?>? = null
+
+    init {
+        repository.setMessageReceiver(messageReceiver)
+        repository.setFilter(null)
+        messageModels = repository.getAbstractMessageModels()
+    }
+
+    fun getAbstractMessageModels(): LiveData<List<AbstractMessageModel?>?>? {
+        return messageModels
+    }
+
+    fun setFilter(contentTypes: IntArray?) {
+        repository.setFilter(contentTypes)
+        repository.onDataChanged()
+    }
+
+    class MediaGalleryViewModelFactory(val messageReceiver: MessageReceiver<*>) : ViewModelProvider.Factory {
+        override fun <T : ViewModel> create(modelClass: Class<T>): T {
+            return modelClass.getConstructor(MessageReceiver::class.java)
+                .newInstance(messageReceiver)
+        }
+    }
+}

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

@@ -56,7 +56,7 @@ public class MentionSelectorAdapter extends AbstractRecyclerAdapter<ContactModel
 		public ItemHolder(View view) {
 			super(view);
 			this.view = view;
-			this.nameView = itemView.findViewById(R.id.group_name);
+			this.nameView = itemView.findViewById(R.id.contact_name);
 			this.avatarView = itemView.findViewById(R.id.avatar_view);
 			this.idView = itemView.findViewById(R.id.threemaid);
 		}
@@ -73,7 +73,7 @@ public class MentionSelectorAdapter extends AbstractRecyclerAdapter<ContactModel
 	@NonNull
 	@Override
 	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
-		View v = LayoutInflater.from(context).inflate(R.layout.item_group_detail, parent, false);
+		View v = LayoutInflater.from(context).inflate(R.layout.item_mention_selector, parent, false);
 		return new ItemHolder(v);
 	}
 

+ 112 - 525
app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java

@@ -22,61 +22,36 @@
 package ch.threema.app.adapters;
 
 import android.content.Context;
-import android.content.res.ColorStateList;
-import android.graphics.PorterDuff;
-import android.os.SystemClock;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.Chronometer;
-import android.widget.ImageView;
-import android.widget.TextView;
 
-import androidx.annotation.AnyThread;
-import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
 import androidx.recyclerview.widget.RecyclerView;
 
-import com.google.android.material.chip.Chip;
+import com.google.android.material.button.MaterialButton;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.emojis.EmojiMarkupUtil;
-import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
-import ch.threema.app.services.ConversationTagService;
-import ch.threema.app.services.ConversationTagServiceImpl;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.RingtoneService;
-import ch.threema.app.ui.AvatarListItemUtil;
-import ch.threema.app.ui.AvatarView;
-import ch.threema.app.ui.CountBoxView;
-import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.EmptyRecyclerView;
-import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
-import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.MessageUtil;
-import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.StateBitmapUtil;
-import ch.threema.app.utils.TestUtil;
-import ch.threema.app.utils.ViewUtil;
-import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.groupcall.GroupCallManager;
-import ch.threema.app.voip.groupcall.GroupCallObserver;
-import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.GroupModel;
-import ch.threema.storage.models.MessageType;
-import ch.threema.storage.models.TagModel;
 
 public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationModel, RecyclerView.ViewHolder> {
 
@@ -85,149 +60,44 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	public static final int TYPE_ITEM = 0;
 	public static final int TYPE_FOOTER = 1;
 
-	private final Context context;
-	private final GroupService groupService;
-	private final GroupCallManager groupCallManager;
-	private final ConversationTagService conversationTagService;
-	private final ContactService contactService;
-	private final DistributionListService distributionListService;
-	private final DeadlineListService mutedChatsListService, hiddenChatsListService, mentionOnlyChatsListService;
-	private final RingtoneService ringtoneService;
-	private final ConversationService conversationService;
-	private final EmojiMarkupUtil emojiMarkupUtil;
-	private final StateBitmapUtil stateBitmapUtil;
-	private @ColorInt final int regularColor;
-	private @ColorInt final int ackColor;
-	private @ColorInt final int decColor;
-	private @ColorInt final int backgroundColor;
-	private final boolean isTablet;
-	private final LayoutInflater inflater;
-	private final ItemClickListener clickListener;
-	private final List<ConversationModel> selectedChats = new ArrayList<>();
-	private String highlightUid;
-	private RecyclerView recyclerView;
-
-	private final TagModel starTagModel, unreadTagModel;
-
-	public static class MessageListViewHolder extends RecyclerView.ViewHolder implements GroupCallObserver {
-
-		TextView fromView;
-		protected TextView dateView;
-		TextView subjectView;
-		ImageView deliveryView, attachmentView, pinIcon;
-		View listItemFG;
-		View latestMessageContainer;
-		View typingContainer;
-		TextView groupMemberName;
-		CountBoxView unreadCountView;
-		View unreadIndicator;
-		ImageView muteStatus;
-		ImageView hiddenStatus;
-		protected AvatarView avatarView;
-		protected ConversationModel conversationModel;
-		AvatarListItemHolder avatarListItemHolder;
-		final View tagStarOn;
-		final GroupCallManager groupCallManager;
-
-		private final View ongoingGroupCallContainer;
-		private final Chip joinGroupCallButton;
-		private final TextView ongoingCallDivider, ongoingCallText;
-		private final Chronometer groupCallDuration;
-		private boolean isChronometerRunning = false;
-
-		MessageListViewHolder(final View itemView, final GroupCallManager groupCallManager) {
-			super(itemView);
-
-			tagStarOn = itemView.findViewById(R.id.tag_star_on);
-
-			fromView = itemView.findViewById(R.id.from);
-			dateView = itemView.findViewById(R.id.date);
-			subjectView = itemView.findViewById(R.id.subject);
-			unreadCountView = itemView.findViewById(R.id.unread_count);
-			avatarView = itemView.findViewById(R.id.avatar_view);
-			attachmentView = itemView.findViewById(R.id.attachment);
-			deliveryView = itemView.findViewById(R.id.delivery);
-			listItemFG = itemView.findViewById(R.id.list_item_fg);
-			latestMessageContainer = itemView.findViewById(R.id.latest_message_container);
-			typingContainer = itemView.findViewById(R.id.typing_container);
-			groupMemberName = itemView.findViewById(R.id.group_member_name);
-			unreadIndicator = itemView.findViewById(R.id.unread_view);
-			muteStatus = itemView.findViewById(R.id.mute_status);
-			hiddenStatus = itemView.findViewById(R.id.hidden_status);
-			pinIcon = itemView.findViewById(R.id.pin_icon);
-			avatarListItemHolder = new AvatarListItemHolder();
-			avatarListItemHolder.avatarView = avatarView;
-			avatarListItemHolder.avatarLoadingAsyncTask = null;
-			ongoingGroupCallContainer = itemView.findViewById(R.id.ongoing_group_call_container);
-			ongoingCallText = itemView.findViewById(R.id.ongoing_call_text);
-			joinGroupCallButton = itemView.findViewById(R.id.join_group_call_button);
-			ongoingCallDivider = itemView.findViewById(R.id.ongoing_call_divider);
-			groupCallDuration = itemView.findViewById(R.id.group_call_duration);
-
-			this.groupCallManager = groupCallManager;
-		}
-
+	private final @NonNull Context context;
+	private final @NonNull GroupCallManager groupCallManager;
+	private final @NonNull ConversationService conversationService;
+	private final @NonNull ContactService contactService;
+	private final @NonNull GroupService groupService;
+	private final @NonNull DeadlineListService mutedChatsListService;
+	private final @NonNull DeadlineListService mentionOnlyChatsListService;
+	private final @NonNull RingtoneService ringtoneService;
+	private final @NonNull DeadlineListService hiddenChatsListService;
+	private final @NonNull MessageListViewHolder.MessageListItemParams messageListItemParams;
+	private final @NonNull MessageListViewHolder.MessageListItemStrings messageListItemStrings;
+	private final @NonNull LayoutInflater inflater;
+	private final @NonNull ItemClickListener clickListener;
+	private final @NonNull MessageListViewHolder.MessageListViewHolderClickListener clickForwarder = new MessageListViewHolder.MessageListViewHolderClickListener() {
 		@Override
-		public void onGroupCallUpdate(@Nullable GroupCallDescription call) {
-			if (ConfigUtils.isGroupCallsEnabled()) {
-				if (call != null && isMatchingGroup(call.getGroupIdInt()) && isNotPrivate()) {
-					updateGroupCallDuration(call);
-				} else {
-					stopGroupCallDuration();
-				}
-				ListenerManager.conversationListeners.handle(listener -> listener.onModified(conversationModel, null));
-			}
-		}
-
-		@AnyThread
-		private void updateGroupCallDuration(@NonNull GroupCallDescription call) {
-			Long runningSince = call.getRunningSince();
-			if (runningSince == null) {
-				stopGroupCallDuration();
-			} else {
-				startGroupCallDuration(runningSince);
-			}
-		}
-
-		@AnyThread
-		private void startGroupCallDuration(long base) {
-			groupCallDuration.post(() -> {
-				if (base != groupCallDuration.getBase() || !isChronometerRunning) {
-					groupCallDuration.setBase(base);
-					groupCallDuration.start();
-					isChronometerRunning = true;
-				}
-				groupCallDuration.setVisibility(View.VISIBLE);
-				ongoingCallDivider.setVisibility(View.VISIBLE);
-			});
+		public void onItemClick(@NonNull View view, int position) {
+			clickListener.onItemClick(view, position, getEntity(position));
 		}
 
-		@AnyThread
-		private void stopGroupCallDuration() {
-			groupCallDuration.post(() -> {
-				if (isChronometerRunning) {
-					groupCallDuration.stop();
-					isChronometerRunning = false;
-				}
-				groupCallDuration.setVisibility(View.GONE);
-				ongoingCallDivider.setVisibility(View.GONE);
-			});
-		}
-
-		private boolean isMatchingGroup(int groupId) {
-			return conversationModel.isGroupConversation() && conversationModel.getGroup().getId() == groupId;
+		@Override
+		public boolean onItemLongClick(@NonNull View view, int position) {
+			return clickListener.onItemLongClick(view, position, getEntity(position));
 		}
 
-		private boolean isNotPrivate() {
-			return hiddenStatus.getVisibility() != View.VISIBLE;
+		@Override
+		public void onAvatarClick(@NonNull View view, int position) {
+			clickListener.onAvatarClick(view, position, getEntity(position));
 		}
 
-		public View getItem() {
-			return itemView;
+		@Override
+		public void onJoinGroupCallClick(int position) {
+			clickListener.onJoinGroupCallClick(getEntity(position));
 		}
+	};
+	private final List<ConversationModel> selectedChats = new ArrayList<>();
+	private RecyclerView recyclerView;
+	private final Map<ConversationModel, MessageListAdapterItem> messageListAdapterItemsCache;
 
-		public ConversationModel getConversationModel() { return conversationModel; }
-	}
 
 	public static class FooterViewHolder extends RecyclerView.ViewHolder {
 		FooterViewHolder(View itemView) {
@@ -237,55 +107,68 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 	public interface ItemClickListener {
 		void onItemClick(View view, int position, ConversationModel conversationModel);
+
 		boolean onItemLongClick(View view, int position, ConversationModel conversationModel);
+
 		void onAvatarClick(View view, int position, ConversationModel conversationModel);
+
 		void onFooterClick(View view);
+
 		void onJoinGroupCallClick(ConversationModel conversationModel);
 	}
 
 	public MessageListAdapter(
-		Context context,
-		ContactService contactService,
-		GroupService groupService,
-		GroupCallManager groupCallManager,
-		DistributionListService distributionListService,
-		ConversationService conversationService,
-		DeadlineListService mutedChatsListService,
-		DeadlineListService mentionOnlyChatsListService,
-		DeadlineListService hiddenChatsListService,
-		ConversationTagService conversationTagService,
-		RingtoneService ringtoneService,
-		String highlightUid,
-		ItemClickListener clickListener) {
-
+		@NonNull Context context,
+		@NonNull ContactService contactService,
+		@NonNull GroupService groupService,
+		@NonNull DistributionListService distributionListService,
+		@NonNull ConversationService conversationService,
+		@NonNull DeadlineListService mutedChatsListService,
+		@NonNull DeadlineListService mentionOnlyChatsListService,
+		@NonNull RingtoneService ringtoneService,
+		@NonNull DeadlineListService hiddenChatsListService,
+		@NonNull GroupCallManager groupCallManager,
+		@Nullable String highlightUid,
+		@NonNull ItemClickListener clickListener,
+		@NonNull Map<ConversationModel, MessageListAdapterItem> messageListAdapterItemCache
+	) {
 		this.context = context;
 		this.inflater = LayoutInflater.from(context);
+		this.conversationService = conversationService;
 		this.contactService = contactService;
 		this.groupService = groupService;
-		this.conversationTagService = conversationTagService;
-		this.emojiMarkupUtil = EmojiMarkupUtil.getInstance();
-		this.stateBitmapUtil = StateBitmapUtil.getInstance();
-		this.distributionListService = distributionListService;
-		this.conversationService = conversationService;
 		this.mutedChatsListService = mutedChatsListService;
 		this.mentionOnlyChatsListService = mentionOnlyChatsListService;
-		this.hiddenChatsListService = hiddenChatsListService;
 		this.ringtoneService = ringtoneService;
-		this.highlightUid = highlightUid;
+		this.hiddenChatsListService = hiddenChatsListService;
 		this.clickListener = clickListener;
-
-		this.regularColor = ConfigUtils.getColorFromAttribute(context, android.R.attr.textColorSecondary);
-		this.backgroundColor = ConfigUtils.getColorFromAttribute(context, android.R.attr.windowBackground);
-
-		this.ackColor = context.getResources().getColor(R.color.material_green);
-		this.decColor = context.getResources().getColor(R.color.material_orange);
-
-		this.isTablet = ConfigUtils.isTabletLayout();
-
-		this.starTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
-		this.unreadTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
+		EmojiMarkupUtil emojiMarkupUtil = EmojiMarkupUtil.getInstance();
+		StateBitmapUtil stateBitmapUtil = StateBitmapUtil.getInstance();
+
+		messageListItemParams = new MessageListViewHolder.MessageListItemParams(
+			ConfigUtils.getColorFromAttribute(context, R.attr.colorOnSurface),
+			ContextCompat.getColor(context, R.color.material_green),
+			ContextCompat.getColor(context, R.color.material_orange),
+			ConfigUtils.getColorFromAttribute(context, android.R.attr.colorBackground),
+			ConfigUtils.isTabletLayout(),
+			emojiMarkupUtil,
+			contactService,
+			groupService,
+			distributionListService,
+			highlightUid,
+			stateBitmapUtil
+		);
+
+		messageListItemStrings = new MessageListViewHolder.MessageListItemStrings(
+			context.getString(R.string.notes),
+			context.getString(R.string.prefs_group_notifications),
+			context.getString(R.string.distribution_list),
+			context.getString(R.string.state_sent),
+			String.format(" %s", context.getString(R.string.draft))
+		);
 
 		this.groupCallManager = groupCallManager;
+		this.messageListAdapterItemsCache = messageListAdapterItemCache;
 	}
 
 	@Override
@@ -307,11 +190,19 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	@Override
 	public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
 		super.onViewRecycled(holder);
-		if (holder instanceof MessageListViewHolder && ((MessageListViewHolder) holder).conversationModel.isGroupConversation()) {
+		ConversationModel conversationModel = null;
+		if (holder instanceof MessageListViewHolder) {
+			MessageListAdapterItem item = ((MessageListViewHolder) holder).getMessageListAdapterItem();
+			if (item != null) {
+				conversationModel = item.getConversationModel();
+			}
+		}
+		if (conversationModel != null && conversationModel.isGroupConversation()) {
 			MessageListViewHolder messageListViewHolder = (MessageListViewHolder) holder;
-			GroupModel group = messageListViewHolder.conversationModel.getGroup();
-			groupCallManager.removeGroupCallObserver(group, messageListViewHolder);
-			messageListViewHolder.stopGroupCallDuration();
+			GroupModel group = conversationModel.getGroup();
+			if (group != null) {
+				groupCallManager.removeGroupCallObserver(group, messageListViewHolder);
+			}
 		}
 	}
 
@@ -321,9 +212,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		if (viewType == TYPE_ITEM) {
 			View itemView = inflater.inflate(R.layout.item_message_list, viewGroup, false);
 			itemView.setClickable(true);
-			// TODO: MaterialCardView: Setting a custom background is not supported.
-			itemView.setBackgroundResource(R.drawable.listitem_background_selector);
-			return new MessageListViewHolder(itemView, groupCallManager);
+			return new MessageListViewHolder(itemView, context, clickForwarder, groupCallManager, messageListItemParams, messageListItemStrings);
 		}
 		return new FooterViewHolder(inflater.inflate(R.layout.footer_message_section, viewGroup, false));
 	}
@@ -331,285 +220,39 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	@Override
 	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder h, int p) {
 		if (h instanceof MessageListViewHolder) {
-			final MessageListViewHolder holder = (MessageListViewHolder) h;
-			final int position = h.getAdapterPosition();
-
-			final ConversationModel conversationModel = this.getEntity(position);
-			holder.conversationModel = conversationModel;
-
-			holder.itemView.setOnClickListener(new DebouncedOnClickListener(500) {
-				@Override
-				public void onDebouncedClick(View v) {
-					// position may have changed after the item was bound. query current position from holder
-					int currentPos = holder.getLayoutPosition();
-
-					if (currentPos >= 0) {
-						clickListener.onItemClick(v, currentPos, getEntity(currentPos));
-					}
-				}
-			});
-
-			holder.itemView.setOnLongClickListener(v -> {
-				// position may have changed after the item was bound. query current position from holder
-				int currentPos = holder.getLayoutPosition();
-
-				if (currentPos >= 0) {
-					return clickListener.onItemLongClick(v, currentPos, getEntity(currentPos));
-				}
-				return false;
-			});
-
-			holder.avatarView.setOnClickListener(v -> {
-				// position may have changed after the item was bound. query current position from holder
-				int currentPos = holder.getLayoutPosition();
-
-				if (currentPos >= 0) {
-					clickListener.onAvatarClick(v, currentPos, getEntity(currentPos));
-				}
-			});
-
-			holder.joinGroupCallButton.setOnClickListener(v -> {
-				// position may have changed after the item was bound. query current position from holder
-				int currentPos = holder.getLayoutPosition();
-
-				if (currentPos >= 0) {
-					clickListener.onJoinGroupCallClick(getEntity(currentPos));
-				}
-			});
-
-			// Show or hide star tag
-			boolean isTagStarOn = this.conversationTagService.isTaggedWith(conversationModel, this.starTagModel);
-			ViewUtil.show(holder.tagStarOn, isTagStarOn);
-			ViewUtil.show(holder.pinIcon, isTagStarOn);
-
-			AbstractMessageModel messageModel = conversationModel.getLatestMessage();
-
-			if (holder.groupMemberName != null) {
-				holder.groupMemberName.setVisibility(View.GONE);
-			}
-
-			holder.fromView.setText(conversationModel.getReceiver().getDisplayName());
-
-			if (messageModel != null && ((!messageModel.isOutbox() && conversationModel.hasUnreadMessage()) || this.conversationTagService.isTaggedWith(conversationModel, this.unreadTagModel))) {
-				holder.fromView.setTextAppearance(context, R.style.Threema_TextAppearance_List_FirstLine_Bold);
-				holder.subjectView.setTextAppearance(context, R.style.Threema_TextAppearance_List_SecondLine_Bold);
-				if (holder.groupMemberName != null && holder.dateView != null) {
-					holder.groupMemberName.setTextAppearance(context, R.style.Threema_TextAppearance_List_SecondLine_Bold);
-				}
-				long unreadCount = conversationModel.getUnreadCount();
-				if (unreadCount > 0) {
-					holder.unreadCountView.setText(String.valueOf(unreadCount));
-					holder.unreadCountView.setVisibility(View.VISIBLE);
-					holder.unreadIndicator.setVisibility(View.VISIBLE);
-				} else if (this.conversationTagService.isTaggedWith(conversationModel, this.unreadTagModel)) {
-					holder.unreadCountView.setText("");
-					holder.unreadCountView.setVisibility(View.VISIBLE);
-					holder.unreadIndicator.setVisibility(View.VISIBLE);
-				}
-			} else {
-				holder.fromView.setTextAppearance(context, R.style.Threema_TextAppearance_List_FirstLine);
-				holder.subjectView.setTextAppearance(context, R.style.Threema_TextAppearance_List_SecondLine);
-				if (holder.groupMemberName != null && holder.dateView != null) {
-					holder.groupMemberName.setTextAppearance(context, R.style.Threema_TextAppearance_List_SecondLine);
-				}
-				holder.unreadCountView.setVisibility(View.GONE);
-				holder.unreadIndicator.setVisibility(View.GONE);
-			}
-
-			holder.deliveryView.setColorFilter(this.regularColor);
-			holder.attachmentView.setColorFilter(this.regularColor);
-			holder.muteStatus.setColorFilter(this.regularColor);
-			holder.dateView.setTextAppearance(context, R.style.Threema_TextAppearance_List_ThirdLine);
-			holder.subjectView.setVisibility(View.VISIBLE);
-
-			String uniqueId = conversationModel.getReceiver().getUniqueIdString();
-
-			if (messageModel != null) {
-				if (hiddenChatsListService.has(uniqueId)) {
-					holder.hiddenStatus.setVisibility(View.VISIBLE);
-					holder.subjectView.setText(R.string.private_chat_subject);
-					holder.attachmentView.setVisibility(View.GONE);
-					holder.dateView.setVisibility(View.INVISIBLE);
-					holder.deliveryView.setVisibility(View.GONE);
-					holder.joinGroupCallButton.setVisibility(View.GONE);
-					holder.ongoingGroupCallContainer.setVisibility(View.GONE);
-				} else {
-					holder.hiddenStatus.setVisibility(View.GONE);
-					holder.dateView.setText(MessageUtil.getDisplayDate(this.context, messageModel, false));
-					holder.dateView.setContentDescription("." + context.getString(R.string.state_dialog_modified) + "." + holder.dateView.getText() + ".");
-					holder.dateView.setVisibility(View.VISIBLE);
-
-					String draft = ThreemaApplication.getMessageDraft(uniqueId);
-					if (!TestUtil.empty(draft)) {
-						holder.groupMemberName.setVisibility(View.GONE);
-						holder.attachmentView.setVisibility(View.GONE);
-						holder.deliveryView.setVisibility(View.GONE);
-						holder.dateView.setText(" " + context.getString(R.string.draft));
-						holder.dateView.setContentDescription(null);
-						holder.dateView.setTextAppearance(context, R.style.Threema_TextAppearance_List_ThirdLine_Red);
-						holder.dateView.setVisibility(View.VISIBLE);
-						holder.subjectView.setText(emojiMarkupUtil.formatBodyTextString(context, draft + " ", 100));
-					} else {
-						if (conversationModel.isGroupConversation()) {
-							if (holder.groupMemberName != null && messageModel.getType() != MessageType.GROUP_CALL_STATUS) {
-								holder.groupMemberName.setText(NameUtil.getShortName(this.context, messageModel, this.contactService) + ": ");
-								holder.groupMemberName.setVisibility(View.VISIBLE);
-							}
-						} else {
-							holder.joinGroupCallButton.setVisibility(View.GONE);
-							holder.ongoingGroupCallContainer.setVisibility(View.GONE);
-						}
-
-						// Configure subject
-						MessageUtil.MessageViewElement viewElement = MessageUtil.getViewElement(this.context, messageModel);
-						String subject = viewElement.text;
-
-						if (messageModel.getType() == MessageType.TEXT) {
-							// we need to add an arbitrary character - otherwise span-only strings are formatted incorrectly in the item layout
-							subject += " ";
-						}
-
-						if (viewElement.icon != null) {
-							holder.attachmentView.setVisibility(View.VISIBLE);
-							holder.attachmentView.setImageResource(viewElement.icon);
-							String description = viewElement.placeholder != null
-								? viewElement.placeholder
-								: "";
-							holder.attachmentView.setContentDescription(description);
-
-							// Configure attachment
-							// Configure color of the attachment view
-							if (viewElement.color != null) {
-								holder.attachmentView.setColorFilter(
-										this.context.getResources().getColor(viewElement.color),
-										PorterDuff.Mode.SRC_IN);
-							}
-						} else {
-							holder.attachmentView.setVisibility(View.GONE);
-						}
-
-						if (TestUtil.empty(subject)) {
-							holder.subjectView.setText("");
-							holder.subjectView.setContentDescription("");
-						} else {
-							// Append space if attachmentView is visible
-							if (holder.attachmentView.getVisibility() == View.VISIBLE) {
-								subject = " " + subject;
-							}
-							holder.subjectView.setText(emojiMarkupUtil.formatBodyTextString(context, subject, 100));
-							holder.subjectView.setContentDescription(viewElement.contentDescription);
-						}
-
-						// Special icons for voice call message
-						if (messageModel.getType() == MessageType.VOIP_STATUS) {
-							// Always show the phone icon
-							holder.deliveryView.setImageResource(R.drawable.ic_phone_locked);
-						} else if (messageModel.getType() == MessageType.GROUP_CALL_STATUS) {
-							holder.deliveryView.setImageResource(R.drawable.ic_group_call);
-						} else {
-							if (!messageModel.isOutbox()) {
-								holder.deliveryView.setImageResource(R.drawable.ic_reply_filled);
-								holder.deliveryView.setContentDescription(context.getString(R.string.state_sent));
-
-								if (conversationModel.isContactConversation()){
-									if (messageModel.getState() != null) {
-										switch (messageModel.getState()) {
-											case USERACK:
-												holder.deliveryView.setColorFilter(this.ackColor);
-												break;
-											case USERDEC:
-												holder.deliveryView.setColorFilter(this.decColor);
-												break;
-										}
-									}
-								}
-								holder.deliveryView.setVisibility(View.VISIBLE);
-							} else {
-								stateBitmapUtil.setStateDrawable(messageModel, holder.deliveryView, false);
-							}
-						}
-
-						if (conversationModel.isGroupConversation()) {
-							if (groupService.isNotesGroup(conversationModel.getGroup())) {
-								holder.deliveryView.setImageResource(R.drawable.ic_spiral_bound_booklet_outline);
-								holder.deliveryView.setContentDescription(context.getString(R.string.notes));
-							} else {
-								holder.deliveryView.setImageResource(R.drawable.ic_group_filled);
-								holder.deliveryView.setContentDescription(context.getString(R.string.prefs_group_notifications));
-							}
-							holder.deliveryView.setVisibility(View.VISIBLE);
-						} else if (conversationModel.isDistributionListConversation()) {
-							holder.deliveryView.setImageResource(R.drawable.ic_distribution_list_filled);
-							holder.deliveryView.setContentDescription(context.getString(R.string.distribution_list));
-							holder.deliveryView.setVisibility(View.VISIBLE);
-						}
-					}
-				}
-				if (mutedChatsListService.has(uniqueId)) {
-					holder.muteStatus.setImageResource(R.drawable.ic_do_not_disturb_filled);
-					holder.muteStatus.setVisibility(View.VISIBLE);
-				} else if (mentionOnlyChatsListService.has(uniqueId)) {
-					holder.muteStatus.setImageResource(R.drawable.ic_dnd_mention_black_18dp);
-					holder.muteStatus.setVisibility(View.VISIBLE);
-				} else if (ringtoneService.hasCustomRingtone(uniqueId) && ringtoneService.isSilent(uniqueId, conversationModel.isGroupConversation())) {
-					holder.muteStatus.setImageResource(R.drawable.ic_notifications_off_filled);
-					holder.muteStatus.setVisibility(View.VISIBLE);
-				} else {
-					holder.muteStatus.setVisibility(View.GONE);
-				}
+			ConversationModel conversationModel = getEntity(h.getAbsoluteAdapterPosition());
+			MessageListAdapterItem item;
+			if (messageListAdapterItemsCache.containsKey(conversationModel)) {
+				item = messageListAdapterItemsCache.get(conversationModel);
 			} else {
-				// empty chat
-				holder.attachmentView.setVisibility(View.GONE);
-				holder.deliveryView.setVisibility(View.GONE);
-				holder.dateView.setVisibility(View.GONE);
-				holder.dateView.setContentDescription(null);
-				holder.subjectView.setText("");
-				holder.subjectView.setContentDescription("");
-				holder.muteStatus.setVisibility(View.GONE);
-				holder.hiddenStatus.setVisibility(uniqueId != null && hiddenChatsListService.has(uniqueId) ? View.VISIBLE : View.GONE);
-				holder.joinGroupCallButton.setVisibility(View.GONE);
-				holder.ongoingGroupCallContainer.setVisibility(View.GONE);
-			}
-
-			initializeGroupCallIndicator(holder, conversationModel);
-
-			AdapterUtil.styleConversation(holder.fromView, groupService, conversationModel);
-
-			AvatarListItemUtil.loadAvatar(conversationModel, contactService, groupService, distributionListService, holder.avatarListItemHolder);
-
-			this.updateTypingIndicator(
-					holder,
-					conversationModel.isTyping()
-			);
-
-			holder.itemView.setActivated(selectedChats.contains(conversationModel));
-
-			if (isTablet) {
-				// handle selection in multi-pane mode
-				if (highlightUid != null && highlightUid.equals(conversationModel.getUid()) && context instanceof ComposeMessageActivity) {
-					if (ConfigUtils.getAppTheme(context) == ConfigUtils.THEME_DARK) {
-						holder.listItemFG.setBackgroundResource(R.color.dark_settings_multipane_selection_bg);
-					} else {
-						holder.listItemFG.setBackgroundResource(R.color.settings_multipane_selection_bg);
-					}
-				} else {
-					holder.listItemFG.setBackgroundColor(this.backgroundColor);
+				item = new MessageListAdapterItem(
+					conversationModel,
+					contactService,
+					groupService,
+					mutedChatsListService,
+					mentionOnlyChatsListService,
+					ringtoneService,
+					hiddenChatsListService
+				);
+				synchronized (messageListAdapterItemsCache) {
+					messageListAdapterItemsCache.put(conversationModel, item);
 				}
 			}
+			((MessageListViewHolder) h).setMessageListAdapterItem(item);
 		} else {
 			// footer
-			Chip archivedChip = h.itemView.findViewById(R.id.archived_text);
+			MaterialButton archivedButton = h.itemView.findViewById(R.id.archived_text);
 
 			int archivedCount = conversationService.getArchivedCount();
 			if (archivedCount > 0) {
-				archivedChip.setVisibility(View.VISIBLE);
-				archivedChip.setOnClickListener(clickListener::onFooterClick);
-				archivedChip.setText(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.num_archived_chats, archivedCount, archivedCount));
+				archivedButton.setVisibility(View.VISIBLE);
+				archivedButton.setOnClickListener(clickListener::onFooterClick);
+				archivedButton.setText(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.num_archived_chats, archivedCount, archivedCount));
 				if (recyclerView != null) {
 					((EmptyRecyclerView) recyclerView).setNumHeadersAndFooters(0);
 				}
 			} else {
-				archivedChip.setVisibility(View.GONE);
+				archivedButton.setVisibility(View.GONE);
 				if (recyclerView != null) {
 					((EmptyRecyclerView) recyclerView).setNumHeadersAndFooters(1);
 				}
@@ -617,55 +260,6 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		}
 	}
 
-	/**
-	 * Initializes the view holder regarding ongoing group calls. If a group call is running, it
-	 * makes the join group call button visible and disables all the views that would be hidden
-	 * by the button.
-	 */
-	private void initializeGroupCallIndicator(@NonNull MessageListViewHolder holder, @NonNull ConversationModel conversationModel) {
-		GroupModel groupModel = conversationModel.getGroup();
-		if (conversationModel.isGroupConversation()
-			&& !groupService.isNotesGroup(groupModel)
-			&& groupService.isGroupMember(groupModel)
-		) {
-			GroupCallDescription call = groupCallManager.getCurrentChosenCall(holder.conversationModel.getGroup());
-			if (call != null && ConfigUtils.isGroupCallsEnabled()) {
-				boolean isJoined = groupCallManager.isJoinedCall(call);
-
-				holder.joinGroupCallButton.setVisibility(View.VISIBLE);
-				holder.joinGroupCallButton.setText(isJoined ? R.string.voip_gc_open_call : R.string.voip_gc_join_call);
-				ColorStateList groupCallTextColor = ColorStateList.valueOf(context.getResources().getColor(R.color.group_call_accent));
-				holder.joinGroupCallButton.setTextColor(groupCallTextColor);
-				holder.joinGroupCallButton.setChipBackgroundColor(groupCallTextColor.withAlpha(0x1a));
-				holder.ongoingCallText.setText(isJoined ? R.string.voip_gc_in_call : R.string.voip_gc_ongoing_call);
-				holder.ongoingGroupCallContainer.setVisibility(View.VISIBLE);
-				holder.groupCallDuration.postDelayed(() -> {
-					Long runningSince = call.getRunningSince();
-					holder.startGroupCallDuration(runningSince != null ? runningSince : SystemClock.elapsedRealtime());
-				}, 100L);
-				holder.unreadCountView.setVisibility(View.GONE);
-				holder.pinIcon.setVisibility(View.GONE);
-				holder.typingContainer.setVisibility(View.GONE);
-				holder.deliveryView.setVisibility(View.GONE);
-				holder.subjectView.setVisibility(View.GONE);
-				holder.dateView.setVisibility(View.GONE);
-				holder.attachmentView.setVisibility(View.GONE);
-				holder.groupMemberName.setVisibility(View.GONE);
-				holder.muteStatus.setVisibility(View.GONE);
-			} else {
-				holder.joinGroupCallButton.setVisibility(View.GONE);
-				holder.ongoingGroupCallContainer.setVisibility(View.GONE);
-			}
-			groupCallManager.addGroupCallObserver(groupModel, holder);
-		} else {
-			if (groupModel != null) {
-				groupCallManager.removeGroupCallObserver(groupModel, holder);
-			}
-			holder.joinGroupCallButton.setVisibility(View.GONE);
-			holder.ongoingGroupCallContainer.setVisibility(View.GONE);
-		}
-	}
-
 	@Override
 	public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
 		super.onAttachedToRecyclerView(recyclerView);
@@ -707,13 +301,6 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	}
 
 	public void setHighlightItem(String uid) {
-		highlightUid = uid;
-	}
-
-	private void updateTypingIndicator(MessageListViewHolder holder, boolean isTyping) {
-		if(holder != null && holder.latestMessageContainer != null && holder.typingContainer != null) {
-			holder.latestMessageContainer.setVisibility(isTyping ? View.GONE : View.VISIBLE);
-			holder.typingContainer.setVisibility(!isTyping ? View.GONE : View.VISIBLE);
-		}
+		messageListItemParams.setHighlightUid(uid);
 	}
 }

+ 178 - 0
app/src/main/java/ch/threema/app/adapters/MessageListAdapterItem.kt

@@ -0,0 +1,178 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022-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
+
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.DeadlineListService
+import ch.threema.app.services.GroupService
+import ch.threema.app.services.RingtoneService
+import ch.threema.app.utils.MessageUtil
+import ch.threema.app.utils.NameUtil
+import ch.threema.app.utils.TestUtil
+import ch.threema.storage.models.ConversationModel
+import ch.threema.storage.models.GroupModel
+import ch.threema.storage.models.MessageState
+import ch.threema.storage.models.MessageType
+
+/**
+ * This class is used to get the information of a conversation list item faster. These objects can
+ * be created in advance and when the user scrolls through the list, the information can be
+ * displayed quickly.
+ */
+class MessageListAdapterItem(
+    val conversationModel: ConversationModel,
+    contactService: ContactService,
+    groupService: GroupService,
+    private val mutedChatsListService: DeadlineListService,
+    private val mentionOnlyChatsListService: DeadlineListService,
+    private val ringtoneService: RingtoneService,
+    hiddenChatsListService: DeadlineListService
+) {
+
+    val group: GroupModel? = conversationModel.group
+
+    val isContactConversation = conversationModel.isContactConversation
+    val isGroupConversation = conversationModel.isGroupConversation
+    private val isDistributionListConversation = conversationModel.isDistributionListConversation
+    val isNotesGroup = group?.let { groupService.isNotesGroup(it) } ?: false
+    val isGroupMember = group?.let { groupService.isGroupMember(it) } ?: false
+
+    private val uniqueId = conversationModel.receiver?.uniqueIdString ?: ""
+    val uid: String = conversationModel.uid
+
+    val isHidden = hiddenChatsListService.has(uniqueId)
+    val isPinTagged = conversationModel.isPinTagged
+    val isTyping = conversationModel.isTyping
+
+    // This string contains the drafted message (only the first 100 characters); no draft available if null
+    private var lastDraft: CharSequence? = null
+    private var lastDraftPadded: CharSequence? = getDraft()
+
+    fun getDraft(): CharSequence? {
+        val draft = ThreemaApplication.getMessageDraft(uniqueId)
+        if (draft == lastDraft) {
+            return lastDraftPadded
+        }
+        if (draft?.isNotBlank() == true) {
+            lastDraft = draft
+            lastDraftPadded = "$draft "
+        } else {
+            lastDraft = null
+            lastDraftPadded = null
+        }
+        return lastDraftPadded
+    }
+
+    // This string contains the number of unread messages. If empty, the conversation is tagged unread
+    val unreadCountText = if (conversationModel.unreadCount > 0) {
+        conversationModel.unreadCount.toString()
+    } else if (conversationModel.isUnreadTagged) {
+        ""
+    } else {
+        null
+    }
+
+    val latestMessage = conversationModel.latestMessage
+    val latestMessageDate: String? =
+        MessageUtil.getDisplayDate(conversationModel.context, latestMessage, false)
+    val latestMessageDateContentDescription =
+        "." + conversationModel.context.getString(R.string.state_dialog_modified) + "." + latestMessageDate + "."
+    val latestMessageViewElement =
+        latestMessage?.let { MessageUtil.getViewElement(conversationModel.context, it) }
+
+    val latestMessageSubject: String by lazy {
+        var subject: String? = ""
+        if (latestMessageViewElement != null) {
+            subject = latestMessageViewElement.text
+        }
+        if (latestMessage != null && latestMessage.type == MessageType.TEXT) {
+            // we need to add an arbitrary character - otherwise span-only strings are formatted incorrectly in the item layout
+            subject += " "
+        }
+        if (subject.isNullOrBlank()) {
+            ""
+        } else {
+            // Append space if attachmentView is visible
+            if (latestMessageViewElement?.icon != null) {
+                subject = " $subject"
+            }
+            subject
+        }
+    }
+    val latestMessageIsAck = latestMessage != null && latestMessage.state == MessageState.USERACK
+    val latestMessageIsDec = latestMessage != null && latestMessage.state == MessageState.USERDEC
+
+    val latestMessageGroupMemberName =
+        if (isGroupConversation && latestMessage != null && latestMessage.type != MessageType.GROUP_CALL_STATUS && TestUtil.empty(getDraft())) {
+            String.format(
+                "%s: ",
+                NameUtil.getShortName(conversationModel.context, latestMessage, contactService)
+            )
+        } else {
+            ""
+        }
+
+    val deliveryIconResource = run {
+        if (isContactConversation) {
+            if (latestMessage != null) {
+                if (latestMessage.type == MessageType.VOIP_STATUS) {
+                    // Always show the phone icon for voip status messages
+                    R.drawable.ic_phone_locked
+                } else {
+                    if (!latestMessage.isOutbox) {
+                        R.drawable.ic_reply_filled
+                    } else {
+                        ConversationModel.NO_RESOURCE
+                    }
+                    // Note that the icon for outbox messages is handled directly in the view holder
+                }
+            } else {
+                ConversationModel.NO_RESOURCE
+            }
+        } else if (isGroupConversation) {
+            if (isNotesGroup) {
+                R.drawable.ic_spiral_bound_booklet_outline
+            } else {
+                R.drawable.ic_group_filled
+            }
+        } else if (isDistributionListConversation) {
+            R.drawable.ic_distribution_list_filled
+        } else {
+            ConversationModel.NO_RESOURCE
+        }
+    }
+
+    val muteStatusResource = run {
+        if (mutedChatsListService.has(uniqueId)) {
+            R.drawable.ic_do_not_disturb_filled
+        } else if (mentionOnlyChatsListService.has(uniqueId)) {
+            R.drawable.ic_dnd_mention_black_18dp
+        } else if (ringtoneService.hasCustomRingtone(uniqueId) && ringtoneService.isSilent(uniqueId, isGroupConversation)) {
+            R.drawable.ic_notifications_off_filled
+        } else {
+            ConversationModel.NO_RESOURCE
+        }
+    }
+}
+

+ 496 - 0
app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt

@@ -0,0 +1,496 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022-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
+
+import android.content.Context
+import android.view.View
+import android.view.View.GONE
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.widget.Chronometer
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.AnyThread
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import androidx.core.widget.TextViewCompat
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.activities.ComposeMessageActivity
+import ch.threema.app.emojis.EmojiMarkupUtil
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.DistributionListService
+import ch.threema.app.services.GroupService
+import ch.threema.app.ui.AvatarListItemUtil
+import ch.threema.app.ui.AvatarView
+import ch.threema.app.ui.DebouncedOnClickListener
+import ch.threema.app.ui.listitemholder.AvatarListItemHolder
+import ch.threema.app.utils.AdapterUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.RuntimeUtil
+import ch.threema.app.utils.StateBitmapUtil
+import ch.threema.app.utils.ViewUtil
+import ch.threema.app.utils.getRunningSince
+import ch.threema.app.voip.groupcall.GroupCallDescription
+import ch.threema.app.voip.groupcall.GroupCallManager
+import ch.threema.app.voip.groupcall.GroupCallObserver
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.storage.models.ConversationModel.NO_RESOURCE
+import com.google.android.material.button.MaterialButton
+import java.util.Objects
+
+private val logger = LoggingUtil.getThreemaLogger("MessageListViewHolder")
+
+class MessageListViewHolder(itemView: View,
+                            private val context: Context,
+                            private val clickListener: MessageListViewHolderClickListener,
+                            private val groupCallManager: GroupCallManager,
+                            private val params: MessageListItemParams,
+                            private val strings: MessageListItemStrings
+) : RecyclerView.ViewHolder(itemView), GroupCallObserver {
+    interface MessageListViewHolderClickListener {
+        fun onItemClick(view: View, position: Int)
+        fun onItemLongClick(view: View, position: Int): Boolean
+        fun onAvatarClick(view: View, position: Int)
+        fun onJoinGroupCallClick(position: Int)
+    }
+
+    class MessageListItemParams(
+            @ColorInt
+            val regularColor: Int,
+            @ColorInt
+            val ackColor: Int,
+            @ColorInt
+            val decColor: Int,
+            @ColorInt
+            val backgroundColor: Int,
+            val isTablet: Boolean,
+            val emojiMarkupUtil: EmojiMarkupUtil,
+            val contactService: ContactService,
+            val groupService: GroupService,
+            val distributionListService: DistributionListService,
+            var highlightUid: String?,
+            val stateBitmapUtil: StateBitmapUtil?,
+    )
+
+    class MessageListItemStrings(
+            val notes: String,
+            val groups: String,
+            val distributionLists: String,
+            val stateSent: String,
+            val draftText: String
+    )
+
+    lateinit var listItem: View
+    private lateinit var unreadBar: View
+    private lateinit var unreadCountView: MaterialButton
+    private lateinit var pinBar: View
+    private lateinit var pinIcon: ImageView
+    private lateinit var fromView: TextView
+    private lateinit var dateView: TextView
+    private lateinit var subjectView: TextView
+    private lateinit var deliveryView: ImageView
+    private lateinit var attachmentView: ImageView
+    private lateinit var listItemFG: View
+    private lateinit var latestMessageContainer: View
+    private lateinit var typingContainer: View
+    private lateinit var groupMemberName: TextView
+    private lateinit var muteStatus: ImageView
+    private lateinit var hiddenStatus: ImageView
+    lateinit var avatarView: AvatarView
+    private lateinit var avatarListItemHolder: AvatarListItemHolder
+    private lateinit var ongoingGroupCallContainer: View
+    private lateinit var joinGroupCallButton: MaterialButton
+    private lateinit var ongoingCallDivider: TextView
+    private lateinit var ongoingCallText: TextView
+    private lateinit var groupCallDuration: Chronometer
+
+    private var isGroupCallOngoing = false
+    private var isGroupCallJoined = false
+
+    var messageListAdapterItem: MessageListAdapterItem? = null
+        set(value) {
+            if (value != null) {
+                initializeMessageListView(value)
+                value.group?.let {
+                    logger.debug("Adding group call observer {}", it)
+                    groupCallManager.addGroupCallObserver(it, this)
+                }
+            }
+            field = value
+        }
+
+    init {
+        initLayout(itemView)
+        initializeOnClickListeners()
+    }
+
+    override fun onGroupCallUpdate(call: GroupCallDescription?) {
+        if (!ConfigUtils.isGroupCallsEnabled()) {
+            return
+        }
+
+        if (call != null && messageListAdapterItem?.group?.id == call.getGroupIdInt() && messageListAdapterItem?.isHidden != true) {
+            updateGroupCallDuration(call)
+        } else {
+            stopGroupCallDuration()
+        }
+        if (
+            call == null && (isGroupCallOngoing || isGroupCallJoined)
+            || call != null && (!isGroupCallOngoing || groupCallManager.isJoinedCall(call) != isGroupCallJoined)
+        ) {
+            RuntimeUtil.runOnUiThread {
+                messageListAdapterItem?.let { initializeMessageListView(it) }
+            }
+        }
+    }
+
+    private fun initLayout(view: View) {
+        listItem = view.findViewById(R.id.list_item)
+        unreadBar = view.findViewById(R.id.unread_bar)
+        unreadCountView = view.findViewById(R.id.unread_count)
+        pinBar = view.findViewById(R.id.pin_bar)
+        pinIcon = view.findViewById(R.id.pin_icon)
+        fromView = view.findViewById(R.id.from)
+        dateView = view.findViewById(R.id.date)
+        subjectView = view.findViewById(R.id.subject)
+        deliveryView = view.findViewById(R.id.delivery)
+        attachmentView = view.findViewById(R.id.attachment)
+        listItemFG = view.findViewById(R.id.list_item_fg)
+        latestMessageContainer = view.findViewById(R.id.latest_message_container)
+        typingContainer = view.findViewById(R.id.typing_container)
+        groupMemberName = view.findViewById(R.id.group_member_name)
+        muteStatus = view.findViewById(R.id.mute_status)
+        hiddenStatus = view.findViewById(R.id.hidden_status)
+        avatarView = view.findViewById(R.id.avatar_view)
+        avatarListItemHolder = AvatarListItemHolder()
+        avatarListItemHolder.avatarView = avatarView
+        avatarListItemHolder.avatarLoadingAsyncTask = null
+        ongoingGroupCallContainer = view.findViewById(R.id.ongoing_group_call_container)
+        ongoingCallText = view.findViewById(R.id.ongoing_call_text)
+        joinGroupCallButton = view.findViewById(R.id.join_group_call_button)
+        ongoingCallDivider = view.findViewById(R.id.ongoing_call_divider)
+        groupCallDuration = view.findViewById(R.id.group_call_duration)
+    }
+
+    private fun initializeOnClickListeners() {
+        listItem.setOnClickListener(object : DebouncedOnClickListener(500) {
+            override fun onDebouncedClick(v: View) {
+                // position may have changed after the item was bound. query current position from holder
+                val currentPos = layoutPosition
+                if (currentPos >= 0) {
+                    clickListener.onItemClick(v, currentPos)
+                }
+            }
+        })
+        listItem.setOnLongClickListener { v: View ->
+            // position may have changed after the item was bound. query current position from holder
+            val currentPos = layoutPosition
+            if (currentPos >= 0) {
+                return@setOnLongClickListener clickListener.onItemLongClick(v, currentPos)
+            }
+            false
+        }
+
+        avatarView.setOnClickListener { v: View ->
+            // position may have changed after the item was bound. query current position from holder
+            val currentPos = layoutPosition
+            if (currentPos >= 0) {
+                clickListener.onAvatarClick(v, currentPos)
+            }
+        }
+
+        joinGroupCallButton.setOnClickListener {
+            val currentPos = layoutPosition
+            if (currentPos >= 0) {
+                clickListener.onJoinGroupCallClick(currentPos)
+            }
+        }
+    }
+
+    private fun initializeMessageListView(messageListAdapterItem: MessageListAdapterItem) {
+        // Show or hide pin tag
+        val isPinTagged = messageListAdapterItem.isPinTagged
+        ViewUtil.show(pinBar, isPinTagged)
+        ViewUtil.show(pinIcon, isPinTagged)
+
+        val latestMessage = messageListAdapterItem.latestMessage
+
+        fromView.text = messageListAdapterItem.conversationModel.receiver.displayName
+
+        val draft = messageListAdapterItem.getDraft()
+
+        // Initialize subject
+        subjectView.visibility = VISIBLE
+        subjectView.text = params.emojiMarkupUtil.formatBodyTextString(
+            context,
+            draft?.toString() ?: messageListAdapterItem.latestMessageSubject,
+            100
+        )
+
+        groupMemberName.text = if (messageListAdapterItem.isHidden) "" else messageListAdapterItem.latestMessageGroupMemberName
+
+        if (draft != null) {
+            initializeDraft()
+        } else if (latestMessage != null) {
+            initializeLatestMessage(messageListAdapterItem)
+        } else {
+            initializeEmptyChat()
+        }
+
+        initializeUnreadAppearance(messageListAdapterItem)
+
+        initializeMuteAppearance(messageListAdapterItem)
+
+        initializeHiddenAppearance(messageListAdapterItem.isHidden)
+
+        initializeDeliveryView(messageListAdapterItem, messageListAdapterItem.isHidden, draft != null)
+
+        initializeGroupCallIndicator(messageListAdapterItem)
+
+        AdapterUtil.styleConversation(fromView, params.groupService, messageListAdapterItem.conversationModel)
+
+        AvatarListItemUtil.loadAvatar(messageListAdapterItem.conversationModel, params.contactService, params.groupService, params.distributionListService, avatarListItemHolder)
+
+        updateTypingIndicator(messageListAdapterItem)
+
+        if (params.isTablet) {
+            // handle selection in multi-pane mode
+            if (params.highlightUid != null && params.highlightUid == messageListAdapterItem.uid && context is ComposeMessageActivity) {
+                listItemFG.setBackgroundResource(R.color.settings_multipane_selection_bg)
+            } else {
+                listItemFG.setBackgroundColor(params.backgroundColor)
+            }
+        }
+    }
+
+    private fun initializeUnreadAppearance(messageListAdapterItem: MessageListAdapterItem) {
+        val unreadCountText = messageListAdapterItem.unreadCountText
+        if (unreadCountText != null) {
+            TextViewCompat.setTextAppearance(fromView, R.style.Threema_TextAppearance_List_FirstLine_Bold)
+            TextViewCompat.setTextAppearance(subjectView, R.style.Threema_TextAppearance_List_SecondLine_Bold)
+            TextViewCompat.setTextAppearance(groupMemberName, R.style.Threema_TextAppearance_List_SecondLine_Bold)
+            unreadCountView.text = unreadCountText
+            unreadCountView.visibility = VISIBLE
+            unreadBar.visibility = VISIBLE
+        } else {
+            TextViewCompat.setTextAppearance(fromView, R.style.Threema_TextAppearance_List_FirstLine)
+            TextViewCompat.setTextAppearance(subjectView, R.style.Threema_TextAppearance_List_SecondLine)
+            TextViewCompat.setTextAppearance(groupMemberName, R.style.Threema_TextAppearance_List_SecondLine)
+            unreadCountView.visibility = GONE
+            unreadBar.visibility = GONE
+        }
+    }
+
+    private fun initializeDraft() {
+        attachmentView.visibility = GONE
+        deliveryView.visibility = GONE
+        dateView.text = strings.draftText
+        dateView.contentDescription = null
+        TextViewCompat.setTextAppearance(dateView, R.style.Threema_TextAppearance_List_ThirdLine_Red)
+        dateView.visibility = VISIBLE
+    }
+
+    private fun initializeLatestMessage(messageListAdapterItem: MessageListAdapterItem) {
+        // Set the date of the latest message
+        dateView.text = messageListAdapterItem.latestMessageDate
+        dateView.contentDescription = messageListAdapterItem.latestMessageDateContentDescription
+        dateView.visibility = VISIBLE
+        TextViewCompat.setTextAppearance(dateView, R.style.Threema_TextAppearance_List_ThirdLine)
+
+        val viewElement = messageListAdapterItem.latestMessageViewElement
+        // Configure subject
+        if (viewElement?.icon != null) {
+            attachmentView.visibility = VISIBLE
+            attachmentView.setImageResource(viewElement.icon)
+            attachmentView.setColorFilter(when {
+                viewElement.color != null -> ContextCompat.getColor(context, viewElement.color)
+                else -> params.regularColor
+            })
+            attachmentView.contentDescription = Objects.requireNonNullElse(viewElement.placeholder, "")
+        } else {
+            attachmentView.visibility = GONE
+        }
+        subjectView.contentDescription = viewElement?.contentDescription ?: ""
+    }
+
+    private fun initializeEmptyChat() {
+        attachmentView.visibility = GONE
+        dateView.visibility = GONE
+        dateView.contentDescription = null
+    }
+
+    private fun initializeDeliveryView(messageListAdapterItem: MessageListAdapterItem,
+                                       isHiddenChat: Boolean,
+                                       hasDraft: Boolean
+    ) {
+        if (isHiddenChat || hasDraft) {
+            deliveryView.visibility = GONE
+        } else {
+            deliveryView.visibility = VISIBLE
+            val deliveryIconResource = messageListAdapterItem.deliveryIconResource
+            if (deliveryIconResource != NO_RESOURCE) {
+                deliveryView.setImageResource(deliveryIconResource)
+                deliveryView.contentDescription = when {
+                    messageListAdapterItem.isContactConversation -> strings.stateSent
+                    messageListAdapterItem.isNotesGroup -> strings.notes
+                    messageListAdapterItem.isGroupConversation -> strings.groups
+                    else -> strings.distributionLists
+                }
+            } else {
+                if (messageListAdapterItem.latestMessage != null) {
+                    // In case there is a latest message but no icon is set, we need to get the
+                    // icon for the current message state
+                    params.stateBitmapUtil?.setStateDrawable(context, messageListAdapterItem.latestMessage, deliveryView, false)
+                } else {
+                    deliveryView.visibility = GONE
+                }
+            }
+
+            deliveryView.setColorFilter(
+                when {
+                    messageListAdapterItem.isGroupConversation -> params.regularColor
+                    messageListAdapterItem.latestMessageIsAck -> params.ackColor
+                    messageListAdapterItem.latestMessageIsDec -> params.decColor
+                    else -> params.regularColor
+                }
+            )
+        }
+    }
+
+    private fun initializeMuteAppearance(messageListAdapterItem: MessageListAdapterItem) {
+        val muteStatusResource = messageListAdapterItem.muteStatusResource
+        if (muteStatusResource != NO_RESOURCE) {
+            muteStatus.visibility = VISIBLE
+            muteStatus.setImageResource(muteStatusResource)
+            muteStatus.setColorFilter(params.regularColor)
+        } else {
+            muteStatus.visibility = GONE
+        }
+    }
+
+    private fun initializeHiddenAppearance(isHiddenChat: Boolean) {
+        if (isHiddenChat) {
+            hiddenStatus.visibility = VISIBLE
+            subjectView.setText(R.string.private_chat_subject)
+            attachmentView.visibility = GONE
+            dateView.visibility = INVISIBLE
+            deliveryView.visibility = GONE
+        } else {
+            hiddenStatus.visibility = GONE
+        }
+    }
+
+    /**
+     * Initializes the view holder regarding ongoing group calls. If a group call is running, it
+     * makes the join group call button visible and disables all the views that would be hidden
+     * by the button.
+     */
+    private fun initializeGroupCallIndicator(messageListAdapterItem: MessageListAdapterItem) {
+        val group = messageListAdapterItem.group
+        if (group != null
+                && !messageListAdapterItem.isNotesGroup
+                && messageListAdapterItem.isGroupMember
+        ) {
+            val call: GroupCallDescription? = groupCallManager.getCurrentChosenCall(group)
+            if (call != null) {
+                val isJoined = groupCallManager.isJoinedCall(call)
+
+                isGroupCallOngoing = true
+                isGroupCallJoined = isJoined
+
+                // Initialize group call related views
+                joinGroupCallButton.visibility = VISIBLE
+                joinGroupCallButton.setText(
+                    if (isJoined) {
+                        R.string.voip_gc_open_call
+                    } else {
+                        R.string.voip_gc_join_call
+                    }
+                )
+                ongoingCallText.setText(
+                    if (isJoined) {
+                        R.string.voip_gc_in_call
+                    } else {
+                        R.string.voip_gc_ongoing_call
+                    }
+                )
+                ongoingGroupCallContainer.visibility = VISIBLE
+
+                // Make views invisible that are only displayed when no group call is happening
+                groupMemberName.visibility = GONE
+                unreadCountView.visibility = GONE
+                pinIcon.visibility = GONE
+                typingContainer.visibility = GONE
+                deliveryView.visibility = GONE
+                subjectView.visibility = GONE
+                dateView.visibility = GONE
+                attachmentView.visibility = GONE
+                muteStatus.visibility = GONE
+            } else {
+                joinGroupCallButton.visibility = GONE
+                ongoingGroupCallContainer.visibility = GONE
+
+                isGroupCallOngoing = false
+                isGroupCallJoined = false
+            }
+        } else {
+            joinGroupCallButton.visibility = GONE
+            ongoingGroupCallContainer.visibility = GONE
+        }
+    }
+
+    private fun updateTypingIndicator(messageListAdapterItem: MessageListAdapterItem) {
+        val isTypingIndicatorHidden = !messageListAdapterItem.isTyping || messageListAdapterItem.isHidden
+        latestMessageContainer.visibility = if (isTypingIndicatorHidden) VISIBLE else GONE
+        typingContainer.visibility = if (isTypingIndicatorHidden) GONE else VISIBLE
+    }
+
+    @AnyThread
+    fun updateGroupCallDuration(call: GroupCallDescription) {
+        val runningSince = getRunningSince(call, context)
+        startGroupCallDuration(runningSince ?: 0)
+    }
+
+    @AnyThread
+    private fun startGroupCallDuration(base: Long) {
+        RuntimeUtil.runOnUiThread {
+            groupCallDuration.apply {
+                this.base = base
+                start()
+                visibility = VISIBLE
+            }
+            ongoingCallDivider.visibility = VISIBLE
+        }
+    }
+
+    @AnyThread
+    private fun stopGroupCallDuration() {
+        RuntimeUtil.runOnUiThread {
+            groupCallDuration.apply {
+                stop()
+                visibility = GONE
+            }
+            ongoingCallDivider.visibility = GONE
+        }
+    }
+}

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

@@ -44,6 +44,7 @@ import ch.threema.app.ui.CheckableConstraintLayout;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListModel;
@@ -52,8 +53,9 @@ import ch.threema.storage.models.GroupModel;
 public class RecentListAdapter extends FilterableListAdapter {
 	private final Context context;
 	private List<ConversationModel> values;
-	private List<ConversationModel> ovalues;
+	private final List<ConversationModel> ovalues;
 	private RecentListFilter recentListFilter;
+	private final FilterResultsListener filterResultsListener;
 	private final ContactService contactService;
 	private final GroupService groupService;
 	private final DistributionListService distributionListService;
@@ -63,7 +65,8 @@ public class RecentListAdapter extends FilterableListAdapter {
 	                         final List<Integer> checkedItems,
 	                         ContactService contactService,
 	                         GroupService groupService,
-	                         DistributionListService distributionListService) {
+	                         DistributionListService distributionListService,
+							 FilterResultsListener filterResultsListener) {
 		super(context, R.layout.item_user_list, (List<Object>) (Object) values);
 
 		this.context = context;
@@ -72,6 +75,8 @@ public class RecentListAdapter extends FilterableListAdapter {
 		this.contactService = contactService;
 		this.groupService = groupService;
 		this.distributionListService = distributionListService;
+		this.filterResultsListener = filterResultsListener;
+
 		if (checkedItems != null && checkedItems.size() > 0) {
 			// restore checked items
 			this.checkedItems.addAll(checkedItems);
@@ -222,6 +227,9 @@ public class RecentListAdapter extends FilterableListAdapter {
 		@Override
 		protected void publishResults(CharSequence constraint, FilterResults results) {
 			values = (List<ConversationModel>) results.values;
+			if (filterResultsListener != null) {
+				filterResultsListener.onResultsAvailable(TestUtil.empty(constraint) ? 0 : results.count);
+			}
 			notifyDataSetChanged();
 		}
 

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

@@ -70,6 +70,7 @@ public class UserListAdapter extends FilterableListAdapter {
 	private final ContactService contactService;
 	private final IdListService blacklistService;
 	private final DeadlineListService hiddenChatsListService;
+	private final FilterResultsListener filterResultsListener;
 
 	public UserListAdapter(
 		Context context,
@@ -79,7 +80,8 @@ public class UserListAdapter extends FilterableListAdapter {
 		ContactService contactService,
 		IdListService blacklistService,
 		DeadlineListService hiddenChatsListService,
-		PreferenceService preferenceService
+		PreferenceService preferenceService,
+		FilterResultsListener filterResultsListener
 	) {
 		super(context, R.layout.item_user_list, (List<Object>) (Object) values);
 
@@ -87,6 +89,7 @@ public class UserListAdapter extends FilterableListAdapter {
 		this.contactService = contactService;
 		this.hiddenChatsListService = hiddenChatsListService;
 		this.blacklistService = blacklistService;
+		this.filterResultsListener = filterResultsListener;
 
 		this.values = new ArrayList<>(values);
 		this.values.addAll(getMissingPreselectedContacts(values, preselectedIdentities));
@@ -236,6 +239,9 @@ public class UserListAdapter extends FilterableListAdapter {
 		@Override
 		protected void publishResults(CharSequence constraint, FilterResults results) {
 			values = (List<ContactModel>) results.values;
+			if (filterResultsListener != null) {
+				filterResultsListener.onResultsAvailable(TestUtil.empty(constraint) ? 0 : results.count);
+			}
 			notifyDataSetChanged();
 		}
 

+ 5 - 4
app/src/main/java/ch/threema/app/adapters/ballot/BallotOverviewListAdapter.java

@@ -28,6 +28,8 @@ import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
 import android.widget.TextView;
 
+import com.google.android.material.button.MaterialButton;
+
 import java.util.List;
 import java.util.Locale;
 
@@ -35,7 +37,6 @@ import ch.threema.app.R;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.ui.AvatarListItemUtil;
-import ch.threema.app.ui.CountBoxView;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.LocaleUtil;
@@ -49,8 +50,8 @@ import ch.threema.storage.models.ballot.BallotModel;
  */
 public class BallotOverviewListAdapter extends ArrayAdapter<BallotModel> {
 
-	private Context context;
-	private List<BallotModel> values;
+	private final Context context;
+	private final List<BallotModel> values;
 	private final BallotService ballotService;
 	private final ContactService contactService;
 
@@ -68,7 +69,7 @@ public class BallotOverviewListAdapter extends ArrayAdapter<BallotModel> {
 		public TextView state;
 		public TextView creator;
 		public TextView creationDate;
-		public CountBoxView countBoxView;
+		public MaterialButton countBoxView;
 	}
 
 	@Override

+ 7 - 5
app/src/main/java/ch/threema/app/adapters/ballot/BallotVoteListAdapter.java

@@ -30,14 +30,16 @@ import android.widget.CheckBox;
 import android.widget.RadioButton;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+
+import com.google.android.material.button.MaterialButton;
+
 import java.util.List;
 import java.util.Map;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ui.CheckableRelativeLayout;
-import ch.threema.app.ui.CountBoxView;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 
@@ -46,8 +48,8 @@ import ch.threema.storage.models.ballot.BallotChoiceModel;
  */
 public class BallotVoteListAdapter extends ArrayAdapter<BallotChoiceModel> {
 
-	private Context context;
-	private List<BallotChoiceModel> values;
+	private final Context context;
+	private final List<BallotChoiceModel> values;
 	private final Map<Integer, Integer> selected;
 	private final boolean readonly;
 	private final boolean multipleChoice;
@@ -71,7 +73,7 @@ public class BallotVoteListAdapter extends ArrayAdapter<BallotChoiceModel> {
 
 	private static class BallotAdminChoiceItemHolder {
 		public TextView name;
-		public CountBoxView voteCount;
+		public MaterialButton voteCount;
 		public RadioButton radioButton;
 		public CheckBox checkBox;
 		int originalPosition;

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

@@ -62,7 +62,7 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 
 		logger.debug("configureChatMessage - position " + position);
 
-		gifMessagePlayer = (GifMessagePlayer) getMessagePlayerService().createPlayer(getMessageModel(), (Activity) getContext(), helper.getMessageReceiver());
+		gifMessagePlayer = (GifMessagePlayer) getMessagePlayerService().createPlayer(getMessageModel(), (Activity) getContext(), helper.getMessageReceiver(), null);
 		holder.messagePlayer = gifMessagePlayer;
 
 		/*
@@ -73,6 +73,9 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 				int status = holder.controller.getStatus();
 
 				switch (status) {
+					case ControllerView.STATUS_READY_TO_RETRY:
+						propagateControllerRetryClickToParent();
+						break;
 					case ControllerView.STATUS_READY_TO_PLAY:
 					case ControllerView.STATUS_READY_TO_DOWNLOAD:
 						gifMessagePlayer.open();
@@ -132,7 +135,7 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 			holder.attachmentImage.invalidate();
 		}
 		if (fileData.getRenderingType() == FileData.RENDERING_STICKER) {
-			holder.messageBlockView.setBackground(null);
+			setStickerBackground(holder);
 		} else {
 			setDefaultBackground(holder);
 		}
@@ -141,7 +144,7 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 
 		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, fileData, fileSize));
 
-		setDatePrefix(FileUtil.getFileMessageDatePrefix(getContext(), getMessageModel(), "GIF"), 0);
+		setDatePrefix(FileUtil.getFileMessageDatePrefix(getContext(), getMessageModel(), "GIF"));
 
 		gifMessagePlayer
 				.attachContainer(holder.attachmentImage)

+ 58 - 68
app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java

@@ -23,8 +23,6 @@ package ch.threema.app.adapters.decorators;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
-import android.os.Build;
-import android.os.PowerManager;
 import android.view.View;
 import android.widget.SeekBar;
 import android.widget.Toast;
@@ -35,62 +33,45 @@ import org.slf4j.Logger;
 
 import java.io.File;
 
-import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.AudioProgressBarView;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
-import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.logging.ThreemaLogger;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.media.AudioDataModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 
-import static ch.threema.app.voicemessage.VoiceRecorderActivity.MAX_VOICE_MESSAGE_LENGTH_MILLIS;
-
 public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("AudioChatAdapterDecorator");
 
 	private static final String LISTENER_TAG = "decorator";
 	private MessagePlayer audioMessagePlayer;
-	private final PowerManager.WakeLock audioPlayerWakelock;
 
 	public AudioChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
-		super(context.getApplicationContext(), messageModel, helper);
-		PowerManager powerManager = (PowerManager) context.getApplicationContext().getSystemService(Context.POWER_SERVICE);
-		audioPlayerWakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":AudioPlayer");
-	}
-
-	private void keepScreenOn() {
-		if (audioPlayerWakelock.isHeld()) {
-			keepScreenOff();
-		}
+		super(context, messageModel, helper);
 
-		if (!audioPlayerWakelock.isHeld()) {
-			audioPlayerWakelock.acquire(MAX_VOICE_MESSAGE_LENGTH_MILLIS);
+		if (logger instanceof ThreemaLogger) {
+			((ThreemaLogger) logger).setPrefix(String.valueOf(getMessageModel().getId()));
 		}
 
-		keepScreenOnUpdate();
-	}
-
-	private void keepScreenOnUpdate() {}
-
-	private void keepScreenOff() {
-		if (audioPlayerWakelock != null && audioPlayerWakelock.isHeld()) {
-			audioPlayerWakelock.release();
-		}
+		logger.info("New AudioChatAdapterDecorator instance for {}", messageModel.getId());
 	}
 
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
+
+		logger.info("configureChatMessage for {}", getMessageModel().getId());
+
 		AudioDataModel audioDataModel;
 		FileDataModel fileDataModel;
 		final long duration;
@@ -109,7 +90,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 
 		audioMessagePlayer = getMessagePlayerService().createPlayer(getMessageModel(),
-			helper.getFragment().getActivity(), helper.getMessageReceiver());
+			helper.getFragment().getActivity(), helper.getMessageReceiver(), helper.getMediaControllerFuture());
 
 		setOnClickListener(view -> {
 			// no action on onClick
@@ -117,23 +98,28 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 		holder.messagePlayer = audioMessagePlayer;
 		holder.readOnButton.setOnClickListener(v -> {
-			float speed = audioMessagePlayer.togglePlaybackSpeed();
+			float currentSpeed = getPreferenceService().getAudioPlaybackSpeed();
+			float speed = audioMessagePlayer.togglePlaybackSpeed(currentSpeed);
 			setSpeedButtonText(holder, speed);
 		});
 
 		setSpeedButtonText(holder, getPreferenceService().getAudioPlaybackSpeed());
 		holder.seekBar.setMessageModel(getMessageModel(), helper.getThumbnailCache());
+		holder.seekBar.setEnabled(false);
 		holder.readOnButton.setVisibility(View.GONE);
 		holder.messageTypeButton.setVisibility(View.VISIBLE);
 		holder.controller.setOnClickListener(v -> {
 			int status = holder.controller.getStatus();
 
 			switch (status) {
+				case ControllerView.STATUS_READY_TO_RETRY:
+					propagateControllerRetryClickToParent();
+					break;
 				case ControllerView.STATUS_READY_TO_PLAY:
 				case ControllerView.STATUS_PLAYING:
 				case ControllerView.STATUS_READY_TO_DOWNLOAD:
 					if (holder.seekBar != null && audioMessagePlayer != null) {
-						audioMessagePlayer.toggle();
+						audioMessagePlayer.togglePlayPause();
 					}
 					break;
 				case ControllerView.STATUS_PROGRESSING:
@@ -167,6 +153,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 					case MessagePlayer.State_NONE:
 						if (isDownloaded) {
 							if (holder.seekBar != null) {
+								updateProgressCount(holder, 0);
 								holder.seekBar.setMessageModel(getMessageModel(), helper.getThumbnailCache());
 							}
 							holder.controller.setPlay();
@@ -186,23 +173,24 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 					case MessagePlayer.State_DOWNLOADED:
 					case MessagePlayer.State_DECRYPTED:
 						if (holder.seekBar != null) {
+							updateProgressCount(holder, 0);
 							holder.seekBar.setMessageModel(getMessageModel(), helper.getThumbnailCache());
 						}
 						holder.controller.setPlay();
 						break;
 					case MessagePlayer.State_PLAYING:
 						isPlaying = true;
-						changePlayingState(holder, true);
+						logger.debug("playing");
 						// fallthrough
 					case MessagePlayer.State_PAUSE:
-					case MessagePlayer.State_INTERRUPTED_PLAY:
 						if (isPlaying) {
 							holder.controller.setPause();
 						} else {
 							holder.controller.setPlay();
 						}
+						changePlayingState(holder, isPlaying);
 
-						if (holder.seekBar != null) {
+						if (holder.seekBar != null && audioMessagePlayer.getDuration() > 0) {
 							holder.seekBar.setEnabled(true);
 							logger.debug("SeekBar: Duration = " + audioMessagePlayer.getDuration());
 							holder.seekBar.setMax(audioMessagePlayer.getDuration());
@@ -210,22 +198,17 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 							updateProgressCount(holder, audioMessagePlayer.getPosition());
 							holder.seekBar.setOnSeekBarChangeListener(new AudioProgressBarView.OnSeekBarChangeListener() {
 								@Override
-								public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
-									if (fromUser) {
-										audioMessagePlayer.seekTo(progress);
-									}
-								}
+								public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { }
 
 								@Override
-								public void onStartTrackingTouch(SeekBar seekBar) {
-								}
+								public void onStartTrackingTouch(SeekBar seekBar) {}
 
 								@Override
 								public void onStopTrackingTouch(SeekBar seekBar) {
+									audioMessagePlayer.seekTo(seekBar.getProgress());
 								}
 							});
 						}
-
 						break;
 				}
 
@@ -239,7 +222,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 						}
 
 						@Override
-						public void onEnd(AbstractMessageModel messageModel, boolean success, final String message, File decryptedFile) {
+						public void onEnd(final AbstractMessageModel messageModel, boolean success, final String message, File decryptedFile) {
 							if (!success) {
 								RuntimeUtil.runOnUiThread(() -> {
 									holder.controller.setPlay();
@@ -263,7 +246,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 						}
 
 						@Override
-						public void onEnd(AbstractMessageModel messageModel, boolean success, final String message) {
+						public void onEnd(final AbstractMessageModel messageModel, boolean success, final String message) {
 							if (!success) {
 								RuntimeUtil.runOnUiThread(() -> {
 									holder.controller.setReadyToDownload();
@@ -278,27 +261,31 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 					.addListener(LISTENER_TAG, new MessagePlayer.PlaybackListener() {
 						@Override
-						public void onPlay(AbstractMessageModel messageModel, boolean autoPlay) {
+						public void onPlay(final AbstractMessageModel messageModel, boolean autoPlay) {
 							RuntimeUtil.runOnUiThread(() -> {
-								invalidate(holder, position);
-								keepScreenOn();
-								changePlayingState(holder, true);
+								if (holder.position == position && getMessageModel().getId() == messageModel.getId()) {
+									logger.debug("onPlay");
+									invalidate(holder, position);
+									changePlayingState(holder, true);
+								}
 							});
 						}
 
 						@Override
-						public void onPause(AbstractMessageModel messageModel) {
+						public void onPause(final AbstractMessageModel messageModel) {
 							RuntimeUtil.runOnUiThread(() -> {
-								invalidate(holder, position);
-								keepScreenOff();
-								changePlayingState(holder, false);
+								if (holder.position == position && getMessageModel().getId() == messageModel.getId()) {
+									logger.debug("onPause");
+									invalidate(holder, position);
+									changePlayingState(holder, false);
+								}
 							});
 						}
 
 						@Override
-						public void onStatusUpdate(AbstractMessageModel messageModel, final int pos) {
+						public void onStatusUpdate(final AbstractMessageModel messageModel, final int pos) {
 							RuntimeUtil.runOnUiThread(() -> {
-								if (holder.position == position) {
+								if (holder.position == position && getMessageModel().getId() == messageModel.getId()) {
 									if (holder.seekBar != null) {
 										if (holder.seekBar.getMax() != holder.messagePlayer.getDuration()) {
 											logger.info("Audio message player duration changed old={} new={}", holder.seekBar.getMax(), holder.messagePlayer.getDuration());
@@ -309,19 +296,25 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 									// make sure pinlock is not activated while playing
 									ThreemaApplication.activityUserInteract(helper.getFragment().getActivity());
-									keepScreenOnUpdate();
 								}
 							});
 						}
 
 						@Override
-						public void onStop(AbstractMessageModel messageModel) {
+						public void onStop(final AbstractMessageModel messageModel) {
 							RuntimeUtil.runOnUiThread(() -> {
-								holder.controller.setPlay();
-								updateProgressCount(holder, 0);
-								invalidate(holder, position);
-								keepScreenOff();
-								changePlayingState(holder, false);
+								if (holder.position == position && getMessageModel().getId() == messageModel.getId()) {
+									logger.debug("onStop getMessageModel {} messageModel {} position {}", getMessageModel().getId(), messageModel.getId(), position);
+									invalidate(holder, position);
+									if (messageModel.isAvailable()) {
+										holder.controller.setPlay();
+									} else {
+										holder.controller.setReadyToDownload();
+									}
+									holder.seekBar.setEnabled(false);
+									updateProgressCount(holder, 0);
+									changePlayingState(holder, false);
+								}
 							});
 						}
 					});
@@ -356,10 +349,8 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 		//do not show duration if 0
 		if(duration > 0) {
-			setDatePrefix(StringConversionUtil.secondsToString(
-					duration,
-					false
-			), holder.dateView.getTextSize());
+			setDatePrefix(StringConversionUtil.secondsToString(duration, false));
+			setDuration(duration);
 			dateContentDescriptionPrefix = getContext().getString(R.string.duration) + ": " + StringConversionUtil.getDurationStringHuman(getContext(), duration);
 		}
 
@@ -381,10 +372,9 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 	@UiThread
 	private synchronized void changePlayingState(final ComposeMessageHolder holder, boolean isPlaying) {
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-			AnimationUtil.setFadingVisibility(holder.readOnButton, isPlaying ? View.VISIBLE : View.GONE);
-			AnimationUtil.setFadingVisibility(holder.messageTypeButton, isPlaying ? View.GONE : View.VISIBLE);
-		}
+		logger.debug("changePlayingState for {} to {}", getMessageModel().getId(), isPlaying);
+		holder.readOnButton.setVisibility(isPlaying ? View.VISIBLE : View.GONE);
+		holder.messageTypeButton.setVisibility(isPlaying ? View.GONE : View.VISIBLE);
 	}
 
 	@SuppressLint("DefaultLocale")

+ 51 - 35
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -22,21 +22,23 @@
 package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.graphics.Bitmap;
-import android.graphics.drawable.Drawable;
+import android.graphics.Color;
 import android.text.Spannable;
-import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
-import android.text.style.ImageSpan;
 import android.view.MotionEvent;
 import android.view.View;
-import android.widget.TextView;
 
+import androidx.annotation.ColorInt;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.content.res.AppCompatResources;
 import androidx.fragment.app.Fragment;
+import androidx.media3.session.MediaController;
+
+import com.google.common.util.concurrent.ListenableFuture;
 
 import org.slf4j.Logger;
 
@@ -98,7 +100,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	private OnLongClickElement onLongClickElement = null;
 	private OnTouchElement onTouchElement = null;
 	protected ActionModeStatus actionModeStatus = null;
-
+	private long durationS = 0;
 	private CharSequence datePrefix = "";
 	protected String dateContentDescriptionPrefix = "";
 
@@ -133,9 +135,9 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		private final Fragment fragment;
 		protected int regularColor;
 		private final Map<String, ContactCache> contacts = new HashMap<>();
-		private final Drawable stopwatchIcon;
 		private final int maxBubbleTextLength;
 		private final int maxQuoteTextLength;
+		private final ListenableFuture<MediaController> mediaControllerFuture;
 
 		public Helper(
 			String myIdentity,
@@ -153,9 +155,9 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			int thumbnailWidth,
 			Fragment fragment,
 			int regularColor,
-			Drawable stopwatchIcon,
 			int maxBubbleTextLength,
-			int maxQuoteTextLength) {
+			int maxQuoteTextLength,
+			ListenableFuture<MediaController> mediaControllerFuture) {
 			this.myIdentity = myIdentity;
 			this.messageService = messageService;
 			this.userService = userService;
@@ -171,9 +173,9 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			this.thumbnailWidth = thumbnailWidth;
 			this.fragment = fragment;
 			this.regularColor = regularColor;
-			this.stopwatchIcon = stopwatchIcon;
 			this.maxBubbleTextLength = maxBubbleTextLength;
 			this.maxQuoteTextLength = maxQuoteTextLength;
+			this.mediaControllerFuture = mediaControllerFuture;
 		}
 
 		public Fragment getFragment() {
@@ -240,10 +242,6 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			thumbnailWidth = preferredThumbnailWidth;
 		}
 
-		public Drawable getStopwatchIcon() {
-			return stopwatchIcon;
-		}
-
 		public int getMaxBubbleTextLength() {
 			return maxBubbleTextLength;
 		}
@@ -255,6 +253,10 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		public void setMessageReceiver(MessageReceiver messageReceiver) {
 			this.messageReceiver = messageReceiver;
 		}
+
+		public ListenableFuture<MediaController> getMediaControllerFuture() {
+			return this.mediaControllerFuture;
+		}
 	}
 
 	public ChatAdapterDecorator(Context context,
@@ -372,9 +374,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			CharSequence contentDescription;
 
 			if (!TestUtil.empty(datePrefix)) {
-				contentDescription = dateContentDescriptionPrefix + ". "
-						+ getContext().getString(R.string.state_dialog_modified) + ": "
-						+ s;
+				contentDescription = getContext().getString(R.string.state_dialog_modified) + ": " + s;
 				if (messageModel.isOutbox()) {
 					s = TextUtils.concat(datePrefix, " | " + s);
 				} else {
@@ -384,11 +384,15 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 				contentDescription = s;
 			}
 			if (holder.dateView != null) {
-				holder.dateView.setText(s, TextView.BufferType.SPANNABLE);
+				holder.dateView.setText(s);
 				holder.dateView.setContentDescription(contentDescription);
 			}
 
-			stateBitmapUtil.setStateDrawable(messageModel, holder.deliveredIndicator, true);
+			if (holder.datePrefixIcon != null) {
+				holder.datePrefixIcon.setVisibility(durationS > 0L ? View.VISIBLE : View.GONE);
+			}
+
+			stateBitmapUtil.setStateDrawable(getContext(), messageModel, holder.deliveredIndicator, true);
 			stateBitmapUtil.setGroupAckCount(messageModel, holder);
 		}
 	}
@@ -414,19 +418,12 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 
 	abstract protected void configureChatMessage(final ComposeMessageHolder holder, final int position);
 
-	protected void setDatePrefix(String prefix, float textSize) {
-		if (!TestUtil.empty(prefix) && textSize > 0) {
-			Drawable icon = helper.getStopwatchIcon();
-			icon.setBounds(0, 0, (int) (textSize * 0.8), (int) (textSize * 0.8));
-
-			SpannableStringBuilder spannableString = new SpannableStringBuilder("  " + prefix);
-			spannableString.setSpan(new ImageSpan(icon, ImageSpan.ALIGN_BASELINE),
-				0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+	protected void setDatePrefix(String prefix) {
+		datePrefix = prefix;
+	}
 
-			datePrefix = spannableString;
-		} else {
-			datePrefix = prefix;
-		}
+	protected void setDuration(long durationS) {
+		this.durationS = durationS;
 	}
 
 	protected MessageService getMessageService() {
@@ -499,18 +496,22 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		}
 	}
 
+	void setStickerBackground(ComposeMessageHolder holder) {
+		holder.messageBlockView.setCardBackgroundColor(AppCompatResources.getColorStateList(getContext(), R.color.bubble_sticker_colorstatelist));
+	}
+
 	void setDefaultBackground(ComposeMessageHolder holder) {
-		if (holder.messageBlockView.getBackground() == null) {
-			@DrawableRes int drawableRes;
+		if (holder.messageBlockView.getCardBackgroundColor().getDefaultColor() == Color.TRANSPARENT) {
+			int colorStateListRes;
 
 			if (getMessageModel().isOutbox() && !(getMessageModel() instanceof DistributionListMessageModel)) {
 				// outgoing
-				drawableRes = R.drawable.bubble_send_selector;
+				colorStateListRes = R.color.bubble_send_colorstatelist;
 			} else {
 				// incoming
-				drawableRes = R.drawable.bubble_recv_selector;
+				colorStateListRes = R.color.bubble_receive_colorstatelist;
 			}
-			holder.messageBlockView.setBackground(AppCompatResources.getDrawable(getContext(), drawableRes));
+			holder.messageBlockView.setCardBackgroundColor(AppCompatResources.getColorStateList(getContext(), colorStateListRes));
 
 			logger.debug("*** setDefaultBackground");
 		}
@@ -560,4 +561,19 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			showHide(holder.bodyTextView, false);
 		}
 	}
+
+	protected void propagateControllerRetryClickToParent() {
+		if (
+			getMessageModel().getState() == MessageState.FS_KEY_MISMATCH ||
+				getMessageModel().getState() == MessageState.SENDFAILED
+		) {
+			propagateControllerClickToParent();
+		}
+	}
+
+	protected void propagateControllerClickToParent() {
+		if (onClickElement != null) {
+			onClickElement.onClick(getMessageModel());
+		}
+	}
 }

+ 12 - 17
app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java

@@ -44,6 +44,7 @@ import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AvatarConverterUtil;
 import ch.threema.app.utils.FileUtil;
+import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
@@ -69,7 +70,7 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
-		fileMessagePlayer = (FileMessagePlayer) getMessagePlayerService().createPlayer(getMessageModel(), (Activity) context, helper.getMessageReceiver());
+		fileMessagePlayer = (FileMessagePlayer) getMessagePlayerService().createPlayer(getMessageModel(), (Activity) context, helper.getMessageReceiver(), null);
 
 		holder.messagePlayer = fileMessagePlayer;
 
@@ -105,8 +106,8 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 			setDatePrefix(
 				FileUtil.getFileMessageDatePrefix(getContext(),
 				getMessageModel(),
-				FileUtil.isImageFile(fileData) ? getContext().getString(R.string.image_placeholder) : null),
-				0);
+				FileUtil.isImageFile(fileData) ? getContext().getString(R.string.image_placeholder) : null)
+			);
 		}
 	}
 
@@ -223,6 +224,9 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 					int status = holder.controller.getStatus();
 
 					switch (status) {
+						case ControllerView.STATUS_READY_TO_RETRY:
+							propagateControllerRetryClickToParent();
+							break;
 						case ControllerView.STATUS_READY_TO_PLAY:
 						case ControllerView.STATUS_READY_TO_DOWNLOAD:
 						case ControllerView.STATUS_NONE:
@@ -295,28 +299,19 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 			}
 
 			if (fileData.getRenderingType() == FileData.RENDERING_STICKER) {
-				holder.messageBlockView.setBackground(null);
+				setStickerBackground(holder);
 			} else {
 				setDefaultBackground(holder);
 			}
 		} else {
-			if (thumbnail == null) {
-				try {
-					thumbnail = getFileService().getDefaultMessageThumbnailBitmap(context, getMessageModel(), null, fileData.getMimeType(), false);
-					if (thumbnail != null) {
-						thumbnail = AvatarConverterUtil.convert(getContext().getResources(), thumbnail, getContext().getResources().getColor(R.color.item_controller_color), Color.WHITE);
-					}
-				} catch (Exception e) {
-					//
-				}
-			} else {
-				thumbnail = AvatarConverterUtil.convert(getContext().getResources(), thumbnail);
-			}
-
 			if (thumbnail != null) {
 				if (holder.controller != null) {
 					holder.controller.setBackgroundImage(thumbnail);
 				}
+			} else {
+				if (holder.controller != null) {
+					holder.controller.setImageResource(IconUtil.getMimeIcon(fileData.getMimeType()));
+				}
 			}
 
 			if (holder.attachmentImage != null) {

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

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

+ 5 - 6
app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java

@@ -39,7 +39,6 @@ import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
-import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
@@ -62,7 +61,7 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
 		final MessagePlayer imageMessagePlayer = getMessagePlayerService().createPlayer(getMessageModel(),
-				(Activity) getContext(), helper.getMessageReceiver());
+				(Activity) getContext(), helper.getMessageReceiver(), null);
 		logger.debug("configureChatMessage Image");
 
 		holder.messagePlayer = imageMessagePlayer;
@@ -132,6 +131,9 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 					int status = holder.controller.getStatus();
 
 					switch (status) {
+						case ControllerView.STATUS_READY_TO_RETRY:
+							propagateControllerRetryClickToParent();
+							break;
 						case ControllerView.STATUS_PROGRESSING:
 							if (getMessageModel().isOutbox() && (getMessageModel().getState() == MessageState.PENDING || getMessageModel().getState() == MessageState.SENDING)) {
 								getMessageService().cancelMessageUpload(getMessageModel());
@@ -139,9 +141,6 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 								imageMessagePlayer.cancel();
 							}
 							break;
-						case ControllerView.STATUS_READY_TO_RETRY:
-							// ignore (retries will be handled by click listener for messageView)
-							break;
 						case ControllerView.STATUS_READY_TO_DOWNLOAD:
 							imageMessagePlayer.open();
 							break;
@@ -184,7 +183,7 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 			Intent intent = new Intent(getContext(), MediaViewerActivity.class);
 			IntentDataUtil.append(m, intent);
 			intent.putExtra(MediaViewerActivity.EXTRA_ID_REVERSE_ORDER, true);
-			AnimationUtil.startActivityForResult((Activity) getContext(), v, intent, ThreemaActivity.ACTIVITY_ID_MEDIA_VIEWER);
+			((Activity) getContext()).startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_MEDIA_VIEWER);
 		}
 	}
 

+ 9 - 3
app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java

@@ -26,6 +26,7 @@ import android.content.Context;
 import android.content.Intent;
 import android.text.method.LinkMovementMethod;
 import android.view.View;
+import android.widget.TextView;
 
 import androidx.annotation.Nullable;
 
@@ -40,6 +41,7 @@ import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QuoteUtil;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -110,7 +112,7 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 	private QuoteUtil.QuoteContent configureQuote(final ComposeMessageHolder holder, final AbstractMessageModel messageModel) {
 		QuoteUtil.QuoteContent content = QuoteUtil.getQuoteContent(
 			messageModel,
-			this.helper.getMessageReceiver().getType(),
+			this.helper.getMessageReceiver(),
 			false,
 			this.helper.getThumbnailCache(),
 			this.getContext(),
@@ -121,9 +123,13 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 
 		if (content != null) {
 			if (holder.secondaryTextView instanceof EmojiConversationTextView) {
-				holder.secondaryTextView.setText(formatTextString(content.quotedText, this.filterString, helper.getMaxQuoteTextLength() + 8));
 				((EmojiConversationTextView) holder.secondaryTextView).setFade(
-					content.quotedText != null && content.quotedText.length() > helper.getMaxQuoteTextLength());
+						TestUtil.empty(filterString) &&
+						content.quotedText != null &&
+						content.quotedText.length() > helper.getMaxQuoteTextLength());
+				holder.secondaryTextView.setText(
+					formatTextString(content.quotedText, this.filterString, helper.getMaxQuoteTextLength() + 8),
+					TextView.BufferType.SPANNABLE);
 			}
 
 			ContactModel contactModel = this.helper.getContactService().getByIdentity(content.identity);

+ 29 - 12
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -62,7 +62,7 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
 		final MessagePlayer videoMessagePlayer = getMessagePlayerService().createPlayer(getMessageModel(),
-			(Activity) getContext(), helper.getMessageReceiver());
+			(Activity) getContext(), helper.getMessageReceiver(), null);
 
 		logger.debug("configureChatMessage Video on position {} instance {} holder {} messageplayer = {}", position, this, holder, videoMessagePlayer);
 
@@ -107,6 +107,7 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 		if (duration > 0) {
 			datePrefixString = StringConversionUtil.secondsToString(duration, false);
 			dateContentDescriptionPrefix = getContext().getString(R.string.duration) + ": " + StringConversionUtil.getDurationStringHuman(getContext(), duration);
+			setDuration(duration);
 		}
 
 		if (size > 0) {
@@ -114,33 +115,41 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 			dateContentDescriptionPrefix = getContext().getString(R.string.file_size) + ": " + Formatter.formatShortFileSize(getContext(), size);
 		}
 
-		setDatePrefix(datePrefixString, holder.dateView.getTextSize());
+		setDatePrefix(datePrefixString);
 
 		setDefaultBackground(holder);
 	}
 
 	private void configureForMessageTypeFile(@NonNull ComposeMessageHolder holder) {
-		String datePrefixString = getMessageModel().getFileData().getDurationString();
+		String datePrefixString = "";
 		long duration = getMessageModel().getFileData().getDurationSeconds();
 
 		if (!getMessageModel().getFileData().isDownloaded()) {
 			long size = getMessageModel().getFileData().getFileSize();
 			if (size > 0) {
-				if (duration > 0) {
-					datePrefixString += " (" + Formatter.formatShortFileSize(getContext(), size) + ")";
-				} else {
-					datePrefixString = Formatter.formatShortFileSize(getContext(), size);
-				}
+				datePrefixString = Formatter.formatShortFileSize(getContext(), size);
 				dateContentDescriptionPrefix = getContext().getString(R.string.file_size) + ": " + Formatter.formatShortFileSize(getContext(), size);
 			}
+
+			if (duration > 0) {
+				if (size > 0) {
+					datePrefixString = datePrefixString + " | ";
+				}
+				datePrefixString = datePrefixString + getMessageModel().getFileData().getDurationString();
+			}
 		} else {
-			dateContentDescriptionPrefix = getContext().getString(R.string.duration) + ": " + StringConversionUtil.getDurationStringHuman(getContext(), duration);
+			if (duration > 0) {
+				datePrefixString = datePrefixString + getMessageModel().getFileData().getDurationString();
+				dateContentDescriptionPrefix = getContext().getString(R.string.duration) + ": " + StringConversionUtil.getDurationStringHuman(getContext(), duration);
+			}
 		}
 
-		if (holder.dateView != null) {
-			setDatePrefix(datePrefixString, 0);
+		if (duration > 0) {
+			setDuration(duration);
 		}
 
+		setDatePrefix(datePrefixString);
+
 		configureBodyText(holder, getMessageModel().getFileData().getCaption());
 
 		configureBackground(holder);
@@ -148,7 +157,7 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 
 	private void configureBackground(@NonNull ComposeMessageHolder holder) {
 		if (getMessageModel().getFileData().getRenderingType() == FileData.RENDERING_STICKER) {
-			holder.messageBlockView.setBackground(null);
+			setStickerBackground(holder);
 		} else {
 			setDefaultBackground(holder);
 		}
@@ -223,11 +232,19 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 		holder.controller.setOnClickListener(new DebouncedOnClickListener(500) {
 			@Override
 			public void onDebouncedClick(View v) {
+				if (actionModeStatus.getActionModeEnabled()) {
+					propagateControllerClickToParent();
+					return;
+				}
+
 				int status = holder.controller.getStatus();
 
 				logger.debug("onClick status = {}", status);
 
 				switch (status) {
+					case ControllerView.STATUS_READY_TO_RETRY:
+						propagateControllerRetryClickToParent();
+						break;
 					case ControllerView.STATUS_READY_TO_PLAY:
 					case ControllerView.STATUS_READY_TO_DOWNLOAD:
 						videoMessagePlayer.open();

+ 2 - 1
app/src/main/java/ch/threema/app/adapters/decorators/VoipStatusDataChatAdapterDecorator.java

@@ -67,7 +67,8 @@ public class VoipStatusDataChatAdapterDecorator extends ChatAdapterDecorator {
 						this.setDatePrefix(StringConversionUtil.secondsToString(
 							status.getDuration(),
 							false
-						), holder.dateView.getTextSize());
+						));
+						this.setDuration(status.getDuration());
 					}
 				}
 

+ 25 - 39
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -21,6 +21,9 @@
 
 package ch.threema.app.archive;
 
+import static ch.threema.app.managers.ListenerManager.conversationListeners;
+import static ch.threema.app.managers.ListenerManager.messageListeners;
+
 import android.annotation.SuppressLint;
 import android.content.Intent;
 import android.content.res.Configuration;
@@ -57,7 +60,6 @@ import ch.threema.app.services.GroupService;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.ThreemaSearchView;
-import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.TestUtil;
@@ -66,9 +68,6 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
 
-import static ch.threema.app.managers.ListenerManager.conversationListeners;
-import static ch.threema.app.managers.ListenerManager.messageListeners;
-
 public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, SearchView.OnQueryTextListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ArchiveActivity");
 	private static final String DIALOG_TAG_REALLY_DELETE_CHATS = "delc";
@@ -202,16 +201,6 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		return true;
 	}
 
-	@Override
-	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				this.finish();
-				return true;
-		}
-		return super.onOptionsItemSelected(item);
-	}
-
 	@Override
 	public boolean onQueryTextSubmit(String query) {
 		return false;
@@ -228,8 +217,6 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
 			mode.getMenuInflater().inflate(R.menu.action_archive, menu);
 
-			ConfigUtils.themeMenu(menu, ConfigUtils.getColorFromAttribute(ArchiveActivity.this, R.attr.colorAccent));
-
 			return true;
 		}
 
@@ -245,23 +232,22 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 
 		@Override
 		public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
-			switch (item.getItemId()) {
-				case R.id.menu_delete:
-					delete(archiveAdapter.getCheckedItems());
-					return true;
-				case R.id.menu_unarchive:
-					unarchive(archiveAdapter.getCheckedItems());
-					return true;
-				case R.id.menu_select_all:
-					archiveAdapter.selectAll();
-					if (archiveAdapter.getCheckedItemsCount() > 0) {
-						actionMode.invalidate();
-					} else {
-						actionMode.finish();
-					}
-					return true;
-				default:
-					return false;
+			if (item.getItemId() == R.id.menu_delete) {
+				delete(archiveAdapter.getCheckedItems());
+				return true;
+			} else if (item.getItemId() == R.id.menu_unarchive) {
+				unarchive(archiveAdapter.getCheckedItems());
+				return true;
+			} else if (item.getItemId() == R.id.menu_select_all) {
+				archiveAdapter.selectAll();
+				if (archiveAdapter.getCheckedItemsCount() > 0) {
+					actionMode.invalidate();
+				} else {
+					actionMode.finish();
+				}
+				return true;
+			} else {
+				return false;
 			}
 		}
 
@@ -287,7 +273,7 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		if (intent == null) {
 			return;
 		}
-		AnimationUtil.startActivityForResult(this, ConfigUtils.isTabletLayout() ? null : v, intent, ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+		startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
 	}
 
 	private void unarchive(List<ConversationModel> checkedItems) {
@@ -346,11 +332,6 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		reallyDelete((List<ConversationModel>)data);
 	}
 
-	@Override
-	public void onNo(String tag, Object data) {
-
-	}
-
 	private final ConversationListener conversationListener = new ConversationListener() {
 		@Override
 		public void onNew(final ConversationModel conversationModel) {
@@ -414,6 +395,11 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 
 		@Override
 		public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {}
+
+		@Override
+		public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
+			// Ignore
+		}
 	};
 
 	@Override

+ 9 - 3
app/src/main/java/ch/threema/app/archive/ArchiveAdapter.java

@@ -30,13 +30,14 @@ import android.view.ViewGroup;
 import android.widget.ImageView;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiMarkupUtil;
@@ -227,7 +228,12 @@ public class ArchiveAdapter extends RecyclerView.Adapter<ArchiveAdapter.ArchiveV
 
 			if (this.onClickItemListener != null) {
 				holder.itemView.setOnClickListener(v -> onClickItemListener.onClick(conversationModel, holder.itemView, position));
-				holder.itemView.setOnLongClickListener(v -> onClickItemListener.onLongClick(conversationModel, holder.itemView, position));
+				holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
+					@Override
+					public boolean onLongClick(View v) {
+						return onClickItemListener.onLongClick(conversationModel, holder.itemView, position);
+					}
+				});
 			}
 		} else {
 			// Covers the case of data not being ready yet.

+ 3 - 3
app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java

@@ -44,7 +44,7 @@ import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.DatabaseServiceNew;
-import ch.threema.storage.NonceDatabaseBlobService;
+import ch.threema.storage.DatabaseNonceStore;
 
 public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("DeleteIdentityAsyncTask");
@@ -76,7 +76,7 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			// clear push token
 			PushService.deleteToken(ThreemaApplication.getAppContext());
 
-			serviceManager.getThreemaSafeService().unscheduleUpload();
+			serviceManager.getThreemaSafeService().unschedulePeriodicUpload();
 			serviceManager.getMessageService().removeAll();
 			serviceManager.getConversationService().reset();
 			serviceManager.getGroupService().removeAll();
@@ -115,7 +115,7 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 
 			File aesFile = new File(ThreemaApplication.getAppContext().getFilesDir(), ThreemaApplication.AES_KEY_FILE);
 			File databaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
-			File nonceDatabaseFile = ThreemaApplication.getAppContext().getDatabasePath(NonceDatabaseBlobService.DATABASE_NAME_V4);
+			File nonceDatabaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseNonceStore.DATABASE_NAME_V4);
 			File backupFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4 + DatabaseServiceNew.DATABASE_BACKUP_EXT);
 			File cacheDirectory = ThreemaApplication.getAppContext().getCacheDir();
 			File externalCacheDirectory = ThreemaApplication.getAppContext().getExternalCacheDir();

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor