Ver Fonte

Version 4.5-beta4

Threema há 5 anos atrás
pai
commit
329b33d7ba
87 ficheiros alterados com 1160 adições e 895 exclusões
  1. 6 13
      app/build.gradle
  2. 8 4
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  3. 4 4
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  4. 27 3
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  5. 2 1
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  6. 6 10
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  7. 28 20
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  8. 16 2
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  9. 1 1
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  10. 2 1
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  11. 3 1
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  12. 4 4
      app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java
  13. 3 1
      app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java
  14. 24 20
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  15. 25 21
      app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java
  16. 16 8
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  17. 5 2
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  18. 5 1
      app/src/main/java/ch/threema/app/camera/CameraActivity.java
  19. 6 3
      app/src/main/java/ch/threema/app/camera/CameraFragment.java
  20. 7 83
      app/src/main/java/ch/threema/app/camera/CameraUtil.java
  21. 27 23
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  22. 3 0
      app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java
  23. 32 1
      app/src/main/java/ch/threema/app/emojis/EmojiConversationTextView.java
  24. 30 0
      app/src/main/java/ch/threema/app/exceptions/TranscodeCanceledException.java
  25. 34 28
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  26. 4 3
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.java
  27. 1 1
      app/src/main/java/ch/threema/app/fragments/mediaviews/ImageViewFragment.java
  28. 4 0
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java
  29. 3 1
      app/src/main/java/ch/threema/app/mediaattacher/ControlPanelButton.java
  30. 97 18
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  31. 23 16
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java
  32. 1 1
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java
  33. 49 24
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  34. 6 2
      app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelingWorker.java
  35. 0 85
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  36. 0 24
      app/src/main/java/ch/threema/app/messagereceiver/DistributionListMessageReceiver.java
  37. 0 71
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  38. 0 40
      app/src/main/java/ch/threema/app/messagereceiver/MessageReceiver.java
  39. 0 46
      app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java
  40. 70 0
      app/src/main/java/ch/threema/app/preference/SettingsMediaFragment.java
  41. 3 3
      app/src/main/java/ch/threema/app/preference/SettingsTroubleshootingFragment.java
  42. 11 0
      app/src/main/java/ch/threema/app/receivers/ReSendMessagesBroadcastReceiver.java
  43. 2 0
      app/src/main/java/ch/threema/app/services/FileService.java
  44. 2 1
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  45. 6 3
      app/src/main/java/ch/threema/app/services/MessageService.java
  46. 196 142
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  47. 5 0
      app/src/main/java/ch/threema/app/services/NotificationService.java
  48. 5 0
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  49. 11 4
      app/src/main/java/ch/threema/app/services/PassphraseService.java
  50. 1 29
      app/src/main/java/ch/threema/app/services/VoiceActionService.java
  51. 16 11
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java
  52. 15 4
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  53. 16 19
      app/src/main/java/ch/threema/app/ui/ContentCommitComposeEditText.java
  54. 3 0
      app/src/main/java/ch/threema/app/ui/ControllerView.java
  55. 5 1
      app/src/main/java/ch/threema/app/ui/listitemholder/ComposeMessageHolder.java
  56. 1 1
      app/src/main/java/ch/threema/app/utils/BitmapUtil.java
  57. 2 2
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  58. 17 10
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  59. 1 1
      app/src/main/java/ch/threema/app/utils/IconUtil.java
  60. 1 1
      app/src/main/java/ch/threema/app/utils/IntentDataUtil.java
  61. 18 3
      app/src/main/java/ch/threema/app/utils/LocationUtil.java
  62. 7 1
      app/src/main/java/ch/threema/app/video/VideoTranscoder.java
  63. 3 6
      app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java
  64. 1 1
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/FileMessageCreateHandler.java
  65. 16 0
      app/src/main/res/drawable/ic_arrow_forward_outline.xml
  66. 3 3
      app/src/main/res/drawable/ic_map_center_marker.xml
  67. 22 0
      app/src/main/res/drawable/ic_outline_campaign.xml
  68. 10 0
      app/src/main/res/drawable/ic_outline_groups.xml
  69. 10 0
      app/src/main/res/drawable/ic_outline_rule_24.xml
  70. 10 0
      app/src/main/res/drawable/ic_outline_visibility.xml
  71. 10 0
      app/src/main/res/drawable/ic_outline_visibility_off.xml
  72. 2 1
      app/src/main/res/layout/activity_media_attach.xml
  73. 2 1
      app/src/main/res/layout/activity_text_chat_bubble.xml
  74. 7 1
      app/src/main/res/layout/button_media_attach.xml
  75. 16 8
      app/src/main/res/layout/conversation_list_item_quote.xml
  76. 13 7
      app/src/main/res/layout/conversation_list_item_recv.xml
  77. 13 7
      app/src/main/res/layout/conversation_list_item_send.xml
  78. 2 1
      app/src/main/res/layout/conversation_list_item_transcoder_view.xml
  79. 20 0
      app/src/main/res/layout/media_attach_control_panel.xml
  80. 48 24
      app/src/main/res/menu/activity_home.xml
  81. 12 0
      app/src/main/res/menu/activity_text_chat_bubble.xml
  82. 4 0
      app/src/main/res/values-de/strings.xml
  83. 4 0
      app/src/main/res/values/strings.xml
  84. 4 2
      app/src/main/res/values/styles.xml
  85. 1 1
      app/src/main/res/values/themes.xml
  86. 0 8
      app/src/main/res/xml/preference_developers.xml
  87. 1 1
      app/src/main/res/xml/preference_media.xml

+ 6 - 13
app/build.gradle

@@ -75,8 +75,8 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 657
-        versionName "4.5-beta3"
+        versionCode 658
+        versionName "4.5-beta4"
         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.5k-beta3"
+            versionName "4.5k-beta4"
             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.5k-beta3"
