Kaynağa Gözat

Version 4.55

Threema 4 yıl önce
ebeveyn
işleme
ca2e8eede3
100 değiştirilmiş dosya ile 1567 ekleme ve 769 silme
  1. 46 33
      app/build.gradle
  2. 1 0
      app/src/hms/AndroidManifest.xml
  3. 1 0
      app/src/hms_work/AndroidManifest.xml
  4. 0 16
      app/src/main/AndroidManifest.xml
  5. 21 40
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  6. 8 6
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  7. 23 14
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  8. 0 2
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  9. 1 2
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  10. 2 1
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  11. 1 2
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  12. 0 7
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  13. 5 10
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  14. 13 2
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  15. 3 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment1.java
  16. 2 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  17. 1 0
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  18. 1 1
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  19. 11 7
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java
  20. 57 17
      app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java
  21. 27 6
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  22. 14 2
      app/src/main/java/ch/threema/app/archive/ArchiveRepository.java
  23. 5 0
      app/src/main/java/ch/threema/app/archive/ArchiveViewModel.java
  24. 1 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  25. 110 98
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  26. 159 67
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  27. 14 44
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  28. 21 8
      app/src/main/java/ch/threema/app/fragments/RecipientListFragment.java
  29. 5 3
      app/src/main/java/ch/threema/app/fragments/mediaviews/MediaViewFragment.java
  30. 7 0
      app/src/main/java/ch/threema/app/listeners/GroupListener.java
  31. 45 0
      app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.java
  32. 3 4
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java
  33. 34 21
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java
  34. 5 2
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  35. 2 1
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachAdapter.java
  36. 1 0
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java
  37. 196 18
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  38. 9 27
      app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java
  39. 5 5
      app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java
  40. 57 47
      app/src/main/java/ch/threema/app/routines/CheckLicenseRoutine.java
  41. 0 1
      app/src/main/java/ch/threema/app/routines/UpdateBusinessAvatarRoutine.java
  42. 2 1
      app/src/main/java/ch/threema/app/services/ContactService.java
  43. 19 13
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  44. 2 2
      app/src/main/java/ch/threema/app/services/ConversationService.java
  45. 20 5
      app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java
  46. 3 0
      app/src/main/java/ch/threema/app/services/DownloadService.java
  47. 4 0
      app/src/main/java/ch/threema/app/services/DownloadServiceImpl.java
  48. 21 1
      app/src/main/java/ch/threema/app/services/GroupService.java
  49. 36 9
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  50. 6 3
      app/src/main/java/ch/threema/app/services/MessageService.java
  51. 40 31
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  52. 9 0
      app/src/main/java/ch/threema/app/services/NotificationService.java
  53. 17 18
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  54. 3 0
      app/src/main/java/ch/threema/app/services/PreferenceService.java
  55. 10 0
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  56. 1 1
      app/src/main/java/ch/threema/app/services/SensorServiceImpl.java
  57. 2 2
      app/src/main/java/ch/threema/app/services/UserService.java
  58. 8 5
      app/src/main/java/ch/threema/app/services/UserServiceImpl.java
  59. 3 2
      app/src/main/java/ch/threema/app/services/WallpaperServiceImpl.java
  60. 9 1
      app/src/main/java/ch/threema/app/services/ballot/BallotService.java
  61. 33 49
      app/src/main/java/ch/threema/app/services/ballot/BallotServiceImpl.java
  62. 30 0
      app/src/main/java/ch/threema/app/services/messageplayer/AudioMessagePlayer.java
  63. 4 0
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java
  64. 1 1
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion61.java
  65. 1 2
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeUploadJobService.java
  66. 6 0
      app/src/main/java/ch/threema/app/ui/EmptyRecyclerView.java
  67. 5 1
      app/src/main/java/ch/threema/app/ui/FastScrollGridView.java
  68. 18 7
      app/src/main/java/ch/threema/app/ui/ListViewBehavior.java
  69. 3 1
      app/src/main/java/ch/threema/app/ui/TypingIndicatorTextWatcher.java
  70. 1 1
      app/src/main/java/ch/threema/app/ui/VideoPopup.java
  71. 2 0
      app/src/main/java/ch/threema/app/ui/listitemholder/ComposeMessageHolder.java
  72. 12 6
      app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java
  73. 1 1
      app/src/main/java/ch/threema/app/utils/AnimationUtil.java
  74. 23 2
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  75. 31 0
      app/src/main/java/ch/threema/app/utils/DNDUtil.java
  76. 6 2
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  77. 4 3
      app/src/main/java/ch/threema/app/utils/LinkifyUtil.java
  78. 26 1
      app/src/main/java/ch/threema/app/utils/MediaPlayerStateWrapper.java
  79. 11 6
      app/src/main/java/ch/threema/app/utils/MimeUtil.java
  80. 2 1
      app/src/main/java/ch/threema/app/utils/ShareUtil.java
  81. 2 1
      app/src/main/java/ch/threema/app/video/transcoder/MediaComponent.java
  82. 4 0
      app/src/main/java/ch/threema/app/video/transcoder/UnrecoverableVideoTranscoderException.java
  83. 41 27
      app/src/main/java/ch/threema/app/video/transcoder/VideoTranscoder.java
  84. 1 0
      app/src/main/java/ch/threema/app/video/transcoder/audio/AbstractAudioTranscoder.java
  85. 11 1
      app/src/main/java/ch/threema/app/video/transcoder/audio/AudioFormatTranscoder.java
  86. 8 5
      app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java
  87. 25 5
      app/src/main/java/ch/threema/app/voip/activities/CallActivity.java
  88. 3 0
      app/src/main/java/ch/threema/app/voip/receivers/VoipMediaButtonReceiver.java
  89. 1 15
      app/src/main/java/ch/threema/app/voip/services/VoipStateService.java
  90. 7 1
      app/src/main/java/ch/threema/app/webrtc/FlowControlledDataChannel.java
  91. 4 3
      app/src/main/java/ch/threema/client/ballot/BallotCreateMessage.java
  92. 1 1
      app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java
  93. 4 0
      app/src/main/res/drawable/fastscroll_thumb_media_dark.xml
  94. 9 0
      app/src/main/res/drawable/ic_arrow_down_ios_black_24dp.xml
  95. 10 0
      app/src/main/res/drawable/shape_rounded_bottomsheet.xml
  96. 22 1
      app/src/main/res/layout/activity_archive.xml
  97. 57 3
      app/src/main/res/layout/activity_media_attach.xml
  98. 3 9
      app/src/main/res/layout/activity_media_preview.xml
  99. 1 1
      app/src/main/res/layout/activity_sessions.xml
  100. 1 1
      app/src/main/res/layout/activity_text_chat_bubble.xml

+ 46 - 33
app/build.gradle

@@ -82,13 +82,10 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 679
-        versionName "4.54"
+        versionCode 682
+        versionName "4.55"
         resValue "string", "version_name_suffix", ""
         resValue "string", "app_name", "Threema"
-        resValue "string", "uri_scheme", "threema"
-        resValue "string", "action_url", "go.threema.ch"
-        resValue "string", "contact_action_url", "threema.id"
         // package name used for sync adapter
         resValue "string", "package_name", applicationId
         resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.profile"
@@ -107,9 +104,15 @@ android {
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
 
+        // config fields for action URLs / deep links
+        buildConfigField "String", "uriScheme", "\"threema\""
+        buildConfigField "String", "actionUrl", "\"go.threema.ch\""
+        buildConfigField "String", "contactActionUrl", "\"threema.id\""
+
+        // duplicated for manifest
         manifestPlaceholders = [
-            actionUrl: "go.threema.ch",
             uriScheme: "threema",
+            actionUrl: "go.threema.ch",
             contactActionUrl: "threema.id"
         ]
 
@@ -149,14 +152,11 @@ android {
         }
         store_threema { }
         store_google_work {
-            versionName "4.54k"
+            versionName "4.55k"
             applicationId "ch.threema.app.work"
             testApplicationId 'ch.threema.app.work.test'
             resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Work"
-            resValue "string", "uri_scheme", "threemawork"
-            resValue "string", "action_url", "work.threema.ch"
-            resValue "string", "contact_action_url", "threema.id"
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
             resValue "integer", "max_group_size", "256"
@@ -167,9 +167,14 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
 
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemawork\""
+            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
+            buildConfigField "String", "contactActionUrl", "\"threema.id\""
+
             manifestPlaceholders = [
-                actionUrl: "work.threema.ch",
                 uriScheme: "threemawork",
+                actionUrl: "work.threema.ch",
                 contactActionUrl: "threema.id"
             ]
         }
@@ -186,15 +191,12 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
         }
         sandbox_work {
-            versionName "4.54k"
+            versionName "4.55k"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
             resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Sandbox Work"
-            resValue "string", "uri_scheme", "threemawork"
-            resValue "string", "action_url", "work.threema.ch"
-            resValue "string", "contact_action_url", "threema.id"
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
             resValue "integer", "max_group_size", "256"
@@ -209,22 +211,24 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
 
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemawork\""
+            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
+            buildConfigField "String", "contactActionUrl", "\"threema.id\""
+
             manifestPlaceholders = [
-                actionUrl: "work.threema.ch",
                 uriScheme: "threemawork",
+                actionUrl: "work.threema.ch",
                 contactActionUrl: "threema.id"
             ]
         }
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
-            versionName "4.54r"
+            versionName "4.55r"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
 
             resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Red"
-            resValue "string", "uri_scheme", "threemawork"
-            resValue "string", "action_url", "work.threema.ch"
-            resValue "string", "contact_action_url", "threema.id"
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.redwork.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.redwork.call"
             resValue "integer", "max_group_size", "256"
@@ -240,9 +244,14 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "true"
 
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemawork\""
+            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
+            buildConfigField "String", "contactActionUrl", "\"threema.id\""
+
             manifestPlaceholders = [
-                actionUrl: "work.threema.ch",
                 uriScheme: "threemawork",
+                actionUrl: "work.threema.ch",
                 contactActionUrl: "threema.id"
             ]
         }
@@ -251,14 +260,11 @@ android {
             resValue "string", "package_name", applicationId
         }
         hms_work {
-            versionName "4.54k"
+            versionName "4.55k"
             applicationId "ch.threema.app.work.hms"
             testApplicationId 'ch.threema.app.work.test.hms'
             resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Work"
-            resValue "string", "uri_scheme", "threemawork"
-            resValue "string", "action_url", "work.threema.ch"
-            resValue "string", "contact_action_url", "threema.id"
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
             resValue "integer", "max_group_size", "256"
@@ -269,9 +275,14 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
 
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemawork\""
+            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
+            buildConfigField "String", "contactActionUrl", "\"threema.id\""
+
             manifestPlaceholders = [
-                actionUrl: "work.threema.ch",
                 uriScheme: "threemawork",
+                actionUrl: "work.threema.ch",
                 contactActionUrl: "threema.id"
             ]
         }
@@ -496,14 +507,15 @@ dependencies {
         //resolutionStrategy.failOnVersionConflict()
     }
 
-    implementation 'net.zetetic:android-database-sqlcipher:4.4.2'
+    implementation 'net.zetetic:android-database-sqlcipher:4.4.3'
 
     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.7.0'
+    implementation 'net.lingala.zip4j:zip4j:2.9.0'
     implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.2'
     implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.2.1'
-    implementation 'commons-io:commons-io:2.8.0'
+    // commons-io >2.6 requires android 8
+    implementation 'commons-io:commons-io:2.6'
     implementation 'org.slf4j:slf4j-api:1.7.30'
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.23'
     implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
@@ -513,15 +525,16 @@ dependencies {
     implementation 'me.zhanghai.android.fastscroll:library:1.1.5'
 
     // AndroidX / Jetpack support libraries
+    implementation "androidx.core:core:1.5.0"
     implementation "androidx.preference:preference:1.1.1"
     implementation 'androidx.legacy:legacy-support-v13:1.0.0'
-    implementation 'androidx.recyclerview:recyclerview:1.2.0'
+    implementation 'androidx.recyclerview:recyclerview:1.2.1'
     implementation 'androidx.palette:palette:1.0.0'
-    implementation 'androidx.appcompat:appcompat:1.2.0'
+    implementation 'androidx.appcompat:appcompat:1.3.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation "androidx.work:work-runtime:2.5.0"
-    implementation 'androidx.fragment:fragment:1.3.3'
+    implementation 'androidx.fragment:fragment:1.3.5'
     implementation 'androidx.activity:activity:1.2.3'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
@@ -545,7 +558,7 @@ dependencies {
     implementation 'com.google.android.exoplayer:exoplayer-ui:2.13.3'
     implementation 'com.google.protobuf:protobuf-javalite:3.9.1'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on kitkat
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22'
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.26'
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.22!!'

+ 1 - 0
app/src/hms/AndroidManifest.xml

@@ -3,6 +3,7 @@
           xmlns:tools="http://schemas.android.com/tools"
           android:installLocation="internalOnly"
           android:testOnly="false">
+	<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
 
 	<application tools:ignore="GoogleAppIndexingWarning">
 

+ 1 - 0
app/src/hms_work/AndroidManifest.xml

@@ -3,6 +3,7 @@
           xmlns:tools="http://schemas.android.com/tools"
           android:installLocation="internalOnly"
           android:testOnly="false">
+	<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
 
 	<application tools:ignore="GoogleAppIndexingWarning">
 

+ 0 - 16
app/src/main/AndroidManifest.xml

@@ -231,16 +231,6 @@
 					android:pathPrefix="/compose"
 					android:scheme="https"/>
 			</intent-filter>
-			<intent-filter android:label="@string/app_name">
-				<action android:name="android.intent.action.VIEW"/>
-				<action android:name="android.intent.action.SENDTO"/>
-				<category android:name="android.intent.category.DEFAULT"/>
-				<category android:name="android.intent.category.BROWSABLE"/>
-				<data
-					android:host="${contactActionUrl}"
-					android:pathPrefix="/compose"
-					android:scheme="https"/>
-			</intent-filter>
 			<meta-data
 				android:name="android.service.chooser.chooser_target_service"
 				android:value=".RecipientChooserTargetService"/>
@@ -468,7 +458,6 @@
 		<activity
 			android:name=".activities.ballot.BallotWizardActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-			android:label="@string/title_activity_new_ballot_wizard"
 			android:theme="@style/Theme.Threema.WithToolbar"/>
 		<activity
 			android:name=".filepicker.FilePickerActivity"
@@ -650,11 +639,6 @@
 			android:resizeableActivity="true"
 			android:windowSoftInputMode="adjustNothing"
 			android:configChanges="uiMode" />
-		<activity
-			android:name=".mediaattacher.MediaPreviewActivity"
-			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-			android:theme="@style/Theme.Threema.MediaViewer">
-		</activity>
 		<activity
 			android:name=".threemasafe.ThreemaSafeConfigureActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"

+ 21 - 40
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -244,8 +244,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String PHONE_LINKED_PLACEHOLDER = "***";
 	public static final String EMAIL_LINKED_PLACEHOLDER = "***@***";
 
-	private static final long NOTIFICATION_TIMEOUT = 2000000000; // nanoseconds - equivalent to two seconds
-
 	public static final long ACTIVITY_CONNECTION_LIFETIME = 60000;
 	public static final int MAX_BLOB_SIZE_MB = 50;
 	public static final int MAX_BLOB_SIZE = MAX_BLOB_SIZE_MB * 1024 * 1024;
@@ -272,8 +270,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	private static boolean ipv6 = false;
 	private static HashMap<String, String> messageDrafts = new HashMap<>();
 
-	public static String uriScheme;
-
 	public static ExecutorService sendMessageExecutorService = Executors.newFixedThreadPool(4);
 
 	private static boolean checkAppReplacingState(Context context) {
@@ -294,27 +290,26 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		}
 	}
 
-	private static void showNotesGroupNotice(GroupModel groupModel, int previousMemberCount) throws ThreemaException {
-		GroupService groupService = serviceManager.getGroupService();
-		MessageService messageService = serviceManager.getMessageService();
-
-		if (groupService != null && messageService != null) {
-			String notice = null;
+	private static void showNotesGroupNotice(GroupModel groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
+		if (oldState != newState) {
+			try {
+				GroupService groupService = serviceManager.getGroupService();
+				MessageService messageService = serviceManager.getMessageService();
+				if (groupService != null && messageService != null) {
+					String notice = null;
 
-			if (previousMemberCount != groupService.countMembers(groupModel) && groupService.isGroupOwner(groupModel)) {
-				if (groupService.isNotesGroup(groupModel)) {
-					if (previousMemberCount == 2 || previousMemberCount == 0) {
+					if (newState == GroupService.NOTES) {
 						notice = serviceManager.getContext().getString(R.string.status_create_notes);
-					}
-				} else {
-					if (previousMemberCount == 1) {
+					} else if (newState == GroupService.PEOPLE && oldState != GroupService.UNDEFINED) {
 						notice = serviceManager.getContext().getString(R.string.status_create_notes_off);
 					}
-				}
 
-				if (notice != null) {
-					messageService.createStatusMessage(notice, groupService.createReceiver(groupModel));
+					if (notice != null) {
+						messageService.createStatusMessage(notice, groupService.createReceiver(groupModel));
+					}
 				}
+			} catch (ThreemaException e) {
+				logger.error("Exception", e);
 			}
 		}
 	}
@@ -379,8 +374,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		/* Instantiate our own SecureRandom implementation to make sure this gets used everywhere */
 		new LinuxSecureRandom();
 
-		uriScheme = getAppContext().getString(R.string.uri_scheme);
-
 		/* prepare app version object */
 		appVersion = new AppVersion(
 				ConfigUtils.getAppVersion(getAppContext()),
@@ -683,15 +676,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		return masterKey;
 	}
 
-	public static boolean isNotifyAgain() {
-		// do not notify again if second messages arrives within NOTIFICATION_TIMEOUT;
-		long newTimeStamp = System.nanoTime();
-		boolean doNotifiy = newTimeStamp - lastNotificationTimeStamp > NOTIFICATION_TIMEOUT;
-		lastNotificationTimeStamp = newTimeStamp;
-
-		return doNotifiy;
-	}
-
 	public static void putMessageDraft(String chatId, CharSequence value) {
 		if (value == null || value.toString().trim().length() < 1) {
 			messageDrafts.remove(chatId);
@@ -1098,7 +1082,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					serviceManager.getMessageService().createStatusMessage(
 							serviceManager.getContext().getString(R.string.status_create_group),
 							serviceManager.getGroupService().createReceiver(newGroupModel));
-					showNotesGroupNotice(newGroupModel, 0);
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
@@ -1193,12 +1176,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								}
 							}
 						}
-
-						if (myIdentity.equals(group.getCreatorIdentity())) {
-							if (previousMemberCount == 1) {
-								showNotesGroupNotice(group, previousMemberCount);
-							}
-						}
 					}
 
 				} catch (ThreemaException x) {
@@ -1232,8 +1209,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							serviceManager.getContext().getString(R.string.status_group_member_left, memberName),
 							receiver);
 
-					showNotesGroupNotice(group, previousMemberCount);
-
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
 				} catch (ThreemaException e) {
@@ -1270,7 +1245,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							serviceManager.getContext().getString(R.string.status_group_member_kicked, memberName),
 							receiver);
 
-					showNotesGroupNotice(group, previousMemberCount);
 
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
@@ -1296,6 +1270,13 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					logger.error("Exception", e);
 				}
 			}
