Browse Source

Version 4.53

Threema 4 years ago
parent
commit
f1b51cfe68
100 changed files with 2666 additions and 3158 deletions
  1. 3 1
      .github/pull_request_template.md
  2. 6 0
      CONTRIBUTING.md
  3. 11 0
      README.md
  4. 7 0
      app/assets/license.html
  5. 24 24
      app/build.gradle
  6. 7 1
      app/src/main/AndroidManifest.xml
  7. 60 154
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  8. 4 0
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  9. 3 3
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  10. 6 2
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  11. 6 0
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  12. 4 5
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  13. 2 2
      app/src/main/java/ch/threema/app/activities/GroupEditActivity.java
  14. 18 4
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  15. 1 1
      app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java
  16. 28 20
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  17. 27 3
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  18. 1 0
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  19. 10 8
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  20. 10 8
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  21. 2 2
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  22. 4 4
      app/src/main/java/ch/threema/app/adapters/GroupListAdapter.java
  23. 11 8
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  24. 4 9
      app/src/main/java/ch/threema/app/adapters/RecentListAdapter.java
  25. 21 2
      app/src/main/java/ch/threema/app/adapters/decorators/FirstUnreadChatAdapterDecorator.java
  26. 0 3
      app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java
  27. 1 1
      app/src/main/java/ch/threema/app/backuprestore/BackupChatService.java
  28. 3 3
      app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java
  29. 46 37
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  30. 8 2
      app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java
  31. 2 2
      app/src/main/java/ch/threema/app/dialogs/DateSelectorDialog.java
  32. 36 20
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  33. 39 11
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  34. 0 2
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java
  35. 3 3
      app/src/main/java/ch/threema/app/listeners/GroupListener.java
  36. 5 2
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  37. 1 1
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java
  38. 35 15
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  39. 9 1
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachAdapter.java
  40. 0 11
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachItem.java
  41. 1 155
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java
  42. 60 0
      app/src/main/java/ch/threema/app/mediaattacher/MediaFilterQuery.java
  43. 41 15
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java
  44. 112 288
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  45. 0 49
      app/src/main/java/ch/threema/app/mediaattacher/data/FailedMediaItemsDAO.java
  46. 0 43
      app/src/main/java/ch/threema/app/mediaattacher/data/ImageLabelListConverter.java
  47. 0 61
      app/src/main/java/ch/threema/app/mediaattacher/data/LabeledMediaItemsDAO.java
  48. 0 104
      app/src/main/java/ch/threema/app/mediaattacher/data/MediaItemsRoomDatabase.java
  49. 0 423
      app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelingWorker.java
  50. 0 487
      app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelsIndexHashMap.java
  51. 1 5
      app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java
  52. 1 71
      app/src/main/java/ch/threema/app/preference/SettingsMediaFragment.java
  53. 6 4
      app/src/main/java/ch/threema/app/processors/MessageProcessor.java
  54. 0 147
      app/src/main/java/ch/threema/app/routines/LinkWithMobileNumberRoutine.java
  55. 3 2
      app/src/main/java/ch/threema/app/services/AvatarCacheService.java
  56. 17 23
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  57. 3 2
      app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java
  58. 8 0
      app/src/main/java/ch/threema/app/services/ConversationTagService.java
  59. 21 5
      app/src/main/java/ch/threema/app/services/ConversationTagServiceImpl.java
  60. 61 42
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  61. 9 4
      app/src/main/java/ch/threema/app/services/GroupService.java
  62. 75 35
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  63. 50 14
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  64. 0 26
      app/src/main/java/ch/threema/app/services/NotificationService.java
  65. 7 86
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  66. 2 5
      app/src/main/java/ch/threema/app/services/PreferenceService.java
  67. 80 92
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  68. 114 0
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion64.java
  69. 1 1
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeAdvancedDialog.java
  70. 9 1
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  71. 3 3
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  72. 8 0
      app/src/main/java/ch/threema/app/ui/CountBoxView.java
  73. 0 4
      app/src/main/java/ch/threema/app/ui/GridRecyclerView.java
  74. 0 5
      app/src/main/java/ch/threema/app/ui/ImagePopup.java
  75. 2 15
      app/src/main/java/ch/threema/app/ui/MediaGridItemDecoration.java
  76. 0 1
      app/src/main/java/ch/threema/app/ui/MentionSelectorPopup.java
  77. 9 0
      app/src/main/java/ch/threema/app/ui/ThreemaEditText.java
  78. 164 76
      app/src/main/java/ch/threema/app/ui/ZoomableExoPlayerView.java
  79. 14 9
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  80. 49 6
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  81. 23 0
      app/src/main/java/ch/threema/app/utils/IntentDataUtil.java
  82. 35 17
      app/src/main/java/ch/threema/app/utils/PowermanagerUtil.java
  83. 1 1
      app/src/main/java/ch/threema/app/video/transcoder/InputSurface.java
  84. 9 6
      app/src/main/java/ch/threema/app/video/transcoder/MediaComponent.java
  85. 1 1
      app/src/main/java/ch/threema/app/video/transcoder/OutputSurface.java
  86. 1 1
      app/src/main/java/ch/threema/app/video/transcoder/TextureRenderer.java
  87. 6 19
      app/src/main/java/ch/threema/app/video/transcoder/UnrecoverableVideoTranscoderException.java
  88. 1 1
      app/src/main/java/ch/threema/app/video/transcoder/VideoConfig.java
  89. 198 348
      app/src/main/java/ch/threema/app/video/transcoder/VideoTranscoder.java
  90. 195 0
      app/src/main/java/ch/threema/app/video/transcoder/audio/AbstractAudioTranscoder.java
  91. 9 21
      app/src/main/java/ch/threema/app/video/transcoder/audio/AudioComponent.java
  92. 567 0
      app/src/main/java/ch/threema/app/video/transcoder/audio/AudioFormatTranscoder.java
  93. 124 0
      app/src/main/java/ch/threema/app/video/transcoder/audio/AudioNullTranscoder.java
  94. 10 16
      app/src/main/java/ch/threema/app/video/transcoder/audio/UnsupportedAudioFormatException.java
  95. 15 13
      app/src/main/java/ch/threema/app/voicemessage/AudioRecorder.java
  96. 37 18
      app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java
  97. 1 0
      app/src/main/java/ch/threema/app/voip/Config.java
  98. 5 0
      app/src/main/java/ch/threema/app/voip/PeerConnectionClient.java
  99. 28 12
      app/src/main/java/ch/threema/app/voip/services/VoipCallService.java
  100. 1 0
      app/src/main/java/ch/threema/app/webclient/Protocol.java

+ 3 - 1
.github/pull_request_template.md

@@ -1,7 +1,9 @@
 ## Description
 
 <!-- Please describe your change. If the change concerns the user interface,
-please include screenshots. -->
+please include screenshots. Note that improvements to translation strings
+should be submitted through OneSky, see CONTRIBUTING.md or README.md for
+more information. -->
 
 ## Checklist
 

+ 6 - 0
CONTRIBUTING.md

@@ -10,3 +10,9 @@ for more information on how to contribute.
 
 To report bugs and request new features, please contact the Threema support
 team through [threema.ch/support](https://threema.ch/support).
+
+## Translations
+
+We manage our app translations through OneSky. If you're interested in
+improving translations, or if you would like to translate Threema to a new
+language, please sign up at <https://threema.oneskyapp.com/collaboration/>.

+ 11 - 0
README.md

@@ -21,6 +21,7 @@ This repository contains the complete source code of
 - [Reproducible Builds](#reproducible-builds)
 - [Code Organization / Architecture](#architecture)
 - [Contributions](#contributions)
+- [Translating](#translating)
 - [License](#license)
 
 
@@ -189,6 +190,16 @@ We accept GitHub pull requests. Please refer to
 <https://threema.ch/open-source/contributions>
 for more information on how to contribute.
 
+Note that translation fixes should not be contributed through GitHub but
+through OneSky, see next section.
+
+
+## <a name="translating"></a>Translating
+
+We manage our app translations through OneSky. If you're interested in
+improving translations, or if you would like to translate Threema to a new
+language, please sign up at <https://threema.oneskyapp.com/collaboration/>.
+
 
 ## <a name="license"></a>License
 

+ 7 - 0
app/assets/license.html

@@ -49,6 +49,13 @@
 <p>This product contains artwork and code from the following rights holders:</p>
 
 
+<h2>Android Fast Scroll</h2>
+
+<p>Copyright 2019 Google LLC</p>
+
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+
+
 <h2>Android Gesture Detectors Framework</h2>
 
 <p>Copyright (c) 2013, Almer Thie</p>

+ 24 - 24
app/build.gradle

@@ -75,8 +75,8 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 671
-        versionName "4.52"
+        versionCode 675
+        versionName "4.53"
         resValue "string", "version_name_suffix", ""
         resValue "string", "app_name", "Threema"
         resValue "string", "uri_scheme", "threema"
@@ -141,7 +141,7 @@ android {
         }
         store_threema { }
         store_google_work {
-            versionName "4.52k"
+            versionName "4.53k"
             applicationId "ch.threema.app.work"
             testApplicationId 'ch.threema.app.work.test'
             resValue "string", "package_name", applicationId
@@ -178,7 +178,7 @@ 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.52k"
+            versionName "4.53k"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
@@ -208,7 +208,7 @@ android {
             ]
         }
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
-            versionName "4.52r"
+            versionName "4.53r"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
 
@@ -428,8 +428,8 @@ dependencies {
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
     implementation 'net.sf.opencsv:opencsv:2.3'
-    implementation 'net.lingala.zip4j:zip4j:2.6.4'
-    implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.0'
+    implementation 'net.lingala.zip4j:zip4j:2.7.0'
+    implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.2'
     implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.2.1'
     // commons-io 2.6 requires android 4.4
     implementation 'commons-io:commons-io:2.4'
@@ -439,42 +439,42 @@ dependencies {
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.3'
     implementation 'com.takisoft.preferencex:preferencex:1.1.0'
     implementation 'com.takisoft.preferencex:preferencex-datetimepicker:1.1.0'
+    implementation 'me.zhanghai.android.fastscroll:library:1.1.5'
 
     // AndroidX / Jetpack support libraries
     implementation "androidx.preference:preference:1.1.1"
     implementation 'androidx.legacy:legacy-support-v13:1.0.0'
-    implementation 'androidx.recyclerview:recyclerview:1.1.0'
+    implementation 'androidx.recyclerview:recyclerview:1.2.0'
     implementation 'androidx.palette:palette:1.0.0'
     implementation 'androidx.appcompat:appcompat:1.2.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.2.5'
-    implementation 'androidx.activity:activity:1.1.0'
+    implementation 'androidx.fragment:fragment:1.3.3'
+    implementation 'androidx.activity:activity:1.2.2'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.0.0-rc03"
-    implementation "androidx.camera:camera-lifecycle:1.0.0-rc03"
-    implementation "androidx.camera:camera-view:1.0.0-alpha22"
+    implementation "androidx.camera:camera-camera2:1.0.0-rc05"
+    implementation "androidx.camera:camera-lifecycle:1.0.0-rc05"
+    implementation "androidx.camera:camera-view:1.0.0-alpha23"
     implementation 'androidx.multidex:multidex:2.0.1'
-    implementation "androidx.lifecycle:lifecycle-viewmodel:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-livedata:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-runtime:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-service:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-process:2.2.0"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.2.0"
+    implementation "androidx.lifecycle:lifecycle-viewmodel:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-livedata:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-runtime:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-service:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-process:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-common-java8:2.3.1"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
     implementation "androidx.paging:paging-runtime:2.1.2"
 
     implementation 'com.google.android.material:material:1.3.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.12.1'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.12.1'
-    implementation 'com.google.mlkit:image-labeling:17.0.2'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.13.3'
+    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.17'
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.20'
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.20'

+ 7 - 1
app/src/main/AndroidManifest.xml

@@ -116,7 +116,8 @@
 		android:name="android.hardware.camera.any"
 		android:required="false"/>
 
-	<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2, androidx.camera.lifecycle, androidx.camera.view" />
+	<uses-sdk tools:overrideLibrary="androidx.camera.core, androidx.camera.camera2,
+		androidx.camera.lifecycle, androidx.camera.view, me.zhanghai.android.fastscroll" />
 
 	<application
 		android:name=".ThreemaApplication"
@@ -415,6 +416,11 @@
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:theme="@style/Theme.Threema.Translucent">
 		</activity>
+		<activity
+			android:name=".activities.NotesEditActivity"
+			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
+			android:theme="@style/Theme.Threema.Translucent">
+		</activity>
 		<activity
 			android:name=".activities.GroupDetailActivity"
 			android:theme="@style/Theme.Threema.TransparentStatusbar"

+ 60 - 154
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -80,7 +80,6 @@ import androidx.appcompat.app.AppCompatDelegate;
 import androidx.core.content.ContextCompat;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.LiveData;
 import androidx.lifecycle.ProcessLifecycleOwner;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.multidex.MultiDexApplication;
@@ -108,7 +107,6 @@ import ch.threema.app.listeners.ServerMessageListener;
 import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.mediaattacher.labeling.ImageLabelingWorker;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -236,7 +234,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final int WORK_SYNC_NOTIFICATION_ID = 735;
 	public static final int NEW_SYNCED_CONTACTS_NOTIFICATION_ID = 736;
 	public static final int WEB_RESUME_FAILED_NOTIFICATION_ID = 737;
-	public static final int IMAGE_LABELING_NOTIFICATION_ID = 738;
 	public static final int PASSPHRASE_SERVICE_NOTIFICATION_ID = 587;
 	public static final int INCOMING_CALL_NOTIFICATION_ID = 800;
 
@@ -261,7 +258,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	private static final int WORK_SYNC_JOB_ID = 63339;
 
 	private static final String WORKER_IDENTITY_STATES_PERIODIC_NAME = "IdentityStates";
-	public static final String WORKER_IMAGE_LABELS_PERIODIC = "ImageLabelsPeriodic";
 
 	private static Context context;
 
@@ -297,6 +293,31 @@ 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;
+
+			if (previousMemberCount != groupService.countMembers(groupModel) && groupService.isGroupOwner(groupModel)) {
+				if (groupService.isNotesGroup(groupModel)) {
+					if (previousMemberCount == 2 || previousMemberCount == 0) {
+						notice = serviceManager.getContext().getString(R.string.status_create_notes);
+					}
+				} else {
+					if (previousMemberCount == 1) {
+						notice = serviceManager.getContext().getString(R.string.status_create_notes_off);
+					}
+				}
+
+				if (notice != null) {
+					messageService.createStatusMessage(notice, groupService.createReceiver(groupModel));
+				}
+			}
+		}
+	}
+
 	@Override
 	public void onCreate() {
 		if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -942,14 +963,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				scheduleIdentityStatesSync(preferenceStore);
 			}).start();
 
-			if (ConfigUtils.isPlayServicesInstalled(getAppContext())) {
-				cleanupOldLabelDatabase();
-
-				if (preferenceStore.getBoolean(getAppContext().getString(R.string.preferences__image_labeling))) {
-					scheduleImageLabelingWork();
-				}
-			}
-
 			initMapbox();
 		} catch (MasterKeyLockedException e) {
 			logger.error("Exception", e);
@@ -1041,109 +1054,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		return false;
 	}
 
-	/**
-	 * Clean up any pre-existing labeling database. This will only exist on devices
-	 * of private beta testers and can be removed in the final release.
-	 */
-	@Deprecated
-	public static void cleanupOldLabelDatabase() {
-		new Thread(() -> {
-			try {
-				final File databasePath = getAppContext().getDatabasePath("media_items_database");
-				if (databasePath.exists() && databasePath.isFile()) {
-					logger.info("Removing stale media_items_database file");
-					if (!databasePath.delete()) {
-						logger.warn("Could not remove stale media_items_database file");
-					}
-				} else {
-					logger.debug("No stale media_items_database file found");
-				}
-			} catch (Exception e) {
-				logger.error("Exception while cleaning up old label database");
-			}
-		}, "OldLabelDatabaseCleanup").start();
-	}
-
-	/**
-	 * Schedule the recurring image labeling task every 24h.
-	 */
-	public static void scheduleImageLabelingWork() {
-		if (!ConfigUtils.isPlayServicesInstalled(context)) {
-			// Image labeling requires play services (for ML Kit)
-			return;
-		}
-		try {
-			final WorkManager workManager = WorkManager.getInstance(context);
-			final NotificationService notificationService = serviceManager.getNotificationService();
-
-			LiveData<List<WorkInfo>> oneTimeLabelingWorkInfo;
-			LiveData<List<WorkInfo>> periodicTimeLabelingWorkInfo;
-
-			// observe worker states and cancel if blocked
-			oneTimeLabelingWorkInfo = workManager.getWorkInfosByTagLiveData(ImageLabelingWorker.UNIQUE_WORK_NAME);
-			periodicTimeLabelingWorkInfo = workManager.getWorkInfosByTagLiveData(WORKER_IMAGE_LABELS_PERIODIC);
-
-			oneTimeLabelingWorkInfo.observe(ProcessLifecycleOwner.get(), workInfos -> {
-				// If there are no matching work infos, do nothing
-				if (workInfos == null || workInfos.isEmpty()) {
-					return;
-				}
-
-				// We only care about the first output status.
-				// Every continuation has only one worker tagged TAG_OUTPUT
-				WorkInfo workInfo = workInfos.get(0);
-				WorkInfo.State state = workInfo.getState();
-				logger.debug("workstate one time label work " + state);
-				if (state == WorkInfo.State.BLOCKED) {
-					logger.info("Cancel image one time labeling work, worker is blocked");
-					workManager.cancelUniqueWork(ImageLabelingWorker.UNIQUE_WORK_NAME);
-					notificationService.showImageLabelingWorkerStuckNotification();
-				}
-			} );
-
-			periodicTimeLabelingWorkInfo.observe(ProcessLifecycleOwner.get(), workInfos -> {
-				// If there are no matching work infos, do nothing
-				if (workInfos == null || workInfos.isEmpty()) {
-					return;
-				}
-
-				// We only care about the first output status.
-				// Every continuation has only one worker tagged TAG_OUTPUT
-				WorkInfo workInfo = workInfos.get(0);
-				WorkInfo.State state = workInfo.getState();
-
-				logger.debug("workstate periodic label work " + state);
-				if (state == WorkInfo.State.BLOCKED) {
-					logger.info("Cancel periodic image labeling work, worker is blocked");
-					workManager.cancelUniqueWork(WORKER_IMAGE_LABELS_PERIODIC);
-					notificationService.showImageLabelingWorkerStuckNotification();
-				}
-			});
-
-
-			// Only run if storage and battery are both not low
-			final Constraints.Builder constraintsLabelingWork = new Constraints.Builder()
-				.setRequiresStorageNotLow(true)
-				.setRequiresBatteryNotLow(true);
-			// On API >= 23, require that the device is idle, in order not to disturb running apps
-			if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
-				constraintsLabelingWork.setRequiresDeviceIdle(true);
-			}
-			final Constraints constraints = constraintsLabelingWork.build();
-
-			// Run every 24h
-			final PeriodicWorkRequest.Builder workBuilder = new PeriodicWorkRequest.Builder(ImageLabelingWorker.class, 24, TimeUnit.HOURS)
-				.addTag(WORKER_IMAGE_LABELS_PERIODIC)
-				.setConstraints(constraints);
-			final PeriodicWorkRequest labelingWorkRequest = workBuilder.build();
-
-			logger.info("Scheduling image labeling work");
-			workManager.enqueueUniquePeriodicWork(WORKER_IMAGE_LABELS_PERIODIC, ExistingPeriodicWorkPolicy.REPLACE, labelingWorkRequest);
-		} catch (IllegalStateException e) {
-			logger.error("Unable to initialize WorkManager", e);
-		}
-	}
-
 	private static boolean scheduleWorkSync(PreferenceStore preferenceStore) {
 		if (!ConfigUtils.isWorkBuild()) {
 			return false;
@@ -1187,6 +1097,7 @@ 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);
 				}
@@ -1196,7 +1107,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onRename(GroupModel groupModel) {
 				try {
 					serviceManager.getConversationService().refresh(groupModel);
-
 					serviceManager.getMessageService().createStatusMessage(
 							serviceManager.getContext().getString(R.string.status_rename_group, groupModel.getName()),
 							serviceManager.getGroupService().createReceiver(groupModel));
@@ -1225,23 +1135,16 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(groupModel);
 
-					//remove all ballots please!
 					serviceManager.getBallotService().remove(receiver);
-
 					serviceManager.getConversationService().removed(groupModel);
-
-//					serviceManager.getConversationService().notifyChanges(true);
-
 					serviceManager.getNotificationService().cancel(new GroupMessageReceiver(groupModel, null, null, null, null));
-
-
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
 			}
 
 			@Override
-			public void onNewMember(GroupModel group, String newIdentity) {
+			public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
 				String memberName = newIdentity;
 				ContactModel contactModel;
 				try {
@@ -1262,37 +1165,46 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								serviceManager.getContext().getString(R.string.status_group_new_member, memberName),
 								receiver);
 
-						//send all open ballots to the new group member
-						BallotService ballotService = serviceManager.getBallotService();
-						if (ballotService != null) {
-							List<BallotModel> openBallots = ballotService.getBallots(new BallotService.BallotFilter() {
-								@Override
-								public MessageReceiver getReceiver() {
-									return receiver;
-								}
+						if ((!myIdentity.equals(group.getCreatorIdentity())) || previousMemberCount > 1) {
+							//send all open ballots to the new group member
+							BallotService ballotService = serviceManager.getBallotService();
+							if (ballotService != null) {
+								List<BallotModel> openBallots = ballotService.getBallots(new BallotService.BallotFilter() {
+									@Override
+									public MessageReceiver getReceiver() {
+										return receiver;
+									}
 
-								@Override
-								public BallotModel.State[] getStates() {
-									return new BallotModel.State[]{BallotModel.State.OPEN};
-								}
+									@Override
+									public BallotModel.State[] getStates() {
+										return new BallotModel.State[]{BallotModel.State.OPEN};
+									}
+
+									@Override
+									public boolean filter(BallotModel ballotModel) {
+										//only my ballots please
+										return ballotModel.getCreatorIdentity().equals(myIdentity);
+									}
+								});
 
-								@Override
-								public boolean filter(BallotModel ballotModel) {
-									//only my ballots please
-									return ballotModel.getCreatorIdentity().equals(myIdentity);
+								for (BallotModel ballotModel : openBallots) {
+									ballotService.publish(receiver, ballotModel, null, newIdentity);
 								}
-							});
+							}
+						}
 
-							for (BallotModel ballotModel : openBallots) {
-								ballotService.publish(receiver, ballotModel, null, newIdentity);
+						if (myIdentity.equals(group.getCreatorIdentity())) {
+							if (previousMemberCount == 1) {
+								showNotesGroupNotice(group, previousMemberCount);
 							}
 						}
 					}
+
 				} catch (ThreemaException x) {
 					logger.error("Exception", x);
 				}
 
-				//reset avatar to recreate him!
+				//reset avatar to recreate it!
 				try {
 					serviceManager.getAvatarCacheService()
 							.reset(group);
@@ -1302,7 +1214,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			}
 
 			@Override
-			public void onMemberLeave(GroupModel group, String identity) {
+			public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 				String memberName = identity;
 				ContactModel contactModel;
 				try {
@@ -1319,10 +1231,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							serviceManager.getContext().getString(R.string.status_group_member_left, memberName),
 							receiver);
 
-					//remove group ballots from user
-
-					//send all open ballots to the new group member
-
+					showNotesGroupNotice(group, previousMemberCount);
 
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
@@ -1332,7 +1241,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			}
 
 			@Override
-			public void onMemberKicked(GroupModel group, String identity) {
+			public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
 				final String myIdentity = serviceManager.getUserService().getIdentity();
 
 				if (myIdentity != null && myIdentity.equals(identity)) {
@@ -1360,10 +1269,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							serviceManager.getContext().getString(R.string.status_group_member_kicked, memberName),
 							receiver);
 
-					//remove group ballots from user
-
-					//send all open ballots to the new group member
-
+					showNotesGroupNotice(group, previousMemberCount);
 
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);

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

@@ -219,6 +219,10 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 				if (resultCode == RESULT_OK) {
 					serviceManager.getScreenLockService().setAuthenticated(true);
+					if (composeMessageFragment != null) {
+						// mark conversation as read as soon as it's unhidden
+						composeMessageFragment.markAsRead();
+					}
 				} else {
 					finish();
 				}

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

@@ -236,21 +236,21 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity) {
+		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
 			if (newIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String leftIdentity) {
+		public void onMemberLeave(GroupModel group, String leftIdentity, int previousMemberCount) {
 			if (leftIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate); ;
 			}
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String kickedIdentity) {
+		public void onMemberKicked(GroupModel group, String kickedIdentity, int previousMemberCount) {
 			if (kickedIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate); ;
 			}

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

@@ -47,6 +47,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 
 	private static final String DIALOG_TAG_CREATING_GROUP = "groupCreate";
 	private static final String BUNDLE_GROUP_IDENTITIES = "grId";
+
 	private String[] groupIdentities;
 
 	@Override
@@ -64,7 +65,9 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 			actionBar.setTitle("");
 		}
 
-		this.groupIdentities = IntentDataUtil.getContactIdentities(getIntent());
+		if (getIntent() != null) {
+			this.groupIdentities = IntentDataUtil.getContactIdentities(getIntent());
+		}
 
 		if (savedInstanceState == null) {
 			launchGroupSetNameAndAvatarDialog();
@@ -111,7 +114,8 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 	}
 
 	private void creatingGroupDone(GroupModel newModel) {
-		Toast.makeText(ThreemaApplication.getAppContext(), getString(R.string.group_created_confirm), Toast.LENGTH_LONG).show();
+		Toast.makeText(ThreemaApplication.getAppContext(),
+			getString(R.string.group_created_confirm), Toast.LENGTH_LONG).show();
 
 		Intent intent = new Intent(this, ComposeMessageActivity.class);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, newModel.getId());

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

@@ -33,6 +33,7 @@ import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.IntentDataUtil;
@@ -43,6 +44,7 @@ import ch.threema.storage.models.GroupModel;
 public class GroupAddActivity extends MemberChooseActivity implements GenericAlertDialog.DialogClickListener {
 	private static final String BUNDLE_EXISTING_MEMBERS = "ExMem";
 	private static final String DIALOG_TAG_NO_MEMBERS = "NoMem";
+	private static final String DIALOG_TAG_NOTE_GROUP_HOWTO = "note_group_hint";
 
 	private GroupService groupService;
 	private GroupModel groupModel;
@@ -107,6 +109,10 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 		}
 
 		initList();
+
+		if (!appendMembers) {
+			ShowOnceDialog.newInstance(R.string.title_select_contacts, R.string.note_group_howto ).show(getSupportFragmentManager(), DIALOG_TAG_NOTE_GROUP_HOWTO);
+		}
 	}
 
 	@Override

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

@@ -236,12 +236,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity) {
+		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String identity) {
+		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 			if (identity.equals(myIdentity)) {
 				finishUp();
 			} else {
@@ -250,7 +250,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String identity) {
+		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
 			if (identity.equals(myIdentity)) {
 				finishUp();
 			} else {
@@ -695,8 +695,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					model = groupService.createGroup(
 							newGroupName,
 							groupService.getGroupIdentities(groupModel),
-							avatar
-					);
+							avatar);
 				} catch (Exception e) {
 					logger.error("Exception", e);
 					return null;

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

@@ -74,8 +74,8 @@ public abstract class GroupEditActivity extends ThreemaToolbarActivity {
 					inputType,
 					avatarFile,
 					isAvatarRemoved,
-					GroupModel.GROUP_NAME_MAX_LENGTH_BYTES
-		).show(getSupportFragmentManager(), DIALOG_TAG_GROUPNAME);
+					GroupModel.GROUP_NAME_MAX_LENGTH_BYTES)
+			.show(getSupportFragmentManager(), DIALOG_TAG_GROUPNAME);
 	}
 
 	@Override

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

@@ -62,6 +62,7 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
 
 import androidx.annotation.AnyThread;
@@ -104,6 +105,7 @@ import ch.threema.app.preference.SettingsActivity;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
+import ch.threema.app.services.ConversationTagService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.LifetimeService;
@@ -141,6 +143,7 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
 import static ch.threema.app.voip.services.VoipCallService.ACTION_HANGUP;
 import static ch.threema.app.voip.services.VoipCallService.EXTRA_ACTIVITY_MODE;
 import static ch.threema.app.voip.services.VoipCallService.EXTRA_CONTACT_IDENTITY;
@@ -234,10 +237,16 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private String currentFragmentTag;
 
 	private static class UpdateBottomNavigationBadgeTask extends AsyncTask<Void, Void, Integer> {
-		WeakReference<Activity> activityWeakReference;
+		private ConversationTagService conversationTagService = null;
+		private final WeakReference<Activity> activityWeakReference;
 
 		UpdateBottomNavigationBadgeTask(Activity activity) {
 			activityWeakReference = new WeakReference<>(activity);
+			try {
+				conversationTagService = Objects.requireNonNull(ThreemaApplication.getServiceManager()).getConversationTagService();
+			} catch (Exception e) {
+				logger.error("UpdateBottomNav", e);
+			}
 		}
 
 		@Override
@@ -281,6 +290,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				unread += conversationModel.getUnreadCount();
 			}
 
+			if (conversationTagService != null) {
+				unread += conversationTagService.getCount(conversationTagService.getTagModel(FIXED_TAG_UNREAD));
+			}
+
 			return unread;
 		}
 
@@ -581,7 +594,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				}
 
 				if (!ConfigUtils.isWorkBuild() && !RuntimeUtil.isInTest() && !isFinishing()) {
-					isWhatsNewShown = true; // make sure this is set false if no whatsnew activity is shown - otherwise pin lock will be skipped once
+/*
+					isWhatsNewShown = true; // make sure this is set to false if no whatsnew activity is shown - otherwise pin lock will be skipped once
 
 					// Do not show whatsnew for users of the previous 4.5x version
 					int previous = preferenceService.getLatestVersion() % 1000;
@@ -591,8 +605,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 						overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 					} else {
-						isWhatsNewShown = false;
-					}
+*/						isWhatsNewShown = false;
+//					}
 					preferenceService.setLatestVersion(this);
 				}
 			}

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

@@ -87,7 +87,7 @@ public class ImagePaintKeyboardActivity extends ThreemaToolbarActivity {
 
 				int keyboardHeight = rootView.getRootView().getHeight() - (statusBarHeight + navigationBarHeight + rect.height());
 
-				if ((currentKeyboardHeight - keyboardHeight) > getResources().getDimensionPixelSize(R.dimen.min_keyboard_size)) {
+				if ((currentKeyboardHeight - keyboardHeight) > getResources().getDimensionPixelSize(R.dimen.min_keyboard_height)) {
 					returnResult(textEntry.getText());
 				}
 				currentKeyboardHeight = keyboardHeight;

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

@@ -464,14 +464,20 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							}
 						} else {
 							if (uri != null) {
+								// guess the correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*" which should be overridden
 								String guessedType = getMimeTypeFromContentUri(uri);
 								if (guessedType != null) {
 									type = guessedType;
 								}
 
-								// if text was shared along with the media item, add that too
 								String textIntent = getTextFromIntent(intent);
-								addMediaItem(type, uri, textIntent);
+								// don't add fixed caption to media item because we want it to be editable when sending a zip file (share chat)
+								if (type.equals("application/zip") && textIntent != null) {
+									captionText = textIntent;
+									mediaItems.add(new MediaItem(uri, MediaItem.TYPE_FILE, MimeUtil.MIME_TYPE_ZIP, textIntent));
+								} else { // if text was shared along with the media item, add that too
+									addMediaItem(type, uri, textIntent);
+								}
 							}
 						}
 					} else {
@@ -636,13 +642,13 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						return mimeType;
 					}
 				}
-			} catch (Exception e) {
-				String filemame = FileUtil.getFilenameFromUri(getContentResolver(), uri);
-				if (!TestUtil.empty(filemame)) {
-					String mimeType = FileUtil.getMimeTypeFromPath(filemame);
-					if (!TestUtil.empty(mimeType)) {
-						return mimeType;
-					}
+			} catch (Exception ignored) { }
+
+			String filemame = FileUtil.getFilenameFromUri(getContentResolver(), uri);
+			if (!TestUtil.empty(filemame)) {
+				String mimeType = FileUtil.getMimeTypeFromPath(filemame);
+				if (!TestUtil.empty(mimeType)) {
+					return mimeType;
 				}
 			}
 		}
@@ -687,7 +693,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			try {
 				getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
 			} catch (Exception e) {
-				logger.error("Exception", e);
+				logger.info("Unable to take persistable uri permission");
+				uri = FileUtil.getFileUri(uri);
 			}
 		}
 		mediaItems.add(new MediaItem(uri, mimeType, caption));
@@ -823,8 +830,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_VIDEO, null, FileData.RENDERING_MEDIA, null);
 							break;
 						case VOICEMESSAGE:
-							// voice messages should always be forwarded as files in order not to appear to be recorded by the forwarder
-							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_FILE, MimeUtil.MIME_TYPE_AUDIO_AAC, FileData.RENDERING_DEFAULT, null);
+							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_VOICEMESSAGE, MimeUtil.MIME_TYPE_AUDIO_AAC, FileData.RENDERING_MEDIA, null);
 							break;
 						case FILE:
 							int mediaType = MediaItem.TYPE_FILE;
@@ -840,9 +846,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 									} else {
 										mediaType = MediaItem.TYPE_IMAGE;
 									}
-								} else {
-									// voice messages should always be forwarded as files in order not to appear to be recorded by the forwarder
-									renderingType = FileData.RENDERING_DEFAULT;
+								} else if (MimeUtil.isAudioFile(mimeType)) {
+									mediaType = MediaItem.TYPE_VOICEMESSAGE;
 								}
 							}
 							sendForwardedMedia(messageReceivers, uri, captionText, mediaType, mimeType, renderingType, messageModel.getFileData().getFileName());
@@ -943,7 +948,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					alertDialog.setData(models);
 					alertDialog.show(getSupportFragmentManager(), null);
 				} else {
-					// content shared by external apps may be referred to by content URIs. we have to copy these files first in order to be able to access it in another activity
+					// content shared by external apps may be referred to by content URIs which will not survive this activity. so in order to be able to use them later we have to copy these files to a local directory first
 					String finalRecipientName = recipientName;
 					GenericProgressDialog.newInstance(R.string.importing_files, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_FILECOPY);
 					try {
@@ -978,7 +983,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 									}
 								} else {
 									// mixed media
-									ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, null, R.string.send, R.string.cancel, false);
+									ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, mediaItems.size() == 1);
 									alertDialog.setData(models);
 									alertDialog.show(getSupportFragmentManager(), null);
 								}
@@ -1235,9 +1240,12 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		this.captionText = text;
 
 		if (data instanceof ArrayList) {
-			prepareComposeIntent((ArrayList<Object>)data);
-		} else {
-			prepareComposeIntent(new ArrayList<>(Arrays.asList(data)));
+			if (!TestUtil.empty(text)) {
+				for (MediaItem mediaItem : mediaItems) {
+					mediaItem.setCaption(text);
+				}
+			}
+			prepareComposeIntent((ArrayList<Object>) data);
 		}
 	}
 

+ 27 - 3
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java

@@ -92,6 +92,7 @@ import ch.threema.app.camera.VideoEditView;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.emojis.EmojiButton;
 import ch.threema.app.emojis.EmojiPicker;
+import ch.threema.app.mediaattacher.MediaFilterQuery;
 import ch.threema.app.mediaattacher.MediaSelectionActivity;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.DeadlineListService;
@@ -171,6 +172,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	private boolean useExternalCamera;
 	private VideoEditView videoEditView;
 	private MenuItem settingsItem;
+	private MediaFilterQuery lastMediaFilter;
 
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
@@ -255,6 +257,8 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		this.pickFromCamera = intent.getBooleanExtra(ThreemaApplication.INTENT_DATA_PICK_FROM_CAMERA, false);
 		this.useExternalCamera = intent.getBooleanExtra(EXTRA_USE_EXTERNAL_CAMERA, false);
 		this.messageReceivers = IntentDataUtil.getMessageReceiversFromIntent(intent);
+		// check if we previously filtered media in MediaAttachActivity to reuse the filter when adding additional media items
+		this.lastMediaFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
 
 		if (this.pickFromCamera && savedInstanceState == null) {
 			launchCamera();
@@ -619,6 +623,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	private void addImage() {
 		//FileUtil.selectFile(SendMediaActivity.this, null, "image/*", ThreemaActivity.ACTIVITY_ID_PICK_IMAGE, true, 0, null);
 		Intent intent = new Intent(getApplicationContext(), MediaSelectionActivity.class);
+		// pass last media filter to open the chooser with the same selection.
+		if (lastMediaFilter != null) {
+			intent = IntentDataUtil.addLastMediaFilterToIntent(intent, this.lastMediaFilter);
+		}
 		startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_PICK_MEDIA);
 	}
 
@@ -711,6 +719,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void prepareRotate() {
+		if (bigImageView.getDrawable() == null) {
+			return;
+		}
+
 		int oldRotation = SendMediaActivity.this.mediaItems.get(bigImagePos).getRotation();
 		int newRotation = (oldRotation + 90) % 360;
 
@@ -753,6 +765,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void prepareFlip() {
+		if (bigImageView.getDrawable() == null) {
+			return;
+		}
+
 		bigImageView.animate().rotationY(180f)
 			.setDuration(IMAGE_ANIMATION_DURATION_MS)
 			.setInterpolator(new FastOutSlowInInterpolator())
@@ -1014,6 +1030,8 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 					if (mediaItemsList != null){
 						addItemsByMediaItem(mediaItemsList);
 					}
+					// update last media filter used to add media items.
+					this.lastMediaFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
 				default:
 					break;
 			}
@@ -1034,7 +1052,13 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		messageService.sendMediaAsync(mediaItems, messageReceivers, null);
 
-		setResult(RESULT_OK);
+		// return last media filter to chat via intermediate hop through MediaAttachActivity
+		if (lastMediaFilter != null) {
+			Intent lastMediaSelectionResult = IntentDataUtil.addLastMediaFilterToIntent(new Intent(), this.lastMediaFilter);
+			setResult(RESULT_OK, lastMediaSelectionResult);
+		} else {
+			setResult(RESULT_OK);
+		}
 		finish();
 	}
 
@@ -1239,10 +1263,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		updateMenu();
 
 		String caption = item.getCaption();
-		captionEditText.setText(caption);
+		captionEditText.setText(null);
 
 		if (!TestUtil.empty(caption)) {
-			captionEditText.setSelection(caption.length());
+			captionEditText.append(caption);
 		}
 	}
 

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

@@ -54,6 +54,7 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 	final static public int ACTIVITY_ID_ENTER_SERIAL = 20017;
 	final static public int ACTIVITY_ID_SHARE_CHAT = 20018;
 	final static public int ACTIVITY_ID_SEND_MEDIA = 20019;
+	final static public int ACTIVITY_ID_ATTACH_MEDIA = 20020;
 	public static final int ACTIVITY_ID_CONFIRM_DEVICE_CREDENTIALS = 20021;
 	final static public int ACTIVITY_ID_GROUP_ADD = 20028;
 	final static public int ACTIVITY_ID_GROUP_DETAIL = 20029;

+ 10 - 8
app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java

@@ -248,7 +248,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	private static final String LANDSCAPE_HEIGHT = "kbd_landscape_height";
 	private final Set<OnSoftKeyboardChangedListener> softKeyboardChangedListeners = new HashSet<>();
 	private boolean softKeyboardOpen = false;
-	private int minKeyboardSize;
+	private int minKeyboardHeight, minEmojiPickerHeight;
 
 	public interface OnSoftKeyboardChangedListener {
 		void onKeyboardHidden();
@@ -282,9 +282,9 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	}
 
 	public void onSoftKeyboardOpened(int softKeyboardHeight) {
-		logger.info("%%% Potential keyboard height = " + softKeyboardHeight + " Min = " + minKeyboardSize);
+		logger.info("%%% Potential keyboard height = " + softKeyboardHeight + " Min = " + minKeyboardHeight);
 
-		if (softKeyboardHeight >= minKeyboardSize) {
+		if (softKeyboardHeight >= minKeyboardHeight) {
 			logger.info("%%% Soft keyboard open detected");
 
 			this.softKeyboardOpen = true;
@@ -366,17 +366,17 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	public int loadStoredSoftKeyboardHeight() {
 		boolean isLandscape = ConfigUtils.isLandscape(this);
 
-		int defaultSoftKeyboardHeight = isLandscape ?
+		int savedSoftKeyboardHeight = isLandscape ?
 			PreferenceManager.getDefaultSharedPreferences(this).getInt(LANDSCAPE_HEIGHT, getResources().getDimensionPixelSize(R.dimen.default_emoji_picker_height_landscape)) :
 			PreferenceManager.getDefaultSharedPreferences(this).getInt(PORTRAIT_HEIGHT, getResources().getDimensionPixelSize(R.dimen.default_emoji_picker_height));
 
-		if (defaultSoftKeyboardHeight < minKeyboardSize) {
-			defaultSoftKeyboardHeight = getResources().getDimensionPixelSize(isLandscape ?
+		if (savedSoftKeyboardHeight < minEmojiPickerHeight) {
+			return getResources().getDimensionPixelSize(isLandscape ?
 				R.dimen.default_emoji_picker_height_landscape :
 				R.dimen.default_emoji_picker_height);
 		}
 
-		return defaultSoftKeyboardHeight;
+		return savedSoftKeyboardHeight;
 	}
 
 	@Override
@@ -387,7 +387,9 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	}
 
 	public void resetKeyboard() {
-		minKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_keyboard_size);
+		minKeyboardHeight = getResources().getDimensionPixelSize(R.dimen.min_keyboard_height);
+		minEmojiPickerHeight = getResources().getDimensionPixelSize(R.dimen.min_emoji_keyboard_height);
+
 		removeAllListeners();
 		softKeyboardOpen = false;
 	}

+ 10 - 8
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -91,21 +91,21 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 
 	private final List<AbstractMessageModel> values;
 	private final ChatAdapterDecorator.Helper decoratorHelper;
-	private MessageService messageService;
-	private UserService userService;
-	private FileService fileService;
+	private final MessageService messageService;
+	private final UserService userService;
+	private final FileService fileService;
 	private final SparseIntArray resultMap = new SparseIntArray();
 	private int resultMapIndex;
 	private ConversationListFilter convListFilter = new ConversationListFilter();
 	public ListView listView;
 	private int groupId;
-	private EmojiMarkupUtil emojiMarkupUtil = EmojiMarkupUtil.getInstance();
+	private final EmojiMarkupUtil emojiMarkupUtil = EmojiMarkupUtil.getInstance();
 	private CharSequence currentConstraint = "";
 
-	private int firstUnreadPos = -1;
+	private int firstUnreadPos = -1, unreadMessagesCount;
 	private final Context context;
 
-	private LayoutInflater layoutInflater;
+	private final LayoutInflater layoutInflater;
 
 	public static final int TYPE_SEND = 0;
 	public static final int TYPE_RECV = 1;
@@ -165,7 +165,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			ListView listView,
 			ThumbnailCache<?> thumbnailCache,
 			int thumbnailWidth,
-			Fragment fragment) {
+			Fragment fragment,
+			int unreadMessagesCount) {
 		super(context, R.layout.conversation_list_item_send, values);
 
 		this.context = context;
@@ -182,6 +183,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		int maxBubbleTextLength = context.getResources().getInteger(R.integer.max_bubble_text_length);
 		int maxQuoteTextLength = context.getResources().getInteger(R.integer.max_quote_text_length);
 		this.resultMapIndex = 0;
+		this.unreadMessagesCount = unreadMessagesCount;
 		this.messageService = messageService;
 		this.userService = userService;
 		this.fileService = fileService;
@@ -434,7 +436,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 
 		if (itemType == TYPE_FIRST_UNREAD) {
 			// add number of unread messages
-			decorator = new FirstUnreadChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			decorator = new FirstUnreadChatAdapterDecorator(this.context, messageModel, this.decoratorHelper, unreadMessagesCount);
 		}
 		else {
 			switch (messageType) {

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

@@ -177,9 +177,9 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			itemHolder.nameView.setText(groupModel.getName());
 			itemHolder.avatarView.setImageBitmap(avatar);
 			if (groupService.isGroupOwner(groupModel)) {
-				itemHolder.statusView.setImageResource(R.drawable.ic_label_group_admin);
+				itemHolder.statusView.setImageResource(R.drawable.ic_group_outline);
 			} else {
-				itemHolder.statusView.setImageResource(R.drawable.ic_label_group_neutral);
+				itemHolder.statusView.setImageResource(R.drawable.ic_group_filled);
 			}
 			itemHolder.view.setOnClickListener(new View.OnClickListener() {
 				@Override

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

@@ -52,7 +52,7 @@ public class GroupListAdapter extends FilterableListAdapter {
 	private List<GroupModel> values;
 	private List<GroupModel> ovalues;
 	private GroupListFilter groupListFilter;
-	private final Bitmap defaultGroupImage, groupOwnerIcon, groupMemberIcon;
+	private final Bitmap defaultGroupImage;
 	private final GroupService groupService;
 
 	public GroupListAdapter(Context context, List<GroupModel> values, List<Integer> checkedItems, GroupService groupService) {
@@ -63,8 +63,6 @@ public class GroupListAdapter extends FilterableListAdapter {
 		this.ovalues = values;
 		this.groupService = groupService;
 		this.defaultGroupImage = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_group);
-		this.groupOwnerIcon = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_label_group_admin);
-		this.groupMemberIcon = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_label_group_neutral);
 
 		if (checkedItems != null && checkedItems.size() > 0) {
 			// restore checked items
@@ -120,7 +118,9 @@ public class GroupListAdapter extends FilterableListAdapter {
 		AdapterUtil.styleGroup(holder.nameView, groupService, groupModel);
 
 		holder.subjectView.setText(this.groupService.getMembersString(groupModel));
- 		holder.roleView.setImageBitmap(groupService.isGroupOwner(groupModel)?groupOwnerIcon:groupMemberIcon);
+ 		holder.roleView.setImageResource(groupService.isGroupOwner(groupModel)
+		    ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline)
+		    : R.drawable.ic_group_filled);
 
 		// load avatars asynchronously
 		AvatarListItemUtil.loadAvatar(

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

@@ -57,6 +57,7 @@ import ch.threema.app.services.RingtoneService;
 import ch.threema.app.ui.AvatarListItemUtil;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.CountBoxView;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -99,8 +100,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	private final List<ConversationModel> selectedChats = new ArrayList<>();
 	private String highlightUid;
 
-	// TODO: remove if custom tags are implemented
-	private final TagModel starTagModel;
+	private final TagModel starTagModel, unreadTagModel;
 
 	public static class MessageListViewHolder extends RecyclerView.ViewHolder {
 
@@ -119,7 +119,6 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		protected AvatarView avatarView;
 		protected ConversationModel conversationModel;
 		AvatarListItemHolder avatarListItemHolder;
-		//TODO: change this logic, if custom tags are implemented
 		final View tagStarOn;
 
 		MessageListViewHolder(final View itemView) {
@@ -208,8 +207,8 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 		this.isTablet = ConfigUtils.isTabletLayout();
 
-		// TODO: select the star model
 		this.starTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
+		this.unreadTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
 	}
 
 	@Override
@@ -250,9 +249,9 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			final ConversationModel conversationModel = this.getEntity(position);
 			holder.conversationModel = conversationModel;
 
-			holder.itemView.setOnClickListener(new View.OnClickListener() {
+			holder.itemView.setOnClickListener(new DebouncedOnClickListener(500) {
 				@Override
-				public void onClick(View v) {
+				public void onDebouncedClick(View v) {
 					// position may have changed after the item was bound. query current position from holder
 					int currentPos = holder.getLayoutPosition();
 
@@ -304,7 +303,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			// holder.fromView.setText(from);
 			holder.fromView.setText(conversationModel.getReceiver().getDisplayName());
 
-			if (conversationModel.hasUnreadMessage() && messageModel != null && !messageModel.isOutbox()) {
+			if (messageModel != null && ((!messageModel.isOutbox() && conversationModel.hasUnreadMessage()) || this.conversationTagService.isTaggedWith(conversationModel, this.unreadTagModel))) {
 				holder.fromView.setTextAppearance(context, R.style.Threema_TextAppearance_List_FirstLine_Bold);
 				holder.subjectView.setTextAppearance(context, R.style.Threema_TextAppearance_List_SecondLine_Bold);
 				if (holder.groupMemberName != null && holder.dateView != null) {
@@ -315,6 +314,10 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 					holder.unreadCountView.setText(String.valueOf(unreadCount));
 					holder.unreadCountView.setVisibility(View.VISIBLE);
 					holder.unreadIndicator.setVisibility(View.VISIBLE);
+				} else if (this.conversationTagService.isTaggedWith(conversationModel, this.unreadTagModel)) {
+					holder.unreadCountView.setText("");
+					holder.unreadCountView.setVisibility(View.VISIBLE);
+					holder.unreadIndicator.setVisibility(View.VISIBLE);
 				}
 			} else {
 				holder.fromView.setTextAppearance(context, R.style.Threema_TextAppearance_List_FirstLine);
@@ -432,7 +435,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 						}
 
 						if (conversationModel.isGroupConversation()) {
-							if (groupService.isGroupOwner(conversationModel.getGroup()) && groupService.countMembers(conversationModel.getGroup()) == 1) {
+							if (groupService.isNotesGroup(conversationModel.getGroup())) {
 								holder.deliveryView.setImageResource(R.drawable.ic_spiral_bound_booklet_outline);
 								holder.deliveryView.setContentDescription(context.getString(R.string.notes));
 							} else {

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

@@ -56,8 +56,7 @@ public class RecentListAdapter extends FilterableListAdapter {
 	private List<ConversationModel> values;
 	private List<ConversationModel> ovalues;
 	private RecentListFilter recentListFilter;
-	private final Bitmap defaultContactImage, defaultGroupImage, defaultContactImageSmall, defaultGroupImageSmall;
-	private final Bitmap defaultDistributionListImageSmall;
+	private final Bitmap defaultContactImage, defaultGroupImage;
 	private final Bitmap defaultDistributionListImage;
 	private final ContactService contactService;
 	private final GroupService groupService;
@@ -80,9 +79,6 @@ public class RecentListAdapter extends FilterableListAdapter {
 		this.defaultContactImage = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_contact);
 		this.defaultGroupImage = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_group);
 		this.defaultDistributionListImage = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_distribution_list);
-		this.defaultGroupImageSmall = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_label_group_neutral);
-		this.defaultContactImageSmall = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_label_person);
-		this.defaultDistributionListImageSmall = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_label_distribution_list);
 		if (checkedItems != null && checkedItems.size() > 0) {
 			// restore checked items
 			this.checkedItems.addAll(checkedItems);
@@ -137,18 +133,17 @@ public class RecentListAdapter extends FilterableListAdapter {
 		if(conversationModel.isGroupConversation()) {
 			fromtext = NameUtil.getDisplayName(groupModel, this.groupService);
 			subjecttext = groupService.getMembersString(groupModel);
-			holder.groupView.setImageBitmap(this.defaultGroupImageSmall);
+			holder.groupView.setImageResource(groupService.isGroupOwner(groupModel) ? (groupService.isNotesGroup(groupModel) ? R.drawable.ic_spiral_bound_booklet_outline : R.drawable.ic_group_outline) : R.drawable.ic_group_filled);
 		}
 		else if(conversationModel.isDistributionListConversation()) {
 			fromtext = NameUtil.getDisplayName(distributionListModel, this.distributionListService);
 			subjecttext = context.getString(R.string.distribution_list);
-			//TODO
-			holder.groupView.setImageBitmap(this.defaultDistributionListImageSmall);
+			holder.groupView.setImageResource(R.drawable.ic_bullhorn_outline);
 		}
 		else {
 			fromtext = NameUtil.getDisplayNameOrNickname(contactModel, true);
 			subjecttext = contactModel.getIdentity();
-			holder.groupView.setImageBitmap(this.defaultContactImageSmall);
+			holder.groupView.setImageResource(R.drawable.ic_person_outline);
 		}
 
 		String filterString = null;

+ 21 - 2
app/src/main/java/ch/threema/app/adapters/decorators/FirstUnreadChatAdapterDecorator.java

@@ -23,16 +23,35 @@ package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
 
+import ch.threema.app.R;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 
 public class FirstUnreadChatAdapterDecorator extends ChatAdapterDecorator {
-	public FirstUnreadChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
+	private int unreadMessagesCount = 0;
+
+	public FirstUnreadChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper, final int unreadMessagesCount) {
 		super(context, messageModel, helper);
+
+		this.unreadMessagesCount = unreadMessagesCount;
 	}
 
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
-		//nothing to decorate :-)
+		if (this.unreadMessagesCount < 1) {
+			return;
+		}
+
+		String s;
+		if (this.unreadMessagesCount > 1) {
+			s = getContext().getString(R.string.unread_messages, unreadMessagesCount);
+		} else {
+			s = getContext().getString(R.string.one_unread_message);
+		}
+
+		if(this.showHide(holder.bodyTextView, !TestUtil.empty(s))) {
+			holder.bodyTextView.setText(s);
+		}
 	}
 }

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

@@ -37,7 +37,6 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.mediaattacher.data.MediaItemsRoomDatabase;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
@@ -124,7 +123,6 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			File databaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
 			File nonceDatabaseFile = ThreemaApplication.getAppContext().getDatabasePath(NonceDatabaseBlobService.DATABASE_NAME_V4);
 			File backupFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4 + DatabaseServiceNew.DATABASE_BACKUP_EXT);
-			File labelDatabaseFile = ThreemaApplication.getAppContext().getDatabasePath(MediaItemsRoomDatabase.DATABASE_NAME);
 			File cacheDirectory = ThreemaApplication.getAppContext().getCacheDir();
 			File externalCacheDirectory = ThreemaApplication.getAppContext().getExternalCacheDir();
 
@@ -132,7 +130,6 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			secureDelete(databaseFile);
 			secureDelete(nonceDatabaseFile);
 			secureDelete(backupFile);
-			secureDelete(labelDatabaseFile);
 			secureDelete(cacheDirectory);
 			secureDelete(externalCacheDirectory);
 

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

@@ -26,6 +26,6 @@ import java.io.File;
 import ch.threema.storage.models.ConversationModel;
 
 public interface BackupChatService {
-	boolean backupChatToZip(ConversationModel conversationModel, File outputFile, String password, boolean includeMedia, String displayName);
+	boolean backupChatToZip(ConversationModel conversationModel, File outputFile, String password, boolean includeMedia);
 	void cancel();
 }

+ 3 - 3
app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java

@@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.ListIterator;
 
@@ -178,13 +179,12 @@ public class BackupChatServiceImpl implements BackupChatService {
 	}
 
 	@Override
-	public boolean backupChatToZip(final ConversationModel conversationModel, final File outputFile, final String password, boolean includeMedia, String displayName) {
+	public boolean backupChatToZip(final ConversationModel conversationModel, final File outputFile, final String password, boolean includeMedia) {
 		StringBuilder messageBody = new StringBuilder();
 
 		try(final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(outputFile, password)) {
 			if (buildThread(conversationModel, zipOutputStream, messageBody, password, includeMedia)) {
-				String filename = FilenameUtils.normalizeNoEndSeparator("messages-" + displayName);
-				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(messageBody, "UTF-8"), filename + ".txt");
+				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(messageBody, StandardCharsets.UTF_8), "messages.txt");
 			}
 			return true;
 

+ 46 - 37
app/src/main/java/ch/threema/app/camera/VideoEditView.java

@@ -361,10 +361,10 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	@UiThread
 	public void setVideo(MediaItem mediaItem) {
-		final int numColumns = calculateNumColumns();
+		int numColumns = calculateNumColumns();
 
 		if (numColumns <= 0 || numColumns > 64) {
-			return;
+			numColumns = GridLayout.UNDEFINED;
 		}
 
 		this.videoItem = mediaItem;
@@ -373,12 +373,11 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 			thumbnailThread.interrupt();
 		}
 
-		this.timelineGridLayout.setColumnCount(numColumns);
 		this.timelineGridLayout.setUseDefaultMargins(false);
 		this.timelineGridLayout.removeAllViewsInLayout();
 
+		GridLayout.Spec rowSpec = GridLayout.spec(0, 1, 1);
 		for (int i = 0; i < numColumns; i++) {
-			GridLayout.Spec rowSpec = GridLayout.spec(0, 1, 1);
 			GridLayout.Spec colSpec = GridLayout.spec(i, 1, 1);
 
 			FrameLayout frameLayout = new FrameLayout(context);
@@ -392,12 +391,18 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 			GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams();
 			layoutParams.rowSpec = rowSpec;
 			layoutParams.columnSpec = colSpec;
-			layoutParams.width = 0;
-			layoutParams.height = 0;
+			layoutParams.width = calculatedWidth;
+			layoutParams.height = targetHeight;
 
 			this.timelineGridLayout.addView(frameLayout, layoutParams);
 		}
 
+		try {
+			this.timelineGridLayout.setColumnCount(numColumns);
+		} catch (IllegalArgumentException e) {
+			logger.debug("Invalid column count. Num columns {}", numColumns);
+		}
+
 		thumbnailThread = new Thread(new Runnable() {
 			@Override
 			public void run() {
@@ -451,45 +456,49 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 						}
 					});
 
-					int step = (int) ((float) duration * 1000 / numColumns);
+					int numColumns = timelineGridLayout.getColumnCount();
+
+					if (numColumns != GridLayout.UNDEFINED) {
+						int step = (int) ((float) duration * 1000 / numColumns);
 
-					for (int i = 0; i < numColumns; i++) {
-						int position = i * step;
+						for (int i = 0; i < numColumns; i++) {
+							int position = i * step;
 
-						logger.debug("*** frame at position: " + position);
+							logger.debug("*** frame at position: " + position);
 
-						Bitmap bitmap = VideoTimelineCache.getInstance().get(mediaItem.getUri(), i);
-						if (bitmap == null) {
-							if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
-								bitmap = metaDataRetriever.getFrameAtTime(position, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
-								if (bitmap.getWidth() > calculatedWidth || bitmap.getHeight() > targetHeight) {
-									bitmap = BitmapUtil.resizeBitmap(bitmap, calculatedWidth, targetHeight);
+							Bitmap bitmap = VideoTimelineCache.getInstance().get(mediaItem.getUri(), i);
+							if (bitmap == null) {
+								if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
+									bitmap = metaDataRetriever.getFrameAtTime(position, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
+									if (bitmap.getWidth() > calculatedWidth || bitmap.getHeight() > targetHeight) {
+										bitmap = BitmapUtil.resizeBitmap(bitmap, calculatedWidth, targetHeight);
+									}
+								} else {
+									bitmap = metaDataRetriever.getScaledFrameAtTime(position, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, calculatedWidth, targetHeight);
 								}
-							} else {
-								bitmap = metaDataRetriever.getScaledFrameAtTime(position, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, calculatedWidth, targetHeight);
-							}
 
-							VideoTimelineCache.getInstance().set(mediaItem.getUri(), i, bitmap);
+								VideoTimelineCache.getInstance().set(mediaItem.getUri(), i, bitmap);
 
-							logger.debug("*** bitmap width: " + bitmap.getWidth() + " height: " + bitmap.getHeight());
-						}
-						final int column = i;
-						Bitmap finalBitmap = bitmap;
-
-						if (Thread.interrupted()) {
-							throw new InterruptedException();
-						} else {
-							RuntimeUtil.runOnUiThread(new Runnable() {
-								@Override
-								public void run() {
-									if (isAttachedToWindow()) {
-										ImageView imageView = findViewWithTag(column);
-										if (imageView != null) {
-											imageView.setImageBitmap(finalBitmap);
+								logger.debug("*** bitmap width: " + bitmap.getWidth() + " height: " + bitmap.getHeight());
+							}
+							final int column = i;
+							Bitmap finalBitmap = bitmap;
+
+							if (Thread.interrupted()) {
+								throw new InterruptedException();
+							} else {
+								RuntimeUtil.runOnUiThread(new Runnable() {
+									@Override
+									public void run() {
+										if (isAttachedToWindow()) {
+											ImageView imageView = findViewWithTag(column);
+											if (imageView != null) {
+												imageView.setImageBitmap(finalBitmap);
+											}
 										}
 									}
-								}
-							});
+								});
+							}
 						}
 					}
 				} catch (Exception e) {

+ 8 - 2
app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java

@@ -45,7 +45,9 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiEditText;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.exceptions.NoIdentityException;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
@@ -151,7 +153,9 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 	/**
 	 * Create a ContactEditDialog for a group
 	 */
-	public static ContactEditDialog newInstance(@StringRes int title, @StringRes int hint1, int groupId, int inputType, File avatarPreset, boolean useDefaultAvatar, int maxLength) {
+	public static ContactEditDialog newInstance(@StringRes int title, @StringRes int hint1,
+	                                            int groupId, int inputType, File avatarPreset,
+	                                            boolean useDefaultAvatar, int maxLength) {
 		final Bundle args = new Bundle();
 		args.putInt(ARG_TITLE, title);
 		args.putInt(ARG_HINT1, hint1);
@@ -231,9 +235,11 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		croppedAvatarFile = (File) getArguments().getSerializable("avatarPreset");
 
 		ContactService contactService = null;
+		GroupService groupService = null;
 		try {
 			contactService = ThreemaApplication.getServiceManager().getContactService();
-		} catch (MasterKeyLockedException|FileSystemNotPresentException e) {
+			groupService = ThreemaApplication.getServiceManager().getGroupService();
+		} catch (MasterKeyLockedException | FileSystemNotPresentException | NoIdentityException e) {
 			logger.error("Exception", e);
 		}
 

+ 2 - 2
app/src/main/java/ch/threema/app/dialogs/DateSelectorDialog.java

@@ -80,7 +80,7 @@ public class DateSelectorDialog extends ThreemaDialogFragment implements DatePic
 
 		if (callback == null) {
 			try {
-				callback = (DateSelectorDialog.DateSelectorDialogListener) getTargetFragment();
+				callback = (DateSelectorDialogListener) getTargetFragment();
 			} catch (ClassCastException e) {
 				//
 			}
@@ -90,7 +90,7 @@ public class DateSelectorDialog extends ThreemaDialogFragment implements DatePic
 				if (!(activity instanceof SelectorDialog.SelectorDialogClickListener)) {
 					throw new ClassCastException("Calling fragment must implement DateSelectorDialogClickListener interface");
 				}
-				callback = (DateSelectorDialog.DateSelectorDialogListener) activity;
+				callback = (DateSelectorDialogListener) activity;
 			}
 		}
 	}

+ 36 - 20
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -132,7 +132,6 @@ import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ContactDetailActivity;
 import ch.threema.app.activities.ContactNotificationsActivity;
 import ch.threema.app.activities.DistributionListAddActivity;
-import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.activities.GroupNotificationsActivity;
 import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.activities.MediaGalleryActivity;
@@ -166,6 +165,7 @@ import ch.threema.app.listeners.QRCodeScanListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.mediaattacher.MediaAttachActivity;
+import ch.threema.app.mediaattacher.MediaFilterQuery;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.routines.ReadMessagesRoutine;
@@ -273,6 +273,8 @@ public class ComposeMessageFragment extends Fragment implements
 
 	public static final String EXTRA_API_MESSAGE_ID = "apimsgid";
 	public static final String EXTRA_SEARCH_QUERY = "searchQuery";
+	public static final String EXTRA_LAST_MEDIA_SEARCH_QUERY = "searchMediaQuery";
+	public static final String EXTRA_LAST_MEDIA_TYPE_QUERY = "searchMediaType";
 
 	private static final int PERMISSION_REQUEST_SAVE_MESSAGE = 2;
 	private static final int PERMISSION_REQUEST_ATTACH_VOICE_MESSAGE = 7;
@@ -322,6 +324,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private Snackbar deleteSnackbar;
 	private TypingIndicatorTextWatcher typingIndicatorTextWatcher;
 	private Map<String, Integer> identityColors;
+	private MediaFilterQuery lastMediaFilter;
 
 	private PreferenceService preferenceService;
 	private ContactService contactService;
@@ -507,7 +510,7 @@ public class ComposeMessageFragment extends Fragment implements
 							}
 						}
 					} else {
-						if (addMessageToList(newMessage, false) && !isPaused) {
+						if (addMessageToList(newMessage, true) && !isPaused) {
 							if (!newMessage.isStatusMessage() && (newMessage.getType() != MessageType.VOIP_STATUS)) {
 								playReceivedSound();
 							}
@@ -596,17 +599,17 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity) {
+		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
 			updateToolBarTitleInUIThread();
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String identity) {
+		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
 			updateToolBarTitleInUIThread();
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String identity) {
+		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
 			updateToolBarTitleInUIThread();
 		}
 
@@ -1592,7 +1595,10 @@ public class ComposeMessageFragment extends Fragment implements
 
 						Intent intent = new Intent(activity, MediaAttachActivity.class);
 						IntentDataUtil.addMessageReceiverToIntent(intent, messageReceiver);
-						activity.startActivity(intent);
+						if (ComposeMessageFragment.this.lastMediaFilter != null) {
+							intent = IntentDataUtil.addLastMediaFilterToIntent(intent, ComposeMessageFragment.this.lastMediaFilter);
+						}
+						activity.startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_ATTACH_MEDIA);
 						activity.overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
 					}
 				}
@@ -1810,7 +1816,7 @@ public class ComposeMessageFragment extends Fragment implements
 					Intent intent = null;
 					if (isGroupChat) {
 						if (groupService.isGroupMember(groupModel)) {
-							intent = new Intent(activity, GroupDetailActivity.class);
+							intent = groupService.getGroupEditIntent(groupModel, activity);
 						}
 					} else if (isDistributionListChat) {
 						intent = new Intent(activity, DistributionListAddActivity.class);
@@ -2466,15 +2472,8 @@ public class ComposeMessageFragment extends Fragment implements
 			}
 		}
 
-		if (markasread && this.messageReceiver != null) {
-			try {
-				List<AbstractMessageModel> unreadMessages = this.messageReceiver.getUnreadMessages();
-				if (unreadMessages != null && unreadMessages.size() > 0) {
-					new Thread(new ReadMessagesRoutine(unreadMessages, this.messageService, this.notificationService)).start();
-				}
-			} catch (SQLException e) {
-				logger.error("Exception", e);
-			}
+		if (markasread) {
+			markAsRead();
 		}
 		return insertedSize;
 	}
@@ -2572,7 +2571,8 @@ public class ComposeMessageFragment extends Fragment implements
 					this.convListView,
 					this.thumbnailCache,
 					ConfigUtils.getPreferredThumbnailWidth(getContext(), false),
-					this
+					this,
+					unreadCount
 			);
 
 			//adding footer before setting the list adapter (android < 4.4)
@@ -2674,7 +2674,8 @@ public class ComposeMessageFragment extends Fragment implements
 					});
 				}
 			});
-			this.insertToList(values, false, true, false);
+
+			this.insertToList(values, false, !hiddenChatsListService.has(this.messageReceiver.getUniqueIdString()), false);
 			this.convListView.setAdapter(this.composeMessageAdapter);
 			this.convListView.setItemsCanFocus(false);
 			this.convListView.setVisibility(View.VISIBLE);
@@ -3426,7 +3427,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 		this.deleteDistributionListItem.setVisible(this.isDistributionListChat);
 		this.shortCutItem.setVisible(ShortcutManagerCompat.isRequestPinShortcutSupported(getAppContext()));
-		this.mutedMenuItem.setVisible(!this.isDistributionListChat);
+		this.mutedMenuItem.setVisible(!this.isDistributionListChat && !(isGroupChat && groupService.isNotesGroup(groupModel)));
 		updateMuteMenu();
 
 		if (contactModel != null) {
@@ -3739,7 +3740,6 @@ public class ComposeMessageFragment extends Fragment implements
 	@Override
 	public void onActivityResult(int requestCode, int resultCode,
 									final Intent intent) {
-
 		if (wallpaperService != null && wallpaperService.handleActivityResult(this, requestCode, resultCode, intent, this.messageReceiver)) {
 			setBackgroundWallpaper();
 			return;
@@ -3750,6 +3750,9 @@ public class ComposeMessageFragment extends Fragment implements
 				this.messagePlayerService.resumeAll(getActivity(), messageReceiver, SOURCE_AUDIORECORDER);
 			}
 		}
+		if (requestCode == ThreemaActivity.ACTIVITY_ID_ATTACH_MEDIA && resultCode == Activity.RESULT_OK) {
+			this.lastMediaFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
+		}
 	}
 
 	private final SearchView.OnQueryTextListener queryTextListener = new SearchView.OnQueryTextListener() {
@@ -4479,6 +4482,19 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
+	public void markAsRead() {
+		if (messageReceiver != null) {
+			try {
+				List<AbstractMessageModel> unreadMessages = messageReceiver.getUnreadMessages();
+				if (unreadMessages != null && unreadMessages.size() > 0) {
+					new Thread(new ReadMessagesRoutine(unreadMessages, this.messageService, this.notificationService)).start();
+				}
+			} catch (SQLException e) {
+				logger.error("Exception", e);
+			}
+		}
+	}
+
 	@Override
 	public void onConfigurationChanged(@NonNull Configuration newConfig) {
 		super.onConfigurationChanged(newConfig);

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

@@ -43,6 +43,7 @@ import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
+import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Toast;
@@ -50,7 +51,6 @@ import android.widget.Toast;
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
 
-import org.apache.commons.io.FilenameUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -74,7 +74,6 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ContactDetailActivity;
 import ch.threema.app.activities.DistributionListAddActivity;
-import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.adapters.MessageListAdapter;
@@ -185,6 +184,7 @@ public class MessageSectionFragment extends MainFragment
 	private static final int TAG_DELETE_LEFT_GROUP = 10;
 	private static final int TAG_EDIT_GROUP = 11;
 	private static final int TAG_MARK_READ = 12;
+	private static final int TAG_MARK_UNREAD = 13;
 
 	private static final String BUNDLE_FILTER_QUERY = "filterQuery";
 	private static String highlightUid;
@@ -214,6 +214,8 @@ public class MessageSectionFragment extends MainFragment
 	private int currentFullSyncs = 0;
 	private String filterQuery;
 	private int cornerRadius;
+	private TagModel unreadTagModel;
+
 
 	private int archiveCount = 0;
 	private Snackbar archiveSnackbar;
@@ -580,6 +582,8 @@ public class MessageSectionFragment extends MainFragment
 	};
 
 	private void showConversation(ConversationModel conversationModel, View v) {
+		conversationTagService.unTag(conversationModel, unreadTagModel);
+
 		Intent intent = IntentDataUtil.getShowConversationIntent(conversationModel, activity);
 
 		if (intent == null) {
@@ -743,17 +747,15 @@ public class MessageSectionFragment extends MainFragment
 		new Thread(new Runnable() {
 			@Override
 			public void run() {
-				String displayName = FileUtil.sanitizeFileName(conversationModel.getReceiver().getDisplayName());
-				String filename = FilenameUtils.normalizeNoEndSeparator("messages-" + displayName);
-				tempMessagesFile = new File(ConfigUtils.useContentUris() ? fileService.getTempPath() : fileService.getExtTmpPath(), filename + ".zip");
+				tempMessagesFile = FileUtil.getUniqueFile(ConfigUtils.useContentUris() ? fileService.getTempPath().getPath() : fileService.getExtTmpPath().getPath(), "threema-chat.zip");
 				FileUtil.deleteFileOrWarn(tempMessagesFile, "tempMessagesFile", logger);
 
-				if (backupChatService.backupChatToZip(conversationModel, tempMessagesFile, password, includeMedia, displayName)) {
+				if (backupChatService.backupChatToZip(conversationModel, tempMessagesFile, password, includeMedia)) {
 
 					if (tempMessagesFile != null && tempMessagesFile.exists() && tempMessagesFile.length() > 0) {
 						final Intent intent = new Intent(Intent.ACTION_SEND);
 						intent.setType(MimeUtil.MIME_TYPE_ZIP);
-						intent.putExtra(Intent.EXTRA_SUBJECT, getResources().getString(R.string.share_subject) + " " + conversationModel.getReceiver().getDisplayName());
+						intent.putExtra(Intent.EXTRA_SUBJECT, getResources().getString(R.string.share_subject));
 						intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.chat_history_attached) + "\n\n" + getString(R.string.share_conversation_body));
 						intent.putExtra(Intent.EXTRA_STREAM, fileService.getShareFileUri(tempMessagesFile, null));
 						intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -832,7 +834,6 @@ public class MessageSectionFragment extends MainFragment
 					return 0.7f;
 				}
 
-
 				@Override
 				public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
 					// disable swiping and dragging for footer views
@@ -854,7 +855,7 @@ public class MessageSectionFragment extends MainFragment
 
 				@Override
 				public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
-					// swipe has ended
+					// swipe has ended successfully
 
 					// required to clear swipe layout
 					messageListAdapter.notifyDataSetChanged();
@@ -1018,12 +1019,32 @@ public class MessageSectionFragment extends MainFragment
 					}
 				}
 			});
+			recyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
+				private final int TOUCH_SAFE_AREA_PX = 5;
+
+				// ignore touches at the very left and right edge of the screen to prevent interference with UI gestures
+				@Override
+				public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
+					int width = getResources().getDisplayMetrics().widthPixels;
+					int touchX = (int) e.getRawX();
+
+					return touchX < TOUCH_SAFE_AREA_PX || touchX > width - TOUCH_SAFE_AREA_PX;
+				}
+
+				@Override
+				public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { }
+
+				@Override
+				public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { }
+			});
 
 			//instantiate fragment
 			//
 			if(!this.requiredInstances()) {
 				logger.error("could not instantiate required objects");
 			}
+
+			this.unreadTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
 		}
 		return fragmentView;
 	}
@@ -1076,7 +1097,7 @@ public class MessageSectionFragment extends MainFragment
 	}
 
 	private void editGroup(ConversationModel model, View view) {
-		Intent intent = new Intent(getActivity(), GroupDetailActivity.class);
+		Intent intent = groupService.getGroupEditIntent(model.getGroup(), activity);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, model.getGroup().getId());
 		AnimationUtil.startActivityForResult(activity, view, intent, 0);
 	}
@@ -1196,9 +1217,12 @@ public class MessageSectionFragment extends MainFragment
 
 		boolean isPrivate = hiddenChatsListService.has(receiver.getUniqueIdString());
 
-		if (conversationModel.hasUnreadMessage()) {
+		if (conversationModel.hasUnreadMessage() || conversationTagService.isTaggedWith(conversationModel, unreadTagModel)) {
 			labels.add(getString(R.string.mark_read));
 			tags.add(TAG_MARK_READ);
+		} else {
+			labels.add(getString(R.string.mark_unread));
+			tags.add(TAG_MARK_UNREAD);
 		}
 
 		if (isPrivate) {
@@ -1334,6 +1358,7 @@ public class MessageSectionFragment extends MainFragment
 				}
 				break;
 			case TAG_MARK_READ:
+				conversationTagService.unTag(conversationModel, unreadTagModel);
 				new Thread(new Runnable() {
 					@Override
 					public void run() {
@@ -1341,6 +1366,9 @@ public class MessageSectionFragment extends MainFragment
 					}
 				}).start();
 				break;
+			case TAG_MARK_UNREAD:
+				conversationTagService.tag(conversationModel, unreadTagModel);
+				break;
 		}
 	}
 

+ 0 - 2
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java

@@ -206,13 +206,11 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 		});
 
 		chatsRecyclerView = this.findViewById(R.id.recycler_chats);
-		chatsRecyclerView.setHasFixedSize(true);
 		chatsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 		chatsRecyclerView.setItemAnimator(new DefaultItemAnimator());
 		chatsRecyclerView.setAdapter(chatsAdapter);
 
 		groupChatsRecyclerView = this.findViewById(R.id.recycler_groups);
-		groupChatsRecyclerView.setHasFixedSize(true);
 		groupChatsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 		groupChatsRecyclerView.setItemAnimator(new DefaultItemAnimator());
 		groupChatsRecyclerView.setAdapter(groupChatsAdapter);

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

@@ -30,9 +30,9 @@ public interface GroupListener {
 	@AnyThread default void onUpdatePhoto(GroupModel groupModel) { }
 	@AnyThread default void onRemove(GroupModel groupModel) { }
 
-	@AnyThread default void onNewMember(GroupModel group, String newIdentity) { }
-	@AnyThread default void onMemberLeave(GroupModel group, String identity) { }
-	@AnyThread default void onMemberKicked(GroupModel group, String identity) { }
+	@AnyThread default void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) { }
+	@AnyThread default void onMemberLeave(GroupModel group, String identity, int previousMemberCount) { }
+	@AnyThread default void onMemberKicked(GroupModel group, String identity, int previousMemberCount) { }
 
 	/**
 	 * Group was updated.

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

@@ -406,7 +406,8 @@ public class ServiceManager {
 					this.getApiService(),
 					this.getDownloadService(),
 					this.getHiddenChatsListService(),
-					this.getProfilePicRecipientsService()
+					this.getProfilePicRecipientsService(),
+					this.getBlackListService()
 			);
 		}
 
@@ -605,7 +606,8 @@ public class ServiceManager {
 					this.getWallpaperService(),
 					this.getMutedChatsListService(),
 					this.getHiddenChatsListService(),
-					this.getRingtoneService()
+					this.getRingtoneService(),
+					this.getBlackListService()
 			);
 		}
 		return this.groupService;
@@ -643,6 +645,7 @@ public class ServiceManager {
 	public ConversationTagService getConversationTagService() {
 		if(null == this.conversationService) {
 			this.conversationTagService = new ConversationTagServiceImpl(
+				this.getContext(),
 				this.databaseServiceNew
 			);
 		}

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

@@ -54,7 +54,7 @@ public class ImagePreviewFragment extends PreviewFragment {
 
 	@Override
 	public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-		this.rootView = inflater.inflate(R.layout.popup_image, container, false);
+		this.rootView = inflater.inflate(R.layout.fragment_image_preview, container, false);
 
 		return rootView;
 	}

+ 35 - 15
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -70,10 +70,12 @@ import ch.threema.app.activities.ballot.BallotWizardActivity;
 import ch.threema.app.camera.CameraUtil;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.listeners.QRCodeScanListener;
 import ch.threema.app.locationpicker.LocationPickerActivity;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
+import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.ui.MediaItem;
@@ -227,7 +229,8 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			this.attachGalleryButton.setVisibility(View.GONE);
 		}
 
-		if (messageReceiver instanceof DistributionListMessageReceiver) {
+		if (messageReceiver instanceof DistributionListMessageReceiver ||
+			(messageReceiver instanceof GroupMessageReceiver && groupService != null && groupService.isNotesGroup(((GroupMessageReceiver) messageReceiver).getGroup()))) {
 			this.attachBallotButton.setVisibility(View.GONE);
 		}
 
@@ -254,8 +257,6 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 	/* start section action methods */
 	@Override
 	public void onItemChecked(int count) {
-		int gridPaddingLeftRight = getResources().getDimensionPixelSize(R.dimen.grid_spacing);
-
 		if (this.selectFromGalleryItem != null) {
 			this.selectFromGalleryItem.setVisible(count == 0);
 		}
@@ -271,9 +272,9 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 				// only slide up when previously hidden otherwise animate switch between panels
 				if (controlPanel.getTranslationY() != 0) {
 					controlPanel.animate().translationY(0).withEndAction(() -> bottomSheetLayout.setPadding(
-						gridPaddingLeftRight,
 						0,
-						gridPaddingLeftRight,
+						0,
+						0,
 						controlPanel.getHeight() - getResources().getDimensionPixelSize(R.dimen.media_attach_control_panel_shadow_size)));
 				} else {
 					AnimationUtil.bubbleAnimate(sendButton, 50);
@@ -281,9 +282,9 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 					AnimationUtil.bubbleAnimate(editButton, 75);
 					AnimationUtil.bubbleAnimate(cancelButton, 100);
 					bottomSheetLayout.setPadding(
-						gridPaddingLeftRight,
 						0,
-						gridPaddingLeftRight,
+						0,
+						0,
 						controlPanel.getHeight() - getResources().getDimensionPixelSize(R.dimen.media_attach_control_panel_shadow_size)
 					);
 				}
@@ -309,9 +310,9 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			//animate padding change to avoid flicker
 			ValueAnimator animator = ValueAnimator.ofInt(bottomSheetLayout.getPaddingBottom(), 0);
 			animator.addUpdateListener(valueAnimator -> bottomSheetLayout.setPadding(
-				gridPaddingLeftRight,
 				0,
-				gridPaddingLeftRight,
+				0,
+				0,
 				(Integer) valueAnimator.getAnimatedValue()));
 			animator.setDuration(300);
 			animator.start();
@@ -319,9 +320,9 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			sendPanel.setVisibility(View.GONE);
 			attachPanel.setVisibility(View.VISIBLE);
 			bottomSheetLayout.setPadding(
-				gridPaddingLeftRight,
 				0,
-				gridPaddingLeftRight,
+				0,
+				0,
 				0);
 			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
 				if (attachGalleryButton.getVisibility() == View.VISIBLE) {
@@ -375,15 +376,20 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			case R.id.edit:
 				if (mediaAttachAdapter != null) {
 					onEdit(mediaAttachViewModel.getSelectedMediaUris());
-					finish();
 				}
 				break;
 			case R.id.send:
 				if (mediaAttachAdapter != null) {
 					v.setAlpha(0.3f);
 					v.setClickable(false);
+					// return last filter to potentially re-use it when attaching more media in compose fragment
+					if (mediaAttachViewModel.getLastQueryType() != null) {
+						Intent resultIntent = IntentDataUtil.addLastMediaFilterToIntent(new Intent(),
+							mediaAttachViewModel.getLastQuery(),
+							mediaAttachViewModel.getLastQueryType());
+						setResult(RESULT_OK, resultIntent);
+					}
 					onSend(mediaAttachViewModel.getSelectedMediaUris());
-					finish();
 				}
 				break;
 			case R.id.attach_gallery:
@@ -461,7 +467,6 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 	@Override
 	public void onActivityResult(int requestCode, int resultCode, final Intent intent) {
 		super.onActivityResult(requestCode, resultCode, intent);
-
 		if (resultCode == Activity.RESULT_OK) {
 			final String scanResult = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
 			if (scanResult != null && scanResult.length() > 0) {
@@ -488,8 +493,17 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 					onEdit(FileUtil.getUrisFromResult(intent, getContentResolver()));
 					break;
 				case ThreemaActivity.ACTIVITY_ID_CREATE_BALLOT:
-					// fallthrough
+					finish();
+					break;
 				case ThreemaActivity.ACTIVITY_ID_SEND_MEDIA:
+					// catch last media filter and forward to compose message fragment
+					Intent resultIntent = new Intent();
+					if (intent != null && intent.hasExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_TYPE_QUERY)) {
+						IntentDataUtil.addLastMediaFilterToIntent(resultIntent,
+							intent.getStringExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY),
+							intent.getIntExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_TYPE_QUERY, -1));
+						setResult(RESULT_OK, resultIntent);
+					}
 					finish();
 					break;
 				case ThreemaActivity.ACTIVITY_ID_PICK_FILE:
@@ -560,6 +574,12 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			Intent intent = IntentDataUtil.addMessageReceiversToIntent(new Intent(this, SendMediaActivity.class), new MessageReceiver[]{this.messageReceiver});
 			intent.putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems);
 			intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, messageReceiver.getDisplayName());
+			// pass on last filter to potentially re-use it when adding more media items
+			if (mediaAttachViewModel.getLastQuery() != null) {
+				intent = IntentDataUtil.addLastMediaFilterToIntent(intent,
+					mediaAttachViewModel.getLastQuery(),
+					mediaAttachViewModel.getLastQueryType());
+			}
 			AnimationUtil.startActivityForResult(this, null, intent, ThreemaActivity.ACTIVITY_ID_SEND_MEDIA);
 		} else {
 			Toast.makeText(MediaAttachActivity.this, R.string.only_images_or_videos, Toast.LENGTH_LONG).show();

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

@@ -105,6 +105,13 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 		return new MediaAttachAdapter.MediaGalleryHolder(itemView);
 	}
 
+	@Override
+	public void onViewRecycled(@NonNull MediaGalleryHolder holder) {
+		super.onViewRecycled(holder);
+		//cancel pending loads when item is out of view
+		Glide.with(context).clear(holder.imageView);
+	}
+
 	/**
 	 * This method is called for every media attach item that is scrolled into view.
 	 */
@@ -124,13 +131,14 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 			try {
 				Glide.with(context).load(mediaAttachItem.getUri())
 					.transition(withCrossFade())
+					.centerInside()
 					.addListener(new RequestListener<Drawable>() {
 						@Override
 						public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
 							loadErrorIndicator.setVisibility(View.VISIBLE);
 							gifIndicator.setVisibility(View.GONE);
 							videoIndicator.setVisibility(View.GONE);
-							//redraw setChecked state in case holder is beeing recycled and reset click listeners
+							//redraw setChecked state in case holder is being recycled and reset click listeners
 							contentView.setChecked(mediaAttachViewModel.getSelectedMediaItemsHashMap().containsKey(mediaAttachItem.getId()));
 							contentView.setOnClickListener(null);
 							contentView.setOnLongClickListener(null);

+ 0 - 11
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachItem.java

@@ -25,8 +25,6 @@ import android.net.Uri;
 import android.os.Parcel;
 import android.os.Parcelable;
 
-import java.util.List;
-
 /**
  * A MediaAttachItem represents a media item in the attacher (e.g. a photo or a video).
  *
@@ -43,7 +41,6 @@ public class MediaAttachItem implements Parcelable {
 	private final int orientation;
 	private final int duration;
 	private final int type;
-	private List<String> labels;
 
 	public MediaAttachItem(
 		int id,
@@ -108,14 +105,6 @@ public class MediaAttachItem implements Parcelable {
 		}
 	};
 
-	public List<String> getLabels() {
-		return labels;
-	}
-
-	public void setLabels(List<String> labels) {
-		this.labels = labels;
-	}
-
 	public int getId() {
 		return id;
 	}

+ 1 - 155
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java

@@ -23,56 +23,30 @@ package ch.threema.app.mediaattacher;
 
 import android.Manifest;
 import android.app.Application;
-import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
 import android.net.Uri;
 
-import net.sqlcipher.database.SQLiteException;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.lang.annotation.Retention;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Set;
 
 import androidx.annotation.AnyThread;
-import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.core.content.ContextCompat;
 import androidx.lifecycle.AndroidViewModel;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.SavedStateHandle;
-import androidx.preference.PreferenceManager;
-import androidx.work.ExistingWorkPolicy;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
-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.mediaattacher.data.FailedMediaItemsDAO;
-import ch.threema.app.mediaattacher.data.ImageLabelListConverter;
-import ch.threema.app.mediaattacher.data.LabeledMediaItemsDAO;
-import ch.threema.app.mediaattacher.data.MediaItemsRoomDatabase;
-import ch.threema.app.mediaattacher.labeling.ImageLabelingWorker;
-import ch.threema.app.mediaattacher.labeling.ImageLabelsIndexHashMap;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.localcrypto.MasterKeyLockedException;
 import java8.util.concurrent.CompletableFuture;
-import java8.util.stream.Collectors;
-import java8.util.stream.StreamSupport;
-
-import static java.lang.annotation.RetentionPolicy.SOURCE;
 
 /**
  * The view model used by the media attacher.
@@ -94,7 +68,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 	private final @NonNull MutableLiveData<List<MediaAttachItem>> currentMedia = new MutableLiveData<>(Collections.emptyList());
 
 	private final LinkedHashMap<Integer, MediaAttachItem> selectedItems;
-	private final MutableLiveData<List<String>> suggestionLabels = new MutableLiveData<>();
 	private final MediaRepository repository;
 	private final SavedStateHandle savedState;
 
@@ -106,13 +79,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 	private final String KEY_RECENT_QUERY = "recent_query_string";
 	private final String KEY_RECENT_QUERY_TYPE = "recent_query_type";
 
-	@Retention(SOURCE)
-	@IntDef({FILTER_MEDIA_TYPE, FILTER_MEDIA_BUCKET, FILTER_MEDIA_LABEL, FILTER_MEDIA_SELECTED})
-	public @interface FilerType {}
-	public static final int FILTER_MEDIA_TYPE = 0;
-	public static final int FILTER_MEDIA_BUCKET = 1;
-	public static final int FILTER_MEDIA_LABEL = 2;
-	public static final int FILTER_MEDIA_SELECTED = 3;
 
 	public MediaAttachViewModel(@NonNull Application application, @NonNull SavedStateHandle savedState) {
 		super(application);
@@ -136,15 +102,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 				if (savedQuery == null) {
 					currentMedia.postValue(this.allMedia.getValue());
 				}
-
-				SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(application);
-
-				// Check whether search can be shown
-				if (ConfigUtils.isPlayServicesInstalled(this.application) &&
-					sharedPreferences != null &&
-					sharedPreferences.getBoolean(application.getString(R.string.preferences__image_labeling), false)) {
-					this.checkLabelingComplete();
-				}
 			});
 		}
 	}
@@ -172,75 +129,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		}, "fetchAllMediaFromRepository").start();
 	}
 
-	/**
-	 * Asynchronously check whether the labels are already processed.
-	 * If this is the case, the `suggestionLabels` LiveData object will be updated.
-	 *
-	 * This should only be called if play services are installed.
-	 */
-	@AnyThread
-	private void checkLabelingComplete() {
-		new Thread(() -> {
-			// Open database
-			final LabeledMediaItemsDAO mediaItemsDAO;
-			final FailedMediaItemsDAO failedMediaItemsDAO;
-			try {
-				mediaItemsDAO = MediaItemsRoomDatabase.getDatabase(application).mediaItemsDAO();
-				failedMediaItemsDAO = MediaItemsRoomDatabase.getDatabase(application).failedMediaItemsDAO();
-			} catch (MasterKeyLockedException e) {
-				logger.error("Could not access database", e);
-				return;
-			} catch (SQLiteException e) {
-				logger.error("Could not get media items database, SQLite Exception", e);
-				return;
-			}
-
-			// Get label count from database
-			final int labeledMediaCount = mediaItemsDAO.getRowCount();
-			final int failedMediaCount = failedMediaItemsDAO.getRowCount();
-
-			// Get the media count (by this time, it should be ready because
-			// this method is called after `initialLoadDone` fires)
-			final List<MediaAttachItem> allMediaValue = Objects.requireNonNull(MediaAttachViewModel.this.allMedia.getValue());
-			final int totalMediaSize = Functional.filter(allMediaValue, (IPredicateNonNull<MediaAttachItem>) ImageLabelingWorker::mediaCanBeLabeled).size() - failedMediaCount;
-
-			final float labeledRatio = (float) labeledMediaCount / (float) totalMediaSize;
-			if (labeledRatio > 0.8) {
-				// More than 80% labeled. Good enough, but kick off the labeller anyways if we're not at 100%.
-				if (labeledMediaCount < totalMediaSize) {
-					MediaAttachViewModel.this.startImageLabeler();
-				}
-
-				// Get hashmap for label mapping
-				ImageLabelsIndexHashMap labelsIndexHashMap = new ImageLabelsIndexHashMap();
-
-				// Iterate over all media items, translate and set labels
-				final Set<String> translatedLabels = new HashSet<>();
-				for (MediaAttachItem mediaItem : allMediaValue) {
-					final List<String> translatedItemLabels = StreamSupport
-						.stream(mediaItemsDAO.getMediaItemLabels(mediaItem.getId()))
-						.map(ImageLabelListConverter::fromString) // TODO: Find out how to do this with the TypeConverter directly
-						.flatMap(StreamSupport::stream)
-						.map(labelsIndexHashMap::mapIdToName)
-						.distinct()
-						.collect(Collectors.toList());
-					translatedLabels.addAll(translatedItemLabels);
-					mediaItem.setLabels(translatedItemLabels);
-				}
-
-				final List<String> sortedLabels = StreamSupport.stream(translatedLabels)
-					.sorted()
-					.collect(Collectors.toList());
-				logger.info("Found {} distinct labels in database", translatedLabels.size());
-				suggestionLabels.postValue(sortedLabels);
-			} else {
-				logger.info("Less than 80% labeled, considering labels incomplete");
-				MediaAttachViewModel.this.startImageLabeler();
-				suggestionLabels.postValue(Collections.emptyList());
-			}
-		}, "checkLabelingComplete").start();
-	}
-
 	/**
 	 * Write all media to {@link #currentMedia} list.
 	 */
@@ -288,41 +176,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		currentMedia.setValue(filteredMedia);
 	}
 
-	/**
-	 * Filter media by label, update the {@link #currentMedia} list.
-	 */
-	@UiThread
-	public void setMediaByLabel(@NonNull String filterLabel) {
-		ArrayList<MediaAttachItem> filteredMedia = new ArrayList<>();
-		final List<MediaAttachItem> items = Objects.requireNonNull(this.allMedia.getValue());
-		for (MediaAttachItem mediaItem : items) {
-			if (mediaItem.getLabels() != null) {
-				for (String mediaItemLabel : mediaItem.getLabels()) {
-					if (mediaItemLabel.toLowerCase().trim().equals(filterLabel.toLowerCase().trim())) {
-						filteredMedia.add(mediaItem);
-					}
-				}
-			}
-		}
-		currentMedia.setValue(filteredMedia);
-	}
-
-	/**
-	 * Start image labeling in a background task.
-	 *
-	 * Note: Should only be called if play services are installed!
-	 */
-	@AnyThread
-	private void startImageLabeler() {
-		logger.debug("startImageLabeler");
-		final WorkManager workManager = WorkManager.getInstance(application);
-		final OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(ImageLabelingWorker.class)
-			.addTag(ImageLabelingWorker.UNIQUE_WORK_NAME)
-			.build();
-		workManager.enqueueUniqueWork(ImageLabelingWorker.UNIQUE_WORK_NAME, ExistingWorkPolicy.KEEP, workRequest);
-		logger.info("Started OneTimeWorkRequest for ImageLabelingWorker");
-	}
-
 	public ArrayList<Uri> getSelectedMediaUris() {
 		ArrayList<Uri> selectedUris = new ArrayList<>();
 		if (selectedItems != null) {
@@ -358,13 +211,6 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		}
 	}
 
-	/**
-	 * Return a LiveData object that will eventually resolve to a list of sorted and translated labels.
-	 */
-	public MutableLiveData<List<String>> getSuggestionLabels() {
-		return suggestionLabels;
-	}
-
 	public String getToolBarTitle() {
 		return savedState.get(KEY_TOOLBAR_TITLE);
 	}
@@ -380,7 +226,7 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		return savedState.get(KEY_RECENT_QUERY_TYPE);
 	}
 
-	public void setlastQuery(@FilerType int type, String labelQuery) {
+	public void setlastQuery(@MediaFilterQuery.FilerType int type, String labelQuery) {
 		savedState.set(KEY_RECENT_QUERY, labelQuery);
 		savedState.set(KEY_RECENT_QUERY_TYPE, type);
 	}

+ 60 - 0
app/src/main/java/ch/threema/app/mediaattacher/MediaFilterQuery.java

@@ -0,0 +1,60 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * 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,
+ * 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 java.lang.annotation.Retention;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+
+public class MediaFilterQuery {
+
+	@Retention(SOURCE)
+	@IntDef({FILTER_MEDIA_TYPE, FILTER_MEDIA_BUCKET, FILTER_MEDIA_LABEL, FILTER_MEDIA_SELECTED, FILTER_MEDIA_DATE})
+	public @interface FilerType {}
+	public static final int FILTER_MEDIA_TYPE = 0;
+	public static final int FILTER_MEDIA_BUCKET = 1;
+	public static final int FILTER_MEDIA_LABEL = 2;
+	public static final int FILTER_MEDIA_SELECTED = 3;
+	public static final int FILTER_MEDIA_DATE = 4;
+
+	public final String query;
+	@FilerType public final int type;
+
+	public MediaFilterQuery(@NonNull String query, @FilerType int type) {
+		this.query = query;
+		this.type = type;
+	}
+
+	public String getQuery() {
+		return query;
+	}
+
+	@FilerType
+	public int getType() {
+		return type;
+	}
+
+}

+ 41 - 15
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java

@@ -35,22 +35,23 @@ import android.widget.Button;
 import com.google.android.material.bottomsheet.BottomSheetBehavior;
 
 import java.util.ArrayList;
+import java.util.List;
 
 import androidx.annotation.NonNull;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.core.app.ActivityCompat;
 import androidx.core.content.ContextCompat;
+import androidx.lifecycle.Observer;
 import ch.threema.app.R;
 import ch.threema.app.activities.SendMediaActivity;
-import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.FileUtil;
+import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LocaleUtil;
 
-import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
-
 public class MediaSelectionActivity extends MediaSelectionBaseActivity {
+
 	private ControlPanelButton selectButton, cancelButton;
 	private Button selectCounterButton;
 
@@ -59,35 +60,53 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 		super.initActivity(null);
 		setControlPanelLayout();
 		setupControlPanelListeners();
+
+		// always open bottom sheet in expanded state right away
+		expandBottomSheet();
 		setInitialMediaGrid();
+
 		handleSavedInstanceState(savedInstanceState);
+	}
 
-		BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
-		bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
-		updateUI(BottomSheetBehavior.STATE_EXPANDED);
+	@Override
+	protected void setInitialMediaGrid() {
+		super.setInitialMediaGrid();
+		// hide media items dependent views until we have data loaded and set to grid
+		dateView.setVisibility(View.GONE);
+		controlPanel.setVisibility(View.GONE);
+		controlPanel.animate().translationY(getResources().getDimensionPixelSize(R.dimen.control_panel_height));
+
+		mediaAttachViewModel.getCurrentMedia().observe(this, new Observer<List<MediaAttachItem>>() {
+			@Override
+			public void onChanged(List<MediaAttachItem> mediaAttachItems) {
+				if (mediaAttachItems.size() != 0) {
+					dateView.setVisibility(View.VISIBLE);
+					controlPanel.setVisibility(View.VISIBLE);
+					mediaAttachViewModel.getCurrentMedia().removeObserver(this);
+				}
+			}
+		});
 	}
 
 	@Override
 	public void onItemChecked(int count) {
-		int gridPaddingLeftRight = getResources().getDimensionPixelSize(R.dimen.grid_spacing);
-
 		if (count > 0) {
 			selectCounterButton.setText(String.format(LocaleUtil.getCurrentLocale(this), "%d", count));
 			selectCounterButton.setVisibility(View.VISIBLE);
 			controlPanel.animate().translationY(0);
 			controlPanel.postDelayed(() -> bottomSheetLayout.setPadding(
-				gridPaddingLeftRight,
 				0,
-				gridPaddingLeftRight,
+				0,
+				0,
 				0), 300);
 		} else {
 			selectCounterButton.setVisibility(View.GONE);
 			controlPanel.animate().translationY(controlPanel.getHeight());
 			ValueAnimator animator = ValueAnimator.ofInt(bottomSheetLayout.getPaddingBottom(), 0);
 			animator.addUpdateListener(valueAnimator -> bottomSheetLayout.setPadding(
-				gridPaddingLeftRight,
 				0,
-				gridPaddingLeftRight,
+				0,
+				0,
 				(Integer) valueAnimator.getAnimatedValue()));
 			animator.setDuration(300);
 			animator.start();
@@ -100,6 +119,7 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 		stub.inflate();
 
 		this.controlPanel = findViewById(R.id.control_panel);
+		controlPanel.setTranslationY(controlPanel.getHeight());
 		ConstraintLayout selectPanel = findViewById(R.id.select_panel);
 		this.cancelButton = selectPanel.findViewById(R.id.cancel);
 		this.selectButton = selectPanel.findViewById(R.id.select);
@@ -130,7 +150,14 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 			mediaItems.add(mediaItem);
 		}
-		setResult(ThreemaActivity.RESULT_OK, new Intent().putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems));
+
+		Intent resultIntent = new Intent();
+		resultIntent.putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems);
+		if (mediaAttachViewModel.getLastQuery() != null) {
+			resultIntent = IntentDataUtil.addLastMediaFilterToIntent(resultIntent,
+				mediaAttachViewModel.getLastQuery(), mediaAttachViewModel.getLastQueryType());
+		}
+		setResult(RESULT_OK, resultIntent);
 		finish();
 	}
 
@@ -148,7 +175,7 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 		if (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
 			switch (requestCode) {
 				case PERMISSION_REQUEST_ATTACH_FILE:
-					updateUI(STATE_COLLAPSED);
+					updateUI(BottomSheetBehavior.STATE_COLLAPSED);
 					toolbar.setVisibility(View.GONE);
 					selectButton.setAlpha(0.3f);
 					selectButton.setOnClickListener(v -> {
@@ -176,5 +203,4 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 			}
 		}
 	}
-
 }

+ 112 - 288
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java

@@ -25,39 +25,30 @@ import android.Manifest;
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
 import android.annotation.SuppressLint;
-import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.content.res.Resources;
-import android.database.Cursor;
-import android.database.MatrixCursor;
-import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
 import android.os.Build;
 import android.os.Bundle;
-import android.provider.BaseColumns;
-import android.provider.MediaStore;
 import android.text.TextUtils;
 import android.util.DisplayMetrics;
+import android.util.TypedValue;
 import android.view.Gravity;
 import android.view.HapticFeedbackConstants;
-import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewTreeObserver;
 import android.view.animation.Animation;
-import android.widget.AutoCompleteTextView;
-import android.widget.EditText;
 import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
+import android.widget.ProgressBar;
 import android.widget.TextView;
-import android.widget.Toast;
 
-import com.getkeepsafe.taptargetview.TapTarget;
-import com.getkeepsafe.taptargetview.TapTargetView;
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.bottomsheet.BottomSheetBehavior;
@@ -69,18 +60,19 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.TreeMap;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
+import androidx.appcompat.content.res.AppCompatResources;
 import androidx.appcompat.widget.FitWindowsFrameLayout;
 import androidx.appcompat.widget.PopupMenu;
 import androidx.appcompat.widget.PopupMenuWrapper;
-import androidx.appcompat.widget.SearchView;
 import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
 import androidx.coordinatorlayout.widget.CoordinatorLayout;
 import androidx.core.content.ContextCompat;
-import androidx.cursoradapter.widget.CursorAdapter;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.GridLayoutManager;
@@ -90,26 +82,26 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.EnterSerialActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.activities.UnlockMasterKeyActivity;
+import ch.threema.app.fragments.ComposeMessageFragment;
 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.GridRecyclerView;
+import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.MediaGridItemDecoration;
 import ch.threema.app.ui.MediaItem;
-import ch.threema.app.ui.OnKeyboardBackRespondingSearchView;
-import ch.threema.app.ui.SingleToast;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.FileUtil;
+import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LocaleUtil;
-import ch.threema.app.utils.MimeUtil;
 import ch.threema.localcrypto.MasterKey;
+import me.zhanghai.android.fastscroll.FastScroller;
+import me.zhanghai.android.fastscroll.FastScrollerBuilder;
 
-import static android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI;
-import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
-import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_BUCKET;
-import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_LABEL;
-import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_SELECTED;
-import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_TYPE;
+import static ch.threema.app.mediaattacher.MediaFilterQuery.FILTER_MEDIA_BUCKET;
+import static ch.threema.app.mediaattacher.MediaFilterQuery.FILTER_MEDIA_SELECTED;
+import static ch.threema.app.mediaattacher.MediaFilterQuery.FILTER_MEDIA_TYPE;
 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_DRAGGING;
 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
@@ -123,6 +115,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	// Threema services
 	protected ServiceManager serviceManager;
 	protected PreferenceService preferenceService;
+	protected GroupService groupService;
 
 	public static final String KEY_BOTTOM_SHEET_STATE = "bottom_sheet_state";
 	protected static final int PERMISSION_REQUEST_ATTACH_FROM_GALLERY = 4;
@@ -132,7 +125,8 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected CoordinatorLayout rootView;
 	protected AppBarLayout appBarLayout;
 	protected MaterialToolbar toolbar;
-	protected GridRecyclerView mediaAttachRecyclerView;
+	protected EmptyRecyclerView mediaAttachRecyclerView;
+	protected FastScroller fastScroller;
 	protected GridLayoutManager gridLayoutManager;
 	protected ConstraintLayout bottomSheetLayout;
 	protected ImageView dragHandle;
@@ -140,23 +134,19 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected LinearLayout menuTitleFrame;
 	protected TextView dateTextView, menuTitle;
 	protected DisplayMetrics displayMetrics;
-	protected SearchView searchView;
-	protected MenuItem searchItem, selectFromGalleryItem;
+	protected MenuItem selectFromGalleryItem;
 	protected PopupMenu bucketFilterMenu;
 
 	protected MediaAttachViewModel mediaAttachViewModel;
 
 	protected MediaAttachAdapter mediaAttachAdapter;
-	protected CursorAdapter suggestionAdapter;
-	protected AutoCompleteTextView searchAutoComplete;
-	protected List<String> labelSuggestions;
 	protected int peekHeightNumElements = 1;
 
 	private boolean isDragging = false;
+	private boolean bottomSheetScroll = false;
 
 	// Locks
 	private final Object filterMenuLock = new Object();
-	private final Object firstTimeTooltipLock = new Object();
 
 	/* start lifecycle methods */
 	@Override
@@ -170,11 +160,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		initActivity(savedInstanceState);
 	}
 
-	@Override
-	public void onDestroy() {
-		super.onDestroy();
-	}
-
 	@UiThread
 	protected void handleSavedInstanceState(Bundle savedInstanceState){
 		if (savedInstanceState != null) {
@@ -226,6 +211,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		if (serviceManager != null) {
 			try {
 				this.preferenceService = serviceManager.getPreferenceService();
+				this.groupService = serviceManager.getGroupService();
 			} catch (Exception e) {
 				logger.error("Exception", e);
 				finish();
@@ -238,10 +224,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		this.rootView = findViewById(R.id.coordinator);
 		this.appBarLayout = findViewById(R.id.appbar_layout);
 		this.toolbar = findViewById(R.id.toolbar);
-		this.searchItem = this.toolbar.getMenu().findItem(R.id.menu_search);
 		this.selectFromGalleryItem = this.toolbar.getMenu().findItem(R.id.menu_select_from_gallery);
-		this.searchView = (SearchView) searchItem.getActionView();
-		this.searchAutoComplete = searchView.findViewById(androidx.appcompat.R.id.search_src_text);
 		this.menuTitleFrame = findViewById(R.id.toolbar_title);
 		this.menuTitle = findViewById(R.id.toolbar_title_textview);
 		this.bottomSheetLayout = findViewById(R.id.bottom_sheet);
@@ -251,8 +234,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		this.dateView = findViewById(R.id.date_separator_container);
 		this.dateTextView = findViewById(R.id.text_view);
 
-		this.searchView.setIconifiedByDefault(true);
-
 		// fill background with transparent black to see chat behind drawer
 		FitWindowsFrameLayout contentFrameLayout = (FitWindowsFrameLayout) ((ViewGroup) rootView.getParent()).getParent();
 		contentFrameLayout.setOnClickListener(v -> finish());
@@ -280,14 +261,12 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			contentFrameLayout.setOnClickListener(v -> finish());
 
 			this.gridLayoutManager = new GridLayoutManager(this, 4);
-			mediaAttachRecyclerView.addItemDecoration(new MediaGridItemDecoration(getResources().getDimensionPixelSize(R.dimen.grid_spacing), 4));
-			mediaAttachRecyclerView.setLayoutManager(gridLayoutManager);
+			this.mediaAttachRecyclerView.setLayoutManager(gridLayoutManager);
 
 			this.peekHeightNumElements = 1;
 		} else {
 			this.gridLayoutManager = new GridLayoutManager(this, 3);
-			mediaAttachRecyclerView.addItemDecoration(new MediaGridItemDecoration(getResources().getDimensionPixelSize(R.dimen.grid_spacing), 3));
-			mediaAttachRecyclerView.setLayoutManager(gridLayoutManager);
+			this.mediaAttachRecyclerView.setLayoutManager(gridLayoutManager);
 
 			this.peekHeightNumElements = isInSplitScreenMode() ? 1 : 2;
 		}
@@ -297,25 +276,21 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 		// Listen for layout changes
 		this.mediaAttachAdapter = new MediaAttachAdapter(this, this);
+		this.mediaAttachRecyclerView.addItemDecoration(new MediaGridItemDecoration(getResources().getDimensionPixelSize(R.dimen.grid_spacing)));
 		this.mediaAttachRecyclerView.setAdapter(mediaAttachAdapter);
-
-		// Wait for search labels to be ready
-		if (ConfigUtils.isPlayServicesInstalled(this)) {
-			this.mediaAttachViewModel.getSuggestionLabels().observe(this, labels -> {
-				if (labels != null && !labels.isEmpty()) {
-					this.labelSuggestions = labels;
-					this.onLabelingComplete();
-
-					// reset last recent label filter if activity was destroyed by the system due to memory pressure etc.
-					String savedQuery = mediaAttachViewModel.getLastQuery();
-					Integer savedQueryType = mediaAttachViewModel.getLastQueryType();
-					if (savedQueryType != null && savedQueryType == FILTER_MEDIA_LABEL) {
-						mediaAttachViewModel.setMediaByLabel(savedQuery);
-						searchView.clearFocus();
-					}
-				}
-			});
-		}
+		ProgressBar progressBar = (ProgressBar) getLayoutInflater().inflate(R.layout.item_progress, null);
+
+		ConstraintSet set = new ConstraintSet();
+		// set view id, else getId() returns -1
+		progressBar.setId(View.generateViewId());
+		bottomSheetLayout.addView(progressBar, 0);
+		set.clone(bottomSheetLayout);
+		set.connect(progressBar.getId(), ConstraintSet.TOP, bottomSheetLayout.getId(), ConstraintSet.TOP, 60);
+		set.connect(progressBar.getId(), ConstraintSet.BOTTOM, bottomSheetLayout.getId(), ConstraintSet.BOTTOM, 60);
+		set.connect(progressBar.getId(), ConstraintSet.LEFT, bottomSheetLayout.getId(), ConstraintSet.LEFT, 60);
+		set.connect(progressBar.getId(), ConstraintSet.RIGHT, bottomSheetLayout.getId(), ConstraintSet.RIGHT, 60);
+		set.applyTo(bottomSheetLayout);
+		this.mediaAttachRecyclerView.setEmptyView(progressBar);
 
 		ConfigUtils.addIconsToOverflowMenu(this, this.toolbar.getMenu());
 
@@ -339,16 +314,14 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected void setDropdownMenu() {
 		this.bucketFilterMenu = new PopupMenuWrapper(this, menuTitle);
 
-		if (mediaAttachViewModel.getToolBarTitle() == null) {
+		if (mediaAttachViewModel.getLastQuery() == null) {
 			menuTitle.setText(R.string.attach_gallery);
 		} else {
-			menuTitle.setText(mediaAttachViewModel.getToolBarTitle());
+			menuTitle.setText(mediaAttachViewModel.getLastQuery());
 		}
 
 		MenuItem topMenuItem = bucketFilterMenu.getMenu().add(Menu.NONE, 0, 0, R.string.attach_gallery).setOnMenuItemClickListener(menuItem -> {
 			setAllResultsGrid();
-			menuTitle.setText(menuItem.toString());
-			mediaAttachViewModel.setToolBarTitle(menuItem.toString());
 			return true;
 		});
 		topMenuItem.setIcon(R.drawable.ic_collections);
@@ -395,8 +368,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				for (Map.Entry<String, Integer> mediaType : mediaTypes.entrySet()) {
 					MenuItem item = menu.add(mediaType.getKey()).setOnMenuItemClickListener(menuItem -> {
 						filterMediaByMimeType(menuItem.toString());
-						menuTitle.setText(menuItem.toString());
-						mediaAttachViewModel.setToolBarTitle(menuItem.toString());
 						return true;
 					});
 
@@ -418,8 +389,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 					if (!TextUtils.isEmpty(bucket)) {
 						MenuItem item = menu.add(bucket).setOnMenuItemClickListener(menuItem -> {
 							filterMediaByBucket(menuItem.toString());
-							menuTitle.setText(menuItem.toString());
-							mediaAttachViewModel.setToolBarTitle(menuItem.toString());
 							return true;
 						});
 						item.setIcon(R.drawable.ic_outline_folder_24);
@@ -431,7 +400,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				menuTitleFrame.setOnClickListener(view -> bucketFilterMenu.show());
 			}
 
-			// reset last recent filter if activity was destroyed by the system due to memory pressure etc and we do not have to wait for suggestion labels.
+			// reset last recent filter if activity was destroyed by the system due to memory pressure etc.
 			String savedQuery = mediaAttachViewModel.getLastQuery();
 			Integer savedQueryType = mediaAttachViewModel.getLastQueryType();
 			if (savedQueryType != null) {
@@ -460,11 +429,44 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	@UiThread
 	protected void setInitialMediaGrid() {
 		if (shouldShowMediaGrid()) {
-			// Observe the LiveData, passing in this activity as the LifecycleOwner and Observer.
-			mediaAttachViewModel.getCurrentMedia().observe(this, newMediaItems -> {
-				mediaAttachAdapter.setMediaItems(newMediaItems);
+			// check for previous filter selection to be reset
+			Intent intent = getIntent();
+			int queryType = 0;
+			String query = null;
+			if (intent.hasExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY)) {
+				MediaFilterQuery lastFilter = IntentDataUtil.getLastMediaFilterFromIntent(intent);
+				queryType = lastFilter.getType();
+				query = lastFilter.getQuery();
+			}
+			// Observe the LiveData for initial loading of all media, passing in this activity as the LifecycleOwner and Observer.
+			// if we previously searched media in a chat we reset the filter, otherwise we post all media to grid view
+			int finalPreviousQueryType = queryType;
+			String finalPreviousQuery = query;
+			mediaAttachViewModel.getAllMedia().observe(this, allItems -> {
+				if (!allItems.isEmpty()) {
+					if (finalPreviousQuery != null) {
+						switch (finalPreviousQueryType) {
+							case FILTER_MEDIA_TYPE:
+								filterMediaByMimeType(finalPreviousQuery);
+								break;
+							case FILTER_MEDIA_BUCKET:
+								filterMediaByBucket(finalPreviousQuery);
+								break;
+						}
+					}
+					// finally set all media unless we remember a query in the viewmodel over orientation change
+					else if (mediaAttachViewModel.getLastQueryType() == null) {
+						setAllResultsGrid();
+					}
+					// remove after receiving full list as we listen to current selected media afterwards to update the grid view
+					mediaAttachViewModel.getAllMedia().removeObservers(this);
+				}
+			});
 
-				// Data loaded, we can now properly calculate the peek height
+			// Observe the LiveData for current selection, passing in this activity as the LifecycleOwner and Observer.
+			mediaAttachViewModel.getCurrentMedia().observe(this, currentlyShowingItems -> {
+				mediaAttachAdapter.setMediaItems(currentlyShowingItems);
+				// Data loaded, we can now properly calculate the peek height and set/reset UI to expanded state
 				updatePeekHeight();
 			});
 		}
@@ -482,52 +484,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected void setListeners() {
 		this.appBarLayout.setOnClickListener(this);
 
-		this.searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
-			@Override
-			public boolean onMenuItemActionExpand(MenuItem item) {
-				menuTitleFrame.setVisibility(View.GONE);
-				return true;
-			}
-
-			@Override
-			public boolean onMenuItemActionCollapse(MenuItem item) {
-				menuTitleFrame.setVisibility(View.VISIBLE);
-				if (item.isEnabled()) {
-					menuTitle.setText(R.string.attach_gallery);
-					mediaAttachViewModel.setAllMedia();
-				} else {
-					item.setEnabled(true);
-				}
-				return true;
-			}
-		});
-
-		this.searchView.setOnQueryTextListener(new OnKeyboardBackRespondingSearchView.OnQueryTextListener() {
-			@Override
-			public boolean onQueryTextSubmit(String query) {
-				mediaAttachViewModel.setMediaByLabel(query);
-				searchView.clearFocus();
-				return false;
-			}
-
-			@Override
-			public boolean onQueryTextChange(String newText) {
-				if (labelSuggestions != null && !TextUtils.isEmpty(newText)){
-					populateAdapter(newText);
-				}
-				return false;
-			}
-		});
-
-		View searchViewCloseButton = searchView.findViewById(androidx.appcompat.R.id.search_close_btn);
-		if (searchViewCloseButton != null) {
-			searchViewCloseButton.setOnClickListener(v -> {
-				mediaAttachViewModel.setAllMedia();
-				searchView.setQuery("", false);
-				searchView.requestFocus();
-			});
-		}
-
 		BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
 		bottomSheetBehavior.setExpandedOffset(50);
 		bottomSheetBehavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@@ -552,6 +508,16 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				if (controlPanel.getTranslationY() == 0 && mediaAttachViewModel.getSelectedMediaItemsHashMap().isEmpty() && bottomSheetBehavior.getState() == STATE_EXPANDED) {
 					controlPanel.animate().translationY(controlPanel.getHeight());
 				}
+
+				// make sure only bottom sheet or recylcerview is scrolling at a same time
+				if (bottomSheetScroll && bottomSheetBehavior.getState() == STATE_EXPANDED) {
+					bottomSheetScroll = false;
+					bottomSheetBehavior.setDraggable(false);
+				}
+				else if (!bottomSheetScroll && !recyclerView.canScrollVertically(-1)) {
+					bottomSheetScroll = true;
+					bottomSheetBehavior.setDraggable(true);
+				}
 			}
 		});
 	}
@@ -566,60 +532,16 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		AnimationUtil.startActivity(this, view, intent);
 	}
 
-	@UiThread
-	public void onLabelingComplete() {
-		logger.debug("Labeling is complete, show search view");
-
-		if (searchView != null) {
-			// Show search icon
-			searchItem.setVisible(true);
-			searchView.setQueryHint(getString(R.string.image_label_query_hint));
-
-			// Don't expand the search text field
-			final EditText editText = searchView.findViewById(R.id.search_src_text);
-			editText.setImeOptions(IME_FLAG_NO_EXTRACT_UI);
-
-			// Create and set a CursorAdapter for the recommendation dropdown list
-			MediaSelectionBaseActivity.this.suggestionAdapter = new CursorAdapter(
-				MediaSelectionBaseActivity.this,
-				new MatrixCursor(new String[]{ BaseColumns._ID, "labelName" }), false
-			) {
-				@Override
-				public View newView(Context context, Cursor cursor, ViewGroup parent) {
-					return LayoutInflater.from(MediaSelectionBaseActivity.this).inflate(android.R.layout.simple_list_item_1, parent, false);
-				}
-
-				@Override
-				public void bindView(View view, Context context, Cursor cursor) {
-					String label = cursor.getString(cursor.getColumnIndexOrThrow("labelName"));
-					TextView textView = view.findViewById(android.R.id.text1);
-					textView.setText(label);
-					view.setOnClickListener(view1 -> {
-						searchView.setQuery(label, true);
-						mediaAttachViewModel.setlastQuery(FILTER_MEDIA_LABEL, label);
-					});
-				}
-			};
-			searchView.setSuggestionsAdapter(suggestionAdapter);
-			MediaSelectionBaseActivity.this.searchAutoComplete.setThreshold(1);
-		}
-	}
-
-	protected void resetLabelSearch() {
-		searchView.setQuery("", false);
-		searchView.setIconified(true);
-	}
-
 	public void setAllResultsGrid() {
 		mediaAttachViewModel.setAllMedia();
+		menuTitle.setText(getResources().getString(R.string.attach_gallery));
 		mediaAttachViewModel.clearLastQuery();
-		resetLabelSearch();
 	}
 
 	public void filterMediaByBucket(@NonNull String mediaBucket) {
 		mediaAttachViewModel.setMediaByBucket(mediaBucket);
+		menuTitle.setText(mediaBucket);
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_BUCKET, mediaBucket);
-		resetLabelSearch();
 	}
 
 	public void filterMediaByMimeType(@NonNull String mimeTypeTitle) {
@@ -638,16 +560,14 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		if (mimeTypeIndex != 0) {
 			mediaAttachViewModel.setMediaByType(mimeTypeIndex);
 		}
+		menuTitle.setText(mimeTypeTitle);
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_TYPE, mimeTypeTitle);
-		resetLabelSearch();
 	}
 
 	public void filterMediaBySelectedItems() {
 		mediaAttachViewModel.setSelectedMedia();
-		searchItem.setEnabled(false);
-		searchItem.collapseActionView();
+		menuTitle.setText(R.string.selected_media);
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_SELECTED, null);
-		resetLabelSearch();
 	}
 
 	public String getMimeTypeTitle(int mimeType) {
@@ -663,33 +583,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		}
 	}
 
-	private void populateAdapter(@NonNull String query) {
-		final MatrixCursor c = new MatrixCursor(new String[]{ BaseColumns._ID, "labelName" });
-		int index = 0;
-		if (!labelSuggestions.isEmpty()){
-			for (String label : labelSuggestions) {
-				if (label != null && label.toLowerCase().startsWith(query.toLowerCase())){
-					c.addRow(new Object[] {index, label});
-				}
-				index++;
-			}
-
-			if (c.getCount() == 0){
-				SingleToast.getInstance().showShortText(getString(R.string.no_labels_info));
-			}
-
-			suggestionAdapter.changeCursor(c);
-			// avoid too long drop down suggestion list that might disappear behind keyboard
-			if (c.getCount() > 6){
-				searchAutoComplete.setDropDownHeight(displayMetrics.heightPixels/3);
-			} else {
-				searchAutoComplete.setDropDownHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
-			}
-		} else {
-			SingleToast.getInstance().showShortText(getString(R.string.no_labels_info));
-		}
-	}
-
 	public void updateUI(int state){
 		Animation animation;
 		switch (state) {
@@ -703,7 +596,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 				bucketFilterMenu.getMenu().setGroupVisible(Menu.NONE, true);
 				menuTitleFrame.setClickable(true);
-				searchView.findViewById(R.id.search_button).setClickable(true);
 
 				animation = toolbar.getAnimation();
 				if (animation != null) {
@@ -727,15 +619,23 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 						50
 					);
 				}
-
-				if (mediaAttachViewModel.getSelectedMediaItemsHashMap().isEmpty()) {
+				// hide
+				if (mediaAttachViewModel.getSelectedMediaItemsHashMap().isEmpty() && controlPanel.getTranslationY() == 0) {
 					controlPanel.animate().translationY(controlPanel.getHeight());
-				} else {
+				} else { // show
 					controlPanel.animate().translationY(0);
 				}
 
-				// Maybe show "new feature" tooltip
-				toolbar.postDelayed(() -> maybeShowFirstTimeToolTip(), 1500);
+				if (Build.VERSION.SDK_INT >= 21 && fastScroller == null) {
+					TypedValue value = new TypedValue();
+					this.getTheme().resolveAttribute(R.attr.attach_media_thumb_drawable, value, true);
+					Drawable thumbDrawable = AppCompatResources.getDrawable(this, value.resourceId);
+					fastScroller = new FastScrollerBuilder(MediaSelectionBaseActivity.this.mediaAttachRecyclerView)
+						.setThumbDrawable(Objects.requireNonNull(thumbDrawable))
+						.setTrackDrawable(Objects.requireNonNull(AppCompatResources.getDrawable(this, R.drawable.fastscroll_track_media)))
+						.setPadding(0,0,0,0)
+						.build();
+				}
 
 				isDragging = false;
 
@@ -772,7 +672,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				dateView.setVisibility(View.GONE);
 				bucketFilterMenu.getMenu().setGroupVisible(Menu.NONE, false);
 				menuTitleFrame.setClickable(false);
-				searchView.findViewById(R.id.search_button).setClickable(false);
 				controlPanel.animate().translationY(0);
 				isDragging = false;
 			default:
@@ -883,10 +782,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			case R.id.select_counter_button:
 				if (mediaAttachAdapter != null) {
 					filterMediaBySelectedItems();
-					searchView.onActionViewCollapsed();
-					searchView.clearFocus();
-					menuTitle.setText(R.string.selected_media);
-					mediaAttachViewModel.setToolBarTitle(getResources().getString(R.string.selected_media));
 				}
 				break;
 		}
@@ -898,67 +793,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		ConfigUtils.showPermissionRationale(this, rootView, stringResource);
 	}
 
-	/**
-	 * Show the "first time" tool tip (but only if it hasn't been shown before
-	 * and if the bottom sheet is fully expanded).
-	 */
-	@UiThread
-	protected void maybeShowFirstTimeToolTip() {
-		// This code is synchronized so that we don't show the tooltip multiple times
-		synchronized (this.firstTimeTooltipLock) {
-			// Check preconditions
-			if (preferenceService.getIsImageLabelingTooltipShown()) {
-				// Only shown if it hasn't already been shown
-				return;
-			}
-			if (this.searchView == null || !this.searchItem.isVisible()) {
-				// Only show if the search icon is visible
-				return;
-			}
-			final BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
-			if (bottomSheetBehavior.getState() != STATE_EXPANDED) {
-				// Only show if the bottom sheet is fully expanded
-				return;
-			}
-
-			// Show tooltip
-			try {
-				final String title = getString(R.string.image_labeling_new);
-				final String description = getString(R.string.tooltip_image_labeling);
-				final int accentColor = ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK ? R.color.accent_dark : R.color.accent_light;
-
-				TapTargetView.showFor(this,
-					TapTarget.forToolbarMenuItem(this.toolbar, R.id.menu_search, title, description)
-						.outerCircleColor(accentColor)      // Specify a color for the outer circle
-						.outerCircleAlpha(0.96f)            // Specify the alpha amount for the outer circle
-						.targetCircleColor(android.R.color.white)   // Specify a color for the target circle
-						.titleTextSize(24)                  // Specify the size (in sp) of the title text
-						.titleTextColor(android.R.color.white)      // Specify the color of the title text
-						.descriptionTextSize(18)            // Specify the size (in sp) of the description text
-						.descriptionTextColor(android.R.color.white)  // Specify the color of the description text
-						.textColor(android.R.color.white)            // Specify a color for both the title and description text
-						.textTypeface(Typeface.SANS_SERIF)  // Specify a typeface for the text
-						.dimColor(android.R.color.black)            // If set, will dim behind the view with 30% opacity of the given color
-						.drawShadow(true)                   // Whether to draw a drop shadow or not
-						.cancelable(true)                  // Whether tapping outside the outer circle dismisses the view
-						.tintTarget(true)                   // Whether to tint the target view's color
-						.transparentTarget(false)           // Specify whether the target is transparent (displays the content underneath)
-						.targetRadius(50),                  // Specify the target radius (in dp)
-					new TapTargetView.Listener() {          // The listener can listen for regular clicks, long clicks or cancels
-						@Override
-						public void onTargetClick(TapTargetView view) {
-							super.onTargetClick(view);
-
-						}
-					});
-				logger.info("First time tool tip shown");
-				preferenceService.setIsImageLabelingTooltipShown(true);
-			} catch (Exception e) {
-				logger.warn("Could not show first time labeling tooltip", e);
-			}
-		}
-	}
-
 	public void checkMasterKey() {
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 		if (masterKey != null && masterKey.isLocked()) {
@@ -983,22 +817,12 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	}
 
 	protected void attachImageFromGallery() {
-		try {
-			Intent getContentIntent = new Intent();
-			getContentIntent.setType(MimeUtil.MIME_TYPE_VIDEO);
-			getContentIntent.setAction(Intent.ACTION_GET_CONTENT);
-			getContentIntent.addCategory(Intent.CATEGORY_OPENABLE);
-			getContentIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
-			getContentIntent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, MAX_BLOB_SIZE);
-			Intent pickIntent = new Intent(Intent.ACTION_PICK);
-			pickIntent.setType(MimeUtil.MIME_TYPE_IMAGE);
-			Intent chooserIntent = Intent.createChooser(pickIntent, getString(R.string.select_from_gallery));
-			chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
-
-			startActivityForResult(chooserIntent, REQUEST_CODE_ATTACH_FROM_GALLERY);
-		} catch (Exception e) {
-			logger.debug("Exception", e);
-			Toast.makeText(this, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show();
-		}
+		FileUtil.selectFromGallery(this, null, REQUEST_CODE_ATTACH_FROM_GALLERY, true);
+	}
+
+	protected void expandBottomSheet() {
+		BottomSheetBehavior<ConstraintLayout> bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
+		bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
+		updateUI(BottomSheetBehavior.STATE_EXPANDED);
 	}
 }

+ 0 - 49
app/src/main/java/ch/threema/app/mediaattacher/data/FailedMediaItemsDAO.java

@@ -1,49 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.data;
-
-import java.util.List;
-
-import androidx.room.Dao;
-import androidx.room.Delete;
-import androidx.room.Insert;
-import androidx.room.OnConflictStrategy;
-import androidx.room.Query;
-
-@Dao
-public interface FailedMediaItemsDAO {
-
-	@Insert(onConflict = OnConflictStrategy.REPLACE)
-	void insert(FailedMediaItemEntity mediaItem);
-
-	@Delete
-	void delete(FailedMediaItemEntity mediaItem);
-
-	@Query("SELECT * FROM failed_media_items WHERE id = :id LIMIT 1")
-	FailedMediaItemEntity get(int id);
-
-	@Query("SELECT COUNT(*) FROM failed_media_items")
-	int getRowCount();
-
-	@Query("SELECT * FROM failed_media_items ORDER BY id ASC")
-	List<FailedMediaItemEntity> getAllItemsByAscIdOrder();
-}

+ 0 - 43
app/src/main/java/ch/threema/app/mediaattacher/data/ImageLabelListConverter.java

@@ -1,43 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.data;
-
-import com.google.gson.Gson;
-import com.google.gson.reflect.TypeToken;
-
-import java.lang.reflect.Type;
-import java.util.ArrayList;
-
-import androidx.room.TypeConverter;
-
-public class ImageLabelListConverter {
-	@TypeConverter
-	public static ArrayList<String> fromString(String value) {
-		Type listType = new TypeToken<ArrayList<String>>() {}.getType();
-		return new Gson().fromJson(value, listType);
-	}
-
-	@TypeConverter
-	public static String fromArrayList(ArrayList<String> list) {
-		return new Gson().toJson(list);
-	}
-}

+ 0 - 61
app/src/main/java/ch/threema/app/mediaattacher/data/LabeledMediaItemsDAO.java

@@ -1,61 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import androidx.room.Dao;
-import androidx.room.Insert;
-import androidx.room.OnConflictStrategy;
-import androidx.room.Query;
-
-@Dao
-public interface LabeledMediaItemsDAO {
-
-	@Insert(onConflict = OnConflictStrategy.IGNORE)
-	void insert(LabeledMediaItemEntity mediaItem);
-
-	@Query("INSERT into media_items_table (id, labels) VALUES (:id, :labelList)")
-	void insert(int id, ArrayList<String> labelList);
-
-	@Query("DELETE FROM media_items_table")
-	void deleteAll();
-
-	@Query("DELETE FROM media_items_table WHERE id = :id")
-	void deleteMediaItemById(int id);
-
-	@Query("SELECT * FROM media_items_table")
-	List<LabeledMediaItemEntity> getAll();
-
-	@Query("SELECT * FROM media_items_table ORDER BY id ASC")
-	List<LabeledMediaItemEntity> getAllItemsByAscIdOrder();
-
-	@Query("SELECT labels from media_items_table WHERE id = :id")
-	List<String> getMediaItemLabels(int id);
-
-	@Query("UPDATE media_items_table SET labels = :labels WHERE id = :id")
-	void setLabels(List<String> labels, int id);
-
-	@Query("SELECT COUNT(*) FROM media_items_table")
-	int getRowCount();
-}

+ 0 - 104
app/src/main/java/ch/threema/app/mediaattacher/data/MediaItemsRoomDatabase.java

@@ -1,104 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.data;
-
-
-import android.content.Context;
-
-import net.sqlcipher.database.SQLiteException;
-import net.sqlcipher.database.SupportFactory;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import androidx.room.Database;
-import androidx.room.Room;
-import androidx.room.RoomDatabase;
-import androidx.room.TypeConverters;
-import androidx.room.migration.Migration;
-import androidx.sqlite.db.SupportSQLiteDatabase;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.localcrypto.MasterKeyLockedException;
-
-@Database(
-	entities = {LabeledMediaItemEntity.class, FailedMediaItemEntity.class},
-	version = 2,
-	exportSchema = false
-)
-@TypeConverters({ImageLabelListConverter.class})
-public abstract class MediaItemsRoomDatabase extends RoomDatabase {
-	private static final Logger logger = LoggerFactory.getLogger(MediaItemsRoomDatabase.class);
-
-	public static final String DATABASE_NAME = "media_items.db";
-
-	public abstract LabeledMediaItemsDAO mediaItemsDAO();
-	public abstract FailedMediaItemsDAO failedMediaItemsDAO();
-
-	private static volatile MediaItemsRoomDatabase db;
-
-	static final Migration MIGRATION_1_2 = new Migration(1, 2) {
-		@Override
-		public void migrate(SupportSQLiteDatabase database) {
-			database.execSQL("CREATE TABLE `failed_media_items` (`id` INTEGER NOT NULL, "
-				+ "`timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))");
-		}
-	};
-
-	public static MediaItemsRoomDatabase getDatabase(final Context context) throws MasterKeyLockedException, SQLiteException {
-		if (db == null) {
-			synchronized (MediaItemsRoomDatabase.class) {
-				if (db == null) {
-					logger.info("Creating database");
-					SupportFactory factory;
-					try {
-						factory = new SupportFactory(ThreemaApplication.getMasterKey().getKey(), null, false);
-					} catch (MasterKeyLockedException e) {
-						throw new MasterKeyLockedException("Masterkey locked, cannot get database");
-					}
-					db = Room
-						.databaseBuilder(context.getApplicationContext(), MediaItemsRoomDatabase.class, DATABASE_NAME)
-						.addMigrations(MIGRATION_1_2)
-						.openHelperFactory(factory)
-						.build();
-				}
-			}
-		}
-		return db;
-	}
-
-	/**
-	 * Close and destroy the database instance.
-	 */
-	public static void destroyInstance() {
-		if (db != null) {
-			synchronized (MediaItemsRoomDatabase.class) {
-				if (db != null) {
-					if (db.isOpen()) {
-						logger.info("Closing database");
-						db.close();
-					}
-					db = null;
-				}
-			}
-		}
-	}
-}

+ 0 - 423
app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelingWorker.java

@@ -1,423 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.labeling;
-
-import android.Manifest;
-import android.app.Notification;
-import android.content.Context;
-import android.content.pm.PackageManager;
-import android.net.Uri;
-import android.os.SystemClock;
-
-import com.google.android.gms.tasks.Task;
-import com.google.android.gms.tasks.Tasks;
-import com.google.mlkit.vision.common.InputImage;
-import com.google.mlkit.vision.label.ImageLabel;
-import com.google.mlkit.vision.label.ImageLabeler;
-import com.google.mlkit.vision.label.ImageLabeling;
-import com.google.mlkit.vision.label.defaults.ImageLabelerOptions;
-
-import net.sqlcipher.database.SQLiteException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.WorkerThread;
-import androidx.core.content.ContextCompat;
-import androidx.work.ForegroundInfo;
-import androidx.work.Worker;
-import androidx.work.WorkerParameters;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.mediaattacher.MediaAttachItem;
-import ch.threema.app.mediaattacher.MediaRepository;
-import ch.threema.app.mediaattacher.data.FailedMediaItemEntity;
-import ch.threema.app.mediaattacher.data.FailedMediaItemsDAO;
-import ch.threema.app.mediaattacher.data.LabeledMediaItemEntity;
-import ch.threema.app.mediaattacher.data.LabeledMediaItemsDAO;
-import ch.threema.app.mediaattacher.data.MediaItemEntity;
-import ch.threema.app.mediaattacher.data.MediaItemsRoomDatabase;
-import ch.threema.app.services.NotificationService;
-import ch.threema.app.ui.MediaItem;
-import ch.threema.app.utils.RandomUtil;
-import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.logging.ThreemaLogger;
-
-/**
- * The ImageLabelingWorker fetches all media from the device and labels all images.
- * Results are written to the media {@link MediaItemsRoomDatabase}.
- *
- * Note: This worker requires Google Play Services due to the reliance on ML Kit!
- */
-public class ImageLabelingWorker extends Worker {
-	// Non-static logger (so that a prefix can be set)
-	private final Logger logger = LoggerFactory.getLogger(ImageLabelingWorker.class);
-
-	// Context
-	private final Context appContext;
-
-	// Media on device
-	private final MediaRepository repository;
-
-	// Database
-	private final LabeledMediaItemsDAO mediaItemsDAO;
-	private final FailedMediaItemsDAO failedMediaDAO;
-
-	// Image labeling
-	private final ImageLabeler labeler;
-
-	// Unique work name
-	public static final String UNIQUE_WORK_NAME = "ImageLabelsOneTime";
-
-	// Progress
-	private volatile boolean cancelled = true;
-	private int mediaCount;
-	private int progress;
-
-	// Executor
-	private final ThreadPoolExecutor executor;
-
-	// Global mutex
-	private static final Object globalLock = new Object();
-
-	/**
-	 * Constructor for the ImageLabelingWorker.
-	 *
-	 * Note: This constructor is called by the WorkManager, so don't add additional parameters!
-	 */
-	public ImageLabelingWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) {
-		super(appContext, workerParams);
-
-		// Set a log prefix to be able to differentiate multiple workers
-		if (logger instanceof ThreemaLogger) {
-			((ThreemaLogger)logger).setPrefix("id=" + RandomUtil.generateInsecureRandomAsciiString(6));
-		}
-
-		this.appContext = getApplicationContext();
-
-		// Get database reference
-		try {
-			this.mediaItemsDAO = MediaItemsRoomDatabase.getDatabase(this.appContext).mediaItemsDAO();
-			this.failedMediaDAO = MediaItemsRoomDatabase.getDatabase(this.appContext).failedMediaItemsDAO();
-
-		} catch (MasterKeyLockedException e) {
-			logger.error("Could not get media items database, master key locked", e);
-			onStopped();
-			throw new IllegalStateException("Could not get media items database, master key locked");
-		} catch (SQLiteException e) {
-			logger.error("Could not get media items database, SQLite Exception", e);
-			onStopped();
-			throw new IllegalStateException("Could not get media items database, SQLite Exception");
-		}
-
-		// Initialize media repository
-		this.repository = new MediaRepository(this.appContext);
-
-		// Create labeler
-		final ImageLabelerOptions options = new ImageLabelerOptions.Builder()
-			.setConfidenceThreshold(0.8f)
-			.build();
-		this.labeler = ImageLabeling.getClient(options);
-
-		// Create executor for database I/O
-		final int numCores = Runtime.getRuntime().availableProcessors();
-		final int minThreads = Math.min(numCores, 2);
-		final int maxThreads = Math.min(numCores, 4);
-		this.executor = new ThreadPoolExecutor(
-			minThreads,
-			maxThreads,
-			5L, TimeUnit.SECONDS,
-			new LinkedBlockingQueue<>()
-		);
-
-		logger.info("Created");
-	}
-
-	/**
-	 * Return whether this media can be labelled.
-	 */
-	public static boolean mediaCanBeLabeled(@NonNull MediaAttachItem mediaItem) {
-		switch (mediaItem.getType()) {
-			case MediaItem.TYPE_IMAGE:
-			case MediaItem.TYPE_GIF:
-				return true;
-			default:
-				return false;
-		}
-	}
-
-	/**
-	 * Main work will be done here.
-	 *
-	 * Note: The worker may be cancelled at any time, so care should be taken that
-	 * no inconsistent state can result from this.
-	 */
-	@Override
-	@NonNull
-	@WorkerThread
-	public Result doWork() {
-		if (!(ContextCompat.checkSelfPermission(this.appContext, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED)) {
-			// we do not currently have permission to read storage - get out of here
-			logger.warn("Unable to label images. Permission denied.");
-			this.cancelled = false;
-			this.onFinish();
-
-			// signal successful execution
-			return Result.success();
-		}
-
-		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager == null) {
-			logger.error("Could not get service manager");
-			this.cancelled = false;
-			this.onFinish();
-			// This might be due to the masterkey, maybe it'll be unlocked later
-			return Result.retry();
-		}
-
-		final NotificationService notificationService = serviceManager.getNotificationService();
-		if (notificationService == null) {
-			logger.error("Could not get notification service");
-			this.cancelled = false;
-			this.onFinish();
-			return Result.failure();
-		}
-
-		// Synchronize on a global static lock, because a cancelled task may still
-		// be running when a replacing task starts.
-		logger.info("Waiting for lock");
-		synchronized (globalLock) {
-			logger.info("Starting");
-			long startTime = SystemClock.elapsedRealtime();
-
-			// Make this a foreground service with a progress notification
-			final Notification notification = notificationService.createImageLabelingProgressNotification().build();
-			if (notification == null) {
-				logger.error("Could not create notification");
-				return Result.failure();
-			}
-			this.setForegroundAsync(new ForegroundInfo(
-				ThreemaApplication.IMAGE_LABELING_NOTIFICATION_ID,
-				notification
-			));
-
-			// Fetch media from device
-			final List<MediaAttachItem> allMediaCache = repository.getMediaFromMediaStore();
-			this.mediaCount = allMediaCache.size();
-			logger.info("Found {} media items", this.mediaCount);
-			notificationService.updateImageLabelingProgressNotification(0, this.mediaCount);
-
-			// Label images without labels
-			int imageCounter = 0;
-			int unlabeledCounter = 0;
-			int timeoutCounter = 0;
-			int skippedCounter = 0;
-
-			for (MediaAttachItem mediaItem : allMediaCache) {
-
-				// abort work to avoid battery drain if too many timeouts were triggered, something else must be wrong atm.
-				if (timeoutCounter > 20) {
-					logger.info("stopping labeling work due to too many timeouts");
-					this.onStopped();
-				}
-				// Check whether we were stopped
-				if (this.isStopped()) {
-					logger.info("Work was cancelled");
-					break;
-				}
-
-				// Update notification
-				notificationService.updateImageLabelingProgressNotification(this.progress, this.mediaCount);
-
-				// Update progress
-				this.progress++;
-
-				// We're only interested in image media
-				if (mediaCanBeLabeled(mediaItem)) {
-					if (failedMediaDAO.get(mediaItem.getId()) != null) {
-						logger.info("Item {} in set label queue failed to load previously. Skipping", progress);
-						skippedCounter++;
-						continue;
-					}
-					imageCounter++;
-				} else {
-					continue;
-				}
-
-				// Query the media items database, maybe we already have labels for this item?
-				final List<String> savedLabels = mediaItemsDAO.getMediaItemLabels(mediaItem.getId());
-				if (savedLabels.isEmpty()) {
-					unlabeledCounter++;
-
-					// Load image from filesystem
-					Future<InputImage> imageFuture;
-					InputImage image;
-					try {
-						final Uri uri = mediaItem.getUri();
-						imageFuture = executor.submit(() -> InputImage.fromFilePath(appContext, uri));
-
-						try {
-							image = imageFuture.get(20, TimeUnit.SECONDS);    // give it a timeout of 20s, otherwise skip bad item
-						} catch (TimeoutException e) {
-							imageFuture.cancel(true);
-							logger.info("Item {} in set label queue cannot be loaded from filepath in reasonable time, timeout triggered", progress);
-							timeoutCounter++;
-							skippedCounter++;
-							continue;
-						}
-
-						if (image != null && (image.getHeight() < 32 || image.getWidth() < 32)) {
-							logger.info("Item {} in set label queue loaded as InputImage due to tiny size. width or height < 32", progress);
-							failedMediaDAO.insert(new FailedMediaItemEntity(mediaItem.getId(), System.currentTimeMillis()));
-							skippedCounter++;
-							continue;
-						}
-					} catch (Exception e) {
-						logger.warn("Exception, could not generate input image from file path: {}", e.getMessage());
-
-						failedMediaDAO.insert(new FailedMediaItemEntity(mediaItem.getId(), System.currentTimeMillis()));
-						if (e.getCause() != null) {
-							logger.warn("  Caused by: {}", e.getCause().getMessage());
-						}
-						skippedCounter++;
-						continue;
-					}
-
-					// Launch a labeling task
-					//
-					// Note: By default, the success/failure listeners run on the UI thread.
-					// to prevent this, we pass in an executor.
-					final Task<?> task = labeler.process(image)
-						.addOnSuccessListener(executor, labels -> {
-							ArrayList<String> labelsListIndexes = new ArrayList<>();
-							for (ImageLabel label : labels) {
-								labelsListIndexes.add(String.valueOf(label.getIndex()));
-							}
-							mediaItemsDAO.insert(new LabeledMediaItemEntity(mediaItem.getId(), labelsListIndexes));
-						});
-
-					// We're waiting for the task to complete, because processing multiple images in parallel
-					// would fill up the memory with their data.
-					try {
-						Tasks.await(task);
-					} catch (ExecutionException e) {
-						logger.error("Could not get image labels for image", e);
-						return Result.failure();
-					} catch (InterruptedException e) {
-						// An interrupt occurred while waiting for the task to complete.
-						// Restore interrupted state...
-						Thread.currentThread().interrupt();
-						// ...and abort the worker.
-						return Result.failure();
-					}
-
-					if (unlabeledCounter % 50 == 0) {
-						logger.info("Processed {} files…", unlabeledCounter);
-					}
-				}
-			}
-
-			// make sure to finish progress notification
-			notificationService.updateImageLabelingProgressNotification(mediaCount, mediaCount);
-
-			final long secondsElapsedLabeling = (SystemClock.elapsedRealtime() - startTime) / 1000;
-			if (this.isStopped()) {
-				logger.info("Aborting now after {}s, because work was cancelled", secondsElapsedLabeling);
-				notificationService.cancelImageLabelingProgressNotification();
-				return Result.failure();
-			} else {
-				logger.info("Processed {} unlabeled images among {} total and {} skipped images of which {} timed out", unlabeledCounter, imageCounter, skippedCounter, timeoutCounter);
-				logger.info("Labeling work done after {}s, starting cleanup", secondsElapsedLabeling);
-			}
-
-			// Delete labels from database that belong to meanwhile deleted media items
-			List<LabeledMediaItemEntity> currentlyStoredLabelItems = mediaItemsDAO.getAllItemsByAscIdOrder();
-			List<FailedMediaItemEntity> currentlyStoredBrokenItems = failedMediaDAO.getAllItemsByAscIdOrder();
-			cleanDBEntries(currentlyStoredLabelItems, allMediaCache);
-			cleanDBEntries(currentlyStoredBrokenItems, allMediaCache);
-
-			final long secondsElapsedTotal = (SystemClock.elapsedRealtime() - startTime) / 1000;
-			logger.info("Processing done, total duration was {}s", secondsElapsedTotal);
-
-			this.cancelled = false;
-			this.onFinish();
-			return Result.success();
-		}
-	}
-
-	private void cleanDBEntries(List<? extends MediaItemEntity> currentlyStoredLabels, List<MediaAttachItem> allCurrentMedia) {
-		if (!currentlyStoredLabels.isEmpty() && allCurrentMedia != null) {
-			logger.info("clean db entries {}", currentlyStoredLabels.get(0).getClass());
-			Collections.sort(allCurrentMedia, (o1, o2) -> Double.compare(o1.getId(), o2.getId()));
-			int indexStoredLabelsList = 0;
-			int indexStoredMediaItemsList = 0;
-			while (indexStoredLabelsList < currentlyStoredLabels.size() && indexStoredMediaItemsList < allCurrentMedia.size()) {
-				int storedItemIDCurrent = currentlyStoredLabels.get(indexStoredLabelsList).getId();
-				int retrievedItemIDCurrent = allCurrentMedia.get(indexStoredMediaItemsList).getId();
-				if (storedItemIDCurrent == retrievedItemIDCurrent) {
-					// Found match!
-					indexStoredLabelsList++;
-					indexStoredMediaItemsList++;
-				} else if (storedItemIDCurrent < retrievedItemIDCurrent) {
-					// No match, discard entry in labels database
-					logger.info("Deleting media id {} entry in db ", currentlyStoredLabels.get(indexStoredLabelsList).getId());
-					mediaItemsDAO.deleteMediaItemById(currentlyStoredLabels.get(indexStoredLabelsList).getId());
-					indexStoredLabelsList++;
-				} else {
-					// No match, discard first entry in long list
-					indexStoredMediaItemsList++;
-				}
-			}
-		}
-	}
-
-	private void onFinish() {
-		// Shut down executor thread pool
-		/*if (!this.executor.isShutdown()) { TODO Causes DuplicateTaskException on switch off image search settings
-			this.logger.info("Shut down thread pool");
-			this.executor.shutdown();
-		}*/
-
-		if (this.cancelled) {
-			logger.info("Cancelled after processing {}/{} media files", this.progress, this.mediaCount);
-		} else {
-			logger.info("Stopped after processing {}/{} media files", this.progress, this.mediaCount);
-		}
-	}
-
-	@Override
-	public void onStopped() {
-		super.onStopped();
-		this.onFinish();
-	}
-}

+ 0 - 487
app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelsIndexHashMap.java

@@ -1,487 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * 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.labeling;
-
-import android.content.Context;
-
-import java.util.HashMap;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-
-/**
- * This data structure contains a mapping from image labels to translated textual labels.
- */
-public class ImageLabelsIndexHashMap {
-
-	private HashMap<String, String> mapping;
-	private Context context = ThreemaApplication.getAppContext();
-
-	public ImageLabelsIndexHashMap(){
-		mapping =  new HashMap<String, String>() {{
-			put("0", context.getString(R.string.label_0));
-			put("1", context.getString(R.string.label_1));
-			put("2", context.getString(R.string.label_2));
-			put("3", context.getString(R.string.label_3));
-			put("4", context.getString(R.string.label_4));
-			put("5", context.getString(R.string.label_5));
-			put("7", context.getString(R.string.label_7));
-			put("8", context.getString(R.string.label_8));
-			put("9", context.getString(R.string.label_9));
-			put("10", context.getString(R.string.label_10));
-			put("11", context.getString(R.string.label_11));
-			put("12", context.getString(R.string.label_12));
-			put("13", context.getString(R.string.label_13));
-			put("14", context.getString(R.string.label_14));
-			put("16", context.getString(R.string.label_16));
-			put("17", context.getString(R.string.label_17));
-			put("18", context.getString(R.string.label_18));
-			put("19", context.getString(R.string.label_19));
-			put("20", context.getString(R.string.label_20));
-			put("21", context.getString(R.string.label_21));
-			put("22", context.getString(R.string.label_22));
-			put("24", context.getString(R.string.label_24));
-			put("25", context.getString(R.string.label_25));
-			put("26", context.getString(R.string.label_26));
-			put("27", context.getString(R.string.label_27));
-			put("28", context.getString(R.string.label_28));
-			put("29", context.getString(R.string.label_29));
-			put("30", context.getString(R.string.label_30));
-			put("31", context.getString(R.string.label_31));
-			put("32", context.getString(R.string.label_32));
-			put("33", context.getString(R.string.label_33));
-			put("34", context.getString(R.string.label_34));
-			put("35", context.getString(R.string.label_35));
-			put("36", context.getString(R.string.label_36));
-			put("37", context.getString(R.string.label_37));
-			put("38", context.getString(R.string.label_38));
-			put("39", context.getString(R.string.label_39));
-			put("40", context.getString(R.string.label_40));
-			put("41", context.getString(R.string.label_41));
-			put("42", context.getString(R.string.label_42));
-			put("43", context.getString(R.string.label_43));
-			put("45", context.getString(R.string.label_45));
-			put("46", context.getString(R.string.label_46));
-			put("47", context.getString(R.string.label_47));
-			put("48", context.getString(R.string.label_48));
-			put("49", context.getString(R.string.label_49));
-			put("50", context.getString(R.string.label_50));
-			put("51", context.getString(R.string.label_51));
-			put("52", context.getString(R.string.label_52));
-			put("53", context.getString(R.string.label_53));
-			put("54", context.getString(R.string.label_54));
-			put("55", context.getString(R.string.label_55));
-			put("56", context.getString(R.string.label_56));
-			put("57", context.getString(R.string.label_57));
-			put("58", context.getString(R.string.label_58));
-			put("59", context.getString(R.string.label_59));
-			put("61", context.getString(R.string.label_61));
-			put("62", context.getString(R.string.label_62));
-			put("63", context.getString(R.string.label_63));
-			put("64", context.getString(R.string.label_64));
-			put("65", context.getString(R.string.label_65));
-			put("66", context.getString(R.string.label_66));
-			put("67", context.getString(R.string.label_67));
-			put("68", context.getString(R.string.label_68));
-			put("69", context.getString(R.string.label_69));
-			put("70", context.getString(R.string.label_70));
-			put("71", context.getString(R.string.label_71));
-			put("72", context.getString(R.string.label_72));
-			put("73", context.getString(R.string.label_73));
-			put("74", context.getString(R.string.label_74));
-			put("75", context.getString(R.string.label_75));
-			put("76", context.getString(R.string.label_76));
-			put("77", context.getString(R.string.label_77));
-			put("78", context.getString(R.string.label_78));
-			put("79", context.getString(R.string.label_79));
-			put("80", context.getString(R.string.label_80));
-			put("81", context.getString(R.string.label_81));
-			put("82", context.getString(R.string.label_82));
-			put("83", context.getString(R.string.label_83));
-			put("84", context.getString(R.string.label_84));
-			put("85", context.getString(R.string.label_85));
-			put("86", context.getString(R.string.label_86));
-			put("87", context.getString(R.string.label_87));
-			put("88", context.getString(R.string.label_88));
-			put("89", context.getString(R.string.label_89));
-			put("90", context.getString(R.string.label_90));
-			put("91", context.getString(R.string.label_91));
-			put("92", context.getString(R.string.label_92));
-			put("93", context.getString(R.string.label_93));
-			put("94", context.getString(R.string.label_94));
-			put("95", context.getString(R.string.label_95));
-			put("96", context.getString(R.string.label_96));
-			put("97", context.getString(R.string.label_97));
-			put("98", context.getString(R.string.label_98));
-			put("99", context.getString(R.string.label_99));
-			put("100", context.getString(R.string.label_100));
-			put("101", context.getString(R.string.label_101));
-			put("102", context.getString(R.string.label_102));
-			put("103", context.getString(R.string.label_103));
-			put("104", context.getString(R.string.label_104));
-			put("105", context.getString(R.string.label_105));
-			put("106", context.getString(R.string.label_106));
-			put("107", context.getString(R.string.label_107));
-			put("108", context.getString(R.string.label_108));
-			put("109", context.getString(R.string.label_109));
-			put("110", context.getString(R.string.label_110));
-			put("111", context.getString(R.string.label_111));
-			put("112", context.getString(R.string.label_112));
-			put("113", context.getString(R.string.label_113));
-			put("114", context.getString(R.string.label_114));
-			put("115", context.getString(R.string.label_115));
-			put("116", context.getString(R.string.label_116));
-			put("117", context.getString(R.string.label_117));
-			put("118", context.getString(R.string.label_118));
-			put("119", context.getString(R.string.label_119));
-			put("120", context.getString(R.string.label_120));
-			put("121", context.getString(R.string.label_121));
-			put("122", context.getString(R.string.label_122));
-			put("123", context.getString(R.string.label_123));
-			put("125", context.getString(R.string.label_125));
-			put("126", context.getString(R.string.label_126));
-			put("127", context.getString(R.string.label_127));
-			put("128", context.getString(R.string.label_128));
-			put("129", context.getString(R.string.label_129));
-			put("130", context.getString(R.string.label_130));
-			put("131", context.getString(R.string.label_131));
-			put("132", context.getString(R.string.label_132));
-			put("133", context.getString(R.string.label_133));
-			put("134", context.getString(R.string.label_134));
-			put("135", context.getString(R.string.label_135));
-			put("137", context.getString(R.string.label_137));
-			put("138", context.getString(R.string.label_138));
-			put("139", context.getString(R.string.label_139));
-			put("140", context.getString(R.string.label_140));
-			put("141", context.getString(R.string.label_141));
-			put("142", context.getString(R.string.label_142));
-			put("143", context.getString(R.string.label_143));
-			put("144", context.getString(R.string.label_144));
-			put("145", context.getString(R.string.label_145));
-			put("146", context.getString(R.string.label_146));
-			put("147", context.getString(R.string.label_147));
-			put("148", context.getString(R.string.label_148));
-			put("149", context.getString(R.string.label_149));
-			put("150", context.getString(R.string.label_150));
-			put("151", context.getString(R.string.label_151));
-			put("152", context.getString(R.string.label_152));
-			put("153", context.getString(R.string.label_153));
-			put("154", context.getString(R.string.label_154));
-			put("155", context.getString(R.string.label_155));
-			put("156", context.getString(R.string.label_156));
-			put("157", context.getString(R.string.label_157));
-			put("158", context.getString(R.string.label_158));
-			put("159", context.getString(R.string.label_159));
-			put("160", context.getString(R.string.label_160));
-			put("161", context.getString(R.string.label_161));
-			put("162", context.getString(R.string.label_162));
-			put("163", context.getString(R.string.label_163));
-			put("164", context.getString(R.string.label_164));
-			put("165", context.getString(R.string.label_165));
-			put("166", context.getString(R.string.label_166));
-			put("167", context.getString(R.string.label_167));
-			put("168", context.getString(R.string.label_168));
-			put("169", context.getString(R.string.label_169));
-			put("170", context.getString(R.string.label_170));
-			put("171", context.getString(R.string.label_171));
-			put("172", context.getString(R.string.label_172));
-			put("173", context.getString(R.string.label_173));
-			put("174", context.getString(R.string.label_174));
-			put("175", context.getString(R.string.label_175));
-			put("176", context.getString(R.string.label_176));
-			put("177", context.getString(R.string.label_177));
-			put("178", context.getString(R.string.label_178));
-			put("179", context.getString(R.string.label_179));
-			put("180", context.getString(R.string.label_180));
-			put("181", context.getString(R.string.label_181));
-			put("182", context.getString(R.string.label_182));
-			put("183", context.getString(R.string.label_183));
-			put("184", context.getString(R.string.label_184));
-			put("185", context.getString(R.string.label_185));
-			put("186", context.getString(R.string.label_186));
-			put("187", context.getString(R.string.label_187));
-			put("188", context.getString(R.string.label_188));
-			put("189", context.getString(R.string.label_189));
-			put("190", context.getString(R.string.label_190));
-			put("191", context.getString(R.string.label_191));
-			put("192", context.getString(R.string.label_192));
-			put("193", context.getString(R.string.label_193));
-			put("194", context.getString(R.string.label_194));
-			put("195", context.getString(R.string.label_195));
-			put("196", context.getString(R.string.label_196));
-			put("197", context.getString(R.string.label_197));
-			put("199", context.getString(R.string.label_199));
-			put("201", context.getString(R.string.label_201));
-			put("202", context.getString(R.string.label_202));
-			put("203", context.getString(R.string.label_203));
-			put("204", context.getString(R.string.label_204));
-			put("205", context.getString(R.string.label_205));
-			put("206", context.getString(R.string.label_206));
-			put("207", context.getString(R.string.label_207));
-			put("208", context.getString(R.string.label_208));
-			put("209", context.getString(R.string.label_209));
-			put("210", context.getString(R.string.label_210));
-			put("211", context.getString(R.string.label_211));
-			put("212", context.getString(R.string.label_212));
-			put("213", context.getString(R.string.label_213));
-			put("214", context.getString(R.string.label_214));
-			put("215", context.getString(R.string.label_215));
-			put("216", context.getString(R.string.label_216));
-			put("217", context.getString(R.string.label_217));
-			put("218", context.getString(R.string.label_218));
-			put("219", context.getString(R.string.label_219));
-			put("220", context.getString(R.string.label_220));
-			put("221", context.getString(R.string.label_221));
-			put("222", context.getString(R.string.label_222));
-			put("223", context.getString(R.string.label_223));
-			put("224", context.getString(R.string.label_224));
-			put("225", context.getString(R.string.label_225));
-			put("226", context.getString(R.string.label_226));
-			put("227", context.getString(R.string.label_227));
-			put("228", context.getString(R.string.label_228));
-			put("229", context.getString(R.string.label_229));
-			put("230", context.getString(R.string.label_230));
-			put("231", context.getString(R.string.label_231));
-			put("232", context.getString(R.string.label_232));
-			put("233", context.getString(R.string.label_233));
-			put("234", context.getString(R.string.label_234));
-			put("235", context.getString(R.string.label_235));
-			put("236", context.getString(R.string.label_236));
-			put("237", context.getString(R.string.label_237));
-			put("238", context.getString(R.string.label_238));
-			put("239", context.getString(R.string.label_239));
-			put("240", context.getString(R.string.label_240));
-			put("241", context.getString(R.string.label_241));
-			put("242", context.getString(R.string.label_242));
-			put("243", context.getString(R.string.label_243));
-			put("244", context.getString(R.string.label_244));
-			put("245", context.getString(R.string.label_245));
-			put("246", context.getString(R.string.label_246));
-			put("247", context.getString(R.string.label_247));
-			put("248", context.getString(R.string.label_248));
-			put("249", context.getString(R.string.label_249));
-			put("250", context.getString(R.string.label_250));
-			put("251", context.getString(R.string.label_251));
-			put("252", context.getString(R.string.label_252));
-			put("253", context.getString(R.string.label_253));
-			put("254", context.getString(R.string.label_254));
-			put("255", context.getString(R.string.label_255));
-			put("256", context.getString(R.string.label_256));
-			put("257", context.getString(R.string.label_257));
-			put("258", context.getString(R.string.label_258));
-			put("259", context.getString(R.string.label_259));
-			put("260", context.getString(R.string.label_260));
-			put("261", context.getString(R.string.label_261));
-			put("262", context.getString(R.string.label_262));
-			put("263", context.getString(R.string.label_263));
-			put("265", context.getString(R.string.label_265));
-			put("266", context.getString(R.string.label_266));
-			put("267", context.getString(R.string.label_267));
-			put("268", context.getString(R.string.label_268));
-			put("269", context.getString(R.string.label_269));
-			put("270", context.getString(R.string.label_270));
-			put("271", context.getString(R.string.label_271));
-			put("272", context.getString(R.string.label_272));
-			put("273", context.getString(R.string.label_273));
-			put("274", context.getString(R.string.label_274));
-			put("275", context.getString(R.string.label_275));
-			put("276", context.getString(R.string.label_276));
-			put("277", context.getString(R.string.label_277));
-			put("278", context.getString(R.string.label_278));
-			put("279", context.getString(R.string.label_279));
-			put("280", context.getString(R.string.label_280));
-			put("281", context.getString(R.string.label_281));
-			put("282", context.getString(R.string.label_282));
-			put("283", context.getString(R.string.label_283));
-			put("284", context.getString(R.string.label_284));
-			put("285", context.getString(R.string.label_285));
-			put("286", context.getString(R.string.label_286));
-			put("287", context.getString(R.string.label_287));
-			put("288", context.getString(R.string.label_288));
-			put("289", context.getString(R.string.label_289));
-			put("290", context.getString(R.string.label_290));
-			put("291", context.getString(R.string.label_291));
-			put("292", context.getString(R.string.label_292));
-			put("293", context.getString(R.string.label_293));
-			put("294", context.getString(R.string.label_294));
-			put("295", context.getString(R.string.label_295));
-			put("296", context.getString(R.string.label_296));
-			put("297", context.getString(R.string.label_297));
-			put("298", context.getString(R.string.label_298));
-			put("299", context.getString(R.string.label_299));
-			put("300", context.getString(R.string.label_300));
-			put("301", context.getString(R.string.label_301));
-			put("302", context.getString(R.string.label_302));
-			put("303", context.getString(R.string.label_303));
-			put("304", context.getString(R.string.label_304));
-			put("305", context.getString(R.string.label_305));
-			put("306", context.getString(R.string.label_306));
-			put("307", context.getString(R.string.label_307));
-			put("308", context.getString(R.string.label_308));
-			put("309", context.getString(R.string.label_309));
-			put("310", context.getString(R.string.label_310));
-			put("311", context.getString(R.string.label_311));
-			put("312", context.getString(R.string.label_312));
-			put("313", context.getString(R.string.label_313));
-			put("314", context.getString(R.string.label_314));
-			put("315", context.getString(R.string.label_315));
-			put("316", context.getString(R.string.label_316));
-			put("317", context.getString(R.string.label_317));
-			put("318", context.getString(R.string.label_318));
-			put("319", context.getString(R.string.label_319));
-			put("320", context.getString(R.string.label_320));
-			put("321", context.getString(R.string.label_321));
-			put("322", context.getString(R.string.label_322));
-			put("323", context.getString(R.string.label_323));
-			put("324", context.getString(R.string.label_324));
-			put("327", context.getString(R.string.label_327));
-			put("328", context.getString(R.string.label_328));
-			put("329", context.getString(R.string.label_329));
-			put("330", context.getString(R.string.label_330));
-			put("331", context.getString(R.string.label_331));
-			put("332", context.getString(R.string.label_332));
-			put("333", context.getString(R.string.label_333));
-			put("335", context.getString(R.string.label_335));
-			put("336", context.getString(R.string.label_336));
-			put("337", context.getString(R.string.label_337));
-			put("338", context.getString(R.string.label_338));
-			put("339", context.getString(R.string.label_339));
-			put("340", context.getString(R.string.label_340));
-			put("341", context.getString(R.string.label_341));
-			put("342", context.getString(R.string.label_342));
-			put("343", context.getString(R.string.label_343));
-			put("344", context.getString(R.string.label_344));
-			put("345", context.getString(R.string.label_345));
-			put("346", context.getString(R.string.label_346));
-			put("347", context.getString(R.string.label_347));
-			put("348", context.getString(R.string.label_348));
-			put("349", context.getString(R.string.label_349));
-			put("350", context.getString(R.string.label_350));
-			put("351", context.getString(R.string.label_351));
-			put("352", context.getString(R.string.label_352));
-			put("353", context.getString(R.string.label_353));
-			put("354", context.getString(R.string.label_354));
-			put("355", context.getString(R.string.label_355));
-			put("356", context.getString(R.string.label_356));
-			put("357", context.getString(R.string.label_357));
-			put("358", context.getString(R.string.label_358));
-			put("359", context.getString(R.string.label_359));
-			put("360", context.getString(R.string.label_360));
-			put("361", context.getString(R.string.label_361));
-			put("362", context.getString(R.string.label_362));
-			put("363", context.getString(R.string.label_363));
-			put("364", context.getString(R.string.label_364));
-			put("365", context.getString(R.string.label_365));
-			put("366", context.getString(R.string.label_366));
-			put("367", context.getString(R.string.label_367));
-			put("368", context.getString(R.string.label_368));
-			put("369", context.getString(R.string.label_369));
-			put("370", context.getString(R.string.label_370));
-			put("371", context.getString(R.string.label_371));
-			put("372", context.getString(R.string.label_372));
-			put("373", context.getString(R.string.label_373));
-			put("374", context.getString(R.string.label_374));
-			put("375", context.getString(R.string.label_375));
-			put("376", context.getString(R.string.label_376));
-			put("377", context.getString(R.string.label_377));
-			put("378", context.getString(R.string.label_378));
-			put("379", context.getString(R.string.label_379));
-			put("380", context.getString(R.string.label_380));
-			put("382", context.getString(R.string.label_382));
-			put("383", context.getString(R.string.label_383));
-			put("384", context.getString(R.string.label_384));
-			put("385", context.getString(R.string.label_385));
-			put("386", context.getString(R.string.label_386));
-			put("387", context.getString(R.string.label_387));
-			put("388", context.getString(R.string.label_388));
-			put("389", context.getString(R.string.label_389));
-			put("390", context.getString(R.string.label_390));
-			put("391", context.getString(R.string.label_391));
-			put("392", context.getString(R.string.label_392));
-			put("393", context.getString(R.string.label_393));
-			put("394", context.getString(R.string.label_394));
-			put("395", context.getString(R.string.label_395));
-			put("397", context.getString(R.string.label_397));
-			put("398", context.getString(R.string.label_398));
-			put("399", context.getString(R.string.label_399));
-			put("400", context.getString(R.string.label_400));
-			put("401", context.getString(R.string.label_401));
-			put("402", context.getString(R.string.label_402));
-			put("403", context.getString(R.string.label_403));
-			put("404", context.getString(R.string.label_404));
-			put("405", context.getString(R.string.label_405));
-			put("406", context.getString(R.string.label_406));
-			put("407", context.getString(R.string.label_407));
-			put("408", context.getString(R.string.label_408));
-			put("409", context.getString(R.string.label_409));
-			put("410", context.getString(R.string.label_410));
-			put("411", context.getString(R.string.label_411));
-			put("412", context.getString(R.string.label_412));
-			put("413", context.getString(R.string.label_413));
-			put("414", context.getString(R.string.label_414));
-			put("415", context.getString(R.string.label_415));
-			put("416", context.getString(R.string.label_416));
-			put("417", context.getString(R.string.label_417));
-			put("419", context.getString(R.string.label_419));
-			put("420", context.getString(R.string.label_420));
-			put("421", context.getString(R.string.label_421));
-			put("422", context.getString(R.string.label_422));
-			put("423", context.getString(R.string.label_423));
-			put("424", context.getString(R.string.label_424));
-			put("425", context.getString(R.string.label_425));
-			put("426", context.getString(R.string.label_426));
-			put("427", context.getString(R.string.label_427));
-			put("428", context.getString(R.string.label_428));
-			put("429", context.getString(R.string.label_429));
-			put("430", context.getString(R.string.label_430));
-			put("431", context.getString(R.string.label_431));
-			put("432", context.getString(R.string.label_432));
-			put("433", context.getString(R.string.label_433));
-			put("434", context.getString(R.string.label_434));
-			put("435", context.getString(R.string.label_435));
-			put("436", context.getString(R.string.label_436));
-			put("437", context.getString(R.string.label_437));
-			put("438", context.getString(R.string.label_438));
-			put("439", context.getString(R.string.label_439));
-			put("440", context.getString(R.string.label_440));
-			put("441", context.getString(R.string.label_441));
-			put("442", context.getString(R.string.label_442));
-			put("443", context.getString(R.string.label_443));
-			put("445", context.getString(R.string.label_445));
-			put("446", context.getString(R.string.label_446));
-		}};
-	}
-
-
-	public HashMap<String, String> getMapping() {
-		return mapping;
-	}
-
-	/**
-	 * Map a label ID to a translated name.
-	 */
-	public @Nullable String mapIdToName(@NonNull String key) {
-		return this.mapping.get(key);
-	}
-}

+ 1 - 5
app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java

@@ -31,7 +31,6 @@ import android.widget.Toast;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
 import java.util.Date;
 
 import androidx.annotation.Nullable;
@@ -42,15 +41,14 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.InvalidEntryException;
+import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.mediaattacher.data.MediaItemsRoomDatabase;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.client.BoxTextMessage;
 import ch.threema.client.MessageId;
 import ch.threema.client.voip.VoipCallAnswerData;
@@ -58,8 +56,6 @@ import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 
-import static ch.threema.app.ThreemaApplication.getAppContext;
-
 public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
 	private static final Logger logger = LoggerFactory.getLogger(SettingsDeveloperFragment.class);
 

+ 1 - 71
app/src/main/java/ch/threema/app/preference/SettingsMediaFragment.java

@@ -22,47 +22,34 @@
 package ch.threema.app.preference;
 
 import android.Manifest;
-import android.annotation.SuppressLint;
 import android.content.Intent;
 import android.content.pm.PackageManager;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.text.TextUtils;
 import android.text.format.Formatter;
 import android.view.View;
-import android.widget.Toast;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
 import androidx.preference.CheckBoxPreference;
 import androidx.preference.MultiSelectListPreference;
 import androidx.preference.Preference;
-import androidx.work.WorkManager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.StorageManagementActivity;
-import ch.threema.app.mediaattacher.data.MediaItemsRoomDatabase;
-import ch.threema.app.mediaattacher.labeling.ImageLabelingWorker;
 import ch.threema.app.services.MessageServiceImpl;
-import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 
-import static ch.threema.app.ThreemaApplication.WORKER_IMAGE_LABELS_PERIODIC;
-import static ch.threema.app.ThreemaApplication.getAppContext;
-import static ch.threema.app.ThreemaApplication.getServiceManager;
-
 public class SettingsMediaFragment extends ThreemaPreferenceFragment {
 	private static final Logger logger = LoggerFactory.getLogger(SettingsMediaFragment.class);
 
@@ -140,22 +127,10 @@ public class SettingsMediaFragment extends ThreemaPreferenceFragment {
 			}
 		});
 		mobileDownloadPreference.setSummary(getAutoDownloadSummary(preferenceService.getMobileAutoDownload()));
-
-		CheckBoxPreference labelingPreference = (CheckBoxPreference) findPreference(getString(R.string.preferences__image_labeling));
-		labelingPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
-			@Override
-			public boolean onPreferenceChange(Preference preference, Object newValue) {
-				Boolean value = (Boolean) newValue;
-				if (value != null && !value) {
-					return deleteMediaLabelsDatabase();
-				}
-				return true;
-			}
-		});
 	}
 
 	@Override
-	public void onViewCreated(View view, Bundle savedInstanceState) {
+	public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
 		this.fragmentView = view;
 		preferenceFragmentCallbackInterface.setToolbarTitle(R.string.prefs_media_title);
 		super.onViewCreated(view, savedInstanceState);
@@ -185,49 +160,4 @@ public class SettingsMediaFragment extends ThreemaPreferenceFragment {
 
 		return result.isEmpty() ? getResources().getString(R.string.never) : TextUtils.join(", ", result);
 	}
-
-	@UiThread
-	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMediaLabelsDatabase() {
-		logger.debug("deleteMediaLabelsDatabase");
-		WorkManager.getInstance(ThreemaApplication.getAppContext()).cancelAllWorkByTag(WORKER_IMAGE_LABELS_PERIODIC);
-		WorkManager.getInstance(ThreemaApplication.getAppContext()).cancelAllWorkByTag(ImageLabelingWorker.UNIQUE_WORK_NAME);
-
-		new AsyncTask<Void, Void, Exception>() {
-			@Override
-			protected Exception doInBackground(Void... voids) {
-				try {
-					final String[] files = new String[] {
-						MediaItemsRoomDatabase.DATABASE_NAME,
-						MediaItemsRoomDatabase.DATABASE_NAME + "-shm",
-						MediaItemsRoomDatabase.DATABASE_NAME + "-wal",
-					};
-					for (String filename : files) {
-						final File databasePath = getAppContext().getDatabasePath(filename);
-						if (databasePath.exists() && databasePath.isFile()) {
-							logger.info("Removing file {}", filename);
-							if (!databasePath.delete()) {
-								logger.warn("Could not remove file {}", filename);
-							}
-						} else {
-							logger.debug("File {} not found", filename);
-						}
-					}
-				} catch (Exception e) {
-					logger.error("Exception while deleting media labels database");
-					return e;
-				}
-				return null;
-			}
-
-			@Override
-			protected void onPostExecute(Exception e) {
-				if (e != null) {
-					Toast.makeText(getContext(), getString(R.string.an_error_occurred_more, e.getMessage()), Toast.LENGTH_LONG).show();
-				}
-			}
-		}.execute();
-
-		return true;
-	}
 }

+ 6 - 4
app/src/main/java/ch/threema/app/processors/MessageProcessor.java

@@ -163,10 +163,12 @@ public class MessageProcessor implements MessageProcessorInterface {
 			}
 
 			//check if sender is on blacklist
-			if(this.blackListService != null && this.blackListService.has(msg.getFromIdentity())) {
-				logger.debug("Message from {}: Contact blacklisted. Ignoring", msg.getFromIdentity());
-				//ignore message of blacklisted member
-				return ProcessIncomingResult.ignore();
+			if (!(msg instanceof AbstractGroupMessage)) {
+				if (this.blackListService != null && this.blackListService.has(msg.getFromIdentity())) {
+					logger.debug("Direct message from {}: Contact blacklisted. Ignoring", msg.getFromIdentity());
+					//ignore message of blacklisted member
+					return ProcessIncomingResult.ignore();
+				}
 			}
 
 			this.contactService.setActive(msg.getFromIdentity());

+ 0 - 147
app/src/main/java/ch/threema/app/routines/LinkWithMobileNumberRoutine.java

@@ -1,147 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2013-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.routines;
-
-import android.content.Context;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-import ch.threema.app.R;
-import ch.threema.app.services.UserService;
-import ch.threema.app.utils.LogUtil;
-
-// TODO: Remove unused class?
-public class LinkWithMobileNumberRoutine implements Runnable {
-	private static final Logger logger = LoggerFactory.getLogger(LinkWithMobileNumberRoutine.class);
-	final ScheduledExecutorService scheduleTaskExecutor = Executors.newScheduledThreadPool(5);
-
-	public interface ExceptionHandler {
-		void handle(Exception x);
-	}
-
-	public interface OnFinished {
-		void finished(boolean success);
-	}
-
-	public interface OnStateUpdate {
-		void update(float percent, String text);
-	}
-
-	private static int CHECK_SMS_EFFORT = 20;
-
-	private final Context context;
-	private final UserService userService;
-	private final String mobileNumber;
-
-	private ExceptionHandler exceptionHandler;
-	private OnFinished onFinished;
-	private OnStateUpdate onStateUpdate;
-	private int steps = CHECK_SMS_EFFORT + 1;
-	private int currentStep = 0;
-
-	volatile int a = 0;
-
-	public LinkWithMobileNumberRoutine(Context context, UserService userService, String mobileNumber) {
-		this.context = context;
-		this.userService = userService;
-		this.mobileNumber = mobileNumber;
-	}
-
-	public void setExceptionHandler(ExceptionHandler h) {
-		this.exceptionHandler = h;
-	}
-
-	public void setOnFinished(OnFinished f) {
-		this.onFinished = f;
-	}
-
-	public void setOnStateUpdate(OnStateUpdate onStateUpdate) {
-		this.onStateUpdate = onStateUpdate;
-	}
-
-	private void handleException(Exception x) {
-		if (this.exceptionHandler != null) {
-			this.exceptionHandler.handle(x);
-		} else {
-			logger.error("Exception", x);
-		}
-	}
-
-	private void stepUp(String text) {
-		if (this.onStateUpdate != null) {
-			this.onStateUpdate.update(100 / this.steps * (this.currentStep + 1), text);
-		}
-
-		this.currentStep++;
-	}
-
-	@Override
-	public void run() {
-		//linkBallot
-		this.currentStep = 0;
-
-		try {
-			this.stepUp(context.getString(R.string.menu_mobile_linking));
-			userService.linkWithMobileNumber(mobileNumber);
-
-			if (onFinished != null) {
-
-				//check every 5 seconds for a auto verification
-				scheduleTaskExecutor.scheduleAtFixedRate(new Runnable() {
-					public void run() {
-						stepUp(context.getString(R.string.check_incoming_sms) + " " + String.valueOf(a) + "/" + String.valueOf(CHECK_SMS_EFFORT));
-						if (a > CHECK_SMS_EFFORT || Thread.currentThread().isInterrupted()) {
-							onFinished.finished(false);
-							scheduleTaskExecutor.shutdown();
-						}
-
-						if (userService.getMobileLinkingState() == UserService.LinkingState_LINKED) {
-							onFinished.finished(true);
-							scheduleTaskExecutor.shutdown();
-						}
-						a++;
-					}
-				}, 0, 3, TimeUnit.SECONDS);
-			}
-		} catch (Exception e) {
-			this.handleException(e);
-			if (this.onFinished != null) {
-				this.onFinished.finished(false);
-			}
-		}
-	}
-
-	public void abort() {
-		if (!scheduleTaskExecutor.isShutdown()) {
-			scheduleTaskExecutor.shutdown();
-		}
-
-		if (this.onFinished != null) {
-			this.onFinished.finished(false);
-		}
-	}
-}

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

@@ -25,6 +25,7 @@ import android.graphics.Bitmap;
 
 import java.util.Collection;
 
+import androidx.annotation.NonNull;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
@@ -40,8 +41,8 @@ public interface AvatarCacheService {
 	Bitmap getContactAvatarLowFromCache(ContactModel contactModel);
 	void reset(ContactModel contactModel);
 
-	Bitmap getGroupAvatarHigh(GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly);
-	Bitmap getGroupAvatarLow(GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly);
+	Bitmap getGroupAvatarHigh(@NonNull GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly);
+	Bitmap getGroupAvatarLow(@NonNull GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly);
 	Bitmap getGroupAvatarLowFromCache(GroupModel groupModel);
 	Bitmap getGroupAvatarNeutral(boolean highResolution);
 

+ 17 - 23
app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java

@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
 import java.util.Collection;
 
 import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
 import ch.threema.app.R;
@@ -195,16 +196,12 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 	}
 
 	@Override
-	public Bitmap getGroupAvatarHigh(GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly) {
-		if(groupModel == null) {
-			return null;
-		}
-
+	public Bitmap getGroupAvatarHigh(@NonNull GroupModel groupModel, Collection<Integer> contactColors, boolean defaultOnly) {
 		return this.getAvatar(groupModel, contactColors, true, defaultOnly);
 	}
 
 	@Override
-	public Bitmap getGroupAvatarLow(final GroupModel groupModel, final Collection<Integer> contactColors, boolean defaultOnly) {
+	public Bitmap getGroupAvatarLow(@NonNull final GroupModel groupModel, final Collection<Integer> contactColors, boolean defaultOnly) {
 		if (defaultOnly) {
 			return getAvatar(groupModel, contactColors, false, true);
 		} else {
@@ -387,32 +384,29 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 				groupImage = this.fileService.getGroupAvatar(groupModel);
 			}
 
-			int color = ColorUtil.getInstance().getCurrentThemeGray(this.context);
-			if (this.getDefaultAvatarColored()
+			if (groupImage == null) {
+				int color = ColorUtil.getInstance().getCurrentThemeGray(this.context);
+				if (this.getDefaultAvatarColored()
 					&& contactColors != null
 					&& contactColors.size() > 0) {
-				//default color
-				color =  contactColors.iterator().next();
-			}
+					//default color
+					color = contactColors.iterator().next();
+				}
 
-			if (highResolution) {
-				if (groupImage == null) {
+				if (highResolution) {
 					groupImage = buildHiresDefaultAvatar(color, AVATAR_GROUP);
-				}
-			} else {
-				if (groupImage == null) {
+				} else {
 					synchronized (this.groupDefaultAvatar) {
 						groupImage = AvatarConverterUtil.getAvatarBitmap(groupDefaultAvatar, color, this.avatarSizeSmall);
 					}
 				}
-				else {
-					//resize image!
-					Bitmap converted = AvatarConverterUtil.convert(this.context.getResources(), groupImage);
-					if (groupImage != converted) {
-						BitmapUtil.recycle(groupImage);
-					}
-					return converted;
+			} else {
+				//resize image!
+				Bitmap converted = AvatarConverterUtil.convert(this.context.getResources(), groupImage);
+				if (groupImage != converted) {
+					BitmapUtil.recycle(groupImage);
 				}
+				return converted;
 			}
 
 			return groupImage;

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

@@ -68,9 +68,9 @@ public class ConversationServiceImpl implements ConversationService {
 	private final MessageService messageService;
 	private final DeadlineListService hiddenChatsListService;
 	private boolean initAllLoaded = false;
-	private TagModel starTag;
+	private final TagModel starTag, unreadTag;
 
-	class ConversationResult {
+	static class ConversationResult {
 		public final int messageId;
 		public final long count;
 		public final String refId;
@@ -102,6 +102,7 @@ public class ConversationServiceImpl implements ConversationService {
 		this.conversationTagService = conversationTagService;
 
 		this.starTag = conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
+		this.unreadTag = conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
 	}
 
 	@Override

+ 8 - 0
app/src/main/java/ch/threema/app/services/ConversationTagService.java

@@ -23,6 +23,7 @@ package ch.threema.app.services;
 
 import java.util.List;
 
+import androidx.annotation.NonNull;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.ConversationTagModel;
 import ch.threema.storage.models.TagModel;
@@ -77,5 +78,12 @@ public interface ConversationTagService {
 	 * Get all tags regardless of type
 	 */
 	List<ConversationTagModel> getAll();
+
+	/**
+	 * Return the number of conversations with the provided tag
+	 * @param tagModel tag
+	 * @return number of conversations or 0 if there is none
+	 */
+	long getCount(@NonNull TagModel tagModel);
 }
 

+ 21 - 5
app/src/main/java/ch/threema/app/services/ConversationTagServiceImpl.java

@@ -21,11 +21,13 @@
 
 package ch.threema.app.services;
 
-import androidx.annotation.NonNull;
+import android.content.Context;
 
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.annotation.NonNull;
+import ch.threema.app.R;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.listeners.ConversationListener;
@@ -38,12 +40,14 @@ import ch.threema.storage.models.TagModel;
 
 public class ConversationTagServiceImpl implements ConversationTagService {
 	// Do not change this tag before db entries not changed
-	public static String FIXED_TAG_PIN = "star";
+	public static final String FIXED_TAG_PIN = "star";
+	public static final String FIXED_TAG_UNREAD = "unread"; // chats deliberately marked as unread
+
 	private final DatabaseServiceNew databaseService;
 
 	private final List<TagModel> tagModels = new ArrayList<>();
 
-	public ConversationTagServiceImpl(DatabaseServiceNew databaseService) {
+	public ConversationTagServiceImpl(Context context, DatabaseServiceNew databaseService) {
 		this.databaseService = databaseService;
 
 		// Initalize Tag Models
@@ -51,8 +55,15 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 			FIXED_TAG_PIN,
 			1,
 			2,
-			// TODO get from Resource
-			"Star"
+			context.getString(R.string.pin)
+		));
+
+		// Initalize Tag Models
+		this.tagModels.add(new TagModel(
+			FIXED_TAG_UNREAD,
+			0xFFFF0000,
+			0xFFFFFFFF,
+			context.getString(R.string.unread_messages)
 		));
 	}
 
@@ -156,6 +167,11 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 		return this.databaseService.getConversationTagFactory().getAll();
 	}
 
+	@Override
+	public long getCount(@NonNull TagModel tagModel) {
+		return this.databaseService.getConversationTagFactory().countByTag(tagModel.getTag());
+	}
+
 	private void triggerChange(final ConversationModel conversationModel) {
 		ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
 			@Override

+ 61 - 42
app/src/main/java/ch/threema/app/services/FileServiceImpl.java

@@ -208,7 +208,7 @@ public class FileServiceImpl implements FileService {
 		return true;
 	}
 
-	@Override
+	@Deprecated
 	public File getBackupPath() {
 		if (!this.backupPath.exists()) {
 			this.backupPath.mkdirs();
@@ -265,37 +265,49 @@ public class FileServiceImpl implements FileService {
 		return getAppDataPath().getAbsolutePath();
 	}
 
+	@Deprecated
 	private File getImagePath() {
-		if (!this.imagePath.exists()) {
-			this.imagePath.mkdirs();
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+			if (!this.imagePath.exists()) {
+				this.imagePath.mkdirs();
+			}
 		}
 		return this.imagePath;
 	}
 
+	@Deprecated
 	private File getVideoPath() {
-		if (!this.videoPath.exists()) {
-			this.videoPath.mkdirs();
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+			if (!this.videoPath.exists()) {
+				this.videoPath.mkdirs();
+			}
 		}
 		return this.videoPath;
 	}
 
+	@Deprecated
 	private File getAudioPath() {
-		if (!this.audioPath.exists()) {
-			this.audioPath.mkdirs();
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+			if (!this.audioPath.exists()) {
+				this.audioPath.mkdirs();
+			}
 		}
 		return this.audioPath;
 	}
 
+	@Deprecated
 	private File getDownloadsPath() {
-		try {
-			if (!this.downloadsPath.exists()) {
-				this.downloadsPath.mkdirs();
-			} else if (!downloadsPath.isDirectory()) {
-				FileUtil.deleteFileOrWarn(this.downloadsPath, "Download Path", logger);
-				this.downloadsPath.mkdirs();
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+			try {
+				if (!this.downloadsPath.exists()) {
+					this.downloadsPath.mkdirs();
+				} else if (!downloadsPath.isDirectory()) {
+					FileUtil.deleteFileOrWarn(this.downloadsPath, "Download Path", logger);
+					this.downloadsPath.mkdirs();
+				}
+			} catch (SecurityException e) {
+				logger.error("Exception", e);
 			}
-		} catch (SecurityException e) {
-			logger.error("Exception", e);
 		}
 		return this.downloadsPath;
 	}
@@ -612,33 +624,27 @@ public class FileServiceImpl implements FileService {
 	}
 
 	/**
-	 * return the file of a message file saved in the gallery (if exist)
+	 * return the filename of a message file saved in the gallery (if exist)
 	 */
-	private File constructGalleryMediaFilename(AbstractMessageModel messageModel) {
+	@Nullable
+	private String constructGalleryMediaFilename(AbstractMessageModel messageModel) {
 		String title = FileUtil.getMediaFilenamePrefix(messageModel);
 
 		switch (messageModel.getType()) {
 			case IMAGE:
-				return new File(getImagePath(), title + JPEG_EXTENSION);
+				return title + JPEG_EXTENSION;
 			case VIDEO:
-				return new File(getVideoPath(), title + MPEG_EXTENSION);
+				return title + MPEG_EXTENSION;
 			case VOICEMESSAGE:
-				return new File(getAudioPath(), title + VOICEMESSAGE_EXTENSION);
+				return title + VOICEMESSAGE_EXTENSION;
 			case FILE:
 				String filename = messageModel.getFileData().getFileName();
 				if (TestUtil.empty(filename)) {
 					filename = title + getMediaFileExtension(messageModel);
 				}
-
-				if (FileUtil.isImageFile(messageModel.getFileData())) {
-					return new File(getImagePath(), filename);
-				} if (FileUtil.isVideoFile(messageModel.getFileData())) {
-					return new File(getVideoPath(), filename);
-				} if (FileUtil.isAudioFile(messageModel.getFileData())) {
-					return new File(getAudioPath(), filename);
-				} else {
-					return new File(getDownloadsPath(), filename);
-				}
+				return filename;
+			default:
+				break;
 		}
 		return null;
 	}
@@ -685,7 +691,7 @@ public class FileServiceImpl implements FileService {
 		}
 	}
 
-	private void copyMediaFileIntoPublicDirectory(InputStream inputStream, File destFile, String mimeType) throws Exception {
+	private void copyMediaFileIntoPublicDirectory(InputStream inputStream, String filename, String mimeType) throws Exception {
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
 			String relativePath;
 			Uri contentUri;
@@ -707,7 +713,7 @@ public class FileServiceImpl implements FileService {
 			}
 
 			final ContentValues contentValues = new ContentValues();
-			contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, destFile.getName());
+			contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
 			contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
 			contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath + "/" + BuildConfig.MEDIA_PATH);
 			contentValues.put(MediaStore.MediaColumns.IS_PENDING, true);
@@ -725,6 +731,20 @@ public class FileServiceImpl implements FileService {
 				throw new Exception("Unable to open file");
 			}
 		} else {
+			File destPath;
+			if (MimeUtil.isAudioFile(mimeType)) {
+				destPath = getAudioPath();
+			} else if (MimeUtil.isVideoFile(mimeType)) {
+				destPath = getVideoPath();
+			} else if (MimeUtil.isImageFile(mimeType)) {
+				destPath = getImagePath();
+			} else if (MimeUtil.isPdfFile(mimeType)) {
+				destPath = getDownloadsPath();
+			} else {
+				destPath = getDownloadsPath();
+			}
+
+			File destFile = new File(destPath, filename);
 			destFile = FileUtil.getUniqueFile(destFile.getParent(), destFile.getName());
 			try (FileOutputStream outputStream = new FileOutputStream(destFile)) {
 				IOUtils.copy(inputStream, outputStream);
@@ -738,8 +758,8 @@ public class FileServiceImpl implements FileService {
 	 * save the data of a message model into the gallery
 	 */
 	private void insertMessageIntoGallery(AbstractMessageModel messageModel) throws Exception {
-		File mediaFile = this.constructGalleryMediaFilename(messageModel);
-		if (mediaFile == null) {
+		String mediaFilename = this.constructGalleryMediaFilename(messageModel);
+		if (mediaFilename == null) {
 			return;
 		}
 
@@ -751,7 +771,7 @@ public class FileServiceImpl implements FileService {
 
 		if (FileUtil.isFilePresent(messageFile)) {
 			try (CipherInputStream cis = masterKey.getCipherInputStream(new FileInputStream(messageFile))) {
-				copyMediaFileIntoPublicDirectory(cis, mediaFile, MimeUtil.getMimeTypeFromMessageModel(messageModel));
+				copyMediaFileIntoPublicDirectory(cis, mediaFilename, MimeUtil.getMimeTypeFromMessageModel(messageModel));
 			}
 		} else {
 			throw new ThreemaException("File not found.");
@@ -760,17 +780,16 @@ public class FileServiceImpl implements FileService {
 
 	@Override
 	public void copyDecryptedFileIntoGallery(Uri sourceUri, AbstractMessageModel messageModel) throws Exception {
-		InputStream inputStream;
-		File mediaFile = this.constructGalleryMediaFilename(messageModel);
-		if (mediaFile == null) {
+		String mediaFilename = this.constructGalleryMediaFilename(messageModel);
+		if (mediaFilename == null) {
 			return;
 		}
 
 		ContentResolver cr = context.getContentResolver();
-		inputStream = cr.openInputStream(sourceUri);
-		if (inputStream != null) {
-			copyMediaFileIntoPublicDirectory(inputStream, mediaFile, MimeUtil.getMimeTypeFromMessageModel(messageModel));
-			inputStream.close();
+		try (final InputStream inputStream = cr.openInputStream(sourceUri)) {
+			if (inputStream != null) {
+				copyMediaFileIntoPublicDirectory(inputStream, mediaFilename, MimeUtil.getMimeTypeFromMessageModel(messageModel));
+			}
 		}
 	}
 

+ 9 - 4
app/src/main/java/ch/threema/app/services/GroupService.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.services;
 
+import android.app.Activity;
+import android.content.Intent;
 import android.graphics.Bitmap;
 
 import java.sql.SQLException;
@@ -76,7 +78,7 @@ public interface GroupService extends AvatarService<GroupModel> {
 
 	boolean sendLeave(AbstractGroupMessage msg);
 
-	GroupCreateMessageResult processGroupCreateMessage(GroupCreateMessage groupCreateMessage) throws ThreemaException, InvalidEntryException;
+	GroupCreateMessageResult processGroupCreateMessage(GroupCreateMessage groupCreateMessage);
 
 	GroupModel createGroup(String name, String[] groupMemberIdentities, Bitmap picture) throws Exception;
 	GroupModel updateGroup(GroupModel groupModel, String name, String[] groupMemberIdentities, Bitmap photo, boolean removePhoto) throws Exception;
@@ -118,14 +120,14 @@ public interface GroupService extends AvatarService<GroupModel> {
 	Bitmap getNeutralAvatar(boolean highResolution);
 
 	boolean isGroupOwner(GroupModel groupModel);
-
-	int countMembers(@NonNull GroupModel 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);
+
 	int getOtherMemberCount(GroupModel model);
 
 	int getPrimaryColor(GroupModel groupModel);
@@ -148,4 +150,7 @@ public interface GroupService extends AvatarService<GroupModel> {
 	String getUniqueIdString(int groupId);
 
 	void setIsArchived(GroupModel groupModel, boolean archived);
+
+	Intent getGroupEditIntent(@NonNull GroupModel groupModel, @NonNull Activity activity);
+	void save(GroupModel model);
 }

+ 75 - 35
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.services;
 
+import android.app.Activity;
+import android.content.Intent;
 import android.graphics.Bitmap;
 import android.text.TextUtils;
 import android.text.format.DateUtils;
@@ -50,6 +52,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
@@ -103,6 +106,7 @@ public class GroupServiceImpl implements GroupService {
 	private final WallpaperService wallpaperService;
 	private final DeadlineListService mutedChatsListService, hiddenChatsListService;
 	private final RingtoneService ringtoneService;
+	private final IdListService blackListService;
 	private final SparseArray<Map<String, Integer>> groupMemberColorCache;
 	private final SparseArray<GroupModel> groupModelCache;
 	private final SparseArray<String[]> groupIdentityCache;
@@ -128,7 +132,8 @@ public class GroupServiceImpl implements GroupService {
 			WallpaperService wallpaperService,
 			DeadlineListService mutedChatsListService,
 			DeadlineListService hiddenChatsListService,
-			RingtoneService ringtoneService) {
+			RingtoneService ringtoneService,
+			IdListService blackListService) {
 		this.apiService = apiService;
 		this.groupApiService = groupApiService;
 
@@ -142,6 +147,7 @@ public class GroupServiceImpl implements GroupService {
 		this.mutedChatsListService = mutedChatsListService;
 		this.hiddenChatsListService = hiddenChatsListService;
 		this.ringtoneService = ringtoneService;
+		this.blackListService = blackListService;
 
 		this.groupModelCache = cacheService.getGroupModelCache();
 		this.groupIdentityCache = cacheService.getGroupIdentityCache();
@@ -251,7 +257,7 @@ public class GroupServiceImpl implements GroupService {
 		ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
 			@Override
 			public void handle(GroupListener listener) {
-				listener.onMemberKicked(groupModel, userService.getIdentity());
+				listener.onMemberKicked(groupModel, userService.getIdentity(), identities.length);
 			}
 		});
 
@@ -429,7 +435,7 @@ public class GroupServiceImpl implements GroupService {
 	public boolean sendLeave(AbstractGroupMessage msg) {
 		if(msg != null) {
 			try {
-				//send a request sync to the creator!!
+				//send a leave to the creator!!
 				this.groupApiService.sendMessage(msg.getGroupId(),
 						msg.getGroupCreator(),
 						new String[]{msg.getFromIdentity(), msg.getGroupCreator()},
@@ -500,6 +506,8 @@ public class GroupServiceImpl implements GroupService {
 
 	@Override
 	public boolean removeMemberFromGroup(final GroupModel group, final String identity) {
+		final int previousMemberCount = countMembers(group);
+
 		if(this.databaseServiceNew.getGroupMemberModelFactory().deleteByGroupIdAndIdentity(
 				group.getId(),
 				identity
@@ -509,7 +517,7 @@ public class GroupServiceImpl implements GroupService {
 			ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
 				@Override
 				public void handle(GroupListener listener) {
-					listener.onMemberLeave(group, identity);
+					listener.onMemberLeave(group, identity, previousMemberCount);
 				}
 			});
 			return true;
@@ -518,6 +526,11 @@ public class GroupServiceImpl implements GroupService {
 		return false;
 	}
 
+	@Override
+	public Intent getGroupEditIntent(@NonNull GroupModel groupModel, @NonNull Activity activity) {
+		return new Intent(activity, GroupDetailActivity.class);
+	}
+
 	@Override
 	public GroupModel getById(int groupId) {
 		synchronized (this.groupModelCache) {
@@ -531,11 +544,12 @@ public class GroupServiceImpl implements GroupService {
 
 
 	@Override
-	public GroupCreateMessageResult processGroupCreateMessage(final GroupCreateMessage groupCreateMessage) throws ThreemaException, InvalidEntryException {
+	public GroupCreateMessageResult processGroupCreateMessage(final GroupCreateMessage groupCreateMessage) {
 		final GroupCreateMessageResult result = new GroupCreateMessageResult();
 		result.success = false;
 
 		boolean isNewGroup;
+		final int previousMemberCount;
 
 		//check if i am in group
 		boolean iAmAGroupMember = Functional.select(groupCreateMessage.getMembers(), new IPredicateNonNull<String>() {
@@ -547,6 +561,7 @@ public class GroupServiceImpl implements GroupService {
 
 		try {
 			result.groupModel = this.getByAbstractGroupMessage(groupCreateMessage);
+			previousMemberCount = result.groupModel != null ? countMembers(result.groupModel) : 0;
 			isNewGroup = result.groupModel == null;
 
 		} catch (SQLException e) {
@@ -554,18 +569,25 @@ public class GroupServiceImpl implements GroupService {
 			return null;
 		}
 
-		if(!iAmAGroupMember) {
+		if (isNewGroup && this.blackListService != null && this.blackListService.has(groupCreateMessage.getFromIdentity())) {
+			logger.info("GroupCreateMessage {}: Received group create from blocked ID. Sending leave.", groupCreateMessage.getMessageId());
+
+			sendLeave(groupCreateMessage);
+
+			result.success = true;
+			return result;
+		}
 
-			if(isNewGroup) {
-				//do nothing
-				//group not saved and i am not a member
+		if (!iAmAGroupMember) {
+			if (isNewGroup) {
+				// i'm not a member of this new group
+				// ignore this groupCreate message
 				result.success = true;
 				result.groupModel = null;
-
 			}
 			else {
-				//user is kicked out of group
-				//remove all members
+				// i was kicked out of group
+				// remove all members
 				this.databaseServiceNew.getGroupMemberModelFactory().deleteByGroupId(
 						result.groupModel.getId());
 
@@ -575,7 +597,6 @@ public class GroupServiceImpl implements GroupService {
 				result.success = true;
 				result.groupModel = null;
 
-
 				//reset cache
 				this.resetIdentityCache(groupModel.getId());
 
@@ -583,7 +604,7 @@ public class GroupServiceImpl implements GroupService {
 				ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
 					@Override
 					public void handle(GroupListener listener) {
-						listener.onMemberKicked(groupModel, userService.getIdentity());
+						listener.onMemberKicked(groupModel, userService.getIdentity(), previousMemberCount);
 					}
 				});
 			}
@@ -591,8 +612,7 @@ public class GroupServiceImpl implements GroupService {
 			return result;
 		}
 
-
-		if(result.groupModel == null) {
+		if (result.groupModel == null) {
 			result.groupModel = new GroupModel();
 			result.groupModel
 					.setApiGroupId(groupCreateMessage.getGroupId().toString())
@@ -603,7 +623,7 @@ public class GroupServiceImpl implements GroupService {
 			this.cache(result.groupModel);
 
 		}
-		else if(result.groupModel.isDeleted()) {
+		else if (result.groupModel.isDeleted()) {
 			result.groupModel.setDeleted(false);
 			this.databaseServiceNew.getGroupModelFactory().update(result.groupModel);
 			isNewGroup = true;
@@ -650,12 +670,10 @@ public class GroupServiceImpl implements GroupService {
 					localSavedGroupMembers);
 
 			for(final GroupMemberModel groupMemberModel: localSavedGroupMembers) {
-				//fire event
-
 				ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
 					@Override
 					public void handle(GroupListener listener) {
-						listener.onMemberKicked(result.groupModel, groupMemberModel.getIdentity());
+						listener.onMemberKicked(result.groupModel, groupMemberModel.getIdentity(), previousMemberCount);
 					}
 				});
 			}
@@ -692,6 +710,7 @@ public class GroupServiceImpl implements GroupService {
 		}
 
 		GroupModel model = this.createGroup(name, groupMemberIdentities);
+
 		if (uploadPhotoResult != null) {
 			this.updateGroupPhoto(model, uploadPhotoResult);
 		}
@@ -755,6 +774,7 @@ public class GroupServiceImpl implements GroupService {
 	public Boolean addMemberToGroup(final GroupModel groupModel, final String identity) {
 		GroupMemberModel m = this.getGroupMember(groupModel, identity);
 		boolean isNewMember = m == null;
+		final int previousMemberCount = countMembers(groupModel);
 
 		//check if member already in group
 
@@ -797,7 +817,7 @@ public class GroupServiceImpl implements GroupService {
 			ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
 				@Override
 				public void handle(GroupListener listener) {
-					listener.onNewMember(groupModel, identity);
+					listener.onNewMember(groupModel, identity, previousMemberCount);
 				}
 			});
 		}
@@ -818,6 +838,8 @@ public class GroupServiceImpl implements GroupService {
 			ArrayList<String> newContacts = new ArrayList<>();
 			ArrayList<String> newMembers = new ArrayList<>();
 
+			int previousMemberCount = countMembers(groupModel);
+
 			// check for new contacts, if necessary, create them
 			if (!this.preferenceService.isBlockUnknown()) {
 				for (String identity : identities) {
@@ -904,7 +926,7 @@ public class GroupServiceImpl implements GroupService {
 				@Override
 				public void handle(GroupListener listener) {
 					for (String identity: newMembers) {
-						listener.onNewMember(groupModel, identity);
+						listener.onNewMember(groupModel, identity, previousMemberCount);
 					}
 				}
 			});
@@ -1031,7 +1053,7 @@ public class GroupServiceImpl implements GroupService {
 			this.resetIdentityCache(groupModel.getId());
 
 			for(final String kickedGroupMemberIdentity: kickedGroupMemberIdentities) {
-				ListenerManager.groupListeners.handle(listener -> listener.onMemberKicked(groupModel, kickedGroupMemberIdentity));
+				ListenerManager.groupListeners.handle(listener -> listener.onMemberKicked(groupModel, kickedGroupMemberIdentity, existingMembers.length));
 			}
 		}
 		return groupModel;
@@ -1213,17 +1235,6 @@ public class GroupServiceImpl implements GroupService {
 		}
 	}
 
-	@Override
-	public int countMembers(@NonNull GroupModel groupModel) {
-		synchronized (this.groupIdentityCache) {
-			String[] existingIdentities = this.groupIdentityCache.get(groupModel.getId());
-			if (existingIdentities != null) {
-				return existingIdentities.length;
-			}
-		}
-		return (int) this.databaseServiceNew.getGroupMemberModelFactory().countMembers(groupModel.getId());
-	}
-
 	private boolean isGroupMember(GroupModel groupModel, String identity) {
 		if (!TestUtil.empty(identity)) {
 			for (String existingIdentity : this.getGroupIdentities(groupModel)) {
@@ -1330,6 +1341,34 @@ public class GroupServiceImpl implements GroupService {
 				&& this.userService.isMe(groupModel.getCreatorIdentity());
 	}
 
+	/**
+	 * Count members in a group
+	 * @param groupModel
+	 * @return Number of members in this group including group creator
+	 */
+	@Override
+	public int countMembers(@NonNull GroupModel groupModel) {
+		synchronized (this.groupIdentityCache) {
+			String[] existingIdentities = this.groupIdentityCache.get(groupModel.getId());
+			if (existingIdentities != null) {
+				return existingIdentities.length;
+			}
+		}
+		return (int) this.databaseServiceNew.getGroupMemberModelFactory().countMembers(groupModel.getId());
+	}
+
+	/**
+	 * Whether the provided group is an implicit note group (i.e. data is kept local)
+	 * @param groupModel of the group
+	 * @return true if the group is a note group, false otherwise
+	 */
+	@Override
+	public boolean isNotesGroup(@NonNull GroupModel groupModel) {
+		return
+			isGroupOwner(groupModel) &&
+			countMembers(groupModel) == 1;
+	}
+
 	@Override
 	public int getOtherMemberCount(GroupModel groupModel) {
 		int count = 0;
@@ -1640,7 +1679,8 @@ public class GroupServiceImpl implements GroupService {
 		}
 	}
 
-	private void save(GroupModel model) {
+	@Override
+	public void save(GroupModel model) {
 		this.databaseServiceNew.getGroupModelFactory().createOrUpdate(
 				model
 		);

+ 50 - 14
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -115,8 +115,8 @@ import ch.threema.app.utils.StreamUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.VideoUtil;
-import ch.threema.app.video.VideoConfig;
-import ch.threema.app.video.VideoTranscoder;
+import ch.threema.app.video.transcoder.VideoConfig;
+import ch.threema.app.video.transcoder.VideoTranscoder;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.AbstractGroupMessage;
 import ch.threema.client.AbstractMessage;
@@ -223,7 +223,7 @@ public class MessageServiceImpl implements MessageService {
 	private final ApiService apiService;
 	private final DownloadService downloadService;
 	private final DeadlineListService hiddenChatsListService;
-	private final IdListService profilePicRecipientsService;
+	private final IdListService profilePicRecipientsService, blackListService;
 
 	public MessageServiceImpl(Context context,
 	                          CacheService cacheService,
@@ -240,7 +240,8 @@ public class MessageServiceImpl implements MessageService {
 	                          ApiService apiService,
 	                          DownloadService downloadService,
 	                          DeadlineListService hiddenChatsListService,
-	                          IdListService profilePicRecipientsService) {
+	                          IdListService profilePicRecipientsService,
+	                          IdListService blackListService) {
 		this.context = context;
 		this.messageQueue = messageQueue;
 		this.databaseServiceNew = databaseServiceNew;
@@ -256,6 +257,7 @@ public class MessageServiceImpl implements MessageService {
 		this.downloadService = downloadService;
 		this.hiddenChatsListService = hiddenChatsListService;
 		this.profilePicRecipientsService = profilePicRecipientsService;
+		this.blackListService = blackListService;
 
 		this.contactMessageCache = cacheService.getMessageModelCache();
 		this.groupMessageCache = cacheService.getGroupMessageModelCache();
@@ -1307,6 +1309,13 @@ public class MessageServiceImpl implements MessageService {
 			return true;
 		}
 
+		// is the user blocked?
+		if (this.blackListService != null && this.blackListService.has(message.getFromIdentity())) {
+			//set success to true (remove from server)
+			logger.info("GroupMessage {}: Sender is blocked, ignoring", message.getMessageId());
+			return true;
+		}
+
 		if(this.groupService.getGroupMember(groupModel, message.getFromIdentity()) == null) {
 			// we received a group message from a user that is not or no longer part of the group
 			if(this.groupService.isGroupOwner(groupModel)) {
@@ -3449,7 +3458,7 @@ public class MessageServiceImpl implements MessageService {
 
 	@WorkerThread
 	@Override
-	public AbstractMessageModel sendMedia(
+	public @Nullable AbstractMessageModel sendMedia(
 		@NonNull final List<MediaItem> mediaItems,
 		@NonNull final List<MessageReceiver> messageReceivers,
 		@Nullable final SendResultListener sendResultListener
@@ -3460,10 +3469,10 @@ public class MessageServiceImpl implements MessageService {
 		// resolve receivers to account for distribution lists
 		final MessageReceiver[] resolvedReceivers = MessageUtil.addDistributionListReceivers(messageReceivers.toArray(new MessageReceiver[0]));
 
-		logger.info("sendMedia: Sending " + mediaItems.size() + " items to " + resolvedReceivers.length + " receivers");
+		logger.info("sendMedia: Sending {} items to {} receivers", mediaItems.size(), resolvedReceivers.length);
 
 		for (MediaItem mediaItem : mediaItems) {
-			logger.info("sendMedia: Now sending item of type " + mediaItem.getType());
+			logger.info("sendMedia: Now sending item of type {}", mediaItem.getType());
 			if (TYPE_TEXT == mediaItem.getType()) {
 				String text = mediaItem.getCaption();
 				if (!TestUtil.empty(text)) {
@@ -3529,12 +3538,12 @@ public class MessageServiceImpl implements MessageService {
 					markAsTerminallyFailed(resolvedReceivers, messageModels);
 				}
 			} catch (ThreemaException e) {
-				if (!(e instanceof TranscodeCanceledException)) {
-					logger.error("Exception", e);
-					failedCounter++;
-				} else {
+				if (e instanceof TranscodeCanceledException) {
 					logger.info("Video transcoding canceled");
 					// canceling is not really a failure
+				} else {
+					logger.error("Exception", e);
+					failedCounter++;
 				}
 				markAsTerminallyFailed(resolvedReceivers, messageModels);
 			}
@@ -3547,6 +3556,7 @@ public class MessageServiceImpl implements MessageService {
 				sendResultListener.onCompleted();
 			}
 		} else {
+			logger.warn("sendMedia: Did not complete successfully, failedCounter={}", failedCounter);
 			final String errorString = context.getString(R.string.an_error_occurred_during_send);
 			logger.info(errorString);
 			RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, errorString, Toast.LENGTH_LONG).show());
@@ -3617,9 +3627,20 @@ public class MessageServiceImpl implements MessageService {
 							imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
 						} else {
 							imageByteArray = BitmapUtil.getPngByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
+
+							if (!MimeUtil.MIME_TYPE_IMAGE_PNG.equals(mediaItem.getMimeType())) {
+								fileDataModel.setMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);
+
+								if (fileDataModel.getFileName() != null) {
+									int dot = fileDataModel.getFileName().lastIndexOf(".");
+									if (dot > 1) {
+										String filenamePart = fileDataModel.getFileName().substring(0, dot);
+										fileDataModel.setFileName(filenamePart + ".png");
+									}
+								}
+							}
 						}
 						if (imageByteArray != null) {
-							fileDataModel.setFileSize(imageByteArray.length);
 							return ArrayUtils.concatByteArrays(new byte[NaCl.BOXOVERHEAD], imageByteArray);
 						}
 					}
@@ -3841,7 +3862,7 @@ public class MessageServiceImpl implements MessageService {
 					sendMachine.reset()
 						.next(() -> {
 							if (getReceiver().sendMediaData()) {
-								// note that encryptFileData will overwrite contents of provided content data!
+								// note that encryptFileData() will overwrite contents of provided content data!
 								if (contentEncryptResult[0] == null) {
 									contentEncryptResult[0] = getReceiver().encryptFileData(contentData);
 									if (contentEncryptResult[0].getData() == null || contentEncryptResult[0].getSize() == 0) {
@@ -3849,7 +3870,7 @@ public class MessageServiceImpl implements MessageService {
 									}
 								}
 							}
-							fileDataModel.setFileSize(contentData.length);
+							fileDataModel.setFileSize(contentData.length - NaCl.BOXOVERHEAD);
 							messageModel.setFileData(fileDataModel);
 							fireOnModifiedMessage(messageModel);
 						})
@@ -4217,6 +4238,21 @@ public class MessageServiceImpl implements MessageService {
 				return transcoderResult;
 			}
 
+			if (videoTranscoder.hasAudioTranscodingError()) {
+				final int errorMessageResource;
+				if(videoTranscoder.audioFormatUnsupported()) {
+					errorMessageResource = R.string.transcoder_unsupported_audio_format;
+				} else {
+					errorMessageResource = R.string.transcoder_unknown_audio_error;
+				}
+
+				RuntimeUtil.runOnUiThread(() -> Toast.makeText(
+					ThreemaApplication.getAppContext(),
+					this.context.getString(errorMessageResource),
+					Toast.LENGTH_LONG
+				).show());
+			}
+
 			// remove original file and set transcoded file as new source file
 			deleteTemporaryFile(mediaItem);
 			mediaItem.setUri(Uri.fromFile(outputFile));

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

@@ -22,7 +22,6 @@
 package ch.threema.app.services;
 
 import android.annotation.TargetApi;
-import android.app.Notification;
 import android.content.pm.ShortcutInfo;
 import android.graphics.Bitmap;
 import android.net.Uri;
@@ -35,8 +34,6 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 
-import androidx.annotation.Nullable;
-import androidx.core.app.NotificationCompat;
 import androidx.core.app.Person;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -62,7 +59,6 @@ public interface NotificationService {
 	String NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS =  "bk";
 	String NOTIFICATION_CHANNEL_CHAT_UPDATE =  "cu";
 	String NOTIFICATION_CHANNEL_NEW_SYNCED_CONTACTS = "nc";
-	String NOTIFICATION_CHANNEL_IMAGE_LABELING =  "il";
 
 	String NOTIFICATION_CHANNELGROUP_CHAT = "group";
 	String NOTIFICATION_CHANNELGROUP_VOIP = "vgroup";
@@ -348,26 +344,4 @@ public interface NotificationService {
 	void showWebclientResumeFailed(String msg);
 	void cancelRestartNotification();
 	void resetConversationNotifications();
-
-	/**
-	 * Create and return an image labeling progress notification builder.
-	 * The progress bar will be set to indeterminate until it's updated with
-	 * {@link #updateImageLabelingProgressNotification(int, int)}.
-	 */
-	@Nullable NotificationCompat.Builder createImageLabelingProgressNotification();
-
-	/**
-	 * Update and show image labeling progress notification.
-	 */
-	void updateImageLabelingProgressNotification(int currentProgress, int maxProgress);
-
-	/**
-	 * Remove existing image labelling progress notification
-	 */
-	void cancelImageLabelingProgressNotification();
-
-	/**
-	 * Show image labeling worker got stuck and is canceled
-	 */
-	void showImageLabelingWorkerStuckNotification();
 }

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

@@ -64,7 +64,6 @@ import java.util.List;
 import java.util.Map;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.app.NotificationCompat;
 import androidx.core.app.NotificationManagerCompat;
@@ -231,10 +230,6 @@ public class NotificationServiceImpl implements NotificationService {
 		if (ConfigUtils.isWorkBuild()) {
 			notificationManager.deleteNotificationChannel(NOTIFICATION_CHANNEL_WORK_SYNC);
 		}
-		if (ConfigUtils.isPlayServicesInstalled(context)) {
-			notificationManager.deleteNotificationChannel(NOTIFICATION_CHANNEL_IMAGE_LABELING);
-		}
-
 		notificationManager.deleteNotificationChannelGroup(NOTIFICATION_CHANNELGROUP_CHAT);
 		notificationManager.deleteNotificationChannelGroup(NOTIFICATION_CHANNELGROUP_CHAT_UPDATE);
 		notificationManager.deleteNotificationChannelGroup(NOTIFICATION_CHANNELGROUP_VOIP);
@@ -350,22 +345,6 @@ public class NotificationServiceImpl implements NotificationService {
 		notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET);
 		notificationChannel.setSound(null,null);
 		notificationManager.createNotificationChannel(notificationChannel);
-
-		// image labeling progress notification
-		if (ConfigUtils.isPlayServicesInstalled(context)) {
-			notificationChannel = new NotificationChannel(
-				NOTIFICATION_CHANNEL_IMAGE_LABELING,
-				context.getString(R.string.notification_channel_image_labeling),
-				NotificationManager.IMPORTANCE_LOW
-			);
-			notificationChannel.setDescription(context.getString(R.string.notification_channel_image_labeling_desc));
-			notificationChannel.enableLights(false);
-			notificationChannel.enableVibration(false);
-			notificationChannel.setShowBadge(false);
-			notificationChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET);
-			notificationChannel.setSound(null, null);
-			notificationManager.createNotificationChannel(notificationChannel);
-		}
 	}
 
 	@Override
@@ -602,7 +581,7 @@ public class NotificationServiceImpl implements NotificationService {
 								.bigPicture(latestThumbnail)
 								.setSummaryText(conversationNotification.getMessage()));
 					}
-					addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, decPendingIntent, markReadPendingIntent, conversationNotification, numberOfNotificationsForCurrentChat, unreadConversationsCount, uniqueId, newestGroup);
+					addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, markReadPendingIntent, conversationNotification, numberOfNotificationsForCurrentChat, unreadConversationsCount, uniqueId, newestGroup);
 					addWearableExtender(builder, newestGroup, ackPendingIntent, decPendingIntent, replyPendingIntent, markReadPendingIntent, timestamp, latestThumbnail, numberOfNotificationsForCurrentChat, singleMessageText != null ? singleMessageText.toString() : "", uniqueId);
 				}
 
@@ -670,7 +649,7 @@ public class NotificationServiceImpl implements NotificationService {
 
 				if (this.preferenceService.isShowMessagePreview() && !hiddenChatsListService.has(uniqueId)) {
 					addConversationNotificationPreviews(builder, latestThumbnail, singleMessageText, contentTitle, conversationNotification.getMessage(), unreadMessagesCount);
-					addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, decPendingIntent, markReadPendingIntent, conversationNotification, unreadMessagesCount, unreadConversationsCount, uniqueId, newestGroup);
+					addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, markReadPendingIntent, conversationNotification, unreadMessagesCount, unreadConversationsCount, uniqueId, newestGroup);
 				}
 
 				if (unreadMessagesCount > 1 && inboxStyle != null) {
@@ -780,7 +759,7 @@ public class NotificationServiceImpl implements NotificationService {
 		}
 	}
 
-	private void addConversationNotificationActions(NotificationCompat.Builder builder, PendingIntent replyPendingIntent, PendingIntent ackPendingIntent, PendingIntent decPendingIntent, PendingIntent markReadPendingIntent, ConversationNotification conversationNotification, int unreadMessagesCount, int unreadGroupsCount, String uniqueId, ConversationNotificationGroup newestGroup) {
+	private void addConversationNotificationActions(NotificationCompat.Builder builder, PendingIntent replyPendingIntent, PendingIntent ackPendingIntent, PendingIntent markReadPendingIntent, ConversationNotification conversationNotification, int unreadMessagesCount, int unreadGroupsCount, String uniqueId, ConversationNotificationGroup newestGroup) {
 		// add action buttons
 		if (preferenceService.isShowMessagePreview() && !hiddenChatsListService.has(uniqueId)) {
 			if (ConfigUtils.canDoGroupedNotifications()) {
@@ -830,12 +809,11 @@ public class NotificationServiceImpl implements NotificationService {
 					}
 				} else {
 					if (unreadMessagesCount == 1) {
-						builder
-							.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_thumb_up_white_24dp, context.getString(R.string.acknowledge), ackPendingIntent)
-								.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP).build())
-							.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_thumb_down_white_24dp, context.getString(R.string.decline), decPendingIntent)
-								.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_DOWN).build());
+						builder.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_thumb_up_white_24dp, context.getString(R.string.acknowledge), ackPendingIntent)
+								.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP).build());
 					}
+					builder.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_mark_read, context.getString(R.string.mark_read_short), markReadPendingIntent)
+						.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ).build());
 				}
 			}
 		}
@@ -1872,61 +1850,4 @@ public class NotificationServiceImpl implements NotificationService {
 	public void resetConversationNotifications(){
 		conversationNotifications.clear();
 	}
-
-	/**
-	 * @see NotificationService#createImageLabelingProgressNotification()
-	 */
-	@Override
-	public @Nullable NotificationCompat.Builder createImageLabelingProgressNotification() {
-		if (!ConfigUtils.isPlayServicesInstalled(context)) {
-			// Requires Google Play Service
-			logger.warn("Cannot create image labeling progress notification: Google play services not installed");
-			return null;
-		}
-		return new NotificationBuilderWrapper(context, NOTIFICATION_CHANNEL_IMAGE_LABELING, null)
-				.setSound(null)
-				.setSmallIcon(R.drawable.ic_image_labeling)
-				.setContentTitle(this.context.getString(R.string.notification_image_labeling_desc))
-				.setProgress(0, 100, true) // Initially indeterminate until updated
-				.setPriority(NotificationManagerCompat.IMPORTANCE_LOW)
-				.setAutoCancel(true)
-				.setLocalOnly(true)
-				.setOnlyAlertOnce(true);
-	}
-
-	/**
-	 * @see NotificationService#updateImageLabelingProgressNotification(int, int)
-	 */
-	@Override
-	public void updateImageLabelingProgressNotification(int currentProgress, int maxProgress) {
-		final NotificationCompat.Builder builder = this.createImageLabelingProgressNotification();
-		if (builder != null) {
-			builder.setProgress(maxProgress, currentProgress, false);
-			builder.setContentText(currentProgress + "/" + maxProgress);
-			this.notificationManager.notify(ThreemaApplication.IMAGE_LABELING_NOTIFICATION_ID, builder.build());
-		}
-	}
-
-	@Override
-	public void cancelImageLabelingProgressNotification() {
-		this.notificationManager.cancel(ThreemaApplication.IMAGE_LABELING_NOTIFICATION_ID);
-	}
-
-	@Override
-	public void showImageLabelingWorkerStuckNotification() {
-		String msg = context.getString(R.string.image_labeling_stuck_error);
-
-		NotificationCompat.Builder builder =
-			new NotificationBuilderWrapper(this.context, NOTIFICATION_CHANNEL_IMAGE_LABELING, null)
-				.setSmallIcon(R.drawable.ic_image_labeling)
-				.setTicker(msg)
-				.setLocalOnly(true)
-				.setPriority(NotificationCompat.PRIORITY_HIGH)
-				.setCategory(NotificationCompat.CATEGORY_ERROR)
-				.setColor(this.context.getResources().getColor(R.color.material_red))
-				.setContentTitle(this.context.getString(R.string.app_name))
-				.setContentText(msg)
-				.setStyle(new NotificationCompat.BigTextStyle().bigText(msg));
-		this.notify(ThreemaApplication.WEB_RESUME_FAILED_NOTIFICATION_ID, builder);
-	}
 }

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

@@ -505,9 +505,6 @@ public interface PreferenceService {
 	void setLastSyncadapterRun(long timestampOfLastSync);
 	long getLastSyncAdapterRun();
 
-	boolean getIsImageLabelingTooltipShown();
-	void setIsImageLabelingTooltipShown(boolean shown);
-
-	boolean getIsImageResolutionTooltipShown();
-	void setIsImageResolutionTooltipShown(boolean shown);
+	void setVoiceRecorderBluetoothDisabled(boolean isEnabled);
+	boolean getVoiceRecorderBluetoothDisabled();
 }

+ 80 - 92
app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java

@@ -223,13 +223,14 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__inapp_sounds));
 	}
 
-    @Override
-    public boolean isInAppVibrate() {
-        return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__inapp_vibrate));
-    }
+	@Override
+	public boolean isInAppVibrate() {
+		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__inapp_vibrate));
+	}
 
 	@Override
-	@ImageScale public int getImageScale() {
+	@ImageScale
+	public int getImageScale() {
 		String imageScale = this.preferenceStore.getString(this.getKeyName(R.string.preferences__image_size));
 		if (imageScale == null || imageScale.length() == 0) {
 			return ImageScale_MEDIUM;
@@ -354,12 +355,13 @@ public class PreferenceServiceImpl implements PreferenceService {
 	public boolean isMasterKeyNewMessageNotifications() {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__masterkey_notification_newmsg));
 	}
-/*
-	@Override
-	public boolean isPinLockEnabled() {
-		return isPinSet() && this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__pin_lock_enabled));
-	}
-*/
+
+	/*
+	    @Override
+	    public boolean isPinLockEnabled() {
+		    return isPinSet() && this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__pin_lock_enabled));
+	    }
+    */
 	@Override
 	public boolean isPinSet() {
 		return isPinCodeValid(this.preferenceStore.getString(this.getKeyName(R.string.preferences__pin_lock_code), true));
@@ -383,16 +385,16 @@ public class PreferenceServiceImpl implements PreferenceService {
 		// use MessageDigest for a timing-safe comparison
 		return
 			code != null &&
-			storedCode != null &&
-			MessageDigest.isEqual(storedCode.getBytes(), code.getBytes());
+				storedCode != null &&
+				MessageDigest.isEqual(storedCode.getBytes(), code.getBytes());
 	}
 
 	private boolean isPinCodeValid(String code) {
 		if (TestUtil.empty(code))
 			return false;
 		else
-			return (code.length()>= ThreemaApplication.MIN_PIN_LENGTH &&
-				code.length()<=ThreemaApplication.MAX_PIN_LENGTH &&
+			return (code.length() >= ThreemaApplication.MIN_PIN_LENGTH &&
+				code.length() <= ThreemaApplication.MAX_PIN_LENGTH &&
 				TextUtils.isDigitsOnly(code));
 	}
 
@@ -400,12 +402,11 @@ public class PreferenceServiceImpl implements PreferenceService {
 	public int getPinLockGraceTime() {
 		String pos = this.preferenceStore.getString(this.getKeyName(R.string.preferences__pin_lock_grace_time));
 		try {
-			int time =  Integer.parseInt(pos);
+			int time = Integer.parseInt(pos);
 			if (time >= 30 || time < 0) {
 				return time;
 			}
-		}
-		catch (NumberFormatException x) {
+		} catch (NumberFormatException x) {
 
 		}
 		return -1;
@@ -419,22 +420,22 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void incrementIDBackupCount() {
 		this.preferenceStore.save(
-				this.getKeyName(R.string.preferences__id_backup_count),
-				this.getIDBackupCount()+1);
+			this.getKeyName(R.string.preferences__id_backup_count),
+			this.getIDBackupCount() + 1);
 	}
 
 	@Override
 	public void resetIDBackupCount() {
 		this.preferenceStore.save(
-				this.getKeyName(R.string.preferences__id_backup_count),
-				0);
+			this.getKeyName(R.string.preferences__id_backup_count),
+			0);
 	}
 
 	@Override
 	public void setLastIDBackupReminderDate(Date lastIDBackupReminderDate) {
 		this.preferenceStore.save(
-				this.getKeyName(R.string.preferences__last_id_backup_date),
-				lastIDBackupReminderDate
+			this.getKeyName(R.string.preferences__last_id_backup_date),
+			lastIDBackupReminderDate
 		);
 	}
 
@@ -442,9 +443,9 @@ public class PreferenceServiceImpl implements PreferenceService {
 	public String getContactListSorting() {
 		String sorting = this.preferenceStore.getString(this.getKeyName(R.string.preferences__contact_sorting));
 
-		if(sorting == null || sorting.length() == 0) {
+		if (sorting == null || sorting.length() == 0) {
 			//set last_name - first_name as default
-			sorting =  this.context.getString(R.string.contact_sorting__last_name);
+			sorting = this.context.getString(R.string.contact_sorting__last_name);
 			this.preferenceStore.save(this.getKeyName(R.string.preferences__contact_sorting), sorting);
 		}
 
@@ -460,9 +461,9 @@ public class PreferenceServiceImpl implements PreferenceService {
 	public String getContactFormat() {
 		String format = this.preferenceStore.getString(this.getKeyName(R.string.preferences__contact_format));
 
-		if(format == null || format.length() == 0) {
+		if (format == null || format.length() == 0) {
 			//set firstname lastname as default
-			format =  this.context.getString(R.string.contact_format__first_name_last_name);
+			format = this.context.getString(R.string.contact_format__first_name_last_name);
 			this.preferenceStore.save(this.getKeyName(R.string.preferences__contact_format), format);
 		}
 
@@ -520,7 +521,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public String[] getList(String listName) {
 		String[] res = this.preferenceStore.getStringArray(listName);
-		if(res == null) {
+		if (res == null) {
 			return new String[0];
 		}
 
@@ -530,8 +531,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setList(String listName, String[] identities) {
 		this.preferenceStore.save(
-				listName,
-				identities
+			listName,
+			identities
 		);
 	}
 
@@ -543,8 +544,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setHashMap(String listName, HashMap<Integer, String> hashMap) {
 		this.preferenceStore.save(
-				listName,
-				hashMap
+			listName,
+			hashMap
 		);
 	}
 
@@ -556,9 +557,9 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setStringHashMap(String listName, HashMap<String, String> hashMap) {
 		this.preferenceStore.saveStringHashMap(
-				listName,
-				hashMap,
-				false
+			listName,
+			hashMap,
+			false
 		);
 	}
 
@@ -571,30 +572,26 @@ public class PreferenceServiceImpl implements PreferenceService {
 		List<String[]> res = new ArrayList<String[]>();
 		Map<String, ?> values = this.preferenceStore.getAllNonCrypted();
 		Iterator<String> i = values.keySet().iterator();
-		while(i.hasNext()) {
+		while (i.hasNext()) {
 			String key = i.next();
 			Object v = values.get(key);
 
 			String value = null;
 			if (v instanceof Boolean) {
 				value = String.valueOf(v);
-			}
-			else if (v instanceof Float) {
+			} else if (v instanceof Float) {
 				value = String.valueOf(v);
-			}
-			else if (v instanceof Integer) {
+			} else if (v instanceof Integer) {
 				value = String.valueOf(v);
-			}
-			else if (v instanceof Long) {
+			} else if (v instanceof Long) {
 				value = String.valueOf(v);
-			}
-			else if (v instanceof String) {
+			} else if (v instanceof String) {
 				value = ((String) v);
 			}
 			res.add(new String[]{
-					key,
-					value,
-					v.getClass().toString()
+				key,
+				value,
+				v.getClass().toString()
 			});
 		}
 		return res;
@@ -603,8 +600,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public boolean read(List<String[]> values) {
 
-		for(String[] v: values) {
-			if(v.length != 3) {
+		for (String[] v : values) {
+			if (v.length != 3) {
 				//invalid row
 				return false;
 			}
@@ -615,17 +612,13 @@ public class PreferenceServiceImpl implements PreferenceService {
 
 			if (valueClass.equals(Boolean.class.toString())) {
 				this.preferenceStore.save(key, Boolean.valueOf(value));
-			}
-			else if (valueClass.equals(Float.class.toString())) {
+			} else if (valueClass.equals(Float.class.toString())) {
 //					this.preferenceStore.save(key, ((Float) v).floatValue());
-			}
-			else if (valueClass.equals(Integer.class.toString())) {
+			} else if (valueClass.equals(Integer.class.toString())) {
 				this.preferenceStore.save(key, Integer.valueOf(value));
-			}
-			else if (valueClass.equals(Long.class.toString())) {
+			} else if (valueClass.equals(Long.class.toString())) {
 				this.preferenceStore.save(key, Long.valueOf(value));
-			}
-			else if (valueClass.equals(String.class.toString())) {
+			} else if (valueClass.equals(String.class.toString())) {
 				this.preferenceStore.save(key, value);
 			}
 		}
@@ -791,17 +784,17 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setAppLogoExpiresAt(Date expiresAt, int theme) {
 		this.preferenceStore.save(this.getKeyName(
-				theme == ConfigUtils.THEME_DARK ?
-						R.string.preferences__app_logo_dark_expires_at :
-						R.string.preferences__app_logo_light_expires_at ), expiresAt);
+			theme == ConfigUtils.THEME_DARK ?
+				R.string.preferences__app_logo_dark_expires_at :
+				R.string.preferences__app_logo_light_expires_at), expiresAt);
 	}
 
 	@Override
 	public Date getAppLogoExpiresAt(int theme) {
 		return this.preferenceStore.getDate(this.getKeyName(
-				theme == ConfigUtils.THEME_DARK ?
-					R.string.preferences__app_logo_dark_expires_at :
-					R.string.preferences__app_logo_light_expires_at));
+			theme == ConfigUtils.THEME_DARK ?
+				R.string.preferences__app_logo_dark_expires_at :
+				R.string.preferences__app_logo_light_expires_at));
 	}
 
 	@Override
@@ -870,7 +863,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getStringHashMap(this.getKeyName(R.string.preferences__message_drafts), true);
 	}
 
-	private @NonNull String getAppLogoKey(@AppTheme int theme) {
+	private @NonNull
+	String getAppLogoKey(@AppTheme int theme) {
 		if (theme == ConfigUtils.THEME_DARK) {
 			return this.getKeyName(R.string.preferences__app_logo_dark_url);
 		}
@@ -936,16 +930,16 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setPushToken(String gcmToken) {
 		this.preferenceStore.save(
-				this.getKeyName(R.string.preferences__gcm_token),
-				gcmToken,
-				true);
+			this.getKeyName(R.string.preferences__gcm_token),
+			gcmToken,
+			true);
 	}
 
 	@Override
 	public String getPushToken() {
 		return this.preferenceStore.getString(
-				this.getKeyName(R.string.preferences__gcm_token),
-				true
+			this.getKeyName(R.string.preferences__gcm_token),
+			true
 		);
 	}
 
@@ -1016,7 +1010,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__receive_profilepics));
 	}
 
-	public @NonNull String getAECMode() {
+	public @NonNull
+	String getAECMode() {
 		String mode = this.preferenceStore.getString(this.getKeyName(R.string.preferences__voip_echocancel));
 		if ("sw".equals(mode)) {
 			return mode;
@@ -1025,7 +1020,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	}
 
 	@Override
-	public @NonNull String getVideoCodec() {
+	public @NonNull
+	String getVideoCodec() {
 		String mode = this.preferenceStore.getString(this.getKeyName(R.string.preferences__voip_video_codec));
 		if (mode != null) {
 			return mode;
@@ -1201,9 +1197,9 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public ThreemaSafeServerInfo getThreemaSafeServerInfo() {
 		return new ThreemaSafeServerInfo(
-				this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_name), true),
-				this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_username), true),
-				this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_password), true)
+			this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_name), true),
+			this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_username), true),
+			this.preferenceStore.getString(this.getKeyName(R.string.preferences__threema_safe_server_password), true)
 		);
 	}
 
@@ -1329,7 +1325,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setWorkDirectoryCategories(List<WorkDirectoryCategory> categories) {
 		JSONArray array = new JSONArray();
-		for(WorkDirectoryCategory category : categories) {
+		for (WorkDirectoryCategory category : categories) {
 			String categoryObjectString = category.toJSON();
 			if (!TestUtil.empty(categoryObjectString)) {
 				try {
@@ -1412,8 +1408,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	public void setDataBackupUri(Uri newUri) {
 		this.preferenceStore.save(
-				this.getKeyName(R.string.preferences__data_backup_uri),
-				newUri != null ? newUri.toString() : null
+			this.getKeyName(R.string.preferences__data_backup_uri),
+			newUri != null ? newUri.toString() : null
 		);
 	}
 
@@ -1524,7 +1520,8 @@ public class PreferenceServiceImpl implements PreferenceService {
 	}
 
 	@Override
-	public @Nullable String getPoiServerHostOverride() {
+	public @Nullable
+	String getPoiServerHostOverride() {
 		// Defined in the developers settings menu
 		final String override = this.preferenceStore.getString(this.getKeyName(R.string.preferences__poi_host));
 		if ("".equals(override)) {
@@ -1545,22 +1542,13 @@ public class PreferenceServiceImpl implements PreferenceService {
 	}
 
 	@Override
-	public boolean getIsImageLabelingTooltipShown() {
-		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__image_labeling_tooltip_shown));
-	}
+	public void setVoiceRecorderBluetoothDisabled(boolean disabled) {
+		this.preferenceStore.save(this.getKeyName(R.string.preferences__voicerecorder_bluetooth_disabled), disabled);
 
-	@Override
-	public void setIsImageLabelingTooltipShown(boolean shown) {
-		this.preferenceStore.save(this.getKeyName(R.string.preferences__image_labeling_tooltip_shown), shown);
-	}
-
-	@Override
-	public boolean getIsImageResolutionTooltipShown() {
-		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__image_resolution_tooltip_shown));
 	}
 
 	@Override
-	public void setIsImageResolutionTooltipShown(boolean shown) {
-		this.preferenceStore.save(this.getKeyName(R.string.preferences__image_resolution_tooltip_shown), shown);
+	public boolean getVoiceRecorderBluetoothDisabled() {
+		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__voicerecorder_bluetooth_disabled));
 	}
 }

+ 114 - 0
app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion64.java

@@ -0,0 +1,114 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.services.systemupdate;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.AsyncTask;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.sql.SQLException;
+
+import androidx.core.app.NotificationManagerCompat;
+import androidx.work.WorkManager;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.services.UpdateSystemService;
+
+/* clean up image labeler */
+
+public class SystemUpdateToVersion64 extends UpdateToVersion implements UpdateSystemService.SystemUpdate {
+	private static final Logger logger = LoggerFactory.getLogger(SystemUpdateToVersion64.class);
+	private Context context;
+
+	public SystemUpdateToVersion64(Context context) {
+		this.context = context;
+	}
+
+	@Override
+	public boolean runDirectly() throws SQLException {
+		return true;
+	}
+
+	@Override
+	public boolean runASync() {
+		deleteMediaLabelsDatabase();
+
+		return true;
+	}
+
+	@SuppressLint("StaticFieldLeak")
+	private void deleteMediaLabelsDatabase() {
+		logger.debug("deleteMediaLabelsDatabase");
+
+		new AsyncTask<Void, Void, Exception>() {
+			@Override
+			protected void onPreExecute() {
+				WorkManager.getInstance(ThreemaApplication.getAppContext()).cancelAllWorkByTag("ImageLabelsPeriodic");
+				WorkManager.getInstance(ThreemaApplication.getAppContext()).cancelAllWorkByTag("ImageLabelsOneTime");
+			}
+
+			@Override
+			protected Exception doInBackground(Void... voids) {
+				try {
+					final String[] files = new String[] {
+						"media_items.db",
+						"media_items.db-shm",
+						"media_items.db-wal",
+					};
+					for (String filename : files) {
+						final File databasePath = context.getDatabasePath(filename);
+						if (databasePath.exists() && databasePath.isFile()) {
+							logger.info("Removing file {}", filename);
+							if (!databasePath.delete()) {
+								logger.warn("Could not remove file {}", filename);
+							}
+						} else {
+							logger.debug("File {} not found", filename);
+						}
+					}
+				} catch (Exception e) {
+					logger.error("Exception while deleting media labels database");
+					return e;
+				}
+				return null;
+			}
+
+			@Override
+			protected void onPostExecute(Exception e) {
+				// remove notification channel
+				String NOTIFICATION_CHANNEL_IMAGE_LABELING =  "il";
+				NotificationManagerCompat notificationManagerCompat = NotificationManagerCompat.from(context);
+				if (notificationManagerCompat != null) {
+					notificationManagerCompat.deleteNotificationChannel(NOTIFICATION_CHANNEL_IMAGE_LABELING);
+				}
+			}
+		}.execute();
+	}
+
+	@Override
+	public String getText() {
+		return "delete media labels database";
+	}
+}

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

@@ -243,7 +243,7 @@ public class ThreemaSafeAdvancedDialog extends ThreemaDialogFragment implements
 				DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_PROGRESS, true);
 
 				if (failureMessage != null) {
-					Toast.makeText(getActivity(), getString(R.string.test_unsuccessful) + ": " + failureMessage, Toast.LENGTH_SHORT).show();
+					Toast.makeText(getActivity(), getString(R.string.test_unsuccessful) + ": " + failureMessage, Toast.LENGTH_LONG).show();
 				} else {
 					onYes();
 				}

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

@@ -75,6 +75,7 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.InvalidEntryException;
+import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.DistributionListService;
@@ -92,7 +93,6 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.VerificationLevel;
 import ch.threema.client.APIConnector;
@@ -339,6 +339,8 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			throw new ThreemaException("IO Exception: " + e.getMessage());
 		} catch (JSONException e) {
 			throw new ThreemaException("Malformed server response");
+		} catch (IllegalArgumentException e) {
+			throw new ThreemaException(e.getMessage());
 		} finally {
 			urlConnection.disconnect();
 		}
@@ -576,6 +578,8 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			}
 		} catch (IOException e) {
 			throw new ThreemaException("IO Exception");
+		} catch (IllegalArgumentException e) {
+			throw new ThreemaException(e.getMessage());
 		} finally {
 			urlConnection.disconnect();
 		}
@@ -630,6 +634,8 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 					throw new ThreemaException("Server error: " + responseCode);
 				}
 			}
+		} catch (IllegalArgumentException e) {
+			throw new ThreemaException(e.getMessage());
 		} finally {
 			urlConnection.disconnect();
 		}
@@ -1149,6 +1155,8 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			}
 		} catch (IOException e) {
 			throw new ThreemaException("HTTPS IO Exception: " + e.getMessage());
+		} catch (IllegalArgumentException e) {
+			throw new ThreemaException(e.getMessage());
 		} finally {
 			urlConnection.disconnect();
 		}

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

@@ -78,7 +78,6 @@ import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.MimeUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
@@ -109,10 +108,11 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 
 	// the type of avatar
 	@Retention(RetentionPolicy.SOURCE)
-	@IntDef({AVATAR_TYPE_CONTACT, AVATAR_TYPE_GROUP})
+	@IntDef({AVATAR_TYPE_CONTACT, AVATAR_TYPE_GROUP, AVATAR_TYPE_NOTES})
 	public @interface AvatarTypeDef {}
 	public static final int AVATAR_TYPE_CONTACT = 0;
 	public static final int AVATAR_TYPE_GROUP = 1;
+	public static final int AVATAR_TYPE_NOTES = 2;
 
 	public AvatarEditView(@NonNull Context context) {
 		super(context);
@@ -251,7 +251,7 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 						}
 						break;
 					case R.id.menu_select_from_gallery:
-						FileUtil.selectFile(getActivity(), getFragment(), new String[]{MimeUtil.MIME_TYPE_IMAGE}, REQUEST_CODE_FILE_SELECTOR_ID, false, 0, null);
+						FileUtil.selectFromGallery(getActivity(), getFragment(), REQUEST_CODE_FILE_SELECTOR_ID, false);
 						break;
 					case R.id.menu_remove_picture:
 						removeAvatar();

+ 8 - 0
app/src/main/java/ch/threema/app/ui/CountBoxView.java

@@ -93,4 +93,12 @@ public class CountBoxView extends androidx.appcompat.widget.AppCompatTextView {
 		this.setBackgroundResource(backgroundRes);
 	}
 
+	@Override
+	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+		if (getMeasuredWidth() < getMeasuredHeight()) {
+			setMeasuredDimension(getMeasuredHeight(), getMeasuredHeight());
+		}
+	}
 }

+ 0 - 4
app/src/main/java/ch/threema/app/ui/GridRecyclerView.java

@@ -27,12 +27,8 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.view.animation.GridLayoutAnimationController;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.mediaattacher.MediaAttachActivity;
 
 public class GridRecyclerView extends RecyclerView {
 

+ 0 - 5
app/src/main/java/ch/threema/app/ui/ImagePopup.java

@@ -21,7 +21,6 @@
 
 package ch.threema.app.ui;
 
-import android.content.ContentResolver;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Matrix;
@@ -50,12 +49,10 @@ import ch.threema.app.utils.AnimationUtil;
 public class ImagePopup extends DimmingPopupWindow {
 	private static final Logger logger = LoggerFactory.getLogger(ImagePopup.class);
 
-	private Context context;
 	private ImageView imageView;
 	private TextView filenameTextView, dateTextView;
 	private View topLayout;
 	private View parentView;
-	private ContentResolver contentResolver;
 
 	final int[] location = new int[2];
 
@@ -76,9 +73,7 @@ public class ImagePopup extends DimmingPopupWindow {
 	}
 
 	private void init(Context context, View parentView, int screenWidth, int screenHeight, int innerBorder, @LayoutRes int layout) {
-		this.context = context;
 		this.parentView = parentView;
-		this.contentResolver = context.getContentResolver();
 
 		LayoutInflater layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 		if (layout == 0) {

+ 2 - 15
app/src/main/java/ch/threema/app/ui/MediaGridItemDecoration.java

@@ -21,38 +21,25 @@
 
 package ch.threema.app.ui;
 
-import android.app.Activity;
-import android.content.Context;
 import android.graphics.Rect;
 import android.view.View;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Arrays;
-
-import androidx.core.content.ContextCompat;
 import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.mediaattacher.MediaAttachActivity;
 
 public class MediaGridItemDecoration extends RecyclerView.ItemDecoration {
 	private int space;
-	private int columns;
 
-	public MediaGridItemDecoration(int space, int columns) {
+	public MediaGridItemDecoration(int space) {
 		this.space = space;
-		this.columns = columns;
 	}
 
 	@Override
 	public void getItemOffsets(Rect outRect, View view,
 	                           RecyclerView parent, RecyclerView.State state) {
+
 		outRect.left = space/2;
 		outRect.right = space/2;
 		outRect.bottom = space;
-
 		// Add top margin only for the first item to avoid double space between items
 		outRect.top = 0;
 	}

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

@@ -154,7 +154,6 @@ public class MentionSelectorPopup extends PopupWindow implements MentionSelector
 		linearLayoutManager.setStackFromEnd(true);
 
 		this.recyclerView.setLayoutManager(linearLayoutManager);
-		this.recyclerView.setHasFixedSize(true);
 		this.recyclerView.setItemAnimator(null);
 
 		this.filterText = "";

+ 9 - 0
app/src/main/java/ch/threema/app/ui/ThreemaEditText.java

@@ -77,4 +77,13 @@ public class ThreemaEditText extends TextInputEditText {
 		// disable Autofill in EditText due to privacy and TransactionTooLargeException as well as bug https://issuetracker.google.com/issues/67675432
 		return AUTOFILL_TYPE_NONE;
 	}
+
+	@Override
+	public void dispatchWindowFocusChanged(boolean hasFocus) {
+		try {
+			super.dispatchWindowFocusChanged(hasFocus);
+		} catch (Exception ignore) {
+			// catch Security Exception in com.samsung.android.content.clipboard.SemClipboardManager.getLatestClip() on Samsung devices
+		}
+	}
 }

+ 164 - 76
app/src/main/java/ch/threema/app/ui/ZoomableExoPlayerView.java

@@ -22,7 +22,6 @@
 package ch.threema.app.ui;
 
 import android.annotation.SuppressLint;
-import android.annotation.TargetApi;
 import android.content.Context;
 import android.content.res.Resources;
 import android.content.res.TypedArray;
@@ -52,6 +51,7 @@ import com.google.android.exoplayer2.ExoPlaybackException;
 import com.google.android.exoplayer2.PlaybackPreparer;
 import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.Player.DiscontinuityReason;
+import com.google.android.exoplayer2.Timeline;
 import com.google.android.exoplayer2.metadata.Metadata;
 import com.google.android.exoplayer2.metadata.flac.PictureFrame;
 import com.google.android.exoplayer2.metadata.id3.ApicFrame;
@@ -59,13 +59,11 @@ import com.google.android.exoplayer2.source.TrackGroupArray;
 import com.google.android.exoplayer2.source.ads.AdsLoader;
 import com.google.android.exoplayer2.text.Cue;
 import com.google.android.exoplayer2.text.TextOutput;
-import com.google.android.exoplayer2.trackselection.TrackSelection;
 import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
 import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
 import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
 import com.google.android.exoplayer2.ui.DefaultTimeBar;
 import com.google.android.exoplayer2.ui.PlayerControlView;
-import com.google.android.exoplayer2.ui.PlayerView;
 import com.google.android.exoplayer2.ui.SubtitleView;
 import com.google.android.exoplayer2.ui.spherical.SingleTapListener;
 import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView;
@@ -75,6 +73,7 @@ import com.google.android.exoplayer2.util.RepeatModeUtil;
 import com.google.android.exoplayer2.util.Util;
 import com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView;
 import com.google.android.exoplayer2.video.VideoListener;
+import com.google.common.collect.ImmutableList;
 
 import java.lang.annotation.Documented;
 import java.lang.annotation.Retention;
@@ -84,6 +83,7 @@ import java.util.List;
 
 import androidx.annotation.IntDef;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
 import androidx.core.content.ContextCompat;
 import ch.threema.app.R;
 
@@ -94,7 +94,8 @@ import ch.threema.app.R;
  * during playback, and displays playback controls using a {@link PlayerControlView}.
  *
  * <p>A PlayerView can be customized by setting attributes (or calling corresponding methods),
- * overriding the view's layout file or by specifying a custom view layout file, as outlined below.
+ * overriding drawables, overriding the view's layout file, or by specifying a custom view layout
+ * file.
  *
  * <h3>Attributes</h3>
  *
@@ -142,7 +143,8 @@ import ch.threema.app.R;
  *         <li>Default: {@code never}
  *       </ul>
  *   <li><b>{@code resize_mode}</b> - Controls how video and album art is resized within the view.
- *       Valid values are {@code fit}, {@code fixed_width}, {@code fixed_height} and {@code fill}.
+ *       Valid values are {@code fit}, {@code fixed_width}, {@code fixed_height}, {@code fill} and
+ *       {@code zoom}.
  *       <ul>
  *         <li>Corresponding method: {@link #setResizeMode(int)}
  *         <li>Default: {@code fit}
@@ -157,6 +159,12 @@ import ch.threema.app.R;
  *         <li>Corresponding method: None
  *         <li>Default: {@code surface_view}
  *       </ul>
+ *   <li><b>{@code use_sensor_rotation}</b> - Whether to use the orientation sensor for rotation
+ *       during spherical playbacks (if available).
+ *       <ul>
+ *         <li>Corresponding method: {@link #setUseSensorRotation(boolean)}
+ *         <li>Default: {@code true}
+ *       </ul>
  *   <li><b>{@code shutter_background_color}</b> - The background color of the {@code exo_shutter}
  *       view.
  *       <ul>
@@ -187,6 +195,12 @@ import ch.threema.app.R;
  *       exo_controller} (see below).
  * </ul>
  *
+ * <h3>Overriding drawables</h3>
+ *
+ * The drawables used by {@link PlayerControlView} (with its default layout file) can be overridden
+ * by drawables with the same names defined in your application. See the {@link PlayerControlView}
+ * documentation for a list of drawables that can be overridden.
+ *
  * <h3>Overriding the layout file</h3>
  *
  * To customize the layout of PlayerView throughout your app, or just for certain configurations,
@@ -195,8 +209,6 @@ import ch.threema.app.R;
  * inflated for use by PlayerView. The view identifies and binds its children by looking for the
  * following ids:
  *
- * <p>
- *
  * <ul>
  *   <li><b>{@code exo_content_frame}</b> - A frame whose aspect ratio is resized based on the video
  *       or album art of the media being played, and the configured {@code resize_mode}. The video
@@ -314,9 +326,10 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	@Nullable private PlayerControlView.VisibilityListener controllerVisibilityListener;
 	private boolean useArtwork;
 	@Nullable private Drawable defaultArtwork;
-	private @com.google.android.exoplayer2.ui.PlayerView.ShowBuffering
+	private @ShowBuffering
 	int showBuffering;
 	private boolean keepContentOnPlayerReset;
+	private boolean useSensorRotation;
 	@Nullable private ErrorMessageProvider<? super ExoPlaybackException> errorMessageProvider;
 	@Nullable private CharSequence customErrorMessage;
 	private int controllerShowTimeoutMs;
@@ -376,6 +389,7 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 		boolean controllerAutoShow = true;
 		boolean controllerHideDuringAds = true;
 		int showBuffering = SHOW_BUFFERING_NEVER;
+		useSensorRotation = true;
 		if (attrs != null) {
 			TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.PlayerView, 0, 0);
 			try {
@@ -399,6 +413,8 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 						R.styleable.PlayerView_keep_content_on_player_reset, keepContentOnPlayerReset);
 				controllerHideDuringAds =
 					a.getBoolean(R.styleable.PlayerView_hide_during_ads, controllerHideDuringAds);
+				useSensorRotation =
+					a.getBoolean(R.styleable.PlayerView_use_sensor_rotation, useSensorRotation);
 			} finally {
 				a.recycle();
 			}
@@ -433,6 +449,7 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 				case SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW:
 					SphericalGLSurfaceView sphericalGLSurfaceView = new SphericalGLSurfaceView(context);
 					sphericalGLSurfaceView.setSingleTapListener(componentListener);
+					sphericalGLSurfaceView.setUseSensorRotation(useSensorRotation);
 					surfaceView = sphericalGLSurfaceView;
 					break;
 				case SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW:
@@ -571,8 +588,6 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 					oldVideoComponent.clearVideoTextureView((TextureView) surfaceView);
 				} else if (surfaceView instanceof SphericalGLSurfaceView) {
 					((SphericalGLSurfaceView) surfaceView).setVideoComponent(null);
-				} else if (surfaceView instanceof VideoDecoderGLSurfaceView) {
-					oldVideoComponent.setVideoDecoderOutputBufferRenderer(null);
 				} else if (surfaceView instanceof SurfaceView) {
 					oldVideoComponent.clearVideoSurfaceView((SurfaceView) surfaceView);
 				}
@@ -582,13 +597,13 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 				oldTextComponent.removeTextOutput(componentListener);
 			}
 		}
+		if (subtitleView != null) {
+			subtitleView.setCues(null);
+		}
 		this.player = player;
 		if (useController()) {
 			controller.setPlayer(player);
 		}
-		if (subtitleView != null) {
-			subtitleView.setCues(null);
-		}
 		updateBuffering();
 		updateErrorMessage();
 		updateForCurrentTrackSelections(/* isNewPlayer= */ true);
@@ -599,9 +614,6 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 					newVideoComponent.setVideoTextureView((TextureView) surfaceView);
 				} else if (surfaceView instanceof SphericalGLSurfaceView) {
 					((SphericalGLSurfaceView) surfaceView).setVideoComponent(newVideoComponent);
-				} else if (surfaceView instanceof VideoDecoderGLSurfaceView) {
-					newVideoComponent.setVideoDecoderOutputBufferRenderer(
-						((VideoDecoderGLSurfaceView) surfaceView).getVideoDecoderOutputBufferRenderer());
 				} else if (surfaceView instanceof SurfaceView) {
 					newVideoComponent.setVideoSurfaceView((SurfaceView) surfaceView);
 				}
@@ -610,6 +622,9 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 			@Nullable Player.TextComponent newTextComponent = player.getTextComponent();
 			if (newTextComponent != null) {
 				newTextComponent.addTextOutput(componentListener);
+				if (subtitleView != null) {
+					subtitleView.setCues(newTextComponent.getCurrentCues());
+				}
 			}
 			player.addListener(componentListener);
 			maybeShowController(false);
@@ -667,19 +682,6 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 		return defaultArtwork;
 	}
 
-	/**
-	 * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is
-	 * present in the media.
-	 *
-	 * @param defaultArtwork the default artwork to display.
-	 * @deprecated use (@link {@link #setDefaultArtwork(Drawable)} instead.
-	 */
-	@Deprecated
-	public void setDefaultArtwork(@Nullable Bitmap defaultArtwork) {
-		setDefaultArtwork(
-			defaultArtwork == null ? null : new BitmapDrawable(getResources(), defaultArtwork));
-	}
-
 	/**
 	 * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is
 	 * present in the media.
@@ -733,9 +735,8 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	/**
 	 * Sets whether the currently displayed video frame or media artwork is kept visible when the
 	 * player is reset. A player reset is defined to mean the player being re-prepared with different
-	 * media, the player transitioning to unprepared media, {@link Player#stop(boolean)} being called
-	 * with {@code reset=true}, or the player being replaced or cleared by calling {@link
-	 * #setPlayer(Player)}.
+	 * media, the player transitioning to unprepared media or an empty list of media items, or the
+	 * player being replaced or cleared by calling {@link #setPlayer(Player)}.
 	 *
 	 * <p>If enabled, the currently displayed video frame or media artwork will be kept visible until
 	 * the player set on the view has been successfully prepared with new media and loaded enough of
@@ -758,15 +759,19 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	}
 
 	/**
-	 * Sets whether a buffering spinner is displayed when the player is in the buffering state. The
-	 * buffering spinner is not displayed by default.
+	 * Sets whether to use the orientation sensor for rotation during spherical playbacks (if
+	 * available)
 	 *
-	 * @deprecated Use {@link #setShowBuffering(int)}
-	 * @param showBuffering Whether the buffering icon is displayed
+	 * @param useSensorRotation Whether to use the orientation sensor for rotation during spherical
+	 *     playbacks.
 	 */
-	@Deprecated
-	public void setShowBuffering(boolean showBuffering) {
-		setShowBuffering(showBuffering ? PlayerView.SHOW_BUFFERING_WHEN_PLAYING : SHOW_BUFFERING_NEVER);
+	public void setUseSensorRotation(boolean useSensorRotation) {
+		if (this.useSensorRotation != useSensorRotation) {
+			this.useSensorRotation = useSensorRotation;
+			if (surfaceView instanceof SphericalGLSurfaceView) {
+				((SphericalGLSurfaceView) surfaceView).setUseSensorRotation(useSensorRotation);
+			}
+		}
 	}
 
 	/**
@@ -777,7 +782,7 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	 *     {@link #SHOW_BUFFERING_NEVER}, {@link #SHOW_BUFFERING_WHEN_PLAYING} and {@link
 	 *     #SHOW_BUFFERING_ALWAYS}.
 	 */
-	public void setShowBuffering(@com.google.android.exoplayer2.ui.PlayerView.ShowBuffering int showBuffering) {
+	public void setShowBuffering(@ShowBuffering int showBuffering) {
 		if (this.showBuffering != showBuffering) {
 			this.showBuffering = showBuffering;
 			updateBuffering();
@@ -963,11 +968,15 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	}
 
 	/**
-	 * Sets the {@link PlaybackPreparer}.
-	 *
-	 * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback
-	 *     preparer.
+	 * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The view calls {@link
+	 *     ControlDispatcher#dispatchPrepare(Player)} instead of {@link
+	 *     PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the view
+	 *     uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour,
+	 *     you can provide a custom implementation of {@link
+	 *     ControlDispatcher#dispatchPrepare(Player)}.
 	 */
+	@SuppressWarnings("deprecation")
+	@Deprecated
 	public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) {
 		Assertions.checkStateNotNull(controller);
 		controller.setPlaybackPreparer(playbackPreparer);
@@ -976,31 +985,70 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	/**
 	 * Sets the {@link ControlDispatcher}.
 	 *
-	 * @param controlDispatcher The {@link ControlDispatcher}, or null to use {@link
-	 *     DefaultControlDispatcher}.
+	 * @param controlDispatcher The {@link ControlDispatcher}.
 	 */
-	public void setControlDispatcher(@Nullable ControlDispatcher controlDispatcher) {
+	public void setControlDispatcher(ControlDispatcher controlDispatcher) {
 		Assertions.checkStateNotNull(controller);
 		controller.setControlDispatcher(controlDispatcher);
 	}
 
 	/**
-	 * Sets the rewind increment in milliseconds.
+	 * Sets whether the rewind button is shown.
 	 *
-	 * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the
-	 *     rewind button to be disabled.
+	 * @param showRewindButton Whether the rewind button is shown.
 	 */
+	public void setShowRewindButton(boolean showRewindButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowRewindButton(showRewindButton);
+	}
+
+	/**
+	 * Sets whether the fast forward button is shown.
+	 *
+	 * @param showFastForwardButton Whether the fast forward button is shown.
+	 */
+	public void setShowFastForwardButton(boolean showFastForwardButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowFastForwardButton(showFastForwardButton);
+	}
+
+	/**
+	 * Sets whether the previous button is shown.
+	 *
+	 * @param showPreviousButton Whether the previous button is shown.
+	 */
+	public void setShowPreviousButton(boolean showPreviousButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowPreviousButton(showPreviousButton);
+	}
+
+	/**
+	 * Sets whether the next button is shown.
+	 *
+	 * @param showNextButton Whether the next button is shown.
+	 */
+	public void setShowNextButton(boolean showNextButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowNextButton(showNextButton);
+	}
+
+	/**
+	 * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link
+	 *     DefaultControlDispatcher#DefaultControlDispatcher(long, long)}.
+	 */
+	@SuppressWarnings("deprecation")
+	@Deprecated
 	public void setRewindIncrementMs(int rewindMs) {
 		Assertions.checkStateNotNull(controller);
 		controller.setRewindIncrementMs(rewindMs);
 	}
 
 	/**
-	 * Sets the fast forward increment in milliseconds.
-	 *
-	 * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will
-	 *     cause the fast forward button to be disabled.
+	 * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link
+	 *     DefaultControlDispatcher#DefaultControlDispatcher(long, long)}.
 	 */
+	@SuppressWarnings("deprecation")
+	@Deprecated
 	public void setFastForwardIncrementMs(int fastForwardMs) {
 		Assertions.checkStateNotNull(controller);
 		controller.setFastForwardIncrementMs(fastForwardMs);
@@ -1200,15 +1248,20 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 	}
 
 	@Override
-	public View[] getAdOverlayViews() {
-		ArrayList<View> overlayViews = new ArrayList<>();
+	public List<AdsLoader.OverlayInfo> getAdOverlayInfos() {
+		List<AdsLoader.OverlayInfo> overlayViews = new ArrayList<>();
 		if (overlayFrameLayout != null) {
-			overlayViews.add(overlayFrameLayout);
+			overlayViews.add(
+				new AdsLoader.OverlayInfo(
+					overlayFrameLayout,
+					AdsLoader.OverlayInfo.PURPOSE_NOT_VISIBLE,
+					/* detailedReason= */ "Transparent overlay does not impact viewability"));
 		}
 		if (controller != null) {
-			overlayViews.add(controller);
+			overlayViews.add(
+				new AdsLoader.OverlayInfo(controller, AdsLoader.OverlayInfo.PURPOSE_CONTROLS));
 		}
-		return overlayViews.toArray(new View[0]);
+		return ImmutableList.copyOf(overlayViews);
 	}
 
 	// Internal methods.
@@ -1307,15 +1360,9 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 		closeShutter();
 		// Display artwork if enabled and available, else hide it.
 		if (useArtwork()) {
-			for (int i = 0; i < selections.length; i++) {
-				@Nullable TrackSelection selection = selections.get(i);
-				if (selection != null) {
-					for (int j = 0; j < selection.length(); j++) {
-						@Nullable Metadata metadata = selection.getFormat(j).metadata;
-						if (metadata != null && setArtworkFromMetadata(metadata)) {
-							return;
-						}
-					}
+			for (Metadata metadata : player.getCurrentStaticMetadata()) {
+				if (setArtworkFromMetadata(metadata)) {
+					return;
 				}
 			}
 			if (setDrawableArtwork(defaultArtwork)) {
@@ -1401,7 +1448,7 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 				errorMessageView.setVisibility(View.VISIBLE);
 				return;
 			}
-			@Nullable ExoPlaybackException error = player != null ? player.getPlaybackError() : null;
+			@Nullable ExoPlaybackException error = player != null ? player.getPlayerError() : null;
 			if (error != null && errorMessageProvider != null) {
 				CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second;
 				errorMessageView.setText(errorMessage);
@@ -1426,7 +1473,15 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 		}
 	}
 
-	@TargetApi(23)
+	private void updateControllerVisibility() {
+		if (isPlayingAd() && controllerHideDuringAds) {
+			hideController();
+		} else {
+			maybeShowController(false);
+		}
+	}
+
+	@RequiresApi(23)
 	private static void configureEditModeLogoV23(Resources resources, ImageView logo) {
 		logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo, null));
 		logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color, null));
@@ -1486,6 +1541,13 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 		SingleTapListener,
 		PlayerControlView.VisibilityListener {
 
+		private final Timeline.Period period;
+		private @Nullable Object lastPeriodUidWithTracks;
+
+		public ComponentListener() {
+			period = new Timeline.Period();
+		}
+
 		// TextOutput implementation
 
 		@Override
@@ -1534,20 +1596,46 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdsLoader.AdVi
 
 		@Override
 		public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) {
+			// Suppress the update if transitioning to an unprepared period within the same window. This
+			// is necessary to avoid closing the shutter when such a transition occurs. See:
+			// https://github.com/google/ExoPlayer/issues/5507.
+			Player player = Assertions.checkNotNull(ZoomableExoPlayerView.this.player);
+			Timeline timeline = player.getCurrentTimeline();
+			if (timeline.isEmpty()) {
+				lastPeriodUidWithTracks = null;
+			} else if (!player.getCurrentTrackGroups().isEmpty()) {
+				lastPeriodUidWithTracks =
+					timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid;
+			} else if (lastPeriodUidWithTracks != null) {
+				int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks);
+				if (lastPeriodIndexWithTracks != C.INDEX_UNSET) {
+					int lastWindowIndexWithTracks =
+						timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex;
+					if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) {
+						// We're in the same window. Suppress the update.
+						return;
+					}
+				}
+				lastPeriodUidWithTracks = null;
+			}
+
 			updateForCurrentTrackSelections(/* isNewPlayer= */ false);
 		}
 
 		// Player.EventListener implementation
 
 		@Override
-		public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+		public void onPlaybackStateChanged(@Player.State int playbackState) {
 			updateBuffering();
 			updateErrorMessage();
-			if (isPlayingAd() && controllerHideDuringAds) {
-				hideController();
-			} else {
-				maybeShowController(false);
-			}
+			updateControllerVisibility();
+		}
+
+		@Override
+		public void onPlayWhenReadyChanged(
+			boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
+			updateBuffering();
+			updateControllerVisibility();
 		}
 
 		@Override

+ 14 - 9
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -209,7 +209,7 @@ public class ConfigUtils {
 	}
 
 	public static boolean supportsVideoCapture() {
-		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInternalCameraSupported();
+		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isInternalCameraSupported();
 	}
 
 	public static boolean supportsPictureInPicture(Context context) {
@@ -693,14 +693,19 @@ public class ConfigUtils {
 
 			DisplayMetrics dm = res.getDisplayMetrics();
 			android.content.res.Configuration conf = res.getConfiguration();
-			if ("pt".equals(confLanguage)) {
-				conf.locale = new Locale(confLanguage, "BR");
-			}
-			else if ("zh".equals(confLanguage)) {
-				conf.locale = new Locale(confLanguage, "CN");
-			}
-			else {
-				conf.locale = new Locale(confLanguage);
+			switch (confLanguage) {
+				case "pt":
+					conf.locale = new Locale(confLanguage, "BR");
+					break;
+				case "zh-rCN":
+					conf.locale = new Locale("zh", "CN");
+					break;
+				case "zh-rTW":
+					conf.locale = new Locale("zh", "TW");
+					break;
+				default:
+					conf.locale = new Locale(confLanguage);
+					break;
 			}
 			res.updateConfiguration(conf, dm);
 		} catch (Exception e) {

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

@@ -69,6 +69,7 @@ import ch.threema.app.ui.MediaItem;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 
+import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
 import static ch.threema.app.filepicker.FilePickerActivity.INTENT_DATA_DEFAULT_PATH;
 
 public class FileUtil {
@@ -346,6 +347,7 @@ public class FileUtil {
 		return inUri;
 	}
 
+	@Nullable
 	public static String getRealPathFromURI(final Context context, final Uri uri) {
 		// DocumentProvider
 		if (DocumentsContract.isDocumentUri(context, uri)) {
@@ -404,9 +406,9 @@ public class FileUtil {
 			// MediaStore (and general)
 		} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
 			// Return the remote address
-			if (isGooglePhotosUri(uri))
+			if (isGooglePhotosUri(uri)) {
 				return uri.getLastPathSegment();
-
+			}
 			return getDataColumn(context, uri, null, null);
 		}
 		// File
@@ -432,6 +434,7 @@ public class FileUtil {
 		return "com.google.android.apps.photos.content".equals(uri.getAuthority());
 	}
 
+	@Nullable
 	private static String getDataColumn(Context context, Uri uri, String selection,
 								 String[] selectionArgs) {
 
@@ -679,7 +682,7 @@ public class FileUtil {
 				if (cursor != null && cursor.moveToNext()) {
 					filename = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME));
 				}
-			} catch (IllegalStateException | SecurityException e) {
+			} catch (Exception e) {
 				logger.error("Unable to query Content Resolver", e);
 			}
 		}
@@ -693,10 +696,50 @@ public class FileUtil {
 	 * @return file uri, if a file path could be resolved
 	 */
 	public static Uri getFileUri(Uri uri) {
-		File file = new File(FileUtil.getRealPathFromURI(ThreemaApplication.getAppContext(), uri));
-		if (file.canRead()) {
-			return Uri.fromFile(file);
+		String path = FileUtil.getRealPathFromURI(ThreemaApplication.getAppContext(), uri);
+
+		if (path != null) {
+			File file = new File(path);
+			if (file.canRead()) {
+				return Uri.fromFile(file);
+			}
 		}
 		return uri;
 	}
+
+	/**
+	 * Select a file from a gallery app. Shows a selector first to allow for choosing the desired gallery app or SystemUIs file picker.
+	 * Does not necessarily need file permissions as a modern gallery app will return a content Uri with a temporary permission to access the file
+ 	 * @param activity Activity where the result of the selection should end up
+	 * @param fragment Fragment where the result of the selection should end up
+	 * @param requestCode Request code to use for result
+	 * @param includeVideo Whether to include the possibility to select video files (if supported by app)
+	 */
+	public static void selectFromGallery(@Nullable Activity activity, @Nullable Fragment fragment, int requestCode, boolean includeVideo) {
+		if (activity == null) {
+			activity = fragment.getActivity();
+		}
+
+		try {
+			Intent getContentIntent = new Intent();
+			getContentIntent.setType(includeVideo ? MimeUtil.MIME_TYPE_VIDEO: MimeUtil.MIME_TYPE_IMAGE);
+			getContentIntent.setAction(Intent.ACTION_GET_CONTENT);
+			getContentIntent.addCategory(Intent.CATEGORY_OPENABLE);
+			getContentIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			getContentIntent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, MAX_BLOB_SIZE);
+			Intent pickIntent = new Intent(Intent.ACTION_PICK);
+			pickIntent.setType(MimeUtil.MIME_TYPE_IMAGE);
+			Intent chooserIntent = Intent.createChooser(pickIntent, activity.getString(R.string.select_from_gallery));
+			chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
+
+			if (fragment != null) {
+				fragment.startActivityForResult(chooserIntent, requestCode);
+			} else {
+				activity.startActivityForResult(chooserIntent, requestCode);
+			}
+		} catch (Exception e) {
+			logger.debug("Exception", e);
+			Toast.makeText(activity, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show();
+		}
+	}
 }

+ 23 - 0
app/src/main/java/ch/threema/app/utils/IntentDataUtil.java

@@ -40,6 +40,7 @@ import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.backuprestore.BackupRestoreDataService;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.mediaattacher.MediaFilterQuery;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
@@ -548,4 +549,26 @@ public class IntentDataUtil {
 
 		return intent;
 	}
+
+	public static MediaFilterQuery getLastMediaFilterFromIntent(Intent intent) {
+		if (intent.getStringExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY) != null) {
+			return new MediaFilterQuery(intent.getStringExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY),
+				intent.getIntExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_TYPE_QUERY, -1));
+		}
+		return null;
+	}
+
+	public static Intent addLastMediaFilterToIntent(Intent intent, MediaFilterQuery mediaFilterQuery) {
+		intent.putExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY, mediaFilterQuery.getQuery());
+		intent.putExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_TYPE_QUERY, mediaFilterQuery.getType());
+		return intent;
+	}
+
+	public static Intent addLastMediaFilterToIntent(Intent intent, String query, int type) {
+		intent.putExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_SEARCH_QUERY, query);
+		intent.putExtra(ComposeMessageFragment.EXTRA_LAST_MEDIA_TYPE_QUERY, type);
+		return intent;
+	}
+
 }
+

+ 35 - 17
app/src/main/java/ch/threema/app/utils/PowermanagerUtil.java

@@ -26,22 +26,30 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
-import androidx.fragment.app.Fragment;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.List;
 
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
 import ch.threema.app.activities.DisableBatteryOptimizationsActivity;
 
 public class PowermanagerUtil {
+	private static final Logger logger = LoggerFactory.getLogger(PowermanagerUtil.class);
+
 	// https://stackoverflow.com/questions/48166206/how-to-start-power-manager-of-all-android-manufactures-to-enable-push-notificati/48166241
 	// https://stackoverflow.com/questions/31638986/protected-apps-setting-on-huawei-phones-and-how-to-handle-it
 
 	private static final Intent[] POWERMANAGER_INTENTS = {
 			new Intent().setComponent(new ComponentName("com.samsung.android.lool", "com.samsung.android.sm.ui.battery.BatteryActivity")),
+			new Intent().setComponent(new ComponentName("com.samsung.android.lool", "com.samsung.android.sm.battery.ui.BatteryActivity")),
 			new Intent().setComponent(new ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity")),
 			new Intent().setComponent(new ComponentName("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity")),
 			new Intent().setComponent(new ComponentName("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager")),
 			new Intent().setComponent(new ComponentName("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity")),
+			new Intent().setComponent(new ComponentName("com.htc.pitroad", "com.htc.pitroad.landingpage.activity.LandingPageActivity")),
 			new Intent("miui.intent.action.POWER_HIDE_MODE_APP_LIST").addCategory(Intent.CATEGORY_DEFAULT)
 	};
 
@@ -51,27 +59,21 @@ public class PowermanagerUtil {
 			new Intent().setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity")),
 			new Intent().setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity")),
 			new Intent().setComponent(new ComponentName("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity")),
+			new Intent().setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.privacypermissionsentry.PermissionTopActivity")),
 			new Intent().setComponent(new ComponentName("com.asus.mobilemanager", "com.asus.mobilemanager.MainActivity")),
+			new Intent().setComponent(new ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity")),
+			new Intent().setComponent(new ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity")),
+			new Intent().setComponent(new ComponentName("com.transsion.phonemanager", "com.itel.autobootmanager.activity.AutoBootMgrActivity")),
 			new Intent("miui.intent.action.OP_AUTO_START").addCategory(Intent.CATEGORY_DEFAULT),
 	};
 
 	public static final int RESULT_DISABLE_POWERMANAGER = 661;
 	public static final int RESULT_DISABLE_AUTOSTART = 662;
 
-	/*
-	public static boolean hasProtectedApps(Context context) {
-		Intent intent = new Intent();
-		intent.setClassName("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity");
-		return isCallable(context, intent);
-	}
-*/
 	private static boolean isCallable(Context context, Intent intent) {
 		List<ResolveInfo> list = context.getPackageManager().queryIntentActivities(intent,
 			PackageManager.MATCH_DEFAULT_ONLY);
 		return list.size() > 0;
-/*
-		return (context.getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null);
-*/
 	}
 
 	private static Intent getPowermanagerIntent(Context context) {
@@ -92,14 +94,30 @@ public class PowermanagerUtil {
 		return null;
 	}
 
-	public static void callPowerManager(Fragment fragment) {
-		Intent intent = getPowermanagerIntent(fragment.getActivity());
-		fragment.startActivityForResult(intent, RESULT_DISABLE_POWERMANAGER);
+	public static void callPowerManager(@NonNull Fragment fragment) {
+		for (Intent intent : POWERMANAGER_INTENTS) {
+			if (isCallable(fragment.getActivity(), intent)) {
+				try {
+					fragment.startActivityForResult(intent, RESULT_DISABLE_POWERMANAGER);
+					return;
+				} catch (Exception e) {
+					logger.error("Unable to start power manager activity", e);
+				}
+			}
+		}
 	}
 
-	public static void callAutostartManager(Fragment fragment) {
-		Intent intent = getAutostartIntent(fragment.getActivity());
-		fragment.startActivityForResult(intent, RESULT_DISABLE_AUTOSTART);
+	public static void callAutostartManager(@NonNull Fragment fragment) {
+		for (Intent intent : AUTOSTART_INTENTS) {
+			if (isCallable(fragment.getActivity(), intent)) {
+				try {
+					fragment.startActivityForResult(intent, RESULT_DISABLE_AUTOSTART);
+					return;
+				} catch (Exception e) {
+					logger.error("Unable to start autostart activity", e);
+				}
+			}
+		}
 	}
 
 	public static boolean hasPowerManagerOption(Context context) {

+ 1 - 1
app/src/main/java/ch/threema/app/video/InputSurface.java → app/src/main/java/ch/threema/app/video/transcoder/InputSurface.java

@@ -35,7 +35,7 @@
  * limitations under the License.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.opengl.EGL14;
 import android.opengl.EGLConfig;

+ 9 - 6
app/src/main/java/ch/threema/app/video/VideoTranscoderComponent.java → app/src/main/java/ch/threema/app/video/transcoder/MediaComponent.java

@@ -19,7 +19,7 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.content.Context;
 import android.media.MediaExtractor;
@@ -30,11 +30,14 @@ import java.io.IOException;
 
 import ch.threema.app.utils.MimeUtil;
 
-public class VideoTranscoderComponent {
-    public static int COMPONENT_TYPE_AUDIO = 0;
-    public static int COMPONENT_TYPE_VIDEO = 1;
+/**
+ * Extracts Media or Audio Components from a file.
+ */
+public class MediaComponent {
+    public static final int COMPONENT_TYPE_AUDIO = 0;
+    public static final int COMPONENT_TYPE_VIDEO = 1;
 
-    public static int NO_TRACK_AVAILABLE = -1;
+    public static final int NO_TRACK_AVAILABLE = -1;
 
     private Context mContext;
     private final Uri mSrcUri;
@@ -51,7 +54,7 @@ public class VideoTranscoderComponent {
      * @param type
      * @throws IOException
      */
-    public VideoTranscoderComponent(Context context, Uri srcUri, int type) throws IOException {
+    public MediaComponent(Context context, Uri srcUri, int type) throws IOException {
         mContext = context;
         mSrcUri = srcUri;
         mType = type;

+ 1 - 1
app/src/main/java/ch/threema/app/video/OutputSurface.java → app/src/main/java/ch/threema/app/video/transcoder/OutputSurface.java

@@ -35,7 +35,7 @@
  * limitations under the License.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.graphics.SurfaceTexture;
 import android.opengl.GLES20;

+ 1 - 1
app/src/main/java/ch/threema/app/video/TextureRenderer.java → app/src/main/java/ch/threema/app/video/transcoder/TextureRenderer.java

@@ -35,7 +35,7 @@
  * limitations under the License.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.graphics.SurfaceTexture;
 import android.opengl.GLES11Ext;

+ 6 - 19
app/src/main/java/ch/threema/app/mediaattacher/data/MediaItemEntity.java → app/src/main/java/ch/threema/app/video/transcoder/UnrecoverableVideoTranscoderException.java

@@ -19,27 +19,14 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.mediaattacher.data;
+package ch.threema.app.video.transcoder;
 
-import androidx.room.ColumnInfo;
-import androidx.room.PrimaryKey;
-
-public class
-MediaItemEntity {
-
-	@PrimaryKey
-	@ColumnInfo(name = "id")
-	private int id;
-
-	public MediaItemEntity(int id) {
-		this.id = id;
-	}
-
-	public int getId() {
-		return id;
+public class UnrecoverableVideoTranscoderException extends RuntimeException {
+	public UnrecoverableVideoTranscoderException(final Exception exception) {
+		super(exception);
 	}
 
-	public void setId(int id) {
-		this.id = id;
+	public UnrecoverableVideoTranscoderException(final String message) {
+		super(message);
 	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/video/VideoConfig.java → app/src/main/java/ch/threema/app/video/transcoder/VideoConfig.java

@@ -19,7 +19,7 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.content.Context;
 import android.media.MediaExtractor;

+ 198 - 348
app/src/main/java/ch/threema/app/video/VideoTranscoder.java → app/src/main/java/ch/threema/app/video/transcoder/VideoTranscoder.java

@@ -19,7 +19,7 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.video;
+package ch.threema.app.video.transcoder;
 
 import android.annotation.TargetApi;
 import android.content.ContentResolver;
@@ -51,6 +51,12 @@ import java.util.concurrent.atomic.AtomicReference;
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.video.transcoder.audio.AbstractAudioTranscoder;
+import ch.threema.app.video.transcoder.audio.AudioComponent;
+import ch.threema.app.video.transcoder.audio.AudioFormatTranscoder;
+import ch.threema.app.video.transcoder.audio.AudioNullTranscoder;
+import ch.threema.app.video.transcoder.audio.UnsupportedAudioFormatException;
+import java8.util.Optional;
 
 /**
  * Based on https://github.com/groupme/android-video-kit
@@ -71,6 +77,10 @@ import ch.threema.app.utils.RuntimeUtil;
 @TargetApi(18)
 public class VideoTranscoder {
 	private static final Logger logger = LoggerFactory.getLogger(VideoTranscoder.class);
+	private @NonNull Optional<AbstractAudioTranscoder> audioTranscoder = Optional.empty();
+	private @NonNull Optional<Exception> audioTranscoderError = Optional.empty();
+
+	//region Constants
 
 	@Retention(RetentionPolicy.SOURCE)
 	@IntDef({FAILURE, SUCCESS, CANCELED})
@@ -79,7 +89,7 @@ public class VideoTranscoder {
 	public static final int SUCCESS = 1;
 	public static final int CANCELED = -1;
 
-	public static int TRIM_TIME_END = -1;
+	public static final int TRIM_TIME_END = -1;
 
 	private static final int POLLING_SUCCESS = 0;
 	private static final int POLLING_ERROR = -1;
@@ -90,7 +100,11 @@ public class VideoTranscoder {
 	/**
 	 * How long to wait for the next buffer to become available in microseconds.
 	 */
-	private static final int TIMEOUT_USEC = 10000;
+	public static final int TIMEOUT_USEC = 10000;
+
+	//endregion
+
+	//region Properties
 
 	private final Context mContext;
 	private final Uri mSrcUri;
@@ -99,8 +113,8 @@ public class VideoTranscoder {
 
 	private boolean mIncludeAudio = true;
 
-	private VideoTranscoderComponent mInputVideoComponent;
-	private VideoTranscoderComponent mInputAudioComponent;
+	private MediaComponent mInputVideoComponent;
+	private AudioComponent mInputAudioComponent;
 
 	private int mOutputVideoWidth;
 	private int mOutputVideoHeight;
@@ -112,12 +126,13 @@ public class VideoTranscoder {
 
 	private int mOutputAudioBitRate;
 
-	// start and end time in Milliseconds
-	private long mTrimStartTime = 0;
-	private long mTrimEndTime = TRIM_TIME_END;
+	/**
+	 * Approximate start time in Milliseconds.
+	 */
+	private long mTrimStartTimeMs = 0;
+	private long mTrimEndTimeMs = TRIM_TIME_END;
 
 	private MediaFormat mOutputVideoFormat;
-	private MediaFormat mOutputAudioFormat;
 
 	private MediaCodec mVideoEncoder;
 	private MediaCodec mVideoDecoder;
@@ -125,8 +140,6 @@ public class VideoTranscoder {
 	private InputSurface mInputSurface;
 	private OutputSurface mOutputSurface;
 
-	private MediaCodec mAudioEncoder;
-	private MediaCodec mAudioDecoder;
 	private MediaMuxer mMuxer;
 
 	private Stats mStats;
@@ -135,23 +148,12 @@ public class VideoTranscoder {
 	// Buffers
 	private ByteBuffer[] mVideoDecoderInputBuffers;
 	private ByteBuffer[] mVideoEncoderOutputBuffers;
-	private ByteBuffer[] mAudioDecoderInputBuffers;
-	private ByteBuffer[] mAudioDecoderOutputBuffers;
-	private ByteBuffer[] mAudioEncoderInputBuffers;
-	private ByteBuffer[] mAudioEncoderOutputBuffers;
 
 	// Media Formats from codecs
 	private MediaFormat mDecoderOutputVideoFormat;
-	private MediaFormat mDecoderOutputAudioFormat;
 	private MediaFormat mEncoderOutputVideoFormat;
-	private MediaFormat mEncoderOutputAudioFormat;
-
-	private int mPendingAudioDecoderOutputBufferIndex = -1;
 
 	private int mOutputVideoTrack = -1;
-	private int mOutputAudioTrack = -1;
-
-	private long mPreviousPresentationTime = 0L;
 
 	private long mStartTime;
 	private int progress;
@@ -161,20 +163,35 @@ public class VideoTranscoder {
 	private long outputDurationUs;
 	private long outputStartTimeUs;
 
+	private boolean shouldIncludeAudio() {
+		return this.mIncludeAudio;
+	}
+
+	private void shouldIncludeAudio(boolean copyAudio) {
+		this.mIncludeAudio = copyAudio;
+	}
+
+
+	public boolean hasAudioTranscodingError() {
+		return this.audioTranscoderError.isPresent();
+	}
+
+	public boolean audioFormatUnsupported() {
+		return this.hasAudioTranscodingError() &&
+			this.audioTranscoderError.get() instanceof UnsupportedAudioFormatException;
+	}
+
+	//endregion
+
 	private VideoTranscoder(Context context, Uri srcUri) {
 		mContext = context;
 		mSrcUri = srcUri;
 	}
 
-	private boolean shouldIncludeAudio() {
-		return mIncludeAudio;
-	}
 
-	private void shouldIncludeAudio(boolean copyAudio) {
-		mIncludeAudio = copyAudio;
-	}
+	//region Lifecycle
 
-	public void start(@NonNull final Listener listener) throws IOException {
+	public void start(@NonNull final Listener listener) {
 		if (mContext == null) {
 			throw new IllegalStateException("Context cannot be null");
 		}
@@ -183,24 +200,18 @@ public class VideoTranscoder {
 			throw new IllegalStateException("Source Uri cannot be null. Make sure to call source()");
 		}
 
-		new Thread(new Runnable() {
-			@Override
-			public void run() {
-				final @TranscoderResult int result = startSync(listener);
-
-				RuntimeUtil.runOnUiThread(new Runnable() {
-					@Override
-					public void run() {
-						if (result == SUCCESS) {
-							listener.onSuccess(mStats);
-						} else if (result == CANCELED) {
-							listener.onCanceled();
-						} else {
-							listener.onFailure();
-						}
-					}
-				});
-			}
+		new Thread(() -> {
+			final @TranscoderResult int result = startSync(listener);
+
+			RuntimeUtil.runOnUiThread(() -> {
+				if (result == SUCCESS) {
+					listener.onSuccess(mStats);
+				} else if (result == CANCELED) {
+					listener.onCanceled();
+				} else {
+					listener.onFailure();
+				}
+			});
 		}).start();
 	}
 
@@ -227,7 +238,7 @@ public class VideoTranscoder {
 			setup();
 			setupSuccess = true;
 		} catch (Exception ex) {
-			logger.error("Failed while setting up VideoTranscoder: " + mSrcUri, ex);
+			logger.error("Failed while setting up VideoTranscoder: {}" , mSrcUri, ex);
 		}
 
 		try {
@@ -235,7 +246,7 @@ public class VideoTranscoder {
 				transcoderResult = transcode();
 			}
 		} catch (Exception ex) {
-			logger.error("Failed while transcoding video: " + mSrcUri, ex);
+			logger.error("Failed while transcoding video: {}", mSrcUri, ex);
 		}
 
 		try {
@@ -256,6 +267,7 @@ public class VideoTranscoder {
 	}
 
 	private void setup() throws IOException {
+		mStats = new Stats();
 		createComponents();
 
 		setOrientationHint();
@@ -266,8 +278,29 @@ public class VideoTranscoder {
 		createVideoDecoder();
 
 		if (shouldIncludeAudio()) {
-			createAudioEncoder();
-			createAudioDecoder();
+			final String mimeType = mInputAudioComponent.getTrackFormat().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(
+					mInputAudioComponent,
+					mStats,
+					mTrimEndTimeMs
+				));
+			} else {
+				logger.info("Transcoding audio track, as in- and output format differ");
+				audioTranscoder = Optional.of(new AudioFormatTranscoder(
+					mInputAudioComponent,
+					mStats,
+					mTrimEndTimeMs,
+					mOutputAudioBitRate
+				));
+			}
+
+			try {
+				this.audioTranscoder.get().setup();
+			} catch (Exception e) {
+				this.handleAudioException(e);
+			}
 		}
 
 		createMuxer();
@@ -276,15 +309,12 @@ public class VideoTranscoder {
 	private @TranscoderResult int transcode() {
 		listener.onStart();
 
-		mStats = new Stats();
 
 		boolean videoEncoderDone = false;
-		boolean audioEncoderDone = false;
 
 		boolean videoDecoderDone = false;
 
 		boolean videoExtractorDone = false;
-		boolean audioExtractorDone = false;
 
 		boolean muxing = false;
 
@@ -293,34 +323,13 @@ public class VideoTranscoder {
 			mVideoEncoderOutputBuffers = mVideoEncoder.getOutputBuffers();
 		}
 
-		if (shouldIncludeAudio() && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-			mAudioDecoderInputBuffers = mAudioDecoder.getInputBuffers();
-			mAudioDecoderOutputBuffers = mAudioDecoder.getOutputBuffers();
-			mAudioEncoderInputBuffers = mAudioEncoder.getInputBuffers();
-			mAudioEncoderOutputBuffers = mAudioEncoder.getOutputBuffers();
-		}
-
 		MediaCodec.BufferInfo videoDecoderOutputBufferInfo = new MediaCodec.BufferInfo();
 		MediaCodec.BufferInfo videoEncoderOutputBufferInfo = new MediaCodec.BufferInfo();
 
-		MediaCodec.BufferInfo audioDecoderOutputBufferInfo = null;
-		MediaCodec.BufferInfo audioEncoderOutputBufferInfo = null;
-
-		if (shouldIncludeAudio()) {
-			audioDecoderOutputBufferInfo = new MediaCodec.BufferInfo();
-			audioEncoderOutputBufferInfo = new MediaCodec.BufferInfo();
-		}
-
-		if (mTrimStartTime > 0) {
-			mInputVideoComponent.getMediaExtractor().seekTo(mTrimStartTime * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
-
-			if (shouldIncludeAudio()) {
-				mInputAudioComponent.getMediaExtractor().seekTo(mTrimStartTime * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
-			}
-		}
+		trimStart();
 
 		// loop until all the encoding is finished
-		while (!videoEncoderDone || (shouldIncludeAudio() && !audioEncoderDone)) {
+		while (!videoEncoderDone || (audioTranscoder.isPresent() && audioTranscoder.get().getState() != AbstractAudioTranscoder.State.DONE)) {
 
 			// Extract video from file and feed to decoder.
 			// Do not extract video if we have determined the output format but we are not yet
@@ -329,12 +338,6 @@ public class VideoTranscoder {
 				videoExtractorDone = extractAndFeedDecoder(mVideoDecoder, mVideoDecoderInputBuffers, mInputVideoComponent);
 			}
 
-			// Extract audio from file and feed to decoder.
-			// Do not extract audio if we have determined the output format but we are not yet
-			// ready to mux the frames.
-			if (shouldIncludeAudio() && !audioExtractorDone && (mEncoderOutputAudioFormat == null || muxing)) {
-				audioExtractorDone = extractAndFeedDecoder(mAudioDecoder, mAudioDecoderInputBuffers, mInputAudioComponent);
-			}
 
 			// Poll output frames from the video decoder and feed the encoder
 			if (!videoDecoderDone && (mEncoderOutputVideoFormat == null || muxing)) {
@@ -346,28 +349,21 @@ public class VideoTranscoder {
 				}
 			}
 
-			// Poll output frames from the audio decoder.
-			if (shouldIncludeAudio() && mPendingAudioDecoderOutputBufferIndex == -1 && (mEncoderOutputAudioFormat == null || muxing)) {
-				pollAudioFromDecoder(audioDecoderOutputBufferInfo);
-			}
-
-			// Feed the pending audio buffer to the audio encoder
-			if (shouldIncludeAudio() && mPendingAudioDecoderOutputBufferIndex != -1) {
-				feedPendingAudioBufferToEncoder(audioDecoderOutputBufferInfo);
-			}
-
 			// Poll frames from video encoder and send them to the muxer
 			if (!videoEncoderDone && (mEncoderOutputVideoFormat == null || muxing)) {
 				videoEncoderDone = pollVideoFromEncoderAndFeedToMuxer(videoEncoderOutputBufferInfo);
 			}
 
-			// Poll frames from audio encoder and send them to the muxer
-			if (shouldIncludeAudio() && !audioEncoderDone && (mEncoderOutputAudioFormat == null || muxing)) {
-				audioEncoderDone = pollAudioFromEncoderAndFeedToMuxer(audioEncoderOutputBufferInfo);
+			if (this.audioTranscoder.isPresent() && this.audioTranscoder.get().getState() != AbstractAudioTranscoder.State.DONE) {
+				try {
+					this.audioTranscoder.get().step();
+				} catch (Exception exception) {
+					this.handleAudioException(exception);
+				}
 			}
 
 			// Setup muxer
-			if (!muxing && (!shouldIncludeAudio() || mEncoderOutputAudioFormat != null) && (mEncoderOutputVideoFormat != null)) {
+			if (!muxing && (audioTranscoder.isEmpty() || audioTranscoder.get().getState() == AbstractAudioTranscoder.State.WAITING_ON_MUXER) && (mEncoderOutputVideoFormat != null)) {
 				setupMuxer();
 				muxing = true;
 			}
@@ -379,6 +375,68 @@ public class VideoTranscoder {
 		return SUCCESS;
 	}
 
+	/**
+	 *
+	 * Trim the start of the video if the trimming time is > 0.
+	 *
+	 * The audio start trim is moved slightly to fit the video key/sync frames:
+	 *
+	 * 1. Search video keyframe that happened before the requested mTrimStartTime,
+	 *    so that for short trim-sequences the key frame cannot be over mTrimEndTime.
+	 *               <<<<¦
+	 *    [---------|--------------------] video track
+	 *
+	 * 2. Search for next audio keyframe after real video cut
+	 *              |>>
+	 *    [------------‖-----------------] audio track
+	 *
+	 * (¦ is the time of this variable, | is the real video cut, ‖ the real audio cut)
+	 *
+	 */
+	private void trimStart() {
+		if (this.mTrimStartTimeMs > 0) {
+			this.mInputVideoComponent.getMediaExtractor().seekTo(
+				this.mTrimStartTimeMs * 1000,
+				MediaExtractor.SEEK_TO_PREVIOUS_SYNC
+			);
+			final long exactVideoStartTrimUs = this.mInputVideoComponent.getMediaExtractor().getSampleTime();
+
+			logger.debug(
+				"transcoder: trim video decoder start to keyframe at {}us (originally requested {}us)",
+				this.mInputVideoComponent.getMediaExtractor().getSampleTime(),
+				this.mTrimStartTimeMs * 1000
+			);
+
+			this.audioTranscoder.ifPresent(t -> t.trimMediaStartTo(exactVideoStartTrimUs));
+		}
+	}
+
+
+	/**
+	 * Handle audio transcoder errors the best way possible. If audio transcoding has not yet
+	 * started, we skip the audio and continue in video-only mode, or else rethrow the exception.
+	 *
+	 * @param exception The exception from the audio transcoder
+	 */
+	private void handleAudioException(Exception exception) {
+		final AbstractAudioTranscoder.State state = this.audioTranscoder.get().getState();
+
+		if (
+			state == AbstractAudioTranscoder.State.TRANSCODING
+			|| state == AbstractAudioTranscoder.State.DONE
+		) {
+			throw new UnrecoverableVideoTranscoderException(exception);
+		}
+
+		this.audioTranscoder = Optional.empty();
+		this.audioTranscoderError = Optional.of(exception);
+		logger.warn(
+			"Audio format is not supported by transcoder",
+			exception
+		);
+		logger.info("Ignoring audio, as transcoding has not yet started");
+	}
+
 	/**
 	 * Performs a basic checks in an attempt to see if the transcode was successful.
 	 * Will throw an IllegalStateException if any checks fail.
@@ -386,15 +444,14 @@ public class VideoTranscoder {
 	private void sanityChecks() {
 		if (mStats.videoDecodedFrameCount != mStats.videoEncodedFrameCount) {
 			logger.info("Frame count mismatch videoDecodedFrameCount: {} videoEncodedFrameCount: {}", mStats.videoDecodedFrameCount, mStats.videoEncodedFrameCount);
-//			throw new IllegalStateException("encoded and decoded video frame counts should match");
 		}
 
 		if (mStats.videoDecodedFrameCount > mStats.videoExtractedFrameCount) {
 			throw new IllegalStateException("decoded frame count should be less than extracted frame count");
 		}
 
-		if (shouldIncludeAudio()) {
-			if (mPendingAudioDecoderOutputBufferIndex != -1) {
+		if (audioTranscoder.isPresent()) {
+			if (audioTranscoder.get().hasPendingIntermediateFrames()) {
 				throw new IllegalStateException("no frame should be pending");
 			}
 
@@ -483,26 +540,13 @@ public class VideoTranscoder {
 				exception = e;
 			}
 		}
-		try {
-			if (mAudioDecoder != null) {
-				mAudioDecoder.stop();
-				mAudioDecoder.release();
-			}
-		} catch (Exception e) {
-			logger.error("error while releasing audioDecoder", e);
-			if (exception == null) {
-				exception = e;
-			}
-		}
-		try {
-			if (mAudioEncoder != null) {
-				mAudioEncoder.stop();
-				mAudioEncoder.release();
-			}
-		} catch (Exception e) {
-			logger.error("error while releasing audioEncoder", e);
-			if (exception == null) {
-				exception = e;
+		if (audioTranscoder.isPresent()) {
+			try {
+				audioTranscoder.get().cleanup();
+			} catch(Exception e) {
+				if(exception == null) {
+					exception = e;
+				}
 			}
 		}
 		try {
@@ -534,13 +578,17 @@ public class VideoTranscoder {
 		logResults();
 	}
 
+	//endregion
+
+	//region Encoder / Decoders
+
 	/**
 	 * Extract and feed to decoder.
 	 *
 	 * @return Finished. True when it extracts the last frame.
 	 */
-	private boolean extractAndFeedDecoder(MediaCodec decoder, ByteBuffer[] buffers, VideoTranscoderComponent component) {
-		String type = component.getType() == VideoTranscoderComponent.COMPONENT_TYPE_VIDEO ? "video" : "audio";
+	private boolean extractAndFeedDecoder(MediaCodec decoder, ByteBuffer[] buffers, MediaComponent component) {
+		String type = component.getType() == MediaComponent.COMPONENT_TYPE_VIDEO ? "video" : "audio";
 
 		int decoderInputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
 		if (decoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
@@ -548,7 +596,7 @@ public class VideoTranscoder {
 			return false;
 		}
 
-		logger.trace("{} decoder: returned input buffer: {}", type, decoderInputBufferIndex);
+		logger.trace("{} extractor: returned input buffer: {}", type, decoderInputBufferIndex);
 
 		MediaExtractor extractor = component.getMediaExtractor();
 		int chunkSize = extractor.readSampleData(
@@ -561,8 +609,8 @@ public class VideoTranscoder {
 		logger.trace("{} extractor: returned buffer of chunkSize {}", type, chunkSize);
 		logger.trace("{} extractor: returned buffer for sampleTime {}", type, sampleTime);
 
-		if (mTrimEndTime > 0 && sampleTime > (mTrimEndTime * 1000)) {
-			logger.debug("The current sample is over the trim time. Lets stop.");
+		if (mTrimEndTimeMs > 0 && sampleTime > (mTrimEndTimeMs * 1000)) {
+			logger.debug("{} extractor: The current sample is over the trim time. Lets stop.", type);
 			decoder.queueInputBuffer(
 				decoderInputBufferIndex,
 				0,
@@ -619,7 +667,7 @@ public class VideoTranscoder {
 		int decoderOutputBufferIndex = mVideoDecoder.dequeueOutputBuffer(videoDecoderOutputBufferInfo, TIMEOUT_USEC);
 
 		if (decoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
-			logger.debug("no video decoder output buffer");
+			logger.debug("video decoder: no output buffer");
 			return POLLING_ERROR;
 		}
 
@@ -679,105 +727,6 @@ public class VideoTranscoder {
 		return POLLING_ERROR;
 	}
 
-	/**
-	 * @param audioDecoderOutputBufferInfo
-	 */
-	private void pollAudioFromDecoder(MediaCodec.BufferInfo audioDecoderOutputBufferInfo) {
-		int decoderOutputBufferIndex = mAudioDecoder.dequeueOutputBuffer(audioDecoderOutputBufferInfo, TIMEOUT_USEC);
-
-		if (decoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
-			logger.debug("no audio decoder output buffer");
-			return;
-		}
-
-		if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
-			logger.debug("audio decoder: output buffers changed");
-			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-				mAudioDecoderOutputBuffers = mAudioDecoder.getOutputBuffers();
-			}
-			return;
-		}
-
-		if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
-			mDecoderOutputAudioFormat = mAudioDecoder.getOutputFormat();
-			logger.debug("audio decoder: output format changed: {}", mDecoderOutputAudioFormat);
-			return;
-		}
-
-		logger.trace("audio decoder: returned output buffer: {}", decoderOutputBufferIndex);
-		logger.trace("audio decoder: returned buffer of size {}", audioDecoderOutputBufferInfo.size);
-
-		if ((audioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
-			logger.debug("audio decoder: codec config buffer");
-			mAudioDecoder.releaseOutputBuffer(decoderOutputBufferIndex, false);
-			return;
-		}
-
-		logger.trace("audio decoder: returned buffer for time {}", audioDecoderOutputBufferInfo.presentationTimeUs);
-		logger.trace("audio decoder: output buffer is now pending: {}", mPendingAudioDecoderOutputBufferIndex);
-
-		mPendingAudioDecoderOutputBufferIndex = decoderOutputBufferIndex;
-		mStats.audioDecodedFrameCount++;
-	}
-
-	/**
-	 * @param audioDecoderOutputBufferInfo
-	 * @return
-	 */
-	private boolean feedPendingAudioBufferToEncoder(MediaCodec.BufferInfo audioDecoderOutputBufferInfo) {
-		logger.trace("audio decoder: attempting to process pending buffer: {}", mPendingAudioDecoderOutputBufferIndex);
-
-		int encoderInputBufferIndex = mAudioEncoder.dequeueInputBuffer(TIMEOUT_USEC);
-
-		if (encoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
-			logger.debug("no audio encoder input buffer");
-			return false;
-		}
-
-		logger.trace("audio encoder: returned input buffer: {}", encoderInputBufferIndex);
-
-		ByteBuffer encoderInputBuffer =
-			Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
-				mAudioEncoderInputBuffers[encoderInputBufferIndex] :
-				mAudioEncoder.getInputBuffer(encoderInputBufferIndex);
-
-		// int chunkSize = audioDecoderOutputBufferInfo.size;
-		// TODO handle the case where encoder buffer is smaller than decoder buffer
-		int chunkSize = Math.min(audioDecoderOutputBufferInfo.size, encoderInputBuffer.capacity());
-		long presentationTime = audioDecoderOutputBufferInfo.presentationTimeUs;
-
-		logger.trace("audio decoder: processing pending buffer: {}", mPendingAudioDecoderOutputBufferIndex);
-		logger.trace("audio decoder: pending buffer of size {}", chunkSize);
-		logger.trace("audio decoder: pending buffer for time {}", presentationTime);
-
-		if (chunkSize >= 0) {
-			ByteBuffer decoderOutputBuffer = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
-				mAudioDecoderOutputBuffers[mPendingAudioDecoderOutputBufferIndex].duplicate() :
-				mAudioDecoder.getOutputBuffer(mPendingAudioDecoderOutputBufferIndex).duplicate();
-			decoderOutputBuffer.position(audioDecoderOutputBufferInfo.offset);
-			decoderOutputBuffer.limit(audioDecoderOutputBufferInfo.offset + chunkSize);
-			encoderInputBuffer.position(0);
-			encoderInputBuffer.put(decoderOutputBuffer);
-
-			mAudioEncoder.queueInputBuffer(
-				encoderInputBufferIndex,
-				0,
-				chunkSize,
-				presentationTime,
-				audioDecoderOutputBufferInfo.flags);
-		}
-
-		mAudioDecoder.releaseOutputBuffer(mPendingAudioDecoderOutputBufferIndex, false);
-		mPendingAudioDecoderOutputBufferIndex = -1;
-
-		if ((audioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
-			logger.debug("audio decoder: EOS");
-			return true;
-		}
-
-		return false;
-	}
-
 	/**
 	 * @param videoEncoderOutputBufferInfo
 	 * @return
@@ -814,11 +763,6 @@ public class VideoTranscoder {
 			return false;
 		}
 
-//        TODO: is this needed?
-//        if (!mMuxing) {
-//            throw new IllegalStateException("should have added track before processing output");
-//        }
-
 		logger.trace("video encoder: returned output buffer: {}", encoderOutputBufferIndex);
 		logger.trace("video encoder: returned buffer of size {}", videoEncoderOutputBufferInfo.size);
 		logger.trace("video encoder: returned buffer for time {}", videoEncoderOutputBufferInfo.presentationTimeUs);
@@ -844,85 +788,20 @@ public class VideoTranscoder {
 		return false;
 	}
 
-	private boolean pollAudioFromEncoderAndFeedToMuxer(MediaCodec.BufferInfo audioEncoderOutputBufferInfo) {
-		int encoderOutputBufferIndex = mAudioEncoder.dequeueOutputBuffer(audioEncoderOutputBufferInfo, TIMEOUT_USEC);
-
-		if (encoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
-			logger.debug("no audio encoder output buffer");
-			return false;
-		}
-
-		if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
-			logger.debug("audio encoder: output buffers changed");
-			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-				mAudioEncoderOutputBuffers = mAudioEncoder.getOutputBuffers();
-			}
-			return false;
-		}
-
-		if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
-			logger.debug("audio encoder: output format changed");
-			if (mOutputAudioTrack >= 0) {
-				throw new IllegalStateException("audio encoder changed its output format again?");
-			}
-
-			mEncoderOutputAudioFormat = mAudioEncoder.getOutputFormat();
-			return false;
-		}
-
-		if ((audioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
-			logger.debug("audio encoder: codec config buffer");
-			// Simply ignore codec config buffers.
-			mAudioEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
-			return false;
-		}
-
-//        TODO: is this needed?
-//        if (!muxing) {
-//            throw new IllegalStateException("should have added track before processing output");
-//        }
-
-		logger.trace("audio encoder: returned output buffer: {}", encoderOutputBufferIndex);
-		logger.trace("audio encoder: returned buffer of size {}", audioEncoderOutputBufferInfo.size);
-		logger.trace("audio encoder: returned buffer for time {}", audioEncoderOutputBufferInfo.presentationTimeUs);
-
-		if (audioEncoderOutputBufferInfo.size != 0) {
-			ByteBuffer encoderOutputBuffer = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
-				mAudioEncoderOutputBuffers[encoderOutputBufferIndex] :
-				mAudioEncoder.getOutputBuffer(encoderOutputBufferIndex);
-			if (audioEncoderOutputBufferInfo.presentationTimeUs >= mPreviousPresentationTime) {
-				mPreviousPresentationTime = audioEncoderOutputBufferInfo.presentationTimeUs;
-				mMuxer.writeSampleData(mOutputAudioTrack, encoderOutputBuffer, audioEncoderOutputBufferInfo);
-			} else {
-				logger.debug("audio encoder: presentationTimeUs {} < previousPresentationTime {}",
-					audioEncoderOutputBufferInfo.presentationTimeUs, mPreviousPresentationTime);
-			}
-		}
-
-		mAudioEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
-
-		if ((audioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
-			logger.debug("audio encoder: EOS");
-			return true;
-		}
-
-		mStats.audioEncodedFrameCount++;
-
-		return false;
-	}
-
 	private void setupMuxer() {
 		logger.debug("muxer: adding video track.");
 		mOutputVideoTrack = mMuxer.addTrack(mEncoderOutputVideoFormat);
 
-		if (shouldIncludeAudio()) {
-			logger.debug("muxer: adding audio track.");
-			mOutputAudioTrack = mMuxer.addTrack(mEncoderOutputAudioFormat);
-		}
+
+		audioTranscoder.ifPresent(transcoder -> {
+			logger.debug("muxer: injecting audio track.");
+			transcoder.injectTrackToMuxer(mMuxer);
+		});
 
 		logger.debug("muxer: starting");
 		mMuxer.setOrientationHint(mOrientationHint);
 		mMuxer.start();
+
 	}
 
 	/**
@@ -932,7 +811,7 @@ public class VideoTranscoder {
 	 * @param mimeType specified MIME type
 	 * @return
 	 */
-	private MediaCodecInfo selectCodec(String mimeType) {
+	public static MediaCodecInfo selectCodec(String mimeType) {
 		int numCodecs = MediaCodecList.getCodecCount();
 		for (int i = 0; i < numCodecs; i++) {
 			MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
@@ -950,11 +829,11 @@ public class VideoTranscoder {
 			}
 		}
 
-		throw new RuntimeException("Unable to find an appropriate codec for " + mimeType);
+		throw new UnrecoverableVideoTranscoderException("Unable to find an appropriate codec for " + mimeType);
 	}
 
 	private void createComponents() throws IOException {
-		mInputVideoComponent = new VideoTranscoderComponent(mContext, mSrcUri, VideoTranscoderComponent.COMPONENT_TYPE_VIDEO);
+		mInputVideoComponent = new MediaComponent(mContext, mSrcUri, MediaComponent.COMPONENT_TYPE_VIDEO);
 
 		MediaFormat inputFormat = mInputVideoComponent.getTrackFormat();
 		if (inputFormat.containsKey("rotation-degrees")) {
@@ -965,8 +844,8 @@ public class VideoTranscoder {
 		}
 
 		if (shouldIncludeAudio()) {
-			mInputAudioComponent = new VideoTranscoderComponent(mContext, mSrcUri, VideoTranscoderComponent.COMPONENT_TYPE_AUDIO);
-			if (mInputAudioComponent.getSelectedTrackIndex() == VideoTranscoderComponent.NO_TRACK_AVAILABLE) {
+			mInputAudioComponent = new AudioComponent(mContext, mSrcUri);
+			if (mInputAudioComponent.getSelectedTrackIndex() == MediaComponent.NO_TRACK_AVAILABLE) {
 				shouldIncludeAudio(false);
 			}
 		}
@@ -1032,9 +911,7 @@ public class VideoTranscoder {
 	private void createOutputFormats() {
 		createVideoOutputFormat();
 
-		if (shouldIncludeAudio()) {
-			createAudioOutputFormat();
-		}
+		// Note: Audio Output formats are set in {@link AudioTranscoder#setup}
 	}
 
 	private void createVideoOutputFormat() {
@@ -1069,47 +946,18 @@ public class VideoTranscoder {
 	private void createVideoDecoder() throws IOException {
 		MediaFormat inputFormat = mInputVideoComponent.getTrackFormat();
 
-		if (mTrimEndTime == TRIM_TIME_END) {
+		if (mTrimEndTimeMs == TRIM_TIME_END) {
 			outputDurationUs = inputFormat.getLong(MediaFormat.KEY_DURATION);
 		} else {
-			outputDurationUs = (mTrimEndTime - mTrimStartTime) * 1000;
+			outputDurationUs = (mTrimEndTimeMs - mTrimStartTimeMs) * 1000;
 		}
-		outputStartTimeUs = mTrimStartTime * 1000;
+		outputStartTimeUs = mTrimStartTimeMs * 1000;
 
 		mVideoDecoder = MediaCodec.createDecoderByType(getMimeTypeFor(inputFormat));
 		mVideoDecoder.configure(inputFormat, mOutputSurface.getSurface(), null, 0);
 		mVideoDecoder.start();
 	}
 
-	private void createAudioOutputFormat() {
-		MediaFormat inputFormat = mInputAudioComponent.getTrackFormat();
-
-		int sampleRate = inputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
-		int channelCount = inputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
-
-		mOutputAudioFormat = MediaFormat.createAudioFormat(Defaults.OUTPUT_AUDIO_MIME_TYPE,
-			sampleRate, channelCount);
-
-		mOutputAudioFormat.setInteger(MediaFormat.KEY_BIT_RATE, mOutputAudioBitRate);
-		mOutputAudioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, Defaults.OUTPUT_AUDIO_AAC_PROFILE);
-	}
-
-	private void createAudioEncoder() throws IOException {
-		MediaCodecInfo codecInfo = selectCodec(Defaults.OUTPUT_AUDIO_MIME_TYPE);
-
-		mAudioEncoder = MediaCodec.createByCodecName(codecInfo.getName());
-		mAudioEncoder.configure(mOutputAudioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
-		mAudioEncoder.start();
-	}
-
-	private void createAudioDecoder() throws IOException {
-		MediaFormat inputFormat = mInputAudioComponent.getTrackFormat();
-
-		mAudioDecoder = MediaCodec.createDecoderByType(getMimeTypeFor(inputFormat));
-		mAudioDecoder.configure(inputFormat, null, null, 0);
-		mAudioDecoder.start();
-	}
-
 	private void createMuxer() throws IOException {
 		mMuxer = new MediaMuxer(mOutputFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
 		mMuxer.setOrientationHint(mOrientationHint);
@@ -1145,6 +993,8 @@ public class VideoTranscoder {
 		}
 	}
 
+	//endregion
+
 	public interface Listener {
 		void onSuccess(Stats stats);
 
@@ -1159,7 +1009,7 @@ public class VideoTranscoder {
 
 	public static final class Defaults {
 		static final String OUTPUT_VIDEO_MIME_TYPE = "video/avc";       // H.264 Advanced Video Coding
-		static final String OUTPUT_AUDIO_MIME_TYPE = "audio/MP4A-LATM"; // Advanced Audio Coding
+		public static final String OUTPUT_AUDIO_MIME_TYPE = "audio/MP4A-LATM"; // Advanced Audio Coding
 
 		static final int OUTPUT_VIDEO_BIT_RATE = 5000 * 1024;       // 2 MBps
 		static final int OUTPUT_AUDIO_BIT_RATE = 128 * 1024;        // 128 kbps
@@ -1170,7 +1020,7 @@ public class VideoTranscoder {
 		static final int OUTPUT_MAX_WIDTH = 1920;
 		static final int OUTPUT_MAX_HEIGHT = 1920;
 
-		static final int OUTPUT_AUDIO_AAC_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC;
+		public static final int OUTPUT_AUDIO_AAC_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC;
 	}
 
 	public static final class Stats {
@@ -1186,8 +1036,8 @@ public class VideoTranscoder {
 		public double inputFileSize;
 		public double outputFileSize;
 
-		void incrementExtractedFrameCount(VideoTranscoderComponent component) {
-			if (component.getType() == VideoTranscoderComponent.COMPONENT_TYPE_VIDEO) {
+		public void incrementExtractedFrameCount(MediaComponent component) {
+			if (component.getType() == MediaComponent.COMPONENT_TYPE_VIDEO) {
 				videoExtractedFrameCount++;
 			} else {
 				audioExtractedFrameCount++;
@@ -1195,7 +1045,7 @@ public class VideoTranscoder {
 		}
 	}
 
-	private static String getMimeTypeFor(MediaFormat format) {
+	public static String getMimeTypeFor(MediaFormat format) {
 		return format.getString(MediaFormat.KEY_MIME);
 	}
 
@@ -1282,11 +1132,11 @@ public class VideoTranscoder {
 			transcoder.mOutputFilePath = mDestFile.getAbsolutePath();
 
 			if (mStartTime > 0) {
-				transcoder.mTrimStartTime = mStartTime;
+				transcoder.mTrimStartTimeMs = mStartTime;
 			}
 
 			if (mEndTime != -1) {
-				transcoder.mTrimEndTime = mEndTime;
+				transcoder.mTrimEndTimeMs = mEndTime;
 			}
 
 			return transcoder;

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

@@ -0,0 +1,195 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2019-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.video.transcoder.audio;
+
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import androidx.annotation.NonNull;
+import ch.threema.app.video.transcoder.VideoTranscoder;
+import java8.util.Optional;
+
+public abstract class AbstractAudioTranscoder {
+	private static final Logger logger = LoggerFactory.getLogger(AbstractAudioTranscoder.class);
+
+	//region Member Variables
+	protected final AudioComponent component;
+	protected final VideoTranscoder.Stats stats;
+	protected long trimStartTimeUs = 0;
+	protected final long trimEndTimeUs;
+
+	private @NonNull State state = State.INITIAL;
+
+	protected MediaFormat outputFormat;
+	protected MediaMuxer muxer;
+	protected @NonNull Optional<Integer> muxerTrack = Optional.empty();
+
+	//endregion
+
+	/**
+	 * @param component The audio component that should be transcoded
+	 * @param stats Transcoder Statistics
+	 * @param trimEndTimeMs Trim time from the end in ms (!)
+	 */
+	public AbstractAudioTranscoder(AudioComponent component, VideoTranscoder.Stats stats, long trimEndTimeMs) {
+		this.component = component;
+		this.stats = stats;
+		this.trimEndTimeUs = trimEndTimeMs * 1000;
+	}
+
+	//region Getter / Setter
+
+	public @NonNull State getState() {
+		return this.state;
+	}
+
+	protected void setState(@NonNull State state) {
+		logger.debug("Setting audio transcoder state to {}", state.name());
+		this.state = state;
+	}
+
+	//endregion
+
+
+	/**
+	 * @return whether there are frames which did not finished the transcoding process.
+	 */
+	abstract public boolean hasPendingIntermediateFrames();
+
+	//region Lifecycle
+
+	/**
+	 * Initializes the transcoder pipeline
+	 *
+	 * Changes State from {@link State#INITIAL} to either {@link State#DETECTING_INPUT_FORMAT},
+	 * {@link State#DETECTING_OUTPUT_FORMAT} or {@link State#WAITING_ON_MUXER}.
+	 *
+	 * Should initialize outputFormat of the {@link AbstractAudioTranscoder} class.
+	 *
+	 * @throws IOException if a codec could not be initialized
+	 */
+	public abstract void setup() throws IOException, UnsupportedAudioFormatException;
+
+	/**
+	 * Trims the media start. Requires {@link AbstractAudioTranscoder#component} to be initialized.
+	 *
+	 * May only be called after {@link State#INITIAL} and before {@link State#TRANSCODING}.
+	 */
+	public void trimMediaStartTo(long trimStartTimeUs) {
+		if (this.getState() == State.INITIAL || this.getState().ordinal() >= State.TRANSCODING.ordinal()) {
+			throw new IllegalStateException(String.format("Trimming may not be done in state %s", this.getState().name()));
+		}
+		this.trimStartTimeUs = trimStartTimeUs;
+		// start sound as soon as possible after provided trimStartTime
+		this.component.getMediaExtractor().seekTo(trimStartTimeUs, MediaExtractor.SEEK_TO_NEXT_SYNC);
+
+		logger.debug(
+			"Trimmed audio until {}us, the next sync after requested trim time {}us",
+			this.component.getMediaExtractor().getSampleTime(),
+			trimStartTimeUs
+		);
+	}
+
+	/**
+	 * Transcoding step. Should be repeatedly called until {@link AudioFormatTranscoder#getState()}
+	 * returns {@link State#DONE}, but not after the done state is reached.
+	 *
+	 * May not be called before {@link AudioFormatTranscoder#setup()}.
+	 */
+	public abstract void step() throws UnsupportedAudioFormatException;
+
+	/**
+	 * Injects the audio as track to the muxer and transfers the class state to
+	 * {@link State#TRANSCODING}.
+	 *
+	 * May only be called if {@link AudioFormatTranscoder#getState()} returns
+	 * {@link State#WAITING_ON_MUXER}.
+	 *
+	 */
+	public void injectTrackToMuxer(@NonNull MediaMuxer muxer) {
+		if(this.state != State.WAITING_ON_MUXER) {
+			throw new IllegalStateException("The muxer may not be reconfigured");
+		}
+
+		this.muxer = muxer;
+		final int trackNumber = muxer.addTrack(this.outputFormat);
+		this.muxerTrack = Optional.of(trackNumber);
+		logger.debug("Added audio track number {} to muxer with format {}", trackNumber, this.outputFormat);
+
+		this.setState(State.TRANSCODING);
+	}
+
+	/**
+	 * Cleanup of codecs etc.
+	 * May only be called if {@link AudioFormatTranscoder#getState()} returns {@link State#DONE}.
+	 */
+	public void cleanup() throws Exception {
+		if (this.state != State.DONE) {
+			throw new IllegalStateException("Cleanup is only permitted after encoding has finished.");
+		}
+	}
+
+	//endregion
+
+	/**
+	 * Current state of the Audio Transcoder.
+	 *
+	 * States should be changed according to the definition order, but states may be skipped.
+	 */
+	public enum State {
+		/**
+		 * Uninitialized state
+		 */
+		INITIAL,
+
+		/**
+		 * Waiting for the input audio format to be configured by the decoder-codec
+		 */
+		DETECTING_INPUT_FORMAT,
+
+		/**
+		 * Waiting for the output format to be configured by the encoder-codec
+		 */
+		DETECTING_OUTPUT_FORMAT,
+
+		/**
+		 * The output format has been detected and we are waiting on the muxer injection.
+		 */
+		WAITING_ON_MUXER,
+
+		/**
+		 * Transcoding the audio.
+		 */
+		TRANSCODING,
+
+		/**
+		 * Transcoding has finished.
+		 */
+		DONE
+	}
+}

+ 9 - 21
app/src/main/java/ch/threema/app/mediaattacher/data/LabeledMediaItemEntity.java → app/src/main/java/ch/threema/app/video/transcoder/audio/AudioComponent.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,
@@ -19,29 +19,17 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.mediaattacher.data;
+package ch.threema.app.video.transcoder.audio;
 
-import java.util.ArrayList;
+import android.content.Context;
+import android.net.Uri;
 
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
+import java.io.IOException;
 
-@Entity(tableName = "media_items_table")
-public class LabeledMediaItemEntity extends MediaItemEntity {
+import ch.threema.app.video.transcoder.MediaComponent;
 
-	@ColumnInfo(name = "labels")
-	private ArrayList<String> labels;
-
-	public LabeledMediaItemEntity(int id, ArrayList<String> labels) {
-		super(id);
-		this.labels = labels;
-	}
-
-	public ArrayList<String> getLabels() {
-		return labels;
-	}
-
-	public void setLabels(ArrayList<String> labels) {
-		this.labels = labels;
+public class AudioComponent extends MediaComponent {
+	public AudioComponent(Context context, Uri srcUri) throws IOException {
+		super(context, srcUri, MediaComponent.COMPONENT_TYPE_AUDIO);
 	}
 }

+ 567 - 0
app/src/main/java/ch/threema/app/video/transcoder/audio/AudioFormatTranscoder.java

@@ -0,0 +1,567 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2019-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.video.transcoder.audio;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.os.Build;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import ch.threema.app.video.transcoder.VideoTranscoder;
+import ch.threema.app.video.transcoder.MediaComponent;
+import java8.util.Optional;
+
+
+/**
+ * Transcode an audio track to another format.
+ *
+ * Based on https://github.com/groupme/android-video-kit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@SuppressWarnings( "deprecation" ) // we use various deprecated methods to support older versions.
+public class AudioFormatTranscoder extends AbstractAudioTranscoder {
+	private static final Logger logger = LoggerFactory.getLogger(AudioFormatTranscoder.class);
+
+	private static final int TIMEOUT_USEC = VideoTranscoder.TIMEOUT_USEC;
+
+
+	//region Member Variables
+
+	/**
+	 * Requested output format for the transcoder.
+	 */
+	private final int outputAudioBitrate;
+
+	private MediaCodec encoder;
+	private MediaCodec decoder;
+
+	/**
+	 * Decoder input buffer access for for Android before {@link Build.VERSION_CODES#LOLLIPOP}
+	 */
+	private ByteBuffer[] decoderInputBuffers;
+
+	/**
+	 * Decoder output buffer access for for Android before {@link Build.VERSION_CODES#LOLLIPOP}
+	 */
+	private ByteBuffer[] decoderOutputBuffers;
+
+	/**
+	 * Encoder input buffer access for for Android before {@link Build.VERSION_CODES#LOLLIPOP}
+	 */
+	private ByteBuffer[] encoderInputBuffers;
+
+	/**
+	 * Encoder output buffer access for for Android before {@link Build.VERSION_CODES#LOLLIPOP}
+	 */
+	private ByteBuffer[] encoderOutputBuffers;
+
+	/**
+	 * Information about the last decoder output buffer that was made available.
+	 */
+	private MediaCodec.BufferInfo decoderOutputBufferInfo;
+
+	/**
+	 * Information about the last encoder output buffer that was made available.
+	 */
+	private MediaCodec.BufferInfo encoderOutputBufferInfo;
+
+	private boolean extractorDone;
+
+	/**
+	 * Next decoder output buffer that should be encoded
+	 */
+	private @NonNull Optional<Integer> decoderOutputBufferNextIndex = Optional.empty();
+
+	private boolean encoderDone = false;
+	private int resendRetryCount = 0;
+
+	/**
+	 * Keeps track of the last appended audio time, so that we do not append out-of-order audio.
+	 */
+	private long previousPresentationTime = -1;
+
+	//endregion
+
+	@Override
+	public boolean hasPendingIntermediateFrames() {
+		return this.decoderOutputBufferNextIndex.isPresent();
+	}
+
+	//region Setup
+
+	/**
+	 * @param component The audio component that should be transcoded
+	 * @param stats Transcoder Statistics
+	 * @param trimEndTimeMs Trim time from the end in ms (!)
+	 * @param outputAudioBitrate Target bitrate for the output audio
+	 */
+	public AudioFormatTranscoder(
+		AudioComponent component,
+		VideoTranscoder.Stats stats,
+		long trimEndTimeMs,
+		int outputAudioBitrate
+	) {
+		super(component, stats, trimEndTimeMs);
+		this.outputAudioBitrate = outputAudioBitrate;
+	}
+
+	@Override
+	public void setup() throws IOException, UnsupportedAudioFormatException {
+		if(this.getState() != State.INITIAL) {
+			throw new IllegalStateException("Setup may only be called on initialization");
+		}
+
+		MediaFormat inputFormat = this.component.getTrackFormat();
+
+		// Setup De/Encoder
+		this.setupAudioDecoder(inputFormat);
+		this.setupAudioEncoder(inputFormat);
+
+		this.setState(State.DETECTING_INPUT_FORMAT);
+	}
+
+	private void setupAudioDecoder(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)) {
+			logger.debug("audio decoder: set bit rate to {}", inputFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+		} else {
+			logger.debug("audio decoder: decoding unknown bit rate");
+		}
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+			this.decoder = this.getDecoderFor(inputFormat);
+		} else {
+			this.decoder = MediaCodec.createDecoderByType(VideoTranscoder.getMimeTypeFor(inputFormat));
+		}
+
+		this.decoder.configure(inputFormat, null, null, 0);
+		this.decoder.start();
+
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+			this.decoderInputBuffers = this.decoder.getInputBuffers();
+			this.decoderOutputBuffers = this.decoder.getOutputBuffers();
+		}
+		this.decoderOutputBufferInfo = new MediaCodec.BufferInfo();
+	}
+
+	private void setupAudioEncoder(MediaFormat inputFormat) throws IOException {
+		int sampleRate = inputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+		int channelCount = inputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+
+		this.outputFormat = MediaFormat.createAudioFormat(VideoTranscoder.Defaults.OUTPUT_AUDIO_MIME_TYPE,
+			sampleRate, channelCount);
+
+		this.outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, this.outputAudioBitrate);
+		this.outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, VideoTranscoder.Defaults.OUTPUT_AUDIO_AAC_PROFILE);
+
+		MediaCodecInfo codecInfo = VideoTranscoder.selectCodec(VideoTranscoder.Defaults.OUTPUT_AUDIO_MIME_TYPE);
+		logger.debug("audio encoder: set sample rate to {}", this.outputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+		logger.debug("audio encoder: set bit rate to {}", this.outputFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+
+		if (this.encoder == null) {
+			this.encoder = MediaCodec.createByCodecName(codecInfo.getName());
+		} else {
+			this.encoder.stop();
+		}
+		this.encoder.configure(this.outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+		this.encoder.start();
+
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+			this.encoderInputBuffers = this.encoder.getInputBuffers();
+			this.encoderOutputBuffers = this.encoder.getOutputBuffers();
+		}
+		this.encoderOutputBufferInfo = new MediaCodec.BufferInfo();
+	}
+
+	/**
+	 * Detect the most optimal decoder. This method is only available with
+	 * Android SDK >= {@link Build.VERSION_CODES#LOLLIPOP}
+	 *
+	 * @throws UnsupportedAudioFormatException if there is no decoder for this format available.
+	 * @throws IOException If the codec creation failed.
+	 */
+	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+	private MediaCodec getDecoderFor(MediaFormat inputFormat) throws UnsupportedAudioFormatException, IOException {
+
+		final MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+
+		if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
+			// Workaround for Framework bug, see {@link MediaCodecList#findDecoderForFormat)
+			inputFormat.setString(MediaFormat.KEY_FRAME_RATE, null);
+		}
+
+		@Nullable final String codec = mediaCodecList.findDecoderForFormat(inputFormat);
+
+		if (codec == null) {
+			logger.warn("Could not find a codec for input format {}", inputFormat);
+			throw new UnsupportedAudioFormatException(inputFormat);
+		}
+
+		return MediaCodec.createByCodecName(codec);
+	}
+
+	//endregion
+
+	@Override
+	public void step() throws UnsupportedAudioFormatException {
+		if (this.getState() == State.INITIAL || this.getState() == State.DONE) {
+			throw new IllegalStateException(String.format("Calling an audio transcoding step is not allowed in state %s", this.getState()));
+		}
+
+		if (this.getState() == State.WAITING_ON_MUXER) {
+			logger.debug("Skipping transcoding step, waiting for muxer to be injected.");
+			return;
+		}
+
+		// Extract audio from file and feed to decoder.
+		// Do not extract audio if we have determined the output format but we are not yet
+		// ready to mux the frames.
+		if (!this.extractorDone) {
+			this.extractorDone = this.pipeExtractorFrameToDecoder(this.decoder, this.decoderInputBuffers, this.component);
+		}
+
+		// Poll output frames from the audio decoder.
+		if (this.decoderOutputBufferNextIndex.isEmpty()) {
+			this.pollAudioFromDecoder(this.decoderOutputBufferInfo);
+		}
+
+		// Feed the pending audio buffer to the audio encoder
+		if (this.decoderOutputBufferNextIndex.isPresent()) {
+			this.pipeDecoderFrameToEncoder(this.decoderOutputBufferInfo);
+		}
+
+		// Poll frames from audio encoder and send them to the muxer
+		if (!this.encoderDone) {
+			this.encoderDone = this.pipeEncoderFrameToMuxer(this.encoderOutputBufferInfo);
+			if (this.encoderDone) {
+				this.setState(State.DONE);
+			}
+		}
+	}
+
+	//region Transcoding
+
+	/**
+	 * Extract and feed to decoder.
+	 *
+	 * @return Finished. True when it extracts the last frame.
+	 */
+	private boolean pipeExtractorFrameToDecoder(MediaCodec decoder, ByteBuffer[] buffers, MediaComponent component) {
+		final int decoderInputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
+
+		if (decoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+			logger.debug("no audio decoder input buffer");
+			return false;
+		}
+
+		logger.trace("audio extractor: returned input buffer: {}", decoderInputBufferIndex);
+
+		MediaExtractor extractor = component.getMediaExtractor();
+		int chunkSize = extractor.readSampleData(
+			Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
+				buffers[decoderInputBufferIndex] :
+				decoder.getInputBuffer(decoderInputBufferIndex), 0);
+
+		long sampleTime = extractor.getSampleTime();
+
+		logger.trace("audio extractor: returned buffer of chunkSize {}", chunkSize);
+		logger.trace("audio extractor: returned buffer for sampleTime {}", sampleTime);
+
+		if (this.trimEndTimeUs > 0 && sampleTime > this.trimEndTimeUs) {
+			logger.debug("audio extractor: The current sample is over the trim time. Lets stop.");
+			decoder.queueInputBuffer(
+				decoderInputBufferIndex,
+				0,
+				0,
+				0,
+				MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+			return true;
+		}
+
+		if (chunkSize >= 0) {
+			decoder.queueInputBuffer(
+				decoderInputBufferIndex,
+				0,
+				chunkSize,
+				sampleTime,
+				extractor.getSampleFlags());
+
+			this.stats.incrementExtractedFrameCount(component);
+		}
+
+		if (!extractor.advance()) {
+			logger.debug("audio extractor: EOS");
+			try {
+				decoder.queueInputBuffer(
+					decoderInputBufferIndex,
+					0,
+					0,
+					0,
+					MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+			} catch (Exception e) {
+				// On some Android versions, queueInputBuffers' native code throws an exception if
+				// BUFFER_FLAG_END_OF_STREAM is set on non-empty buffers.
+				this.resendRetryCount++;
+				if (this.resendRetryCount < 5) {
+					return this.pipeExtractorFrameToDecoder(decoder, buffers, component);
+				} else {
+					this.resendRetryCount = 0;
+					throw e;
+				}
+			}
+			return true;
+		}
+
+		return false;
+	}
+
+	private void pollAudioFromDecoder(MediaCodec.BufferInfo audioDecoderOutputBufferInfo) throws UnsupportedAudioFormatException {
+		final int decoderOutputBufferIndex;
+
+		try {
+			decoderOutputBufferIndex = this.decoder.dequeueOutputBuffer(audioDecoderOutputBufferInfo, TIMEOUT_USEC);
+		} catch(IllegalStateException exception) {
+			// We cannot determine the exact cause of the Exception, as it is only reported in the
+			// system's log. However, the most likely cause is an unsupported format/extension by
+			// the codec.
+			logger.warn("Decoder input buffer could not be dequeued.");
+			throw new UnsupportedAudioFormatException("Decoder error: " + exception.getMessage(), exception);
+		}
+
+		if (decoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+			logger.debug("audio decoder: no output buffer");
+			return;
+		}
+
+
+		if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+			logger.debug("audio decoder: output buffers changed");
+			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+				this.decoderOutputBuffers = this.decoder.getOutputBuffers();
+			}
+			return;
+		}
+
+		if (decoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+			MediaFormat decoderOutputFormat = this.decoder.getOutputFormat();
+			logger.debug("audio decoder: output format changed: {}", decoderOutputFormat);
+			try {
+				this.setupAudioEncoder(decoderOutputFormat);
+				this.setState(State.DETECTING_OUTPUT_FORMAT);
+			} catch (IOException e) {
+				logger.error("Reconfiguring encoder media format failed");
+			}
+			return;
+		}
+
+		logger.trace("audio decoder: returned output buffer: {}", decoderOutputBufferIndex);
+		logger.trace("audio decoder: returned buffer of size {}", audioDecoderOutputBufferInfo.size);
+
+		if ((audioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
+			logger.debug("audio decoder: codec config buffer");
+			this.decoder.releaseOutputBuffer(decoderOutputBufferIndex, false);
+			return;
+		}
+
+		logger.trace("audio decoder: returned buffer for time {}", audioDecoderOutputBufferInfo.presentationTimeUs);
+		logger.trace("audio decoder: output buffer is now pending: {}", decoderOutputBufferIndex);
+
+		this.decoderOutputBufferNextIndex = Optional.of(decoderOutputBufferIndex);
+		this.stats.audioDecodedFrameCount++;
+	}
+
+	private void pipeDecoderFrameToEncoder(MediaCodec.BufferInfo audioDecoderOutputBufferInfo) {
+		logger.trace("audio decoder: attempting to process pending buffer: {}", this.decoderOutputBufferNextIndex.get());
+
+		int encoderInputBufferIndex = this.encoder.dequeueInputBuffer(TIMEOUT_USEC);
+
+		if (encoderInputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+			logger.debug("no audio encoder input buffer");
+			return;
+		}
+
+		logger.trace("audio encoder: returned input buffer: {}", encoderInputBufferIndex);
+
+		ByteBuffer encoderInputBuffer =
+			Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
+				this.encoderInputBuffers[encoderInputBufferIndex] :
+				this.encoder.getInputBuffer(encoderInputBufferIndex);
+
+		int chunkSize = Math.min(audioDecoderOutputBufferInfo.size, encoderInputBuffer.capacity());
+	    long presentationTime = audioDecoderOutputBufferInfo.presentationTimeUs;
+
+		logger.trace("audio decoder: processing pending buffer: {}", this.decoderOutputBufferNextIndex.get());
+		logger.trace("audio decoder: pending buffer of size {}", chunkSize);
+		logger.trace("audio decoder: pending buffer for time {}", presentationTime);
+
+		if (chunkSize >= 0) {
+			ByteBuffer decoderOutputBuffer = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
+				this.decoderOutputBuffers[this.decoderOutputBufferNextIndex.get()].duplicate() :
+				this.decoder.getOutputBuffer(this.decoderOutputBufferNextIndex.get()).duplicate();
+			decoderOutputBuffer.position(audioDecoderOutputBufferInfo.offset);
+			decoderOutputBuffer.limit(audioDecoderOutputBufferInfo.offset + chunkSize);
+			encoderInputBuffer.position(0);
+			encoderInputBuffer.put(decoderOutputBuffer);
+
+			this.encoder.queueInputBuffer(
+				encoderInputBufferIndex,
+				0,
+				chunkSize,
+				presentationTime,
+				audioDecoderOutputBufferInfo.flags);
+		}
+
+		this.decoder.releaseOutputBuffer(this.decoderOutputBufferNextIndex.get(), false);
+		this.decoderOutputBufferNextIndex = Optional.empty();
+
+		if ((audioDecoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+			logger.debug("audio decoder: EOS");
+		}
+
+	}
+
+	private boolean pipeEncoderFrameToMuxer(MediaCodec.BufferInfo audioEncoderOutputBufferInfo) {
+		int encoderOutputBufferIndex = this.encoder.dequeueOutputBuffer(audioEncoderOutputBufferInfo, TIMEOUT_USEC);
+
+		if (encoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+			logger.debug("no audio encoder output buffer");
+			return false;
+		}
+
+		if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+			logger.debug("audio encoder: output buffers changed");
+			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+				this.encoderOutputBuffers = this.encoder.getOutputBuffers();
+			}
+			return false;
+		}
+
+		if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+			if (this.muxer != null) {
+				throw new IllegalStateException("audio encoder format may not be changed after muxer is initialized");
+			}
+			this.outputFormat = this.encoder.getOutputFormat();
+			logger.debug("audio encoder: output format changed to {}", this.outputFormat);
+			if (this.getState() == State.DETECTING_OUTPUT_FORMAT) {
+				this.setState(State.WAITING_ON_MUXER);
+			} else {
+				logger.debug("audio encoder: preliminary output format change detected, not switching state");
+			}
+			return false;
+		}
+
+		if ((audioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
+			logger.debug("audio encoder: codec config buffer");
+			// Simply ignore codec config buffers.
+			this.encoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
+			return false;
+		}
+
+		logger.trace("audio encoder: returned output buffer: {}", encoderOutputBufferIndex);
+		logger.trace("audio encoder: returned buffer of size {}", audioEncoderOutputBufferInfo.size);
+		logger.trace("audio encoder: returned buffer for time {}", audioEncoderOutputBufferInfo.presentationTimeUs);
+
+		if (audioEncoderOutputBufferInfo.size != 0) {
+			ByteBuffer encoderOutputBuffer = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
+				this.encoderOutputBuffers[encoderOutputBufferIndex] :
+				this.encoder.getOutputBuffer(encoderOutputBufferIndex);
+			if (audioEncoderOutputBufferInfo.presentationTimeUs >= this.previousPresentationTime) {
+				this.previousPresentationTime = audioEncoderOutputBufferInfo.presentationTimeUs;
+				this.muxer.writeSampleData(this.muxerTrack.get(), encoderOutputBuffer, audioEncoderOutputBufferInfo);
+			} else {
+				// skip old audio, as this only results in quality reduction.
+				logger.debug("audio encoder: presentationTimeUs {} < previousPresentationTime {}",
+					audioEncoderOutputBufferInfo.presentationTimeUs, this.previousPresentationTime);
+			}
+		}
+
+		this.encoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
+
+		if ((audioEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+			logger.debug("audio encoder: EOS");
+			return true;
+		}
+
+		this.stats.audioEncodedFrameCount++;
+
+		return false;
+	}
+
+	//endregion
+
+	@Override
+	public void cleanup() throws Exception {
+		super.cleanup();
+
+		Exception firstException = null; // Collect root cause exception without aborting cleanup
+
+		try {
+			if (this.decoder != null) {
+				this.decoder.stop();
+				this.decoder.release();
+			}
+		} catch (Exception e) {
+			logger.error("error while releasing decoder", e);
+			firstException = e;
+		}
+
+		try {
+			if (this.encoder != null) {
+				this.encoder.stop();
+				this.encoder.release();
+			}
+		} catch (Exception e) {
+			logger.error("error while releasing encoder", e);
+			if (firstException == null) {
+				firstException = e;
+			}
+		}
+
+		if (firstException != null) {
+			throw firstException;
+		}
+	}
+
+}

+ 124 - 0
app/src/main/java/ch/threema/app/video/transcoder/audio/AudioNullTranscoder.java

@@ -0,0 +1,124 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2019-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.video.transcoder.audio;
+
+import android.media.MediaCodec;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+
+import ch.threema.app.video.transcoder.VideoTranscoder;
+
+/**
+ * Keep audio input track and return it unchanged to the muxer
+ */
+public class AudioNullTranscoder extends AbstractAudioTranscoder {
+	private static final Logger logger = LoggerFactory.getLogger(AudioNullTranscoder.class);
+
+	/**
+	 * Time of the previously muxed sample.
+	 */
+	private long previousSampleTime;
+
+	private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+	private ByteBuffer buffer;
+
+
+	/**
+	 * @param component The audio component that should be transcoded
+	 * @param stats Transcoder Statistics
+	 * @param trimEndTimeMs Trim time from the end in ms (!)
+	 */
+	public AudioNullTranscoder(AudioComponent component, VideoTranscoder.Stats stats, long trimEndTimeMs) {
+		super(component, stats, trimEndTimeMs);
+	}
+
+	@Override
+	public boolean hasPendingIntermediateFrames() {
+		// We don't have any intermediate frames which could be pending when done.
+		return this.getState() != State.DONE;
+	}
+
+	@Override
+	public void setup() {
+		if(this.getState() != State.INITIAL) {
+			throw new IllegalStateException("Setup may only be called on initialization");
+		}
+
+		this.outputFormat = this.component.getTrackFormat();
+		this.buffer = ByteBuffer.allocate(this.outputFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE));
+
+		this.setState(State.WAITING_ON_MUXER);
+	}
+
+	@Override
+	public void step() {
+		if (this.getState() == State.INITIAL || this.getState() == State.DONE) {
+			throw new IllegalStateException(String.format("Calling an audio transcoding step is not allowed in state %s", this.getState()));
+		}
+
+		if (this.getState() == State.WAITING_ON_MUXER) {
+			logger.debug("Skipping transcoding step, waiting for muxer to be injected.");
+			return;
+		}
+
+		MediaExtractor extractor = this.component.getMediaExtractor();
+
+		final int sampleSize = extractor.readSampleData(this.buffer, 0);
+		this.bufferInfo.set(
+		 	0,
+			 sampleSize,
+			 extractor.getSampleTime(),
+			 extractor.getSampleFlags()
+		 );
+
+		logger.trace("audio extractor: returned buffer of chunkSize {}", sampleSize);
+		logger.trace("audio extractor: returned buffer for sampleTime {}", this.bufferInfo.presentationTimeUs);
+
+		if (this.trimEndTimeUs > 0 && this.bufferInfo.presentationTimeUs > this.trimEndTimeUs) {
+			logger.debug("audio extractor: The current sample is over the trim time. Lets stop.");
+			this.setState(State.DONE);
+			return;
+		}
+
+		if (sampleSize >= 0) {
+			this.stats.incrementExtractedFrameCount(this.component);
+
+			if (this.bufferInfo.presentationTimeUs >= this.previousSampleTime) {
+				this.previousSampleTime = this.bufferInfo.presentationTimeUs;
+				this.muxer.writeSampleData(this.muxerTrack.get(), this.buffer, this.bufferInfo);
+			} else {
+				// skip old audio, as this only results in quality reduction.
+				logger.debug("audio muxer: presentationTimeUs {} < previousPresentationTime {}",
+					this.bufferInfo.presentationTimeUs, this.previousSampleTime);
+			}
+		}
+
+		if (!extractor.advance()) {
+			this.setState(State.DONE);
+		}
+	}
+}

+ 10 - 16
app/src/main/java/ch/threema/app/mediaattacher/data/FailedMediaItemEntity.java → app/src/main/java/ch/threema/app/video/transcoder/audio/UnsupportedAudioFormatException.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,
@@ -19,27 +19,21 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.mediaattacher.data;
+package ch.threema.app.video.transcoder.audio;
 
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
+import android.media.MediaFormat;
 
-@Entity(tableName = "failed_media_items")
-public class FailedMediaItemEntity extends MediaItemEntity {
+public class UnsupportedAudioFormatException extends Exception {
 
-	@ColumnInfo(name = "timestamp")
-	private long timestamp;
-
-	public FailedMediaItemEntity(int id, long timestamp) {
-		super(id);
-		this.timestamp = timestamp;
+	public UnsupportedAudioFormatException(final MediaFormat inputFormat) {
+		super(inputFormat.toString());
 	}
 
-	public long getTimestamp() {
-		return timestamp;
+	public UnsupportedAudioFormatException(final String message) {
+		super(message);
 	}
 
-	public void setTimestamp(long timestamp) {
-		this.timestamp = timestamp;
+	public UnsupportedAudioFormatException(final String msg, final IllegalStateException cause) {
+		super(msg, cause);
 	}
 }

+ 15 - 13
app/src/main/java/ch/threema/app/voicemessage/AudioRecorder.java

@@ -46,6 +46,7 @@ public class AudioRecorder implements MediaRecorder.OnErrorListener, MediaRecord
 	}
 
 	public MediaRecorder prepare(Uri uri, int maxDuration, int samplingRate) {
+		logger.info("Preparing MediaRecorder with sampling rate {}", samplingRate);
 		mediaRecorder = new MediaRecorder();
 
 		mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
@@ -55,9 +56,7 @@ public class AudioRecorder implements MediaRecorder.OnErrorListener, MediaRecord
 		mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
 		mediaRecorder.setAudioEncodingBitRate(32000);
 		mediaRecorder.setAudioSamplingRate(samplingRate != 0 ? samplingRate : DEFAULT_SAMPLING_RATE_HZ);
-		mediaRecorder.setMaxFileSize(20*1024*1024);
-		//trial to mitigate instant onInfo -> maxDurationReached triggers on some devices
-//		mediaRecorder.setMaxDuration(maxDuration);
+		mediaRecorder.setMaxFileSize(20L*1024*1024);
 
 		mediaRecorder.setOnErrorListener(this);
 		mediaRecorder.setOnInfoListener(this);
@@ -65,10 +64,10 @@ public class AudioRecorder implements MediaRecorder.OnErrorListener, MediaRecord
 		try {
 			mediaRecorder.prepare();
 		} catch (IllegalStateException e) {
-			logger.info("IllegalStateException preparing MediaRecorder: " + e.getMessage());
+			logger.info("IllegalStateException preparing MediaRecorder: {}", e.getMessage());
 			return null;
 		} catch (IOException e) {
-			logger.info("IOException preparing MediaRecorder: " + e.getMessage());
+			logger.info("IOException preparing MediaRecorder: {}", e.getMessage());
 			return null;
 		}
 		return mediaRecorder;
@@ -78,27 +77,30 @@ public class AudioRecorder implements MediaRecorder.OnErrorListener, MediaRecord
 	public void onInfo(MediaRecorder mr, int what, int extra) {
 		switch (what) {
 			case MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED:
-				logger.info("Max recording duration reached.");
+				logger.info("Max recording duration reached. ({})", extra);
 				onStopListener.onRecordingStop();
 				break;
 			case MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED:
-				logger.info("Max recording filesize reached.");
+				logger.info("Max recording filesize reached. ({})", extra);
 				onStopListener.onRecordingStop();
 				break;
 			case MediaRecorder.MEDIA_RECORDER_INFO_UNKNOWN:
-				logger.info("Unknown media recorder info");
+				logger.info("Unknown media recorder info (What: {} / Extra: {})", what, extra);
 				onStopListener.onRecordingCancel();
 				break;
+			default:
+				logger.info("Undefined media recorder info type (What: {} / Extra: {})", what, extra);
+				break;
 		}
 	}
 
 	@Override
 	public void onError(MediaRecorder mr, int what, int extra) {
-		switch (what) {
-			case MediaRecorder.MEDIA_RECORDER_ERROR_UNKNOWN:
-				logger.info("Unkown media recorder error");
-				onStopListener.onRecordingCancel();
-				break;
+		if (what == MediaRecorder.MEDIA_RECORDER_ERROR_UNKNOWN) {
+			logger.info("Unknown media recorder error (What: {}, Extra: {})", what, extra);
+			onStopListener.onRecordingCancel();
+		} else {
+			logger.info("Undefined media recorder error type (What: {}, Extra: {})", what, extra);
 		}
 	}
 

+ 37 - 18
app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java

@@ -264,9 +264,13 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 			}
 			};
 			registerReceiver(audioStateChangedReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED));
-			try {
-				audioManager.startBluetoothSco();
-			} catch (Exception ignored) { }
+
+			if (!preferenceService.getVoiceRecorderBluetoothDisabled()) {
+				try {
+					audioManager.startBluetoothSco();
+				} catch (Exception ignored) {
+				}
+			}
 		} else {
 			if (bluetoothToogle != null) {
 				bluetoothToogle.setVisibility(View.INVISIBLE);
@@ -325,7 +329,7 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 		BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
 		boolean result = bluetoothAdapter != null && bluetoothAdapter.isEnabled() && bluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED;
 
-		logger.debug("isBluetoothEnabled = " + result);
+		logger.debug("isBluetoothEnabled = {}",result);
 
 		return result;
 	}
@@ -364,21 +368,22 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 
 	private void updateBluetoothButton() {
 		if (bluetoothToogle != null) {
-			@DrawableRes int stateRes = R.drawable.ic_bluetooth_searching_outline;
+			@DrawableRes int stateRes;
 
 			switch (scoAudioState) {
 				case AudioManager.SCO_AUDIO_STATE_CONNECTED:
 					stateRes = R.drawable.ic_bluetooth_connected;
+					preferenceService.setVoiceRecorderBluetoothDisabled(false);
 					break;
 				case AudioManager.SCO_AUDIO_STATE_DISCONNECTED:
+				case AudioManager.SCO_AUDIO_STATE_ERROR:
 					stateRes = R.drawable.ic_bluetooth_disabled;
+					preferenceService.setVoiceRecorderBluetoothDisabled(true);
 					break;
 				case AudioManager.SCO_AUDIO_STATE_CONNECTING:
+				default:
 					stateRes = R.drawable.ic_bluetooth_searching_outline;
 					break;
-				case AudioManager.SCO_AUDIO_STATE_ERROR:
-					stateRes = R.drawable.ic_bluetooth_disabled;
-					break;
 			}
 			bluetoothToogle.setImageResource(stateRes);
 		}
@@ -441,13 +446,13 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 
 		audioRecorder = new AudioRecorder(this);
 		audioRecorder.setOnStopListener(this);
-		logger.info("new audioRecorder instance " + audioRecorder);
+		logger.info("new audioRecorder instance {}", audioRecorder);
 		try {
 			mediaRecorder = audioRecorder.prepare(uri, MAX_VOICE_MESSAGE_LENGTH_MILLIS,
 			scoAudioState == AudioManager.SCO_AUDIO_STATE_CONNECTED ?
 			BLUETOOTH_SAMPLING_RATE_HZ :
 			DEFAULT_SAMPLING_RATE_HZ );
-			logger.info("new mediaRecorder instance " + mediaRecorder);
+			logger.info("Started recording with mediaRecorder instance {}", this.mediaRecorder);
 			if (mediaRecorder != null) {
 				startTimestamp = System.nanoTime();
 				mediaRecorder.start();
@@ -455,7 +460,7 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 
 		} catch (Exception e) {
 			logger.info("Error opening media recorder");
-			logger.error("Exception", e);
+			logger.error("Media Recorder Exception occurred", e);
 			releaseMediaRecorder();
 			return false;
 		}
@@ -506,12 +511,16 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 
 	private void pauseMedia() {
 		if (supportsPauseResume()) {
+			logger.info("Pause media recording");
 			if (status == MediaState.STATE_RECORDING) {
 				if (mediaRecorder != null) {
 					try {
 						mediaRecorder.pause();  // pause the recording
 					} catch (Exception e) {
-						//
+						logger.warn(
+							"Unexpected MediaRecorder Exception while pausing recording audio",
+							e
+						);
 					}
 					pauseTimestamp = System.nanoTime();
 					updateMediaState(MediaState.STATE_PAUSED);
@@ -522,7 +531,10 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 					try {
 						mediaPlayer.pause();  // pause the recording
 					} catch (Exception e) {
-						//
+						logger.warn(
+							"Unexpected MediaRecorder Exception while pausing playing audio",
+							e
+						);
 					}
 					pauseTimestamp = System.nanoTime();
 					updateMediaState(MediaState.STATE_PLAYING_PAUSED);
@@ -535,12 +547,16 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 
 	private void resumeRecording() {
 		if (supportsPauseResume()) {
+			logger.info("Resume media recording");
 			if (status == MediaState.STATE_PAUSED) {
 				if (mediaRecorder != null) {
 					try {
 						mediaRecorder.resume();  // pause the recording
 					} catch (Exception e) {
-						//
+						logger.warn(
+							"Unexpected MediaRecorder Exception while resuming playing audio",
+							e
+						);
 					}
 					pauseDuration += System.nanoTime() - pauseTimestamp;
 					updateMediaState(MediaState.STATE_RECORDING);
@@ -654,10 +670,13 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 				}
 				break;
 			case R.id.bluetooth_toggle:
-				if (audioManager.isBluetoothScoOn()) {
-					audioManager.stopBluetoothSco();
-				} else {
-					audioManager.startBluetoothSco();
+				try {
+					if (audioManager.isBluetoothScoOn()) {
+						audioManager.stopBluetoothSco();
+					} else {
+						audioManager.startBluetoothSco();
+					}
+				} catch (Exception ignored) {
 				}
 				updateBluetoothButton();
 				break;

+ 1 - 0
app/src/main/java/ch/threema/app/voip/Config.java

@@ -56,6 +56,7 @@ public class Config {
 
 	// Hardware video codec exclusion list (Manufacturer;Model;AndroidVersionPrefix)
 	@NonNull private final static String[] HW_VIDEO_CODEC_EXCLUSION_LIST = new String[] {
+		"Samsung;SM-A310F;7.", // Galaxy A3 (2016), Ticket #301129
 		"Samsung;SM-A320FL;8.", // Galaxy A3 (2017), Ticket #926673
 		"Samsung;SM-G930F;7.", // Galaxy S7, Ticket #573851
 		"Samsung;SM-G960F;8.", // Galaxy S9, Ticket #379708

+ 5 - 0
app/src/main/java/ch/threema/app/voip/PeerConnectionClient.java

@@ -805,6 +805,11 @@ public class PeerConnectionClient {
 		@Nullable Integer maxBitrate,
 		int maxFps
 	) {
+		if (!this.isVideoCallEnabled()) {
+			// Video calls not enabled, ignoring
+			return;
+		}
+
 		logger.info("setOutgoingVideoBandwidthLimit: " + maxBitrate);
 		final RtpSender sender = this.localVideoSender;
 		if (sender == null) {

+ 28 - 12
app/src/main/java/ch/threema/app/voip/services/VoipCallService.java

@@ -722,7 +722,6 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 	@Override
 	public void onDestroy() {
-		super.onDestroy();
 		logger.info("onDestroy");
 
 		if (localBroadcastReceiver != null) {
@@ -1000,6 +999,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@UiThread
 	public synchronized void onToggleAudioDevice(AudioDevice audioDevice) {
 		final long callId = this.voipStateService.getCallState().getCallId();
+		logCallInfo(callId, "Change audio device to {}", audioDevice);
 		if (this.audioManager != null) {
 			// Do the switch if possible
 			if (this.audioManager.hasAudioDevice(audioDevice)) {
@@ -1016,6 +1016,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 	@AnyThread
 	private synchronized void enableUIDebugStats(boolean enable) {
+		logger.info("Enable UI debug stats: {}", enable);
 		if (this.peerConnectionClient == null) {
 			logger.error("Cannot enable/disable UI debug stats: Peer connection client is null");
 			return;
@@ -1036,7 +1037,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@UiThread
 	private synchronized void startCall(boolean startActivity, boolean launchVideo) {
 		final long callId = this.voipStateService.getCallState().getCallId();
-		logCallTrace(callId, "startCall");
+		logCallInfo(callId, "Start call");
 
 		this.callStartedTimeMs = System.currentTimeMillis();
 		callStartedRealtimeMs = SystemClock.elapsedRealtime();
@@ -1349,6 +1350,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 */
 	@AnyThread
 	private synchronized void preDisconnect(long callId) {
+		logCallInfo(callId, "Pre-disconnect");
 		if (this.voipStateService != null && !this.voipStateService.getCallState().isIdle()) {
 			this.voipStateService.setStateDisconnecting(callId);
 			VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_PRE_DISCONNECT);
@@ -1365,6 +1367,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 		// Stop timers
 		synchronized (this.iceDisconnectedSoundTimer) {
+			logger.info("Cancel iceDisconnectedSoundTimeout");
 			if (this.iceDisconnectedSoundTimeout != null) {
 				this.iceDisconnectedSoundTimeout.cancel();
 				this.iceDisconnectedSoundTimeout = null;
@@ -1384,6 +1387,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			this.iceConnected = false;
 
 			synchronized (this) {
+				logger.info("Unregister debug stats collector");
 				// Unregister debug stats collector & do a final stats collection
 				final VoipStats.Builder statsBuilder = new VoipStats.Builder()
 					.withSelectedCandidatePair(false)
@@ -1403,12 +1407,14 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 					this.frameDetector = null;
 				}
 			}
+			logger.info("Closing peer connection client");
 			this.peerConnectionClient.close();
 			this.peerConnectionClient = null;
 		}
 
 		// Stop audio manager
 		if (this.audioManager != null) {
+			logger.info("Stopping audio manager");
 			VoipListenerManager.audioManagerListener.remove(this.audioManagerListener);
 			this.audioManager.stop();
 			this.audioManager = null;
@@ -1429,11 +1435,14 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 		// Update state
 		if (this.voipStateService != null) {
+			logger.info("Releasing video context, transition to IDLE state");
 			// Release video context
 			this.voipStateService.releaseVideoContext();
 			this.voipStateService.setVideoRenderMode(VIDEO_RENDER_FLAG_NONE);
 			this.voipStateService.setStateIdle();
 		}
+
+		logger.info("Cleanup done");
 	}
 
 	/**
@@ -1447,7 +1456,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		final CallStateSnapshot callState = this.voipStateService.getCallState();
 		final long callId = callState.getCallId();
 
-		logger.info(
+		logCallInfo(
+			callId,
 			"disconnect (isConnected? {} | isError? {} | message: {})",
 			this.iceConnected, this.isError, message
 		);
@@ -1577,9 +1587,15 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			// Log error
 			final long callId = this.voipStateService.getCallState().getCallId();
 			if (throwable != null) {
-				logger.error(callId + ": Aborting call: " + description, throwable);
+				logCallError(callId, "Aborting call: " + description, throwable);
 			} else {
-				logger.error(callId + ": Aborting call: " + description);
+				logCallError(callId, "Aborting call: {}", description);
+			}
+		} else {
+			if (throwable != null) {
+				logger.error("Aborting call: " + description, throwable);
+			} else {
+				logger.error("Aborting call: {}", description);
 			}
 		}
 
@@ -1719,8 +1735,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@Override
 	@AnyThread
 	public void onLocalDescription(long callId, final SessionDescription sdp) {
-		logCallInfo(callId, "onLocalDescription");
-		RuntimeUtil.runInAsyncTask(() -> {
+		new Thread(() -> {
+			logCallInfo(callId, "onLocalDescription");
 			synchronized (VoipCallService.this) {
 				final CallStateSnapshot callState = voipStateService.getCallState();
 				logCallInfo(callId, "Sending {} in call state {}", sdp.type, callState.getName());
@@ -1738,7 +1754,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 					logCallInfo(callId, "Discarding local description (wrong state)");
 				}
 			}
-		});
+		}, callId + ".onLocalDescription").start();
 	}
 
 	@Override
@@ -2019,10 +2035,10 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	                                            int rawResource,
 	                                            final String soundName) {
 		if (this.mediaPlayer != null) {
-			logCallError(callId, "Not playing {} sound, mediaPlayer is not null!", soundName);
+			logCallError(callId, "Not looping {} sound, mediaPlayer is not null!", soundName);
 			return;
 		}
-		logCallInfo(callId, "Playing {} sound...", soundName);
+		logCallInfo(callId, "Looping {} sound...", soundName);
 
 		// Initialize media player
 		this.mediaPlayer = new MediaPlayerStateWrapper();
@@ -2059,7 +2075,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@AnyThread
 	private synchronized void stopLoopingSound(long callId) {
 		if (this.mediaPlayer != null) {
-			logCallInfo(callId, "Stopping ringing tone...");
+			logCallInfo(callId, "Stopping looping sound...");
 			this.mediaPlayer.stop();
 			this.mediaPlayer.release();
 		}
@@ -2136,7 +2152,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 * @param elapsedTimeMs Timestamp at which the call was started (elapsed monotonic time since boot).
 	 */
 	private synchronized void showInCallNotification(long callStartedTimeMs, long elapsedTimeMs) {
-		logger.trace("showInCallNotification");
+		logger.info("Show onging in-call notification");
 
 		// Prepare hangup action
 		final Intent hangupIntent = new Intent(this, VoipCallService.class);

+ 1 - 0
app/src/main/java/ch/threema/app/webclient/Protocol.java

@@ -67,6 +67,7 @@ public class Protocol {
 	public final static String SUB_TYPE_PROFILE = "profile";
 	public final static String SUB_TYPE_CONNECTION_INFO = "connectionInfo";
 	public final static String SUB_TYPE_CONNECTION_DISCONNECT = "connectionDisconnect";
+	public final static String SUB_TYPE_ACTIVE_CONVERSATION = "activeConversation";
 	public final static String ARGUMENT_MODE = "mode";
 	public final static String ARGUMENT_MODE_NEW = "new";
 	public final static String ARGUMENT_MODE_MODIFIED = "modified";

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