+            versionName "4.5k-beta4"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
@@ -407,14 +407,7 @@ dependencies {
     implementation 'com.takisoft.preferencex:preferencex:1.1.0'
     implementation 'com.takisoft.preferencex:preferencex-datetimepicker:1.1.0'
 
-    implementation 'com.google.mlkit:image-labeling:17.0.1'
-    implementation 'com.google.android.material:material:1.2.1'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.12.1'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.12.1'
-    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.13'
-
+    // 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'
@@ -431,7 +424,6 @@ dependencies {
     implementation "androidx.camera:camera-lifecycle:1.0.0-rc01"
     implementation "androidx.camera:camera-view:1.0.0-alpha20"
     implementation 'androidx.multidex:multidex:2.0.1'
-    // Lifecycle
     implementation "androidx.lifecycle:lifecycle-viewmodel:2.2.0"
     implementation "androidx.lifecycle:lifecycle-livedata:2.2.0"
     implementation "androidx.lifecycle:lifecycle-runtime:2.2.0"
@@ -446,6 +438,7 @@ dependencies {
     implementation 'com.google.android.material:material:1.2.1'
     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.1'
     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.13'

+ 8 - 4
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -40,6 +40,8 @@ import android.database.ContentObserver;
 import android.net.ConnectivityManager;
 import android.os.Build;
 import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
 import android.os.PowerManager;
 import android.os.StrictMode;
 import android.os.SystemClock;
@@ -69,6 +71,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Random;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 
@@ -77,6 +80,7 @@ import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import androidx.appcompat.app.AppCompatDelegate;
 import androidx.core.content.ContextCompat;
+import androidx.core.os.HandlerCompat;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.ProcessLifecycleOwner;
@@ -201,7 +205,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String INTENT_DATA_TIMESTAMP = "timestamp";
 	public static final String INTENT_DATA_EDITFOCUS = "editfocus";
 	public static final String INTENT_DATA_GROUP = "group";
-	public static final String INTENT_IS_GROUP_CHAT = "isGroupChat";
 	public static final String INTENT_DATA_DISTRIBUTION_LIST = "distribution_list";
 	public static final String INTENT_DATA_QRCODE = "qrcodestring";
 	public static final String INTENT_DATA_QRCODE_TYPE_OK = "qrcodetypeok";
@@ -229,7 +232,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final int NEW_MESSAGE_LOCKED_NOTIFICATION_ID = 725;
 	public static final int NEW_MESSAGE_PIN_LOCKED_NOTIFICATION_ID = 726;
 	public static final int SAFE_FAILED_NOTIFICATION_ID = 727;
-	public static final int IMMEDIATE_PUSH_NOTIFICATION_ID = 728;
 	public static final int SERVER_MESSAGE_NOTIFICATION_ID = 730;
 	public static final int NOT_ENOUGH_DISK_SPACE_NOTIFICATION_ID = 731;
 	public static final int UNSENT_MESSAGE_NOTIFICATION_ID = 732;
@@ -242,7 +244,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final int INCOMING_CALL_NOTIFICATION_ID = 800;
 
 	private static final String THREEMA_APPLICATION_LISTENER_TAG = "al";
-	public static final String AES_KEY_BACKUP_FILE = "keybackup.bin";
 	public static final String AES_KEY_FILE = "key.dat";
 	public static final String ECHO_USER_IDENTITY = "ECHOECHO";
 	public static final String PHONE_LINKED_PLACEHOLDER = "***";
@@ -263,7 +264,7 @@ 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";
-	private static final String WORKER_IMAGE_LABELS_PERIODIC = "ImageLabelsPeriodic";
+	public static final String WORKER_IMAGE_LABELS_PERIODIC = "ImageLabelsPeriodic";
 
 	private static Context context;
 
@@ -279,6 +280,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 	public static String uriScheme;
 
+	public static ExecutorService sendMessageExecutorService = Executors.newFixedThreadPool(4);
+
 	private static boolean checkAppReplacingState(Context context) {
 		// workaround https://code.google.com/p/android/issues/detail?id=56296
 		if (context.getResources() == null) {
@@ -303,6 +306,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
 				.detectLeakedSqlLiteObjects()
 				.detectLeakedClosableObjects()
+				.penaltyLog()
 				.penaltyListener(Executors.newSingleThreadExecutor(), v -> {
 					logger.info("STRICTMODE VMPolicy: " + v.getCause());
 					logStackTrace(v.getStackTrace());

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

@@ -90,7 +90,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 		//check master key
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 
-		if (!(masterKey != null && masterKey.isLocked()) && !checkHiddenChatLock(getIntent(), ID_HIDDEN_CHECK_ON_CREATE)) {
+		if (!(masterKey != null && masterKey.isLocked())) {
 			this.initActivity(this.bundle);
 		}
 	}
@@ -103,6 +103,8 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 		logger.debug("initActivity");
 
+		checkHiddenChatLock(getIntent(), ID_HIDDEN_CHECK_ON_CREATE);
+
 		this.getFragments();
 
 		if (findViewById(R.id.messages) != null) {
@@ -119,6 +121,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 			getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).commit();
 		}
 
+
 		return true;
 	}
 
@@ -218,9 +221,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 				if (resultCode == RESULT_OK) {
 					serviceManager.getScreenLockService().setAuthenticated(true);
-					if (!this.initActivity(this.bundle)) {
-						finish();
-					}
 				} else {
 					finish();
 				}

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

@@ -68,8 +68,10 @@ import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.appcompat.widget.AppCompatImageView;
 import androidx.appcompat.widget.Toolbar;
+import androidx.core.view.MenuCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.lifecycle.LifecycleOwner;
@@ -131,6 +133,7 @@ import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.activities.CallActivity;
 import ch.threema.app.voip.services.VoipCallService;
+import ch.threema.app.webclient.Config;
 import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.client.ConnectionState;
 import ch.threema.client.ConnectionStateListener;
@@ -1170,12 +1173,28 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}.execute();
 	}
 
+	@SuppressLint("RestrictedApi")
 	@Override
 	public boolean onCreateOptionsMenu(Menu menu) {
 		super.onCreateOptionsMenu(menu);
 
 		// Inflate the menu; this adds items to the action bar if it is present.
 		getMenuInflater().inflate(R.menu.activity_home, menu);
+
+		MenuCompat.setGroupDividerEnabled(menu, true);
+
+		try {
+			// restricted API
+			if (menu instanceof MenuBuilder) {
+				MenuBuilder menuBuilder = (MenuBuilder) menu;
+				menuBuilder.setOptionalIconsVisible(true);
+
+				ConfigUtils.themeMenu(menu, ConfigUtils.getColorFromAttribute(this, R.attr.textColorSecondary));
+			}
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
+
 		return true;
 	}
 
@@ -1284,9 +1303,14 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 			MenuItem privateChatToggleMenuItem = menu.findItem(R.id.menu_toggle_private_chats);
 			if (privateChatToggleMenuItem != null) {
-				privateChatToggleMenuItem.setTitle(preferenceService.isPrivateChatsHidden() ?
-					R.string.title_show_private_chats :
-					R.string.title_hide_private_chats);
+				if (preferenceService.isPrivateChatsHidden()) {
+					privateChatToggleMenuItem.setIcon(R.drawable.ic_outline_visibility);
+					privateChatToggleMenuItem.setTitle(R.string.title_show_private_chats);
+				} else {
+					privateChatToggleMenuItem.setIcon(R.drawable.ic_outline_visibility_off);
+					privateChatToggleMenuItem.setTitle(R.string.title_hide_private_chats);
+				}
+				ConfigUtils.themeMenuItem(privateChatToggleMenuItem, ConfigUtils.getColorFromAttribute(this, R.attr.textColorSecondary));
 			}
 
 			Boolean addDisabled;

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

@@ -28,6 +28,7 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.graphics.Bitmap;
+import android.graphics.Canvas;
 import android.graphics.Color;
 import android.location.Location;
 import android.location.LocationManager;
@@ -414,7 +415,7 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 		return new MarkerOptions()
 				.position(latLng)
 				.title(name)
-				.setIcon(IconFactory.getInstance(this).fromBitmap(bitmap))
+				.setIcon(IconFactory.getInstance(this).fromBitmap(LocationUtil.moveMarker(bitmap)))
 				.setSnippet(provider);
 	}
 

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

@@ -58,6 +58,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
+import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
@@ -173,11 +174,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		return true;
 	}
 
-	public interface SendCompletionHandler {
-		void onError(String errorMessage);
-		void onCompleted();
-	}
-
 	public int getLayoutResource() {
 		return R.layout.activity_recipientlist;
 	}
@@ -734,7 +730,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	private void copySelectedFiles() {
 		for (int i = 0; i < mediaItems.size(); i++) {
 			MediaItem mediaItem = mediaItems.get(i);
-			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem.getUri()));
+			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 
 			if ("content".equals(mediaItem.getUri().getScheme())) {
 				try {
@@ -753,7 +749,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, mediaItems.get(0).getCaption());
 			startComposeActivity(intent);
 		} else if (messageReceivers.length > 1 || mediaItems.size() > 0) {
-			new Thread(() -> messageService.sendMedia(mediaItems, Arrays.asList(messageReceivers))).start();
+			messageService.sendMediaAsync(mediaItems, Arrays.asList(messageReceivers));
 			startComposeActivity(intent);
 		} else {
 			startComposeActivity(intent);
@@ -910,7 +906,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					new AsyncTask<Void, Void, Void>() {
 						@Override
 						protected void onPreExecute() {
-							GenericProgressDialog.newInstance(R.string.copy_message_action, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_FILECOPY);
+							GenericProgressDialog.newInstance(R.string.importing_files, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_FILECOPY);
 						}
 
 						@Override
@@ -1107,7 +1103,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	 * Message action frontends
 	 */
 
-	@WorkerThread
+	@AnyThread
 	private void sendMediaMessage(final MessageReceiver[] messageReceivers, final Uri uri, final String caption, final int type, @Nullable final String mimeType, @FileData.RenderingType final int renderingType) {
 		final MediaItem mediaItem = new MediaItem(uri, type);
 		if (mimeType != null) {
@@ -1117,7 +1113,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			mediaItem.setRenderingType(renderingType);
 		}
 		mediaItem.setCaption(caption);
-		messageService.sendMedia(Collections.singletonList(mediaItem), Arrays.asList(messageReceivers), null);
+		messageService.sendMediaAsync(Collections.singletonList(mediaItem), Arrays.asList(messageReceivers));
 	}
 
 	@WorkerThread

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

@@ -94,6 +94,7 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.services.MessageServiceImpl;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.ComposeEditText;
@@ -250,13 +251,13 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		Intent intent = getIntent();
 		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);
 
 		if (this.pickFromCamera && savedInstanceState == null) {
 			launchCamera();
 		}
 
-		this.useExternalCamera = intent.getBooleanExtra(EXTRA_USE_EXTERNAL_CAMERA, false);
-		this.messageReceivers = IntentDataUtil.getMessageReceiversFromIntent(intent);
 		ArrayList<Uri> urilist = intent.getParcelableArrayListExtra(EXTRA_URLILIST);
 		if (urilist != null) {
 			intent.removeExtra(EXTRA_URLILIST);
@@ -689,7 +690,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		final Intent cameraIntent;
 		final int requestCode;
-		if (!CameraUtil.isBlacklistedCamera() && !useExternalCamera) {
+		if (CameraUtil.isInternalCameraSupported() && !useExternalCamera) {
 			// use internal camera
 			cameraIntent = new Intent(this, CameraActivity.class);
 			cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraFilePath);
@@ -708,7 +709,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
 			cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileService.getShareFileUri(cameraFile));
 			cameraIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
-			requestCode = ThreemaActivity.ACTIVITY_ID_PICK_CAMERA;
+			requestCode = ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_EXTERNAL;
 		}
 
 		try {
@@ -882,7 +883,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 							}
 							logger.debug("type is " );
 
-							BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.rotationForImage(getApplicationContext(), fixedUri);
+							BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(getApplicationContext(), fixedUri);
 
 							MediaItem mediaItem = new MediaItem(fixedUri, type);
 							mediaItem.setExifRotation((int) exifOrientation.getRotation());
@@ -932,7 +933,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 				protected List<MediaItem> doInBackground(Void... voids) {
 					for (MediaItem incomingMediaItem : incomingMediaItems) {
 						if (incomingMediaItem.getUri() != null) {
-							BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.rotationForImage(getApplicationContext(), incomingMediaItem.getUri());
+							BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(getApplicationContext(), incomingMediaItem.getUri());
 							incomingMediaItem.setExifRotation((int) exifOrientation.getRotation());
 							incomingMediaItem.setExifFlip(exifOrientation.getFlip());
 
@@ -992,7 +993,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 						}
 					});
 					break;
-				case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA:
+				case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_EXTERNAL:
 				case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_INTERNAL:
 					ConfigUtils.setRequestedOrientation(this, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
 					if (ConfigUtils.supportsVideoCapture() && intent != null && intent.getBooleanExtra(CameraActivity.EXTRA_VIDEO_RESULT, false)) {
@@ -1002,7 +1003,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 							if (videoFile.exists() && videoFile.length() > 0) {
 								final Uri videoUri = Uri.fromFile(videoFile);
 								if (videoUri != null) {
-									final int position = addItemFromCamera(MediaItem.TYPE_VIDEO_CAM, videoUri, 0);
+									final int position = addItemFromCamera(MediaItem.TYPE_VIDEO_CAM, videoUri, null);
 									showBigImage(position);
 									break;
 								}
@@ -1012,10 +1013,9 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 						if (!TestUtil.empty(this.cameraFilePath)) {
 							final Uri cameraUri = Uri.fromFile(new File(this.cameraFilePath));
 							if (cameraUri != null) {
-								int exifRotation = 0;
-								if (requestCode != ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_INTERNAL) {
-									exifRotation = (int) BitmapUtil.rotationForImage(this, cameraUri).getRotation();
-									logger.debug("*** ExifRotation: " + exifRotation);
+								BitmapUtil.ExifOrientation exifOrientation = null;
+								if (requestCode == ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_EXTERNAL) {
+									exifOrientation =  BitmapUtil.getExifOrientation(this, cameraUri);
 								} else {
 									if (bigImageView != null) {
 										bigImageView.setVisibility(View.GONE);
@@ -1025,7 +1025,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 									}
 								}
 
-								final int position = addItemFromCamera(MediaItem.TYPE_IMAGE_CAM, cameraUri, exifRotation);
+								final int position = addItemFromCamera(MediaItem.TYPE_IMAGE_CAM, cameraUri, exifOrientation);
 								showBigImage(position);
 
 								break;
@@ -1059,11 +1059,17 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			return;
 		}
 
-		new Thread(() -> {
-			if (messageService.sendMedia(mediaItems, messageReceivers) != null) {
-				fileService.cleanTempDirs();
+		messageService.sendMediaAsync(mediaItems, messageReceivers, new MessageServiceImpl.SendResultListener() {
+			@Override
+			public void onError(String errorMessage) { }
+
+			@Override
+			public void onCompleted() {
+				new Thread(() -> {
+					fileService.cleanTempDirs();
+				}).start();
 			}
-		}).start();
+		});
 
 		setResult(RESULT_OK);
 		finish();
@@ -1086,14 +1092,16 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	@UiThread
-	private int addItemFromCamera(int type, Uri imageUri, int imageRotation) {
+	private int addItemFromCamera(int type, Uri imageUri, BitmapUtil.ExifOrientation exifOrientation) {
 		if (mediaItems.size() >= MAX_SELECTABLE_IMAGES) {
 			Snackbar.make((View) gridView.getParent(), String.format(getString(R.string.max_images_reached), MAX_SELECTABLE_IMAGES), Snackbar.LENGTH_LONG).show();
 		}
 
 		MediaItem item = new MediaItem(imageUri, type);
-		item.setRotation(imageRotation);
-		item.setExifRotation(imageRotation);
+		if (exifOrientation != null) {
+			item.setExifRotation((int) exifOrientation.getRotation());
+			item.setExifFlip(exifOrientation.getFlip());
+		}
 
 		if (type == MediaItem.TYPE_VIDEO_CAM) {
 			item.setMimeType(MimeUtil.MIME_TYPE_VIDEO_MP4);

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

@@ -65,7 +65,9 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 	private static final int CONTEXT_MENU_FORWARD = 600;
 	private static final int CONTEXT_MENU_GROUP = 22200;
 
-	private ActionMode.Callback textSelectionCallback = new ActionMode.Callback() {
+	private EmojiConversationTextView textView;
+
+	private final ActionMode.Callback textSelectionCallback = new ActionMode.Callback() {
 		@Override
 		public boolean onCreateActionMode(ActionMode mode, Menu menu) {
 			return true;
@@ -180,6 +182,18 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 
 		Toolbar toolbar = findViewById(R.id.toolbar);
 		toolbar.setNavigationOnClickListener(view -> finish());
+		toolbar.setOnMenuItemClickListener(item -> {
+			if (item.isChecked()) {
+				item.setChecked(false);
+				textView.setIgnoreMarkup(true);
+				textView.setText(messageModel.getBody());
+			} else {
+				item.setChecked(true);
+				textView.setIgnoreMarkup(false);
+				textView.setText(messageModel.getBody());
+			}
+			return true;
+		});
 		toolbar.setTitle(title);
 
 		// TODO: replace with "toolbarNavigationButtonStyle" attribute in theme as soon as all Toolbars have been switched to Material Components
@@ -195,7 +209,7 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 		View footerView = LayoutInflater.from(this).inflate(footerLayout, null);
 		((ViewGroup) findViewById(R.id.footer)).addView(footerView);
 
-		EmojiConversationTextView textView = findViewById(R.id.text_view);
+		textView = findViewById(R.id.text_view);
 		textView.setText(messageModel.getBody());
 
 		LinkifyUtil.getInstance().linkify(null, this, textView, messageModel, messageModel.getBody().length() < 80, false, null);

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

@@ -45,7 +45,7 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 	final static public int ACTIVITY_ID_VERIFY_MOBILE = 20005;
 	final static public int ACTIVITY_ID_CONTACT_DETAIL = 20007;
 	final static public int ACTIVITY_ID_UNLOCK_MASTER_KEY = 20008;
-	final static public int ACTIVITY_ID_PICK_CAMERA = 20011;
+	final static public int ACTIVITY_ID_PICK_CAMERA_EXTERNAL = 20011;
 	final static public int ACTIVITY_ID_PICK_CAMERA_INTERNAL = 20012;
 	final static public int ACTIVITY_ID_SET_PASSPHRASE = 20013;
 	final static public int ACTIVITY_ID_CHANGE_PASSPHRASE = 20014;

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

@@ -403,7 +403,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.quoteThumbnail = itemView.findViewById(R.id.quote_thumbnail);
 					holder.quoteTypeImage = itemView.findViewById(R.id.quote_type_image);
 					holder.transcoderView = itemView.findViewById(R.id.transcoder_view);
-					holder.readOnTextView = itemView.findViewById(R.id.read_on_text);
+					holder.readOnContainer = itemView.findViewById(R.id.read_on_container);
+					holder.readOnButton = itemView.findViewById(R.id.read_on_button);
 				}
 				itemView.setTag(holder);
 			}

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

@@ -136,7 +136,9 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 					this.getThumbnailWidth()
 			);
 			holder.bodyTextView.setWidth(this.getThumbnailWidth());
-			holder.attachmentImage.invalidate();
+			if (holder.attachmentImage != null) {
+				holder.attachmentImage.invalidate();
+			}
 			if (fileData.getRenderingType() == FileData.RENDERING_STICKER) {
 				holder.messageBlockView.setBackground(null);
 			} else {

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

@@ -56,11 +56,11 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 
 	private static final String LISTENER_TAG = "decorator";
 	private MessagePlayer audioMessagePlayer;
-	private PowerManager powerManager;
-	private PowerManager.WakeLock audioPlayerWakelock;
+	private final PowerManager powerManager;
+	private final PowerManager.WakeLock audioPlayerWakelock;
 
 	public AudioChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
-		super(context, messageModel, helper);
+		super(context.getApplicationContext(), messageModel, helper);
 		this.powerManager = (PowerManager) context.getApplicationContext().getSystemService(Context.POWER_SERVICE);
 		this.audioPlayerWakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":AudioPlayer");
 	}
@@ -295,7 +295,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 								RuntimeUtil.runOnUiThread(() -> {
 									if (holder.position == position) {
 										if (holder.seekBar != null) {
-											holder.seekBar.setMax(audioMessagePlayer.getDuration());
+											holder.seekBar.setMax(holder.messagePlayer.getDuration());
 										}
 										updateProgressCount(holder, pos);
 

+ 3 - 1
app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java

@@ -101,7 +101,9 @@ public class BallotChatAdapterDecorator extends ChatAdapterDecorator {
 				}
 			}, holder.messageBlockView);
 
-			holder.controller.setImageResource(R.drawable.ic_poll_outline);
+			if (holder.controller != null) {
+				holder.controller.setImageResource(R.drawable.ic_poll_outline);
+			}
 
 		} catch (NotAllowedException x) {
 			logger.error("Exception", x);

+ 24 - 20
app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java

@@ -43,6 +43,7 @@ import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.messageplayer.FileMessagePlayer;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AvatarConverterUtil;
 import ch.threema.app.utils.FileUtil;
@@ -85,26 +86,29 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, fileData));
 
 		if (holder.controller != null) {
-			holder.controller.setOnClickListener(v -> {
-				int status = holder.controller.getStatus();
-
-				switch (status) {
-					case ControllerView.STATUS_READY_TO_PLAY:
-					case ControllerView.STATUS_READY_TO_DOWNLOAD:
-					case ControllerView.STATUS_NONE:
-						prepareDownload(fileData, fileMessagePlayer);
-						break;
-					case ControllerView.STATUS_PROGRESSING:
-						if (getMessageModel().isOutbox() && (getMessageModel().getState() == MessageState.PENDING || getMessageModel().getState() == MessageState.SENDING)) {
-							getMessageService().cancelMessageUpload(getMessageModel());
-						} else {
-							fileMessagePlayer.cancel();
-						}
-						break;
-					case ControllerView.STATUS_READY_TO_RETRY:
-						if (onClickRetry != null) {
-							onClickRetry.onClick(getMessageModel());
-						}
+			holder.controller.setOnClickListener(new DebouncedOnClickListener(500) {
+				@Override
+				public void onDebouncedClick(View v) {
+					int status = holder.controller.getStatus();
+
+					switch (status) {
+						case ControllerView.STATUS_READY_TO_PLAY:
+						case ControllerView.STATUS_READY_TO_DOWNLOAD:
+						case ControllerView.STATUS_NONE:
+							FileChatAdapterDecorator.this.prepareDownload(fileData, fileMessagePlayer);
+							break;
+						case ControllerView.STATUS_PROGRESSING:
+							if (FileChatAdapterDecorator.this.getMessageModel().isOutbox() && (FileChatAdapterDecorator.this.getMessageModel().getState() == MessageState.PENDING || FileChatAdapterDecorator.this.getMessageModel().getState() == MessageState.SENDING)) {
+								FileChatAdapterDecorator.this.getMessageService().cancelMessageUpload(FileChatAdapterDecorator.this.getMessageModel());
+							} else {
+								fileMessagePlayer.cancel();
+							}
+							break;
+						case ControllerView.STATUS_READY_TO_RETRY:
+							if (onClickRetry != null) {
+								onClickRetry.onClick(FileChatAdapterDecorator.this.getMessageModel());
+							}
+					}
 				}
 			});
 		}

+ 25 - 21
app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java

@@ -38,6 +38,7 @@ import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ImageViewUtil;
@@ -79,27 +80,30 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 		this.setOnClickListener(view -> viewImage(getMessageModel(), holder.attachmentImage), holder.messageBlockView);
 
 		if (holder.controller != null) {
-			holder.controller.setOnClickListener(v -> {
-				int status = holder.controller.getStatus();
-
-				switch (status) {
-					case ControllerView.STATUS_PROGRESSING:
-						if (getMessageModel().isOutbox() && (getMessageModel().getState() == MessageState.PENDING || getMessageModel().getState() == MessageState.SENDING)) {
-							getMessageService().cancelMessageUpload(getMessageModel());
-						} else {
-							imageMessagePlayer.cancel();
-						}
-						break;
-					case ControllerView.STATUS_READY_TO_RETRY:
-						if (onClickRetry != null) {
-							onClickRetry.onClick(getMessageModel());
-						}
-						break;
-					case ControllerView.STATUS_READY_TO_DOWNLOAD:
-						imageMessagePlayer.open();
-						break;
-					default:
-						viewImage(getMessageModel(), holder.attachmentImage);
+			holder.controller.setOnClickListener(new DebouncedOnClickListener(500) {
+				@Override
+				public void onDebouncedClick(View v) {
+					int status = holder.controller.getStatus();
+
+					switch (status) {
+						case ControllerView.STATUS_PROGRESSING:
+							if (ImageChatAdapterDecorator.this.getMessageModel().isOutbox() && (ImageChatAdapterDecorator.this.getMessageModel().getState() == MessageState.PENDING || ImageChatAdapterDecorator.this.getMessageModel().getState() == MessageState.SENDING)) {
+								ImageChatAdapterDecorator.this.getMessageService().cancelMessageUpload(ImageChatAdapterDecorator.this.getMessageModel());
+							} else {
+								imageMessagePlayer.cancel();
+							}
+							break;
+						case ControllerView.STATUS_READY_TO_RETRY:
+							if (onClickRetry != null) {
+								onClickRetry.onClick(ImageChatAdapterDecorator.this.getMessageModel());
+							}
+							break;
+						case ControllerView.STATUS_READY_TO_DOWNLOAD:
+							imageMessagePlayer.open();
+							break;
+						default:
+							ImageChatAdapterDecorator.this.viewImage(ImageChatAdapterDecorator.this.getMessageModel(), holder.attachmentImage);
+					}
 				}
 			});
 		}

+ 16 - 8
app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java

@@ -24,6 +24,9 @@ package ch.threema.app.adapters.decorators;
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.LinearGradient;
+import android.graphics.Shader;
 import android.text.method.LinkMovementMethod;
 import android.view.View;
 
@@ -31,6 +34,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import ch.threema.app.activities.TextChatBubbleActivity;
+import ch.threema.app.emojis.EmojiConversationTextView;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.ConfigUtils;
@@ -46,8 +50,7 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 	private static final Logger logger = LoggerFactory.getLogger(ChatAdapterDecorator.class);
 	private static final int MAX_TEXT_BUBBLE_CONTENTS_LENGTH = 640;
 
-	private int quoteType;
-
+	private final int quoteType;
 
 	public TextChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
 		super(context, messageModel, helper);
@@ -73,18 +76,23 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.bodyTextView.setText(formatTextString(messageText, this.filterString));
 			}
 
-			if (holder.readOnTextView != null) {
+			if (holder.readOnContainer != null) {
 				if (messageText != null && messageText.length() > MAX_TEXT_BUBBLE_CONTENTS_LENGTH) {
-					// todo append an ellipsis even though the text capacity has been reached
-					holder.readOnTextView.setVisibility(View.VISIBLE);
-					holder.readOnTextView.setOnClickListener(view -> {
+					if (holder.bodyTextView instanceof EmojiConversationTextView) {
+						((EmojiConversationTextView) holder.bodyTextView).setFade(true);
+					}
+					holder.readOnContainer.setVisibility(View.VISIBLE);
+					holder.readOnButton.setOnClickListener(view -> {
 						Intent intent = new Intent(helper.getFragment().getContext(), TextChatBubbleActivity.class);
 						IntentDataUtil.append(this.getMessageModel(), intent);
 						helper.getFragment().startActivity(intent);
 					});
 				} else {
-					holder.readOnTextView.setVisibility(View.GONE);
-					holder.readOnTextView.setOnClickListener(null);
+					if (holder.bodyTextView instanceof EmojiConversationTextView) {
+						((EmojiConversationTextView) holder.bodyTextView).setFade(false);
+					}
+					holder.readOnContainer.setVisibility(View.GONE);
+					holder.readOnButton.setOnClickListener(null);
 				}
 			}
 

+ 5 - 2
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -28,6 +28,8 @@ import android.text.format.Formatter;
 import android.view.View;
 import android.widget.Toast;
 
+import com.google.android.exoplayer2.ui.DefaultTimeBar;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -37,6 +39,7 @@ import ch.threema.app.R;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.LinkifyUtil;
@@ -89,9 +92,9 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 			}
 		}, holder.messageBlockView);
 
-		holder.controller.setOnClickListener(new View.OnClickListener() {
+		holder.controller.setOnClickListener(new DebouncedOnClickListener(500) {
 			@Override
-			public void onClick(View v) {
+			public void onDebouncedClick(View v) {
 				int status = holder.controller.getStatus();
 
 				logger.debug("onClick status = " + status);

+ 5 - 1
app/src/main/java/ch/threema/app/camera/CameraActivity.java

@@ -113,7 +113,11 @@ public class CameraActivity extends ThreemaAppCompatActivity implements CameraFr
 			teardownCamera();
 		}
 
-		super.onDestroy();
+		try {
+			super.onDestroy();
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
 	}
 
 	@Override

+ 6 - 3
app/src/main/java/ch/threema/app/camera/CameraFragment.java

@@ -236,13 +236,16 @@ public class CameraFragment extends Fragment {
 			new AsyncTask<Void, Boolean, byte[]>() {
 				@Override
 				protected void onPreExecute() {
-					ConstraintLayout constraintLayout = container.findViewById(R.id.camera_ui_container);
-
 					if (!lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED)) {
 						cancel(true);
 					} else {
 						progressBar.setVisibility(View.VISIBLE);
-						constraintLayout.setVisibility(View.GONE);
+						if (container != null) {
+							ConstraintLayout constraintLayout = container.findViewById(R.id.camera_ui_container);
+							if (constraintLayout != null) {
+								constraintLayout.setVisibility(View.GONE);
+							}
+						}
 					}
 				}
 

+ 7 - 83
app/src/main/java/ch/threema/app/camera/CameraUtil.java

@@ -52,87 +52,7 @@ public class CameraUtil {
 
 	// list of cameras that are incompatible with camerax
 	private static final HashSet<String> BLACKLISTED_CAMERAS = new HashSet<String>() {{
-/*		// Pixel 4
-		add("Pixel 4");
-		add("Pixel 4 XL");
-
-		// Huawei Mate 10
-		add("ALP-L29");
-		add("ALP-L09");
-		add("ALP-AL00");
-
-		// Huawei Mate 10 Pro
-		add("BLA-L29");
-		add("BLA-L09");
-		add("BLA-AL00");
-		add("BLA-A09");
-
-		// Huawei Mate 20
-		add("HMA-L29");
-		add("HMA-L09");
-		add("HMA-LX9");
-		add("HMA-AL00");
-
-		// Huawei Mate 20 Pro
-		add("LYA-L09");
-		add("LYA-L29");
-		add("LYA-AL00");
-		add("LYA-AL10");
-		add("LYA-TL00");
-		add("LYA-L0C");
-
-		// Huawei P20
-		add("EML-L29C");
-		add("EML-L09C");
-		add("EML-AL00");
-		add("EML-TL00");
-		add("EML-L29");
-		add("EML-L09");
-
-		// Huawei P20 Pro
-		add("CLT-L29C");
-		add("CLT-L29");
-		add("CLT-L09C");
-		add("CLT-L09");
-		add("CLT-AL00");
-		add("CLT-AL01");
-		add("CLT-TL01");
-		add("CLT-AL00L");
-		add("CLT-L04");
-		add("HW-01K");
-
-		// Huawei P30
-		add("ELE-L29");
-		add("ELE-L09");
-		add("ELE-AL00");
-		add("ELE-TL00");
-		add("ELE-L04");
-
-		// Huawei P30 Pro
-		add("VOG-L29");
-		add("VOG-L09");
-		add("VOG-AL00");
-		add("VOG-TL00");
-		add("VOG-L04");
-		add("VOG-AL10");
-
-		// Huawei Honor 10
-		add("COL-AL10");
-		add("COL-L29");
-		add("COL-L19");
-
-		// Huawei Honor 10 View
-		add("BKL-AL20");
-		add("BKL-L04");
-		add("BKL-L09");
-		add("BKL-AL00");
-
-		// OnePlus 3T
-		add("A3010");
-
-		// Sony Xperia 5
-		add("J9210");
-*/	}};
+	}};
 
 	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	private static byte[] transformByteArray(@NonNull byte[] data, Rect cropRect, int rotation, boolean flip) throws IOException {
@@ -213,7 +133,11 @@ public class CameraUtil {
 		return MAX_QUALITY_CAMERAS.contains(Build.MODEL) ? ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY : ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY;
 	}
 
-	public static boolean isBlacklistedCamera() {
-		return Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || BLACKLISTED_CAMERAS.contains(Build.MODEL);
+	/**
+	 * Return true if the internal camera is compatible with the current hardware or operating system
+	 * @return
+	 */
+	public static boolean isInternalCameraSupported() {
+		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !BLACKLISTED_CAMERAS.contains(Build.MODEL);
 	}
 }

+ 27 - 23
app/src/main/java/ch/threema/app/camera/VideoEditView.java

@@ -60,8 +60,11 @@ import org.slf4j.LoggerFactory;
 import java.io.File;
 
 import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.UiThread;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ui.MediaItem;
@@ -73,7 +76,7 @@ import ch.threema.app.video.VideoTimelineCache;
 
 import static com.google.android.exoplayer2.C.TIME_END_OF_SOURCE;
 
-public class VideoEditView extends FrameLayout {
+public class VideoEditView extends FrameLayout implements DefaultLifecycleObserver {
 	private static final Logger logger = LoggerFactory.getLogger(VideoEditView.class);
 
 	private static final int MOVING_NONE = 0;
@@ -120,6 +123,8 @@ public class VideoEditView extends FrameLayout {
 
 		this.touchTargetWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_touch_target_width);
 
+		((LifecycleOwner)context).getLifecycle().addObserver(this);
+
 		LayoutInflater.from(context).inflate(R.layout.view_video_edit, this, true);
 
 		this.timelineGridLayout = findViewById(R.id.video_timeline);
@@ -352,28 +357,6 @@ public class VideoEditView extends FrameLayout {
 		return super.onTouchEvent(event);
 	}
 
-	@Override
-	protected void onDetachedFromWindow() {
-		if (thumbnailThread != null && thumbnailThread.isAlive()) {
-			thumbnailThread.interrupt();
-		}
-
-		if (videoView != null) {
-			if (videoView.getPlayer() != null) {
-				videoView.setPlayer(null);
-			}
-		}
-
-		if (videoPlayer != null) {
-			videoPlayer.stop();
-			videoPlayer.release();
-		}
-
-		this.context = null;
-
-		super.onDetachedFromWindow();
-	}
-
 	@SuppressLint("StaticFieldLeak")
 	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	@UiThread
@@ -624,4 +607,25 @@ public class VideoEditView extends FrameLayout {
 		}
 		return 0;
 	}
+
+
+	@Override
+	public void onDestroy(@NonNull LifecycleOwner owner) {
+		if (thumbnailThread != null && thumbnailThread.isAlive()) {
+			thumbnailThread.interrupt();
+		}
+
+		if (videoView != null) {
+			if (videoView.getPlayer() != null) {
+				videoView.setPlayer(null);
+			}
+		}
+
+		if (videoPlayer != null) {
+			videoPlayer.stop();
+			videoPlayer.release();
+		}
+
+		this.context = null;
+	}
 }

+ 3 - 0
app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java

@@ -209,6 +209,9 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 				case PENDING:
 					stateResource = R.string.state_pending;
 					break;
+				case TRANSCODING:
+					stateResource = R.string.state_transcoding;
+					break;
 			}
 		} else {
 			stateResource = R.string.state_sent;

+ 32 - 1
app/src/main/java/ch/threema/app/emojis/EmojiConversationTextView.java

@@ -22,14 +22,21 @@
 package ch.threema.app.emojis;
 
 import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.LinearGradient;
+import android.graphics.Shader;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import ch.threema.app.utils.ConfigUtils;
 
 public class EmojiConversationTextView extends androidx.appcompat.widget.AppCompatTextView {
 	protected final EmojiMarkupUtil emojiMarkupUtil;
+	private boolean isFade = false;
+	private boolean ignoreMarkup = false;
 
 	public EmojiConversationTextView(Context context) {
 		this(context, null);
@@ -48,12 +55,28 @@ public class EmojiConversationTextView extends androidx.appcompat.widget.AppComp
 	@Override
 	public void setText(@Nullable CharSequence text, BufferType type) {
 		if (emojiMarkupUtil != null) {
-			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, false, true), type);
+			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, this.ignoreMarkup, true), type);
 		} else {
 			super.setText(text, type);
 		}
 	}
 
+	@Override
+	protected void onDraw(Canvas canvas) {
+		if (isFade) {
+			getPaint().clearShadowLayer();
+			getPaint().setShader(
+				new LinearGradient(0,
+					getHeight(),
+					0,
+					getHeight() - (getTextSize() * 3),
+					Color.TRANSPARENT,
+					ConfigUtils.getColorFromAttribute(getContext(), android.R.attr.textColorPrimary),
+					Shader.TileMode.CLAMP));
+		}
+		super.onDraw(canvas);
+	}
+
 	@Override
 	public void invalidateDrawable(@NonNull Drawable drawable) {
 		if (drawable instanceof EmojiDrawable) {
@@ -62,4 +85,12 @@ public class EmojiConversationTextView extends androidx.appcompat.widget.AppComp
 			super.invalidateDrawable(drawable);
 		}
 	}
+
+	public void setFade(boolean isFade) {
+		this.isFade = isFade;
+	}
+
+	public void setIgnoreMarkup(boolean ignoreMarkup) {
+		this.ignoreMarkup = ignoreMarkup;
+	}
 }

+ 30 - 0
app/src/main/java/ch/threema/app/exceptions/TranscodeCanceledException.java

@@ -0,0 +1,30 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2013-2020 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.exceptions;
+
+import ch.threema.base.ThreemaException;
+
+public class TranscodeCanceledException extends ThreemaException {
+	public TranscodeCanceledException() {
+		super("Transcode canceled");
+	}
+}

+ 34 - 28
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -275,7 +275,6 @@ public class ComposeMessageFragment extends Fragment implements
 	private static final int PERMISSION_REQUEST_SAVE_MESSAGE = 2;
 	private static final int PERMISSION_REQUEST_ATTACH_VOICE_MESSAGE = 7;
 	private static final int PERMISSION_REQUEST_ATTACH_CAMERA = 8;
-	private static final int PERMISSION_REQUEST_ATTACH_CAMERA_EXTERNAL = 10;
 	private static final int PERMISSION_REQUEST_ATTACH_CAMERA_VIDEO = 11;
 
 	private static final int ACTIVITY_ID_VOICE_RECORDER = 9731;
@@ -741,10 +740,15 @@ public class ComposeMessageFragment extends Fragment implements
 		@Override
 		public void onScanCompleted(String scanResult) {
 			if (scanResult != null && scanResult.length() > 0) {
-				if (messageText != null) {
-					messageText.setText(scanResult);
-					messageText.setSelection(messageText.length());
-				}
+				RuntimeUtil.runOnUiThread(new Runnable() {
+					@Override
+					public void run() {
+						if (messageText != null) {
+							messageText.setText(scanResult);
+							messageText.setSelection(messageText.length());
+						}
+					}
+				});
 			}
 		}
 	};
@@ -915,7 +919,7 @@ public class ComposeMessageFragment extends Fragment implements
 					return;
 				}
 				if (ConfigUtils.requestCameraPermissions(activity, this, PERMISSION_REQUEST_ATTACH_CAMERA)) {
-					attachCamera(false);
+					attachCamera();
 				}
 			});
 			updateCameraButton();
@@ -1121,21 +1125,25 @@ public class ComposeMessageFragment extends Fragment implements
 					}
 				});
 			} else {
-				ViewCompat.setOnApplyWindowInsetsListener(activity.findViewById(R.id.compose_activity_parent).getRootView(), new OnApplyWindowInsetsListener() {
-					@Override
-					public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
+				try {
+					ViewCompat.setOnApplyWindowInsetsListener(activity.getWindow().getDecorView().getRootView(), new OnApplyWindowInsetsListener() {
+						@Override
+						public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
 
-						logger.info("%%% system window top " + insets.getSystemWindowInsetTop() + " bottom " + insets.getSystemWindowInsetBottom());
-						logger.info("%%% stable insets top " + insets.getStableInsetTop() + " bottom " + insets.getStableInsetBottom());
+							logger.info("%%% system window top " + insets.getSystemWindowInsetTop() + " bottom " + insets.getSystemWindowInsetBottom());
+							logger.info("%%% stable insets top " + insets.getStableInsetTop() + " bottom " + insets.getStableInsetBottom());
 
-						if (insets.getSystemWindowInsetBottom() == insets.getStableInsetBottom()) {
-							activity.onSoftKeyboardClosed();
-						} else {
-							activity.onSoftKeyboardOpened(insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom());
+							if (insets.getSystemWindowInsetBottom() == insets.getStableInsetBottom()) {
+								activity.onSoftKeyboardClosed();
+							} else {
+								activity.onSoftKeyboardOpened(insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom());
+							}
+							return insets;
 						}
-						return insets;
-					}
-				});
+					});
+				} catch (NullPointerException e) {
+					logger.error("Exception", e);
+				}
 			}
 			activity.addOnSoftKeyboardChangedListener(this);
 		}
@@ -2561,7 +2569,12 @@ public class ComposeMessageFragment extends Fragment implements
 						try {
 							messageService.resendMessage(messageModel, messageReceiver, null);
 						} catch (Exception e) {
-							//show error?
+							RuntimeUtil.runOnUiThread(new Runnable() {
+								@Override
+								public void run() {
+									Toast.makeText(getContext(), R.string.original_file_no_longer_avilable, Toast.LENGTH_LONG).show();
+								}
+							});
 						}
 					}
 				}
@@ -3480,15 +3493,12 @@ public class ComposeMessageFragment extends Fragment implements
 		return intent;
 	}
 
-	private void attachCamera(boolean useExternalCamera) {
+	private void attachCamera() {
 		Intent previewIntent = IntentDataUtil.addMessageReceiversToIntent(new Intent(activity, SendMediaActivity.class), new MessageReceiver[]{this.messageReceiver});
 		if (this.actionBarTitleTextView != null && this.actionBarTitleTextView.getText() != null) {
 			previewIntent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, this.actionBarTitleTextView.getText().toString());
 		}
 		previewIntent.putExtra(ThreemaApplication.INTENT_DATA_PICK_FROM_CAMERA, true);
-		if (useExternalCamera) {
-			previewIntent.putExtra(SendMediaActivity.EXTRA_USE_EXTERNAL_CAMERA, useExternalCamera);
-		}
 		AnimationUtil.startActivityForResult(activity, null, previewIntent, ThreemaActivity.ACTIVITY_ID_SEND_MEDIA);
 	}
 
@@ -4340,10 +4350,7 @@ public class ComposeMessageFragment extends Fragment implements
 					break;
 				case PERMISSION_REQUEST_ATTACH_CAMERA:
 					updateCameraButton();
-					attachCamera(false);
-					break;
-				case PERMISSION_REQUEST_ATTACH_CAMERA_EXTERNAL:
-					attachCamera(true);
+					attachCamera();
 					break;
 			}
 		} else {
@@ -4359,7 +4366,6 @@ public class ComposeMessageFragment extends Fragment implements
 					}
 					break;
 				case PERMISSION_REQUEST_ATTACH_CAMERA:
-				case PERMISSION_REQUEST_ATTACH_CAMERA_EXTERNAL:
 				case PERMISSION_REQUEST_ATTACH_CAMERA_VIDEO:
 					preferenceService.setCameraPermissionRequestShown(true);
 					if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {

+ 4 - 3
app/src/main/java/ch/threema/app/fragments/MyIDFragment.java

@@ -235,10 +235,11 @@ public class MyIDFragment extends MainFragment
 			this.nicknameTextView = fragmentView.findViewById(R.id.nickname);
 
 			if (isReadonlyProfile) {
-				fragmentView.findViewById(R.id.profile_edit).setVisibility(View.GONE);
+				this.fragmentView.findViewById(R.id.profile_edit).setVisibility(View.GONE);
+				this.avatarView.setEditable(false);
 			} else {
-				fragmentView.findViewById(R.id.profile_edit).setVisibility(View.VISIBLE);
-				fragmentView.findViewById(R.id.profile_edit).setOnClickListener(this);
+				this.fragmentView.findViewById(R.id.profile_edit).setVisibility(View.VISIBLE);
+				this.fragmentView.findViewById(R.id.profile_edit).setOnClickListener(this);
 			}
 
 			AppCompatSpinner spinner = fragmentView.findViewById(R.id.picrelease_spinner);

+ 1 - 1
app/src/main/java/ch/threema/app/fragments/mediaviews/ImageViewFragment.java

@@ -137,7 +137,7 @@ public class ImageViewFragment extends MediaViewFragment {
 			imageViewReference.get().setImage(ImageSource.uri(file.getPath()));
 
 			try {
-				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.rotationForImage(getContext(), Uri.fromFile(file));
+				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(getContext(), Uri.fromFile(file));
 				logger.debug("Orientation = " + exifOrientation);
 				if (exifOrientation.getRotation() != 0) {
 					imageViewReference.get().setOrientation((int) exifOrientation.getRotation());

+ 4 - 0
app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java

@@ -377,6 +377,10 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	private void setupLocationComponent(Style style) {
 		logger.debug("setupLocationComponent");
 
+		if (style == null) {
+			return;
+		}
+
 		locationComponent = mapboxMap.getLocationComponent();
 		locationComponent.activateLocationComponent(LocationComponentActivationOptions.builder(this, style).build());
 		locationComponent.setCameraMode(CameraMode.TRACKING);

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

@@ -22,6 +22,7 @@
 package ch.threema.app.mediaattacher;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
@@ -40,6 +41,7 @@ import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.appcompat.widget.AppCompatImageView;
 import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.core.widget.ImageViewCompat;
 import ch.threema.app.R;
 import ch.threema.app.utils.ConfigUtils;
 
@@ -88,7 +90,7 @@ public class ControlPanelButton extends FrameLayout {
 
 	private void setForegroundColor(@ColorInt int color) {
 		this.labelTextView.setTextColor(color);
-		this.labelImageView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
+		ImageViewCompat.setImageTintList(this.labelImageView, ColorStateList.valueOf(color));
 	}
 
 	private void setFillAndStrokeColor(@ColorInt int fillColor, @ColorInt int strokeColor, int fillColorAlpha) {

+ 97 - 18
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -22,6 +22,8 @@
 package ch.threema.app.mediaattacher;
 
 import android.Manifest;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
 import android.animation.ValueAnimator;
 import android.annotation.SuppressLint;
 import android.app.Activity;
@@ -37,7 +39,10 @@ import android.provider.ContactsContract;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewStub;
+import android.view.ViewTreeObserver;
 import android.widget.Button;
+import android.widget.HorizontalScrollView;
+import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.Toast;
 
@@ -54,6 +59,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.appcompat.widget.FitWindowsFrameLayout;
 import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
 import androidx.core.app.ActivityCompat;
 import ch.threema.app.QRScannerUtil;
 import ch.threema.app.R;
@@ -63,6 +69,7 @@ import ch.threema.app.actions.SendAction;
 import ch.threema.app.activities.SendMediaActivity;
 import ch.threema.app.activities.ThreemaActivity;
 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.listeners.QRCodeScanListener;
@@ -97,6 +104,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 	private static final int PERMISSION_REQUEST_ATTACH_CONTACT = 2;
 	private static final int PERMISSION_REQUEST_QR_READER = 3;
 	private static final int PERMISSION_REQUEST_ATTACH_FROM_GALLERY = 4;
+	private static final int PERMISSION_REQUEST_ATTACH_FROM_EXTERNAL_CAMERA = 6;
 
 	protected static final int REQUEST_CODE_ATTACH_FROM_GALLERY = 2454;
 
@@ -105,8 +113,10 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 
 	private ConstraintLayout sendPanel;
 	private LinearLayout attachPanel;
-	private ControlPanelButton attachGalleryButton, attachLocationButton, attachQRButton, attachBallotButton, attachContactButton, attachFileButton, sendButton, editButton, cancelButton;
+	private ControlPanelButton attachGalleryButton, attachLocationButton, attachQRButton, attachBallotButton, attachContactButton, attachFileButton, sendButton, editButton, cancelButton, attachFromExternalCameraButton;
 	private Button selectCounterButton;
+	private ImageView moreArrowView;
+	private HorizontalScrollView scrollView;
 
 	private MessageReceiver messageReceiver;
 	private MessageService messageService;
@@ -133,6 +143,35 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			}
 			return false;
 		});
+
+		this.scrollView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+			@Override
+			public void onGlobalLayout() {
+				rootView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+
+				// we shortly display an arrow hinting users at more options that are available when scrolling
+				// of course, we do this only when some buttons are obscured
+				View child = (View) scrollView.getChildAt(0);
+				if (child != null) {
+					int childWidth = (child).getWidth();
+					if (scrollView.getWidth() < (childWidth + scrollView.getPaddingLeft() + scrollView.getPaddingRight())) {
+						if (moreArrowView != null) {
+							moreArrowView.setVisibility(View.VISIBLE);
+							moreArrowView.animate()
+								.alpha(0f)
+								.setStartDelay(1500)
+								.setDuration(500)
+								.setListener(new AnimatorListenerAdapter() {
+									@Override
+									public void onAnimationEnd(Animator animation) {
+										moreArrowView.setVisibility(View.GONE);
+									}
+								});
+						}
+					}
+				}
+			}
+		});
 	}
 
 	@Override
@@ -163,6 +202,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		this.controlPanel = findViewById(R.id.control_panel);
 		this.sendPanel = findViewById(R.id.send_panel);
 		this.attachPanel = findViewById(R.id.attach_options_container);
+		this.scrollView = findViewById(R.id.attach_panel);
 
 		// Horizontal buttons in the panel
 		this.attachGalleryButton = attachPanel.findViewById(R.id.attach_gallery);
@@ -171,6 +211,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		this.attachQRButton = attachPanel.findViewById(R.id.attach_qr_code);
 		this.attachBallotButton = attachPanel.findViewById(R.id.attach_poll);
 		this.attachContactButton = attachPanel.findViewById(R.id.attach_contact);
+		this.attachFromExternalCameraButton = attachPanel.findViewById(R.id.attach_system_camera);
 
 		// Send/edit/cancel buttons
 		this.sendButton = sendPanel.findViewById(R.id.send);
@@ -182,10 +223,25 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		this.controlPanel.setOnClickListener(null);
 		this.sendPanel.setOnClickListener(null);
 
+		// additional decoration
+		this.moreArrowView = findViewById(R.id.more_arrow);
+		this.moreArrowView.setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				if (scrollView != null) {
+					scrollView.smoothScrollTo(65535, 0);
+				}
+			}
+		});
+
 		// If the media grid is shown, we don't need the gallery button
 		if (shouldShowMediaGrid()) {
 			this.attachGalleryButton.setVisibility(View.GONE);
 		}