+
+			@Override
+			public void onGroupStateChanged(GroupModel groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
+				logger.debug("&&& onGroupStateChanged: {} -> {}", oldState, newState);
+
+				showNotesGroupNotice(groupModel, oldState, newState);
+			}
 		}, THREEMA_APPLICATION_LISTENER_TAG);
 
 		ListenerManager.distributionListListeners.add(new DistributionListListener() {

+ 8 - 6
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -41,7 +41,7 @@ import java.util.Date;
 
 import androidx.annotation.NonNull;
 import androidx.fragment.app.DialogFragment;
-import ch.threema.app.utils.QRScannerUtil;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -50,6 +50,7 @@ import ch.threema.app.dialogs.NewContactDialog;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.InvalidEntryException;
+import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.LockAppService;
@@ -59,10 +60,10 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
+import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.webclient.services.QRCodeParser;
 import ch.threema.app.webclient.services.QRCodeParserImpl;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.client.Base64;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
@@ -130,9 +131,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 
 						if (scheme != null && host != null) {
 							if (
-									(ThreemaApplication.uriScheme.equals(scheme) && "add".equals(host))
+									(BuildConfig.uriScheme.equals(scheme) && "add".equals(host))
 											||
-											("https".equals(scheme) && getString(R.string.action_url).equals(host) && "/add".equals(dataUri.getPath()))
+											("https".equals(scheme) && BuildConfig.actionUrl.equals(host) && "/add".equals(dataUri.getPath()))
 							) {
 
 								String id = dataUri.getQueryParameter("id");
@@ -372,9 +373,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 				Uri uri = Uri.parse(payload);
 				if (uri != null) {
 					String scheme = uri.getScheme();
-					if (getString(R.string.uri_scheme).equals(scheme) && "add".equals(uri.getAuthority())) {
+					if (BuildConfig.uriScheme.equals(scheme) && "add".equals(uri.getAuthority())) {
 						scannedIdentity = uri.getQueryParameter("id");
-					} else if ("https".equals(scheme) && getString(R.string.contact_action_url).equals(uri.getHost())) {
+					} else if ("https".equals(scheme) && BuildConfig.contactActionUrl.equals(uri.getHost())) {
 						scannedIdentity = uri.getLastPathSegment();
 					}
 
@@ -437,6 +438,7 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			default:
 				break;
 		}
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 	}
 
 	@Override

+ 23 - 14
app/src/main/java/ch/threema/app/activities/AppLinksActivity.java

@@ -83,26 +83,35 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
 		final Uri appLinkData = getIntent().getData();
 		if (Intent.ACTION_VIEW.equals(appLinkAction) && appLinkData != null){
 			final String threemaId = appLinkData.getLastPathSegment();
-			if (threemaId != null && threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
-				new AddContactAsyncTask(null, null, threemaId, false, () -> {
-					String text = appLinkData.getQueryParameter("text");
+			if (threemaId != null) {
+				if (threemaId.equalsIgnoreCase("compose")) {
+					Intent intent = new Intent(this, RecipientListActivity.class);
+					intent.setAction(appLinkAction);
+					intent.setData(appLinkData);
+					startActivity(intent);
+				} else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
+					new AddContactAsyncTask(null, null, threemaId, false, () -> {
+						String text = appLinkData.getQueryParameter("text");
 
-					Intent intent = new Intent(AppLinksActivity.this, text != null ?
+						Intent intent = new Intent(AppLinksActivity.this, text != null ?
 							ComposeMessageActivity.class :
 							ContactDetailActivity.class);
-					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-					intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, threemaId);
-					intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
+						intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+						intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, threemaId);
+						intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
 
-					if (text != null) {
-						text = text.trim();
-						intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
-					}
+						if (text != null) {
+							text = text.trim();
+							intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
+						}
 
-					startActivity(intent);
-				}).execute();
+						startActivity(intent);
+					}).execute();
+				} else {
+					Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
+				}
 			} else {
-				Toast.makeText(this, R.string.invalid_threema_id, Toast.LENGTH_LONG).show();
+				Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
 			}
 		}
 		finish();

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

@@ -34,7 +34,6 @@ import android.text.TextUtils;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.TextView;
 import android.widget.Toast;
 
 import com.google.android.material.chip.Chip;
@@ -92,7 +91,6 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 	private List<WorkDirectoryCategory> categoryList = new ArrayList<>();
 	private List<WorkDirectoryCategory> checkedCategories = new ArrayList<>();
-	private TextView categoriesHeaderTextView;
 
 	private String queryText;
 

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

@@ -23,7 +23,6 @@ package ch.threema.app.activities;
 
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
-import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
@@ -96,7 +95,7 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 			try {
 				startActivityForResult(newIntent, REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS);
 				return;
-			} catch (ActivityNotFoundException e) {
+			} catch (Exception e) {
 				// Some Samsung devices don't bother implementing this API
 				logger.error("Exception", e);
 				setResult(RESULT_OK);

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

@@ -44,6 +44,7 @@ import android.widget.Toast;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -165,7 +166,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 
 		if (!ConfigUtils.isSerialLicenseValid()) {
 			if (scheme != null) {
-				if (scheme.startsWith(ThreemaApplication.uriScheme)) {
+				if (scheme.startsWith(BuildConfig.uriScheme)) {
 					parseUrlAndCheck(data);
 				} else if (scheme.startsWith("https")) {
 					String path = data.getPath();

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

@@ -77,7 +77,6 @@ import androidx.fragment.app.FragmentTransaction;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildFlavor;
-import ch.threema.app.push.PushService;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.wizard.WizardBaseActivity;
@@ -104,6 +103,7 @@ import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.preference.SettingsActivity;
+import ch.threema.app.push.PushService;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
@@ -717,7 +717,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 				if (this.checkLicenseBroadcastReceiver != null) {
 					try {
-						// http://stackoverflow.com/questions/2682043/how-to-check-if-receiver-is-registered-in-android
 						this.unregisterReceiver(this.checkLicenseBroadcastReceiver);
 					} catch (IllegalArgumentException e) {
 						logger.error("Exception", e);

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

@@ -21,9 +21,6 @@
 
 package ch.threema.app.activities;
 
-import android.app.SearchManager;
-import android.app.SearchableInfo;
-import android.content.Context;
 import android.os.Bundle;
 import android.util.SparseArray;
 import android.view.Menu;
@@ -224,14 +221,10 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 			checkItem.setVisible(false);
 		}
 
-		// Associate searchable configuration with the SearchView
 		this.searchMenuItem = menu.findItem(R.id.menu_search_messages);
 		this.searchView = (ThreemaSearchView) this.searchMenuItem.getActionView();
 
 		if (this.searchView != null) {
-			SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
-			SearchableInfo mSearchableInfo = searchManager.getSearchableInfo(getComponentName());
-			this.searchView.setSearchableInfo(mSearchableInfo);
 			this.searchView.setQueryHint(getString(R.string.hint_filter_list));
 			this.searchView.setOnQueryTextListener(this);
 		} else {

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

@@ -23,11 +23,8 @@ package ch.threema.app.activities;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
-import android.app.SearchManager;
-import android.app.SearchableInfo;
 import android.content.ClipData;
 import android.content.ContentResolver;
-import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.database.Cursor;
@@ -75,6 +72,7 @@ import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.actions.LocationMessageSendAction;
@@ -549,9 +547,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 						if (scheme != null && host != null) {
 							if (
-								(ThreemaApplication.uriScheme.equals(scheme) && "compose".equals(host))
+								(BuildConfig.uriScheme.equals(scheme) && "compose".equals(host))
 								||
-								("https".equals(scheme) && (getString(R.string.action_url).equals(host) || getString(R.string.contact_action_url).equals(host)) && "/compose".equals(dataUri.getPath()))
+								("https".equals(scheme) && (BuildConfig.actionUrl.equals(host) || BuildConfig.contactActionUrl.equals(host)) && "/compose".equals(dataUri.getPath()))
 							)
 							{
 								String text = dataUri.getQueryParameter("text");
@@ -744,14 +742,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		// Inflate the menu; this adds items to the action bar if it is present.
 		getMenuInflater().inflate(R.menu.activity_recipientlist, menu);
 
-		// Associate searchable configuration with the SearchView
 		this.searchMenuItem = menu.findItem(R.id.menu_search_messages);
 		this.searchView = (ThreemaSearchView) this.searchMenuItem.getActionView();
 
 		if (this.searchView != null) {
-			SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
-			SearchableInfo mSearchableInfo = searchManager.getSearchableInfo(getComponentName());
-			this.searchView.setSearchableInfo(mSearchableInfo);
 			this.searchView.setQueryHint(getString(R.string.hint_filter_list));
 			this.searchView.setOnQueryTextListener(this);
 			if (hideUi) {
@@ -1258,7 +1252,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
-	                                       @NonNull String permissions[], @NonNull int[] grantResults) {
+	                                       @NonNull String[] permissions, @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
 			switch (requestCode) {
 				case REQUEST_READ_EXTERNAL_STORAGE:

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

@@ -42,13 +42,24 @@ public class ServerMessageActivity extends ThreemaActivity {
 		final ActionBar actionBar = getSupportActionBar();
 		if (actionBar != null) {
 			actionBar.setDisplayHomeAsUpEnabled(true);
-			actionBar.setTitle(R.string.server_message_title);
+			actionBar.setTitle(R.string.warning);
 		}
 
 		setContentView(R.layout.activity_server_message);
 		this.serverMessageModel = IntentDataUtil.getServerMessageModel(this.getIntent());
 
-		((TextView)findViewById(R.id.server_message_text)).setText(this.serverMessageModel.getMessage());
+		String message = this.serverMessageModel.getMessage();
+
+		if (message == null) {
+			finish();
+			return;
+		}
+
+		if (message.startsWith("Another connection")) {
+			message = getString(R.string.another_connection_instructions, getString(R.string.app_name));
+		}
+
+		((TextView)findViewById(R.id.server_message_text)).setText(message);
 		findViewById(R.id.close_button).setOnClickListener(new View.OnClickListener() {
 			@Override
 			public void onClick(View v) {

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

@@ -83,7 +83,9 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 					choiceRecyclerView.post(new Runnable() {
 						@Override
 						public void run() {
-							choiceRecyclerView.smoothScrollToPosition(lastVisibleBallotPosition);
+							try {
+								choiceRecyclerView.smoothScrollToPosition(lastVisibleBallotPosition);
+							} catch (IllegalArgumentException ignored) { }
 						}
 					});
 				}

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

@@ -21,6 +21,7 @@
 
 package ch.threema.app.activities.wizard;
 
+import android.annotation.SuppressLint;
 import android.content.Intent;
 import android.os.AsyncTask;
 import android.os.Bundle;
@@ -40,7 +41,6 @@ import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.WizardDialog;
 import ch.threema.app.ui.NewWizardFingerPrintView;
 import ch.threema.app.utils.DialogUtil;
-import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 
@@ -94,6 +94,7 @@ public class WizardFingerPrintActivity extends WizardBackgroundActivity implemen
 				}, PROGRESS_MAX);
 	}
 
+	@SuppressLint("StaticFieldLeak")
 	private void createIdentity(final byte[] bytes) {
 		new AsyncTask<Void, Void, String>() {
 			@Override

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

@@ -411,6 +411,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.transcoderView = itemView.findViewById(R.id.transcoder_view);
 					holder.readOnContainer = itemView.findViewById(R.id.read_on_container);
 					holder.readOnButton = itemView.findViewById(R.id.read_on_button);
+					holder.messageTypeButton = itemView.findViewById(R.id.message_type_button);
 				}
 				itemView.setTag(holder);
 			}

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

@@ -173,7 +173,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 	private void setupIndexer() {
 		int size = values.size();
-		String firstLetter, sortingValue;
+		String firstLetter;
 
 		alphaIndexer.clear();
 		positionIndexer.clear();

+ 11 - 7
app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java

@@ -54,6 +54,7 @@ import ch.threema.app.utils.FileUtil;
 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 static ch.threema.storage.models.data.MessageContentsType.AUDIO;
 import static ch.threema.storage.models.data.MessageContentsType.FILE;
@@ -185,7 +186,16 @@ public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
 								holder.imageView.clearColorFilter();
 								holder.imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
 							} else {
-								if (messageModel.getType() == MessageType.FILE && messageModel.getFileData().getMimeType() != null) {
+								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().getDuration():
+											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());
 									holder.topTextView.setText(messageModel.getFileData().getFileName());
@@ -197,12 +207,6 @@ public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
 									} else {
 										broken = true;
 									}
-								} else if (messageModel.getType() == MessageType.VOICEMESSAGE) {
-									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((long) (messageModel.getAudioData().getDuration()), false));
-									holder.textContainerView.setVisibility(View.VISIBLE);
 								} else {
 									holder.textContainerView.setVisibility(View.GONE);
 									broken = true;

+ 57 - 17
app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java

@@ -21,9 +21,12 @@
 
 package ch.threema.app.adapters.decorators;
 
+import android.annotation.SuppressLint;
 import android.content.Context;
+import android.os.Build;
 import android.os.PowerManager;
 import android.text.TextUtils;
+import android.view.View;
 import android.widget.SeekBar;
 import android.widget.Toast;
 
@@ -32,6 +35,7 @@ import org.slf4j.LoggerFactory;
 
 import java.io.File;
 
+import androidx.annotation.UiThread;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -39,6 +43,8 @@ import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.utils.AnimationUtil;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
@@ -128,6 +134,17 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 		}, holder.messageBlockView);
 
 		holder.messagePlayer = audioMessagePlayer;
+		holder.readOnButton.setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				float speed = audioMessagePlayer.togglePlaybackSpeed();
+				setSpeedButtonText(holder, speed);
+			}
+		});
+
+		setSpeedButtonText(holder, getPreferenceService().getAudioPlaybackSpeed());
+		holder.readOnButton.setVisibility(View.GONE);
+		holder.messageTypeButton.setVisibility(View.VISIBLE);
 		holder.controller.setOnClickListener(v -> {
 			int status = holder.controller.getStatus();
 
@@ -154,7 +171,6 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 		});
 
 		RuntimeUtil.runOnUiThread(() -> {
-
 			holder.controller.setNeutral();
 
 			//reset progressbar
@@ -189,6 +205,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 						break;
 					case MessagePlayer.State_PLAYING:
 						isPlaying = true;
+						changePlayingState(holder, true);
 						// fallthrough
 					case MessagePlayer.State_PAUSE:
 					case MessagePlayer.State_INTERRUPTED_PLAY:
@@ -280,30 +297,36 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 					.addListener(LISTENER_TAG, new MessagePlayer.PlaybackListener() {
 						@Override
 						public void onPlay(AbstractMessageModel messageModel, boolean autoPlay) {
-							invalidate(holder, position);
-							keepScreenOn();
+							RuntimeUtil.runOnUiThread(() -> {
+								invalidate(holder, position);
+								keepScreenOn();
+								changePlayingState(holder, true);
+							});
 						}
 
 						@Override
 						public void onPause(AbstractMessageModel messageModel) {
-							invalidate(holder, position);
-							keepScreenOff();
+							RuntimeUtil.runOnUiThread(() -> {
+								invalidate(holder, position);
+								keepScreenOff();
+								changePlayingState(holder, false);
+							});
 						}
 
 						@Override
 						public void onStatusUpdate(AbstractMessageModel messageModel, final int pos) {
-								RuntimeUtil.runOnUiThread(() -> {
-									if (holder.position == position) {
-										if (holder.seekBar != null) {
-											holder.seekBar.setMax(holder.messagePlayer.getDuration());
-										}
-										updateProgressCount(holder, pos);
-
-										// make sure pinlock is not activated while playing
-										ThreemaApplication.activityUserInteract(helper.getFragment().getActivity());
-										keepScreenOnUpdate();
+							RuntimeUtil.runOnUiThread(() -> {
+								if (holder.position == position) {
+									if (holder.seekBar != null) {
+										holder.seekBar.setMax(holder.messagePlayer.getDuration());
 									}
-								});
+									updateProgressCount(holder, pos);
+
+									// make sure pinlock is not activated while playing
+									ThreemaApplication.activityUserInteract(helper.getFragment().getActivity());
+									keepScreenOnUpdate();
+								}
+							});
 						}
 
 						@Override
@@ -313,6 +336,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 								updateProgressCount(holder, 0);
 								invalidate(holder, position);
 								keepScreenOff();
+								changePlayingState(holder, false);
 							});
 						}
 					});
@@ -355,7 +379,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 		if(holder.contentView != null) {
 			//one size fits all :-)
-			holder.contentView.getLayoutParams().width = helper.getThumbnailWidth();
+			holder.contentView.getLayoutParams().width = ConfigUtils.getPreferredAudioMessageWidth(getContext(), false);
 		}
 
 		// format caption
@@ -376,6 +400,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 	}
 
+	@UiThread
 	private void updateProgressCount(final ComposeMessageHolder holder, int value) {
 		if (holder != null && holder.size != null && holder.seekBar != null) {
 			holder.seekBar.setProgress(value);
@@ -383,4 +408,19 @@ 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);
+		}
+	}
+
+	@SuppressLint("DefaultLocale")
+	private void setSpeedButtonText(final ComposeMessageHolder holder, float speed) {
+		holder.readOnButton.setText(
+			speed % 1.0 != 0L ?
+			String.format("%sx", speed) :
+			String.format(" %.0fx ", speed));
+	}
 }

+ 27 - 6
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -30,14 +30,16 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 
+import com.google.android.material.appbar.MaterialToolbar;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.util.List;
 
 import androidx.annotation.NonNull;
-import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.ActionMode;
+import androidx.appcompat.widget.SearchView;
 import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DefaultItemAnimator;
@@ -53,6 +55,7 @@ import ch.threema.app.services.ConversationService;
 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;
@@ -63,7 +66,7 @@ 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 {
+public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, SearchView.OnQueryTextListener {
 	private static final Logger logger = LoggerFactory.getLogger(ArchiveActivity.class);
 	private static final String DIALOG_TAG_REALLY_DELETE_CHATS = "delc";
 
@@ -108,10 +111,17 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 			return false;
 		}
 
-		ActionBar actionBar = getSupportActionBar();
-		if (actionBar != null) {
-			actionBar.setDisplayHomeAsUpEnabled(true);
-			actionBar.setTitle(R.string.archived_chats);
+		MaterialToolbar toolbar = findViewById(R.id.material_toolbar);
+		toolbar.setNavigationOnClickListener(view -> finish());
+		toolbar.setTitle(R.string.archived_chats);
+		MenuItem filterMenu = toolbar.getMenu().findItem(R.id.menu_filter_archive);
+		ThreemaSearchView searchView = (ThreemaSearchView) filterMenu.getActionView();
+
+		if (searchView != null) {
+			searchView.setQueryHint(getString(R.string.hint_filter_list));
+			searchView.setOnQueryTextListener(this);
+		} else {
+			filterMenu.setVisible(false);
 		}
 
 		archiveAdapter = new ArchiveAdapter(this);
@@ -188,6 +198,17 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		return super.onOptionsItemSelected(item);
 	}
 
+	@Override
+	public boolean onQueryTextSubmit(String query) {
+		return false;
+	}
+
+	@Override
+	public boolean onQueryTextChange(String newText) {
+		viewModel.filter(newText);
+		return true;
+	}
+
 	public class ArchiveAction implements ActionMode.Callback {
 		@Override
 		public boolean onCreateActionMode(ActionMode mode, Menu menu) {

+ 14 - 2
app/src/main/java/ch/threema/app/archive/ArchiveRepository.java

@@ -32,6 +32,7 @@ import androidx.lifecycle.MutableLiveData;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ConversationService;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.ConversationModel;
 
@@ -45,6 +46,7 @@ import ch.threema.storage.models.ConversationModel;
 class ArchiveRepository {
 	private MutableLiveData<List<ConversationModel>> conversationModels;
 	private ConversationService conversationService;
+	private String filter = null;
 
 	ArchiveRepository() {
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
@@ -60,7 +62,7 @@ class ArchiveRepository {
 					@Nullable
 					@Override
 					public List<ConversationModel> getValue() {
-						return conversationService.getArchived();
+						return conversationService.getArchived(null);
 					}
 				};
 			}
@@ -76,9 +78,19 @@ class ArchiveRepository {
 		new AsyncTask<String, Void, Void>() {
 			@Override
 			protected Void doInBackground(String... strings) {
-				conversationModels.postValue(conversationService.getArchived());
+				conversationModels.postValue(conversationService.getArchived(filter));
 				return null;
 			}
 		}.execute();
 	}
+
+
+	public void setFilter(String constraint) {
+		if (!TestUtil.empty(constraint)) {
+			this.filter = constraint.trim();
+		}
+		else {
+			this.filter = null;
+		}
+	}
 }

+ 5 - 0
app/src/main/java/ch/threema/app/archive/ArchiveViewModel.java

@@ -52,4 +52,9 @@ public class ArchiveViewModel extends ViewModel {
 	public void onDataChanged() {
 		repository.onDataChanged();
 	}
+
+	public void filter(String constraint) {
+		repository.setFilter(constraint);
+		repository.onDataChanged();
+	}
 }

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

@@ -1435,7 +1435,7 @@ public class RestoreService extends Service {
 			String body = row.getString(Tags.TAG_MESSAGE_BODY);
 			if (!TestUtil.empty(body)) {
 				FileDataModel fileDataModel = FileDataModel.create(body);
-				messageContentsType = MimeUtil.getContentTypeFromMimeType(fileDataModel.getMimeType());
+				messageContentsType = MimeUtil.getContentTypeFromFileData(fileDataModel);
 			} else {
 				messageContentsType = MessageContentsType.FILE;
 			}

+ 110 - 98
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -25,8 +25,6 @@ import android.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 import android.app.Activity;
-import android.app.SearchManager;
-import android.app.SearchableInfo;
 import android.content.ClipData;
 import android.content.ClipboardManager;
 import android.content.Context;
@@ -106,6 +104,7 @@ import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.view.ActionMode;
@@ -270,6 +269,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private static final String DIALOG_TAG_CONFIRM_BLOCK = "block";
 	private static final String DIALOG_TAG_DECRYPTING_MESSAGES = "dcr";
 	private static final String DIALOG_TAG_SEARCHING = "src";
+	private static final String DIALOG_TAG_LOADING_MESSAGES = "loadm";
 
 	public static final String EXTRA_API_MESSAGE_ID = "apimsgid";
 	public static final String EXTRA_SEARCH_QUERY = "searchQuery";
@@ -849,6 +849,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 		// resolution and layout may have changed after being attached to a new activity
 		ConfigUtils.getPreferredThumbnailWidth(activity, true);
+		ConfigUtils.getPreferredAudioMessageWidth(activity, true);
 	}
 
 	@Override
@@ -1927,6 +1928,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
+	@UiThread
 	private void handleIntent(Intent intent) {
 		logger.debug("handleIntent");
 		String conversationUid;
@@ -2044,7 +2046,7 @@ public class ComposeMessageFragment extends Fragment implements
 		// set wallpaper based on message receiver
 		this.setBackgroundWallpaper();
 
-		this.unreadCount = initConversationList();
+		this.initConversationList();
 
 		// work around the problem that the same original intent may be sent
 		// each time a singleTop activity (like this one) is coming back to front
@@ -2071,6 +2073,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 		String defaultText = intent.getStringExtra(ThreemaApplication.INTENT_DATA_TEXT);
 		if (!TestUtil.empty(defaultText)) {
+			this.messageText.setText(null);
 			this.messageText.append(defaultText);
 		}
 
@@ -2392,12 +2395,14 @@ public class ComposeMessageFragment extends Fragment implements
 	/**
 	 * Loading the next records for the listview
 	 */
+	@WorkerThread
 	private List<AbstractMessageModel> getNextRecords() {
 		List<AbstractMessageModel> messageModels = this.messageService.getMessagesForReceiver(this.messageReceiver, this.nextMessageFilter);
 		this.valuesLoaded(messageModels);
 		return messageModels;
 	}
 
+	@WorkerThread
 	private List<AbstractMessageModel> getAllRecords() {
 		List<AbstractMessageModel> messageModels = this.messageService.getMessagesForReceiver(this.messageReceiver);
 		this.valuesLoaded(messageModels);
@@ -2490,108 +2495,126 @@ public class ComposeMessageFragment extends Fragment implements
 			this.currentPageReferenceId = values.get(values.size()-1).getId();
 		}
 	}
+
 	/**
-	 * initialize conversation list and return the unread message count
-	 *
-	 * @return
+	 * initialize conversation list and set the unread message count
+	 * @return number of unread messages
 	 */
-	private int initConversationList() {
-
-		final int unreadCount = (int) this.messageReceiver.getUnreadMessagesCount();
-
-		final List<AbstractMessageModel> values;
-		if(unreadCount > MESSAGE_PAGE_SIZE) {
-			//do not use next record, create a "custom" selector
-			//load ALL unread messages.
-			values = this.messageService.getMessagesForReceiver(this.messageReceiver, new MessageService.MessageFilter() {
+	@SuppressLint("StaticFieldLeak")
+	@UiThread
+	private void initConversationList() {
+		this.unreadCount = (int) this.messageReceiver.getUnreadMessagesCount();
+		if (this.unreadCount > MESSAGE_PAGE_SIZE) {
+			new AsyncTask<Void, Void, List<AbstractMessageModel>>() {
 				@Override
-				public long getPageSize() {
-					return -1;
+				protected void onPreExecute() {
+					GenericProgressDialog.newInstance(-1, R.string.please_wait).show(getParentFragmentManager(), DIALOG_TAG_LOADING_MESSAGES);
 				}
 
 				@Override
-				public Integer getPageReferenceId() {
-					return null;
-				}
+				protected List<AbstractMessageModel> doInBackground(Void... voids) {
+					return messageService.getMessagesForReceiver(messageReceiver, new MessageService.MessageFilter() {
+						@Override
+						public long getPageSize() {
+							return -1;
+						}
 
-				@Override
-				public boolean withStatusMessages() {
-					return false;
-				}
+						@Override
+						public Integer getPageReferenceId() {
+							return null;
+						}
 
-				@Override
-				public boolean withUnsaved() {
-					return false;
-				}
+						@Override
+						public boolean withStatusMessages() {
+							return false;
+						}
 
-				@Override
-				public boolean onlyUnread() {
-					return false;
-				}
+						@Override
+						public boolean withUnsaved() {
+							return false;
+						}
 
-				@Override
-				public boolean onlyDownloaded() {
-					return false;
-				}
+						@Override
+						public boolean onlyUnread() {
+							return false;
+						}
 
-				@Override
-				public MessageType[] types() {
-					return new MessageType[0];
+						@Override
+						public boolean onlyDownloaded() {
+							return false;
+						}
+
+						@Override
+						public MessageType[] types() {
+							return new MessageType[0];
+						}
+
+						@Override
+						public int[] contentTypes() {
+							return null;
+						}
+					});
 				}
 
 				@Override
-				public int[] contentTypes() {
-					return null;
+				protected void onPostExecute(List<AbstractMessageModel> values) {
+					valuesLoaded(values);
+					populateList(values);
+					DialogUtil.dismissDialog(getParentFragmentManager(), DIALOG_TAG_LOADING_MESSAGES, true);
 				}
-			});
-
-			this.valuesLoaded(values);
-		}
-		else {
-			values = this.getNextRecords();
+			}.execute();
+		} else {
+			populateList(getNextRecords());
 		}
+	}
 
-		if (this.composeMessageAdapter != null) {
+	/**
+	 * Populate ListView with provided message models
+	 * @param values
+	 */
+	@UiThread
+	private void populateList(List<AbstractMessageModel> values) {
+		if (composeMessageAdapter != null) {
 			// re-use existing adapter (for example on tablets)
-			this.composeMessageAdapter.clear();
-			this.composeMessageAdapter.setThumbnailWidth(ConfigUtils.getPreferredThumbnailWidth(getContext(), false));
-			this.composeMessageAdapter.setGroupId(groupId);
-			this.composeMessageAdapter.setMessageReceiver(this.messageReceiver);
-			this.composeMessageAdapter.setUnreadMessagesCount(unreadCount);
-			this.insertToList(values, true, true, true);
+			composeMessageAdapter.clear();
+			composeMessageAdapter.setThumbnailWidth(ConfigUtils.getPreferredThumbnailWidth(getContext(), false));
+			composeMessageAdapter.setGroupId(groupId);
+			composeMessageAdapter.setMessageReceiver(messageReceiver);
+			composeMessageAdapter.setUnreadMessagesCount(unreadCount);
+			insertToList(values, true, true, true);
 			updateToolbarTitle();
 		} else {
-			this.thumbnailCache = new ThumbnailCache<Integer>(null);
-
-			this.composeMessageAdapter = new ComposeMessageAdapter(
-					this.activity,
-					this.messagePlayerService,
-					this.messageValues,
-					this.userService,
-					this.contactService,
-					this.fileService,
-					this.messageService,
-					this.ballotService,
-					this.preferenceService,
-					this.downloadService,
-					this.licenseService,
-					this.messageReceiver,
-					this.convListView,
-					this.thumbnailCache,
-					ConfigUtils.getPreferredThumbnailWidth(getContext(), false),
-					this,
-					unreadCount
+			thumbnailCache = new ThumbnailCache<Integer>(null);
+
+			composeMessageAdapter = new ComposeMessageAdapter(
+				activity,
+				messagePlayerService,
+				messageValues,
+				userService,
+				contactService,
+				fileService,
+				messageService,
+				ballotService,
+				preferenceService,
+				downloadService,
+				licenseService,
+				messageReceiver,
+				convListView,
+				thumbnailCache,
+				ConfigUtils.getPreferredThumbnailWidth(getContext(), false),
+				ComposeMessageFragment.this,
+				unreadCount
 			);
 
 			//adding footer before setting the list adapter (android < 4.4)
-			if(null != this.convListView && !this.isGroupChat && !this.isDistributionListChat) {
+			if (null != convListView && !isGroupChat && !isDistributionListChat) {
 				//create the istyping instance for later use
-				this.isTypingView = layoutInflater.inflate(R.layout.conversation_list_item_typing, null);
-				this.convListView.addFooterView(this.isTypingView, null, false);
+				isTypingView = layoutInflater.inflate(R.layout.conversation_list_item_typing, null);
+				convListView.addFooterView(isTypingView, null, false);
 			}
 
-			this.composeMessageAdapter.setGroupId(groupId);
-			this.composeMessageAdapter.setOnClickListener(new ComposeMessageAdapter.OnClickListener() {
+			composeMessageAdapter.setGroupId(groupId);
+			composeMessageAdapter.setOnClickListener(new ComposeMessageAdapter.OnClickListener() {
 				@Override
 				public void resend(AbstractMessageModel messageModel) {
 					if (messageModel.isOutbox() && messageModel.getState() == MessageState.SENDFAILED && messageReceiver.isMessageBelongsToMe(messageModel)) {
@@ -2683,18 +2706,15 @@ public class ComposeMessageFragment extends Fragment implements
 				}
 			});
 
-			this.insertToList(values, false, !hiddenChatsListService.has(this.messageReceiver.getUniqueIdString()), false);
-			this.convListView.setAdapter(this.composeMessageAdapter);
-			this.convListView.setItemsCanFocus(false);
-			this.convListView.setVisibility(View.VISIBLE);
+			insertToList(values, false, !hiddenChatsListService.has(messageReceiver.getUniqueIdString()), false);
+			convListView.setAdapter(composeMessageAdapter);
+			convListView.setItemsCanFocus(false);
+			convListView.setVisibility(View.VISIBLE);
 		}
 
 		setIdentityColors();
 
-		//hack for android < 4.4.... remove footer after adding
 		removeIsTypingFooter();
-
-		return unreadCount;
 	}
 
 	/**
@@ -4160,24 +4180,16 @@ public class ComposeMessageFragment extends Fragment implements
 	}
 
 	private void configureSearchWidget(final MenuItem menuItem) {
-		// Associate searchable configuration with the SearchView
-		SearchManager searchManager =
-				(SearchManager) activity.getSystemService(Context.SEARCH_SERVICE);
 		SearchView searchView = (SearchView) menuItem.getActionView();
-		SearchableInfo mSearchableInfo = searchManager.getSearchableInfo(activity.getComponentName());
 		if (searchView != null) {
-			searchView.setSearchableInfo(mSearchableInfo);
 			searchView.setOnQueryTextListener(queryTextListener);
 			searchView.setQueryHint(getString(R.string.hint_search_keyword));
 			searchView.setIconified(false);
-			searchView.setOnCloseListener(new SearchView.OnCloseListener() {
-				@Override
-				public boolean onClose() {
-					if (searchActionMode != null) {
-						searchActionMode.finish();
-					}
-					return false;
+			searchView.setOnCloseListener(() -> {
+				if (searchActionMode != null) {
+					searchActionMode.finish();
 				}
+				return false;
 			});
 
 			LinearLayout linearLayoutOfSearchView = (LinearLayout) searchView.getChildAt(0);

+ 159 - 67
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -24,8 +24,6 @@ package ch.threema.app.fragments;
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
-import android.app.SearchManager;
-import android.app.SearchableInfo;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -53,6 +51,7 @@ import android.widget.Toast;
 
 import com.google.android.material.chip.Chip;
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+import com.google.android.material.tabs.TabLayout;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -132,13 +131,17 @@ public class ContactsSectionFragment
 	private static final String DIALOG_TAG_REALLY_DELETE_CONTACTS = "rdc";
 	private static final String DIALOG_TAG_SHARE_WITH = "wsw";
 
-	private final String RUN_ON_ACTIVE_SHOW_LOADING = "show_loading";
-	private final String RUN_ON_ACTIVE_HIDE_LOADING = "hide_loading";
-	private final String RUN_ON_ACTIVE_UPDATE_LIST = "update_list";
-	private final String RUN_ON_ACTIVE_REFRESH_LIST = "refresh_list";
-	private final String RUN_ON_ACTIVE_REFRESH_PULL_TO_REFRESH = "pull_to_refresh";
+	private static final String RUN_ON_ACTIVE_SHOW_LOADING = "show_loading";
+	private static final String RUN_ON_ACTIVE_HIDE_LOADING = "hide_loading";
+	private static final String RUN_ON_ACTIVE_UPDATE_LIST = "update_list";
+	private static final String RUN_ON_ACTIVE_REFRESH_LIST = "refresh_list";
+	private static final String RUN_ON_ACTIVE_REFRESH_PULL_TO_REFRESH = "pull_to_refresh";
 
-	private final String BUNDLE_FILTER_QUERY_C = "BundleFilterC";
+	private static final String BUNDLE_FILTER_QUERY_C = "BundleFilterC";
+	private static final String BUNDLE_SELECTED_TAB = "tabpos";
+
+	private static final int TAB_ALL_CONTACTS = 0;
+	private static final int TAB_WORK_ONLY = 1;
 
 	private ResumePauseHandler resumePauseHandler;
 	private ListView listView;
@@ -152,6 +155,7 @@ public class ContactsSectionFragment
 	private ExtendedFloatingActionButton floatingButtonView;
 	private EmojiTextView stickyInitialView;
 	private FrameLayout stickyInitialLayout;
+	private TabLayout workTabLayout;
 
 	private SynchronizeContactsService synchronizeContactsService;
 	private ContactService contactService;
@@ -159,17 +163,47 @@ public class ContactsSectionFragment
 	private LockAppService lockAppService;
 
 	private String filterQuery;
+	@SuppressLint("StaticFieldLeak")
+	private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
+		@Override
+		public void onTabSelected(TabLayout.Tab tab) {
+			if (swipeRefreshLayout != null && swipeRefreshLayout.isRefreshing()) {
+				return;
+			}
+
+			new FetchContactsTask(contactService, false, tab.getPosition(), true) {
+				@Override
+				protected void onPostExecute(Pair<List<ContactModel>, FetchResults> result) {
+					final List<ContactModel> contactModels = result.first;
+
+					if (contactModels != null && contactListAdapter != null) {
+						contactListAdapter.updateData(contactModels);
+						if (!TestUtil.empty(filterQuery)) {
+							contactListAdapter.getFilter().filter(filterQuery);
+						}
+					}
+				}
+			}.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+		}
+
+		@Override
+		public void onTabUnselected(TabLayout.Tab tab) {}
+
+		@Override
+		public void onTabReselected(TabLayout.Tab tab) {}
+	};
 
 	/**
 	 * Simple POJO to hold the number of contacts that were added in the last 24h / 30d.
 	 */
-	private static class RecentlyAddedCounts {
+	private static class FetchResults {
 		int last24h = 0;
 		int last30d = 0;
+		int workCount = 0;
 	}
 
 	// Contacts changed receiver
-	private BroadcastReceiver contactsChangedReceiver = new BroadcastReceiver() {
+	private final BroadcastReceiver contactsChangedReceiver = new BroadcastReceiver() {
 		@Override
 		public void onReceive(Context context, Intent intent) {
 			if (resumePauseHandler != null) {
@@ -181,6 +215,10 @@ public class ContactsSectionFragment
 	private void startSwipeRefresh() {
 		if (swipeRefreshLayout != null) {
 			swipeRefreshLayout.setRefreshing(true);
+
+			if (ConfigUtils.isWorkBuild() && workTabLayout != null) {
+				workTabLayout.selectTab(workTabLayout.getTabAt(TAB_ALL_CONTACTS), true);
+			}
 		}
 	}
 
@@ -241,7 +279,7 @@ public class ContactsSectionFragment
 	private final ResumePauseHandler.RunIfActive runIfActiveCreateList = new ResumePauseHandler.RunIfActive() {
 		@Override
 		public void runOnUiThread() {
-			createListAdapter();
+			createListAdapter(null);
 		}
 	};
 
@@ -363,35 +401,55 @@ public class ContactsSectionFragment
 	/**
 	 * An AsyncTask that fetches contacts and add counts in the background.
 	 *
-	 * NOTE: The ContactService needs to be passed in as a parameter!
 	 */
-	private abstract static class FetchContactsTask extends AsyncTask<ContactService, Void, Pair<List<ContactModel>, RecentlyAddedCounts>> {
-		@Override
-		protected Pair<List<ContactModel>, RecentlyAddedCounts> doInBackground(ContactService... contactServices) {
-			final ContactService contactService = contactServices[0];
+	private static class FetchContactsTask extends AsyncTask<Void, Void, Pair<List<ContactModel>, FetchResults>> {
+		ContactService contactService;
+		boolean isOnLaunch, forceWork;
+		int selectedTab;
+
+		FetchContactsTask(ContactService contactService, boolean isOnLaunch, int selectedTab, boolean forceWork) {
+			this.contactService = contactService;
+			this.isOnLaunch = isOnLaunch;
+			this.selectedTab = selectedTab;
+			this.forceWork = forceWork;
+		}
 
-			// Fetch contacts
-			final List<ContactModel> allContacts = contactService.getAll();
+		@Override
+		protected Pair<List<ContactModel>, FetchResults> doInBackground(Void... voids) {
+			List<ContactModel> allContacts = null;
 
 			// Count new contacts
-			final RecentlyAddedCounts counts = new RecentlyAddedCounts();
-			long now = System.currentTimeMillis();
-			long delta24h = 1000L * 3600 * 24;
-			long delta30d = delta24h * 30;
-			for (ContactModel contact : allContacts) {
-				final Date dateCreated = contact.getDateCreated();
-				if (dateCreated == null) {
-					continue;
-				}
-				if (now - dateCreated.getTime() < delta24h) {
-					counts.last24h += 1;
-				}
-				if (now - dateCreated.getTime() < delta30d) {
-					counts.last30d += 1;
+			final FetchResults results = new FetchResults();
+
+			if (ConfigUtils.isWorkBuild() && selectedTab == TAB_WORK_ONLY) {
+				results.workCount = contactService.countIsWork();
+				if (results.workCount > 0 || forceWork) {
+					allContacts = contactService.getIsWork();
 				}
 			}
 
-			return new Pair<>(allContacts, counts);
+			if (allContacts == null) {
+				allContacts = contactService.getAll();
+			}
+
+			if (!ConfigUtils.isWorkBuild()) {
+				long now = System.currentTimeMillis();
+				long delta24h = 1000L * 3600 * 24;
+				long delta30d = delta24h * 30;
+				for (ContactModel contact : allContacts) {
+					final Date dateCreated = contact.getDateCreated();
+					if (dateCreated == null) {
+						continue;
+					}
+					if (now - dateCreated.getTime() < delta24h) {
+						results.last24h += 1;
+					}
+					if (now - dateCreated.getTime() < delta30d) {
+						results.last30d += 1;
+					}
+				}
+			}
+			return new Pair<>(allContacts, results);
 		}
 	}
 
@@ -496,32 +554,24 @@ public class ContactsSectionFragment
 		if (searchMenuItem == null) {
 			inflater.inflate(R.menu.fragment_contacts, menu);
 
-			// Associate searchable configuration with the SearchView
 			if (getActivity() != null && this.isAdded()) {
-				SearchManager searchManager =
-					(SearchManager) getActivity().getSystemService(Context.SEARCH_SERVICE);
-
 				this.searchMenuItem = menu.findItem(R.id.menu_search_contacts);
 				this.searchView = (SearchView) searchMenuItem.getActionView();
 
-				if (this.searchView != null && searchManager != null) {
-					SearchableInfo mSearchableInfo = searchManager.getSearchableInfo(getActivity().getComponentName());
-					if (this.searchView != null) {
-						if (!TestUtil.empty(filterQuery)) {
-							// restore filter
-							MenuItemCompat.expandActionView(searchMenuItem);
-							this.searchView.post(new Runnable() {
-								@Override
-								public void run() {
-									searchView.setQuery(filterQuery, true);
-									searchView.clearFocus();
-								}
-							});
-						}
-						this.searchView.setSearchableInfo(mSearchableInfo);
-						this.searchView.setQueryHint(getString(R.string.hint_filter_list));
-						this.searchView.setOnQueryTextListener(queryTextListener);
+				if (this.searchView != null) {
+					if (!TestUtil.empty(filterQuery)) {
+						// restore filter
+						MenuItemCompat.expandActionView(searchMenuItem);
+						this.searchView.post(new Runnable() {
+							@Override
+							public void run() {
+								searchView.setQuery(filterQuery, true);
+								searchView.clearFocus();
+							}
+						});
 					}
+					this.searchView.setQueryHint(getString(R.string.hint_filter_list));
+					this.searchView.setOnQueryTextListener(queryTextListener);
 				}
 			}
 		}
@@ -544,8 +594,23 @@ public class ContactsSectionFragment
 		}
 	};
 
+	private int getDesiredWorkTab(boolean isOnFirstLaunch, Bundle savedInstanceState) {
+		if (ConfigUtils.isWorkBuild()) {
+			if (isOnFirstLaunch) {
+				return TAB_WORK_ONLY; // may be overridden later if there are no work contacts
+			} else {
+				if (savedInstanceState != null) {
+					return savedInstanceState.getInt(BUNDLE_SELECTED_TAB, TAB_ALL_CONTACTS);
+				} else if (workTabLayout != null) {
+					return workTabLayout.getSelectedTabPosition();
+				}
+			}
+		}
+		return TAB_ALL_CONTACTS;
+	}
+
 	@SuppressLint("StaticFieldLeak")
-	protected void createListAdapter() {
+	protected void createListAdapter(final Bundle savedInstanceState) {
 		if (getActivity() == null) {
 			return;
 		}
@@ -554,11 +619,13 @@ public class ContactsSectionFragment
 			return;
 		}
 
-		new FetchContactsTask() {
+		final int[] desiredTabPosition = {getDesiredWorkTab(savedInstanceState == null, savedInstanceState)};
+
+		new FetchContactsTask(contactService, savedInstanceState == null, desiredTabPosition[0], false) {
 			@Override
-			protected void onPostExecute(Pair<List<ContactModel>, RecentlyAddedCounts> result) {
+			protected void onPostExecute(Pair<List<ContactModel>, FetchResults> result) {
 				final List<ContactModel> contactModels = result.first;
-				final RecentlyAddedCounts counts = result.second;
+				final FetchResults counts = result.second;
 				if (contactModels != null) {
 					updateContactsCounter(contactModels.size(), counts);
 					if (contactModels.size() > 0) {
@@ -576,9 +643,22 @@ public class ContactsSectionFragment
 						);
 						listView.setAdapter(contactListAdapter);
 					}
+
+					if (ConfigUtils.isWorkBuild()) {
+						if (savedInstanceState == null && desiredTabPosition[0] == TAB_WORK_ONLY && counts.workCount == 0) {
+							// fix selected tab as there is now work contact
+							desiredTabPosition[0] = TAB_ALL_CONTACTS;
+						}
+
+						if (desiredTabPosition[0] != workTabLayout.getSelectedTabPosition()) {
+							workTabLayout.removeOnTabSelectedListener(onTabSelectedListener);
+							workTabLayout.selectTab(workTabLayout.getTabAt(selectedTab));
+							workTabLayout.addOnTabSelectedListener(onTabSelectedListener);
+						}
+					}
 				}
 			}
-		}.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, this.contactService);
+		}.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
 	}
 
 	@SuppressLint("StaticFieldLeak")
@@ -588,23 +668,25 @@ public class ContactsSectionFragment
 			return;
 		}
 
+		int desiredTab = getDesiredWorkTab(false, null);
+
 		if (contactListAdapter != null) {
-			new FetchContactsTask() {
+			new FetchContactsTask(contactService, false, desiredTab, false) {
 				@Override
-				protected void onPostExecute(Pair<List<ContactModel>, RecentlyAddedCounts> result) {
+				protected void onPostExecute(Pair<List<ContactModel>, FetchResults> result) {
 					final List<ContactModel> contactModels = result.first;
-					final RecentlyAddedCounts counts = result.second;
+					final FetchResults counts = result.second;
 
 					if (contactModels != null && contactListAdapter != null && isAdded()) {
 						updateContactsCounter(contactModels.size(), counts);
 						contactListAdapter.updateData(contactModels);
 					}
 				}
-			}.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, this.contactService);
+			}.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
 		}
 	}
 
-	private void updateContactsCounter(int numContacts, @Nullable RecentlyAddedCounts counts) {
+	private void updateContactsCounter(int numContacts, @Nullable FetchResults counts) {
 		if (getActivity() != null && listView != null && isAdded()) {
 			if (contactsCounterChip != null) {
 				if (numContacts > 1) {
@@ -664,7 +746,7 @@ public class ContactsSectionFragment
 
 	@Override
 	public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
-		View fragmentView = getView();
+		View headerView, fragmentView = getView();
 
 		logger.debug("*** onCreateView");
 		if (fragmentView == null) {
@@ -734,7 +816,7 @@ public class ContactsSectionFragment
 			});
 
 			if (!ConfigUtils.isWorkBuild()) {
-				View headerView = View.inflate(getActivity(), R.layout.header_contact_section, null);
+				headerView = View.inflate(getActivity(), R.layout.header_contact_section, null);
 				listView.addHeaderView(headerView, null, false);
 
 				View footerView = View.inflate(getActivity(), R.layout.footer_contact_section, null);
@@ -747,6 +829,12 @@ public class ContactsSectionFragment
 						shareInvite();
 					}
 				});
+			} else {
+				headerView = View.inflate(getActivity(), R.layout.header_contact_section_work, null);
+				listView.addHeaderView(headerView, null, false);
+
+				workTabLayout = ((TabLayout) headerView.findViewById(R.id.tab_layout));
+				workTabLayout.addOnTabSelectedListener(onTabSelectedListener);
 			}
 
 			this.swipeRefreshLayout = fragmentView.findViewById(R.id.swipe_container);
@@ -765,6 +853,7 @@ public class ContactsSectionFragment
 
 			this.stickyInitialView = fragmentView.findViewById(R.id.initial_sticky);
 			this.stickyInitialLayout = fragmentView.findViewById(R.id.initial_sticky_layout);
+			this.stickyInitialLayout.setVisibility(View.GONE);
 		}
 		return fragmentView;
 	}
@@ -859,7 +948,7 @@ public class ContactsSectionFragment
 		}
 
 		// fill adapter with data
-		createListAdapter();
+		createListAdapter(savedInstanceState);
 
 		// register a receiver that will receive info about changed contacts from contact sync
 		IntentFilter filter = new IntentFilter();
@@ -935,6 +1024,9 @@ public class ContactsSectionFragment
 		if (!TestUtil.empty(filterQuery)) {
 			outState.putString(BUNDLE_FILTER_QUERY_C, filterQuery);
 		}
+		if (ConfigUtils.isWorkBuild() && workTabLayout != null) {
+			outState.putInt(BUNDLE_SELECTED_TAB, workTabLayout.getSelectedTabPosition());
+		}
 		super.onSaveInstanceState(outState);
 	}
 

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

@@ -25,8 +25,6 @@ import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.app.KeyguardManager;
-import android.app.SearchManager;
-import android.app.SearchableInfo;
 import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
@@ -61,6 +59,7 @@ import java.util.List;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import androidx.appcompat.widget.SearchView;
 import androidx.core.view.MenuItemCompat;
 import androidx.recyclerview.widget.DefaultItemAnimator;
@@ -512,26 +511,19 @@ public class MessageSectionFragment extends MainFragment
 				if (searchMenuItem == null) {
 					inflater.inflate(R.menu.fragment_messages, menu);
 
-					// Associate searchable configuration with the SearchView
 					if (activity != null && this.isAdded()) {
-						SearchManager searchManager = (SearchManager) activity.getSystemService(Context.SEARCH_SERVICE);
-
 						searchMenuItem = menu.findItem(R.id.menu_search_messages);
 						this.searchView = (SearchView) searchMenuItem.getActionView();
 
-						if (this.searchView != null && searchManager != null) {
-							SearchableInfo mSearchableInfo = searchManager.getSearchableInfo(activity.getComponentName());
-							if (this.searchView != null) {
-								if (!TestUtil.empty(filterQuery)) {
-									// restore filter
-									MenuItemCompat.expandActionView(searchMenuItem);
-									searchView.setQuery(filterQuery, false);
-									searchView.clearFocus();
-								}
-								this.searchView.setSearchableInfo(mSearchableInfo);
-								this.searchView.setQueryHint(getString(R.string.hint_filter_list));
-								this.searchView.setOnQueryTextListener(queryTextListener);
+						if (this.searchView != null) {
+							if (!TestUtil.empty(filterQuery)) {
+								// restore filter
+								MenuItemCompat.expandActionView(searchMenuItem);
+								searchView.setQuery(filterQuery, false);
+								searchView.clearFocus();
 							}
+							this.searchView.setQueryHint(getString(R.string.hint_filter_list));
+							this.searchView.setOnQueryTextListener(queryTextListener);
 						}
 					}
 				}
@@ -548,8 +540,7 @@ public class MessageSectionFragment extends MainFragment
 										requestUnhideChats();
 									} else {
 										preferenceService.setPrivateChatsHidden(true);
-										fireSecretReceiverUpdate();
-										updateList();
+										updateList(null, null, new Thread(() -> fireSecretReceiverUpdate()));
 									}
 									return true;
 								}
@@ -631,8 +622,7 @@ public class MessageSectionFragment extends MainFragment
 				if (resultCode == Activity.RESULT_OK) {
 					serviceManager.getScreenLockService().setAuthenticated(true);
 					preferenceService.setPrivateChatsHidden(false);
-					fireSecretReceiverUpdate();
-					updateList(0, null, null);
+					updateList(0, null, new Thread(() -> fireSecretReceiverUpdate()));
 				}
 				break;
 			case ID_RETURN_FROM_SECURITY_SETTINGS:
@@ -731,8 +721,7 @@ public class MessageSectionFragment extends MainFragment
 				}
 				updateHiddenMenuVisibility();
 				if (ConfigUtils.hasProtection(preferenceService) && preferenceService.isPrivateChatsHidden()) {
-					fireSecretReceiverUpdate();
-					updateList();
+					updateList(null, null, new Thread(() -> fireSecretReceiverUpdate()));
 				}
 			}
 		}.execute();
@@ -1617,29 +1606,10 @@ public class MessageSectionFragment extends MainFragment
 		//ignore distribution lists
 	}
 
+	@WorkerThread
 	private void fireSecretReceiverUpdate() {
 		//fire a update for every secret receiver (to update webclient data)
-		for(ConversationModel c: Functional.filter(this.conversationService.getAll(false, new ConversationService.Filter() {
-			@Override
-			public boolean onlyUnread() {
-				return false;
-			}
-
-			@Override
-			public boolean noDistributionLists() {
-				return false;
-			}
-
-			@Override
-			public boolean noHiddenChats() {
-				return false;
-			}
-
-			@Override
-			public boolean noInvalid() {
-				return false;
-			}
-		}), new IPredicateNonNull<ConversationModel>() {
+		for(ConversationModel c: Functional.filter(this.conversationService.getAll(false, null), new IPredicateNonNull<ConversationModel>() {
 			@Override
 			public boolean apply(ConversationModel conversationModel) {
 				return conversationModel != null && hiddenChatsListService.has(conversationModel.getReceiver().getUniqueIdString());

+ 21 - 8
app/src/main/java/ch/threema/app/fragments/RecipientListFragment.java

@@ -61,6 +61,7 @@ import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.CheckableConstraintLayout;
 import ch.threema.app.ui.CheckableRelativeLayout;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LogUtil;
@@ -166,7 +167,12 @@ public abstract class RecipientListFragment extends ListFragment implements List
 
 		if (isMultiSelectAllowed()) {
 			getListView().setOnItemLongClickListener(this);
-			floatingActionButton.setOnClickListener(v -> onFloatingActionButtonClick());
+			floatingActionButton.setOnClickListener(new DebouncedOnClickListener(500) {
+				@Override
+				public void onDebouncedClick(View v) {
+					onFloatingActionButtonClick();
+				}
+			});
 		} else {
 			floatingActionButton.hide();
 		}
@@ -207,16 +213,18 @@ public abstract class RecipientListFragment extends ListFragment implements List
 
 	private void startMultiSelect() {
 		getListView().setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
-		floatingActionButton.show();
 		if (isVisible) {
 			snackbar = SnackbarUtil.make(topLayout, "", Snackbar.LENGTH_INDEFINITE, 4);
-			snackbar.getView().setBackgroundColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
+			snackbar.setBackgroundTint(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
+			snackbar.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorOnSecondary));
 			snackbar.getView().getLayoutParams().width = AppBarLayout.LayoutParams.MATCH_PARENT;
-			TextView textView = snackbar.getView().findViewById(com.google.android.material.R.id.snackbar_text);
-			if (textView != null) {
-				textView.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorOnSecondary));
-			}
 			snackbar.show();
+			snackbar.getView().post(new Runnable() {
+				@Override
+				public void run() {
+					floatingActionButton.show();
+				}
+			});
 		}
 	}
 
@@ -232,10 +240,15 @@ public abstract class RecipientListFragment extends ListFragment implements List
 
 	private void stopMultiSelect() {
 		getListView().setChoiceMode(AbsListView.CHOICE_MODE_NONE);
-		floatingActionButton.hide();
 		if (snackbar != null && snackbar.isShown()) {
 			snackbar.dismiss();
 		}
+		floatingActionButton.postDelayed(new Runnable() {
+			@Override
+			public void run() {
+				floatingActionButton.hide();
+			}
+		}, 100);
 	}
 
 	private void onItemClick(View v) {

+ 5 - 3
app/src/main/java/ch/threema/app/fragments/mediaviews/MediaViewFragment.java

@@ -55,6 +55,8 @@ import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageType;
 
+import static ch.threema.storage.models.data.MessageContentsType.VOICE_MESSAGE;
+
 abstract public class MediaViewFragment extends Fragment {
 	private static final Logger logger = LoggerFactory.getLogger(MediaViewFragment.class);
 
@@ -200,12 +202,12 @@ abstract public class MediaViewFragment extends Fragment {
 			String filename = null;
 
 			if (thumbnail == null) {
-				if (messageModel.getType() == MessageType.FILE) {
+				if (messageModel.getMessageContentsType() == VOICE_MESSAGE) {
+					thumbnail = BitmapUtil.getBitmapFromVectorDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.ic_keyboard_voice_outline), getResources().getColor(R.color.material_dark_grey));
+				} else if (messageModel.getType() == MessageType.FILE) {
 					thumbnail = BitmapUtil.tintImage(fileService.getDefaultMessageThumbnailBitmap(getContext(), messageModel, null, messageModel.getFileData().getMimeType()), getResources().getColor(R.color.material_dark_grey));
 					filename = messageModel.getFileData().getFileName();
 					isGeneric = true;
-				} else if (messageModel.getType() == MessageType.VOICEMESSAGE) {
-					thumbnail = BitmapUtil.getBitmapFromVectorDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.ic_keyboard_voice_outline), getResources().getColor(R.color.material_dark_grey));
 				}
 			}
 

+ 7 - 0
app/src/main/java/ch/threema/app/listeners/GroupListener.java

@@ -22,6 +22,7 @@
 package ch.threema.app.listeners;
 
 import androidx.annotation.AnyThread;
+import ch.threema.app.services.GroupService;
 import ch.threema.storage.models.GroupModel;
 
 public interface GroupListener {
@@ -43,4 +44,10 @@ public interface GroupListener {
 	 * User left his own group.
 	 */
 	@AnyThread default void onLeave(GroupModel groupModel) { }
+
+	/**
+	 * Group State has possibly changed
+	 * Note that oldState may be equal to newState
+	 */
+	@AnyThread default void onGroupStateChanged(GroupModel groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) { };
 }

+ 45 - 0
app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.java

@@ -0,0 +1,45 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2020-2021 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.mediaattacher;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import ch.threema.app.R;
+
+public class DummyPreviewFragment extends PreviewFragment {
+
+	DummyPreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel){
+		super(mediaItem, mediaAttachViewModel);
+	}
+
+	@Override
+	public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+		this.rootView = inflater.inflate(R.layout.fragment_dummy_preview, container, false);
+
+		return rootView;
+	}
+}

+ 3 - 4
app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java

@@ -47,9 +47,8 @@ public class ImagePreviewFragment extends PreviewFragment {
 	private GifImageView gifView;
 	private SubsamplingScaleImageView imageView;
 
-	ImagePreviewFragment(MediaAttachItem mediaItem){
-		super();
-		this.mediaItem = mediaItem;
+	ImagePreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel){
+		super(mediaItem, mediaAttachViewModel);
 	}
 
 	@Override
@@ -85,7 +84,7 @@ public class ImagePreviewFragment extends PreviewFragment {
 
 						@Override
 						public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
-							if (resource != null && resource instanceof BitmapDrawable) {
+							if (resource instanceof BitmapDrawable) {
 								imageView.setImage(ImageSource.bitmap(((BitmapDrawable) resource).getBitmap()));
 							}
 						}

+ 34 - 21
app/src/main/java/ch/threema/app/mediaattacher/MediaPreviewActivity.java → app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2020-2021 Threema GmbH
+ * Copyright (c) 2021 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,
@@ -21,55 +21,68 @@
 
 package ch.threema.app.mediaattacher;
 
-import android.content.Intent;
 import android.os.Bundle;
 import android.widget.Toast;
 
+import java.util.ArrayList;
+import java.util.List;
+
+import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
-import ch.threema.app.R;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.ui.MediaItem;
 
-/**
- * The full-screen media preview that's shown when long-pressing on an item in the media attacher.
- */
-public class MediaPreviewActivity extends FragmentActivity {
-	public static String EXTRA_PARCELABLE_MEDIA_ITEM = "media_item";
+public class ImagePreviewPagerAdapter extends FragmentStateAdapter {
+	private final MediaAttachViewModel mediaAttachViewModel;
+	private List<MediaAttachItem> mediaAttachItems = new ArrayList<>();
 
-	@Override
-	protected void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-		setContentView(R.layout.activity_media_preview);
+	public ImagePreviewPagerAdapter(FragmentActivity mediaSelectionBaseActivity) {
+		super(mediaSelectionBaseActivity);
 
-		Intent intent = getIntent();
-		MediaAttachItem mediaAttachItem = intent.getParcelableExtra(EXTRA_PARCELABLE_MEDIA_ITEM);
+		this.mediaAttachViewModel = new ViewModelProvider(mediaSelectionBaseActivity).get(MediaAttachViewModel.class);
+	}
 
+	@NonNull
+	@Override
+	public Fragment createFragment(int position) {
+		MediaAttachItem mediaAttachItem = getItem(position);
 		if (mediaAttachItem != null) {
 			int mimeType = mediaAttachItem.getType();
 			Bundle args = new Bundle();
 			args.putBoolean(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, true);
 
-			Fragment fragment = null;
+			PreviewFragment fragment = null;
 			if (mimeType == MediaItem.TYPE_IMAGE || mimeType == MediaItem.TYPE_GIF) {
-				fragment = new ImagePreviewFragment(mediaAttachItem);
+				fragment = new ImagePreviewFragment(mediaAttachItem, mediaAttachViewModel);
 			} else if (mimeType == MediaItem.TYPE_VIDEO) {
-				fragment = new VideoPreviewFragment(mediaAttachItem);
+				fragment = new VideoPreviewFragment(mediaAttachItem, mediaAttachViewModel);
 			}
 
 			if (fragment != null) {
 				fragment.setArguments(args);
-				getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, fragment).commit();
+				return fragment;
 			} else {
 				Toast.makeText(ThreemaApplication.getAppContext(), "Unrecognized Preview Format", Toast.LENGTH_SHORT).show();
 			}
 		}
+		return new DummyPreviewFragment(mediaAttachItem, mediaAttachViewModel);
+	}
+
+	public void setMediaItems(List<MediaAttachItem> items) {
+		mediaAttachItems = items;
+		notifyDataSetChanged();
 	}
 
 	@Override
-	public void finish() {
-		super.finish();
-		overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
+	public int getItemCount() {
+		return mediaAttachItems.size();
+	}
+
+	public MediaAttachItem getItem(int position) {
+		return mediaAttachItems.get(position);
 	}
 }

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

@@ -559,9 +559,10 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
 			if (MimeUtil.isVideoFile(mimeType) || MimeUtil.isImageFile(mimeType)) {
 				try {
+					logger.info("Number of taken persistable uri permissions" + getContentResolver().getPersistedUriPermissions().size());
 					getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
 				} catch (Exception e) {
-					logger.info("Unable to take persistable uri permission");
+					logger.info("Unable to take persistable uri permission ", e);
 					uri = FileUtil.getFileUri(uri);
 				}
 
@@ -595,9 +596,11 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 
 		for (Uri uri : list) {
 			try {
+				// log the number of permissions due to limit https://commonsware.com/blog/2020/06/13/count-your-saf-uri-permission-grants.html
+				logger.info("Number of taken persistable uri permissions" + getContentResolver().getPersistedUriPermissions().size());
 				getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
 			} catch (Exception e) {
-				logger.info("Unable to take persistable uri permission");
+				logger.info("Unable to take persistable uri permission ", e);
 				uri = FileUtil.getFileUri(uri);
 			}
 

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

@@ -135,6 +135,7 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 					.addListener(new RequestListener<Drawable>() {
 						@Override
 						public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
+							logger.error("Glide Loading Exception ", e);
 							loadErrorIndicator.setVisibility(View.VISIBLE);
 							gifIndicator.setVisibility(View.GONE);
 							videoIndicator.setVisibility(View.GONE);
@@ -155,7 +156,7 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 
 							contentView.setOnLongClickListener(view -> {
 								clickListener.onItemLongClick(view, holder.getAdapterPosition(), mediaAttachItem);
-								return false;
+								return true;
 							});
 
 							if (mediaAttachItem.getType() == MediaItem.TYPE_GIF) {

+ 1 - 0
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java

@@ -172,6 +172,7 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 
 	@Override
 	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		if (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
 			switch (requestCode) {
 				case PERMISSION_REQUEST_ATTACH_FILE:

+ 196 - 18
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java

@@ -63,6 +63,7 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.TreeMap;
 
+import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.appcompat.content.res.AppCompatResources;
@@ -77,6 +78,7 @@ import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager2.widget.ViewPager2;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.EnterSerialActivity;
@@ -87,10 +89,10 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.CheckableFrameLayout;
+import ch.threema.app.ui.CheckableView;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.MediaGridItemDecoration;
 import ch.threema.app.ui.MediaItem;
-import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.IntentDataUtil;
@@ -118,35 +120,47 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected GroupService groupService;
 
 	public static final String KEY_BOTTOM_SHEET_STATE = "bottom_sheet_state";
+	public static final String KEY_PREVIEW_MODE = "preview_mode";
+	private static final String KEY_PREVIEW_ITEM_POSITION = "preview_item";
+
 	protected static final int PERMISSION_REQUEST_ATTACH_FROM_GALLERY = 4;
 	protected static final int PERMISSION_REQUEST_ATTACH_FILE = 5;
 	protected static final int REQUEST_CODE_ATTACH_FROM_GALLERY = 2454;
 
-	protected CoordinatorLayout rootView;
+	protected CoordinatorLayout rootView, gridContainer, pagerContainer;
 	protected AppBarLayout appBarLayout;
-	protected MaterialToolbar toolbar;
+	protected MaterialToolbar toolbar, previewToolbar;
 	protected EmptyRecyclerView mediaAttachRecyclerView;
 	protected FastScroller fastScroller;
 	protected GridLayoutManager gridLayoutManager;
-	protected ConstraintLayout bottomSheetLayout;
+	protected ConstraintLayout bottomSheetLayout, previewBottomSheetLayout;
 	protected ImageView dragHandle;
 	protected FrameLayout controlPanel, dateView;
 	protected LinearLayout menuTitleFrame;
-	protected TextView dateTextView, menuTitle;
+	protected TextView dateTextView, menuTitle, previewFilenameTextView, previewDateTextView;
 	protected DisplayMetrics displayMetrics;
 	protected MenuItem selectFromGalleryItem;
 	protected PopupMenu bucketFilterMenu;
+	protected ViewPager2 previewPager;
+	private CheckableView checkBox;
 
 	protected MediaAttachViewModel mediaAttachViewModel;
 
 	protected MediaAttachAdapter mediaAttachAdapter;
+	protected ImagePreviewPagerAdapter imagePreviewPagerAdapter;
+
 	protected int peekHeightNumElements = 1;
+	private @ColorInt int savedStatusBarColor = 0;
 
 	private boolean isDragging = false;
 	private boolean bottomSheetScroll = false;
+	private boolean isPreviewMode = false;
+
+	BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior, previewBottomSheetBehavior;
 
 	// Locks
 	private final Object filterMenuLock = new Object();
+	private final Object previewLock = new Object();
 
 	/* start lifecycle methods */
 	@Override
@@ -160,24 +174,35 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		initActivity(savedInstanceState);
 	}
 
+	@Override
+	protected void onDestroy() {
+		logger.debug("*** onDestroy");
+		super.onDestroy();
+	}
+
 	@UiThread
 	protected void handleSavedInstanceState(Bundle savedInstanceState){
 		if (savedInstanceState != null) {
 			onItemChecked(mediaAttachViewModel.getSelectedMediaItemsHashMap().size());
 			int bottomSheetStyleState = savedInstanceState.getInt(KEY_BOTTOM_SHEET_STATE);
 			if (bottomSheetStyleState != 0) {
-				final BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
-				bottomSheetBehavior.setState(bottomSheetStyleState);
 				updateUI(bottomSheetStyleState);
 			}
+			boolean previewModeState = savedInstanceState.getBoolean(KEY_PREVIEW_MODE);
+			int previewItemPosition = savedInstanceState.getInt(KEY_PREVIEW_ITEM_POSITION);
+
+			if (previewModeState) {
+				startPreviewMode(previewItemPosition, 50);
+			}
 		}
 	}
 
 	@Override
 	protected void onSaveInstanceState(Bundle outState) {
 		super.onSaveInstanceState(outState);
-		final BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
 		outState.putInt(KEY_BOTTOM_SHEET_STATE, bottomSheetBehavior.getState());
+		outState.putBoolean(KEY_PREVIEW_MODE, isPreviewMode);
+		outState.putInt(KEY_PREVIEW_ITEM_POSITION, previewPager.getCurrentItem());
 	}
 
 	/* end lifecycle methods */
@@ -188,7 +213,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		this.displayMetrics = ThreemaApplication.getAppContext().getResources().getDisplayMetrics();
 
 		// The view model handles data associated with this view
-		this.mediaAttachViewModel = new ViewModelProvider(MediaSelectionBaseActivity.this).get(MediaAttachViewModel.class);
+		this.mediaAttachViewModel = new ViewModelProvider(this).get(MediaAttachViewModel.class);
 
 		// Initialize UI
 		this.setLayout();
@@ -204,6 +229,12 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			}
 			return false;
 		});
+		this.toolbar.setNavigationOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				collapseBottomSheet();
+			}
+		});
 	}
 
 	protected void initServices() {
@@ -228,11 +259,42 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		this.menuTitleFrame = findViewById(R.id.toolbar_title);
 		this.menuTitle = findViewById(R.id.toolbar_title_textview);
 		this.bottomSheetLayout = findViewById(R.id.bottom_sheet);
+		this.previewBottomSheetLayout = findViewById(R.id.preview_bottom_sheet);
 		this.mediaAttachRecyclerView = findViewById(R.id.media_grid_recycler);
 		this.dragHandle = findViewById(R.id.drag_handle);
 		this.controlPanel = findViewById(R.id.control_panel);
 		this.dateView = findViewById(R.id.date_separator_container);
 		this.dateTextView = findViewById(R.id.text_view);
+		this.gridContainer = findViewById(R.id.grid_container);
+		this.previewPager = findViewById(R.id.pager);
+		this.pagerContainer = findViewById(R.id.pager_container);
+		this.checkBox = findViewById(R.id.check_box);
+		this.previewFilenameTextView = findViewById(R.id.filename_view);
+		this.previewDateTextView = findViewById(R.id.date_view);
+
+		this.bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
+		this.previewBottomSheetBehavior = BottomSheetBehavior.from(previewBottomSheetLayout);
+
+		MaterialToolbar previewToolbar = findViewById(R.id.preview_toolbar);
+		previewToolbar.setNavigationOnClickListener(v -> onBackPressed());
+
+		this.checkBox.setOnClickListener(v -> {
+			checkBox.toggle();
+			MediaAttachItem mediaItem = imagePreviewPagerAdapter.getItem(previewPager.getCurrentItem());
+			if (checkBox.isChecked()) {
+				mediaAttachViewModel.addSelectedMediaItem(mediaItem.getId(), mediaItem);
+			} else {
+				mediaAttachViewModel.removeSelectedMediaItem(mediaItem.getId());
+			}
+		});
+
+		this.previewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+			@Override
+			public void onPageSelected(int position) {
+				super.onPageSelected(position);
+				updatePreviewInfo(position);
+			}
+		});
 
 		// fill background with transparent black to see chat behind drawer
 		FitWindowsFrameLayout contentFrameLayout = (FitWindowsFrameLayout) ((ViewGroup) rootView.getParent()).getParent();
@@ -276,6 +338,8 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 		// Listen for layout changes
 		this.mediaAttachAdapter = new MediaAttachAdapter(this, this);
+		this.imagePreviewPagerAdapter = new ImagePreviewPagerAdapter(this);
+		this.previewPager.setOffscreenPageLimit(1);
 		this.mediaAttachRecyclerView.addItemDecoration(new MediaGridItemDecoration(getResources().getDimensionPixelSize(R.dimen.grid_spacing)));
 		this.mediaAttachRecyclerView.setAdapter(mediaAttachAdapter);
 		ProgressBar progressBar = (ProgressBar) getLayoutInflater().inflate(R.layout.item_progress, null);
@@ -422,6 +486,28 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		});
 	}
 
+	private void updatePreviewInfo(int position) {
+		MediaAttachItem mediaItem = imagePreviewPagerAdapter.getItem(position);
+		checkBox.setChecked(mediaAttachViewModel.getSelectedMediaItemsHashMap().containsKey(mediaItem.getId()));
+
+		previewBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
+
+		previewFilenameTextView.setText(String.format("%s/%s", mediaItem.getBucketName(), mediaItem.getDisplayName()));
+		long taken = mediaItem.getDateTaken();
+		//multiply because of format takes millis
+		long modified = mediaItem.getDateModified() * 1000;
+		long added = mediaItem.getDateAdded() * 1000;
+		if (taken != 0) {
+			previewDateTextView.setText(String.format(getString(R.string.media_date_taken), LocaleUtil.formatTimeStampString(this, taken, false)));
+		} else if (added != 0) {
+			previewDateTextView.setText(String.format(getString(R.string.media_date_added), LocaleUtil.formatTimeStampString(this, added, false)));
+		} else if (modified != 0) {
+			previewDateTextView.setText(String.format(getString(R.string.media_date_modified), LocaleUtil.formatTimeStampString(this, modified, false)));
+		} else {
+			previewDateTextView.setText(getString(R.string.media_date_unknown));
+		}
+	}
+
 	/**
 	 * If the media grid is enabled and all necessary permissions are granted,
 	 * initialize and show it.
@@ -466,6 +552,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			// Observe the LiveData for current selection, passing in this activity as the LifecycleOwner and Observer.
 			mediaAttachViewModel.getCurrentMedia().observe(this, currentlyShowingItems -> {
 				mediaAttachAdapter.setMediaItems(currentlyShowingItems);
+				imagePreviewPagerAdapter.setMediaItems(currentlyShowingItems);
 				// Data loaded, we can now properly calculate the peek height and set/reset UI to expanded state
 				updatePeekHeight();
 			});
@@ -484,7 +571,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected void setListeners() {
 		this.appBarLayout.setOnClickListener(this);
 
-		BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
 		bottomSheetBehavior.setExpandedOffset(50);
 		bottomSheetBehavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
 			@Override
@@ -503,7 +589,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
 				super.onScrolled(recyclerView, dx, dy);
 				setFirstVisibleItemDate();
-				final BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
 
 				if (controlPanel.getTranslationY() == 0 && mediaAttachViewModel.getSelectedMediaItemsHashMap().isEmpty() && bottomSheetBehavior.getState() == STATE_EXPANDED) {
 					controlPanel.animate().translationY(controlPanel.getHeight());
@@ -527,9 +612,77 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	public void onItemLongClick(View view, int position, MediaAttachItem mediaAttachItem) {
 		view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
 
-		Intent intent = new Intent(this, MediaPreviewActivity.class);
-		intent.putExtra(MediaPreviewActivity.EXTRA_PARCELABLE_MEDIA_ITEM, mediaAttachItem);
-		AnimationUtil.startActivity(this, view, intent);
+		startPreviewMode(position, 0);
+	}
+
+	@Override
+	public void onBackPressed() {
+		logger.debug("*** onBackPressed");
+		synchronized (previewLock) {
+			if (pagerContainer.getVisibility() == View.VISIBLE) {
+				if (isPreviewMode) {
+					gridContainer.setVisibility(View.VISIBLE);
+					pagerContainer.setVisibility(View.GONE);
+					previewPager.setAdapter(null);
+					mediaAttachAdapter.notifyDataSetChanged();
+					onItemChecked(mediaAttachViewModel.getSelectedMediaItemsHashMap().size());
+
+					if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+						getWindow().setStatusBarColor(savedStatusBarColor);
+						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+							getWindow().setNavigationBarColor(ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_expanded));
+						}
+					}
+					if (ConfigUtils.getAppTheme(this) != ConfigUtils.THEME_DARK) {
+						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+							getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+						}
+					}
+					isPreviewMode = false;
+				}
+			} else {
+				super.onBackPressed();
+			}
+		}
+	}
+
+	private void startPreviewMode(int position, int delay) {
+		logger.debug("*** startPreviewMode");
+		synchronized (previewLock) {
+			if (!isPreviewMode) {
+				pagerContainer.setVisibility(View.VISIBLE);
+				previewPager.setAdapter(imagePreviewPagerAdapter);
+				gridContainer.setVisibility(View.GONE);
+
+				logger.debug("*** setStatusBarColor");
+
+				toolbar.postDelayed(new Runnable() {
+					@Override
+					public void run() {
+						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+							savedStatusBarColor = getWindow().getStatusBarColor();
+							getWindow().setStatusBarColor(getResources().getColor(R.color.gallery_background));
+							if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+								getWindow().setNavigationBarColor(getResources().getColor(R.color.gallery_background));
+							}
+						}
+						if (ConfigUtils.getAppTheme(MediaSelectionBaseActivity.this) != ConfigUtils.THEME_DARK) {
+							if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+								getWindow().getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+							}
+						}
+					}
+				}, delay);
+
+				previewPager.post(new Runnable() {
+					@Override
+					public void run() {
+						previewPager.setCurrentItem(position, false);
+					}
+				});
+				isPreviewMode = true;
+			}
+		}
 	}
 
 	public void setAllResultsGrid() {
@@ -585,6 +738,11 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 	public void updateUI(int state){
 		Animation animation;
+
+		if (bottomSheetBehavior.getState() != state) {
+			bottomSheetBehavior.setState(state);
+		}
+
 		switch (state) {
 			case STATE_HIDDEN:
 				finish();
@@ -669,6 +827,8 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				}
 				break;
 			case STATE_COLLAPSED:
+				bottomSheetBehavior.setDraggable(true);
+				bottomSheetScroll = true;
 				dateView.setVisibility(View.GONE);
 				bucketFilterMenu.getMenu().setGroupVisible(Menu.NONE, false);
 				menuTitleFrame.setClickable(false);
@@ -684,7 +844,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	 * number of items vertically.
 	 */
 	protected synchronized void updatePeekHeight() {
-		final BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
+		logger.debug("*** updatePeekHeight");
 
 		if (shouldShowMediaGrid()) {
 			final int numElements = this.peekHeightNumElements;
@@ -721,7 +881,10 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				peekHeightKnown = false;
 				logger.debug("Peek height could not yet be determined, no items found");
 			}
-			bottomSheetBehavior.setPeekHeight(peekHeight);
+
+			if (bottomSheetBehavior != null) {
+				bottomSheetBehavior.setPeekHeight(peekHeight);
+			}
 
 			// Recalculate the peek height when the layout changes the next time
 			if (!peekHeightKnown) {
@@ -821,8 +984,23 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	}
 
 	protected void expandBottomSheet() {
-		BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
-		bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
 		updateUI(BottomSheetBehavior.STATE_EXPANDED);
 	}
+
+	protected void collapseBottomSheet() {
+		Animation animation = toolbar.getAnimation();
+		if (animation != null) {
+			animation.cancel();
+		}
+
+		dragHandle.setVisibility(View.VISIBLE);
+		toolbar.setVisibility(View.GONE);
+		toolbar.post(() -> {
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+				getWindow().setStatusBarColor(ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_collapsed));
+			}
+		});
+
+		updateUI(STATE_COLLAPSED);
+	}
 }

+ 9 - 27
app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java

@@ -25,49 +25,31 @@ import android.content.Context;
 import android.media.AudioManager;
 import android.os.Bundle;
 import android.view.View;
-import android.widget.TextView;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
-import ch.threema.app.utils.LocaleUtil;
 
 public abstract class PreviewFragment extends Fragment implements AudioManager.OnAudioFocusChangeListener, PreviewFragmentInterface.AudioFocusActions {
 	private AudioManager audioManager;
-	protected TextView filenameTextView, dateTextView;
 	protected MediaAttachItem mediaItem;
+	protected MediaAttachViewModel mediaAttachViewModel;
 	protected View rootView;
+	protected boolean isChecked = false;
 
-	@Override
-	public void onCreate(@Nullable Bundle savedInstanceState) {
-		this.audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
+	public PreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
+		this.mediaItem = mediaItem;
+		this.mediaAttachViewModel = mediaAttachViewModel;
 
-		super.onCreate(savedInstanceState);
+		setRetainInstance(true);
 	}
 
 	@Override
-	public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-		super.onViewCreated(view, savedInstanceState);
-
-		this.filenameTextView = rootView.findViewById(R.id.filename_view);
-		this.dateTextView = rootView.findViewById(R.id.date_view);
+	public void onCreate(@Nullable Bundle savedInstanceState) {
+		this.audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
 
-		filenameTextView.setText(String.format("%s/%s", mediaItem.getBucketName(), mediaItem.getDisplayName()));
-		long taken = mediaItem.getDateTaken();
-		//multiply because of format takes millis
-		long modified = mediaItem.getDateModified() * 1000;
-		long added = mediaItem.getDateAdded() * 1000;
-		if (taken != 0) {
-			dateTextView.setText(String.format(getString(R.string.media_date_taken), LocaleUtil.formatTimeStampString(getContext(), taken, false)));
-		} else if (added != 0) {
-			dateTextView.setText(String.format(getString(R.string.media_date_added), LocaleUtil.formatTimeStampString(getContext(), added, false)));
-		} else if (modified != 0) {
-			dateTextView.setText(String.format(getString(R.string.media_date_modified), LocaleUtil.formatTimeStampString(getContext(), modified, false)));
-		} else {
-			dateTextView.setText(getString(R.string.media_date_unknown));
-		}
+		super.onCreate(savedInstanceState);
 	}
 
 	@Override

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

@@ -50,14 +50,14 @@ public class VideoPreviewFragment extends PreviewFragment implements DefaultLife
 	private ZoomableExoPlayerView videoView;
 	private SimpleExoPlayer videoPlayer;
 
-	VideoPreviewFragment(MediaAttachItem mediaItem){
-		super();
-		this.mediaItem = mediaItem;
+	VideoPreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel){
+		super(mediaItem, mediaAttachViewModel);
 	}
 
 	@Override
 	public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-		this.rootView = inflater.inflate(R.layout.popup_video, container, false);
+		this.rootView = inflater.inflate(R.layout.fragment_video_preview, container, false);
+
 		this.getViewLifecycleOwner().getLifecycle().addObserver(this);
 
 		return this.rootView;
@@ -91,7 +91,7 @@ public class VideoPreviewFragment extends PreviewFragment implements DefaultLife
 
 	@Override
 	public void onPause(@NonNull LifecycleOwner owner) {
-		if (this.videoPlayer != null && this.videoPlayer.isPlaying()) {
+		if (this.videoPlayer != null) {
 			this.videoPlayer.pause();
 		}
 	}

+ 57 - 47
app/src/main/java/ch/threema/app/routines/CheckLicenseRoutine.java

@@ -86,20 +86,18 @@ public class CheckLicenseRoutine implements Runnable {
 	}
 	@Override
 	public void run() {
-		if(this.deviceService.isOnline()) {
-			switch(BuildFlavor.getLicenseType()) {
-				case GOOGLE:
-					this.checkLVL();
-					break;
-				case SERIAL:
-				case GOOGLE_WORK:
-				case HMS_WORK:
-					this.checkSerial();
-					break;
-				case HMS:
-					this.checkDRM();
-					break;
-			}
+		switch(BuildFlavor.getLicenseType()) {
+			case GOOGLE:
+				this.checkLVL();
+				break;
+			case SERIAL:
+			case GOOGLE_WORK:
+			case HMS_WORK:
+				this.checkSerial();
+				break;
+			case HMS:
+				this.checkDRM();
+				break;
 		}
 	}
 
@@ -152,14 +150,19 @@ public class CheckLicenseRoutine implements Runnable {
 					logger.info("HMS License OK");
 					userService.setPolicyResponse(
 						signData,
-						signature
+						signature,
+						0
 					);
 				}
 
 				@Override
 				public void onCheckFailed(int errorCode) {
 					logger.debug("HMS License failed errorCode: {}", errorCode);
-
+					userService.setPolicyResponse(
+						null,
+						null,
+						errorCode
+					);
 				}
 			};
 			Drm.check((Activity) context, context.getPackageName(), HMS_ID, HMS_PUBLIC_KEY, callback);
@@ -167,40 +170,47 @@ public class CheckLicenseRoutine implements Runnable {
 	}
 
 	private void checkLVL() {
-		logger.debug("Check GCM licence");
-		if(this.deviceService.isOnline()) {
-			final ThreemaLicensePolicy policy = new ThreemaLicensePolicy();
-			LicenseCheckerCallback callback = new LicenseCheckerCallback() {
-				@Override
-				public void allow(int reason) {
-					logger.debug("GCM License OK");
-					userService.setPolicyResponse(
-							policy.getLastResponseData().responseData,
-							policy.getLastResponseData().signature
-					);
-				}
-
-				@Override
-				public void dontAllow(int reason) {
-					logger.debug("not allowed (code " + reason + ")");
-					//if (reason == ThreemaLicensePolicy.NOT_LICENSED) {
-					//	invalidLicense("Not licensed (code " + reason + ")");
-					//}
-				}
+		logger.debug("Checking LVL licence");
+		final ThreemaLicensePolicy policy = new ThreemaLicensePolicy();
+		LicenseCheckerCallback callback = new LicenseCheckerCallback() {
+			@Override
+			public void allow(int reason) {
+				logger.debug("LVL License OK");
+				userService.setPolicyResponse(
+						policy.getLastResponseData().responseData,
+						policy.getLastResponseData().signature,
+						0
+				);
+			}
 
-				@Override
-				public void applicationError(int errorCode) {
-					logger.debug("GCM License check failed errorCode: {}", errorCode);
-					//invalidLicense("License check failed (code " + errorCode + ")");
-				}
-			};
-			LicenseChecker licenseChecker = new LicenseChecker(this.context, policy, LICENSE_PUBLIC_KEY);
-			try {
-				licenseChecker.checkAccess(callback);
+			@Override
+			public void dontAllow(int reason) {
+				// 561 == not licensed
+				// 291 == no connection
+				logger.debug("LVL License not allowed (code {})", reason);
+				userService.setPolicyResponse(
+					null,
+					null,
+					reason
+				);
 			}
-			catch (ReceiverCallNotAllowedException x) {
-				logger.error("LVL: Receiver call not allowed", x);
+
+			@Override
+			public void applicationError(int errorCode) {
+				logger.debug("LVL License check failed errorCode: {}", errorCode);
+				userService.setPolicyResponse(
+					null,
+					null,
+					errorCode
+				);
 			}
+		};
+		LicenseChecker licenseChecker = new LicenseChecker(this.context, policy, LICENSE_PUBLIC_KEY);
+		try {
+			licenseChecker.checkAccess(callback);
+		}
+		catch (ReceiverCallNotAllowedException x) {
+			logger.error("LVL: Receiver call not allowed", x);
 		}
 	}
 }

+ 0 - 1
app/src/main/java/ch/threema/app/routines/UpdateBusinessAvatarRoutine.java

@@ -51,7 +51,6 @@ import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME;
  */
 public class UpdateBusinessAvatarRoutine implements Runnable {
 	private static final Logger logger = LoggerFactory.getLogger(UpdateBusinessAvatarRoutine.class);
-	private static final String TAG = "UpdateBusinessAvatarRoutine";
 
 	private final ContactService contactService;
 	private FileService fileService;

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

@@ -31,8 +31,8 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.InvalidEntryException;
-import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.exceptions.PolicyViolationException;
+import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.VerificationLevel;
 import ch.threema.client.AbstractMessage;
@@ -111,6 +111,7 @@ public interface ContactService extends AvatarService<ContactModel> {
 	List<ContactModel> getByIdentities(List<String> identities);
 
 	List<ContactModel> getIsWork();
+	int countIsWork();
 
 	List<ContactModel> getCanReceiveProfilePics();
 

+ 19 - 13
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -112,7 +112,7 @@ import ch.threema.storage.models.access.AccessModel;
 public class ContactServiceImpl implements ContactService {
 	private static final Logger logger = LoggerFactory.getLogger(ContactServiceImpl.class);
 
-	private static final int TYPING_TIMEOUT = 60*1000;
+	private static final int TYPING_RECEIVE_TIMEOUT = (int) DateUtils.MINUTE_IN_MILLIS;
 
 	private final Context context;
 	private final AvatarCacheService avatarCacheService;
@@ -295,17 +295,6 @@ public class ContactServiceImpl implements ContactService {
 				queryBuilder.appendWhere(ContactModel.COLUMN_IS_HIDDEN + "=0");
 			}
 
-/*			final Integer featureLevel = filter.requiredFeature();
-
-			if(featureLevel != null) {
-				if(filter.fetchMissingFeatureLevel() == null
-						|| !filter.fetchMissingFeatureLevel()) {
-					//filtering with sql
-					queryBuilder.appendWhere(ContactModel.COLUMN_FEATURE_LEVEL + ">=?");
-					placeholders.add(featureLevel.toString());
-				}
-			}
-*/
 			if (!filter.includeMyself() && getMe() != null) {
 				queryBuilder.appendWhere(ContactModel.COLUMN_IDENTITY + "!=?");
 				placeholders.add(getMe().getIdentity());
@@ -494,6 +483,23 @@ public class ContactServiceImpl implements ContactService {
 		});
 	}
 
+	@Override
+	public int countIsWork() {
+		int count = 0;
+		Cursor c = this.databaseServiceNew.getReadableDatabase().rawQuery(
+			"SELECT COUNT(*) FROM contacts " +
+			"WHERE " + ContactModel.COLUMN_IS_WORK + " = 1 " +
+			"AND " + ContactModel.COLUMN_IS_HIDDEN + " = 0", null);
+
+		if (c != null) {
+			if(c.moveToFirst()) {
+				count = c.getInt(0);
+			}
+			c.close();
+		}
+		return count;
+	}
+
 	@Override
 	public List<ContactModel> getCanReceiveProfilePics() {
 		return Functional.filter(this.find(new Filter() {
@@ -618,7 +624,7 @@ public class ContactServiceImpl implements ContactService {
 				};
 
 				typingTimerTasks.put(identity, newTimerTask);
-				typingTimer.schedule(newTimerTask, TYPING_TIMEOUT);
+				typingTimer.schedule(newTimerTask, TYPING_RECEIVE_TIMEOUT);
 			}
 		}
 	}

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

@@ -56,9 +56,9 @@ public interface ConversationService {
 	List<ConversationModel> getAll(boolean forceReloadFromDatabase, Filter filter);
 
 	/**
-	 * return a list of all conversation models that have been archived
+	 * return a list of all conversation models that have been archived and match the constraint (case-insensitive match)
 	 */
-	List<ConversationModel> getArchived();
+	List<ConversationModel> getArchived(String constraint);
 
 	/**
 	 * return the number of conversations that have been archived

+ 20 - 5
app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java

@@ -206,7 +206,7 @@ public class ConversationServiceImpl implements ConversationService {
 	}
 
 	@Override
-	public List<ConversationModel> getArchived() {
+	public List<ConversationModel> getArchived(String constraint) {
 		List<ConversationModel> conversationModels = new ArrayList<>();
 
 		for (ConversationModelParser parser : new ConversationModelParser[]{
@@ -214,7 +214,7 @@ public class ConversationServiceImpl implements ConversationService {
 				new GroupConversationModelParser(),
 				new DistributionListConversationModelParser()
 		}) {
-			parser.processArchived(conversationModels);
+			parser.processArchived(conversationModels, constraint);
 		}
 
 		Collections.sort(conversationModels, (conversationModel, conversationModel2) -> {
@@ -628,10 +628,25 @@ public class ConversationServiceImpl implements ConversationService {
 			}
 		}
 
-		public final List<ConversationModel> processArchived(List<ConversationModel> conversationModels) {
+		public final List<ConversationModel> processArchived(List<ConversationModel> conversationModels, String constraint) {
 			List<ConversationResult> res = this.selectAll(true);
-			for(ConversationResult r: res) {
-				conversationModels.add(this.parseResult(r, null, false));
+
+			if (!TestUtil.empty(constraint)) {
+				constraint = constraint.toLowerCase();
+				for(ConversationResult r: res) {
+					ConversationModel conversationModel = this.parseResult(r, null, false);
+					String title = conversationModel.toString();
+
+					if (!TestUtil.empty(title)) {
+						if (title.toLowerCase().contains(constraint)) {
+							conversationModels.add(conversationModel);
+						}
+					}
+				}
+			} else {
+				for(ConversationResult r: res) {
+					conversationModels.add(this.parseResult(r, null, false));
+				}
 			}
 			return conversationModels;
 		}

+ 3 - 0
app/src/main/java/ch/threema/app/services/DownloadService.java

@@ -21,9 +21,12 @@
 
 package ch.threema.app.services;
 
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import ch.threema.client.ProgressListener;
 
 public interface DownloadService{
+	@WorkerThread @Nullable
 	byte[] download(int id, byte[] blobId, boolean markAsDown, ProgressListener progressListener);
 	void complete(int id, byte[] blobId);
 	boolean cancel(int id);

+ 4 - 0
app/src/main/java/ch/threema/app/services/DownloadServiceImpl.java

@@ -36,6 +36,8 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.client.BlobLoader;
@@ -61,6 +63,8 @@ public class DownloadServiceImpl implements DownloadService {
 	}
 
 	@Override
+	@WorkerThread
+	@Nullable
 	public byte[] download(int id, byte[] blobId, boolean markAsDown, ProgressListener progressListener) {
 		PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG);
 		try {

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

@@ -25,11 +25,14 @@ import android.app.Activity;
 import android.content.Intent;
 import android.graphics.Bitmap;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.sql.SQLException;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
+import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import ch.threema.app.exceptions.InvalidEntryException;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
@@ -48,6 +51,24 @@ import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.access.GroupAccessModel;
 
 public interface GroupService extends AvatarService<GroupModel> {
+
+	/**
+	 * Group state note yet determined
+	 */
+	public static final int UNDEFINED = 0;
+	/**
+	 * A local notes "group"
+	 */
+	public static final int NOTES = 1;
+	/**
+	 * A group with other people in it
+	 */
+	public static final int PEOPLE = 2;
+
+	@Retention(RetentionPolicy.SOURCE)
+	@IntDef({UNDEFINED, NOTES, PEOPLE})
+	@interface GroupState {}
+
 	interface GroupFilter {
 		boolean sortingByDate();
 		boolean sortingAscending();
@@ -123,7 +144,6 @@ public interface GroupService extends AvatarService<GroupModel> {
 	boolean isGroupMember(GroupModel groupModel);
 
 	boolean removeMemberFromGroup(GroupLeaveMessage msg);
-	boolean removeMemberFromGroup(GroupModel group, String identity);
 
 	int countMembers(@NonNull GroupModel groupModel);
 	boolean isNotesGroup(@NonNull GroupModel groupModel);

+ 36 - 9
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -490,7 +490,17 @@ public class GroupServiceImpl implements GroupService {
 			GroupModel model = this.getByAbstractGroupMessage(msg);
 
 			if(model != null) {
+				@GroupState int groupState = getGroupState(model);
+
 				this.removeMemberFromGroup(model, msg.getFromIdentity());
+
+				ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
+					@Override
+					public void handle(GroupListener listener) {
+						listener.onGroupStateChanged(model, groupState, getGroupState(model));
+					}
+				});
+
 				return true;
 			}
 			else {
@@ -504,8 +514,7 @@ public class GroupServiceImpl implements GroupService {
 		return false;
 	}
 
-	@Override
-	public boolean removeMemberFromGroup(final GroupModel group, final String identity) {
+	private boolean removeMemberFromGroup(final GroupModel group, final String identity) {
 		final int previousMemberCount = countMembers(group);
 
 		if(this.databaseServiceNew.getGroupMemberModelFactory().deleteByGroupIdAndIdentity(
@@ -514,12 +523,7 @@ public class GroupServiceImpl implements GroupService {
 		)> 0) {
 			this.resetIdentityCache(group.getId());
 
-			ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
-				@Override
-				public void handle(GroupListener listener) {
-					listener.onMemberLeave(group, identity, previousMemberCount);
-				}
-			});
+			ListenerManager.groupListeners.handle(listener -> listener.onMemberLeave(group, identity, previousMemberCount));
 			return true;
 		}
 
@@ -569,6 +573,8 @@ public class GroupServiceImpl implements GroupService {
 			return null;
 		}
 
+		@GroupState int groupState = getGroupState(result.groupModel);
+
 		if (isNewGroup && this.blackListService != null && this.blackListService.has(groupCreateMessage.getFromIdentity())) {
 			logger.info("GroupCreateMessage {}: Received group create from blocked ID. Sending leave.", groupCreateMessage.getMessageId());
 
@@ -609,6 +615,8 @@ public class GroupServiceImpl implements GroupService {
 				});
 			}
 
+			ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(result.groupModel, groupState, getGroupState(result.groupModel)));
+
 			return result;
 		}
 
@@ -695,6 +703,8 @@ public class GroupServiceImpl implements GroupService {
 			});
 		}
 
+		ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(result.groupModel, groupState, getGroupState(result.groupModel)));
+
 		return result;
 	}
 
@@ -735,7 +745,6 @@ public class GroupServiceImpl implements GroupService {
 		this.databaseServiceNew.getGroupModelFactory().create(groupModel);
 		this.cache(groupModel);
 
-		//if a name is set
 		for (String identity : groupMemberIdentities) {
 			this.addMemberToGroup(groupModel, identity);
 		}
@@ -750,6 +759,8 @@ public class GroupServiceImpl implements GroupService {
 			}
 		});
 
+		ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(groupModel, UNDEFINED, getGroupState(groupModel)));
+
 		//send event to server
 		this.groupApiService.sendMessage(groupModel, this.getGroupIdentities(groupModel), new GroupApiService.CreateApiMessage() {
 			@Override
@@ -835,6 +846,8 @@ public class GroupServiceImpl implements GroupService {
 	@Override
 	public Boolean addMembersToGroup(final GroupModel groupModel, @Nullable final String[] identities) {
 		if (identities != null && identities.length > 0) {
+			@GroupState int groupState = getGroupState(groupModel);
+
 			ArrayList<String> newContacts = new ArrayList<>();
 			ArrayList<String> newMembers = new ArrayList<>();
 
@@ -931,13 +944,24 @@ public class GroupServiceImpl implements GroupService {
 				}
 			});
 
+			ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(groupModel, groupState, getGroupState(groupModel)));
+
 			return true;
 		}
 		return false;
 	}
 
+	private int getGroupState(@Nullable GroupModel groupModel) {
+		if (groupModel != null) {
+			return isNotesGroup(groupModel) ? NOTES : PEOPLE;
+		}
+		return UNDEFINED;
+	}
+
 	@Override
 	public GroupModel updateGroup(final GroupModel groupModel, String name, final String[] groupMemberIdentities, Bitmap photo, boolean removePhoto) throws Exception {
+		@GroupState int groupState = getGroupState(groupModel);
+
 		//existing members
 		String[] existingMembers = this.getGroupIdentities(groupModel);
 
@@ -1056,6 +1080,9 @@ public class GroupServiceImpl implements GroupService {
 				ListenerManager.groupListeners.handle(listener -> listener.onMemberKicked(groupModel, kickedGroupMemberIdentity, existingMembers.length));
 			}
 		}
+
+		ListenerManager.groupListeners.handle(listener -> listener.onGroupStateChanged(groupModel, groupState, getGroupState(groupModel)));
+
 		return groupModel;
 	}
 

+ 6 - 3
app/src/main/java/ch/threema/app/services/MessageService.java

@@ -156,9 +156,12 @@ public interface MessageService {
 	boolean processIncomingContactMessage(AbstractMessage message) throws Exception;
 	boolean processIncomingGroupMessage(AbstractGroupMessage message) throws Exception;
 
-	List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver, MessageFilter messageFilter, boolean appendUnreadMessage);
-	List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver, MessageFilter messageFilter);
-	List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver);
+	@WorkerThread
+	List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter, boolean appendUnreadMessage);
+	@WorkerThread
+	List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter);
+	@WorkerThread
+	List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver);
 	List<AbstractMessageModel> getMessageForBallot(BallotModel ballotModel);
 	List<AbstractMessageModel> getContactMessagesForText(String query);
 	List<AbstractMessageModel> getGroupMessagesForText(String query);

+ 40 - 31
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -120,6 +120,7 @@ import ch.threema.app.video.transcoder.VideoTranscoder;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.AbstractGroupMessage;
 import ch.threema.client.AbstractMessage;
+import ch.threema.client.BadMessageException;
 import ch.threema.client.BlobUploader;
 import ch.threema.client.BoxAudioMessage;
 import ch.threema.client.BoxImageMessage;
@@ -1491,7 +1492,7 @@ public class MessageServiceImpl implements MessageService {
 	                                                     MessageId messageId,
 	                                                     BallotCreateInterface message,
 	                                                     AbstractMessageModel messageModel)
-			throws ThreemaException
+			throws ThreemaException, BadMessageException
 	{
 		BallotUpdateResult result = this.ballotService.update(message);
 
@@ -1709,7 +1710,20 @@ public class MessageServiceImpl implements MessageService {
 		logger.debug("process incoming file");
 		if (messageModel == null) {
 			newModel = true;
-			messageModel = receiver.createLocalModel(MessageType.FILE, MimeUtil.getContentTypeFromMimeType(fileData.getMimeType()), message.getDate());
+
+			FileDataModel fileDataModel = new FileDataModel(
+				fileData.getFileBlobId(),
+				fileData.getEncryptionKey(),
+				fileData.getMimeType(),
+				fileData.getThumbnailMimeType(),
+				fileData.getFileSize(),
+				FileUtil.sanitizeFileName(fileData.getFileName()),
+				fileData.getRenderingType(),
+				fileData.getDescription(),
+				false,
+				fileData.getMetaData());
+
+			messageModel = receiver.createLocalModel(MessageType.FILE, MimeUtil.getContentTypeFromFileData(fileDataModel), message.getDate());
 			this.cache(messageModel);
 
 			messageModel.setApiMessageId(message.getMessageId().toString());
@@ -1718,18 +1732,7 @@ public class MessageServiceImpl implements MessageService {
 			messageModel.setIdentity(message.getFromIdentity());
 			// Save correlation id into db field instead json
 			messageModel.setCorrelationId(fileData.getCorrelationId());
-			messageModel.setFileData(
-					new FileDataModel(
-							fileData.getFileBlobId(),
-							fileData.getEncryptionKey(),
-							fileData.getMimeType(),
-							fileData.getThumbnailMimeType(),
-							fileData.getFileSize(),
-							FileUtil.sanitizeFileName(fileData.getFileName()),
-							fileData.getRenderingType(),
-							fileData.getDescription(),
-							false,
-							fileData.getMetaData()));
+			messageModel.setFileData(fileDataModel);
 
 			//create the record
 			receiver.saveLocalModel(messageModel);
@@ -2282,12 +2285,12 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
-	public List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver, MessageFilter messageFilter) {
+	public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter) {
 		return this.getMessagesForReceiver(receiver, messageFilter, true);
 	}
 
 	@Override
-	public List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver, MessageFilter messageFilter, boolean appendUnreadMessage) {
+	public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter, boolean appendUnreadMessage) {
 		try {
 			List<AbstractMessageModel> messages  = receiver.loadMessages(messageFilter);
 			if (!appendUnreadMessage) {
@@ -2346,7 +2349,7 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
-	public List<AbstractMessageModel> getMessagesForReceiver(MessageReceiver receiver) {
+	public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver) {
 		return this.getMessagesForReceiver(receiver, null);
 	}
 
@@ -3134,7 +3137,7 @@ public class MessageServiceImpl implements MessageService {
 				}
 				text += locationUri.toString();
 			} else {
-				text = model.getBody();
+				text = QuoteUtil.getMessageBody(model, false);
 			}
 
 			intent.setAction(Intent.ACTION_SEND);
@@ -3665,8 +3668,11 @@ public class MessageServiceImpl implements MessageService {
 
 					bitmap = BitmapUtil.safeGetBitmapFromUri(context, mediaItem.getUri(), maxSize, true, hasNoTransparency);
 					if (bitmap != null) {
-						bitmap = BitmapUtil.rotateBitmap(bitmap, mediaItem.getExifRotation(), mediaItem.getExifFlip());
-						byte[] imageByteArray;
+						bitmap = BitmapUtil.rotateBitmap(bitmap,
+							mediaItem.getExifRotation(),
+							mediaItem.getExifFlip());
+
+						final byte[] imageByteArray;
 						if (hasNoTransparency) {
 							imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
 						} else {
@@ -3686,11 +3692,11 @@ public class MessageServiceImpl implements MessageService {
 						}
 						if (imageByteArray != null) {
 							fileDataModel.setFileSize(imageByteArray.length);
-							ByteArrayOutputStream outputStream = new ByteArrayOutputStream( );
-							outputStream.write( new byte[NaCl.BOXOVERHEAD] );
-							outputStream.write( imageByteArray );
+							ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+							outputStream.write(new byte[NaCl.BOXOVERHEAD]);
+							outputStream.write(imageByteArray);
 
-							return outputStream.toByteArray( );
+							return outputStream.toByteArray();
 						}
 					}
 				} catch (Exception e) {
@@ -3707,16 +3713,19 @@ public class MessageServiceImpl implements MessageService {
 					if (inputStream != null && inputStream.available() > 0) {
 						bitmap = BitmapFactory.decodeStream(new BufferedInputStream(inputStream), null, null);
 						if (bitmap != null) {
-							bitmap = BitmapUtil.rotateBitmap(BitmapUtil.rotateBitmap(
+							bitmap = BitmapUtil.rotateBitmap(
 								bitmap,
 								mediaItem.getExifRotation(),
-								mediaItem.getExifFlip()), mediaItem.getRotation(), mediaItem.getFlip());
+								mediaItem.getExifFlip());
 
-							ByteArrayOutputStream outputStream = new ByteArrayOutputStream( );
-							outputStream.write( new byte[NaCl.BOXOVERHEAD] );
-							outputStream.write( BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip()) );
+							final byte[] imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
+							if (imageByteArray != null) {
+								ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+								outputStream.write(new byte[NaCl.BOXOVERHEAD]);
+								outputStream.write(imageByteArray);
 
-							return outputStream.toByteArray( );
+								return outputStream.toByteArray();
+							}
 						}
 					}
 				} catch (Exception e) {
@@ -4036,7 +4045,7 @@ public class MessageServiceImpl implements MessageService {
 
 		for (MessageReceiver messageReceiver : resolvedReceivers) {
 
-			final AbstractMessageModel messageModel = messageReceiver.createLocalModel(MessageType.FILE, MimeUtil.getContentTypeFromMimeType(fileDataModel.getMimeType()), new Date());
+			final AbstractMessageModel messageModel = messageReceiver.createLocalModel(MessageType.FILE, MimeUtil.getContentTypeFromFileData(fileDataModel), new Date());
 			this.cache(messageModel);
 
 			messageModel.setOutbox(true);

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

@@ -85,6 +85,7 @@ public interface NotificationService {
 		private final FetchBitmap fetchAvatar;
 		private final MessageReceiver messageReceiver;
 		private final String lookupUri;
+		private long lastNotificationDate = 0;
 
 		//reference to conversations
 		protected List<ConversationNotification> conversations = new ArrayList<>();
@@ -146,6 +147,14 @@ public interface NotificationService {
 				}
 			}*/
 		}
+
+		public void setLastNotificationDate(long lastNotificationDate) {
+			this.lastNotificationDate = lastNotificationDate;
+		}
+
+		public long getLastNotificationDate() {
+			return this.lastNotificationDate;
+		}
 	}
 
 	class ConversationNotification {

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

@@ -113,6 +113,7 @@ import static ch.threema.app.voip.services.VoipCallService.EXTRA_IS_INITIATOR;
 
 public class NotificationServiceImpl implements NotificationService {
 	private static final Logger logger = LoggerFactory.getLogger(NotificationServiceImpl.class);
+	private static final long NOTIFY_AGAIN_TIMEOUT = 30 * DateUtils.SECOND_IN_MILLIS;
 
 	private final Context context;
 	private final LockAppService lockAppService;
@@ -530,6 +531,9 @@ public class NotificationServiceImpl implements NotificationService {
 				PendingIntent.FLAG_UPDATE_CURRENT);
 
 			long timestamp = System.currentTimeMillis();
+			boolean onlyAlertOnce = (timestamp - newestGroup.getLastNotificationDate()) < NOTIFY_AGAIN_TIMEOUT;
+			newestGroup.setLastNotificationDate(timestamp);
+
 			final NotificationCompat.Builder builder;
 
 			if (ConfigUtils.canDoGroupedNotifications()) {
@@ -547,7 +551,8 @@ public class NotificationServiceImpl implements NotificationService {
 						.setContentTitle(summaryText)
 						.setContentText(context.getString(R.string.notification_hidden_text))
 						.setSmallIcon(R.drawable.ic_notification_small)
-						.setColor(context.getResources().getColor(R.color.accent_light));
+						.setColor(context.getResources().getColor(R.color.accent_light))
+						.setOnlyAlertOnce(onlyAlertOnce);
 
 				// private version
 				builder = new NotificationBuilderWrapper(context, NOTIFICATION_CHANNEL_CHAT, notificationSchema, publicBuilder)
@@ -559,7 +564,7 @@ public class NotificationServiceImpl implements NotificationService {
 								.setColor(context.getResources().getColor(R.color.accent_light))
 								.setGroup(newestGroup.getGroupUid())
 								.setGroupSummary(false)
-								.setOnlyAlertOnce((updateExisting && numberOfNotificationsForCurrentChat == 1 ) || !ThreemaApplication.isNotifyAgain())
+								.setOnlyAlertOnce(onlyAlertOnce)
 								.setPriority(this.preferenceService.getNotificationPriority())
 								.setCategory(NotificationCompat.CATEGORY_MESSAGE)
 								.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
@@ -638,7 +643,7 @@ public class NotificationServiceImpl implements NotificationService {
 								.setWhen(timestamp)
 								.setPriority(this.preferenceService.getNotificationPriority())
 								.setCategory(NotificationCompat.CATEGORY_MESSAGE)
-								.setOnlyAlertOnce((this.conversationNotifications.size() == 1 && conversationNotification.getMessageType() == MessageType.IMAGE) || !ThreemaApplication.isNotifyAgain());
+								.setOnlyAlertOnce(onlyAlertOnce);
 
 				int smallIcon = getSmallIconResource(unreadConversationsCount);
 				if (smallIcon > 0) {
@@ -1243,21 +1248,15 @@ public class NotificationServiceImpl implements NotificationService {
 	public void cancelAllCachedConversationNotifications() {
 		this.cancel(ThreemaApplication.NEW_MESSAGE_NOTIFICATION_ID);
 
-		if (!conversationNotifications.isEmpty()){
-			for (ConversationNotification conversationNotification : conversationNotifications) {
-				this.conversationNotifications.remove(conversationNotification);
-				this.cancelAndDestroyConversationNotification(conversationNotification);
+		synchronized (this.conversationNotifications) {
+			if (!conversationNotifications.isEmpty()) {
+				for (ConversationNotification conversationNotification : conversationNotifications) {
+					this.conversationNotifications.remove(conversationNotification);
+					this.cancelAndDestroyConversationNotification(conversationNotification);
+				}
+				showDefaultPinLockedNewMessageNotification();
 			}
-			showDefaultPinLockedNewMessageNotification();
-		}
-	}
-
-	private HashSet<ConversationNotificationGroup> getConversationNotificationGroups() {
-		HashSet<ConversationNotificationGroup> groups = new HashSet<>();
-		for (ConversationNotification notification : this.conversationNotifications) {
-			groups.add(notification.getGroup());
 		}
-		return groups;
 	}
 
 	private NotificationSchema createNotificationSchema(ConversationNotificationGroup notificationGroup, CharSequence rawMessage) {
@@ -1524,8 +1523,8 @@ public class NotificationServiceImpl implements NotificationService {
 			new NotificationBuilderWrapper(context, NOTIFICATION_CHANNEL_NOTICE, null)
 					.setSmallIcon(R.drawable.ic_error_red_24dp)
 					.setTicker(this.context.getString(R.string.server_message_title))
-					.setContentTitle(this.context.getString(R.string.app_name))
-					.setContentText(this.context.getString(R.string.server_message_title))
+					.setContentTitle(this.context.getString(R.string.server_message_title))
+					.setContentText(this.context.getString(R.string.tap_here_for_more))
 					.setContentIntent(pendingIntent)
 					.setLocalOnly(true)
 					.setPriority(NotificationCompat.PRIORITY_MAX)

+ 3 - 0
app/src/main/java/ch/threema/app/services/PreferenceService.java

@@ -507,4 +507,7 @@ public interface PreferenceService {
 
 	void setVoiceRecorderBluetoothDisabled(boolean isEnabled);
 	boolean getVoiceRecorderBluetoothDisabled();
+
+	void setAudioPlaybackSpeed(float newSpeed);
+	float getAudioPlaybackSpeed();
 }

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

@@ -1551,4 +1551,14 @@ public class PreferenceServiceImpl implements PreferenceService {
 	public boolean getVoiceRecorderBluetoothDisabled() {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__voicerecorder_bluetooth_disabled));
 	}
+
+	@Override
+	public void setAudioPlaybackSpeed(float newSpeed) {
+		this.preferenceStore.save(this.getKeyName(R.string.preferences__audio_playback_speed), newSpeed);
+	}
+
+	@Override
+	public float getAudioPlaybackSpeed() {
+		return this.preferenceStore.getFloat(this.getKeyName(R.string.preferences__audio_playback_speed), 1f);
+	}
 }

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

@@ -213,7 +213,7 @@ public class SensorServiceImpl implements SensorService, SensorEventListener {
 			z = (z / norm_Of_g);
 			int inclination = (int) Math.round(Math.toDegrees(Math.acos(z)));
 
-			isFlatOnTable = (inclination < 30 || inclination > 150);
+			isFlatOnTable = (inclination < 20 || inclination > 160);
 		}
 	}
 

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

@@ -23,10 +23,10 @@ package ch.threema.app.services;
 
 import android.accounts.Account;
 import android.accounts.AccountManagerCallback;
-import androidx.annotation.Nullable;
 
 import java.util.Date;
 
+import androidx.annotation.Nullable;
 import ch.threema.app.services.license.LicenseService;
 
 /**
@@ -118,7 +118,7 @@ public interface UserService {
 
 	boolean restoreIdentity(String identity, byte[] privateKey, byte[] publicKey) throws Exception;
 
-	void setPolicyResponse(String responseData, String signature);
+	void setPolicyResponse(String responseData, String signature, int policyErrorCode);
 
 	void setCredentials(LicenseService.Credentials credentials);
 

+ 8 - 5
app/src/main/java/ch/threema/app/services/UserServiceImpl.java

@@ -39,9 +39,9 @@ import java.util.HashSet;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.listeners.SMSVerificationListener;
@@ -87,6 +87,7 @@ public class UserServiceImpl implements UserService, CreateIdentityRequestDataIn
 	private final PreferenceService preferenceService;
 	private String policyResponseData;
 	private String policySignature;
+	private int policyErrorCode;
     private LicenseService.Credentials credentials;
 	private Account account;
 
@@ -114,9 +115,10 @@ public class UserServiceImpl implements UserService, CreateIdentityRequestDataIn
 			throw new ThreemaException("please remove your existing identity " + this.getIdentity());
 		}
 
-		// no need to send a request if we have no licence
-		if (policySignature == null && policyResponseData == null && credentials == null) {
-			throw new ThreemaException(ThreemaApplication.getAppContext().getResources().getString(R.string.missing_app_licence));    /* Create identity phase 1 unsuccessful:*/
+		// no need to send a request if we have no licence.
+		// note that CheckLicenseRoutine may not have received an upstream response yet.
+		if (policySignature == null && policyResponseData == null && credentials == null && !BuildConfig.DEBUG) {
+			throw new ThreemaException(context.getString(R.string.missing_app_licence) + "\n" + context.getString(R.string.app_store_error_code, policyErrorCode));    /* Create identity phase 1 unsuccessful:*/
 		}
 		else {
 			this.apiConnector.createIdentity(
@@ -545,9 +547,10 @@ public class UserServiceImpl implements UserService, CreateIdentityRequestDataIn
 	}
 
 	@Override
-	public void setPolicyResponse(String responseData, String signature) {
+	public void setPolicyResponse(String responseData, String signature, int policyErrorCode) {
 		this.policyResponseData = responseData;
 		this.policySignature = signature;
+		this.policyErrorCode = policyErrorCode;
 	}
 
 

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

@@ -54,6 +54,7 @@ import java.util.concurrent.ExecutionException;
 import javax.crypto.CipherInputStream;
 
 import androidx.annotation.AnyThread;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
@@ -190,7 +191,7 @@ public class WallpaperServiceImpl implements WallpaperService {
 	}
 
 	@UiThread
-	private boolean setImageView(ImageView wallpaperView, Bitmap bitmap) {
+	private boolean setImageView(ImageView wallpaperView, @Nullable Bitmap bitmap) {
 		if (wallpaperView != null) {
 			if (bitmap != null) {
 				wallpaperView.setImageBitmap(bitmap);
@@ -212,8 +213,8 @@ public class WallpaperServiceImpl implements WallpaperService {
 			try {
 				if (!hasEmptyWallpaper(messageReceiver).get()) {
 					bitmap = getWallpaperBitmap(messageReceiver, landscape).get();
-					return setImageView(wallpaperView, bitmap);
 				}
+				return setImageView(wallpaperView, bitmap);
 			} catch (InterruptedException e) {
 				logger.error("Exception", e);
 				// Restore interrupted state...

+ 9 - 1
app/src/main/java/ch/threema/app/services/ballot/BallotService.java

@@ -31,6 +31,7 @@ import ch.threema.app.listeners.BallotListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.base.ThreemaException;
+import ch.threema.client.BadMessageException;
 import ch.threema.client.MessageTooLongException;
 import ch.threema.client.ballot.BallotCreateInterface;
 import ch.threema.client.ballot.BallotVoteInterface;
@@ -85,7 +86,14 @@ public interface BallotService {
 	long countBallots(BallotFilter filter);
 
 	boolean belongsToMe(Integer ballotModelId, MessageReceiver messageReceiver) throws NotAllowedException;
-	BallotUpdateResult update(BallotCreateInterface createMessage) throws ThreemaException;
+
+	/**
+	 * Create / Update ballot from createMessage
+	 * @param createMessage BallotCreateMessage received from server
+	 * @return BallotUpdateResult
+	 * @throws ThreemaException if an error occurred during processing
+	 */
+	@NonNull BallotUpdateResult update(BallotCreateInterface createMessage) throws ThreemaException, BadMessageException;
 	boolean update(BallotModel ballotModel);
 
 	BallotPublishResult publish(MessageReceiver messageReceiver, BallotModel ballotModel,

+ 33 - 49
app/src/main/java/ch/threema/app/services/ballot/BallotServiceImpl.java

@@ -55,6 +55,7 @@ import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.AbstractMessage;
+import ch.threema.client.BadMessageException;
 import ch.threema.client.MessageTooLongException;
 import ch.threema.client.ProtocolDefines;
 import ch.threema.client.Utils;
@@ -95,7 +96,6 @@ public class BallotServiceImpl implements BallotService {
 
 	private int openBallotId = 0;
 
-
 	public BallotServiceImpl(SparseArray<BallotModel> ballotModelCache,
 	                         SparseArray<LinkBallotModel> linkBallotModelCache,
 	                         DatabaseServiceNew databaseServiceNew,
@@ -338,35 +338,38 @@ public class BallotServiceImpl implements BallotService {
 	}
 
 	@Override
-	public BallotUpdateResult update(BallotCreateInterface createMessage) throws ThreemaException {
+	@NonNull
+	public BallotUpdateResult update(BallotCreateInterface createMessage) throws ThreemaException, BadMessageException {
 		//check if allowed
 		BallotData data = createMessage.getData();
 		if (data == null) {
 			throw new ThreemaException("invalid format");
 		}
 
-		boolean newBallot;
-		Date date = ((AbstractMessage)createMessage).getDate();
-
-
-		//check if the create message is a update or a insert
-		BallotModel existingModel = this.get(createMessage.getBallotId().toString(), createMessage.getBallotCreator());
+		final BallotModel.State toState;
 		final BallotModel ballotModel;
-		boolean isClosing = false;
 
-		if(existingModel != null) {
-			ballotModel = existingModel;
-			newBallot = false;
-		}
-		else {
-			newBallot = true;
-			ballotModel = new BallotModel();
-			ballotModel.setCreatorIdentity(createMessage.getBallotCreator());
-			ballotModel.setApiBallotId(createMessage.getBallotId().toString());
+		Date date = ((AbstractMessage)createMessage).getDate();
+		BallotModel existingModel = this.get(createMessage.getBallotId().toString(), createMessage.getBallotCreator());
 
-			//dirty hack
-			ballotModel.setCreatedAt(date);
-			ballotModel.setLastViewedAt(null);
+		if (existingModel != null) {
+			if (data.getState() == BallotData.State.CLOSED) {
+				ballotModel = existingModel;
+				toState = BallotModel.State.CLOSED;
+			} else {
+				throw new BadMessageException("Ballot with same ID already exists. Discarding message.", true);
+			}
+		} else {
+			if (data.getState() != BallotData.State.CLOSED) {
+				ballotModel = new BallotModel();
+				ballotModel.setCreatorIdentity(createMessage.getBallotCreator());
+				ballotModel.setApiBallotId(createMessage.getBallotId().toString());
+				ballotModel.setCreatedAt(date);
+				ballotModel.setLastViewedAt(null);
+				toState = BallotModel.State.OPEN;
+			} else {
+				throw new BadMessageException("New ballot with closed state requested. Discarding message.", true);
+			}
 		}
 
 		ballotModel.setName(data.getDescription());
@@ -396,20 +399,9 @@ public class BallotServiceImpl implements BallotService {
 				break;
 		}
 
-		switch (data.getState()) {
-			case OPEN:
-				ballotModel.setState(BallotModel.State.OPEN);
-				break;
-			case CLOSED:
-				if(ballotModel.getState() == BallotModel.State.OPEN) {
-					//ok, closing the ballot
-					isClosing = true;
-				}
-				ballotModel.setState(BallotModel.State.CLOSED);
-				break;
-		}
+		ballotModel.setState(toState);
 
-		if(newBallot) {
+		if (toState == BallotModel.State.OPEN) {
 			this.databaseServiceNew.getBallotModelFactory().create(
 					ballotModel
 			);
@@ -446,12 +438,13 @@ public class BallotServiceImpl implements BallotService {
 			throw new ThreemaException("invalid");
 		}
 
-		if(isClosing) {
-			//remove all votes!
+		if (toState == BallotModel.State.CLOSED) {
+			//remove all votes
 			this.databaseServiceNew.getBallotVoteModelFactory().deleteByBallotId(
 					ballotModel.getId()
 			);
 		}
+
 		//create choices of ballot
 		for(BallotDataChoice apiChoice: data.getChoiceList()) {
 			//check if choice already exist
@@ -465,7 +458,6 @@ public class BallotServiceImpl implements BallotService {
 
 			ballotChoiceModel.setName(apiChoice.getName());
 			ballotChoiceModel.setOrder(apiChoice.getOrder());
-
 			switch (data.getChoiceType()) {
 				case TEXT:
 					ballotChoiceModel.setType(BallotChoiceModel.Type.Text);
@@ -496,7 +488,7 @@ public class BallotServiceImpl implements BallotService {
 			}
 		}
 
-		if(newBallot) {
+		if (toState == BallotModel.State.OPEN) {
 			this.cache(ballotModel);
 			this.send(ballotModel, listener -> {
 				if (listener.handle(ballotModel)) {
@@ -506,7 +498,8 @@ public class BallotServiceImpl implements BallotService {
 
 			return new BallotUpdateResult(ballotModel, BallotUpdateResult.Operation.CREATE);
 		}
-		else if(isClosing) {
+		else {
+			// toState == BallotModel.State.CLOSED
 			this.send(ballotModel, listener -> {
 				if (listener.handle(ballotModel)) {
 					listener.onClosed(ballotModel);
@@ -514,15 +507,6 @@ public class BallotServiceImpl implements BallotService {
 			});
 			return new BallotUpdateResult(ballotModel, BallotUpdateResult.Operation.CLOSE);
 		}
-		else {
-			ListenerManager.ballotListeners.handle(listener -> {
-				if(listener.handle(ballotModel)) {
-					listener.onModified(ballotModel);
-				}
-			});
-
-			return new BallotUpdateResult(ballotModel, BallotUpdateResult.Operation.UPDATE);
-		}
 	}
 
 	@Override
@@ -839,6 +823,7 @@ public class BallotServiceImpl implements BallotService {
 			default:
 				ballotData.setAssessmentType(BallotData.AssessmentType.SINGLE);
 		}
+
 		switch (ballotModel.getState()) {
 			case CLOSED:
 				ballotData.setState(BallotData.State.CLOSED);
@@ -848,7 +833,6 @@ public class BallotServiceImpl implements BallotService {
 				ballotData.setState(BallotData.State.OPEN);
 		}
 
-
 		HashMap<String, Integer> participantPositions = new HashMap<>();
 		List<BallotVoteModel> voteModels = null;
 		int participantCount = 0;

+ 30 - 0
app/src/main/java/ch/threema/app/services/messageplayer/AudioMessagePlayer.java

@@ -164,6 +164,10 @@ public class AudioMessagePlayer extends MessagePlayer implements AudioManager.On
 			logger.debug("starting prepare - streamType = {}", streamType);
 			setOutputStream(streamType);
 			mediaPlayer.setDataSource(getContext(), uri);
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+				float audioPlaybackSpeed = preferenceService.getAudioPlaybackSpeed();
+				mediaPlayer.setPlaybackParams(mediaPlayer.getPlaybackParams().setSpeed(audioPlaybackSpeed).setPitch(1f));
+			}
 			mediaPlayer.prepare();
 			prepared(mediaPlayer, resume);
 			markAsConsumed();
@@ -394,6 +398,32 @@ public class AudioMessagePlayer extends MessagePlayer implements AudioManager.On
 		return super.stop();
 	}
 
+	@Override
+	public float togglePlaybackSpeed() {
+		float newSpeed = 1f;
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && mediaPlayer != null) {
+			float currentSpeed = mediaPlayer.getPlaybackParams().getSpeed();
+
+			if (currentSpeed == 1f) {
+				newSpeed = 1.5f;
+			} else if (currentSpeed == 1.5f) {
+				newSpeed = 2f;
+			} else if (currentSpeed == 2f) {
+				newSpeed = 0.5f;
+			}
+
+			if (mediaPlayer != null && mediaPlayer.isPlaying()) {
+				if (!mediaPlayer.setPlaybackParams(mediaPlayer.getPlaybackParams().setSpeed(newSpeed).setPitch(1f))) {
+					newSpeed = currentSpeed;
+				}
+			}
+		}
+
+		preferenceService.setAudioPlaybackSpeed(newSpeed);
+		return newSpeed;
+	}
+
 	@Override
 	public void seekTo(int pos) {
 		logger.debug("seekTo");

+ 4 - 0
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java

@@ -370,6 +370,10 @@ public abstract class MessagePlayer {
 		return false;
 	}
 
+	public float togglePlaybackSpeed() {
+		return 1f;
+	}
+
 	public MessagePlayer addListener(String key, PlayerListener listener) {
 		synchronized (this.playerListeners) {
 			this.playerListeners.put(key, listener);

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

@@ -74,7 +74,7 @@ public class SystemUpdateToVersion61 extends UpdateToVersion implements UpdateSy
 							final String body = fileMessages.getString(1);
 							if (body != null && body.length() > 0) {
 								FileDataModel fileDataModel = FileDataModel.create(body);
-								sqLiteDatabase.rawExecSQL("UPDATE " + table + " SET messageContentsType = " + MimeUtil.getContentTypeFromMimeType(fileDataModel.getMimeType()) + " WHERE id = " + id);
+								sqLiteDatabase.rawExecSQL("UPDATE " + table + " SET messageContentsType = " + MimeUtil.getContentTypeFromFileData(fileDataModel) + " WHERE id = " + id);
 							}
 						}
 					}

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

@@ -31,7 +31,6 @@ import org.slf4j.LoggerFactory;
 
 import androidx.annotation.RequiresApi;
 
-
 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class ThreemaSafeUploadJobService extends JobService {
 	private static final Logger logger = LoggerFactory.getLogger(ThreemaSafeUploadJobService.class);
@@ -40,7 +39,7 @@ public class ThreemaSafeUploadJobService extends JobService {
 	public boolean onStartJob(JobParameters params) {
 		logger.debug("onStartJob");
 
-		ThreemaSafeUploadService.enqueueWork(getApplicationContext(), new Intent());
+		new Thread(() -> ThreemaSafeUploadService.enqueueWork(getApplicationContext(), new Intent()), "SafeUploadEnqueue").start();
 
 		// work has been queued, we no longer need this job
 		return false;

+ 6 - 0
app/src/main/java/ch/threema/app/ui/EmptyRecyclerView.java

@@ -25,6 +25,8 @@ import android.content.Context;
 import android.util.AttributeSet;
 import android.view.View;
 
+import org.msgpack.core.annotations.Nullable;
+
 import java.lang.ref.WeakReference;
 
 import androidx.recyclerview.widget.RecyclerView;
@@ -91,4 +93,8 @@ public class EmptyRecyclerView extends RecyclerView {
 		this.emptyViewReference = new WeakReference<>(emptyView);
 		checkIfEmpty();
 	}
+
+	public @Nullable View getEmptyView() {
+		return this.emptyViewReference.get();
+	}
 }

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

@@ -29,6 +29,7 @@ import android.widget.GridView;
 
 import androidx.appcompat.view.ContextThemeWrapper;
 import ch.threema.app.R;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RuntimeUtil;
 
 /**
@@ -44,7 +45,10 @@ public class FastScrollGridView extends GridView implements AbsListView.OnScroll
 	private final Runnable fastScrollRemoveTask = () -> RuntimeUtil.runOnUiThread(() -> setFastScrollAlwaysVisible(false));
 
 	public FastScrollGridView(Context context, AttributeSet attrs) {
-		super(new ContextThemeWrapper(context, R.style.Threema_MediaGallery_FastScroll), attrs);
+		super(new ContextThemeWrapper(context,
+			ConfigUtils.getAppTheme(context) == ConfigUtils.THEME_DARK ?
+			R.style.Threema_MediaGallery_FastScroll_Dark :
+			R.style.Threema_MediaGallery_FastScroll), attrs);
 		setOnScrollListener(this);
 	}
 

+ 18 - 7
app/src/main/java/ch/threema/app/ui/ListViewBehavior.java

@@ -31,6 +31,7 @@ import com.google.android.material.snackbar.Snackbar;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import androidx.annotation.NonNull;
 import androidx.coordinatorlayout.widget.CoordinatorLayout;
 
 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
@@ -43,28 +44,38 @@ public class ListViewBehavior extends CoordinatorLayout.Behavior<View> {
 	}
 
 	@Override
-	public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
-
+	public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
 		return dependency instanceof Snackbar.SnackbarLayout;
 	}
 
 	@Override
-	public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
+	public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
 		logger.debug("onDependentViewChanged");
 
 		ViewGroup.LayoutParams layoutParams = child.getLayoutParams();
-		layoutParams.height = parent.getHeight() - dependency.getHeight();
-		child.setLayoutParams(layoutParams);
+		final int height = parent.getHeight() - dependency.getHeight();
 
-		return true;
+		if (height != layoutParams.height) {
+			layoutParams.height = height;
+			child.setLayoutParams(layoutParams);
+			logger.debug("*** height: " + layoutParams.height);
+			return true;
+		} else {
+			return false;
+		}
 	}
 
 	@Override
-	public void onDependentViewRemoved(CoordinatorLayout parent, View child, View dependency) {
+	public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, View child, @NonNull View dependency) {
 		logger.debug("onDependentViewRemoved");
 
 		ViewGroup.LayoutParams layoutParams = child.getLayoutParams();
 		layoutParams.height = MATCH_PARENT;
 		child.setLayoutParams(layoutParams);
 	}
+
+	@Override
+	public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull View child, int layoutDirection) {
+		return super.onLayoutChild(parent, child, layoutDirection);
+	}
 }

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

@@ -24,6 +24,7 @@ package ch.threema.app.ui;
 import android.os.Handler;
 import android.text.Editable;
 import android.text.TextWatcher;
+import android.text.format.DateUtils;
 
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.TestUtil;
@@ -31,6 +32,7 @@ import ch.threema.storage.models.ContactModel;
 
 public class TypingIndicatorTextWatcher implements TextWatcher {
 
+	private static final long TYPING_SEND_TIMEOUT = 10 * DateUtils.SECOND_IN_MILLIS;
 	private final Handler typingIndicatorHandler = new Handler();
 	private final UserService userService;
 	private final ContactModel contactModel;
@@ -87,7 +89,7 @@ public class TypingIndicatorTextWatcher implements TextWatcher {
 		if (editable != null && editable.length() == 0) {
 			typingIndicatorHandler.post(sendStoppedTyping);
 		} else {
-			typingIndicatorHandler.postDelayed(sendStoppedTyping, 10000);
+			typingIndicatorHandler.postDelayed(sendStoppedTyping, TYPING_SEND_TIMEOUT);
 		}
 	}
 

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

@@ -94,7 +94,7 @@ public class VideoPopup extends DimmingPopupWindow {
 
 		LayoutInflater layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 		if (layout == 0) {
-			topLayout = layoutInflater.inflate(R.layout.popup_video, null, true);
+			topLayout = layoutInflater.inflate(R.layout.fragment_video_preview, null, true);
 		} else {
 			topLayout = layoutInflater.inflate(layout, null, true);
 		}

+ 2 - 0
app/src/main/java/ch/threema/app/ui/listitemholder/ComposeMessageHolder.java

@@ -28,6 +28,7 @@ import android.widget.ImageView;
 import android.widget.SeekBar;
 import android.widget.TextView;
 
+import com.google.android.material.button.MaterialButton;
 import com.google.android.material.chip.Chip;
 
 import ch.threema.app.services.messageplayer.MessagePlayer;
@@ -52,6 +53,7 @@ public class ComposeMessageHolder extends AvatarListItemHolder {
 	public TranscoderView transcoderView;
 	public FrameLayout readOnContainer;
 	public Chip readOnButton;
+	public MaterialButton messageTypeButton;
 
 	public ControllerView controller;
 

+ 12 - 6
app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java

@@ -105,7 +105,7 @@ public class AndroidContactUtil {
 	private Map<String, String> identityLookupCache = null;
 	private final Object identityLookupCacheLock = new Object();
 
-	private Account getAccount() {
+	private @Nullable Account getAccount() {
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		if(serviceManager != null) {
 			UserService userService = serviceManager.getUserService();
@@ -686,7 +686,7 @@ public class AndroidContactUtil {
 	}
 
 	/**
-	 * Create a raw contact for the given identity. Put the identity into the SYNC1 column and set DISPLAY_NAME and data records for messaging and calling
+	 * Create a raw contact for the given identity. Put the identity into the SYNC1 column and set data records for messaging and calling
 	 * @param identity
 	 * @param supportsVoiceCalls
 	 * @return LOOKUP_KEY of the newly created raw contact or null if no contact has been created
@@ -717,31 +717,37 @@ public class AndroidContactUtil {
 		builder.withValue(ContactsContract.RawContacts.SYNC1, identity);
 		insertOperationList.add(builder.build());
 
+		Uri insertUri = ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();
+/*
 		logger.debug("   Create a Data record of type 'Nickname' for our RawContact");
-		builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI);
+		builder = ContentProviderOperation.newInsert(insertUri);
 		builder.withValueBackReference(ContactsContract.CommonDataKinds.Nickname.RAW_CONTACT_ID, 0);
 		builder.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE);
 		builder.withValue(ContactsContract.CommonDataKinds.Nickname.NAME, identity);
+		builder.withValue(ContactsContract.CommonDataKinds.Nickname.TYPE, ContactsContract.CommonDataKinds.Nickname.TYPE_CUSTOM);
+		builder.withValue(ContactsContract.CommonDataKinds.Nickname.LABEL, context.getString(R.string.title_threemaid));
 		insertOperationList.add(builder.build());
-
+*/
 		logger.debug("   Create a Data record of custom type");
-		builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI);
+		builder = ContentProviderOperation.newInsert(insertUri);
 		builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0);
 		builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.contacts_mime_type));
 		//DATA1 have to be the identity to fetch in the activity
 		builder.withValue(ContactsContract.Data.DATA1, identity);
 		builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
 		builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_message_to, identity));
+		builder.withYieldAllowed(true);
 		insertOperationList.add(builder.build());
 
 		if (supportsVoiceCalls) {
 			logger.debug("   Create a Data record of custom type for call");
-			builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI);
+			builder = ContentProviderOperation.newInsert(insertUri);
 			builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0);
 			builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.call_mime_type));
 			builder.withValue(ContactsContract.Data.DATA1, identity);
 			builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
 			builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_call_with, identity));
+			builder.withYieldAllowed(true);
 			insertOperationList.add(builder.build());
 		}
 

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

@@ -509,7 +509,7 @@ public class AnimationUtil {
 	public static void setFadingVisibility(View view, int visibility) {
 		if (view.getVisibility() != visibility) {
 			Transition transition = new Fade();
-			transition.setDuration(150);
+			transition.setDuration(170);
 			transition.addTarget(view);
 
 			TransitionManager.endTransitions((ViewGroup) view.getParent());

+ 23 - 2
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -139,7 +139,7 @@ public class ConfigUtils {
 	private static Integer primaryColor = null, accentColor = null;
 	private static int emojiStyle = 0;
 	private static Boolean isTablet = null, isBiggerSingleEmojis = null, isMIUI10 = null, hasNoMapboxSupport = null;
-	private static int preferredThumbnailWidth = -1;
+	private static int preferredThumbnailWidth = -1, preferredAudioMessageWidth = -1;
 
 	private static final float[] NEGATIVE_MATRIX = {
 			-1.0f,     0,     0,    0, 255, // red
@@ -268,8 +268,12 @@ public class ConfigUtils {
 		return hasNoMapboxSupport;
 	}
 
+	public static boolean isXiaomiDevice() {
+		return Build.MANUFACTURER.equalsIgnoreCase("Xiaomi");
+	}
+
 	public static boolean isMIUI10() {
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !Build.MANUFACTURER.equalsIgnoreCase("Xiaomi")) {
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isXiaomiDevice()) {
 			return false;
 		}
 		if (isMIUI10 == null) {
@@ -1165,6 +1169,23 @@ public class ConfigUtils {
 		return preferredThumbnailWidth;
 	}
 
+	public static int getPreferredAudioMessageWidth(Context context, boolean reset) {
+		if (preferredAudioMessageWidth == -1 || reset) {
+			if (context != null) {
+				int width = context.getResources().getDisplayMetrics().widthPixels;
+				int height = context.getResources().getDisplayMetrics().heightPixels;
+
+				if (ConfigUtils.isTabletLayout()) {
+					width -= context.getResources().getDimensionPixelSize(R.dimen.message_fragment_width);
+				}
+
+				// width of audio message should be 80% of smallest display width
+				preferredAudioMessageWidth = (int) ((float) width < height ? width * 0.75f : height * 0.75f);
+			}
+		}
+		return preferredAudioMessageWidth;
+	}
+
 	public static int getPreferredImageDimensions(@PreferenceService.ImageScale int imageScale) {
 		int maxSize = 0;
 		switch (imageScale) {

+ 31 - 0
app/src/main/java/ch/threema/app/utils/DNDUtil.java

@@ -23,6 +23,7 @@ package ch.threema.app.utils;
 
 import android.Manifest;
 import android.annotation.TargetApi;
+import android.app.NotificationManager;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
@@ -38,6 +39,7 @@ import java.util.Calendar;
 import java.util.Set;
 
 import androidx.annotation.Nullable;
+import androidx.core.app.NotificationManagerCompat;
 import androidx.core.content.ContextCompat;
 import androidx.preference.PreferenceManager;
 import ch.threema.app.R;
@@ -218,4 +220,33 @@ public class DNDUtil {
 		}
 		return false;
 	}
+
+	/**
+	 * Check if the contact specified in messageReceiver is muted in the system. "Starred" contacts may override the global DND setting in "priority" mode
+	 * and should be signalled.
+	 * @param messageReceiver A MessageReceiver representing a ContactModel
+	 * @param notificationManager
+	 * @param notificationManagerCompat
+	 * @return false if the contact is not muted in the system and a ringtone should be played, false otherwise
+	 */
+	public boolean isSystemMuted(MessageReceiver messageReceiver, NotificationManager notificationManager, NotificationManagerCompat notificationManagerCompat) {
+		boolean isSystemMuted = !notificationManagerCompat.areNotificationsEnabled();
+
+		if (messageReceiver instanceof ContactMessageReceiver) {
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+				/* we do not play a ringtone sound if system-wide DND is enabled - except for starred contacts */
+				switch (notificationManager.getCurrentInterruptionFilter()) {
+					case NotificationManager.INTERRUPTION_FILTER_NONE:
+						isSystemMuted = true;
+						break;
+					case NotificationManager.INTERRUPTION_FILTER_PRIORITY:
+						isSystemMuted = !isStarredContact(messageReceiver);
+						break;
+					default:
+						break;
+				}
+			}
+		}
+		return isSystemMuted;
+	}
 }

+ 6 - 2
app/src/main/java/ch/threema/app/utils/FileUtil.java

@@ -732,8 +732,12 @@ public class FileUtil {
 			if (includeVideo) {
 				Intent pickIntent = new Intent(Intent.ACTION_PICK);
 				pickIntent.setType(MimeUtil.MIME_TYPE_IMAGE);
-				startIntent = Intent.createChooser(pickIntent, activity.getString(R.string.select_from_gallery));
-				startIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
+				if (ConfigUtils.isXiaomiDevice()) {
+					startIntent = getContentIntent;
+				} else {
+					startIntent = Intent.createChooser(pickIntent, activity.getString(R.string.select_from_gallery));
+					startIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
+				}
 			} else {
 				startIntent = getContentIntent;
 			}

+ 4 - 3
app/src/main/java/ch/threema/app/utils/LinkifyUtil.java

@@ -50,6 +50,7 @@ import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.text.util.LinkifyCompat;
 import androidx.core.view.GestureDetectorCompat;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ContactDetailActivity;
@@ -79,9 +80,9 @@ public class LinkifyUtil {
 	}
 
 	private LinkifyUtil() {
-		this.add = Pattern.compile("\\b" + ThreemaApplication.uriScheme + "://add\\?id=\\S{8}\\b");
-		this.compose = Pattern.compile("\\b" + ThreemaApplication.uriScheme + "://compose\\?\\S+\\b");
-		this.license = Pattern.compile("\\b" + ThreemaApplication.uriScheme + "://license\\?key=\\S{11}\\b");
+		this.add = Pattern.compile("\\b" + BuildConfig.uriScheme + "://add\\?id=\\S{8}\\b");
+		this.compose = Pattern.compile("\\b" + BuildConfig.uriScheme + "://compose\\?\\S+\\b");
+		this.license = Pattern.compile("\\b" + BuildConfig.uriScheme + "://license\\?key=\\S{11}\\b");
 		this.gestureDetector = new GestureDetectorCompat(null, new GestureDetector.OnGestureListener() {
 			@Override
 			public boolean onDown(MotionEvent e) {

+ 26 - 1
app/src/main/java/ch/threema/app/utils/MediaPlayerStateWrapper.java

@@ -25,6 +25,7 @@ import android.content.Context;
 import android.content.res.AssetFileDescriptor;
 import android.media.AudioAttributes;
 import android.media.MediaPlayer;
+import android.media.PlaybackParams;
 import android.net.Uri;
 import android.os.Build;
 
@@ -46,7 +47,6 @@ import androidx.annotation.RequiresApi;
 public class MediaPlayerStateWrapper {
 	private static final Logger logger = LoggerFactory.getLogger(MediaPlayerStateWrapper.class);
 
-	private static String TAG = "MediaPlayerStateWrapper";
 	private MediaPlayer mediaPlayer;
 	private State currentState;
 	private MediaPlayerStateWrapper stateWrapper;
@@ -303,4 +303,29 @@ public class MediaPlayerStateWrapper {
 	public void setScreenOnWhilePlaying(boolean screenOn) {
 		mediaPlayer.setScreenOnWhilePlaying(screenOn);
 	}
+
+	/**
+	 * Set playback parameters of MediaPlayer instance
+	 * @param playbackParams PlaybackParams to set
+	 * @return true if setting parameters was successful, false if MediaPlayer was in an invalid state or setting PlaybackParams failed.
+	 */
+	@RequiresApi(Build.VERSION_CODES.M)
+	public boolean setPlaybackParams(PlaybackParams playbackParams) {
+		// will not work with states Idle or Stopped
+		if (EnumSet.of(State.INITIALIZED, State.PREPARED, State.STARTED, State.PAUSED, State.PLAYBACK_COMPLETE, State.ERROR).contains(
+			currentState)) {
+			try {
+				mediaPlayer.setPlaybackParams(playbackParams);
+				return true;
+			} catch (IllegalArgumentException e) {
+				logger.info("Unable to set playback params {}", e.getMessage());
+			}
+		}
+		return false;
+	}
+
+	@RequiresApi(Build.VERSION_CODES.M)
+	public PlaybackParams getPlaybackParams() {
+		return mediaPlayer.getPlaybackParams();
+	}
 }

+ 11 - 6
app/src/main/java/ch/threema/app/utils/MimeUtil.java

@@ -21,9 +21,7 @@
 
 package ch.threema.app.utils;
 
-import android.content.ContentResolver;
 import android.content.Context;
-import android.net.Uri;
 
 import java.lang.annotation.Retention;
 import java.util.HashMap;
@@ -36,8 +34,9 @@ import ch.threema.app.R;
 import ch.threema.app.exceptions.MalformedMimeTypeException;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.data.MessageContentsType;
+import ch.threema.storage.models.data.media.FileDataModel;
 
-import static ch.threema.app.ThreemaApplication.getAppContext;
+import static ch.threema.client.file.FileData.RENDERING_MEDIA;
 import static java.lang.annotation.RetentionPolicy.SOURCE;
 
 public class MimeUtil {
@@ -199,9 +198,11 @@ public class MimeUtil {
 		return MIME_TYPE_ANY;
 	}
 
-	public static @MessageContentsType int getContentTypeFromMimeType(String mimeType) {
+	public static @MessageContentsType int getContentTypeFromFileData(@NonNull FileDataModel fileDataModel) {
+		String mimeType = fileDataModel.getMimeType();
+
 		int messageContentsType = MessageContentsType.FILE;
-		if (mimeType != null && mimeType.length() > 0) {
+		if (mimeType.length() > 0) {
 			if (MimeUtil.isGifFile(mimeType)) {
 				messageContentsType = MessageContentsType.GIF;
 			} else if (MimeUtil.isImageFile(mimeType)) {
@@ -209,7 +210,11 @@ public class MimeUtil {
 			} else if (MimeUtil.isVideoFile(mimeType)) {
 				messageContentsType = MessageContentsType.VIDEO;
 			} else if (MimeUtil.isAudioFile(mimeType)) {
-				messageContentsType = MessageContentsType.AUDIO;
+				if (fileDataModel.getRenderingType() == RENDERING_MEDIA) {
+					messageContentsType = MessageContentsType.VOICE_MESSAGE;
+				} else {
+					messageContentsType = MessageContentsType.AUDIO;
+				}
 			} else if (MimeUtil.isContactFile(mimeType)) {
 				messageContentsType = MessageContentsType.CONTACT;
 			}

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

@@ -25,6 +25,7 @@ import android.content.Context;
 import android.content.Intent;
 
 import androidx.core.app.ActivityCompat;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.UserService;
@@ -43,7 +44,7 @@ public class ShareUtil {
 
 			Intent shareIntent = new Intent(Intent.ACTION_SEND);
 			shareIntent.setType("text/plain");
-			shareIntent.putExtra(Intent.EXTRA_TEXT, contactName + ": https://" + context.getString(R.string.contact_action_url) + "/" + identity);
+			shareIntent.putExtra(Intent.EXTRA_TEXT, contactName + ": https://" + BuildConfig.contactActionUrl + "/" + identity);
 
 			ActivityCompat.startActivity(context, Intent.createChooser(shareIntent, context.getString(R.string.share_via)), null);
 		}

+ 2 - 1
app/src/main/java/ch/threema/app/video/transcoder/MediaComponent.java

@@ -28,6 +28,7 @@ import android.net.Uri;
 
 import java.io.IOException;
 
+import androidx.annotation.Nullable;
 import ch.threema.app.utils.MimeUtil;
 
 /**
@@ -79,7 +80,7 @@ public class MediaComponent {
      * The MediaFormat for the selected track of this component.
      * @return
      */
-    public MediaFormat getTrackFormat() {
+    public @Nullable MediaFormat getTrackFormat() {
         return mTrackFormat;
     }
 

+ 4 - 0
app/src/main/java/ch/threema/app/video/transcoder/UnrecoverableVideoTranscoderException.java

@@ -29,4 +29,8 @@ public class UnrecoverableVideoTranscoderException extends RuntimeException {
 	public UnrecoverableVideoTranscoderException(final String message) {
 		super(message);
 	}
+
+	public UnrecoverableVideoTranscoderException(final String message, final Exception exception) {
+		super(message, exception);
+	}
 }

+ 41 - 27
app/src/main/java/ch/threema/app/video/transcoder/VideoTranscoder.java

@@ -237,8 +237,10 @@ public class VideoTranscoder {
 		try {
 			setup();
 			setupSuccess = true;
+		} catch(UnrecoverableVideoTranscoderException ex) {
+			logger.error("Setup failed due to unrecoverable video transcoder exception: {}", ex.getMessage(), ex);
 		} catch (Exception ex) {
-			logger.error("Failed while setting up VideoTranscoder: {}" , mSrcUri, ex);
+			logger.error("Unexpected error while setting up VideoTranscoder for file {}: {}" , mSrcUri, ex.getMessage(), ex);
 		}
 
 		try {
@@ -271,14 +273,20 @@ public class VideoTranscoder {
 		createComponents();
 
 		setOrientationHint();
-		calculateOutputDimensions();
+		if (!calculateOutputDimensions()) {
+			throw new UnrecoverableVideoTranscoderException("Unable to calculate dimensions");
+		}
 
 		createOutputFormats();
 		createVideoEncoder();
 		createVideoDecoder();
 
 		if (shouldIncludeAudio()) {
-			final String mimeType = mInputAudioComponent.getTrackFormat().getString(MediaFormat.KEY_MIME);
+			final MediaFormat trackFormat = mInputAudioComponent.getTrackFormat();
+			if (trackFormat == null) {
+				throw new UnrecoverableVideoTranscoderException("Could not detect audio track despite transcoding requested.");
+			}
+			final String mimeType = trackFormat.getString(MediaFormat.KEY_MIME);
 			if (mimeType.equalsIgnoreCase(Defaults.OUTPUT_AUDIO_MIME_TYPE)) {
 				logger.info("Keeping audio track, as in- and output format match");
 				audioTranscoder = Optional.of(new AudioNullTranscoder(
@@ -836,6 +844,10 @@ public class VideoTranscoder {
 		mInputVideoComponent = new MediaComponent(mContext, mSrcUri, MediaComponent.COMPONENT_TYPE_VIDEO);
 
 		MediaFormat inputFormat = mInputVideoComponent.getTrackFormat();
+		if(inputFormat == null) {
+			throw new UnrecoverableVideoTranscoderException("Could not detect video track");
+		}
+
 		if (inputFormat.containsKey("rotation-degrees")) {
 			// Decoded video is rotated automatically in Android 5.0 lollipop.
 			// Turn off here because we don't want to encode rotated one.
@@ -851,15 +863,12 @@ public class VideoTranscoder {
 		}
 	}
 
-	private void calculateOutputDimensions() {
+	private boolean calculateOutputDimensions() {
 		MediaFormat trackFormat = mInputVideoComponent.getTrackFormat();
 
-		int inputWidth = trackFormat.getInteger(MediaFormat.KEY_WIDTH);
-		int inputHeight = trackFormat.getInteger(MediaFormat.KEY_HEIGHT);
-
-		// If this is a portrait video taken by a device that supports orientation hints, the resolution will be swapped.
-		// If its landscape, a screencap, or a device that doesn't support hints, it won't be.
-		if (inputWidth >= inputHeight || mOrientationHint == 0 || mOrientationHint == 180) {
+		if (trackFormat != null) {
+			int inputWidth = trackFormat.getInteger(MediaFormat.KEY_WIDTH);
+			int inputHeight = trackFormat.getInteger(MediaFormat.KEY_HEIGHT);
 
 			if (inputWidth > mOutputVideoWidth || inputHeight > mOutputVideoHeight) {
 				float ratio = Math.min(mOutputVideoWidth / (float) inputWidth, mOutputVideoHeight / (float) inputHeight);
@@ -869,16 +878,12 @@ public class VideoTranscoder {
 				mOutputVideoHeight = inputHeight;
 				mOutputVideoWidth = inputWidth;
 			}
-		} else {
-			if (inputHeight > mOutputVideoWidth || inputWidth > mOutputVideoHeight) {
-				float ratio = Math.min(mOutputVideoWidth / (float) inputHeight, mOutputVideoHeight / (float) inputWidth);
-				mOutputVideoHeight = getRoundedSize(ratio, inputWidth);
-				mOutputVideoWidth = getRoundedSize(ratio, inputHeight);
-			} else {
-				mOutputVideoHeight = inputWidth;
-				mOutputVideoWidth = inputHeight;
-			}
+
+			logger.info("Input dimensions: {}x{} Output dimensions: {}x{} Orientation: {}", inputWidth, inputHeight, mOutputVideoWidth, mOutputVideoHeight, mOrientationHint);
+
+			return true;
 		}
+		return false;
 	}
 
 	private int getRoundedSize(float ratio, int size) {
@@ -945,8 +950,14 @@ public class VideoTranscoder {
 
 	private void createVideoDecoder() throws IOException {
 		MediaFormat inputFormat = mInputVideoComponent.getTrackFormat();
+		if(inputFormat == null) {
+			throw new UnrecoverableVideoTranscoderException("Could not detect video track");
+		}
 
 		if (mTrimEndTimeMs == TRIM_TIME_END) {
+			if (!inputFormat.containsKey(MediaFormat.KEY_DURATION)) {
+				throw new UnrecoverableVideoTranscoderException("Video key length duration could not be detected");
+			}
 			outputDurationUs = inputFormat.getLong(MediaFormat.KEY_DURATION);
 		} else {
 			outputDurationUs = (mTrimEndTimeMs - mTrimStartTimeMs) * 1000;
@@ -966,9 +977,14 @@ public class VideoTranscoder {
 	private int getOutputVideoBitRate() {
 		int inputBitRate = mOutputVideoBitRate;
 
+		if (mInputVideoComponent.getTrackFormat() == null) {
+			throw new UnrecoverableVideoTranscoderException("Video format could not be detected");
+		}
+
 		if (mInputVideoComponent.getTrackFormat().containsKey(MediaFormat.KEY_BIT_RATE)) {
 			inputBitRate = mInputVideoComponent.getTrackFormat().getInteger(MediaFormat.KEY_BIT_RATE);
 		} else {
+			logger.info("Track format could not detect video bitrate");
 			final MediaMetadataRetriever retriever = new MediaMetadataRetriever();
 			try {
 				retriever.setDataSource(mContext, mSrcUri);
@@ -978,19 +994,17 @@ public class VideoTranscoder {
 					inputBitRate = Integer.parseInt(bitrate);
 				}
 			} catch (Exception e) {
-				logger.error("Error extracting bitrate", e);
+				throw new UnrecoverableVideoTranscoderException(
+					"Extracting Video bitrate failed",
+					e
+				);
 			} finally {
 				retriever.release();
+
 			}
 		}
 
-		if (false) {
-			// broken device
-			logger.info("Broken device that cannot properly read a video file's bitrate using MediaMetadataRetriever");
-			return mOutputVideoBitRate;
-		} else {
-			return Math.min(inputBitRate, mOutputVideoBitRate);
-		}
+		return Math.min(inputBitRate, mOutputVideoBitRate);
 	}
 
 	//endregion

+ 1 - 0
app/src/main/java/ch/threema/app/video/transcoder/audio/AbstractAudioTranscoder.java

@@ -92,6 +92,7 @@ public abstract class AbstractAudioTranscoder {
 	 * Should initialize outputFormat of the {@link AbstractAudioTranscoder} class.
 	 *
 	 * @throws IOException if a codec could not be initialized
+	 * @throws UnsupportedAudioFormatException if audio format is not supported by device
 	 */
 	public abstract void setup() throws IOException, UnsupportedAudioFormatException;
 

+ 11 - 1
app/src/main/java/ch/threema/app/video/transcoder/audio/AudioFormatTranscoder.java

@@ -154,6 +154,16 @@ public class AudioFormatTranscoder extends AbstractAudioTranscoder {
 
 		MediaFormat inputFormat = this.component.getTrackFormat();
 
+		if (inputFormat == null) {
+			throw new UnsupportedAudioFormatException("No input audio format could be detected");
+		}
+
+		if (!inputFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+			// Some manufacturer's buggy codec implementation return incomplete inputFormat objects.
+			// Observed on SM-A530F
+			throw new UnsupportedAudioFormatException("Audio format not properly supported by device manufacturer");
+		}
+
 		// Setup De/Encoder
 		this.setupAudioDecoder(inputFormat);
 		this.setupAudioEncoder(inputFormat);
@@ -161,7 +171,7 @@ public class AudioFormatTranscoder extends AbstractAudioTranscoder {
 		this.setState(State.DETECTING_INPUT_FORMAT);
 	}
 
-	private void setupAudioDecoder(MediaFormat inputFormat) throws IOException, UnsupportedAudioFormatException {
+	private void setupAudioDecoder(@NonNull MediaFormat inputFormat) throws IOException, UnsupportedAudioFormatException {
 		logger.debug("audio decoder: set sample rate to {}", inputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
 
 		if (logger.isDebugEnabled() && inputFormat.containsKey(MediaFormat.KEY_BIT_RATE)) {

+ 8 - 5
app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java

@@ -63,6 +63,7 @@ import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.SensorService;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
@@ -175,7 +176,13 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 			timerTextView = findViewById(R.id.timer_text);
 
 			sendButton = findViewById(R.id.send_button);
-			sendButton.setOnClickListener(this);
+			sendButton.setOnClickListener(new DebouncedOnClickListener(1000) {
+				@Override
+				public void onDebouncedClick(View v) {
+					stopAndReleaseMediaPlayer(mediaPlayer);
+					sendRecording(false);
+				}
+			});
 
 			discardButton = findViewById(R.id.discard_button);
 			discardButton.setOnClickListener(this);
@@ -626,10 +633,6 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 	@Override
 	public void onClick(View v) {
 		switch (v.getId()) {
-			case R.id.send_button:
-				stopAndReleaseMediaPlayer(mediaPlayer);
-				sendRecording(false);
-				break;
 			case R.id.discard_button:
 				stopAndReleaseMediaPlayer(mediaPlayer);
 				if (status == MediaState.STATE_RECORDING && getRecordingDuration() >= 5) {

+ 25 - 5
app/src/main/java/ch/threema/app/voip/activities/CallActivity.java

@@ -41,6 +41,7 @@ import android.media.AudioManager;
 import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.Handler;
 import android.os.SystemClock;
 import android.renderscript.Allocation;
 import android.renderscript.Element;
@@ -94,10 +95,8 @@ import androidx.transition.ChangeBounds;
 import androidx.transition.Transition;
 import androidx.transition.TransitionManager;
 import ch.threema.app.BuildConfig;
-import ch.threema.app.push.PushService;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.wearable.WearableHandler;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetListDialog;
@@ -108,6 +107,7 @@ import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.SensorListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.push.PushService;
 import ch.threema.app.routines.UpdateFeatureLevelRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.LifetimeService;
@@ -134,6 +134,7 @@ import ch.threema.app.voip.services.VideoContext;
 import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
+import ch.threema.app.wearable.WearableHandler;
 import ch.threema.client.APIConnector;
 import ch.threema.client.ThreemaFeature;
 import ch.threema.client.Utils;
@@ -277,6 +278,16 @@ public class CallActivity extends ThreemaActivity implements
 
 	private ContactModel contact;
 
+	private static final int KEEP_ALIVE_DELAY = 20000;
+	private final static Handler keepAliveHandler = new Handler();
+	private final Runnable keepAliveTask = new Runnable() {
+		@Override
+		public void run() {
+			ThreemaApplication.activityUserInteract(CallActivity.this);
+			keepAliveHandler.postDelayed(keepAliveTask, KEEP_ALIVE_DELAY);
+		}
+	};
+
 	/**
 	 * The result of a permission request.
 	 */
@@ -802,6 +813,10 @@ public class CallActivity extends ThreemaActivity implements
 				this.preferenceService.setRejectMobileCalls(false);
 			}
 		}
+
+		// make sure lock screen is not activated during call
+		keepAliveHandler.removeCallbacksAndMessages(null);
+		keepAliveHandler.postDelayed(keepAliveTask, KEEP_ALIVE_DELAY);
 	}
 
 	private boolean restoreState(@NonNull Intent intent, Bundle savedInstanceState) {
@@ -941,6 +956,9 @@ public class CallActivity extends ThreemaActivity implements
 
 		this.preferenceService.setPipPosition(pipPosition);
 
+		// remove lockscreen keepalive
+		keepAliveHandler.removeCallbacksAndMessages(null);
+
 		// Remove uncaught exception handler
 		Thread.setDefaultUncaughtExceptionHandler(null);
 
@@ -1518,9 +1536,11 @@ public class CallActivity extends ThreemaActivity implements
 				private GestureDetector gestureDetector = new GestureDetector(CallActivity.this, new GestureDetector.SimpleOnGestureListener() {
 					@Override
 					public boolean onSingleTapConfirmed(MotionEvent e) {
-						videoViews.pipVideoRenderer.setTranslationX(0);
-						videoViews.pipVideoRenderer.setTranslationY(0);
-						videoViews.pipVideoRenderer.performClick();
+						if (videoViews != null && videoViews.pipVideoRenderer != null) {
+							videoViews.pipVideoRenderer.setTranslationX(0);
+							videoViews.pipVideoRenderer.setTranslationY(0);
+							videoViews.pipVideoRenderer.performClick();
+						}
 						return true;
 					}
 				});

+ 3 - 0
app/src/main/java/ch/threema/app/voip/receivers/VoipMediaButtonReceiver.java

@@ -57,6 +57,9 @@ public class VoipMediaButtonReceiver extends BroadcastReceiver {
 
 		if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
 			KeyEvent mediaButtonEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
+			if (mediaButtonEvent == null) {
+				return;
+			}
 
 			logger.info("MediaButtonReceiver: mediaAction={}, keyCode={}",
 				intent.getAction(), mediaButtonEvent.getKeyCode());

+ 1 - 15
app/src/main/java/ch/threema/app/voip/services/VoipStateService.java

@@ -1455,21 +1455,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 				stopRingtone();
 			}
 
-			boolean isSystemMuted = !notificationManagerCompat.areNotificationsEnabled();
-
-			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-				/* we do not play a ringtone sound if system-wide DND is enabled - except for starred contacts */
-				switch (notificationManager.getCurrentInterruptionFilter()) {
-					case NotificationManager.INTERRUPTION_FILTER_NONE:
-						isSystemMuted = true;
-						break;
-					case NotificationManager.INTERRUPTION_FILTER_PRIORITY:
-						isSystemMuted = !DNDUtil.getInstance().isStarredContact(messageReceiver);
-						break;
-					default:
-						break;
-				}
-			}
+			boolean isSystemMuted = DNDUtil.getInstance().isSystemMuted(messageReceiver, notificationManager, notificationManagerCompat);
 
 			if (!isMuted && !isSystemMuted) {
 				audioManager.requestAudioFocus(this, AudioManager.STREAM_RING, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);

+ 7 - 1
app/src/main/java/ch/threema/app/webrtc/FlowControlledDataChannel.java

@@ -147,7 +147,13 @@ public class FlowControlledDataChannel {
 	 *            channel! When in doubt, post it to some other thread!
 	 */
 	public synchronized void bufferedAmountChange() {
-		final long bufferedAmount = this.dc.bufferedAmount();
+		final long bufferedAmount;
+		try {
+			bufferedAmount = this.dc.bufferedAmount();
+		} catch (IllegalStateException e) {
+			logger.warn("IllegalStateException when calling `dc.bufferedAmount`, data channel already disposed?");
+			return;
+		}
 
 		// Unpause once low water mark has been reached
 		if (bufferedAmount <= this.lowWaterMark && !this.readyFuture.isDone()) {

+ 4 - 3
app/src/main/java/ch/threema/client/ballot/BallotCreateMessage.java

@@ -21,15 +21,16 @@
 
 package ch.threema.client.ballot;
 
-import ch.threema.client.AbstractMessage;
-import ch.threema.client.ProtocolDefines;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.ByteArrayOutputStream;
 
+import ch.threema.client.AbstractMessage;
+import ch.threema.client.ProtocolDefines;
+
 /**
- * A group creation message.
+ * A ballot creation message.
  */
 public class BallotCreateMessage extends AbstractMessage
 	implements BallotCreateInterface{

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

@@ -249,7 +249,7 @@ public class FileDataModel implements MediaMessageDataInterface {
 	}
 
 	/**
-	 * Return the duration as set in the metadata field.
+	 * Return the duration in SECONDS as set in the metadata field.
 	 *
 	 * Note: Floats are converted to long integers.
 	 */

+ 4 - 0
app/src/main/res/drawable/fastscroll_thumb_media_dark.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+	<item android:drawable="@drawable/fastscroll_custom_thumb_dark" />
+</selector>

+ 9 - 0
app/src/main/res/drawable/ic_arrow_down_ios_black_24dp.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:pathData="M2.115,7.885l10,10l10,-10l-1.77,-1.77l-8.23,8.23l-8.23,-8.23z"
+      android:fillColor="#000000"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/shape_rounded_bottomsheet.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+	android:shape="rectangle">
+
+	<solid android:color="@color/dark_material_level3" />
+	<corners
+		android:topLeftRadius="16dp"
+		android:topRightRadius="16dp" />
+
+</shape>

+ 22 - 1
app/src/main/res/layout/activity_archive.xml

@@ -10,7 +10,28 @@
                                                  android:layout_width="match_parent"
                                                  android:layout_height="match_parent">
 
-	<include layout="@layout/toolbar_view"/>
+	<com.google.android.material.appbar.AppBarLayout
+		android:id="@+id/material_appbar"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content">
+
+		<FrameLayout
+			android:layout_width="match_parent"
+			android:layout_height="match_parent">
+
+		<com.google.android.material.appbar.MaterialToolbar
+			android:id="@+id/material_toolbar"
+			style="?attr/materialToolbarStyle"
+			android:layout_width="match_parent"
+			android:layout_height="?attr/actionBarSize"
+			app:menu="@menu/activity_archive"
+			app:navigationIcon="?attr/homeAsUpIndicator"/>
+
+			<include layout="@layout/connection_indicator"/>
+
+		</FrameLayout>
+
+	</com.google.android.material.appbar.AppBarLayout>
 
 	<ch.threema.app.ui.EmptyRecyclerView
 			android:id="@+id/recycler"

+ 57 - 3
app/src/main/res/layout/activity_media_attach.xml

@@ -8,6 +8,11 @@
 	android:layout_height="match_parent"
 	android:fitsSystemWindows="true">
 
+	<androidx.coordinatorlayout.widget.CoordinatorLayout
+		android:id="@+id/grid_container"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
 	<androidx.coordinatorlayout.widget.CoordinatorLayout
 		android:id="@+id/bottom_sheet_container"
 		android:layout_width="match_parent"
@@ -54,19 +59,21 @@
 	</androidx.coordinatorlayout.widget.CoordinatorLayout>
 
 	<com.google.android.material.appbar.AppBarLayout
-		android:id="@id/appbar_layout"
+		android:id="@+id/appbar_layout"
 		android:layout_width="match_parent"
 		android:layout_height="wrap_content"
 		android:background="@android:color/transparent"
 		app:elevation="0dp">
 
 		<com.google.android.material.appbar.MaterialToolbar
-			android:id="@id/toolbar"
+			android:id="@+id/toolbar"
+			style="?attr/materialToolbarStyle"
 			android:layout_width="match_parent"
 			android:layout_height="?attr/actionBarSize"
 			android:visibility="invisible"
 			app:menu="@menu/activity_media_attach"
-			app:navigationIcon="@null">
+			app:navigationIcon="@drawable/ic_arrow_down_ios_black_24dp"
+			app:navigationIconTint="?attr/textColorSecondary">
 
 			<LinearLayout
 				android:id="@+id/toolbar_title"
@@ -121,5 +128,52 @@
 		<include layout="@layout/date_separator" />
 	</FrameLayout>
 
+	</androidx.coordinatorlayout.widget.CoordinatorLayout>
+
+	<androidx.coordinatorlayout.widget.CoordinatorLayout
+		android:id="@+id/pager_container"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:visibility="gone">
+
+		<androidx.viewpager2.widget.ViewPager2
+			android:id="@+id/pager"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:background="@color/gallery_background" />
+
+		<com.google.android.material.appbar.AppBarLayout
+			android:id="@id/appbar_layout"
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content"
+			android:background="@color/preview_navigation_area_bg"
+			app:elevation="0dp">
+
+			<com.google.android.material.appbar.MaterialToolbar
+				android:id="@+id/preview_toolbar"
+				style="?attr/materialToolbarStyle"
+				android:layout_width="match_parent"
+				android:layout_height="?attr/actionBarSize"
+				android:background="@android:color/transparent"
+				app:navigationIcon="@drawable/ic_arrow_left"
+				app:navigationIconTint="@color/dark_text_color_secondary">
+
+				<ch.threema.app.ui.CheckableView
+					android:id="@+id/check_box"
+					android:layout_width="32dp"
+					android:layout_height="32dp"
+					android:layout_gravity="top|right"
+					android:layout_marginTop="12dp"
+					android:layout_marginRight="16dp"
+					android:background="@drawable/selector_grid_checkbox" />
+
+			</com.google.android.material.appbar.MaterialToolbar>
+
+		</com.google.android.material.appbar.AppBarLayout>
+
+		<include layout="@layout/bottom_sheet_media_preview"/>
+
+	</androidx.coordinatorlayout.widget.CoordinatorLayout>
+
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
 

+ 3 - 9
app/src/main/res/layout/activity_media_preview.xml

@@ -1,12 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout
+<androidx.viewpager2.widget.ViewPager2
 	xmlns:android="http://schemas.android.com/apk/res/android"
+	android:id="@+id/pager"
 	android:layout_width="match_parent"
-	android:layout_height="match_parent">
-
-	<androidx.fragment.app.FragmentContainerView
-		android:id="@id/fragment_container"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"/>
-
-</androidx.constraintlayout.widget.ConstraintLayout>
+	android:layout_height="match_parent" />

+ 1 - 1
app/src/main/res/layout/activity_sessions.xml

@@ -41,7 +41,7 @@
 					android:layout_gravity="center_horizontal"
 					android:layout_marginBottom="24dp"
 					android:src="@drawable/ic_phonelink_white_128dp"
-					android:tint="?attr/image_tint_default"/>
+					app:tint="?attr/image_tint_default" />
 
 				<TextView
 					android:id="@+id/empty_text"

+ 1 - 1
app/src/main/res/layout/activity_text_chat_bubble.xml

@@ -18,7 +18,7 @@
 			style="?attr/materialToolbarStyle"
 			android:layout_width="match_parent"
 			android:layout_height="?attr/actionBarSize"
-			app:navigationIcon="@drawable/ic_arrow_left"
+			app:navigationIcon="?attr/homeAsUpIndicator"
 			app:menu="@menu/activity_text_chat_bubble"/>
 
 	</com.google.android.material.appbar.AppBarLayout>

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