+
+		if (attachFromExternalCameraButton != null && !CameraUtil.isInternalCameraSupported()) {
+			this.attachFromExternalCameraButton.setVisibility(View.GONE);
+		}
 	}
 
 	private void setupControlPanelListeners() {
@@ -199,6 +255,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		editButton.setOnClickListener(this);
 		cancelButton.setOnClickListener(this);
 		selectCounterButton.setOnClickListener(this);
+		attachFromExternalCameraButton.setOnClickListener(this);
 	}
 	/* end setup methods */
 
@@ -212,6 +269,10 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		}
 
 		if (count > 0) {
+			if (moreArrowView != null) {
+				moreArrowView.setVisibility(View.GONE);
+			}
+
 			if (sendPanel.getVisibility() == View.GONE) {
 				attachPanel.setVisibility(View.GONE);
 				sendPanel.setVisibility(View.VISIBLE);
@@ -335,10 +396,15 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 					attachImageFromGallery();
 				}
 				break;
+			case R.id.attach_system_camera:
+				if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_ATTACH_FROM_EXTERNAL_CAMERA)) {
+					attachFromExternalCamera();
+				}
 			default:
 				break;
 		}
 	}
+
 	/* end section action methods */
 
 	/* start section callback methods */
@@ -362,6 +428,9 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 				case PERMISSION_REQUEST_ATTACH_FROM_GALLERY:
 					attachImageFromGallery();
 					break;
+				case PERMISSION_REQUEST_ATTACH_FROM_EXTERNAL_CAMERA:
+					attachFromExternalCamera();
+					break;
 			}
 		} else {
 			switch (requestCode) {
@@ -386,6 +455,10 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 						showPermissionRationale(R.string.permission_storage_required);
 					}
 					break;
+				case PERMISSION_REQUEST_ATTACH_FROM_EXTERNAL_CAMERA:
+					if (!ActivityCompat.shouldShowRequestPermissionRationale(MediaAttachActivity.this, Manifest.permission.CAMERA)) {
+						showPermissionRationale(R.string.permission_camera_photo_required);
+					}
 			}
 		}
 	}
@@ -420,6 +493,8 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 					onEdit(FileUtil.getUrisFromResult(intent));
 					break;
 				case ThreemaActivity.ACTIVITY_ID_CREATE_BALLOT:
+					// fallthrough
+				case ThreemaActivity.ACTIVITY_ID_SEND_MEDIA:
 					finish();
 					break;
 				case ThreemaActivity.ACTIVITY_ID_PICK_FILE:
@@ -475,7 +550,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			String mimeType = MimeUtil.getMimeTypeFromUri(uri);
 			if (MimeUtil.isVideoFile(mimeType) || MimeUtil.isImageFile(mimeType)) {
 				MediaItem mediaItem = new MediaItem(uri, mimeType, null);
-				mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), uri));
+				mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 				mediaItems.add(mediaItem);
 			}
 		}
@@ -498,15 +573,13 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 
 		for (Uri uri : list) {
 			MediaItem mediaItem = new MediaItem(uri, MimeUtil.getMimeTypeFromUri(uri), null);
-			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), uri));
+			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 			mediaItems.add(mediaItem);
 		}
 
 		if (mediaItems.size() > 0) {
-			new Thread(() -> {
-				messageService.sendMedia(mediaItems, Collections.singletonList(messageReceiver));
-				finish();
-			}).start();
+			messageService.sendMediaAsync(mediaItems, Collections.singletonList(messageReceiver));
+			finish();
 		}
 	}
 
@@ -531,6 +604,14 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		FileUtil.selectFile(this, null, mimeTypes, REQUEST_CODE_ATTACH_FROM_GALLERY, true, MAX_BLOB_SIZE, null);
 	}
 
+	private void attachFromExternalCamera() {
+		Intent intent = IntentDataUtil.addMessageReceiversToIntent(new Intent(this, SendMediaActivity.class), new MessageReceiver[]{this.messageReceiver});
+		intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, messageReceiver.getDisplayName());
+		intent.putExtra(ThreemaApplication.INTENT_DATA_PICK_FROM_CAMERA, true);
+		intent.putExtra(SendMediaActivity.EXTRA_USE_EXTERNAL_CAMERA, true);
+		AnimationUtil.startActivityForResult(this, null, intent, ThreemaActivity.ACTIVITY_ID_SEND_MEDIA);
+	}
+
 	private void createBallot() {
 		Intent intent = new Intent(this, BallotWizardActivity.class);
 		IntentDataUtil.addMessageReceiverToIntent(intent, messageReceiver);
@@ -617,18 +698,16 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			return;
 		}
 
-		new Thread(() -> {
-			List<MediaItem> mediaItems = new ArrayList<>(uriList.size());
-			for(int i = 0; i < uriList.size(); i++) {
-				MediaItem mediaItem = new MediaItem(uriList.get(i), MediaItem.TYPE_NONE);
-				if (captions != null) {
-					mediaItem.setCaption(captions.get(i));
-				}
-				mediaItems.add(mediaItem);
+		List<MediaItem> mediaItems = new ArrayList<>(uriList.size());
+		for(int i = 0; i < uriList.size(); i++) {
+			MediaItem mediaItem = new MediaItem(uriList.get(i), MediaItem.TYPE_NONE);
+			if (captions != null) {
+				mediaItem.setCaption(captions.get(i));
 			}
-			messageService.sendMedia(mediaItems, Collections.singletonList(messageReceiver));
-			finish();
-		}).start();
+			mediaItems.add(mediaItem);
+		}
+		messageService.sendMediaAsync(mediaItems, Collections.singletonList(messageReceiver));
+		finish();
 	}
 
 	private boolean validateSendingPermission() {

+ 23 - 16
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java

@@ -89,7 +89,7 @@ public class MediaAttachViewModel extends AndroidViewModel {
 	private final @NonNull MutableLiveData<List<MediaAttachItem>> allMedia = new MutableLiveData<>(Collections.emptyList());
 	private final @NonNull MutableLiveData<List<MediaAttachItem>> currentMedia = new MutableLiveData<>(Collections.emptyList());
 
-	private final MutableLiveData<LinkedHashMap<Integer, MediaAttachItem>> selectedItems;
+	private final LinkedHashMap<Integer, MediaAttachItem> selectedItems;
 	private final MutableLiveData<List<String>> suggestionLabels = new MutableLiveData<>();
 	private final MediaRepository repository;
 	private final SavedStateHandle savedState;
@@ -106,8 +106,13 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		this.savedState = savedState;
 		this.application = application;
 		this.repository = new MediaRepository(application.getApplicationContext());
-		this.selectedItems = savedState.getLiveData(KEY_SELECTED_MEDIA, new LinkedHashMap<>());
-		savedState.set(KEY_SELECTED_MEDIA, selectedItems.getValue());
+		final HashMap<Integer, MediaAttachItem> savedItems = savedState.get(KEY_SELECTED_MEDIA);
+		if (savedItems == null || savedItems.isEmpty()) {
+			this.selectedItems = new LinkedHashMap<>();
+		} else {
+			this.selectedItems = new LinkedHashMap<>(savedItems);
+		}
+		savedState.set(KEY_SELECTED_MEDIA, selectedItems);
 
 		// Fetch initial data
 		if (ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
@@ -233,7 +238,7 @@ public class MediaAttachViewModel extends AndroidViewModel {
 	 */
 	@UiThread
 	public void setSelectedMedia() {
-		currentMedia.setValue(new ArrayList<>(Objects.requireNonNull(selectedItems.getValue()).values()));
+		currentMedia.setValue(new ArrayList<>(Objects.requireNonNull(selectedItems).values()));
 	}
 
 	/**
@@ -248,7 +253,9 @@ public class MediaAttachViewModel extends AndroidViewModel {
 				filteredMedia.add(mediaItem);
 			}
 		}
-		currentMedia.setValue(filteredMedia);
+		if (currentMedia != null) {
+			currentMedia.setValue(filteredMedia);
+		}
 	}
 
 	/**
@@ -303,8 +310,8 @@ public class MediaAttachViewModel extends AndroidViewModel {
 
 	public ArrayList<Uri> getSelectedMediaUris() {
 		ArrayList<Uri> selectedUris = new ArrayList<>();
-		if (selectedItems.getValue() != null) {
-			for (Map.Entry<Integer, MediaAttachItem> entry : selectedItems.getValue().entrySet()) {
+		if (selectedItems != null) {
+			for (Map.Entry<Integer, MediaAttachItem> entry : selectedItems.entrySet()) {
 				selectedUris.add(entry.getValue().getUri());
 			}
 		}
@@ -316,23 +323,23 @@ public class MediaAttachViewModel extends AndroidViewModel {
 	}
 
 	public void removeSelectedMediaItem(int id) {
-		if (selectedItems.getValue() != null) {
-			selectedItems.getValue().remove(id);
-			savedState.set(KEY_SELECTED_MEDIA, selectedItems.getValue());
+		if (selectedItems != null) {
+			selectedItems.remove(id);
+			savedState.set(KEY_SELECTED_MEDIA, selectedItems);
 		}
 	}
 
 	public void addSelectedMediaItem(int id, MediaAttachItem mediaAttachItem) {
-		if (selectedItems.getValue() != null) {
-			selectedItems.getValue().put(id, mediaAttachItem);
-			savedState.set(KEY_SELECTED_MEDIA, selectedItems.getValue());
+		if (selectedItems != null) {
+			selectedItems.put(id, mediaAttachItem);
+			savedState.set(KEY_SELECTED_MEDIA, selectedItems);
 		}
 	}
 
 	public void clearSelection() {
-		if (selectedItems.getValue() != null) {
-			selectedItems.getValue().clear();
-			savedState.set(KEY_SELECTED_MEDIA, selectedItems.getValue());
+		if (selectedItems != null) {
+			selectedItems.clear();
+			savedState.set(KEY_SELECTED_MEDIA, selectedItems);
 		}
 	}
 

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

@@ -121,7 +121,7 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 				ArrayList<MediaItem> mediaItems = new ArrayList<>();
 				for (Uri uri : mediaAttachViewModel.getSelectedMediaUris()) {
 					MediaItem mediaItem = new MediaItem(uri, MimeUtil.getMimeTypeFromUri(uri), null);
-					mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), uri));
+					mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 					mediaItems.add(mediaItem);
 				}
 				setResult(ThreemaActivity.RESULT_OK, new Intent().putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems));

+ 49 - 24
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java

@@ -46,6 +46,7 @@ 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;
@@ -143,6 +144,8 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected ImageLabelsIndexHashMap labelsIndexHashMap;
 	protected int peekHeightNumElements = 1;
 
+	private boolean isDragging = false;
+
 	// Locks
 	private final Object filterMenuLock = new Object();
 	private final Object firstTimeTooltipLock = new Object();
@@ -225,7 +228,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		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_text);
+		this.menuTitle = findViewById(R.id.toolbar_title_textview);
 		this.bottomSheetLayout = findViewById(R.id.bottom_sheet);
 		this.mediaAttachRecyclerView = findViewById(R.id.media_grid_recycler);
 		this.dragHandle = findViewById(R.id.drag_handle);
@@ -646,11 +649,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	}
 
 	public void updateUI(int state){
-		int pixelmargin = Math.round(TypedValue.applyDimension(
-			TypedValue.COMPLEX_UNIT_DIP,
-			8,
-			MediaSelectionBaseActivity.this.getResources().getDisplayMetrics()
-		));
+		Animation animation;
 		switch (state) {
 			case STATE_HIDDEN:
 				finish();
@@ -664,12 +663,22 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				menuTitleFrame.setClickable(true);
 				searchView.findViewById(R.id.search_button).setClickable(true);
 
+				animation = toolbar.getAnimation();
+				if (animation != null) {
+					animation.cancel();
+				}
+
 				toolbar.setAlpha(0f);
 				toolbar.setVisibility(View.VISIBLE);
 				toolbar.animate()
 					.alpha(1f)
 					.setDuration(100)
-					.setListener(null);
+					.setListener(new AnimatorListenerAdapter() {
+					@Override
+					public void onAnimationEnd(Animator animation) {
+						toolbar.setVisibility(View.VISIBLE);
+					}
+				});
 				if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
 					toolbar.postDelayed(
 						() -> getWindow().setStatusBarColor(ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_expanded)),
@@ -684,28 +693,43 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				}
 
 				// Maybe show "new feature" tooltip
-				RuntimeUtil.runOnUiThreadDelayed(this::maybeShowFirstTimeToolTip, 1500);
+				toolbar.postDelayed(new Runnable() {
+					@Override
+					public void run() {
+						maybeShowFirstTimeToolTip();
+					}
+				}, 1500);
+
+				isDragging = false;
 
 				break;
 			case STATE_DRAGGING:
-				dateView.setVisibility(View.GONE);
-				dragHandle.setVisibility(View.VISIBLE);
+				if (!isDragging) {
+					isDragging = true;
 
-				toolbar.setAlpha(1f);
-				toolbar.animate()
-					.alpha(0f)
-					.setDuration(100)
-					.setListener(new AnimatorListenerAdapter() {
-						@Override
-						public void onAnimationEnd(Animator animation) {
-							toolbar.setVisibility(View.GONE);
-						}
-					});
-				toolbar.postDelayed(() -> {
-					if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-						getWindow().setStatusBarColor(ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_collapsed));
+					dateView.setVisibility(View.GONE);
+					dragHandle.setVisibility(View.VISIBLE);
+
+					animation = toolbar.getAnimation();
+					if (animation != null) {
+						animation.cancel();
 					}
-				}, 50);
+					toolbar.setAlpha(1f);
+					toolbar.animate()
+						.alpha(0f)
+						.setDuration(100)
+						.setListener(new AnimatorListenerAdapter() {
+							@Override
+							public void onAnimationEnd(Animator animation) {
+								toolbar.setVisibility(View.GONE);
+							}
+						});
+					toolbar.postDelayed(() -> {
+						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+							getWindow().setStatusBarColor(ConfigUtils.getColorFromAttribute(this, R.attr.attach_status_bar_color_collapsed));
+						}
+					}, 50);
+				}
 				break;
 			case STATE_COLLAPSED:
 				dateView.setVisibility(View.GONE);
@@ -713,6 +737,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				menuTitleFrame.setClickable(false);
 				searchView.findViewById(R.id.search_button).setClickable(false);
 				controlPanel.animate().translationY(0);
+				isDragging = false;
 			default:
 				break;
 		}

+ 6 - 2
app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelingWorker.java

@@ -331,6 +331,7 @@ public class ImageLabelingWorker extends Worker {
 			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", unlabeledCounter, imageCounter, skippedCounter);
@@ -372,12 +373,15 @@ public class ImageLabelingWorker extends Worker {
 	}
 
 	private void onFinish() {
+		logger.debug("onFinish() called");
+
 		// Shut down executor thread pool
-		if (!this.executor.isShutdown()) {
+/*		if (!this.executor.isShutdown()) {
 			this.logger.debug("Shut down thread pool");
 			this.executor.shutdown();
 		}
-
+		// TODO this causes a DuplicateTaskCompletionException
+*/
 		if (this.cancelled) {
 			logger.info("Cancelled after processing {}/{} media files", this.progress, this.mediaCount);
 		} else {

+ 0 - 85
app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java

@@ -214,91 +214,6 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		return false;
 	}
 
-	@Override
-	public boolean createBoxedImageMessage(byte[] blobId, EncryptResult fromResult, MessageModel messageModel) throws ThreemaException {
-		BoxImageMessage msg = new BoxImageMessage();
-		msg.setBlobId(blobId);
-		msg.setNonce(fromResult.getNonce());
-		msg.setSize(fromResult.getSize());
-		msg.setToIdentity(this.contactModel.getIdentity());
-
-		//fix #ANDR-512
-		//save model after receiving a new message id
-		this.initNewAbstractMessage(messageModel, msg);
-
-		logger.info("Enqueue image message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
-		BoxedMessage boxedMessage = this.messageQueue.enqueue(msg);
-		if(boxedMessage != null) {
-			messageModel.setIsQueued(true);
-			MessageId id = boxedMessage.getMessageId();
-			if (id!= null) {
-				messageModel.setApiMessageId(id.toString());
-				contactService.setIsHidden(msg.getToIdentity(), false);
-				contactService.setIsArchived(msg.getToIdentity(), false);
-				return true;
-			}
-		}
-		return false;
-	}
-
-	@Override
-	public boolean createBoxedVideoMessage(byte[] thumbnailBlobId, EncryptResult thumbnailResult,
-										   byte[] videoBlobId, EncryptResult videoResult,
-										   int duration,
-										   MessageModel messageModel) throws ThreemaException {
-		BoxVideoMessage msg = new BoxVideoMessage();
-		msg.setDuration(duration);
-		msg.setVideoBlobId(videoBlobId);
-		msg.setVideoSize(videoResult.getSize());
-		msg.setThumbnailBlobId(thumbnailBlobId);
-		msg.setThumbnailSize(thumbnailResult.getSize());
-		msg.setEncryptionKey(videoResult.getKey());
-		msg.setToIdentity(this.contactModel.getIdentity());
-
-		//fix #ANDR-512
-		//save model after receiving a new message id
-		this.initNewAbstractMessage(messageModel, msg);
-
-		logger.info("Enqueue video message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
-		BoxedMessage boxedMessage = this.messageQueue.enqueue(msg);
-		if(boxedMessage != null) {
-			messageModel.setIsQueued(true);
-			MessageId id = boxedMessage.getMessageId();
-			if (id!= null) {
-				messageModel.setApiMessageId(id.toString());
-				contactService.setIsHidden(msg.getToIdentity(), false);
-				contactService.setIsArchived(msg.getToIdentity(), false);
-				return true;
-			}
-		}
-		return false;
-	}
-
-	@Override
-	public boolean createBoxedAudioMessage(byte[] blobId, EncryptResult fromResult, int duration, MessageModel messageModel) throws ThreemaException {
-		BoxAudioMessage msg = new BoxAudioMessage();
-		msg.setDuration(duration);
-		msg.setAudioBlobId(blobId);
-		msg.setAudioSize(fromResult.getSize());
-		msg.setEncryptionKey(fromResult.getKey());
-		msg.setToIdentity(this.contactModel.getIdentity());
-
-		//fix #ANDR-512
-		//save model after receiving a new message id
-		this.initNewAbstractMessage(messageModel, msg);
-
-		logger.info("Enqueue audio message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
-		BoxedMessage boxedMessage = this.messageQueue.enqueue(msg);
-		if(boxedMessage != null) {
-			messageModel.setIsQueued(true);
-			messageModel.setApiMessageId(boxedMessage.getMessageId().toString());
-			contactService.setIsHidden(msg.getToIdentity(), false);
-			contactService.setIsArchived(msg.getToIdentity(), false);
-			return true;
-		}
-		return false;
-	}
-
 	@Override
 	public boolean createBoxedFileMessage(byte[] thumbnailBlobId,
 											 byte[] fileBlobId, EncryptResult fileResult,

+ 0 - 24
app/src/main/java/ch/threema/app/messagereceiver/DistributionListMessageReceiver.java

@@ -131,36 +131,12 @@ public class DistributionListMessageReceiver implements MessageReceiver<Distribu
 		return this.handleSendImage(messageModel);
 	}
 
-	@Override
-	public boolean createBoxedImageMessage(final byte[] blobId, final EncryptResult result, final DistributionListMessageModel messageModel) throws ThreemaException {
-		return this.handleSendImage(messageModel);
-	}
-
 	private boolean handleSendImage(DistributionListMessageModel model) {
 		model.setIsQueued(true);
 		distributionListService.setIsArchived(distributionListModel, false);
 		return true;
 	}
 
-	@Override
-	public boolean createBoxedVideoMessage(final byte[] thumbnailBlobId,
-	                                       final EncryptResult thumbnailResult,
-	                                       final byte[] videoBlobId,
-	                                       final EncryptResult videoResult,
-	                                       final int duration,
-	                                       final DistributionListMessageModel messageModel) throws ThreemaException {
-		return this.handleSendImage(messageModel);
-	}
-
-	@Override
-	public boolean createBoxedAudioMessage(final byte[] audioBlobId,
-										   final EncryptResult audioResult,
-										   final int duration,
-										   final DistributionListMessageModel messageModel) throws ThreemaException {
-
-		return this.handleSendImage(messageModel);
-	}
-
 	@Override
 	public boolean createBoxedFileMessage(byte[] thumbnailBlobId,
 										  byte[] fileBlobId, EncryptResult fileResult,

+ 0 - 71
app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java

@@ -184,77 +184,6 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		}, messageModel);
 	}
 
-	@Override
-	public boolean createBoxedImageMessage(final byte[] blobId, final EncryptResult fromResult, GroupMessageModel messageModel) throws ThreemaException {
-		return this.sendMessage(new GroupApiService.CreateApiMessage() {
-			@Override
-			public AbstractGroupMessage create(MessageId messageId) {
-				GroupImageMessage msg = new GroupImageMessage();
-				msg.setMessageId(messageId);
-				msg.setBlobId(blobId);
-				msg.setSize(fromResult.getSize());
-				msg.setEncryptionKey(fromResult.getKey());
-
-				if (messageId != null) {
-					messageModel.setApiMessageId(messageId.toString());
-				}
-
-				return msg;
-			}
-		}, messageModel);
-	}
-
-	@Override
-	public boolean createBoxedVideoMessage(final byte[] thumbnailBlobId, final EncryptResult thumbnailResult,
-										   final byte[] videoBlobId, final EncryptResult videoResult,
-										   final int duration,
-										   GroupMessageModel messageModel) throws ThreemaException {
-		return this.sendMessage(new GroupApiService.CreateApiMessage() {
-			@Override
-			public AbstractGroupMessage create(MessageId messageId) {
-				GroupVideoMessage msg = new GroupVideoMessage();
-				msg.setMessageId(messageId);
-				msg.setDuration(duration);
-				msg.setVideoBlobId(videoBlobId);
-				msg.setVideoSize(videoResult.getSize());
-				msg.setThumbnailBlobId(thumbnailBlobId);
-				msg.setThumbnailSize(thumbnailResult.getSize());
-				msg.setEncryptionKey(videoResult.getKey());
-
-				if (messageId != null) {
-					messageModel.setApiMessageId(messageId.toString());
-				}
-
-				logger.info("Enqueue group video message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
-
-				return msg;
-			}
-		}, messageModel);
-	}
-
-	@Override
-	public boolean createBoxedAudioMessage(final byte[] blobId, final EncryptResult fromResult, final int duration, final GroupMessageModel messageModel) throws ThreemaException {
-		return this.sendMessage(new GroupApiService.CreateApiMessage() {
-			@Override
-			public AbstractGroupMessage create(MessageId messageId) {
-				GroupAudioMessage msg = new GroupAudioMessage();
-				msg.setMessageId(messageId);
-				msg.setAudioBlobId(blobId);
-				msg.setDuration(duration);
-				msg.setAudioSize(fromResult.getSize());
-				msg.setEncryptionKey(fromResult.getKey());
-
-				if (messageId != null) {
-					messageModel.setApiMessageId(messageId.toString());
-				}
-
-				logger.info("Enqueue group audio message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
-
-				return msg;
-			}
-		}, messageModel);
-	}
-
 	@Override
 	public boolean createBoxedFileMessage(final byte[] thumbnailBlobId,
 										  final byte[] fileBlobId, final EncryptResult fileResult,

+ 0 - 40
app/src/main/java/ch/threema/app/messagereceiver/MessageReceiver.java

@@ -136,46 +136,6 @@ public interface MessageReceiver<T extends AbstractMessageModel> {
 	 */
 	boolean createBoxedLocationMessage(double lat, double lng, float acc, String poiName, T messageModel) throws ThreemaException;
 
-	/**
-	 * send a image message
-	 * @param blobId
-	 * @param result
-	 * @param messageModel
-	 * @return
-	 * @throws ThreemaException
-	 */
-	boolean createBoxedImageMessage(byte[] blobId, EncryptResult result, T messageModel) throws ThreemaException;
-
-	/**
-	 * send a video message
-	 * @param thumbnailBlobId
-	 * @param thumbnailResult
-	 * @param videoBlobId
-	 * @param videoResult
-	 * @param duration
-	 * @param messageModel
-	 * @return
-	 * @throws ThreemaException
-	 */
-	boolean createBoxedVideoMessage(byte[] thumbnailBlobId, EncryptResult thumbnailResult,
-									byte[] videoBlobId, EncryptResult videoResult,
-									int duration,
-									T messageModel) throws ThreemaException;
-
-	/**
-	 * send a audio message
-	 * @param audioBlobId
-	 * @param audioResult
-	 * @param duration
-	 * @param messageModel
-	 * @return
-	 * @throws ThreemaException
-	 */
-	boolean createBoxedAudioMessage(byte[] audioBlobId, EncryptResult audioResult,
-									int duration,
-									T messageModel) throws ThreemaException;
-
-
 	/**
 	 * send a file message
 	 * @param thumbnailBlobId

+ 0 - 46
app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java

@@ -93,10 +93,6 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
 			+ TEST_IDENTITY_2 + " and add some test quotes.");
 		generateRecursiveQuote.setOnPreferenceClickListener(this::generateTestQuotes);
 
-		// Delete labels database
-		final Preference deleteLabelsDb = findPreference(getResources().getString(R.string.preferences__labels_delete));
-		deleteLabelsDb.setOnPreferenceClickListener(this::deleteMediaLabelsDatabase);
-
 		// Remove developer menu
 		final Preference removeMenuPreference = findPreference(getResources().getString(R.string.preferences__remove_menu));
 		removeMenuPreference.setSummary("Hide the developer menu from the settings.");
@@ -256,48 +252,6 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
 		return true;
 	}
 
-	@UiThread
-	@SuppressLint("StaticFieldLeak")
-	private boolean deleteMediaLabelsDatabase(Preference preference) {
-		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) {
-					showOk("Database deleted");
-				} else {
-					showError(e);
-				}
-			}
-		}.execute();
-		return true;
-	}
-
 	@UiThread
 	@SuppressLint("StaticFieldLeak")
 	private boolean hideDeveloperMenu(Preference preference) {

+ 70 - 0
app/src/main/java/ch/threema/app/preference/SettingsMediaFragment.java

@@ -22,34 +22,47 @@
 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);
 
@@ -127,6 +140,18 @@ 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
@@ -160,4 +185,49 @@ 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;
+	}
 }

+ 3 - 3
app/src/main/java/ch/threema/app/preference/SettingsTroubleshootingFragment.java

@@ -62,7 +62,6 @@ import ch.threema.app.FcmRegistrationIntentService;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.DisableBatteryOptimizationsActivity;
-import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -77,6 +76,7 @@ import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.LifetimeService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.services.MessageServiceImpl;
 import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.RingtoneService;
@@ -595,8 +595,8 @@ public class SettingsTroubleshootingFragment extends ThreemaPreferenceFragment i
 					mediaItem.setFilename(zipFile.getName());
 					mediaItem.setMimeType(MimeUtil.MIME_TYPE_ZIP);
 
-					messageService.sendMedia(Collections.singletonList(mediaItem),
-						Collections.singletonList(receiver), new RecipientListBaseActivity.SendCompletionHandler() {
+					messageService.sendMediaAsync(Collections.singletonList(mediaItem),
+						Collections.singletonList(receiver), new MessageServiceImpl.SendResultListener() {
 							@Override
 							public void onError(String errorMessage) {
 								RuntimeUtil.runOnUiThread(() -> Toast.makeText(getContext(), R.string.an_error_occurred_during_send, Toast.LENGTH_LONG).show());

+ 11 - 0
app/src/main/java/ch/threema/app/receivers/ReSendMessagesBroadcastReceiver.java

@@ -21,9 +21,11 @@
 
 package ch.threema.app.receivers;
 
+import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Intent;
 import android.os.AsyncTask;
+import android.widget.Toast;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -32,10 +34,12 @@ import androidx.core.app.NotificationManagerCompat;
 
 import java.util.ArrayList;
 
+import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -45,6 +49,7 @@ public class ReSendMessagesBroadcastReceiver extends ActionBroadcastReceiver {
 	private static final Logger logger = LoggerFactory.getLogger(ReSendMessagesBroadcastReceiver.class);
 
 	@Override
+	@SuppressLint("StaticFieldLeak")
 	public void onReceive(final Context context, final Intent intent) {
 		final PendingResult pendingResult = goAsync();
 
@@ -65,6 +70,12 @@ public class ReSendMessagesBroadcastReceiver extends ActionBroadcastReceiver {
 						try {
 							messageService.resendMessage(failedMessage, messageReceiver, null);
 						} catch (Exception e) {
+							RuntimeUtil.runOnUiThread(new Runnable() {
+								@Override
+								public void run() {
+									Toast.makeText(context, R.string.original_file_no_longer_avilable, Toast.LENGTH_LONG).show();
+								}
+							});
 							logger.error("Exception", e);
 						}
 					}

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

@@ -176,6 +176,8 @@ public interface FileService {
 	 */
 	void copyDecryptedFileIntoGallery(Uri sourceUri, AbstractMessageModel messageModel) throws Exception;
 
+	File getMessageFile(AbstractMessageModel messageModel);
+
 	/**
 	 * write a message (modify if needed) and return the original or modified file as byte
 	 */

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

@@ -802,7 +802,8 @@ public class FileServiceImpl implements FileService {
 		return new File(getAppDataPathAbsolute(), getGroupAvatarFileName(groupModel));
 	}
 
-	private File getMessageFile(AbstractMessageModel messageModel) {
+	@Override
+	public File getMessageFile(AbstractMessageModel messageModel) {
 		return getMessageFile(messageModel, true);
 	}
 

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

@@ -31,6 +31,7 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 
+import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
@@ -123,10 +124,12 @@ public interface MessageService {
 
 	String getCorrelationId();
 
+	@AnyThread
+	void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers);
+	@AnyThread
+	void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers, @Nullable MessageServiceImpl.SendResultListener sendResultListener);
 	@WorkerThread
-	AbstractMessageModel sendMedia(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers);
-	@WorkerThread
-	AbstractMessageModel sendMedia(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers, @Nullable RecipientListBaseActivity.SendCompletionHandler sendCompletionHandler);
+	AbstractMessageModel sendMedia(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers, @Nullable MessageServiceImpl.SendResultListener sendResultListener);
 
 	boolean sendUserAcknowledgement(AbstractMessageModel messageModel);
 	boolean sendUserDecline(AbstractMessageModel messageModel);

+ 196 - 142
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -72,6 +72,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import javax.crypto.CipherInputStream;
 import javax.crypto.CipherOutputStream;
 
+import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
@@ -79,10 +80,10 @@ import androidx.collection.ArrayMap;
 import androidx.core.app.NotificationManagerCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.exceptions.NotAllowedException;
+import ch.threema.app.exceptions.TranscodeCanceledException;
 import ch.threema.app.listeners.MessageListener;
 import ch.threema.app.listeners.ServerMessageListener;
 import ch.threema.app.managers.ListenerManager;
@@ -614,6 +615,7 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
+	@WorkerThread
 	public void resendMessage(AbstractMessageModel messageModel, MessageReceiver receiver, CompletionHandler completionHandler) throws Exception {
 		NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
 		notificationManager.cancel(ThreemaApplication.UNSENT_MESSAGE_NOTIFICATION_ID);
@@ -623,9 +625,17 @@ public class MessageServiceImpl implements MessageService {
 		}
 	}
 
+	@WorkerThread
 	private void resendFileMessage(final AbstractMessageModel messageModel,
-								  final MessageReceiver receiver,
-								  final CompletionHandler completionHandler) throws Exception {
+	                               final MessageReceiver receiver,
+	                               final CompletionHandler completionHandler) throws Exception {
+
+		// check if a message file exists that could be resent or abort immediately
+		File file = fileService.getMessageFile(messageModel);
+		if (file == null || !file.exists()) {
+			throw new Exception("Message file not present");
+		}
+
 		updateMessageState(messageModel, MessageState.PENDING, new Date());
 
 		//enqueue processing and uploading stuff...
@@ -653,119 +663,118 @@ public class MessageServiceImpl implements MessageService {
 			public boolean send() throws Exception {
 				SendMachine sendMachine = getSendMachine(messageModel);
 				sendMachine.reset()
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								File decryptedMessageFile = fileService.getDecryptedMessageFile(messageModel);
-
-								if (decryptedMessageFile != null) {
-
-									try (FileInputStream inputStream = new FileInputStream(decryptedMessageFile)) {
-										fileDataBoxedLength = inputStream.available();
-										fileData = new byte[fileDataBoxedLength + NaCl.BOXOVERHEAD];
-										IOUtils.readFully(inputStream,
-											fileData,
-											NaCl.BOXOVERHEAD,
-											fileDataBoxedLength);
-									}
-								} else {
-									// TODO: we should abort upload here instead of trying again
-									throw new Exception("Message file not present");
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							File decryptedMessageFile = fileService.getDecryptedMessageFile(messageModel);
+
+							if (decryptedMessageFile != null) {
+
+								try (FileInputStream inputStream = new FileInputStream(decryptedMessageFile)) {
+									fileDataBoxedLength = inputStream.available();
+									fileData = new byte[fileDataBoxedLength + NaCl.BOXOVERHEAD];
+									IOUtils.readFully(inputStream,
+										fileData,
+										NaCl.BOXOVERHEAD,
+										fileDataBoxedLength);
 								}
+							} else {
+								throw new Exception("Message file not present");
 							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								encryptResult = getReceiver().encryptFileData(fileData);
-								if (encryptResult.getData() == null || encryptResult.getSize() == 0) {
-									throw new Exception("File data encrypt failed");
-								}
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							encryptResult = getReceiver().encryptFileData(fileData);
+							if (encryptResult.getData() == null || encryptResult.getSize() == 0) {
+								throw new Exception("File data encrypt failed");
 							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								try (InputStream is = fileService.getDecryptedMessageThumbnailStream(messageModel)) {
-									if (is != null) {
-										thumbnailData = IOUtils.toByteArray(is);
-									} else {
-										thumbnailData = null;
-									}
-								} catch (Exception e) {
-									logger.debug("No thumbnail for file message");
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							try (InputStream is = fileService.getDecryptedMessageThumbnailStream(messageModel)) {
+								if (is != null) {
+									thumbnailData = IOUtils.toByteArray(is);
+								} else {
+									thumbnailData = null;
 								}
+							} catch (Exception e) {
+								logger.debug("No thumbnail for file message");
 							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								BlobUploader blobUploader = initUploader(getMessageModel(), encryptResult.getData());
-								blobUploader.setProgressListener(new ProgressListener() {
-									@Override
-									public void updateProgress(int progress) {
-										updateMessageLoadingProgress(messageModel, progress);
-									}
-
-									@Override
-									public void onFinished(boolean success) {
-										setMessageLoadingFinished(messageModel, success);
-									}
-								});
-								blobId = blobUploader.upload();
-							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								if (thumbnailData != null) {
-									thumbnailEncryptResult = getReceiver().encryptFileThumbnailData(thumbnailData, encryptResult.getKey());
-
-									if (thumbnailEncryptResult.getData() != null) {
-										BlobUploader blobUploader = initUploader(getMessageModel(), thumbnailEncryptResult.getData());
-										blobUploader.setProgressListener(new ProgressListener() {
-											@Override
-											public void updateProgress(int progress) {
-												updateMessageLoadingProgress(messageModel, progress);
-											}
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							BlobUploader blobUploader = initUploader(getMessageModel(), encryptResult.getData());
+							blobUploader.setProgressListener(new ProgressListener() {
+								@Override
+								public void updateProgress(int progress) {
+									updateMessageLoadingProgress(messageModel, progress);
+								}
 
-											@Override
-											public void onFinished(boolean success) {
-												setMessageLoadingFinished(messageModel, success);
-											}
-										});
-										blobIdThumbnail = blobUploader.upload();
-									} else {
-										throw new Exception("Thumbnail encrypt failed");
-									}
+								@Override
+								public void onFinished(boolean success) {
+									setMessageLoadingFinished(messageModel, success);
+								}
+							});
+							blobId = blobUploader.upload();
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							if (thumbnailData != null) {
+								thumbnailEncryptResult = getReceiver().encryptFileThumbnailData(thumbnailData, encryptResult.getKey());
+
+								if (thumbnailEncryptResult.getData() != null) {
+									BlobUploader blobUploader = initUploader(getMessageModel(), thumbnailEncryptResult.getData());
+									blobUploader.setProgressListener(new ProgressListener() {
+										@Override
+										public void updateProgress(int progress) {
+											updateMessageLoadingProgress(messageModel, progress);
+										}
+
+										@Override
+										public void onFinished(boolean success) {
+											setMessageLoadingFinished(messageModel, success);
+										}
+									});
+									blobIdThumbnail = blobUploader.upload();
+								} else {
+									throw new Exception("Thumbnail encrypt failed");
 								}
 							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								getReceiver().createBoxedFileMessage(
-										blobIdThumbnail,
-										blobId,
-										encryptResult,
-										messageModel
-								);
-								save(messageModel);
-							}
-						})
-						.next(new SendMachineProcess() {
-							@Override
-							public void run() throws Exception {
-								updateMessageState(messageModel, MessageState.SENDING, null);
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							getReceiver().createBoxedFileMessage(
+								blobIdThumbnail,
+								blobId,
+								encryptResult,
+								messageModel
+							);
+							save(messageModel);
+						}
+					})
+					.next(new SendMachineProcess() {
+						@Override
+						public void run() throws Exception {
+							updateMessageState(messageModel, MessageState.SENDING, null);
 
-								if (completionHandler != null)
-									completionHandler.sendComplete(messageModel);
+							if (completionHandler != null)
+								completionHandler.sendComplete(messageModel);
 
-								success = true;
-							}
-						});
+							success = true;
+						}
+					});
 
-				if(this.success) {
+				if (this.success) {
 					removeSendMachine(sendMachine);
 				}
 				return this.success;
@@ -3377,33 +3386,51 @@ public class MessageServiceImpl implements MessageService {
 
 	/******************************************************************************************************/
 
+	public interface SendResultListener {
+		void onError(String errorMessage);
+		void onCompleted();
+	}
+
 	/**
 	 * Send media messages of any kind to an arbitrary number of receivers
 	 * @param mediaItems List of MediaItems to be sent
 	 * @param messageReceivers List of MessageReceivers
 	 * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
 	 */
-	@WorkerThread
+	@AnyThread
 	@Override
-	public AbstractMessageModel sendMedia(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers) {
-		return sendMedia(mediaItems, messageReceivers, null);
+	public void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers) {
+		sendMediaAsync(mediaItems, messageReceivers, null);
 	}
 
 	/**
 	 * Send media messages of any kind to an arbitrary number of receivers
 	 * @param mediaItems List of MediaItems to be sent
 	 * @param messageReceivers List of MessageReceivers
-	 * @param sendCompletionHandler Handler to notify when messages are queued
+	 * @param sendResultListener Listener to notify when messages are queued
 	 * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
 	 */
+	@AnyThread
+	@Override
+	public void sendMediaAsync(
+		@NonNull final List<MediaItem> mediaItems,
+		@NonNull final List<MessageReceiver> messageReceivers,
+		@Nullable final SendResultListener sendResultListener
+	) {
+		ThreemaApplication.sendMessageExecutorService.submit(() -> {
+			sendMedia(mediaItems, messageReceivers, sendResultListener);
+		});
+	}
+
 	@WorkerThread
 	@Override
 	public AbstractMessageModel sendMedia(
-		@NonNull List<MediaItem> mediaItems,
-		@NonNull List<MessageReceiver> messageReceivers,
-		@Nullable RecipientListBaseActivity.SendCompletionHandler sendCompletionHandler
+		@NonNull final List<MediaItem> mediaItems,
+		@NonNull final List<MessageReceiver> messageReceivers,
+		@Nullable final SendResultListener sendResultListener
 	) {
 		AbstractMessageModel successfulMessageModel = null;
+		int failedCounter = 0;
 
 		// resolve receivers to account for distribution lists
 		final MessageReceiver[] resolvedReceivers = MessageUtil.addDistributionListReceivers(messageReceivers.toArray(new MessageReceiver[0]));
@@ -3411,6 +3438,7 @@ public class MessageServiceImpl implements MessageService {
 		logger.info("sendMedia: Sending " + mediaItems.size() + " items to " + resolvedReceivers.length + " receivers");
 
 		for (MediaItem mediaItem : mediaItems) {
+			logger.info("sendMedia: Now sending item of type " + mediaItem.getType());
 			if (MimeUtil.isTextFile(mediaItem.getMimeType())) {
 				String text = mediaItem.getCaption();
 				if (!TestUtil.empty(text)) {
@@ -3418,15 +3446,18 @@ public class MessageServiceImpl implements MessageService {
 						try {
 							successfulMessageModel = sendText(text, messageReceiver);
 							if (successfulMessageModel != null) {
-								logger.info("Text successfuly sent");
+								logger.info("Text successfully sent");
 							} else {
+								failedCounter++;
 								logger.info("Text send failed");
 							}
 						} catch (Exception e) {
+							failedCounter++;
 							logger.error("Could not send text message", e);
 						}
 					}
 				} else {
+					failedCounter++;
 					logger.info("Text is empty");
 				}
 				continue;
@@ -3437,11 +3468,13 @@ public class MessageServiceImpl implements MessageService {
 			final FileDataModel fileDataModel = createFileDataModel(context, mediaItem);
 			if (fileDataModel == null) {
 				logger.info("Unable to create FileDataModel");
+				failedCounter++;
 				continue;
 			}
 
 			if (!createMessagesAndSetPending(mediaItem, resolvedReceivers, messageModels, fileDataModel)) {
 				logger.info("Unable to create messages");
+				failedCounter++;
 				continue;
 			}
 
@@ -3449,41 +3482,52 @@ public class MessageServiceImpl implements MessageService {
 			if (thumbnailData != null) {
 				writeThumbnails(messageModels, resolvedReceivers, thumbnailData);
 			} else {
-				logger.info("Unable to write thumbnails");
+				logger.info("Unable to generate thumbnails");
 			}
 
 			if (!allChatsArePrivate(resolvedReceivers)) {
 				saveToGallery(mediaItem);
 			}
 
-			final byte[] contentData = generateContentData(mediaItem, resolvedReceivers, messageModels, fileDataModel);
-			if (contentData != null) {
-				if (encryptAndSend(resolvedReceivers, messageModels, fileDataModel, thumbnailData, contentData)) {
-					successfulMessageModel = messageModels.get(resolvedReceivers[0]);
+			try {
+				final byte[] contentData = generateContentData(mediaItem, resolvedReceivers, messageModels, fileDataModel);
+				if (contentData != null) {
+					if (encryptAndSend(resolvedReceivers, messageModels, fileDataModel, thumbnailData, contentData)) {
+						successfulMessageModel = messageModels.get(resolvedReceivers[0]);
+					} else {
+						throw new ThreemaException("Error encrypting and sending");
+					}
+				} else {
+					logger.info("Error encrypting and sending");
+					failedCounter++;
+					markAsTerminallyFailed(resolvedReceivers, messageModels);
+				}
+			} catch (ThreemaException e) {
+				if (!(e instanceof TranscodeCanceledException)) {
+					logger.error("Exception", e);
+					failedCounter++;
+				} else {
+					logger.info("Video transcoding canceled");
+					// canceling is not really a failure
 				}
-			} else {
-				logger.info("Error encrypting and sending");
 				markAsTerminallyFailed(resolvedReceivers, messageModels);
 			}
 		}
 
-		if (successfulMessageModel != null) {
+		if (failedCounter == 0) {
 			logger.info("sendMedia: Send successful.");
-
 			sendProfilePicture(resolvedReceivers);
-
-			if (sendCompletionHandler != null) {
-				sendCompletionHandler.onCompleted();
+			if (sendResultListener != null) {
+				sendResultListener.onCompleted();
 			}
 		} else {
 			final String errorString = context.getString(R.string.an_error_occurred_during_send);
-			logger.info("sendMedia: " + errorString);
+			logger.info(errorString);
 			RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, errorString, Toast.LENGTH_LONG).show());
-			if (sendCompletionHandler != null) {
-				sendCompletionHandler.onError(errorString);
+			if (sendResultListener != null) {
+				sendResultListener.onError(errorString);
 			}
 		}
-
 		return successfulMessageModel;
 	}
 
@@ -3518,13 +3562,16 @@ public class MessageServiceImpl implements MessageService {
 	private @Nullable byte[] generateContentData(@NonNull MediaItem mediaItem,
 	                                             @NonNull MessageReceiver[] resolvedReceivers,
 	                                             @NonNull Map<MessageReceiver, AbstractMessageModel> messageModels,
-	                                             @NonNull FileDataModel fileDataModel) {
+	                                             @NonNull FileDataModel fileDataModel) throws ThreemaException {
 		switch (mediaItem.getType()) {
 			case TYPE_VIDEO:
 				// fallthrough
 			case TYPE_VIDEO_CAM:
-				if (transcodeVideo(mediaItem, resolvedReceivers, messageModels, fileDataModel)) {
+				@VideoTranscoder.TranscoderResult int result = transcodeVideo(mediaItem, resolvedReceivers, messageModels);
+				if (result == VideoTranscoder.SUCCESS) {
 					return getContentData(mediaItem);
+				} else if (result == VideoTranscoder.CANCELED) {
+					throw new TranscodeCanceledException();
 				}
 				break;
 			case TYPE_IMAGE:
@@ -3631,7 +3678,7 @@ public class MessageServiceImpl implements MessageService {
 				break;
 			case MediaItem.TYPE_IMAGE:
 				// images are always sent as JPGs - so use this for thumbnails - except for stickers which may have a format containing transparency
-				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.rotationForImage(context, mediaItem.getUri());
+				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(context, mediaItem.getUri());
 				mediaItem.setExifRotation((int) exifOrientation.getRotation());
 				mediaItem.setExifFlip(exifOrientation.getFlip());
 				if (mediaItem.getRenderingType() == RENDERING_STICKER) {
@@ -3997,17 +4044,16 @@ public class MessageServiceImpl implements MessageService {
 	 * @param mediaItem
 	 * @param resolvedReceivers
 	 * @param messageModels
-	 * @param fileDataModel
-	 * @return true if transcoding was successful, false otherwise
+	 * @return Result of transcoding
 	 */
 	@WorkerThread
-	private boolean transcodeVideo(MediaItem mediaItem, MessageReceiver[] resolvedReceivers, Map<MessageReceiver, AbstractMessageModel> messageModels, FileDataModel fileDataModel) {
+	private @VideoTranscoder.TranscoderResult int transcodeVideo(MediaItem mediaItem, MessageReceiver[] resolvedReceivers, Map<MessageReceiver, AbstractMessageModel> messageModels) {
 		final MessagePlayerService messagePlayerService;
 		try {
 			messagePlayerService = getServiceManager().getMessagePlayerService();
 		} catch (ThreemaException e) {
 			logger.error("Exception", e);
-			return false;
+			return VideoTranscoder.FAILURE;
 		}
 
 		boolean needsTrimming = videoNeedsTrimming(mediaItem);
@@ -4018,7 +4064,7 @@ public class MessageServiceImpl implements MessageService {
 			logger.error("Error getting target bitrate", e);
 			// skip this MediaItem
 			markAsTerminallyFailed(resolvedReceivers, messageModels);
-			return false;
+			return VideoTranscoder.FAILURE;
 		}
 
 		if (targetBitrate == -1) {
@@ -4026,7 +4072,7 @@ public class MessageServiceImpl implements MessageService {
 			logger.error("Video file ist too large");
 			// skip this MediaItem
 			markAsTerminallyFailed(resolvedReceivers, messageModels);
-			return false;
+			return VideoTranscoder.FAILURE;
 		}
 
 		if (needsTrimming || targetBitrate > 0) {
@@ -4045,7 +4091,7 @@ public class MessageServiceImpl implements MessageService {
 				logger.error("Unable to open temp file");
 				// skip this MediaItem
 				markAsTerminallyFailed(resolvedReceivers, messageModels);
-				return false;
+				return VideoTranscoder.FAILURE;
 			}
 
 			final VideoTranscoder.Builder transcoderBuilder = new VideoTranscoder.Builder(mediaItem.getUri(), outputFile);
@@ -4090,6 +4136,14 @@ public class MessageServiceImpl implements MessageService {
 					}
 				}
 
+				@Override
+				public void onCanceled() {
+					for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
+						AbstractMessageModel messageModel = entry.getValue();
+						messagePlayerService.setTranscodeFinished(messageModel, true, null);
+					}
+				}
+
 				@Override
 				public void onSuccess(VideoTranscoder.Stats stats) {
 					if (stats != null) {
@@ -4105,7 +4159,7 @@ public class MessageServiceImpl implements MessageService {
 				public void onFailure() {
 					for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
 						AbstractMessageModel messageModel = entry.getValue();
-						messagePlayerService.setTranscodeFinished(messageModel, false, "Failure"); // TODO reason
+						messagePlayerService.setTranscodeFinished(messageModel, false, "Failure");
 					}
 				}
 			});
@@ -4119,12 +4173,12 @@ public class MessageServiceImpl implements MessageService {
 			}
 
 			if (transcoderResult != VideoTranscoder.SUCCESS) {
-				return false;
+				return transcoderResult;
 			}
 
 			mediaItem.setUri(Uri.fromFile(outputFile));
 		}
-		return true;
+		return VideoTranscoder.SUCCESS;
 	}
 
 	/**

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

@@ -360,4 +360,9 @@ public interface NotificationService {
 	 * Update and show image labeling progress notification.
 	 */
 	void updateImageLabelingProgressNotification(int currentProgress, int maxProgress);
+
+	/**
+	 * Remove existing image labelling progress notification
+	 */
+	void cancelImageLabelingProgressNotification();
 }

+ 5 - 0
app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java

@@ -1906,4 +1906,9 @@ public class NotificationServiceImpl implements NotificationService {
 			this.notificationManager.notify(ThreemaApplication.IMAGE_LABELING_NOTIFICATION_ID, builder.build());
 		}
 	}
+
+	@Override
+	public void cancelImageLabelingProgressNotification() {
+		this.notificationManager.cancel(ThreemaApplication.IMAGE_LABELING_NOTIFICATION_ID);
+	}
 }

+ 11 - 4
app/src/main/java/ch/threema/app/services/PassphraseService.java

@@ -22,6 +22,7 @@
 package ch.threema.app.services;
 
 import android.app.Notification;
+import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.app.Service;
 import android.content.Context;
@@ -123,10 +124,16 @@ public class PassphraseService extends Service {
 
 	private static void removePersistentNotification(Context context) {
 		logger.debug("removePersistentNotification");
-		NotificationService notificationService = ThreemaApplication.getServiceManager().getNotificationService();
-		if (notificationService != null){
-			notificationService.cancel(ThreemaApplication.PASSPHRASE_SERVICE_NOTIFICATION_ID);
-			notificationService.cancelConversationNotificationsOnLockApp();
+
+		// ServiceManager may not yet be available at this point!
+		NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+		notificationManager.cancel(ThreemaApplication.PASSPHRASE_SERVICE_NOTIFICATION_ID);
+
+		if (ThreemaApplication.getServiceManager() != null) {
+			NotificationService notificationService = ThreemaApplication.getServiceManager().getNotificationService();
+			if (notificationService != null){
+				notificationService.cancelConversationNotificationsOnLockApp();
+			}
 		}
 	}
 

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

@@ -35,7 +35,6 @@ import com.google.android.search.verification.client.SearchActionVerificationCli
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Arrays;
 import java.util.Collections;
 
 import androidx.annotation.RequiresApi;
@@ -48,7 +47,6 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 
 public class VoiceActionService extends SearchActionVerificationClientService {
@@ -128,7 +126,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
 		MediaItem mediaItem = new MediaItem(uri, MediaItem.TYPE_VOICEMESSAGE);
 		mediaItem.setCaption(caption);
 
-		messageService.sendMedia(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver), new RecipientListBaseActivity.SendCompletionHandler() {
+		messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver), new MessageServiceImpl.SendResultListener() {
 			@Override
 			public void onError(String errorMessage) {
 				logger.debug("Error sending audio message: " + errorMessage);
@@ -180,34 +178,8 @@ public class VoiceActionService extends SearchActionVerificationClientService {
 				}
 			}
 		}
-
-		/* TODO - we don't want to do app indexing - privacy leak
-		// Completion token helps Google App match which voice action this completion status is for.
-		final String completionToken = intent.getStringExtra(
-				IntentUtils.INTENT_EXTRA_COMPLETION_TOKEN);
-		final Uri msgUri = …; // Set to the uri deep linking to the sent message.
-		Thing thing = new Thing.Builder()
-				.setName("Message to Jane at 2:27pm")
-				.setUrl(msgUri)
-				.build();
-
-		boolean status = isVerified;
-
-		GoogleApiClient mClient = new GoogleApiClient.Builder(this).addApi(AppIndex.API).build();
-		mClient.connect();
-		Action sendAction = new Action.Builder("http://schema.org/CommunicateAction")
-				.setActionStatus(status ? Action.STATUS_TYPE_COMPLETED : Action.STATUS_TYPE_FAILED)
-				.setObject(thing)
-				.put("completionToken", completionToken)
-				.put(".private:isDeviceOnly", true)
-				.build();
-		AppIndex.AppIndexApi.start(mClient, sendAction);
-		AppIndex.AppIndexApi.end(mClient, sendAction);
-		mClient.disconnect();
-		*/
 	}
 
-
 /*	@Override
 	public boolean isTestingMode() {
 		return true;

+ 16 - 11
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java

@@ -128,10 +128,19 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 				if (m.getType() == MessageType.VOICEMESSAGE) {
 					o.setData(m.getAudioData());
 				}
+				if (m.getType() == MessageType.FILE &&
+					MimeUtil.isAudioFile(m.getFileData().getMimeType())	&&
+					m.getFileData().getRenderingType() == FileData.RENDERING_MEDIA) {
+					o.setData(m.getFileData());
+				}
 			}
 			if (o != null) {
 				if (activity != null) {
-					o.setCurrentActivity(activity, messageReceiver);
+					if (o.isReceiverMatch(messageReceiver)) {
+						o.setCurrentActivity(activity, messageReceiver);
+					} else {
+						o.release();
+					}
 				}
 				this.messagePlayers.put(key, o);
 			}
@@ -165,14 +174,6 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 		return o;
 	}
 
-	@Nullable
-	private MessagePlayer getMessagePlayer(@NonNull AbstractMessageModel messageModel) {
-		synchronized (this.messagePlayers) {
-
-		}
-		return null;
-	}
-
 	private void stopOtherPlayers(AbstractMessageModel messageModel) {
 		logger.debug("stopOtherPlayers");
 		synchronized (this.messagePlayers) {
@@ -237,8 +238,12 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 		synchronized (this.messagePlayers) {
 			for (Map.Entry<String, MessagePlayer> entry : messagePlayers.entrySet()) {
 				// re-attach message players to current activity
-				entry.getValue().setCurrentActivity(activity, messageReceiver);
-				entry.getValue().resume(source);
+				if (entry.getValue().isReceiverMatch(messageReceiver)) {
+					entry.getValue().setCurrentActivity(activity, messageReceiver);
+					entry.getValue().resume(source);
+				} else {
+					entry.getValue().release();
+				}
 			}
 		}
 	}

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

@@ -97,7 +97,7 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	private PreferenceService preferenceService;
 	private ImageView avatarImage, avatarEditOverlay;
 	private AvatarEditListener listener;
-	private boolean hires;
+	private boolean hires, isEditable;
 
 	// the hosting fragment
 	private Fragment fragment;
@@ -153,6 +153,8 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 
 		this.avatarEditOverlay = findViewById(R.id.avatar_edit);
 		this.avatarEditOverlay.setVisibility(View.VISIBLE);
+
+		this.isEditable = true;
 	}
 
 	/**
@@ -187,7 +189,7 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 				boolean editable = isAvatarEditable();
 				avatarImage.setClickable(editable);
 				avatarImage.setFocusable(editable);
-				findViewById(R.id.avatar_edit).setVisibility(editable ? View.VISIBLE : View.GONE);
+				avatarEditOverlay.setVisibility(editable ? View.VISIBLE : View.GONE);
 			}
 		}.execute();
 	}
@@ -225,6 +227,10 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	@SuppressLint("RestrictedApi")
 	@Override
 	public void onClick(View v) {
+		if (!isAvatarEditable()) {
+			return;
+		}
+
 		MenuBuilder menuBuilder = new MenuBuilder(getContext());
 		new MenuInflater(getContext()).inflate(R.menu.view_avatar_edit, menuBuilder);
 
@@ -516,9 +522,9 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	 */
 	private boolean isAvatarEditable() {
 		if (this.avatarData.getContactModel() != null) {
-			return ContactUtil.canHaveCustomAvatar(this.avatarData.getContactModel()) && !(preferenceService.getProfilePicReceive() && fileService.hasContactPhotoFile(this.avatarData.getContactModel()));
+			return isEditable && ContactUtil.canHaveCustomAvatar(this.avatarData.getContactModel()) && !(preferenceService.getProfilePicReceive() && fileService.hasContactPhotoFile(this.avatarData.getContactModel()));
 		} else if (this.avatarData.getGroupModel() != null) {
-			return groupService.isGroupOwner(this.avatarData.getGroupModel());
+			return isEditable && groupService.isGroupOwner(this.avatarData.getGroupModel());
 		}
 		return false;
 	}
@@ -557,7 +563,12 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 		}
 	}
 
+	/**
+	 * Set whether the avatar is editable (i.e. is clickable and gets an overlaid photo button or not)
+ 	 * @param avatarEditable Desired status
+	 */
 	public void setEditable(boolean avatarEditable) {
+		this.isEditable = avatarEditable;
 		this.avatarEditOverlay.setVisibility(avatarEditable ? View.VISIBLE : View.GONE);
 		this.avatarImage.setClickable(avatarEditable);
 		this.avatarImage.setFocusable(avatarEditable);

+ 16 - 19
app/src/main/java/ch/threema/app/ui/ContentCommitComposeEditText.java

@@ -123,25 +123,22 @@ public class ContentCommitComposeEditText extends ComposeEditText {
 						getContext().startActivity(intent);
 
 					} else {
-						new Thread(() -> {
-							String caption = null;
-
-							if (messageService != null) {
-								MediaItem mediaItem = new MediaItem(
-									uri,
-									MimeUtil.isGifFile(mimeType) ?
-										MediaItem.TYPE_GIF :
-										MediaItem.TYPE_IMAGE);
-								mediaItem.setCaption(caption);
-								mediaItem.setMimeType(mimeType);
-								mediaItem.setRenderingType(
-									MimeUtil.MIME_TYPE_IMAGE_JPG.equalsIgnoreCase(mimeType) ?
-									FileData.RENDERING_MEDIA :
-									FileData.RENDERING_STICKER);
-
-								messageService.sendMedia(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver));
-							}
-						}).start();
+						String caption = null;
+
+						if (messageService != null) {
+							MediaItem mediaItem = new MediaItem(
+								uri,
+								MimeUtil.isGifFile(mimeType) ?
+									MediaItem.TYPE_GIF :
+									MediaItem.TYPE_IMAGE);
+							mediaItem.setCaption(caption);
+							mediaItem.setMimeType(mimeType);
+							mediaItem.setRenderingType(
+								MimeUtil.MIME_TYPE_IMAGE_JPG.equalsIgnoreCase(mimeType) ?
+								FileData.RENDERING_MEDIA :
+								FileData.RENDERING_STICKER);
+							messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver));
+						}
 					}
 					return true;
 				}

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

@@ -133,6 +133,7 @@ public class ControllerView extends FrameLayout {
 		status = STATUS_NONE;
 	}
 
+	@UiThread
 	public void setPlay() {
 		logger.debug("setPlay");
 		if (status != STATUS_READY_TO_PLAY) {
@@ -142,6 +143,7 @@ public class ControllerView extends FrameLayout {
 		}
 	}
 
+	@UiThread
 	public void setBroken() {
 		logger.debug("setBroken");
 		if (status != STATUS_BROKEN) {
@@ -151,6 +153,7 @@ public class ControllerView extends FrameLayout {
 		}
 	}
 
+	@UiThread
 	public void setPause() {
 		logger.debug("setPause");
 		setImageResource(R.drawable.ic_pause);

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

@@ -23,10 +23,13 @@ package ch.threema.app.ui.listitemholder;
 
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.SeekBar;
 import android.widget.TextView;
 
+import com.google.android.material.chip.Chip;
+
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.TranscoderView;
@@ -47,7 +50,8 @@ public class ComposeMessageHolder extends AvatarListItemHolder {
 	public View quoteBar;
 	public ImageView quoteThumbnail, quoteTypeImage;
 	public TranscoderView transcoderView;
-	public TextView readOnTextView;
+	public FrameLayout readOnContainer;
+	public Chip readOnButton;
 
 	public ControllerView controller;
 

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

@@ -453,7 +453,7 @@ public class BitmapUtil {
 	 */
 	@SuppressLint("InlinedApi")
 	@WorkerThread
-	public static ExifOrientation rotationForImage(Context context, Uri uri) {
+	public static ExifOrientation getExifOrientation(Context context, Uri uri) {
 		ExifOrientation retVal = new ExifOrientation(FLIP_NONE, 0);
 
 		if (uri != null) {

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

@@ -113,7 +113,7 @@ import static android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
 import static android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS;
 import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
 import static ch.threema.app.ThreemaApplication.getAppContext;
-import static ch.threema.app.camera.CameraUtil.isBlacklistedCamera;
+import static ch.threema.app.camera.CameraUtil.isInternalCameraSupported;
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
 import static ch.threema.app.services.NotificationServiceImpl.APP_RESTART_NOTIFICATION_ID;
 
@@ -206,7 +206,7 @@ public class ConfigUtils {
 	}
 
 	public static boolean supportsVideoCapture() {
-		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isBlacklistedCamera();
+		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInternalCameraSupported();
 	}
 
 	public static boolean supportsPictureInPicture(Context context) {

+ 17 - 10
app/src/main/java/ch/threema/app/utils/FileUtil.java

@@ -58,6 +58,7 @@ import java.util.Locale;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -65,6 +66,7 @@ import ch.threema.app.camera.CameraActivity;
 import ch.threema.app.filepicker.FilePickerActivity;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.FileServiceImpl;
+import ch.threema.app.ui.MediaItem;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 
@@ -558,6 +560,7 @@ public class FileUtil {
 		return null;
 	}
 
+	@WorkerThread
 	public static boolean copyFile(File source, File dest) {
 		try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(source));
 		     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dest, false))) {
@@ -575,6 +578,7 @@ public class FileUtil {
 		return false;
 	}
 
+	@WorkerThread
 	public static boolean copyFile(Uri source, File dest, ContentResolver contentResolver) {
 		try (BufferedInputStream bis = new BufferedInputStream(contentResolver.openInputStream(source));
 		     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dest, false))) {
@@ -665,24 +669,27 @@ public class FileUtil {
 	}
 
 	/**
-	 * Returns the filename of the object referred to by uri. If no filename can be found, generate one
+	 * Returns the filename of the object referred to by mediaItem. If no filename can be found, generate one
 	 * @param contentResolver ContentResolver
-	 * @param uri Uri of the source file
+	 * @param mediaItem MediaItem representing the source file
 	 * @return A filename
 	 */
-	public static @NonNull String getFilenameFromUri(@NonNull ContentResolver contentResolver, @NonNull Uri uri) {
+	public static @NonNull String getFilenameFromUri(@NonNull ContentResolver contentResolver, @NonNull MediaItem mediaItem) {
 		String filename = null;
 
-		if ("file".equals(uri.getScheme())) {
-			filename = uri.getLastPathSegment();
+		if ("file".equals(mediaItem.getUri().getScheme())) {
+			filename = mediaItem.getUri().getLastPathSegment();
 		} else {
-			final Cursor cursor = contentResolver.query(uri, null, null, null, null);
-			if (cursor != null && cursor.moveToNext()) {
-				filename = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME));
+			try (final Cursor cursor = contentResolver.query(mediaItem.getUri(), null, null, null, null)) {
+				if (cursor != null && cursor.moveToNext()) {
+					filename = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME));
+				}
+			} catch (IllegalStateException | SecurityException e) {
+				logger.error("Unable to query Content Resolver", e);
 			}
 		}
-		if (TestUtil.empty(filename)) {
-			filename = "blah";
+		if (TextUtils.isEmpty(filename)) {
+			filename = getDefaultFilename(mediaItem.getMimeType());
 		}
 		return filename;
 	}

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

@@ -260,7 +260,7 @@ public class IconUtil {
 		long imageId = -1;
 		Bitmap thumbnailBitmap = null;
 		ContentResolver contentResolver = context.getContentResolver();
-		BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.rotationForImage(context, uri);
+		BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(context, uri);
 
 		if (!MimeUtil.MIME_TYPE_IMAGE_JPG.equals(mimeType) && !MimeUtil.MIME_TYPE_IMAGE_PNG.equals(mimeType) && !MimeUtil.MIME_TYPE_IMAGE_HEIF.equals(mimeType) && !MimeUtil.MIME_TYPE_IMAGE_HEIC.equals(mimeType)) {
 			if (DocumentsContract.isDocumentUri(context, uri)) {

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

@@ -514,7 +514,7 @@ public class IntentDataUtil {
 	public static Intent getComposeIntentForReceivers(Context context, ArrayList<MessageReceiver> receivers) {
 		Intent intent;
 
-		if (receivers.size() == 1) {
+		if (receivers.size() >= 1) {
 			intent = addMessageReceiverToIntent(new Intent(context, ComposeMessageActivity.class), receivers.get(0));
 			intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
 		} else {

+ 18 - 3
app/src/main/java/ch/threema/app/utils/LocationUtil.java

@@ -78,13 +78,14 @@ public class LocationUtil {
 		Drawable fgDrawable = AppCompatResources.getDrawable(context, getPlaceDrawableRes(context, poi, false));
 		DrawableCompat.setTint(fgDrawable, context.getResources().getColor(R.color.lp_marker_icon));
 
-		Bitmap bitmap = Bitmap.createBitmap(bgDrawable.getIntrinsicWidth(), bgDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+		Bitmap bitmap = Bitmap.createBitmap(bgDrawable.getIntrinsicWidth(), bgDrawable.getIntrinsicHeight() * 2, Bitmap.Config.ARGB_8888);
 
 		Canvas canvas = new Canvas(bitmap);
 
-		bgDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+		bgDrawable.setBounds(0, 0, canvas.getWidth(), bgDrawable.getIntrinsicHeight());
+
 		int left = (canvas.getWidth() - innerIconSize) / 2;
-		int top = (canvas.getHeight() - innerIconSize) / 3;
+		int top = (bgDrawable.getIntrinsicHeight() - innerIconSize) / 3;
 		int right = left + innerIconSize;
 		int bottom = top + innerIconSize;
 
@@ -123,4 +124,18 @@ public class LocationUtil {
 	public static @NonNull String getPoiUrl(@NonNull PreferenceService preferenceService) {
 		return "https://" + getPoiHost(preferenceService) + "/around/%f/%f/%d/";
 	}
+
+
+	/**
+	 * Move marker bitmap up so its bottom will be at the center of the resulting bitmap
+	 * This is necessary because MapBox references the center of the marker image
+	 * @return The resulting bitmap. Will have twice the height of the originating bitmap
+	 */
+	public static Bitmap moveMarker(Bitmap inBitmap) {
+		Bitmap outBitmap = Bitmap.createBitmap(inBitmap.getWidth(), inBitmap.getHeight() * 2, Bitmap.Config.ARGB_8888);
+		Canvas bitmapCanvas = new Canvas(outBitmap);
+		Bitmap tempBitmap = inBitmap.copy(Bitmap.Config.ARGB_8888, false);
+		bitmapCanvas.drawBitmap(tempBitmap, 0, 0, null);
+		return outBitmap;
+	}
 }

+ 7 - 1
app/src/main/java/ch/threema/app/video/VideoTranscoder.java

@@ -193,6 +193,8 @@ public class VideoTranscoder {
 					public void run() {
 						if (result == SUCCESS) {
 							listener.onSuccess(mStats);
+						} else if (result == CANCELED) {
+							listener.onCanceled();
 						} else {
 							listener.onFailure();
 						}
@@ -245,6 +247,8 @@ public class VideoTranscoder {
 
 		if (setupSuccess && transcoderResult == SUCCESS && cleanupSuccess) {
 			this.listener.onSuccess(mStats);
+		} else if (transcoderResult == CANCELED) {
+			this.listener.onCanceled();
 		} else {
 			this.listener.onFailure();
 		}
@@ -1140,9 +1144,9 @@ public class VideoTranscoder {
 			}
 		}
 
-		// TODO: Some devices that cannot properly read a video file's bitrate using MediaMetadataRetriever
 		if (false) {
 			// broken device
+			logger.info("Broken device that cannot properly read a video file's bitrate using MediaMetadataRetriever");
 			return mOutputVideoBitRate;
 		} else {
 			return Math.min(inputBitRate, mOutputVideoBitRate);
@@ -1154,6 +1158,8 @@ public class VideoTranscoder {
 
 		void onProgress(int progress);
 
+		void onCanceled();
+
 		void onFailure();
 
 		void onStart();

+ 3 - 6
app/src/main/java/ch/threema/app/voicemessage/VoiceRecorderActivity.java

@@ -564,12 +564,9 @@ public class VoiceRecorderActivity extends AppCompatActivity implements View.OnC
 	}
 
 	private void returnData() {
-		new Thread(() -> {
-			MediaItem mediaItem = new MediaItem(uri, MimeUtil.MIME_TYPE_AUDIO_AAC, null);
-			mediaItem.setDurationMs(getDurationFromFile());
-
-			messageService.sendMedia(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver));
-		}).start();
+		MediaItem mediaItem = new MediaItem(uri, MimeUtil.MIME_TYPE_AUDIO_AAC, null);
+		mediaItem.setDurationMs(getDurationFromFile());
+		messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver));
 
 		this.finish();
 	}

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/FileMessageCreateHandler.java

@@ -149,7 +149,7 @@ public class FileMessageCreateHandler extends MessageCreateHandler {
 		mediaItem.setRenderingType(renderingType);
 
 		// Send media
-		final AbstractMessageModel model = messageService.sendMedia(Collections.singletonList(mediaItem), receivers);
+		final AbstractMessageModel model = messageService.sendMedia(Collections.singletonList(mediaItem), receivers, null);
 
 		// Remove temporary file
 		if (!file.delete()) {

+ 16 - 0
app/src/main/res/drawable/ic_arrow_forward_outline.xml

@@ -0,0 +1,16 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:pathData="m7.1137,21.2646c-0.0107,0.1928 0.6461,0.3267 0.9718,0.3008 0.2218,-0.0177 0.7262,-0.2171 1.0756,-0.5664l8.31,-8.31c0.39,-0.39 0.39,-1.02 0,-1.41L9.161,2.969c-0.49,-0.49 -1.5417,-0.697 -2.1137,-0.0469 -0.4577,0.5203 -0.1462,1.3269 0.3438,1.8169l7.24,7.25 -7.25,7.25c-0.48,0.48 -0.2886,1.6316 -0.2673,2.0256z"
+      android:fillColor="#666666"/>
+  <path
+      android:pathData="m7.0381,21.2801c0.5994,0.4588 1.2979,0.3497 1.7775,-0.1504l8.0991,-8.4467c0.3817,-0.3981 0.39,-1.02 0,-1.41L8.6046,2.9629c-0.49,-0.49 -1.1016,-0.487 -1.6059,-0.0117C6.3876,3.5271 6.497,4.4382 6.987,4.9282L14.0746,11.9829 6.8661,19.2552c-0.4779,0.4821 -0.3844,1.5725 0.1719,2.0248z"
+      android:fillColor="#666666"/>
+  <path
+      android:pathData="m7.1676,20.8969c0.4902,0.4845 1.2513,0.3227 1.7375,-0.1657l8.1344,-8.1721c0.3901,-0.3856 0.4757,-0.734 0.0856,-1.1195L8.9603,3.3141C8.591,2.9466 7.9345,2.7068 7.4443,3.1913 6.9542,3.6757 6.9757,4.3808 7.4659,4.8652l7.1652,7.1237 -7.3056,7.2987c-0.4776,0.4771 -0.5199,1.21 -0.1579,1.6093 0,0 0,0 0,0z"
+      android:strokeWidth="0.99449557"
+      android:fillColor="#ffffff"/>
+</vector>

+ 3 - 3
app/src/main/res/drawable/ic_map_center_marker.xml

@@ -2,8 +2,8 @@
 <vector xmlns:android="http://schemas.android.com/apk/res/android"
         android:width="48dp"
         android:height="48dp"
-        android:viewportWidth="48"
-        android:viewportHeight="48">
+        android:viewportWidth="43"
+        android:viewportHeight="43">
 	<path
 		android:fillColor="#EF5350"
 		android:pathData="M38.2558,14.5577C38.2558,7.0849 31.944,1.026 24.1527,1.0019V1C24.1454,1 24.1372,1.0009 24.1289,1.0009C24.1217,1.0009 24.1135,1 24.1062,1V1.0019C16.3143,1.026 10.0031,7.0849 10.0031,14.5577C10.0031,14.5577 9.8545,17.9323 11.6149,21.4284C12.9266,24.0362 14.5912,25.7794 16.3003,28.1506C18.9353,31.8058 20.187,33.7253 21.5007,37.2636C22.4339,39.7745 23.3293,42.7376 24.1285,47C24.9286,42.7376 25.8245,39.7745 26.7577,37.2636C28.0728,33.7248 29.3245,31.8054 31.9581,28.1506C33.6672,25.7798 35.3308,24.0362 36.644,21.4284C38.4039,17.9323 38.2558,14.5577 38.2558,14.5577ZM24.1193,19.463C21.2291,19.463 18.8874,17.2184 18.8874,14.4482C18.8874,11.6794 21.2287,9.4358 24.1193,9.4358C27.0104,9.4358 29.3516,11.6794 29.3516,14.4482C29.3516,17.2179 27.0104,19.463 24.1193,19.463Z"
@@ -12,4 +12,4 @@
 	<path
 		android:fillColor="#952F2D"
 		android:pathData="M18,14.4166a6,5.75 0,1 0,12 0a6,5.75 0,1 0,-12 0z" />
-</vector>
+</vector>

+ 22 - 0
app/src/main/res/drawable/ic_outline_campaign.xml

@@ -0,0 +1,22 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M18,11c0,0.67 0,1.33 0,2c1.2,0 2.76,0 4,0c0,-0.67 0,-1.33 0,-2C20.76,11 19.2,11 18,11z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M16,17.61c0.96,0.71 2.21,1.65 3.2,2.39c0.4,-0.53 0.8,-1.07 1.2,-1.6c-0.99,-0.74 -2.24,-1.68 -3.2,-2.4C16.8,16.54 16.4,17.08 16,17.61z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M20.4,5.6C20,5.07 19.6,4.53 19.2,4c-0.99,0.74 -2.24,1.68 -3.2,2.4c0.4,0.53 0.8,1.07 1.2,1.6C18.16,7.28 19.41,6.35 20.4,5.6z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M4,9c-1.1,0 -2,0.9 -2,2v2c0,1.1 0.9,2 2,2h1v4h2v-4h1l5,3V6L8,9H4zM9.03,10.71L11,9.53v4.94l-1.97,-1.18L8.55,13H8H4v-2h4h0.55L9.03,10.71z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M15.5,12c0,-1.33 -0.58,-2.53 -1.5,-3.35v6.69C14.92,14.53 15.5,13.33 15.5,12z"/>
+</vector>

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

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M4,13c1.1,0 2,-0.9 2,-2c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2C2,12.1 2.9,13 4,13zM5.13,14.1C4.76,14.04 4.39,14 4,14c-0.99,0 -1.93,0.21 -2.78,0.58C0.48,14.9 0,15.62 0,16.43V18l4.5,0v-1.61C4.5,15.56 4.73,14.78 5.13,14.1zM20,13c1.1,0 2,-0.9 2,-2c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2C18,12.1 18.9,13 20,13zM24,16.43c0,-0.81 -0.48,-1.53 -1.22,-1.85C21.93,14.21 20.99,14 20,14c-0.39,0 -0.76,0.04 -1.13,0.1c0.4,0.68 0.63,1.46 0.63,2.29V18l4.5,0V16.43zM16.24,13.65c-1.17,-0.52 -2.61,-0.9 -4.24,-0.9c-1.63,0 -3.07,0.39 -4.24,0.9C6.68,14.13 6,15.21 6,16.39V18h12v-1.61C18,15.21 17.32,14.13 16.24,13.65zM8.07,16c0.09,-0.23 0.13,-0.39 0.91,-0.69c0.97,-0.38 1.99,-0.56 3.02,-0.56s2.05,0.18 3.02,0.56c0.77,0.3 0.81,0.46 0.91,0.69H8.07zM12,8c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,8 12,8M12,6c-1.66,0 -3,1.34 -3,3c0,1.66 1.34,3 3,3s3,-1.34 3,-3C15,7.34 13.66,6 12,6L12,6z"/>
+</vector>

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

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M16.54,11L13,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L16.54,11zM11,7H2v2h9V7zM21,13.41L19.59,12L17,14.59L14.41,12L13,13.41L15.59,16L13,18.59L14.41,20L17,17.41L19.59,20L21,18.59L18.41,16L21,13.41zM11,15H2v2h9V15z"/>
+</vector>

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

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M12,6c3.79,0 7.17,2.13 8.82,5.5C19.17,14.87 15.79,17 12,17s-7.17,-2.13 -8.82,-5.5C4.83,8.13 8.21,6 12,6m0,-2C7,4 2.73,7.11 1,11.5 2.73,15.89 7,19 12,19s9.27,-3.11 11,-7.5C21.27,7.11 17,4 12,4zM12,9c1.38,0 2.5,1.12 2.5,2.5S13.38,14 12,14s-2.5,-1.12 -2.5,-2.5S10.62,9 12,9m0,-2c-2.48,0 -4.5,2.02 -4.5,4.5S9.52,16 12,16s4.5,-2.02 4.5,-4.5S14.48,7 12,7z"/>
+</vector>

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

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M12,6c3.79,0 7.17,2.13 8.82,5.5 -0.59,1.22 -1.42,2.27 -2.41,3.12l1.41,1.41c1.39,-1.23 2.49,-2.77 3.18,-4.53C21.27,7.11 17,4 12,4c-1.27,0 -2.49,0.2 -3.64,0.57l1.65,1.65C10.66,6.09 11.32,6 12,6zM10.93,7.14L13,9.21c0.57,0.25 1.03,0.71 1.28,1.28l2.07,2.07c0.08,-0.34 0.14,-0.7 0.14,-1.07C16.5,9.01 14.48,7 12,7c-0.37,0 -0.72,0.05 -1.07,0.14zM2.01,3.87l2.68,2.68C3.06,7.83 1.77,9.53 1,11.5 2.73,15.89 7,19 12,19c1.52,0 2.98,-0.29 4.32,-0.82l3.42,3.42 1.41,-1.41L3.42,2.45 2.01,3.87zM9.51,11.37l2.61,2.61c-0.04,0.01 -0.08,0.02 -0.12,0.02 -1.38,0 -2.5,-1.12 -2.5,-2.5 0,-0.05 0.01,-0.08 0.01,-0.13zM6.11,7.97l1.75,1.75c-0.23,0.55 -0.36,1.15 -0.36,1.78 0,2.48 2.02,4.5 4.5,4.5 0.63,0 1.23,-0.13 1.77,-0.36l0.98,0.98c-0.88,0.24 -1.8,0.38 -2.75,0.38 -3.79,0 -7.17,-2.13 -8.82,-5.5 0.7,-1.43 1.72,-2.61 2.93,-3.53z"/>
+</vector>

+ 2 - 1
app/src/main/res/layout/activity_media_attach.xml

@@ -56,6 +56,7 @@
 		android:id="@id/appbar_layout"
 		android:layout_width="match_parent"
 		android:layout_height="wrap_content"
+		android:background="@android:color/transparent"
 		app:elevation="0dp">
 
 		<com.google.android.material.appbar.MaterialToolbar
@@ -76,7 +77,7 @@
 				android:background="?attr/selectableItemBackgroundBorderless">
 
 				<TextView
-					android:id="@+id/toolbar_title_text"
+					android:id="@+id/toolbar_title_textview"
 					android:layout_width="wrap_content"
 					android:layout_height="match_parent"
 					android:textColor="?android:textColorPrimary"

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

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

+ 7 - 1
app/src/main/res/layout/button_media_attach.xml

@@ -23,13 +23,19 @@
 			app:layout_constraintRight_toRightOf="parent"
 			app:layout_constraintVertical_chainStyle="packed"/>
 
-		<TextView
+		<androidx.appcompat.widget.AppCompatTextView
 			android:id="@+id/label"
 			android:layout_width="wrap_content"
 			android:layout_height="wrap_content"
+			android:paddingLeft="4dp"
+			android:paddingRight="4dp"
 			android:layout_marginTop="4dp"
 			android:textAppearance="@style/AttachmentsLabel"
 			android:importantForAccessibility="no"
+			android:textAlignment="center"
+			android:gravity="center_horizontal"
+			android:hyphenationFrequency="normal"
+			android:breakStrategy="simple"
 			app:layout_constraintTop_toBottomOf="@+id/image"
 			app:layout_constraintBottom_toBottomOf="parent"
 			app:layout_constraintLeft_toLeftOf="parent"

+ 16 - 8
app/src/main/res/layout/conversation_list_item_quote.xml

@@ -105,15 +105,23 @@
 		app:layout_constraintLeft_toLeftOf="parent"
 		app:layout_constraintTop_toBottomOf="@id/quote_layout"/>
 
-	<TextView
-		android:id="@+id/read_on_text"
-		android:layout_width="wrap_content"
+	<FrameLayout
+		android:id="@+id/read_on_container"
+		android:layout_width="match_parent"
 		android:layout_height="wrap_content"
-		android:text="@string/read_on"
-		android:textColor="?colorAccent"
-		android:textSize="?attr/font_large"
+		android:visibility="gone"
 		app:layout_constraintLeft_toLeftOf="parent"
-		app:layout_constraintTop_toBottomOf="@id/text_view"
-		/>
+		app:layout_constraintLeft_toRightOf="parent"
+		app:layout_constraintTop_toBottomOf="@id/text_view">
+
+		<com.google.android.material.chip.Chip
+			android:id="@+id/read_on_button"
+			style="@style/Threema.Chip.VideoTranscoder"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_gravity="center_horizontal"
+			android:text="@string/read_on" />
+
+	</FrameLayout>
 
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 13 - 7
app/src/main/res/layout/conversation_list_item_recv.xml

@@ -47,15 +47,21 @@
 
 		</FrameLayout>
 
-		<TextView
-			android:id="@+id/read_on_text"
+		<FrameLayout
+			android:id="@+id/read_on_container"
 			android:layout_width="match_parent"
 			android:layout_height="wrap_content"
-			android:paddingLeft="@dimen/chat_bubble_margin_start"
-			android:paddingRight="@dimen/chat_bubble_margin_end"
-			android:text="@string/read_on"
-			android:textColor="?colorAccent"
-			android:textSize="?attr/font_large" />
+			android:visibility="gone">
+
+			<com.google.android.material.chip.Chip
+				android:id="@+id/read_on_button"
+				style="@style/Threema.Chip.VideoTranscoder"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:layout_gravity="center_horizontal"
+				android:text="@string/read_on" />
+
+		</FrameLayout>
 
 		<include layout="@layout/conversation_bubble_footer_recv"/>
 

+ 13 - 7
app/src/main/res/layout/conversation_list_item_send.xml

@@ -46,15 +46,21 @@
 
 		</FrameLayout>
 
-		<TextView
-			android:id="@+id/read_on_text"
+		<FrameLayout
+			android:id="@+id/read_on_container"
 			android:layout_width="match_parent"
 			android:layout_height="wrap_content"
-			android:paddingLeft="@dimen/chat_bubble_margin_start"
-			android:paddingRight="@dimen/chat_bubble_margin_end"
-			android:text="@string/read_on"
-			android:textColor="?colorAccent"
-			android:textSize="?attr/font_large"/>
+			android:visibility="gone">
+
+			<com.google.android.material.chip.Chip
+				android:id="@+id/read_on_button"
+				style="@style/Threema.Chip.VideoTranscoder"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:layout_gravity="center_horizontal"
+				android:text="@string/read_on" />
+
+		</FrameLayout>
 
 		<include layout="@layout/conversation_bubble_footer_send"/>
 

+ 2 - 1
app/src/main/res/layout/conversation_list_item_transcoder_view.xml

@@ -26,7 +26,7 @@
 			android:layout_gravity="center_horizontal"
 			android:textColor="@android:color/black"
 			android:text="@string/converting_video"
-			android:textSize="12sp" />
+			android:textAppearance="@style/Threema.TextAppearance.Chip.VideoTranscoder"/>
 
 		<com.google.android.material.chip.Chip
 			android:id="@+id/cancel_button"
@@ -34,6 +34,7 @@
 			android:layout_width="wrap_content"
 			android:layout_height="wrap_content"
 			android:layout_gravity="center_horizontal"
+			android:layout_marginTop="2dp"
 			android:text="@string/cancel" />
 
 	</LinearLayout>

+ 20 - 0
app/src/main/res/layout/media_attach_control_panel.xml

@@ -117,10 +117,30 @@
 					app:labelIcon="@drawable/ic_qr_code"
 					app:labelText="@string/qr_code" />
 
+				<ch.threema.app.mediaattacher.ControlPanelButton
+					android:id="@+id/attach_system_camera"
+					android:layout_width="@dimen/media_attach_button_size"
+					android:layout_height="@dimen/media_attach_button_size"
+					android:layout_gravity="center_vertical"
+					app:fillColor="@color/material_brown"
+					app:foregroundColor="@android:color/white"
+					app:labelIcon="@drawable/ic_camera_outline"
+					app:labelText="@string/attach_camera" />
+
 			</LinearLayout>
 
 		</HorizontalScrollView>
 
+		<ImageView
+			android:id="@+id/more_arrow"
+			android:layout_width="48dp"
+			android:layout_height="48dp"
+			app:srcCompat="@drawable/ic_arrow_forward_outline"
+			android:layout_gravity="center_vertical|right"
+			android:importantForAccessibility="no"
+			android:clickable="true"
+			android:visibility="gone"/>
+
 		<androidx.constraintlayout.widget.ConstraintLayout
 			android:id="@+id/send_panel"
 			android:layout_width="match_parent"

+ 48 - 24
app/src/main/res/menu/activity_home.xml

@@ -10,6 +10,10 @@
 		app:iconTint="?android:textColorSecondary"
 		app:showAsAction="always"/>
 
+	<group
+		android:id="@+id/group_search"
+		android:checkableBehavior="none">
+
 	<item
 		android:id="@+id/globalsearch"
 		android:orderInCategory="15"
@@ -19,17 +23,17 @@
 		app:iconTint="?android:textColorSecondary"
 		app:showAsAction="never"/>
 
-	<item
-		android:id="@+id/menu_toggle_private_chats"
-		android:orderInCategory="20"
-		android:title="@string/title_hide_private_chats"
-		android:visible="false"
-		app:showAsAction="never"/>
+	</group>
+
+	<group
+		android:id="@+id/group_actions"
+		android:checkableBehavior="none">
 
 	<item
 		android:id="@+id/menu_new_group"
 		android:orderInCategory="30"
 		android:title="@string/title_addgroup"
+		android:icon="@drawable/ic_outline_groups"
 		android:visible="true"
 		app:showAsAction="never"/>
 
@@ -37,9 +41,23 @@
 		android:id="@+id/menu_new_distribution_list"
 		android:orderInCategory="40"
 		android:title="@string/title_add_distribution_list"
+		android:icon="@drawable/ic_outline_campaign"
 		android:visible="true"
 		app:showAsAction="never"/>
 
+	</group>
+
+	<group android:id="@+id/stuff"
+			android:checkableBehavior="none">
+
+	<item
+		android:id="@+id/menu_toggle_private_chats"
+		android:orderInCategory="20"
+		android:title="@string/title_hide_private_chats"
+		android:icon="@drawable/ic_outline_visibility"
+		android:visible="false"
+		app:showAsAction="never"/>
+
 	<item
 		android:id="@+id/my_backups"
 		android:icon="@drawable/ic_settings_backup_restore_outline"
@@ -69,26 +87,32 @@
 		android:visible="false"
 		app:showAsAction="never"/>
 
-	<item
-		android:id="@+id/threema_channel"
-		android:icon="@drawable/ic_rss_feed_outline"
-		android:orderInCategory="75"
-		android:title="@string/threema_channel"
-		app:showAsAction="never"/>
+	</group>
 
-	<item
-		android:id="@+id/settings"
-		android:icon="@drawable/ic_settings_outline_24dp"
-		android:orderInCategory="90"
-		android:title="@string/menu_settings"
-		app:showAsAction="never"/>
+	<group
+		android:id="@+id/group_help"
+		android:checkableBehavior="none">
 
-	<item
-		android:id="@+id/help"
-		android:icon="@drawable/ic_help_outline_24dp"
-		android:orderInCategory="95"
-		android:title="@string/support"
-		app:showAsAction="never"/>
+		<item
+			android:id="@+id/threema_channel"
+			android:icon="@drawable/ic_rss_feed_outline"
+			android:orderInCategory="75"
+			android:title="@string/threema_channel"
+			app:showAsAction="never"/>
+
+		<item
+			android:id="@+id/settings"
+			android:icon="@drawable/ic_settings_outline_24dp"
+			android:orderInCategory="90"
+			android:title="@string/menu_settings"
+			app:showAsAction="never"/>
 
+		<item
+			android:id="@+id/help"
+			android:icon="@drawable/ic_help_outline_24dp"
+			android:orderInCategory="95"
+			android:title="@string/support"
+			app:showAsAction="never"/>
 
+	</group>
 </menu>

+ 12 - 0
app/src/main/res/menu/activity_text_chat_bubble.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:android="http://schemas.android.com/apk/res/android">
+
+	<item
+		android:id="@+id/enable_formatting"
+		android:title="@string/enable_formatting"
+		android:checkable="true"
+		android:checked="true"
+		android:orderInCategory="50"
+		app:showAsAction="never" />
+</menu>

+ 4 - 0
app/src/main/res/values-de/strings.xml

@@ -1275,4 +1275,8 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
 	<string name="no_media_found_global">Auf diesem Gerät wurden keine Medien gefunden</string>
 	<string name="prefs_sum_image_labeling">Stichwort-Suche für die öffentlichen Bilder in Ihrer Galerie ermöglichen</string>
 	<string name="prefs_image_labeling">Bildersuche</string>
+	<string name="enable_formatting">Formatierung aktivieren</string>
+	<string name="original_file_no_longer_avilable">Kein Zugriff mehr auf die Original-Datei. Bitte senden Sie diese Nachricht erneut.</string>
+	<string name="state_transcoding">wird transcodiert</string>
+	<string name="importing_files">Dateien werden importiert</string>
 </resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -1204,4 +1204,8 @@
 	<string name="no_media_found_global">No media found on this device</string>
 	<string name="prefs_sum_image_labeling">Enable searching public images in your gallery by keywords</string>
 	<string name="prefs_image_labeling">Image search</string>
+	<string name="enable_formatting">Enable formatting</string>
+	<string name="original_file_no_longer_avilable">The original file is no longer accessible. Please re-send the message.</string>
+	<string name="state_transcoding">transcoding</string>
+	<string name="importing_files">Importing files</string>
 </resources>

+ 4 - 2
app/src/main/res/values/styles.xml

@@ -262,7 +262,9 @@
 	</style>
 
 	<style name="Threema.TextAppearance.Chip.VideoTranscoder" parent="Threema.TextAppearance.Chip">
-		<item name="android:textSize">12sp</item>
+		<item name="android:textSize">14sp</item>
+		<item name="android:fontFamily">sans-serif-condensed</item>
+		<item name="android:letterSpacing">0</item>
 	</style>
 
 	<style name="Threema.TextAppearance.Subtitle1" parent="@style/TextAppearance.MaterialComponents.Subtitle1"/>
@@ -680,7 +682,7 @@
 		<item name="chipBackgroundColor">?attr/colorAccent</item>
 		<item name="chipCornerRadius">14dp</item>
 		<item name="chipMinTouchTargetSize">36dp</item>
-		<item name="chipMinHeight">26dp</item>
+		<item name="chipMinHeight">28dp</item>
 	</style>
 
 	<style name="Threema.OutlinedButton" parent="@style/Widget.MaterialComponents.Button.OutlinedButton">

+ 1 - 1
app/src/main/res/values/themes.xml

@@ -568,6 +568,7 @@
 	</style>
 
 	<style name="Theme.MediaAttacher.Dark" parent="Theme.Threema.WithToolbar.Dark">
+		<item name="android:windowNoTitle">true</item>
 		<item name="colorPrimary">@android:color/transparent</item>
 		<item name="shape_border_toprounded_square">@drawable/shape_border_toprounded_square_dark</item>
 		<item name="gallery_attach_item_background">@color/list_item_background_checked_dark</item>
@@ -575,7 +576,6 @@
 		<item name="android:windowIsTranslucent">true</item>
 		<item name="android:windowAnimationStyle">@android:style/Animation</item>
 		<item name="android:colorBackgroundCacheHint">@null</item>
-		<item name="android:windowNoTitle">true</item>
 		<item name="colorBackgroundFloating">@android:color/transparent</item>
 		<item name="attach_button_background">@color/attach_button_background_dark</item>
 	</style>

+ 0 - 8
app/src/main/res/xml/preference_developers.xml

@@ -25,14 +25,6 @@
 			android:title="POI Host Override"
 			android:summary="Will default to 'poi.threema.ch' if not set."/>
 	</PreferenceCategory>
-	<PreferenceCategory
-		android:key="pref_key_labels"
-		android:title="Image Labels">
-		<Preference
-			android:key="@string/preferences__labels_delete"
-			android:title="Delete image label database"
-			android:summary="This will delete the database with the media image labels. You may need to restart the app afterwards."/>
-	</PreferenceCategory>
 	<PreferenceCategory
 		android:key="pref_key_various"
 		android:title="Various">

+ 1 - 1
app/src/main/res/xml/preference_media.xml

@@ -34,7 +34,7 @@
 			android:title="@string/prefs_save_media"/>
 
 		<CheckBoxPreference
-			android:defaultValue="true"
+			android:defaultValue="false"
 			android:key="@string/preferences__image_labeling"
 			android:summary="@string/prefs_sum_image_labeling"
 			android:title="@string/prefs_image_labeling"/>