Browse Source

Version 4.51

Threema 5 năm trước cách đây
mục cha
commit
ec29965c87
100 tập tin đã thay đổi với 1912 bổ sung881 xóa
  1. 4 0
      .editorconfig
  2. 1 1
      .github/pull_request_template.md
  3. 1 1
      app/assets/license.html
  4. 14 14
      app/build.gradle
  5. 12 11
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  6. 1 1
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  7. 10 15
      app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java
  8. 2 4
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  9. 13 4
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  10. 61 76
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  11. 1 1
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  12. 30 15
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  13. 6 0
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  14. 12 13
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  15. 210 15
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  16. 13 2
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  17. 18 0
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  18. 49 27
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  19. 142 191
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  20. 7 0
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  21. 77 18
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment1.java
  22. 16 0
      app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java
  23. 8 2
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  24. 108 0
      app/src/main/java/ch/threema/app/adapters/ballot/BallotWizard1Adapter.java
  25. 0 118
      app/src/main/java/ch/threema/app/adapters/ballot/BallotWizard1ListAdapter.java
  26. 4 2
      app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java
  27. 3 3
      app/src/main/java/ch/threema/app/camera/CameraFragment.java
  28. 1 1
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  29. 1 1
      app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java
  30. 9 2
      app/src/main/java/ch/threema/app/dialogs/RingtoneSelectorDialog.java
  31. 2 2
      app/src/main/java/ch/threema/app/emojis/EmojiManager.java
  32. 0 1
      app/src/main/java/ch/threema/app/emojis/EmojiSpritemapBitmap.java
  33. 86 34
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  34. 4 2
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  35. 4 2
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.java
  36. 12 16
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java
  37. 6 0
      app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java
  38. 8 1
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java
  39. 1 1
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  40. 14 31
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  41. 7 7
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java
  42. 29 9
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java
  43. 75 29
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  44. 13 6
      app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelingWorker.java
  45. 5 2
      app/src/main/java/ch/threema/app/mediaattacher/labeling/ImageLabelsIndexHashMap.java
  46. 0 4
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  47. 4 4
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  48. 49 0
      app/src/main/java/ch/threema/app/motionviews/FaceItem.java
  49. 89 0
      app/src/main/java/ch/threema/app/motionviews/widget/FaceBlurEntity.java
  50. 48 0
      app/src/main/java/ch/threema/app/motionviews/widget/FaceEmojiEntity.java
  51. 96 0
      app/src/main/java/ch/threema/app/motionviews/widget/FaceEntity.java
  52. 18 9
      app/src/main/java/ch/threema/app/motionviews/widget/ImageEntity.java
  53. 2 0
      app/src/main/java/ch/threema/app/motionviews/widget/MotionEntity.java
  54. 12 7
      app/src/main/java/ch/threema/app/motionviews/widget/MotionView.java
  55. 5 0
      app/src/main/java/ch/threema/app/motionviews/widget/PathEntity.java
  56. 5 0
      app/src/main/java/ch/threema/app/motionviews/widget/TextEntity.java
  57. 1 0
      app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.java
  58. 6 4
      app/src/main/java/ch/threema/app/processors/MessageAckProcessor.java
  59. 1 1
      app/src/main/java/ch/threema/app/receivers/AlarmManagerBroadcastReceiver.java
  60. 5 0
      app/src/main/java/ch/threema/app/receivers/WidgetProvider.java
  61. 1 1
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  62. 3 0
      app/src/main/java/ch/threema/app/services/GroupService.java
  63. 13 1
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  64. 3 1
      app/src/main/java/ch/threema/app/services/MessageService.java
  65. 87 30
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  66. 3 0
      app/src/main/java/ch/threema/app/services/PreferenceService.java
  67. 10 0
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  68. 50 4
      app/src/main/java/ch/threema/app/stores/IdentityStore.java
  69. 7 1
      app/src/main/java/ch/threema/app/stores/PreferenceStore.java
  70. 11 2
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  71. 68 0
      app/src/main/java/ch/threema/app/ui/DebouncedOnMenuItemClickListener.java
  72. 5 11
      app/src/main/java/ch/threema/app/ui/LockingSwipeRefreshLayout.java
  73. 14 8
      app/src/main/java/ch/threema/app/ui/PaintSelectionPopup.java
  74. 13 0
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  75. 14 0
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  76. 20 0
      app/src/main/java/ch/threema/app/utils/IntentDataUtil.java
  77. 0 7
      app/src/main/java/ch/threema/app/utils/RuntimeUtil.java
  78. 9 8
      app/src/main/java/ch/threema/app/video/VideoTranscoder.java
  79. 18 11
      app/src/main/java/ch/threema/app/voip/activities/CallActivity.java
  80. 5 1
      app/src/main/java/ch/threema/app/voip/services/VoipCallService.java
  81. 1 1
      app/src/main/java/ch/threema/base/Contact.java
  82. 1 1
      app/src/main/java/ch/threema/base/ThreemaException.java
  83. 1 1
      app/src/main/java/ch/threema/base/VerificationLevel.java
  84. 5 2
      app/src/main/java/ch/threema/client/APIConnector.java
  85. 1 1
      app/src/main/java/ch/threema/client/AbstractGroupMessage.java
  86. 94 65
      app/src/main/java/ch/threema/client/AbstractMessage.java
  87. 1 1
      app/src/main/java/ch/threema/client/AppVersion.java
  88. 1 1
      app/src/main/java/ch/threema/client/AsyncResolver.java
  89. 1 1
      app/src/main/java/ch/threema/client/BadMessageException.java
  90. 1 1
      app/src/main/java/ch/threema/client/Base32.java
  91. 1 1
      app/src/main/java/ch/threema/client/BlobLoader.java
  92. 1 1
      app/src/main/java/ch/threema/client/BlobUploader.java
  93. 1 1
      app/src/main/java/ch/threema/client/BoxAudioMessage.java
  94. 1 1
      app/src/main/java/ch/threema/client/BoxImageMessage.java
  95. 1 1
      app/src/main/java/ch/threema/client/BoxLocationMessage.java
  96. 1 1
      app/src/main/java/ch/threema/client/BoxTextMessage.java
  97. 1 1
      app/src/main/java/ch/threema/client/BoxVideoMessage.java
  98. 1 1
      app/src/main/java/ch/threema/client/BoxedMessage.java
  99. 1 1
      app/src/main/java/ch/threema/client/ConnectionState.java
  100. 1 1
      app/src/main/java/ch/threema/client/ConnectionStateListener.java

+ 4 - 0
.editorconfig

@@ -14,3 +14,7 @@ indent_size = 4
 [*.gradle]
 indent_style = space
 indent_size = 4
+
+[*.php]
+indent_style = tab
+indent_size = 4

+ 1 - 1
.github/pull_request_template.md

@@ -8,5 +8,5 @@ please include screenshots. -->
 <!-- Please check the items that apply. -->
 
 - [ ] I signed the [Contributor License Agreement](https://threema.ch/en/open-source/cla)
-- [ ] All changes in this pull request were created by me, I own the full copyright
+- [ ] All changes in this pull request were made by me, I own the full copyright
       for all these changes

+ 1 - 1
app/assets/license.html

@@ -43,7 +43,7 @@
 
 <body>
 
-<p class="maincopyright">Copyright © 2020 Threema GmbH.<br/>
+<p class="maincopyright">Copyright © 2021 Threema GmbH.<br/>
     All rights reserved.</p>
 
 <p>This product contains artwork and code from the following rights holders:</p>

+ 14 - 14
app/build.gradle

@@ -75,8 +75,8 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 663
-        versionName "4.5"
+        versionCode 669
+        versionName "4.51"
         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"
+            versionName "4.51k"
             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"
+            versionName "4.51k"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
@@ -208,7 +208,7 @@ android {
             ]
         }
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
-            versionName "4.5r"
+            versionName "4.51r"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
 
@@ -447,15 +447,15 @@ dependencies {
     implementation 'androidx.palette:palette:1.0.0'
     implementation 'androidx.appcompat:appcompat:1.2.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
-    implementation 'androidx.biometric:biometric:1.0.1'
-    implementation "androidx.work:work-runtime:2.4.0"
+    implementation 'androidx.biometric:biometric:1.1.0'
+    implementation "androidx.work:work-runtime:2.5.0"
     implementation 'androidx.fragment:fragment:1.2.5'
     implementation 'androidx.activity:activity:1.1.0'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.0.0-rc02"
-    implementation "androidx.camera:camera-lifecycle:1.0.0-rc02"
-    implementation "androidx.camera:camera-view:1.0.0-alpha21"
+    implementation "androidx.camera:camera-camera2:1.0.0-rc03"
+    implementation "androidx.camera:camera-lifecycle:1.0.0-rc03"
+    implementation "androidx.camera:camera-view:1.0.0-alpha22"
     implementation 'androidx.multidex:multidex:2.0.1'
     implementation "androidx.lifecycle:lifecycle-viewmodel:2.2.0"
     implementation "androidx.lifecycle:lifecycle-livedata:2.2.0"
@@ -468,13 +468,13 @@ dependencies {
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
     implementation "androidx.paging:paging-runtime:2.1.2"
 
-    implementation 'com.google.android.material:material:1.2.1'
+    implementation 'com.google.android.material:material:1.3.0'
     implementation 'com.google.android.exoplayer:exoplayer-core:2.12.1'
     implementation 'com.google.android.exoplayer:exoplayer-ui:2.12.1'
-    implementation 'com.google.mlkit:image-labeling:17.0.1'
+    implementation 'com.google.mlkit:image-labeling:17.0.2'
     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'
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.17'
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.20'
@@ -506,7 +506,7 @@ dependencies {
     annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
 
     // use leak canary in debug builds
-    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.5'
+//    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.5'
 
     // test dependencies
     testImplementation 'junit:junit:4.12'

+ 12 - 11
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -439,15 +439,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						}
 					} else {
 						logger.info("OK, masterKeyFile exists");
-
-/*						// we create a copy of the database for easy restore when database gets corrupted
-						String databasePath = getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME).getAbsolutePath();
-						File databaseFile = new File(databasePath);
-						File backupDatabaseFile = new File(databasePath + ".backup");
-						if (databaseFile.exists()) {
-							FileUtil.copyFile(databaseFile, backupDatabaseFile);
-						}
-*/					}
+					}
 
 					masterKey = new MasterKey(masterKeyFile, null, true);
 
@@ -560,7 +552,16 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 	@Override
 	public void onStop(@NonNull LifecycleOwner owner) {
-		logger.info("*** Lifecycle: App now hidden");
+		logger.info("*** Lifecycle: App now stopped");
+		if (masterKey == null || masterKey.isLocked() || serviceManager == null) {
+			return;
+		}
+
+		try {
+			serviceManager.getMessageService().saveMessageQueue(masterKey);
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
 	}
 
 	@Override
@@ -634,7 +635,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				/* take the opportunity to save the message queue */
 				try {
 					if (serviceManager != null)
-						serviceManager.getMessageService().saveMessageQueue();
+						serviceManager.getMessageService().saveMessageQueueAsync();
 				} catch (Exception e) {
 					logger.error("Exception", e);
 				}

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

@@ -84,7 +84,7 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
 		if (Intent.ACTION_VIEW.equals(appLinkAction) && appLinkData != null){
 			final String threemaId = appLinkData.getLastPathSegment();
 			if (threemaId != null && threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
-				new AddContactAsyncTask(null, null, threemaId, () -> {
+				new AddContactAsyncTask(null, null, threemaId, false, () -> {
 					String text = appLinkData.getQueryParameter("text");
 
 					Intent intent = new Intent(AppLinksActivity.this, text != null ?

+ 10 - 15
app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java

@@ -96,21 +96,6 @@ public class BiometricLockActivity extends ThreemaAppCompatActivity {
 		if (!lockAppService.isLocked() && !isCheckOnly) {
 			finish();
 		}
-	}
-
-	@Override
-	public void finish() {
-		logger.debug("finish");
-		try {
-			super.finish();
-			overridePendingTransition(0, 0);
-		} catch (Exception ignored) {}
-	}
-
-	@Override
-	protected void onResume() {
-		logger.debug("onResume");
-		super.onResume();
 
 		switch (authenticationType) {
 			case PreferenceService.LockingMech_SYSTEM:
@@ -129,6 +114,15 @@ public class BiometricLockActivity extends ThreemaAppCompatActivity {
 		}
 	}
 
+	@Override
+	public void finish() {
+		logger.debug("finish");
+		try {
+			super.finish();
+			overridePendingTransition(0, 0);
+		} catch (Exception ignored) {}
+	}
+
 	private void showBiometricPrompt() {
 		KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
 
@@ -148,6 +142,7 @@ public class BiometricLockActivity extends ThreemaAppCompatActivity {
 		BiometricPrompt biometricPrompt = new BiometricPrompt(this, new RuntimeUtil.MainThreadExecutor(), new BiometricPrompt.AuthenticationCallback() {
 			@Override
 			public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
+				super.onAuthenticationError(errorCode, errString);
 				if (errorCode != BiometricPrompt.ERROR_USER_CANCELED && errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
 					Toast.makeText(BiometricLockActivity.this, errString + " (" + errorCode + ")", Toast.LENGTH_LONG).show();
 				}

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

@@ -61,12 +61,11 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 	private MessageSectionFragment messageSectionFragment;
 
 	private Intent currentIntent;
-	private Bundle bundle;
 
 	private final String COMPOSE_FRAGMENT_TAG = "compose_message_fragment";
 	private final String MESSAGES_FRAGMENT_TAG = "message_section_fragment";
 
-	private MessagePlayerListener messagePlayerListener = new MessagePlayerListener() {
+	private final MessagePlayerListener messagePlayerListener = new MessagePlayerListener() {
 		@Override
 		public void onAudioStreamChanged(int newStreamType) {
 			setVolumeControlStream(newStreamType == AudioManager.STREAM_VOICE_CALL ? AudioManager.STREAM_VOICE_CALL : AudioManager.USE_DEFAULT_STREAM_TYPE);
@@ -83,7 +82,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 		super.onCreate(savedInstanceState);
 
 		this.currentIntent = getIntent();
-		this.bundle = savedInstanceState;
 
 		ListenerManager.messagePlayerListener.add(this.messagePlayerListener);
 
@@ -91,7 +89,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 
 		if (!(masterKey != null && masterKey.isLocked())) {
-			this.initActivity(this.bundle);
+			this.initActivity(savedInstanceState);
 		}
 	}
 

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

@@ -28,6 +28,8 @@ import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.database.Cursor;
 import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Build;
@@ -63,7 +65,6 @@ import androidx.lifecycle.LifecycleOwner;
 import androidx.palette.graphics.Palette;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.ContactDetailAdapter;
@@ -94,6 +95,7 @@ import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
@@ -166,7 +168,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	};
 
-	private ContactSettingsListener contactSettingsListener = new ContactSettingsListener() {
+	private final ContactSettingsListener contactSettingsListener = new ContactSettingsListener() {
 		@Override
 		public void onSortingChanged() { }
 
@@ -185,7 +187,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		public void onNotificationSettingChanged(String uid) { }
 	};
 
-	private ContactListener contactListener = new ContactListener() {
+	private final ContactListener contactListener = new ContactListener() {
 		@Override
 		public void onModified(ContactModel modifiedContactModel) {
 		 	RuntimeUtil.runOnUiThread(() -> {
@@ -212,7 +214,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	};
 
-	private GroupListener groupListener = new GroupListener() {
+	private final GroupListener groupListener = new GroupListener() {
 		@Override
 		public void onCreate(GroupModel newGroupModel) {
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
@@ -387,6 +389,10 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				openContactEditor();
 			}
 		});
+
+		if (getToolbar().getNavigationIcon() != null) {
+			getToolbar().getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+		}
 	}
 
 	private void showWorkTooltip() {
@@ -592,6 +598,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				GenericProgressDialog.newInstance(R.string.deleting_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DELETING_CONTACT);
 			}
 
+
 			@Override
 			protected Boolean doInBackground(Void... params) {
 				if (addToExcludeList) {
@@ -804,9 +811,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 					if (ContactUtil.canReceiveProfilePics(contact)) {
 						if (profilePicRecipientsService != null && profilePicRecipientsService.has(contact.getIdentity())) {
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
+							profilePicItem.setIcon(R.drawable.ic_person_remove_outline);
 							profilePicSendItem.setVisible(true);
 						} else {
 							profilePicItem.setTitle(R.string.menu_send_profilpic);
+							profilePicItem.setIcon(R.drawable.ic_person_add_outline);
 							profilePicSendItem.setVisible(false);
 						}
 						this.profilePicItem.setVisible(true);

+ 61 - 76
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -23,24 +23,23 @@ package ch.threema.app.activities;
 
 import android.annotation.SuppressLint;
 import android.content.Intent;
+import android.content.res.ColorStateList;
 import android.content.res.Configuration;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.SystemClock;
-import android.text.Layout;
-import android.text.Spannable;
-import android.text.SpannableStringBuilder;
-import android.text.Spanned;
-import android.text.method.LinkMovementMethod;
-import android.text.style.ClickableSpan;
+import android.text.TextUtils;
 import android.view.MenuItem;
-import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,9 +48,9 @@ import java.util.List;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.widget.Toolbar;
-import androidx.core.util.Pair;
 import androidx.lifecycle.LiveData;
 import androidx.paging.LivePagedListBuilder;
 import androidx.paging.PagedList;
@@ -66,13 +65,10 @@ import ch.threema.app.ui.DirectoryDataSourceFactory;
 import ch.threema.app.ui.DirectoryHeaderItemDecoration;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
-import ch.threema.app.ui.MentionClickableSpan;
 import ch.threema.app.ui.ThreemaSearchView;
-import ch.threema.app.ui.WorkCategorySpan;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.client.work.WorkDirectoryCategory;
 import ch.threema.client.work.WorkDirectoryContact;
 import ch.threema.client.work.WorkOrganization;
@@ -91,6 +87,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 	private DirectoryAdapter directoryAdapter;
 	private DirectoryDataSourceFactory directoryDataSourceFactory;
 	private EmptyRecyclerView recyclerView;
+	private ChipGroup chipGroup;
 
 	private List<WorkDirectoryCategory> categoryList = new ArrayList<>();
 	private List<WorkDirectoryCategory> checkedCategories = new ArrayList<>();
@@ -184,60 +181,8 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 		sortByFirstName = preferenceService.isContactListSortingFirstName();
 
-		categoriesHeaderTextView = this.findViewById(R.id.categories_header_textview);
-		categoriesHeaderTextView.setMovementMethod(new LinkMovementMethod());
-		categoriesHeaderTextView.setOnTouchListener(new View.OnTouchListener() {
-			@Override
-			public boolean onTouch(View v, MotionEvent event) {
-				int action = event.getAction();
-
-				if (action == MotionEvent.ACTION_UP) {
-					TextView widget = (TextView) v;
-					Object text = widget.getText();
-					if (text instanceof Spannable) {
-						int x = (int) event.getX();
-						int y = (int) event.getY();
-
-						x -= widget.getTotalPaddingLeft();
-						y -= widget.getTotalPaddingTop();
-
-						x += widget.getScrollX();
-						y += widget.getScrollY();
-
-						Layout layout = widget.getLayout();
-						if (layout != null) {
-							int line = layout.getLineForVertical(y);
-							int off = layout.getOffsetForHorizontal(line, x);
-							Spannable buffer = (Spannable) text;
-							ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
-
-							if (link.length != 0) {
-								if (link[0] instanceof MentionClickableSpan) {
-									MentionClickableSpan clickableSpan = (MentionClickableSpan) link[0];
-
-									try {
-										String categoryId = String.valueOf(clickableSpan.getText());
-										if (!TestUtil.empty(categoryId)) {
-											for(WorkDirectoryCategory checkedCategory: checkedCategories) {
-												if (categoryId.equals(checkedCategory.getId())) {
-													checkedCategories.remove(checkedCategory);
-												}
-											}
-											updateSelectedCategories();
-										}
-	//									Toast.makeText(DirectoryActivity.this, "Click on item " + clickableSpan.getText(), Toast.LENGTH_LONG).show();
-										return true;
-									} catch (Exception e) {
-										//
-									}
-								}
-							}
-						}
-					}
-				}
-				return false;
-			}
-		});
+		chipGroup = findViewById(R.id.chip_group);
+
 		categorySpanColor = ConfigUtils.getColorFromAttribute(this, R.attr.mention_background);
 		categorySpanTextColor = ConfigUtils.getColorFromAttribute(this, R.attr.mention_text_color);
 
@@ -325,6 +270,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 			workDirectoryContact.firstName,
 			workDirectoryContact.lastName,
 			workDirectoryContact.threemaId,
+			true,
 			runAfter).execute();
 	}
 
@@ -372,27 +318,66 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 		MultiChoiceSelectorDialog.newInstance(getString(R.string.work_select_categories), categoryNames, categoryChecked).show(getSupportFragmentManager(), DIALOG_TAG_CATEGORY_SELECTOR);
 	}
 
+	@UiThread
 	private void updateSelectedCategories() {
 		int activeCategories = 0;
-		ArrayList<Pair<Integer, Integer>> spans = new ArrayList<>();
 
-		SpannableStringBuilder headerText = new SpannableStringBuilder();
+		chipGroup.removeAllViews();
+
 		for (WorkDirectoryCategory checkedCategory: checkedCategories) {
+			if (!TextUtils.isEmpty(checkedCategory.name)) {
 				activeCategories++;
 
-				int start = headerText.length();
-				headerText.append(checkedCategory.name);
-				int end = headerText.length();
-				headerText.append(" ");
-				spans.add(new Pair<>(start, end));
+				Chip chip = new Chip(this);
+				if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+					chip.setTextAppearance(R.style.TextAppearance_Chip_Ballot);
+				} else {
+					chip.setTextSize(14);
+				}
+
+				ColorStateList foregroundColor, backgroundColor;
+				if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
+					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.textColorPrimary));
+					backgroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
+				} else {
+					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
+					backgroundColor = foregroundColor.withAlpha(0x1A);
+				}
+
+				chip.setTextColor(foregroundColor);
+				chip.setChipBackgroundColor(backgroundColor);
+				chip.setText(checkedCategory.name);
+				chip.setCloseIconVisible(true);
+				chip.setTag(checkedCategory.id);
+				chip.setCloseIconTint(foregroundColor);
+				chip.setOnCloseIconClickListener(new View.OnClickListener() {
+					@Override
+					public void onClick(View v) {
+						String categoryId = (String) v.getTag();
+
+						if (!TextUtils.isEmpty(categoryId)) {
+							for (WorkDirectoryCategory checkedCategory : checkedCategories) {
+								if (categoryId.equals(checkedCategory.getId())) {
+									checkedCategories.remove(checkedCategory);
+									chipGroup.removeView(v);
+									updateDirectory();
+									break;
+								}
+							}
+						}
+					}
+				});
 
-				headerText.setSpan(new WorkCategorySpan(categorySpanColor, categorySpanTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-				headerText.setSpan(new MentionClickableSpan(checkedCategory.getId()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+				chipGroup.addView(chip);
+			}
 		}
 
-		categoriesHeaderTextView.setText(headerText);
-		categoriesHeaderTextView.setVisibility(activeCategories == 0 ? View.GONE : View.VISIBLE);
+		chipGroup.setVisibility(activeCategories == 0 ? View.GONE : View.VISIBLE);
+
+		updateDirectory();
+	}
 
+	private void updateDirectory() {
 		directoryDataSourceFactory.postLiveData.getValue().setQueryCategories(checkedCategories);
 		directoryDataSourceFactory.postLiveData.getValue().invalidate();
 	}

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

@@ -111,7 +111,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 	}
 
 	private void creatingGroupDone(GroupModel newModel) {
-		Toast.makeText(this, getString(R.string.group_created_confirm), Toast.LENGTH_LONG).show();
+		Toast.makeText(ThreemaApplication.getAppContext(), getString(R.string.group_created_confirm), Toast.LENGTH_LONG).show();
 
 		Intent intent = new Intent(this, ComposeMessageActivity.class);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, newModel.getId());

+ 30 - 15
app/src/main/java/ch/threema/app/activities/GroupAddActivity.java

@@ -29,8 +29,10 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.IntentDataUtil;
@@ -38,8 +40,9 @@ import ch.threema.app.utils.LogUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
-public class GroupAddActivity extends MemberChooseActivity {
+public class GroupAddActivity extends MemberChooseActivity implements GenericAlertDialog.DialogClickListener {
 	private static final String BUNDLE_EXISTING_MEMBERS = "ExMem";
+	private static final String DIALOG_TAG_NO_MEMBERS = "NoMem";
 
 	private GroupService groupService;
 	private GroupModel groupModel;
@@ -114,22 +117,26 @@ public class GroupAddActivity extends MemberChooseActivity {
 			if ((previousContacts + selectedContacts.size()) > getResources().getInteger(R.integer.max_group_size)) {
 				Toast.makeText(this, String.format(getString(R.string.group_select_max), getResources().getInteger(R.integer.max_group_size) - previousContacts), Toast.LENGTH_LONG).show();
 			} else {
-				//ok!
-				if(this.groupModel != null) {
-					// edit group mode
-					Intent intent = new Intent();
-					IntentDataUtil.append(selectedContacts, intent);
-					setResult(RESULT_OK, intent);
-					finish();
-				} else {
-					// new group mode
-					Intent nextIntent =  new Intent(this, GroupAdd2Activity.class);
-					IntentDataUtil.append(selectedContacts, nextIntent);
-					startActivityForResult(nextIntent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
-				}
+				createOrUpdateGroup(selectedContacts);
 			}
 		} else {
-			Toast.makeText(this, getString(R.string.group_select_at_least_two), Toast.LENGTH_LONG).show();
+			GenericAlertDialog.newInstance(R.string.title_addgroup, R.string.group_create_no_members, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_NO_MEMBERS);
+		}
+	}
+
+	private void createOrUpdateGroup(@NonNull final List<ContactModel> selectedContacts) {
+		//ok!
+		if(this.groupModel != null) {
+			// edit group mode
+			Intent intent = new Intent();
+			IntentDataUtil.append(selectedContacts, intent);
+			setResult(RESULT_OK, intent);
+			finish();
+		} else {
+			// new group mode
+			Intent nextIntent =  new Intent(this, GroupAdd2Activity.class);
+			IntentDataUtil.append(selectedContacts, nextIntent);
+			startActivityForResult(nextIntent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
 		}
 	}
 
@@ -152,4 +159,12 @@ public class GroupAddActivity extends MemberChooseActivity {
 		super.onSaveInstanceState(outState);
 		outState.putStringArrayList(BUNDLE_EXISTING_MEMBERS, this.excludedIdentities);
 	}
+
+	@Override
+	public void onYes(String tag, Object data) {
+		createOrUpdateGroup(new ArrayList<>());
+	}
+
+	@Override
+	public void onNo(String tag, Object data) { }
 }

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

@@ -26,6 +26,8 @@ import android.app.Activity;
 import android.content.Intent;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
@@ -406,6 +408,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		setScrimColor();
 		updateFloatingActionButton();
 
+		if (toolbar.getNavigationIcon() != null) {
+			toolbar.getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+		}
+
 		ListenerManager.contactSettingsListeners.add(this.contactSettingsListener);
 		ListenerManager.groupListeners.add(this.groupListener);
 		ListenerManager.contactListeners.add(this.contactListener);

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

@@ -198,7 +198,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private final ArrayList<AbstractMessageModel> unsentMessages = new ArrayList<>();
 
 	private BroadcastReceiver checkLicenseBroadcastReceiver = null;
-	private BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
+	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
 		@Override
 		public void onReceive(Context context, final Intent intent) {
 			RuntimeUtil.runOnUiThread(new Runnable() {
@@ -334,7 +334,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
-	private SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
+	private final SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
 		@Override
 		public void onVerified() {
 		 	RuntimeUtil.runOnUiThread(new Runnable() {
@@ -372,7 +372,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		});
 	}
 
-	private ConversationListener conversationListener = new ConversationListener() {
+	private final ConversationListener conversationListener = new ConversationListener() {
 		@Override
 		public void onNew(ConversationModel conversationModel) {
 			updateBottomNavigation();
@@ -394,7 +394,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	};
 
-	private MessageListener messageListener = new MessageListener() {
+	private final MessageListener messageListener = new MessageListener() {
 		@Override
 		public void onNew(AbstractMessageModel newMessage) {
 			//do nothing
@@ -430,7 +430,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	};
 
-	private AppIconListener appIconListener = new AppIconListener() {
+	private final AppIconListener appIconListener = new AppIconListener() {
 		@Override
 		public void onChanged() {
 		 	RuntimeUtil.runOnUiThread(new Runnable() {
@@ -442,7 +442,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	};
 
-	private ProfileListener profileListener = new ProfileListener() {
+	private final ProfileListener profileListener = new ProfileListener() {
 		@Override
 		public void onAvatarChanged() {
 			RuntimeUtil.runOnUiThread(() -> updateDrawerImage());
@@ -457,7 +457,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onNicknameChanged(String newNickname) { }
 	};
 
-	private VoipCallListener voipCallListener = new VoipCallListener() {
+	private final VoipCallListener voipCallListener = new VoipCallListener() {
 		@Override
 		public void onStart(String contact, long elpasedTimeMs) {
 			RuntimeUtil.runOnUiThread(() -> {
@@ -583,17 +583,16 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				if (!ConfigUtils.isWorkBuild() && !RuntimeUtil.isInTest() && !isFinishing()) {
 					isWhatsNewShown = true; // make sure this is set false if no whatsnew activity is shown - otherwise pin lock will be skipped once
 
-					// Do not show whatsnew for users of the previous 4.0x version
-/*					int previous = preferenceService.getLatestVersion() % 1000;
+					// Do not show whatsnew for users of the previous 4.5x version
+					int previous = preferenceService.getLatestVersion() % 1000;
 
-					if (previous < 494) {
-*/						Intent intent = new Intent(this, WhatsNewActivity.class);
+					if (previous < 663) {
+						Intent intent = new Intent(this, WhatsNewActivity.class);
 						startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 						overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
-/*					} else {
+					} else {
 						isWhatsNewShown = false;
 					}
-*/
 					preferenceService.setLatestVersion(this);
 				}
 			}

+ 210 - 15
app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java

@@ -27,6 +27,11 @@ import android.content.res.Configuration;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.Typeface;
+import android.media.FaceDetector;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Build;
@@ -42,25 +47,35 @@ import android.widget.Toast;
 
 import com.android.colorpicker.ColorPickerDialog;
 import com.android.colorpicker.ColorPickerSwatch;
+import com.getkeepsafe.taptargetview.TapTarget;
+import com.getkeepsafe.taptargetview.TapTargetView;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.BufferedInputStream;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.List;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.motionviews.FaceItem;
 import ch.threema.app.motionviews.viewmodel.Font;
 import ch.threema.app.motionviews.viewmodel.Layer;
 import ch.threema.app.motionviews.viewmodel.TextLayer;
+import ch.threema.app.motionviews.widget.FaceBlurEntity;
+import ch.threema.app.motionviews.widget.FaceEmojiEntity;
+import ch.threema.app.motionviews.widget.FaceEntity;
 import ch.threema.app.motionviews.widget.ImageEntity;
 import ch.threema.app.motionviews.widget.MotionEntity;
 import ch.threema.app.motionviews.widget.MotionView;
@@ -78,7 +93,6 @@ import ch.threema.app.utils.TestUtil;
 
 public class ImagePaintActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(ImagePaintActivity.class);
-	private static final String TAG = "ImagePaintActivity";
 
 	private static final String DIALOG_TAG_COLOR_PICKER = "colp";
 	private static final String KEY_PEN_COLOR = "pc";
@@ -86,9 +100,13 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	private static final int REQUEST_CODE_ENTER_TEXT = 45;
 	private static final String DIALOG_TAG_QUIT_CONFIRM = "qq";
 	private static final String DIALOG_TAG_SAVING_IMAGE = "se";
+	private static final String DIALOG_TAG_BLUR_FACES = "bf";
+
+	private static final String SMILEY_PATH = "emojione/3_Emoji_classic/1f600.png";
 
 	private static final int STROKE_MODE_BRUSH = 0;
 	private static final int STROKE_MODE_PENCIL = 1;
+	private static final int MAX_FACES = 16;
 
 	private ImageView imageView;
 	private PaintView paintView;
@@ -98,7 +116,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	private Uri imageUri, outputUri;
 	private ProgressBar progressBar;
 	@ColorInt private int penColor;
-	private MenuItem undoItem, paletteItem, paintItem, pencilItem;
+	private MenuItem undoItem, paletteItem, paintItem, pencilItem, blurFacesItem;
 	private PaintSelectionPopup paintSelectionPopup;
 	private ArrayList<MotionEntity> undoHistory = new ArrayList<>();
 	private boolean saveSemaphore = false;
@@ -276,8 +294,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			}
 
 			@Override
-			public void onLongClick(int x, int y) {
-				paintSelectionPopup.show((int) motionView.getX() + x, (int) motionView.getY() + y);
+			public void onLongClick(MotionEntity entity, int x, int y) {
+				paintSelectionPopup.show((int) motionView.getX() + x, (int) motionView.getY() + y, !entity.hasFixedPositionAndSize());
 			}
 
 			@Override
@@ -336,12 +354,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		});
 
 		this.imageFrame = findViewById(R.id.content_frame);
-		this.imageFrame.post(new Runnable() {
-			@Override
-			public void run() {
-				loadImage();
-			}
-		});
+		this.imageFrame.post(() -> loadImage());
+
+		showTooltip();
 	}
 
 	private void loadImage() {
@@ -409,13 +424,151 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 	}
 
+	@SuppressLint("StaticFieldLeak")
+	private void blurFaces(final boolean useEmoji) {
+		this.paintView.setActive(false);
+
+		new AsyncTask<Void, Void, List<FaceItem>>() {
+			int numFaces = -1;
+			int originalImageWidth, originalImageHeight;
+
+			@Override
+			protected void onPreExecute() {
+				GenericProgressDialog.newInstance(-1, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_BLUR_FACES);
+			}
+
+			@Override
+			protected List<FaceItem> doInBackground(Void... voids) {
+				BitmapFactory.Options options;
+				Bitmap bitmap, orgBitmap;
+				List<FaceItem> faceItemList = new ArrayList<>();
+
+				try (InputStream measure = getContentResolver().openInputStream(imageUri)) {
+					options = BitmapUtil.getImageDimensions(measure);
+				} catch (IOException | SecurityException | IllegalStateException | OutOfMemoryError e) {
+					logger.error("Exception", e);
+					return null;
+				}
+
+				if (options.outWidth < 16 || options.outHeight < 16) {
+					return null;
+				}
+
+				originalImageWidth = options.outWidth;
+				originalImageHeight = options.outHeight;
+
+				options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+				options.inJustDecodeBounds = false;
+
+				try (InputStream data = getContentResolver().openInputStream(imageUri)) {
+					if (data != null) {
+						orgBitmap = BitmapFactory.decodeStream(new BufferedInputStream(data), null, options);
+						if (orgBitmap != null) {
+							bitmap = Bitmap.createBitmap(orgBitmap.getWidth() & ~0x1, orgBitmap.getHeight(), Bitmap.Config.RGB_565);
+							new Canvas(bitmap).drawBitmap(orgBitmap, 0, 0, null);
+						} else {
+							logger.info("could not open image");
+							return null;
+						}
+					} else {
+						logger.info("could not open input stream");
+						return null;
+					}
+				} catch (Exception e) {
+					logger.error("Exception", e);
+					return null;
+				}
+
+				try {
+					FaceDetector faceDetector = new FaceDetector(bitmap.getWidth(), bitmap.getHeight(), MAX_FACES);
+					FaceDetector.Face[] faces = new FaceDetector.Face[MAX_FACES];
+
+					numFaces = faceDetector.findFaces(bitmap, faces);
+					if (numFaces < 1) {
+						return null;
+					}
+
+					logger.debug("{} faces found.", numFaces);
+
+					Bitmap emoji = null;
+					if (useEmoji) {
+						emoji = BitmapFactory.decodeStream(getAssets().open(SMILEY_PATH));
+					}
+
+					for (FaceDetector.Face face: faces) {
+						if (face != null) {
+							if (useEmoji) {
+								faceItemList.add(new FaceItem(face, emoji, 1));
+							} else {
+								float offsetY = face.eyesDistance() * FaceEntity.BLUR_RADIUS;
+								PointF midPoint = new PointF();
+								face.getMidPoint(midPoint);
+
+								int croppedBitmapSize = (int) (offsetY * 2);
+								float scale = 1f;
+								// pixelize large bitmaps
+								if (croppedBitmapSize > 64) {
+									scale = (float) croppedBitmapSize / 64f;
+								}
+
+								float scaleFactor = 1f / scale;
+								Matrix matrix = new Matrix();
+								matrix.setScale(scaleFactor, scaleFactor);
+
+								Bitmap croppedBitmap = Bitmap.createBitmap(
+									orgBitmap,
+									offsetY > midPoint.x ? 0 : (int) (midPoint.x - offsetY),
+									offsetY > midPoint.y ? 0 : (int) (midPoint.y - offsetY),
+									croppedBitmapSize,
+									croppedBitmapSize,
+									matrix,
+									false);
+
+								faceItemList.add(new FaceItem(face, croppedBitmap, scale));
+							}
+						}
+					}
+
+					return faceItemList;
+				} catch (Exception e) {
+					logger.error("Face detection failed", e);
+					return null;
+				} finally {
+					bitmap.recycle();
+				}
+			}
+
+			@Override
+			protected void onPostExecute(List<FaceItem> faceItemList) {
+				if (faceItemList != null && faceItemList.size() > 0) {
+					motionView.post(() -> {
+						for (FaceItem faceItem : faceItemList) {
+							Layer layer = new Layer();
+							if (useEmoji) {
+								FaceEmojiEntity entity = new FaceEmojiEntity(layer, faceItem, originalImageWidth, originalImageHeight, motionView.getWidth(), motionView.getHeight());
+								motionView.addEntity(entity);
+							} else {
+								FaceBlurEntity entity = new FaceBlurEntity(layer, faceItem, originalImageWidth, originalImageHeight, motionView.getWidth(), motionView.getHeight());
+								motionView.addEntity(entity);
+							}
+						}
+					});
+				} else {
+					Toast.makeText(ImagePaintActivity.this, R.string.no_faces_detected, Toast.LENGTH_LONG).show();
+				}
+
+				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_BLUR_FACES, true);
+			}
+		}.execute();
+	}
+
 	@Override
 	public boolean onPrepareOptionsMenu(Menu menu) {
 		super.onPrepareOptionsMenu(menu);
 
-		ConfigUtils.themeMenuItem(paletteItem, getResources().getColor(android.R.color.white));
-		ConfigUtils.themeMenuItem(paintItem, getResources().getColor(android.R.color.white));
-		ConfigUtils.themeMenuItem(pencilItem, getResources().getColor(android.R.color.white));
+		ConfigUtils.themeMenuItem(paletteItem, Color.WHITE);
+		ConfigUtils.themeMenuItem(paintItem, Color.WHITE);
+		ConfigUtils.themeMenuItem(pencilItem, Color.WHITE);
 
 		if (motionView.getSelectedEntity() == null) {
 			// no selected entities => draw mode or neutral mode
@@ -428,6 +581,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			}
 		}
 		undoItem.setVisible(undoHistory.size() > 0);
+		blurFacesItem.setVisible(motionView.getEntitiesCount() == 0);
 		return true;
 	}
 
@@ -441,6 +595,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		paletteItem = menu.findItem(R.id.item_palette);
 		paintItem = menu.findItem(R.id.item_draw);
 		pencilItem = menu.findItem(R.id.item_pencil);
+		blurFacesItem = menu.findItem(R.id.item_face);
 
 		return true;
 	}
@@ -488,12 +643,52 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 					setDrawMode(true);
 				}
 				break;
+			case R.id.item_face_blur:
+				blurFaces(false);
+				break;
+			case R.id.item_face_emoji:
+				blurFaces(true);
+				break;
 			default:
 				break;
 		}
 		return false;
 	}
 
+	@UiThread
+	public void showTooltip() {
+		if (!preferenceService.getIsFaceBlurTooltipShown()) {
+			if (getToolbar() != null) {
+				getToolbar().postDelayed(() -> {
+					final View v = findViewById(R.id.item_face);
+					try {
+						TapTargetView.showFor(this,
+							TapTarget.forView(v, getString(R.string.face_blur_tooltip_title), getString(R.string.face_blur_tooltip_text))
+								.outerCircleColor(R.color.accent_dark)      // Specify a color for the outer circle
+								.outerCircleAlpha(0.96f)            // Specify the alpha amount for the outer circle
+								.targetCircleColor(android.R.color.white)   // Specify a color for the target circle
+								.titleTextSize(24)                  // Specify the size (in sp) of the title text
+								.titleTextColor(android.R.color.white)      // Specify the color of the title text
+								.descriptionTextSize(18)            // Specify the size (in sp) of the description text
+								.descriptionTextColor(android.R.color.white)  // Specify the color of the description text
+								.textColor(android.R.color.white)            // Specify a color for both the title and description text
+								.textTypeface(Typeface.SANS_SERIF)  // Specify a typeface for the text
+								.dimColor(android.R.color.black)            // If set, will dim behind the view with 30% opacity of the given color
+								.drawShadow(true)                   // Whether to draw a drop shadow or not
+								.cancelable(true)                  // Whether tapping outside the outer circle dismisses the view
+								.tintTarget(true)                   // Whether to tint the target view's color
+								.transparentTarget(false)           // Specify whether the target is transparent (displays the content underneath)
+								.targetRadius(50)                  // Specify the target radius (in dp)
+						);
+						preferenceService.setFaceBlurTooltipShown(true);
+					} catch (Exception ignore) {
+						// catch null typeface exception on CROSSCALL Action-X3
+					}
+				}, 2000);
+			}
+		}
+	}
+
 	private void setStrokeMode(int strokeMode) {
 		this.strokeMode = strokeMode;
 		this.paintView.setStrokeWidth(
@@ -579,8 +774,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 				getResources().getColor(R.color.material_grey_600),
 				getResources().getColor(R.color.material_grey_500),
 				getResources().getColor(R.color.material_grey_300),
-				getResources().getColor(android.R.color.white),
-				getResources().getColor(android.R.color.black),
+				Color.WHITE,
+				Color.BLACK,
 		};
 
 		ColorPickerDialog colorPickerDialog = new ColorPickerDialog();

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

@@ -216,6 +216,9 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 				if (count > 0) {
 					mode.setTitle(Integer.toString(count));
 				}
+				if (actionMode != null) {
+					actionMode.getMenu().findItem(R.id.menu_show_in_chat).setVisible(count == 1);
+				}
 			}
 
 			@Override
@@ -247,8 +250,8 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 					case R.id.menu_message_save:
 						saveMessages();
 						return true;
-					case R.id.menu_message_select_all:
-						selectAllMessages();
+					case R.id.menu_show_in_chat:
+						showInChat();
 						return true;
 					default:
 						return false;
@@ -304,6 +307,14 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		return true;
 	}
 
+	private void showInChat() {
+		if (getSelectedMessages().size() != 1) {
+			return;
+		}
+		AnimationUtil.startActivityForResult(this, null, IntentDataUtil.getJumpToMessageIntent(this, getSelectedMessages().get(0)), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+		finish();
+	}
+
 	@Override
 	protected void onDestroy() {
 		if (this.thumbnailCache != null) {

+ 18 - 0
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -28,6 +28,8 @@ import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
@@ -75,6 +77,7 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.ui.LockableViewPager;
+import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
@@ -388,6 +391,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity {
 			menu.findItem(R.id.menu_view).setVisible(false);
 		}
 
+		if (getToolbar().getNavigationIcon() != null) {
+			getToolbar().getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+		}
+
 		return true;
 	}
 
@@ -411,6 +418,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity {
 		} else if (itemId == R.id.menu_gallery) {
 			showGallery();
 			return true;
+		} else if (itemId == R.id.menu_show_in_chat) {
+			showInChat(this.currentMessageModel);
+			return true;
 		} else {
 			return super.onOptionsItemSelected(item);
 		}
@@ -462,6 +472,14 @@ public class MediaViewerActivity extends ThreemaToolbarActivity {
 		}
 	}
 
+	private void showInChat(AbstractMessageModel messageModel) {
+		if (messageModel == null) {
+			return;
+		}
+		AnimationUtil.startActivityForResult(this, null, IntentDataUtil.getJumpToMessageIntent(this, messageModel), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+		finish();
+	}
+
 	private void hideSystemUi() {
 		logger.debug("hideSystemUi");
 		if (getWindow() != null) {

+ 49 - 27
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -59,7 +59,6 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 
 import androidx.annotation.AnyThread;
@@ -171,7 +170,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		public void run() {
 			for (int i = 0; i < mediaItems.size(); i++) {
 				MediaItem mediaItem = mediaItems.get(i);
-				mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
+
+				if (TestUtil.empty(mediaItem.getFilename())) {
+					mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
+				}
 
 				if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(mediaItem.getUri().getScheme())) {
 					try {
@@ -444,10 +446,16 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						}
 						if (type.equals("text/plain")) {
 							String textIntent = getTextFromIntent(intent);
-
 							if (uri != null) {
-								// send text as file
-								addMediaItem("x-text/plain", uri, null);
+								// default to sending text as file
+								type = "x-text/plain";
+
+								String guessedType = getMimeTypeFromContentUri(uri);
+								if (guessedType != null) {
+									type = guessedType;
+								}
+
+								addMediaItem(type, uri, textIntent);
 								if (textIntent != null) {
 									captionText = textIntent;
 								}
@@ -456,28 +464,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							}
 						} else {
 							if (uri != null) {
-								if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
-									// query database for correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*"
-									String[] proj = {
-										DocumentsContract.Document.COLUMN_MIME_TYPE
-									};
-
-									try (Cursor cursor = getContentResolver().query(uri, proj, null, null, null)) {
-										if (cursor != null && cursor.moveToFirst()) {
-											String mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
-											if (!TestUtil.empty(mimeType)) {
-												type = mimeType;
-											}
-										}
-									} catch (Exception e) {
-										String filemame = FileUtil.getFilenameFromUri(getContentResolver(), uri);
-										if (!TestUtil.empty(filemame)) {
-											String mimeType = FileUtil.getMimeTypeFromPath(filemame);
-											if (!TestUtil.empty(mimeType)) {
-												type = mimeType;
-											}
-										}
-									}
+								String guessedType = getMimeTypeFromContentUri(uri);
+								if (guessedType != null) {
+									type = guessedType;
 								}
 
 								// if text was shared along with the media item, add that too
@@ -633,6 +622,33 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		setupUI();
 	}
 
+	private @Nullable String getMimeTypeFromContentUri(@NonNull Uri uri) {
+		if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
+			// query database for correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*"
+			String[] proj = {
+				DocumentsContract.Document.COLUMN_MIME_TYPE
+			};
+
+			try (Cursor cursor = getContentResolver().query(uri, proj, null, null, null)) {
+				if (cursor != null && cursor.moveToFirst()) {
+					String mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
+					if (!TestUtil.empty(mimeType)) {
+						return mimeType;
+					}
+				}
+			} catch (Exception e) {
+				String filemame = FileUtil.getFilenameFromUri(getContentResolver(), uri);
+				if (!TestUtil.empty(filemame)) {
+					String mimeType = FileUtil.getMimeTypeFromPath(filemame);
+					if (!TestUtil.empty(mimeType)) {
+						return mimeType;
+					}
+				}
+			}
+		}
+		return null;
+	}
+
 	private String getTextFromIntent(Intent intent) {
 		String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
 		String text = intent.getStringExtra(Intent.EXTRA_TEXT);
@@ -667,6 +683,12 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					return;
 				}
 			}
+		} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
+			try {
+				getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			} catch (Exception e) {
+				logger.error("Exception", e);
+			}
 		}
 		mediaItems.add(new MediaItem(uri, mimeType, caption));
 	}

+ 142 - 191
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java

@@ -33,11 +33,14 @@ import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.media.MediaMetadataRetriever;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.Handler;
 import android.provider.MediaStore;
 import android.text.Editable;
 import android.text.TextWatcher;
@@ -89,20 +92,17 @@ import ch.threema.app.camera.VideoEditView;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.emojis.EmojiButton;
 import ch.threema.app.emojis.EmojiPicker;
-import ch.threema.app.emojis.EmojiTextView;
 import ch.threema.app.mediaattacher.MediaSelectionActivity;
 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.PreferenceService;
-import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.ComposeEditText;
 import ch.threema.app.ui.DebouncedOnClickListener;
+import ch.threema.app.ui.DebouncedOnMenuItemClickListener;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SendButton;
-import ch.threema.app.ui.TooltipPopup;
-import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.ui.draggablegrid.DynamicGridView;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.BitmapUtil;
@@ -165,13 +165,12 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	private ImageButton cameraButton;
 	private String cameraFilePath, videoFilePath;
 	private boolean pickFromCamera, hasChanges = false;
-	private View editPanel;
 	private View backgroundLayout;
 	private int parentWidth = 0, parentHeight = 0;
 	private int bigImagePos = 0;
 	private boolean useExternalCamera;
 	private VideoEditView videoEditView;
-	private ImageButton settingsItem;
+	private MenuItem settingsItem;
 
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
@@ -231,6 +230,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			return false;
 		}
 		actionBar.setDisplayHomeAsUpEnabled(true);
+		actionBar.setTitle("");
 
 		DeadlineListService hiddenChatsListService;
 		try {
@@ -482,114 +482,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		});
 		sendButton.setEnabled(true);
 
-		findViewById(R.id.rotate).setOnClickListener(new DebouncedOnClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
-			@Override
-			public void onDebouncedClick(View v) {
-				if (bigImagePos >= SendMediaActivity.this.mediaItems.size()) {
-					return;
-				}
-
-				int oldRotation = SendMediaActivity.this.mediaItems.get(bigImagePos).getRotation();
-				int newRotation = (oldRotation + 90) % 360;
-
-				int height = bigImageView.getDrawable().getBounds().width();
-				int width = bigImageView.getDrawable().getBounds().height();
-
-				float screenAspectRatio = (float) parentWidth / (float) parentHeight;
-				float imageAspectRatio = (float) width / (float) height;
-
-				float scalingFactor;
-				if (screenAspectRatio > imageAspectRatio) {
-					scalingFactor = (float) parentHeight / (float) height;
-				} else {
-					scalingFactor = (float) parentWidth / (float) width;
-				}
-
-				bigImageView.animate().rotationBy(90f)
-						.scaleX(scalingFactor)
-						.scaleY(scalingFactor)
-						.setDuration(IMAGE_ANIMATION_DURATION_MS)
-						.setInterpolator(new FastOutSlowInInterpolator())
-						.setListener(new Animator.AnimatorListener() {
-							@Override
-							public void onAnimationStart(Animator animation) {}
-
-							@Override
-							public void onAnimationEnd(Animator animation) {
-								SendMediaActivity.this.mediaItems.get(bigImagePos).setRotation(newRotation);
-								showBigImage(bigImagePos, false);
-								sendMediaGridAdapter.notifyDataSetChanged();
-								hasChanges = true;
-							}
-
-							@Override
-							public void onAnimationCancel(Animator animation) {}
-
-							@Override
-							public void onAnimationRepeat(Animator animation) {}
-						});
-			}
-		});
-		findViewById(R.id.crop).setOnClickListener(new DebouncedOnClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
-			@Override
-			public void onDebouncedClick(View v) {
-				if (bigImagePos >= SendMediaActivity.this.mediaItems.size()) {
-					return;
-				}
-
-				cropImage();
-			}
-		});
-		findViewById(R.id.flip).setOnClickListener(new DebouncedOnClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
-			@Override
-			public void onDebouncedClick(View v) {
-				if (bigImagePos >= SendMediaActivity.this.mediaItems.size()) {
-					return;
-				}
-
-				bigImageView.animate().rotationY(180f)
-						.setDuration(IMAGE_ANIMATION_DURATION_MS)
-						.setInterpolator(new FastOutSlowInInterpolator())
-						.setListener(new Animator.AnimatorListener() {
-							@Override
-							public void onAnimationStart(Animator animation) {}
-
-							@Override
-							public void onAnimationEnd(Animator animation) {
-								flip(SendMediaActivity.this.mediaItems.get(bigImagePos));
-								showBigImage(bigImagePos, false);
-								sendMediaGridAdapter.notifyDataSetChanged();
-								hasChanges = true;
-							}
-
-							@Override
-							public void onAnimationCancel(Animator animation) {}
-
-							@Override
-							public void onAnimationRepeat(Animator animation) {}
-						});
-			}
-		});
-		findViewById(R.id.edit).setOnClickListener(new DebouncedOnClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
-			@Override
-			public void onDebouncedClick(View v) {
-				if (bigImagePos >= SendMediaActivity.this.mediaItems.size()) {
-					return;
-				}
-
-				editImage();
-			}
-		});
-		settingsItem = findViewById(R.id.settings);
-		findViewById(R.id.settings).setOnClickListener(new DebouncedOnClickListener(IMAGE_ANIMATION_DURATION_MS) {
-			@Override
-			public void onDebouncedClick(View v) {
-				showSettingsDropDown(v, SendMediaActivity.this.mediaItems.get(bigImagePos));
-			}
-		});
-
-		this.editPanel = findViewById(R.id.edit_panel);
-
 		this.backgroundLayout = findViewById(R.id.background_layout);
 
 		final ViewTreeObserver observer = backgroundLayout.getViewTreeObserver();
@@ -608,8 +500,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		parentWidth = backgroundLayout.getWidth();
 		parentHeight = backgroundLayout.getHeight();
 
-		logger.debug("*** initUI width = " + parentWidth + " height = " + parentHeight);
-
 		int itemWidth = (parentWidth -
 			getResources().getDimensionPixelSize(R.dimen.preview_gridview_padding_right) -
 			getResources().getDimensionPixelSize(R.dimen.preview_gridview_padding_left)) /
@@ -650,22 +540,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		} else {
 			this.backgroundLayout.setVisibility(View.VISIBLE);
 		}
-		maybeShowImageResolutionTooltip();
-	}
-
-	@UiThread
-	public void maybeShowImageResolutionTooltip() {
-		editPanel.postDelayed(() -> {
-			if (editPanel.getVisibility() == View.VISIBLE && settingsItem.getVisibility() == View.VISIBLE && !preferenceService.getIsImageResolutionTooltipShown()) {
-				int[] location = new int[2];
-				settingsItem.getLocationOnScreen(location);
-				location[1] -= (settingsItem.getHeight() / 5);
-
-				final TooltipPopup resolutionTooltipPopup = new TooltipPopup(SendMediaActivity.this, 0, R.layout.popup_tooltip_top_right, SendMediaActivity.this);
-				resolutionTooltipPopup.show(this, settingsItem, getString(R.string.tooltip_image_resolution_hint), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, location, 6000);
-				preferenceService.setIsImageResolutionTooltipShown(true);
-			}
-		}, 2000);
 	}
 
 	private void showSettingsDropDown(final View view, final MediaItem mediaItem) {
@@ -770,49 +644,136 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	@Override
-	public boolean onCreateOptionsMenu(Menu menu) {
-		this.setupToolbar();
+	public boolean onPrepareOptionsMenu(Menu menu) {
+		updateMenu();
 
-		return true;
+		return super.onPrepareOptionsMenu(menu);
 	}
 
-	private void setupToolbar() {
-		View actionBarTitleView = getLayoutInflater().inflate(R.layout.actionbar_compose_title, null);
-
-		if (actionBarTitleView != null) {
-			EmojiTextView actionBarTitleTextView = actionBarTitleView.findViewById(R.id.title);
-			VerificationLevelImageView actionBarSubtitleImageView = actionBarTitleView.findViewById(R.id.subtitle_image);
-			TextView actionBarSubtitleTextView = actionBarTitleView.findViewById(R.id.subtitle_text);
-			AvatarView actionBarAvatarView = actionBarTitleView.findViewById(R.id.avatar_view);
+	@Override
+	public boolean onCreateOptionsMenu(Menu menu) {
+		getMenuInflater().inflate(R.menu.activity_send_media, menu);
+
+		settingsItem = menu.findItem(R.id.settings);
+		settingsItem.setOnMenuItemClickListener(item -> {
+			new Handler().post(() -> {
+				final View v = findViewById(R.id.settings);
+				if (v != null) {
+					showSettingsDropDown(v, SendMediaActivity.this.mediaItems.get(bigImagePos));
+				}
+			});
+			return true;
+		});
 
-			ActionBar actionBar = getSupportActionBar();
-			if (actionBar != null) {
-				actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_HOME_AS_UP);
-				actionBar.setCustomView(actionBarTitleView);
+		menu.findItem(R.id.flip).setOnMenuItemClickListener(new DebouncedOnMenuItemClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
+			@Override
+			public boolean onDebouncedMenuItemClick(MenuItem item) {
+				if (bigImagePos < SendMediaActivity.this.mediaItems.size()) {
+					prepareFlip();
+					return true;
+				}
+				return false;
 			}
+		});
 
-			actionBarTitleTextView.setText(getString(R.string.send_media));
-			actionBarSubtitleImageView.setVisibility(View.GONE);
-			actionBarSubtitleTextView.setVisibility(View.GONE);
-
-			if (getIntent() != null) {
-				String subtitle = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_TEXT);
-				if (!TestUtil.empty(subtitle)) {
-					actionBarSubtitleTextView.setText(subtitle);
-					actionBarSubtitleTextView.setVisibility(View.VISIBLE);
-
-					actionBarAvatarView.setVisibility(View.GONE);
-					if (messageReceivers != null && messageReceivers.size() == 1) {
-						Bitmap avatar = messageReceivers.get(0).getNotificationAvatar();
-						if (avatar != null) {
-							getToolbar().setContentInsetStartWithNavigation(0);
-							actionBarAvatarView.setImageBitmap(avatar);
-							actionBarAvatarView.setVisibility(View.VISIBLE);
-						}
-					}
+		menu.findItem(R.id.rotate).setOnMenuItemClickListener(new DebouncedOnMenuItemClickListener(IMAGE_ANIMATION_DURATION_MS * 2) {
+			@Override
+			public boolean onDebouncedMenuItemClick(MenuItem item) {
+				if (bigImagePos < SendMediaActivity.this.mediaItems.size()) {
+					prepareRotate();
+					return true;
 				}
+				return false;
+			}
+		});
+
+		menu.findItem(R.id.crop).setOnMenuItemClickListener(item -> {
+			if (bigImagePos < SendMediaActivity.this.mediaItems.size()) {
+				cropImage();
+				return true;
 			}
+			return false;
+		});
+
+		menu.findItem(R.id.edit).setOnMenuItemClickListener(item -> {
+			if (bigImagePos < SendMediaActivity.this.mediaItems.size()) {
+				editImage();
+				return true;
+			}
+			return false;
+		});
+
+		if (getToolbar().getNavigationIcon() != null) {
+			getToolbar().getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
 		}
+
+		return super.onCreateOptionsMenu(menu);
+	}
+
+	private void prepareRotate() {
+		int oldRotation = SendMediaActivity.this.mediaItems.get(bigImagePos).getRotation();
+		int newRotation = (oldRotation + 90) % 360;
+
+		int height = bigImageView.getDrawable().getBounds().width();
+		int width = bigImageView.getDrawable().getBounds().height();
+
+		float screenAspectRatio = (float) parentWidth / (float) parentHeight;
+		float imageAspectRatio = (float) width / (float) height;
+
+		float scalingFactor;
+		if (screenAspectRatio > imageAspectRatio) {
+			scalingFactor = (float) parentHeight / (float) height;
+		} else {
+			scalingFactor = (float) parentWidth / (float) width;
+		}
+
+		bigImageView.animate().rotationBy(90f)
+			.scaleX(scalingFactor)
+			.scaleY(scalingFactor)
+			.setDuration(IMAGE_ANIMATION_DURATION_MS)
+			.setInterpolator(new FastOutSlowInInterpolator())
+			.setListener(new Animator.AnimatorListener() {
+				@Override
+				public void onAnimationStart(Animator animation) {}
+
+				@Override
+				public void onAnimationEnd(Animator animation) {
+					SendMediaActivity.this.mediaItems.get(bigImagePos).setRotation(newRotation);
+					showBigImage(bigImagePos, false);
+					sendMediaGridAdapter.notifyDataSetChanged();
+					hasChanges = true;
+				}
+
+				@Override
+				public void onAnimationCancel(Animator animation) {}
+
+				@Override
+				public void onAnimationRepeat(Animator animation) {}
+			});
+	}
+
+	private void prepareFlip() {
+		bigImageView.animate().rotationY(180f)
+			.setDuration(IMAGE_ANIMATION_DURATION_MS)
+			.setInterpolator(new FastOutSlowInInterpolator())
+			.setListener(new Animator.AnimatorListener() {
+				@Override
+				public void onAnimationStart(Animator animation) {}
+
+				@Override
+				public void onAnimationEnd(Animator animation) {
+					flip(SendMediaActivity.this.mediaItems.get(bigImagePos));
+					showBigImage(bigImagePos, false);
+					sendMediaGridAdapter.notifyDataSetChanged();
+					hasChanges = true;
+				}
+
+				@Override
+				public void onAnimationCancel(Animator animation) {}
+
+				@Override
+				public void onAnimationRepeat(Animator animation) {}
+			});
 	}
 
 	@Override
@@ -846,13 +807,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		mediaItems.get(bigImagePos).setFlip(currentFlip);
 	}
 
-	@Override
-	public boolean onPrepareOptionsMenu(Menu menu) {
-		updateMenu();
-
-		return super.onPrepareOptionsMenu(menu);
-	}
-
 	@Override
 	public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
 		if (this.sendMediaGridAdapter.getItemViewType(position) == VIEW_TYPE_ADD) {
@@ -1059,7 +1013,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 					ArrayList<MediaItem> mediaItemsList = intent.getParcelableArrayListExtra(EXTRA_MEDIA_ITEMS);
 					if (mediaItemsList != null){
 						addItemsByMediaItem(mediaItemsList);
-						maybeShowImageResolutionTooltip();
 					}
 				default:
 					break;
@@ -1166,7 +1119,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			intent.putExtra(ThreemaApplication.EXTRA_EXIF_FLIP, mediaItems.get(bigImagePos).getExifFlip());
 
 			startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_PAINT);
-			overridePendingTransition(0, 0);
+			overridePendingTransition(0, R.anim.slow_fade_out);
 		} catch (IOException e) {
 			logger.debug("Unable to create temp file for crop");
 		}
@@ -1191,7 +1144,19 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		if (this.cameraButton != null) {
 			this.cameraButton.setVisibility(this.mediaItems.size() < MAX_SELECTABLE_IMAGES ? View.VISIBLE : View.GONE);
 		}
-		updateEditMenus(bigImagePos);
+
+		if (mediaItems.size() > 0) {
+			boolean canEdit = mediaItems.get(bigImagePos).getType() == TYPE_IMAGE || mediaItems.get(bigImagePos).getType() == TYPE_IMAGE_CAM;
+			boolean canSettings = mediaItems.get(bigImagePos).getType() == TYPE_IMAGE;
+
+			getToolbar().getMenu().setGroupVisible(R.id.group_tools, canEdit);
+
+			if (settingsItem != null) {
+				settingsItem.setVisible(canSettings);
+			}
+		} else {
+			getToolbar().getMenu().setGroupVisible(R.id.group_tools, false);
+		}
 	}
 
 	private void showBigVideo(MediaItem item) {
@@ -1271,7 +1236,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		}
 
 		selectImage(bigImagePos);
-		updateEditMenus(bigImagePos);
+		updateMenu();
 
 		String caption = item.getCaption();
 		captionEditText.setText(caption);
@@ -1281,20 +1246,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
-	private void updateEditMenus(int position) {
-		if (editPanel != null) {
-			if (mediaItems.size() > 0) {
-				boolean canEdit = mediaItems.get(position).getType() == TYPE_IMAGE || mediaItems.get(position).getType() == TYPE_IMAGE_CAM;
-				boolean canSettings = mediaItems.get(position).getType() == TYPE_IMAGE;
-
-				settingsItem.setVisibility(canSettings ? View.VISIBLE : View.GONE);
-				editPanel.setVisibility(canEdit ? View.VISIBLE : View.GONE);
-			} else {
-				editPanel.setVisibility(View.GONE);
-			}
-		}
-	}
-
 	@Override
 	public void onBackPressed() {
 		if (emojiPicker != null && emojiPicker.isShown()) {

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

@@ -226,6 +226,13 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 			// do not add on lollipop or lower due to this bug: https://issuetracker.google.com/issues/36937508
 			textView.setCustomSelectionActionModeCallback(textSelectionCallback);
 		}
+
+		findViewById(R.id.back_button).setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				finish();
+			}
+		});
 	}
 
 	private void setText(AbstractMessageModel messageModel) {

+ 77 - 18
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment1.java

@@ -32,15 +32,19 @@ import android.view.ViewGroup;
 import android.view.inputmethod.EditorInfo;
 import android.widget.EditText;
 import android.widget.ImageButton;
-import android.widget.ListView;
 import android.widget.TextView;
 
 import java.util.Calendar;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
-import ch.threema.app.adapters.ballot.BallotWizard1ListAdapter;
+import ch.threema.app.adapters.ballot.BallotWizard1Adapter;
 import ch.threema.app.dialogs.DateSelectorDialog;
 import ch.threema.app.dialogs.TimeSelectorDialog;
 import ch.threema.app.utils.EditTextUtil;
@@ -52,13 +56,15 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 	private static final String DIALOG_TAG_SELECT_TIME = "selectTime";
 	private static final String DIALOG_TAG_SELECT_DATETIME = "selectDateTime";
 
-	private ListView choiceListView;
+	private RecyclerView choiceRecyclerView;
 	private List<BallotChoiceModel> ballotChoiceModelList;
-	private BallotWizard1ListAdapter listAdapter = null;
+	private BallotWizard1Adapter listAdapter = null;
 	private ImageButton createChoiceButton;
 	private ImageButton addDateButton, addDateTimeButton;
 	private EditText createChoiceEditText;
 	private Date originalDate = null;
+	private LinearLayoutManager choiceRecyclerViewLayoutManager;
+	private int lastVisibleBallotPosition;
 
 	@Override
 	public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -67,7 +73,61 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 		ViewGroup rootView = (ViewGroup) inflater.inflate(
 				R.layout.fragment_ballot_wizard1, container, false);
 
-		this.choiceListView = rootView.findViewById(R.id.ballot_list);
+		this.choiceRecyclerView = rootView.findViewById(R.id.ballot_list);
+		this.choiceRecyclerViewLayoutManager = new LinearLayoutManager(getActivity());
+		this.choiceRecyclerView.setLayoutManager(choiceRecyclerViewLayoutManager);
+		this.choiceRecyclerView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+			@Override
+			public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
+				if (bottom < oldBottom) {
+					choiceRecyclerView.post(new Runnable() {
+						@Override
+						public void run() {
+							choiceRecyclerView.smoothScrollToPosition(lastVisibleBallotPosition);
+						}
+					});
+				}
+			}
+		});
+		this.choiceRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+			@Override
+			public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
+				super.onScrollStateChanged(recyclerView, newState);
+				if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+					lastVisibleBallotPosition = choiceRecyclerViewLayoutManager.findLastVisibleItemPosition();
+				}
+			}
+		});
+		int moveUpDown = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
+		ItemTouchHelper.Callback swipeCallback = new ItemTouchHelper.SimpleCallback(moveUpDown, 0) {
+			@Override
+			public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
+				int fromPosition = viewHolder.getAdapterPosition();
+				int toPosition = target.getAdapterPosition();
+				if (fromPosition < toPosition) {
+					for (int i = fromPosition; i < toPosition; i++) {
+						Collections.swap(ballotChoiceModelList, i, i + 1);
+					}
+				} else {
+					for (int i = fromPosition; i > toPosition; i--) {
+						Collections.swap(ballotChoiceModelList, i, i - 1);
+					}
+				}
+				listAdapter.notifyItemMoved(fromPosition, toPosition);
+				return true;
+			}
+
+			@Override
+			public boolean isItemViewSwipeEnabled() {
+				return false;
+			}
+
+			@Override
+			public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {}
+		};
+		ItemTouchHelper itemTouchHelper = new ItemTouchHelper(swipeCallback);
+		itemTouchHelper.attachToRecyclerView(choiceRecyclerView);
+
 		this.createChoiceEditText = rootView.findViewById(R.id.create_choice_name);
 		this.createChoiceEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
 			@Override
@@ -133,19 +193,16 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 	private void initAdapter() {
 		if(this.getBallotActivity() != null) {
 			this.ballotChoiceModelList = this.getBallotActivity().getBallotChoiceModelList();
-			this.listAdapter = new BallotWizard1ListAdapter(getActivity(), this.ballotChoiceModelList);
-
-			this.listAdapter.setOnChoiceListener(new BallotWizard1ListAdapter.OnChoiceListener() {
-				@Override
-				public void onRemoveClicked(BallotChoiceModel choiceModel) {
-					synchronized (ballotChoiceModelList) {
-						ballotChoiceModelList.remove(choiceModel);
-						listAdapter.notifyDataSetChanged();
-					}
-				}
-			});
+			this.listAdapter = new BallotWizard1Adapter(this.ballotChoiceModelList);
+			this.listAdapter.setOnChoiceListener(this::removeChoice);
+			this.choiceRecyclerView.setAdapter(this.listAdapter);
+		}
+	}
 
-			this.choiceListView.setAdapter(this.listAdapter);
+	private void removeChoice(int position) {
+		synchronized (ballotChoiceModelList) {
+			ballotChoiceModelList.remove(position);
+			listAdapter.notifyItemRemoved(position);
 		}
 	}
 
@@ -158,7 +215,9 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 			String text = createChoiceEditText.getText().toString();
 			if (!TestUtil.empty(text)) {
 				createChoice(text.trim(), BallotChoiceModel.Type.Text);
-				listAdapter.notifyDataSetChanged();
+				int insertPosition = this.ballotChoiceModelList.size() - 1;
+				listAdapter.notifyItemInserted(insertPosition);
+				choiceRecyclerView.smoothScrollToPosition(insertPosition);
 				createChoiceEditText.setText("");
 				createChoiceEditText.post(new Runnable() {
 					@Override

+ 16 - 0
app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java

@@ -28,6 +28,8 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
 
+import com.google.android.material.chip.Chip;
+
 import java.util.HashMap;
 import java.util.List;
 
@@ -44,12 +46,14 @@ import ch.threema.app.ui.InitialAvatarView;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.client.work.WorkDirectoryCategory;
 import ch.threema.client.work.WorkDirectoryContact;
+import ch.threema.client.work.WorkOrganization;
 
 public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, RecyclerView.ViewHolder> {
 	private final Context context;
 	private final LayoutInflater inflater;
 	private final PreferenceService preferenceService;
 	private final ContactService contactService;
+	private final WorkOrganization workOrganization;
 	private final HashMap<String, String> categoryMap = new HashMap<>();
 	private DirectoryAdapter.OnClickItemListener onClickItemListener;
 	@DrawableRes private int backgroundRes;
@@ -60,6 +64,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 		private final AppCompatImageView statusImageView;
 		private final InitialAvatarView avatarView;
 		private final TextView categoriesView;
+		private final Chip organizationView;
 		protected WorkDirectoryContact contact;
 
 		private DirectoryHolder(final View itemView) {
@@ -70,6 +75,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 			this.statusImageView = itemView.findViewById(R.id.status);
 			this.avatarView = itemView.findViewById(R.id.avatar_view);
 			this.categoriesView = itemView.findViewById(R.id.categories);
+			this.organizationView = itemView.findViewById(R.id.organization);
 		}
 
 		public View getItem() {
@@ -84,6 +90,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 		this.inflater = LayoutInflater.from(context);
 		this.preferenceService = preferenceService;
 		this.contactService = contactService;
+		this.workOrganization = preferenceService.getWorkOrganization();
 
 		for(WorkDirectoryCategory category: categoryList) {
 			this.categoryMap.put(category.id, category.name);
@@ -165,6 +172,15 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 		holder.avatarView.setInitials(workDirectoryContact.firstName, workDirectoryContact.lastName);
 		holder.identityView.setText(workDirectoryContact.threemaId);
 
+		if (workDirectoryContact.organization != null &&
+			workDirectoryContact.organization.getName() != null &&
+			!workDirectoryContact.organization.getName().equals(workOrganization.getName())) {
+			holder.organizationView.setText(workDirectoryContact.organization.getName());
+			holder.organizationView.setVisibility(View.VISIBLE);
+		} else {
+			holder.organizationView.setVisibility(View.GONE);
+		}
+
 		boolean isAddedContact = contactService.getByIdentity(workDirectoryContact.threemaId) != null;
 
 		holder.statusImageView.setBackgroundResource(isAddedContact ? 0 : this.backgroundRes);

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

@@ -234,6 +234,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		if (viewType == TYPE_ITEM) {
 			View itemView = inflater.inflate(R.layout.item_message_list, viewGroup, false);
 			itemView.setClickable(true);
+			// TODO: MaterialCardView: Setting a custom background is not supported.
 			itemView.setBackgroundResource(R.drawable.listitem_background_selector);
 			return new MessageListViewHolder(itemView);
 		}
@@ -431,8 +432,13 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 						}
 
 						if (conversationModel.isGroupConversation()) {
-							holder.deliveryView.setImageResource(R.drawable.ic_group_filled);
-							holder.deliveryView.setContentDescription(context.getString(R.string.prefs_group_notifications));
+							if (groupService.isGroupOwner(conversationModel.getGroup()) && groupService.countMembers(conversationModel.getGroup()) == 1) {
+								holder.deliveryView.setImageResource(R.drawable.ic_spiral_bound_booklet_outline);
+								holder.deliveryView.setContentDescription(context.getString(R.string.notes));
+							} else {
+								holder.deliveryView.setImageResource(R.drawable.ic_group_filled);
+								holder.deliveryView.setContentDescription(context.getString(R.string.prefs_group_notifications));
+							}
 							holder.deliveryView.setVisibility(View.VISIBLE);
 						} else if (conversationModel.isDistributionListConversation()) {
 							holder.deliveryView.setImageResource(R.drawable.ic_distribution_list_filled);

+ 108 - 0
app/src/main/java/ch/threema/app/adapters/ballot/BallotWizard1Adapter.java

@@ -0,0 +1,108 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters.ballot;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import ch.threema.app.R;
+import ch.threema.storage.models.ballot.BallotChoiceModel;
+
+/**
+ *
+ */
+public class BallotWizard1Adapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+
+	public interface OnChoiceListener {
+		void onRemoveClicked(int position);
+	}
+
+	private static class BallotAdminChoiceItemHolder extends RecyclerView.ViewHolder {
+
+		public TextView name;
+		public ImageView removeButton;
+
+		public BallotAdminChoiceItemHolder(@NonNull View itemView) {
+			super(itemView);
+			name = itemView.findViewById(R.id.choice_name_readonly);
+			removeButton = itemView.findViewById(R.id.remove_button);
+		}
+
+		public void bind(BallotChoiceModel choiceModel, OnChoiceListener onChoiceListener) {
+			if (choiceModel != null) {
+				name.setText(choiceModel.getName());
+				if (canEdit(choiceModel)) {
+					removeButton.setOnClickListener(view -> {
+						if (onChoiceListener != null) {
+							onChoiceListener.onRemoveClicked(getAdapterPosition());
+						}
+					});
+					removeButton.setVisibility(View.VISIBLE);
+				} else {
+					removeButton.setVisibility(View.GONE);
+				}
+			}
+		}
+
+		private boolean canEdit(BallotChoiceModel choiceModel) {
+			return choiceModel.getId() <= 0;
+		}
+	}
+
+	private final List<BallotChoiceModel> values;
+	private OnChoiceListener onChoiceListener;
+
+	public BallotWizard1Adapter(List<BallotChoiceModel> values) {
+		this.values = values;
+	}
+
+	public BallotWizard1Adapter setOnChoiceListener(OnChoiceListener onChoiceListener) {
+		this.onChoiceListener = onChoiceListener;
+		return this;
+	}
+
+	@NonNull
+	@Override
+	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+		LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+		View view = inflater.inflate(R.layout.item_ballot_wizard1, parent, false);
+		return new BallotAdminChoiceItemHolder(view);
+	}
+
+	@Override
+	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+		BallotAdminChoiceItemHolder viewHolder = (BallotAdminChoiceItemHolder) holder;
+		viewHolder.bind(values.get(position), onChoiceListener);
+	}
+
+	@Override
+	public int getItemCount() {
+		return values.size();
+	}
+}

+ 0 - 118
app/src/main/java/ch/threema/app/adapters/ballot/BallotWizard1ListAdapter.java

@@ -1,118 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.adapters.ballot;
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import java.util.List;
-
-import ch.threema.app.R;
-import ch.threema.storage.models.ballot.BallotChoiceModel;
-
-/**
- *
- */
-public class BallotWizard1ListAdapter extends ArrayAdapter<BallotChoiceModel> {
-
-	public interface OnChoiceListener {
-		void onRemoveClicked(BallotChoiceModel choiceModel);
-	}
-
-	private static class BallotAdminChoiceItemHolder {
-		public TextView name;
-		public ImageView removeButton;
-	}
-
-	private Context context;
-	private List<BallotChoiceModel> values;
-	private OnChoiceListener onChoiceListener;
-
-	public BallotWizard1ListAdapter(Context context, List<BallotChoiceModel> values) {
-		super(context, R.layout.item_ballot_wizard1, values);
-		this.context = context;
-		this.values = values;
-	}
-
-	public BallotWizard1ListAdapter setOnChoiceListener(OnChoiceListener onChoiceListener) {
-		this.onChoiceListener = onChoiceListener;
-		return this;
-	}
-
-	@Override
-	public View getView(final int position, View convertView, ViewGroup parent) {
-		View itemView = convertView;
-		final BallotAdminChoiceItemHolder holder;
-
-		if (convertView == null) {
-			holder = new BallotAdminChoiceItemHolder();
-			LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-			itemView = inflater.inflate(R.layout.item_ballot_wizard1, parent, false);
-
-			holder.name = itemView.findViewById(R.id.choice_name_readonly);
-			holder.removeButton = itemView.findViewById(R.id.remove_button);
-
-			itemView.setTag(holder);
-		}
-		else {
-			holder = (BallotAdminChoiceItemHolder) itemView.getTag();
-		}
-
-		final BallotChoiceModel choiceModel = values.get(position);
-
-		if(choiceModel != null) {
-			if(holder.name != null) {
-				holder.name.setText(choiceModel.getName());
-			}
-
-			if(holder.removeButton != null) {
-				if(canEdit(position)) {
-					holder.removeButton.setOnClickListener(new View.OnClickListener() {
-						@Override
-						public void onClick(View view) {
-							if (onChoiceListener != null) {
-								onChoiceListener.onRemoveClicked(choiceModel);
-							}
-						}
-					});
-					holder.removeButton.setVisibility(View.VISIBLE);
-				}
-				else {
-					holder.removeButton.setVisibility(View.GONE);
-				}
-			}
-		}
-
-		return itemView;
-	}
-
-	public boolean canEdit(int pos) {
-		synchronized (values) {
-			return pos >= 0 && pos < values.size() && values.get(pos).getId() <= 0;
-		}
-	}
-}

+ 4 - 2
app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java

@@ -41,12 +41,14 @@ public class AddContactAsyncTask extends AsyncTask<Void, Void, Boolean> {
 	private ContactService contactService;
 	private final Runnable runOnCompletion;
 	private final String firstName, lastName, threemaId;
+	private final boolean markAsWorkVerified;
 
-	public AddContactAsyncTask(String firstname, String lastname, String identity, Runnable runOnCompletion) {
+	public AddContactAsyncTask(String firstname, String lastname, String identity, boolean markAsWorkVerified, Runnable runOnCompletion) {
 		this.firstName = firstname;
 		this.lastName = lastname;
 		this.threemaId = identity.toUpperCase();
 		this.runOnCompletion = runOnCompletion;
+		this.markAsWorkVerified = markAsWorkVerified;
 
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		try {
@@ -74,7 +76,7 @@ public class AddContactAsyncTask extends AsyncTask<Void, Void, Boolean> {
 					contactService.save(contactModel);
 				}
 
-				if (contactModel.getType() == IdentityType.WORK) {
+				if (contactModel.getType() == IdentityType.WORK || markAsWorkVerified) {
 					contactModel.setIsWork(true);
 
 					if(contactModel.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {

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

@@ -117,7 +117,7 @@ public class CameraFragment extends Fragment {
 	private ExecutorService cameraExecutor;
 
 	// Volume down button receiver
-	private BroadcastReceiver volumeDownReceiver = new BroadcastReceiver() {
+	private final BroadcastReceiver volumeDownReceiver = new BroadcastReceiver() {
 		@Override
 		public void onReceive(Context context, Intent intent) {
 			int keyCode = intent.getIntExtra(KEY_EVENT_EXTRA, KeyEvent.KEYCODE_UNKNOWN);
@@ -135,7 +135,7 @@ public class CameraFragment extends Fragment {
 	 * change, for example if we choose to override config change in manifest or for 180-degree
 	 * orientation changes.
 	 */
-	private DisplayManager.DisplayListener displayListener = new DisplayManager.DisplayListener() {
+	private final DisplayManager.DisplayListener displayListener = new DisplayManager.DisplayListener() {
 		@Override
 		public void onDisplayAdded(int displayId) {
 		}
@@ -152,7 +152,7 @@ public class CameraFragment extends Fragment {
 			}
 
 			if (displayId == CameraFragment.this.displayId) {
-				if (getView() != null) {
+				if (getView() != null && getView().getDisplay() != null) {
 					int rotation = getView().getDisplay().getRotation();
 					logger.debug("Rotation changed from {} to {}", displayRotation, rotation);
 					if (displayRotation != rotation) {

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

@@ -363,7 +363,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	public void setVideo(MediaItem mediaItem) {
 		final int numColumns = calculateNumColumns();
 
-		if (numColumns == 0) {
+		if (numColumns <= 0 || numColumns > 64) {
 			return;
 		}
 

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

@@ -254,7 +254,6 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 			avatarEditView.setListener(this);
 		}
 
-
 		if (!TestUtil.empty(identity)) {
 			avatarEditView.setVisibility(View.GONE);
 			if (contactService != null) {
@@ -293,6 +292,7 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 
 		if (inputType != 0) {
 			editText1.setInputType(inputType);
+			editText2.setInputType(inputType);
 		}
 
 		if (maxLength > 0) {

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

@@ -37,6 +37,7 @@ import android.widget.Toast;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
+import org.msgpack.core.annotations.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -312,7 +313,7 @@ public class RingtoneSelectorDialog extends ThreemaDialogFragment {
 		}
 	}
 
-	private Uri getUriFromPosition(int index, boolean showSilent, boolean showDefault) {
+	private @Nullable Uri getUriFromPosition(int index, boolean showSilent, boolean showDefault) {
 		int positionFix = 0;
 
 		if (showSilent) {
@@ -332,7 +333,13 @@ public class RingtoneSelectorDialog extends ThreemaDialogFragment {
 			positionFix += 1;
 		}
 
-		return ringtoneManager.getRingtoneUri(index - positionFix);
+		Uri uri = null;
+		try {
+			uri = ringtoneManager.getRingtoneUri(index - positionFix);
+		} catch (Exception e) {
+			logger.error("Buggy Ringtone Manager", e);
+		}
+		return uri;
 	}
 
 	@Override

+ 2 - 2
app/src/main/java/ch/threema/app/emojis/EmojiManager.java

@@ -47,8 +47,8 @@ public class EmojiManager {
 
 	public static final int EMOJI_HEIGHT = 64;
 	public static final int EMOJI_WIDTH = 64;
-	private int spritemapInSampleSize;
-	private Context appContext;
+	private final int spritemapInSampleSize;
+	private final Context appContext;
 	private static final EmojiGroup[] emojiGroups = {
 		new EmojiGroup(null, null, R.drawable.emoji_category_recent, R.string.emoji_recent),
 		new EmojiGroup("emojis/people-", ".png", R.drawable.emoji_category_people, R.string.emoji_emotions),

+ 0 - 1
app/src/main/java/ch/threema/app/emojis/EmojiSpritemapBitmap.java

@@ -60,7 +60,6 @@ public class EmojiSpritemapBitmap {
 		return isSpritemapLoaded() ? bitmapReference.get() : null;
 	}
 
-	@Nullable
 	@AnyThread
 	public boolean isSpritemapLoaded() {
 		return bitmapReference != null && bitmapReference.get() != null;

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

@@ -54,6 +54,7 @@ import android.text.format.DateUtils;
 import android.util.DisplayMetrics;
 import android.util.Pair;
 import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
 import android.view.KeyEvent;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -235,6 +236,7 @@ import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DateSeparatorMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.DistributionListModel;
+import ch.threema.storage.models.FirstUnreadMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.MessageState;
@@ -1109,6 +1111,10 @@ public class ComposeMessageFragment extends Fragment implements
 		 */
 		activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
 
+		if (preferenceService == null) {
+			return;
+		}
+
 		if (preferenceService.getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
 			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
 				activity.findViewById(R.id.compose_activity_parent).getRootView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@@ -1130,7 +1136,19 @@ public class ComposeMessageFragment extends Fragment implements
 				});
 			} else {
 				try {
-					ViewCompat.setOnApplyWindowInsetsListener(activity.getWindow().getDecorView().getRootView(), new OnApplyWindowInsetsListener() {
+					View rootView = activity.getWindow().getDecorView().getRootView();
+					if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+						try {
+							ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
+							if (decorView.getChildCount() == 1 && decorView.getChildAt(0) instanceof LinearLayout) {
+								rootView = decorView.getChildAt(0);
+							}
+						} catch (Exception e) {
+							logger.error("Exception", e);
+						}
+					}
+
+					ViewCompat.setOnApplyWindowInsetsListener(rootView, new OnApplyWindowInsetsListener() {
 						@Override
 						public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
 
@@ -1167,7 +1185,7 @@ public class ComposeMessageFragment extends Fragment implements
 	public void onWindowFocusChanged(boolean hasFocus) {
 		logger.debug("onWindowFocusChanged " + hasFocus);
 
-		// workaround for proximity wake lock causing calls to onPause/onResume on Samsuck devices:
+		// workaround for proximity wake lock causing calls to onPause/onResume on Samsung devices:
 		// see: http://stackoverflow.com/questions/35318649/android-proximity-sensor-issue-only-in-samsung-devices
 		if (hasFocus) {
 			if (!this.hasFocus) {
@@ -1202,8 +1220,8 @@ public class ComposeMessageFragment extends Fragment implements
 			// mark all unread messages
 			if (this.unreadMessages.size() > 0) {
 				ReadMessagesRoutine r = new ReadMessagesRoutine(this.unreadMessages,
-						this.messageService,
-						this.notificationService);
+					this.messageService,
+					this.notificationService);
 
 				r.addOnFinished(new ReadMessagesRoutine.OnFinished() {
 					@Override
@@ -1224,31 +1242,27 @@ public class ComposeMessageFragment extends Fragment implements
 			this.messagePlayerService.resumeAll(getActivity(), this.messageReceiver, SOURCE_LIFECYCLE);
 
 			// restore scroll position after orientation change
-			convListView.post(new Runnable() {
-				@Override
-				public void run() {
-					if (listInstancePosition != AbsListView.INVALID_POSITION &&
-							messageReceiver != null &&
-							messageReceiver.getUniqueIdString().equals(listInstanceReceiverId)) {
-						logger.debug("restoring position " + listInstancePosition);
-						convListView.setSelectionFromTop(listInstancePosition, listInstanceTop);
-					} else {
-						if (unreadCount > 0) {
-							// jump to first unread message
-							int position = convListView.getCount() - unreadCount - 1;
-							logger.debug("jump to initial position " + position);
-							convListView.setSelection(Math.max(position, 0));
-							unreadCount = 0;
-						} else {
-							logger.debug("reset position");
-							convListView.setSelection(Integer.MAX_VALUE);
+			if (getActivity() != null) {
+				Intent intent = getActivity().getIntent();
+				if (intent != null && !intent.hasExtra(EXTRA_API_MESSAGE_ID) && !intent.hasExtra(EXTRA_SEARCH_QUERY)) {
+					convListView.post(new Runnable() {
+						@Override
+						public void run() {
+							if (listInstancePosition != AbsListView.INVALID_POSITION &&
+								messageReceiver != null &&
+								messageReceiver.getUniqueIdString().equals(listInstanceReceiverId)) {
+								logger.debug("restoring position " + listInstancePosition);
+								convListView.setSelectionFromTop(listInstancePosition, listInstanceTop);
+							} else {
+								jumpToFirstUnreadMessage();
+							}
+							// make sure it's not restored twice
+							listInstancePosition = AbsListView.INVALID_POSITION;
+							listInstanceReceiverId = null;
 						}
-					}
-					// make sure it's not restored twice
-					listInstancePosition = AbsListView.INVALID_POSITION;
-					listInstanceReceiverId = null;
+					});
 				}
-			});
+			}
 		}
 	}
 
@@ -1358,7 +1372,7 @@ public class ComposeMessageFragment extends Fragment implements
 			}
 
 			if (this.messageService != null) {
-				this.messageService.saveMessageQueue();
+				this.messageService.saveMessageQueueAsync();
 			}
 
 			if (this.thumbnailCache != null) {
@@ -2100,6 +2114,8 @@ public class ComposeMessageFragment extends Fragment implements
 
 				ComposeMessageAdapter.ConversationListFilter filter = (ComposeMessageAdapter.ConversationListFilter) composeMessageAdapter.getQuoteFilter(quoteContent);
 				searchV2Quote(apiMessageId, filter);
+
+				intent.removeExtra(EXTRA_API_MESSAGE_ID);
 			} else {
 				Toast.makeText(getContext().getApplicationContext(), R.string.message_not_found, Toast.LENGTH_SHORT).show();
 			}
@@ -2119,7 +2135,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private void deleteSelectedMessages() {
 		int deleteableMessagesCount = 0;
 
-		if (selectedMessages != null) {
+		if (selectedMessages != null && selectedMessages.size() > 0) {
 			// sort highest first for removal
 			Collections.sort(selectedMessages, new Comparator<AbstractMessageModel>() {
 				@Override
@@ -2484,7 +2500,7 @@ public class ComposeMessageFragment extends Fragment implements
 			values = this.messageService.getMessagesForReceiver(this.messageReceiver, new MessageService.MessageFilter() {
 				@Override
 				public long getPageSize() {
-					return unreadCount;
+					return -1;
 				}
 
 				@Override
@@ -2672,6 +2688,40 @@ public class ComposeMessageFragment extends Fragment implements
 		return unreadCount;
 	}
 
+	/**
+	 * Jump to first unread message keeping in account shift caused by date separators and other decorations
+	 * Currently depends on various globals...
+	 */
+	@UiThread
+	private void jumpToFirstUnreadMessage() {
+		if (unreadCount > 0) {
+			synchronized (this.messageValues) {
+				int position = Math.min(convListView.getCount() - unreadCount, this.messageValues.size() - 1);
+				while (position >= 0) {
+					if (this.messageValues.get(position) instanceof FirstUnreadMessageModel) {
+						break;
+					}
+					position--;
+
+				}
+				unreadCount = 0;
+
+				if (position > 0) {
+					final int finalPosition = position;
+					logger.debug("jump to initial position " + finalPosition);
+
+					convListView.setSelection(finalPosition);
+					convListView.postDelayed(() -> {
+						convListView.setSelection(finalPosition);
+					}, 750);
+
+					return;
+				}
+			}
+			convListView.setSelection(Integer.MAX_VALUE);
+		}
+	}
+
 	private void setIdentityColors() {
 		logger.debug("setIdentityColors");
 
@@ -2696,7 +2746,7 @@ public class ComposeMessageFragment extends Fragment implements
 								hsl[2] = 0.7f; // pull up luminance
 							}
 							if (hsl[1] > 0.6f) {
-								hsl[1] = 0.6f; // tome down saturation
+								hsl[1] = 0.6f; // tone down saturation
 							}
 							newColor = ColorUtils.HSLToColor(hsl);
 						}
@@ -2856,10 +2906,11 @@ public class ComposeMessageFragment extends Fragment implements
 		});
 	}
 
-	private boolean onListItemLongClick(View view, final int position) {
+	@UiThread
+	private void onListItemLongClick(@NonNull View view, final int position) {
 		int viewType = composeMessageAdapter.getItemViewType(position);
 		if (viewType == ComposeMessageAdapter.TYPE_FIRST_UNREAD  || viewType == ComposeMessageAdapter.TYPE_DATE_SEPARATOR) {
-			return false;
+			return;
 		}
 
 		selectedMessages.clear();
@@ -2876,11 +2927,12 @@ public class ComposeMessageFragment extends Fragment implements
 			actionMode = activity.startSupportActionMode(new ComposeMessageAction(position));
 		}
 
+		view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+
 		// fix linkify on longclick problem
 		// see: http://stackoverflow.com/questions/16047215/android-how-to-stop-linkify-on-long-press
 		longClickItem = position;
 
-		return true;
 	}
 
 	private boolean isMuted() {

+ 4 - 2
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -144,6 +144,8 @@ import ch.threema.storage.models.TagModel;
 import static android.view.MenuItem.SHOW_AS_ACTION_ALWAYS;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_NEVER;
+import static ch.threema.app.ThreemaApplication.MAX_PW_LENGTH_BACKUP;
+import static ch.threema.app.ThreemaApplication.MIN_PW_LENGTH_BACKUP;
 import static ch.threema.app.managers.ListenerManager.conversationListeners;
 
 public class MessageSectionFragment extends MainFragment
@@ -779,8 +781,8 @@ public class MessageSectionFragment extends MainFragment
 				R.string.password_hint,
 				R.string.ok,
 				R.string.cancel,
-				8,
-				16,
+				MIN_PW_LENGTH_BACKUP,
+				MAX_PW_LENGTH_BACKUP,
 				R.string.backup_password_again_summary,
 				0,
 				R.string.backup_data_media);

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

@@ -132,7 +132,7 @@ public class MyIDFragment extends MainFragment
 	private static final String DIALOG_TAG_LINKED_MOBILE_CONFIRM = "cfm";
 	private static final String DIALOG_TAG_REVOKING = "revk";
 
-	private SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
+	private final SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
 		@Override
 		public void onVerified() {
 		 	RuntimeUtil.runOnUiThread(new Runnable() {
@@ -249,6 +249,7 @@ public class MyIDFragment extends MainFragment
 
 			this.avatarView = fragmentView.findViewById(R.id.avatar_edit_view);
 			this.avatarView.setFragment(this);
+			this.avatarView.setIsMyProfilePicture(true);
 			this.avatarView.setContactModel(contactService.getMe());
 
 			this.nicknameTextView = fragmentView.findViewById(R.id.nickname);
@@ -284,7 +285,8 @@ public class MyIDFragment extends MainFragment
 			});
 
 			if (isDisabledProfilePicReleaseSettings) {
-				fragmentView.findViewById(R.id.picrelease_spinner_container).setVisibility(View.GONE);
+				fragmentView.findViewById(R.id.picrelease_spinner).setVisibility(View.GONE);
+				fragmentView.findViewById(R.id.picrelease_config).setVisibility(View.GONE);
 				fragmentView.findViewById(R.id.picrelease_text).setVisibility(View.GONE);
 			}
 

+ 12 - 16
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java

@@ -219,14 +219,12 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 
 		chatsViewModel = new ViewModelProvider(this).get(GlobalSearchChatsViewModel.class);
 		chatsViewModel.getMessageModels().observe(this, messageModels -> {
-			if (preferenceService.isPrivateChatsHidden()) {
-				messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
-					if (messageModel.getIdentity() != null) {
-						return !hiddenChatsListService.has(contactService.getUniqueIdString(messageModel.getIdentity()));
-					}
-					return true;
-				});
-			}
+			messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
+				if (messageModel.getIdentity() != null) {
+					return !hiddenChatsListService.has(contactService.getUniqueIdString(messageModel.getIdentity()));
+				}
+				return true;
+			});
 			chatsAdapter.setMessageModels(messageModels);
 		});
 
@@ -240,14 +238,12 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 
 		groupChatsViewModel = new ViewModelProvider(this).get(GlobalSearchGroupChatsViewModel.class);
 		groupChatsViewModel.getMessageModels().observe(this, messageModels -> {
-			if (preferenceService.isPrivateChatsHidden()) {
-				messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
-					if (((GroupMessageModel) messageModel).getGroupId() > 0) {
-						return !hiddenChatsListService.has(groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId()));
-					}
-					return true;
-				});
-			}
+			messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
+				if (((GroupMessageModel) messageModel).getGroupId() > 0) {
+					return !hiddenChatsListService.has(groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId()));
+				}
+				return true;
+			});
 			groupChatsAdapter.setMessageModels(messageModels);
 		});
 

+ 6 - 0
app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java

@@ -22,6 +22,8 @@
 package ch.threema.app.locationpicker;
 
 import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.os.Bundle;
 import android.os.Handler;
 import android.text.Editable;
@@ -101,6 +103,10 @@ public class LocationAutocompleteActivity extends ThreemaActivity {
 		actionBar.setTitle(null);
 		actionBar.setDisplayHomeAsUpEnabled(true);
 
+		if (toolbar.getNavigationIcon() != null) {
+			toolbar.getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+		}
+
 		Intent intent = getIntent();
 		currentLocation.setLatitude(intent.getDoubleExtra(INTENT_DATA_LOCATION_LAT, 0));
 		currentLocation.setLongitude(intent.getDoubleExtra(INTENT_DATA_LOCATION_LNG, 0));

+ 8 - 1
app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java

@@ -27,6 +27,8 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
 import android.location.Criteria;
 import android.location.Location;
 import android.location.LocationListener;
@@ -188,7 +190,8 @@ public class LocationPickerActivity extends ThreemaActivity implements
 		appBarLayout = findViewById(R.id.appbar_layout);
 		mapView = findViewById(R.id.map);
 
-		setSupportActionBar(findViewById(R.id.toolbar));
+		Toolbar toolbar = findViewById(R.id.toolbar);
+		setSupportActionBar(toolbar);
 		final ActionBar actionBar = getSupportActionBar();
 		if (actionBar == null) {
 			finish();
@@ -196,6 +199,10 @@ public class LocationPickerActivity extends ThreemaActivity implements
 		}
 		actionBar.setDisplayHomeAsUpEnabled(true);
 
+		if (toolbar.getNavigationIcon() != null) {
+			toolbar.getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+		}
+
 		// Get Threema services
 		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		if (serviceManager == null) {

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

@@ -306,7 +306,7 @@ public class ServiceManager {
 
 		// Write message queue to file at this opportunity (we can never know when we'll get killed)
 		try {
-			this.getMessageService().saveMessageQueue();
+			this.getMessageService().saveMessageQueueAsync();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}

+ 14 - 31
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -36,7 +36,6 @@ import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.provider.ContactsContract;
-import android.provider.MediaStore;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewStub;
@@ -104,10 +103,8 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 	private static final int PERMISSION_REQUEST_LOCATION = 1;
 	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;
 
 	public static final String CONFIRM_TAG_REALLY_SEND_FILE = "reallySendFile";
 	public static final String DIALOG_TAG_PREPARE_SEND_FILE = "prepSF";
@@ -135,16 +132,6 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 
 		this.handleSavedInstanceState(savedInstanceState);
 
-		this.toolbar.setOnMenuItemClickListener(item -> {
-			if (item.getItemId() == R.id.menu_select_from_gallery) {
-				if (ConfigUtils.requestStoragePermissions(MediaAttachActivity.this, null, PERMISSION_REQUEST_ATTACH_FROM_GALLERY)) {
-					attachImageFromGallery();
-				}
-				return true;
-			}
-			return false;
-		});
-
 		this.scrollView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
 			@Override
 			public void onGlobalLayout() {
@@ -557,6 +544,13 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		for (Uri uri: uriList) {
 			String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
 			if (MimeUtil.isVideoFile(mimeType) || MimeUtil.isImageFile(mimeType)) {
+				try {
+					getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+				} catch (Exception e) {
+					logger.info("Unable to take persistable uri permission");
+					uri = FileUtil.getFileUri(uri);
+				}
+
 				MediaItem mediaItem = new MediaItem(uri, mimeType, null);
 				mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 				mediaItems.add(mediaItem);
@@ -580,6 +574,13 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		}
 
 		for (Uri uri : list) {
+			try {
+				getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			} catch (Exception e) {
+				logger.info("Unable to take persistable uri permission");
+				uri = FileUtil.getFileUri(uri);
+			}
+
 			MediaItem mediaItem = new MediaItem(uri, FileUtil.getMimeTypeFromUri(this, uri), null);
 			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
 			mediaItems.add(mediaItem);
@@ -603,24 +604,6 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		}
 	}
 
-	private void attachImageFromGallery() {
-		try {
-			Intent getContentIntent = new Intent();
-			getContentIntent.setType(MimeUtil.MIME_TYPE_VIDEO);
-			getContentIntent.setAction(Intent.ACTION_GET_CONTENT);
-			getContentIntent.addCategory(Intent.CATEGORY_OPENABLE);
-			getContentIntent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, MAX_BLOB_SIZE);
-			Intent pickIntent = new Intent(Intent.ACTION_PICK);
-			pickIntent.setType(MimeUtil.MIME_TYPE_IMAGE);
-			Intent chooserIntent = Intent.createChooser(pickIntent, getString(R.string.select_from_gallery));
-			chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
-
-			startActivityForResult(chooserIntent, REQUEST_CODE_ATTACH_FROM_GALLERY);
-		} catch (Exception e) {
-			logger.debug("Exception", e);
-			Toast.makeText(this, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show();
-		}
-	}
 
 	private void attachFromExternalCamera() {
 		Intent intent = IntentDataUtil.addMessageReceiversToIntent(new Intent(this, SendMediaActivity.class), new MessageReceiver[]{this.messageReceiver});

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

@@ -169,7 +169,7 @@ public class MediaAttachViewModel extends AndroidViewModel {
 				allMedia.setValue(mediaAttachItems);
 				initialLoadDone.complete(null);
 			});
-		}).start();
+		}, "fetchAllMediaFromRepository").start();
 	}
 
 	/**
@@ -201,18 +201,18 @@ public class MediaAttachViewModel extends AndroidViewModel {
 
 			// Get the media count (by this time, it should be ready because
 			// this method is called after `initialLoadDone` fires)
-			final List<MediaAttachItem> allMediaValue = Objects.requireNonNull(this.allMedia.getValue());
+			final List<MediaAttachItem> allMediaValue = Objects.requireNonNull(MediaAttachViewModel.this.allMedia.getValue());
 			final int totalMediaSize = Functional.filter(allMediaValue, (IPredicateNonNull<MediaAttachItem>) ImageLabelingWorker::mediaCanBeLabeled).size() - failedMediaCount;
 
 			final float labeledRatio = (float) labeledMediaCount / (float) totalMediaSize;
 			if (labeledRatio > 0.8) {
 				// More than 80% labeled. Good enough, but kick off the labeller anyways if we're not at 100%.
 				if (labeledMediaCount < totalMediaSize) {
-					this.startImageLabeler();
+					MediaAttachViewModel.this.startImageLabeler();
 				}
 
 				// Get hashmap for label mapping
-				final ImageLabelsIndexHashMap labelsIndexHashMap = new ImageLabelsIndexHashMap(this.application);
+				ImageLabelsIndexHashMap labelsIndexHashMap = new ImageLabelsIndexHashMap();
 
 				// Iterate over all media items, translate and set labels
 				final Set<String> translatedLabels = new HashSet<>();
@@ -235,10 +235,10 @@ public class MediaAttachViewModel extends AndroidViewModel {
 				suggestionLabels.postValue(sortedLabels);
 			} else {
 				logger.info("Less than 80% labeled, considering labels incomplete");
-				this.startImageLabeler();
+				MediaAttachViewModel.this.startImageLabeler();
 				suggestionLabels.postValue(Collections.emptyList());
 			}
-		}).start();
+		}, "checkLabelingComplete").start();
 	}
 
 	/**
@@ -266,7 +266,7 @@ public class MediaAttachViewModel extends AndroidViewModel {
 		ArrayList<MediaAttachItem> filteredMedia = new ArrayList<>();
 		final List<MediaAttachItem> items = Objects.requireNonNull(this.allMedia.getValue());
 		for (MediaAttachItem mediaItem : items) {
-			if (mediaItem.getBucketName().equals(bucket)) {
+			if (bucket.equals(mediaItem.getBucketName())) {
 				filteredMedia.add(mediaItem);
 			}
 		}

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

@@ -23,6 +23,7 @@ package ch.threema.app.mediaattacher;
 
 import android.Manifest;
 import android.animation.ValueAnimator;
+import android.app.Activity;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.net.Uri;
@@ -46,7 +47,6 @@ import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.LocaleUtil;
-import ch.threema.app.utils.MimeUtil;
 
 import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
 
@@ -118,18 +118,22 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 			@Override
 			public void onDebouncedClick(View v) {
 				v.setAlpha(0.3f);
-				ArrayList<MediaItem> mediaItems = new ArrayList<>();
-				for (Uri uri : mediaAttachViewModel.getSelectedMediaUris()) {
-					MediaItem mediaItem = new MediaItem(uri, FileUtil.getMimeTypeFromUri(MediaSelectionActivity.this, uri), null);
-					mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
-					mediaItems.add(mediaItem);
-				}
-				setResult(ThreemaActivity.RESULT_OK, new Intent().putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems));
-				finish();
+				selectItemsAndClose(mediaAttachViewModel.getSelectedMediaUris());
 			}
 		});
 	}
 
+	private void selectItemsAndClose(ArrayList<Uri> uris) {
+		ArrayList<MediaItem> mediaItems = new ArrayList<>();
+		for (Uri uri : uris) {
+			MediaItem mediaItem = new MediaItem(uri, FileUtil.getMimeTypeFromUri(MediaSelectionActivity.this, uri), null);
+			mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
+			mediaItems.add(mediaItem);
+		}
+		setResult(ThreemaActivity.RESULT_OK, new Intent().putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mediaItems));
+		finish();
+	}
+
 	/**
 	 * Check if the media attacher's selectable media grid can be shown
 	 * @return true if option has been enabled by user and permissions are available
@@ -157,4 +161,20 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 			}
 		}
 	}
+
+	@Override
+	public void onActivityResult(int requestCode, int resultCode, final Intent intent) {
+		super.onActivityResult(requestCode, resultCode, intent);
+
+		if (resultCode == Activity.RESULT_OK) {
+			switch (requestCode) {
+				case REQUEST_CODE_ATTACH_FROM_GALLERY:
+					selectItemsAndClose(FileUtil.getUrisFromResult(intent, getContentResolver()));
+					break;
+				default:
+					break;
+			}
+		}
+	}
+
 }

+ 75 - 29
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java

@@ -36,9 +36,11 @@ import android.graphics.Typeface;
 import android.os.Build;
 import android.os.Bundle;
 import android.provider.BaseColumns;
+import android.provider.MediaStore;
 import android.text.TextUtils;
 import android.util.DisplayMetrics;
 import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -52,6 +54,7 @@ import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
+import android.widget.Toast;
 
 import com.getkeepsafe.taptargetview.TapTarget;
 import com.getkeepsafe.taptargetview.TapTargetView;
@@ -62,9 +65,11 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.HashMap;
-import java.util.HashSet;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
@@ -86,7 +91,6 @@ import ch.threema.app.activities.EnterSerialActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.activities.UnlockMasterKeyActivity;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.mediaattacher.labeling.ImageLabelsIndexHashMap;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.CheckableFrameLayout;
 import ch.threema.app.ui.GridRecyclerView;
@@ -97,9 +101,11 @@ import ch.threema.app.ui.SingleToast;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LocaleUtil;
+import ch.threema.app.utils.MimeUtil;
 import ch.threema.localcrypto.MasterKey;
 
 import static android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI;
+import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
 import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_BUCKET;
 import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_LABEL;
 import static ch.threema.app.mediaattacher.MediaAttachViewModel.FILTER_MEDIA_SELECTED;
@@ -119,7 +125,9 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected PreferenceService preferenceService;
 
 	public static final String KEY_BOTTOM_SHEET_STATE = "bottom_sheet_state";
+	protected static final int PERMISSION_REQUEST_ATTACH_FROM_GALLERY = 4;
 	protected static final int PERMISSION_REQUEST_ATTACH_FILE = 5;
+	protected static final int REQUEST_CODE_ATTACH_FROM_GALLERY = 2454;
 
 	protected CoordinatorLayout rootView;
 	protected AppBarLayout appBarLayout;
@@ -142,7 +150,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	protected CursorAdapter suggestionAdapter;
 	protected AutoCompleteTextView searchAutoComplete;
 	protected List<String> labelSuggestions;
-	protected ImageLabelsIndexHashMap labelsIndexHashMap;
 	protected int peekHeightNumElements = 1;
 
 	private boolean isDragging = false;
@@ -198,13 +205,20 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		// The view model handles data associated with this view
 		this.mediaAttachViewModel = new ViewModelProvider(MediaSelectionBaseActivity.this).get(MediaAttachViewModel.class);
 
-		// The ImageLabelsIndexHashMap maps label indexes to readable names (e.g. "Twig" or "Boat")
-		this.labelsIndexHashMap = new ImageLabelsIndexHashMap(this);
-
 		// Initialize UI
 		this.setLayout();
 		this.setDropdownMenu();
 		this.setListeners();
+
+		this.toolbar.setOnMenuItemClickListener(item -> {
+			if (item.getItemId() == R.id.menu_select_from_gallery) {
+				if (ConfigUtils.requestStoragePermissions(MediaSelectionBaseActivity.this, null, PERMISSION_REQUEST_ATTACH_FROM_GALLERY)) {
+					attachImageFromGallery();
+				}
+				return true;
+			}
+			return false;
+		});
 	}
 
 	protected void initServices() {
@@ -238,7 +252,6 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		this.dateTextView = findViewById(R.id.text_view);
 
 		this.searchView.setIconifiedByDefault(true);
-		this.selectFromGalleryItem.setVisible(this instanceof MediaAttachActivity);
 
 		// fill background with transparent black to see chat behind drawer
 		FitWindowsFrameLayout contentFrameLayout = (FitWindowsFrameLayout) ((ViewGroup) rootView.getParent()).getParent();
@@ -360,28 +373,34 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				}
 
 				// Extract buckets and media types
-				final HashSet<String> buckets = new HashSet<>();
-				final HashMap<Integer, String> mediaTypes = new HashMap<>();
+				final List<String> buckets = new ArrayList<>();
+				final TreeMap<String, Integer> mediaTypes = new TreeMap<>();
+
 				for (MediaAttachItem mediaItem : mediaAttachItems) {
-					buckets.add(mediaItem.getBucketName());
+					String bucket = mediaItem.getBucketName();
+					if (!TextUtils.isEmpty(bucket) && !buckets.contains(bucket)) {
+						buckets.add(mediaItem.getBucketName());
+					}
+
 					int type = mediaItem.getType();
-					if (!mediaTypes.containsKey(type)) {
+					if (!mediaTypes.containsValue(type)) {
 						String mediaTypeName = getMimeTypeTitle(type);
-						mediaTypes.put(type, mediaTypeName);
+						mediaTypes.put(mediaTypeName, type);
 					}
 				}
 
-				// Fill menu
-				for (int mediaTypeKey : mediaTypes.keySet()) {
-					String mediaTypeName = mediaTypes.get(mediaTypeKey);
-					MenuItem item = menu.add(mediaTypeName).setOnMenuItemClickListener(menuItem -> {
+				Collections.sort(buckets);
+
+				// Fill menu first media types sorted then folders/buckets sorted
+				for (Map.Entry<String, Integer> mediaType : mediaTypes.entrySet()) {
+					MenuItem item = menu.add(mediaType.getKey()).setOnMenuItemClickListener(menuItem -> {
 						filterMediaByMimeType(menuItem.toString());
 						menuTitle.setText(menuItem.toString());
 						mediaAttachViewModel.setToolBarTitle(menuItem.toString());
 						return true;
 					});
 
-					switch(mediaTypeKey) {
+					switch(mediaType.getValue()) {
 						case MediaItem.TYPE_IMAGE:
 							item.setIcon(R.drawable.ic_image_outline);
 							break;
@@ -396,14 +415,16 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				}
 
 				for (String bucket : buckets) {
-					MenuItem item = menu.add(bucket).setOnMenuItemClickListener(menuItem -> {
-						filterMediaByBucket(menuItem.toString());
-						menuTitle.setText(menuItem.toString());
-						mediaAttachViewModel.setToolBarTitle(menuItem.toString());
-						return true;
-					});
-					item.setIcon(R.drawable.ic_outline_folder_24);
-					ConfigUtils.themeMenuItem(item, ConfigUtils.getColorFromAttribute(this, R.attr.textColorSecondary));
+					if (!TextUtils.isEmpty(bucket)) {
+						MenuItem item = menu.add(bucket).setOnMenuItemClickListener(menuItem -> {
+							filterMediaByBucket(menuItem.toString());
+							menuTitle.setText(menuItem.toString());
+							mediaAttachViewModel.setToolBarTitle(menuItem.toString());
+							return true;
+						});
+						item.setIcon(R.drawable.ic_outline_folder_24);
+						ConfigUtils.themeMenuItem(item, ConfigUtils.getColorFromAttribute(this, R.attr.textColorSecondary));
+					}
 				}
 
 				// Enable menu
@@ -538,6 +559,8 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 	@Override
 	public void onItemLongClick(View view, int position, MediaAttachItem mediaAttachItem) {
+		view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+
 		Intent intent = new Intent(this, MediaPreviewActivity.class);
 		intent.putExtra(MediaPreviewActivity.EXTRA_PARCELABLE_MEDIA_ITEM, mediaAttachItem);
 		AnimationUtil.startActivity(this, view, intent);
@@ -593,22 +616,25 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		resetLabelSearch();
 	}
 
-	public void filterMediaByBucket(String mediaBucket) {
+	public void filterMediaByBucket(@NonNull String mediaBucket) {
 		mediaAttachViewModel.setMediaByBucket(mediaBucket);
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_BUCKET, mediaBucket);
 		resetLabelSearch();
 	}
 
-	public void filterMediaByMimeType(String mimeTypeTitle) {
+	public void filterMediaByMimeType(@NonNull String mimeTypeTitle) {
 		int mimeTypeIndex = 0;
+
 		if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_pictures))) {
 			mimeTypeIndex = MediaItem.TYPE_IMAGE;
 		}
 		else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_videos))) {
 			mimeTypeIndex = MediaItem.TYPE_VIDEO;
-		} else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_gifs))) {
+		}
+		else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_gifs))) {
 			mimeTypeIndex = MediaItem.TYPE_GIF;
 		}
+
 		if (mimeTypeIndex != 0) {
 			mediaAttachViewModel.setMediaByType(mimeTypeIndex);
 		}
@@ -955,4 +981,24 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			return false;
 		}
 	}
+
+	protected void attachImageFromGallery() {
+		try {
+			Intent getContentIntent = new Intent();
+			getContentIntent.setType(MimeUtil.MIME_TYPE_VIDEO);
+			getContentIntent.setAction(Intent.ACTION_GET_CONTENT);
+			getContentIntent.addCategory(Intent.CATEGORY_OPENABLE);
+			getContentIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			getContentIntent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, MAX_BLOB_SIZE);
+			Intent pickIntent = new Intent(Intent.ACTION_PICK);
+			pickIntent.setType(MimeUtil.MIME_TYPE_IMAGE);
+			Intent chooserIntent = Intent.createChooser(pickIntent, getString(R.string.select_from_gallery));
+			chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{getContentIntent});
+
+			startActivityForResult(chooserIntent, REQUEST_CODE_ATTACH_FROM_GALLERY);
+		} catch (Exception e) {
+			logger.debug("Exception", e);
+			Toast.makeText(this, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show();
+		}
+	}
 }

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

@@ -240,9 +240,16 @@ public class ImageLabelingWorker extends Worker {
 			// Label images without labels
 			int imageCounter = 0;
 			int unlabeledCounter = 0;
+			int timeoutCounter = 0;
 			int skippedCounter = 0;
 
 			for (MediaAttachItem mediaItem : allMediaCache) {
+
+				// abort work to avoid battery drain if too many timeouts were triggered, something else must be wrong atm.
+				if (timeoutCounter > 20) {
+					logger.info("stopping labeling work due to too many timeouts");
+					this.onStopped();
+				}
 				// Check whether we were stopped
 				if (this.isStopped()) {
 					logger.info("Work was cancelled");
@@ -280,16 +287,16 @@ public class ImageLabelingWorker extends Worker {
 						imageFuture = executor.submit(() -> InputImage.fromFilePath(appContext, uri));
 
 						try {
-							image = imageFuture.get(30, TimeUnit.SECONDS);    // give it a timeout of 30s, otherwise skip and remember bad item
+							image = imageFuture.get(20, TimeUnit.SECONDS);    // give it a timeout of 20s, otherwise skip bad item
 						} catch (TimeoutException e) {
 							imageFuture.cancel(true);
 							logger.info("Item {} in set label queue cannot be loaded from filepath in reasonable time, timeout triggered", progress);
-							failedMediaDAO.insert(new FailedMediaItemEntity(mediaItem.getId(), System.currentTimeMillis()));
+							timeoutCounter++;
 							skippedCounter++;
 							continue;
 						}
 
-						if (image.getHeight() < 32 || image.getWidth() < 32 ) {
+						if (image != null && (image.getHeight() < 32 || image.getWidth() < 32)) {
 							logger.info("Item {} in set label queue loaded as InputImage due to tiny size. width or height < 32", progress);
 							failedMediaDAO.insert(new FailedMediaItemEntity(mediaItem.getId(), System.currentTimeMillis()));
 							skippedCounter++;
@@ -340,8 +347,8 @@ public class ImageLabelingWorker extends Worker {
 				}
 			}
 
-			// Update notification
-			notificationService.updateImageLabelingProgressNotification(this.progress, this.mediaCount);
+			// make sure to finish progress notification
+			notificationService.updateImageLabelingProgressNotification(mediaCount, mediaCount);
 
 			final long secondsElapsedLabeling = (SystemClock.elapsedRealtime() - startTime) / 1000;
 			if (this.isStopped()) {
@@ -349,7 +356,7 @@ public class ImageLabelingWorker extends Worker {
 				notificationService.cancelImageLabelingProgressNotification();
 				return Result.failure();
 			} else {
-				logger.info("Processed {} unlabeled images among {} total and {} skipped images", unlabeledCounter, imageCounter, skippedCounter);
+				logger.info("Processed {} unlabeled images among {} total and {} skipped images of which {} timed out", unlabeledCounter, imageCounter, skippedCounter, timeoutCounter);
 				logger.info("Labeling work done after {}s, starting cleanup", secondsElapsedLabeling);
 			}
 

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

@@ -28,14 +28,17 @@ import java.util.HashMap;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
 
 /**
  * This data structure contains a mapping from image labels to translated textual labels.
  */
 public class ImageLabelsIndexHashMap {
-	private final HashMap<String, String> mapping;
 
-	public ImageLabelsIndexHashMap(Context context){
+	private HashMap<String, String> mapping;
+	private Context context = ThreemaApplication.getAppContext();
+
+	public ImageLabelsIndexHashMap(){
 		mapping =  new HashMap<String, String>() {{
 			put("0", context.getString(R.string.label_0));
 			put("1", context.getString(R.string.label_1));

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

@@ -48,11 +48,8 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.AbstractMessage;
 import ch.threema.client.BlobUploader;
-import ch.threema.client.BoxAudioMessage;
-import ch.threema.client.BoxImageMessage;
 import ch.threema.client.BoxLocationMessage;
 import ch.threema.client.BoxTextMessage;
-import ch.threema.client.BoxVideoMessage;
 import ch.threema.client.BoxedMessage;
 import ch.threema.client.MessageId;
 import ch.threema.client.MessageQueue;
@@ -234,7 +231,6 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 				.setCorrelationId(messageModel.getCorrelationId())
 				.setMetaData(modelFileData.getMetaData());
 
-
 		fileMessage.setData(fileData);
 		fileMessage.setToIdentity(this.contactModel.getIdentity());
 

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

@@ -53,11 +53,8 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.AbstractGroupMessage;
 import ch.threema.client.BlobUploader;
-import ch.threema.client.GroupAudioMessage;
-import ch.threema.client.GroupImageMessage;
 import ch.threema.client.GroupLocationMessage;
 import ch.threema.client.GroupTextMessage;
-import ch.threema.client.GroupVideoMessage;
 import ch.threema.client.MessageId;
 import ch.threema.client.ProtocolDefines;
 import ch.threema.client.ThreemaFeature;
@@ -215,11 +212,14 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 						.setThumbnailBlobId(thumbnailBlobId)
 						.setEncryptionKey(fileResult.getKey())
 						.setMimeType(modelFileData.getMimeType())
+						.setThumbnailMimeType(modelFileData.getThumbnailMimeType())
 						.setFileSize(modelFileData.getFileSize())
 						.setFileName(modelFileData.getFileName())
 						.setRenderingType(modelFileData.getRenderingType())
 						.setDescription(modelFileData.getCaption())
-						.setThumbnailMimeType(modelFileData.getThumbnailMimeType());
+						.setCorrelationId(messageModel.getCorrelationId())
+						.setMetaData(modelFileData.getMetaData());
+
 				fileMessage.setData(fileData);
 
 				if (messageId != null) {

+ 49 - 0
app/src/main/java/ch/threema/app/motionviews/FaceItem.java

@@ -0,0 +1,49 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.motionviews;
+
+import android.graphics.Bitmap;
+import android.media.FaceDetector;
+
+public class FaceItem {
+	private FaceDetector.Face face;
+	private Bitmap bitmap;
+	private float preScale;
+
+	public FaceItem(FaceDetector.Face face, Bitmap bitmap, float preScale) {
+		this.face = face;
+		this.bitmap = bitmap;
+		this.preScale = preScale;
+	}
+
+	public Bitmap getBitmap() {
+		return bitmap;
+	}
+
+	public FaceDetector.Face getFace() {
+		return face;
+	}
+
+	public float getPreScale() {
+		return preScale;
+	}
+}

+ 89 - 0
app/src/main/java/ch/threema/app/motionviews/widget/FaceBlurEntity.java

@@ -0,0 +1,89 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.motionviews.widget;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.renderscript.Allocation;
+import android.renderscript.Element;
+import android.renderscript.RenderScript;
+import android.renderscript.ScriptIntrinsicBlur;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.motionviews.FaceItem;
+import ch.threema.app.motionviews.viewmodel.Layer;
+
+public class FaceBlurEntity extends FaceEntity {
+
+	public FaceBlurEntity(@NonNull Layer layer,
+	                   @NonNull FaceItem faceItem,
+	                   @IntRange(from = 1) int originalImageWidth,
+	                   @IntRange(from = 1) int originalImageHeight,
+	                   @IntRange(from = 1) int canvasWidth,
+	                   @IntRange(from = 1) int canvasHeight) {
+		super(layer, faceItem, originalImageWidth, originalImageHeight, canvasWidth, canvasHeight);
+	}
+
+	@Override
+	public void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {
+		RenderScript rs = RenderScript.create(ThreemaApplication.getAppContext());
+		Allocation input = Allocation.createFromBitmap(rs, faceItem.getBitmap());
+		Allocation output = Allocation.createTyped(rs, input.getType());
+		ScriptIntrinsicBlur blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
+		blurScript.setRadius(25f);
+		blurScript.setInput(input);
+		blurScript.forEach(output);
+
+		Paint paint = new Paint();
+		paint.setDither(true);
+		paint.setAntiAlias(true);
+
+		Bitmap blurred = Bitmap.createBitmap(faceItem.getBitmap().getWidth(), faceItem.getBitmap().getHeight(), faceItem.getBitmap().getConfig());
+		output.copyTo(blurred);
+
+		Matrix newMatrix = new Matrix(matrix);
+		newMatrix.preScale(faceItem.getPreScale(), faceItem.getPreScale());
+
+		canvas.drawBitmap(blurred, newMatrix, paint);
+
+		blurScript.destroy();
+		input.destroy();
+		output.destroy();
+		rs.destroy();
+		blurred.recycle();
+	}
+
+	@Override
+	public int getWidth() {
+		return Math.round(faceItem.getBitmap().getWidth() * faceItem.getPreScale());
+	}
+
+	@Override
+	public int getHeight() {
+		return Math.round(faceItem.getBitmap().getHeight() * faceItem.getPreScale());
+	}
+}

+ 48 - 0
app/src/main/java/ch/threema/app/motionviews/widget/FaceEmojiEntity.java

@@ -0,0 +1,48 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.motionviews.widget;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import ch.threema.app.motionviews.FaceItem;
+import ch.threema.app.motionviews.viewmodel.Layer;
+
+public class FaceEmojiEntity extends FaceEntity {
+
+	public FaceEmojiEntity(@NonNull Layer layer,
+	                       @NonNull FaceItem faceItem,
+	                       @IntRange(from = 1) int originalImageWidth,
+	                       @IntRange(from = 1) int originalImageHeight,
+	                       @IntRange(from = 1) int canvasWidth,
+	                       @IntRange(from = 1) int canvasHeight) {
+		super(layer, faceItem, originalImageWidth, originalImageHeight, canvasWidth, canvasHeight);
+	}
+
+	@Override
+	public void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {
+		canvas.drawBitmap(bitmap, matrix, drawingPaint);
+	}
+}

+ 96 - 0
app/src/main/java/ch/threema/app/motionviews/widget/FaceEntity.java

@@ -0,0 +1,96 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.motionviews.widget;
+
+import android.graphics.Bitmap;
+import android.graphics.PointF;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import ch.threema.app.motionviews.FaceItem;
+import ch.threema.app.motionviews.viewmodel.Layer;
+
+public abstract class FaceEntity extends MotionEntity {
+	public static final float BLUR_RADIUS = 1.5f;
+
+	@NonNull protected final FaceItem faceItem;
+	@NonNull protected final Bitmap bitmap;
+
+	public FaceEntity(@NonNull Layer layer,
+	                  @NonNull FaceItem faceItem,
+	                  @IntRange(from = 1) int originalImageWidth,
+	                  @IntRange(from = 1) int originalImageHeight,
+	                  @IntRange(from = 1) int canvasWidth,
+	                  @IntRange(from = 1) int canvasHeight) {
+		super(layer, canvasWidth, canvasHeight);
+
+		this.faceItem = faceItem;
+		this.bitmap = faceItem.getBitmap();
+
+		float width = bitmap.getWidth() * faceItem.getPreScale();
+		float height = bitmap.getHeight() * faceItem.getPreScale();
+
+		// initial position of the entity
+		srcPoints[0] = 0;
+		srcPoints[1] = 0;
+		srcPoints[2] = width;
+		srcPoints[3] = 0;
+		srcPoints[4] = width;
+		srcPoints[5] = height;
+		srcPoints[6] = 0;
+		srcPoints[7] = height;
+		srcPoints[8] = 0;
+		srcPoints[9] = 0;
+
+		float widthAspect = 1.0F * canvasWidth / width;
+		float heightAspect = 1.0F * canvasHeight / height;
+		// fit the smallest size
+		holyScale = Math.min(widthAspect, heightAspect);
+		float canvasScaleX = (float) canvasWidth / originalImageWidth;
+		float canvasScaleY = (float) canvasHeight / originalImageHeight;
+
+		PointF midPoint = new PointF();
+		faceItem.getFace().getMidPoint(midPoint);
+		midPoint.x = midPoint.x * canvasScaleX;
+		midPoint.y = midPoint.y * canvasScaleY;
+
+		float diameter = faceItem.getFace().eyesDistance() * canvasScaleX * (2f * BLUR_RADIUS);
+
+		moveCenterTo(midPoint);
+		getLayer().setScale(diameter / (originalImageWidth > originalImageHeight ? canvasHeight : canvasWidth));
+	}
+
+	@Override
+	public boolean hasFixedPositionAndSize() {
+		return true;
+	}
+
+	@Override
+	public int getWidth() {
+		return bitmap.getWidth();
+	}
+
+	@Override
+	public int getHeight() {
+		return bitmap.getHeight();
+	}
+}

+ 18 - 9
app/src/main/java/ch/threema/app/motionviews/widget/ImageEntity.java

@@ -24,16 +24,15 @@ package ch.threema.app.motionviews.widget;
 import android.graphics.Bitmap;
 import android.graphics.Canvas;
 import android.graphics.Paint;
+
 import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-
 import ch.threema.app.motionviews.viewmodel.Layer;
 
 public class ImageEntity extends MotionEntity {
 
-    @NonNull
-    private final Bitmap bitmap;
+    @NonNull private final Bitmap bitmap;
 
     public ImageEntity(@NonNull Layer layer,
                        @NonNull Bitmap bitmap,
@@ -51,11 +50,16 @@ public class ImageEntity extends MotionEntity {
         holyScale = Math.min(widthAspect, heightAspect);
 
         // initial position of the entity
-        srcPoints[0] = 0; srcPoints[1] = 0;
-        srcPoints[2] = width; srcPoints[3] = 0;
-        srcPoints[4] = width; srcPoints[5] = height;
-        srcPoints[6] = 0; srcPoints[7] = height;
-        srcPoints[8] = 0; srcPoints[8] = 0;
+        srcPoints[0] = 0;
+        srcPoints[1] = 0;
+        srcPoints[2] = width;
+        srcPoints[3] = 0;
+        srcPoints[4] = width;
+        srcPoints[5] = height;
+        srcPoints[6] = 0;
+        srcPoints[7] = height;
+        srcPoints[8] = 0;
+        srcPoints[9] = 0;
     }
 
     @Override
@@ -63,7 +67,12 @@ public class ImageEntity extends MotionEntity {
         canvas.drawBitmap(bitmap, matrix, drawingPaint);
     }
 
-    @Override
+	@Override
+	public boolean hasFixedPositionAndSize() {
+		return false;
+	}
+
+	@Override
     public int getWidth() {
         return bitmap.getWidth();
     }

+ 2 - 0
app/src/main/java/ch/threema/app/motionviews/widget/MotionEntity.java

@@ -271,6 +271,8 @@ public abstract class MotionEntity {
 
 	protected abstract void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint);
 
+	public abstract boolean hasFixedPositionAndSize();
+
 	public abstract int getWidth();
 
 	public abstract int getHeight();

+ 12 - 7
app/src/main/java/ch/threema/app/motionviews/widget/MotionView.java

@@ -53,8 +53,6 @@ import ch.threema.app.motionviews.gestures.RotateGestureDetector;
  */
 
 public class MotionView extends FrameLayout {
-
-	private static final String TAG = MotionView.class.getSimpleName();
 	private TouchListener touchListener;
 
 	public interface Constants {
@@ -142,8 +140,11 @@ public class MotionView extends FrameLayout {
 
 	public void addEntity(@Nullable MotionEntity entity) {
 		if (entity != null) {
+			initEntityBorder(entity);
 			entities.add(entity);
 			selectEntity(entity, false);
+			touchListener.onAdded(entity);
+			unselectEntity();
 		}
 	}
 
@@ -224,7 +225,7 @@ public class MotionView extends FrameLayout {
 	}
 
 	private void handleTranslate(PointF delta) {
-		if (selectedEntity != null) {
+		if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
 			float newCenterX = selectedEntity.absoluteCenterX() + delta.x;
 			float newCenterY = selectedEntity.absoluteCenterY() + delta.y;
 			// limit entity center to screen bounds
@@ -293,7 +294,7 @@ public class MotionView extends FrameLayout {
 		if (selectedEntity != null) {
 			PointF p = new PointF(e.getX(), e.getY());
 			if (selectedEntity.pointInLayerRect(p)) {
-				touchListener.onLongClick((int) e.getX(), (int) e.getY());
+				touchListener.onLongClick(selectedEntity, (int) e.getX(), (int) e.getY());
 			}
 		}
 	}
@@ -415,7 +416,7 @@ public class MotionView extends FrameLayout {
 	private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
 		@Override
 		public boolean onScale(ScaleGestureDetector detector) {
-			if (selectedEntity != null) {
+			if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
 				float scaleFactorDiff = detector.getScaleFactor();
 				selectedEntity.getLayer().postScale(scaleFactorDiff - 1.0F);
 				updateUI();
@@ -427,7 +428,7 @@ public class MotionView extends FrameLayout {
 	private class RotateListener extends RotateGestureDetector.SimpleOnRotateGestureListener {
 		@Override
 		public boolean onRotate(RotateGestureDetector detector) {
-			if (selectedEntity != null) {
+			if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
 				selectedEntity.getLayer().postRotate(-detector.getRotationDegreesDelta());
 				updateUI();
 			}
@@ -450,13 +451,17 @@ public class MotionView extends FrameLayout {
 		draw(canvas);
 	}
 
+	public int getEntitiesCount() {
+		return entities.size();
+	}
+
 	public void setTouchListener(TouchListener touchListener) {
 		this.touchListener = touchListener;
 	}
 
 	public interface TouchListener {
 		void onSelected(boolean isSelected);
-		void onLongClick(int x, int y);
+		void onLongClick(MotionEntity entity, int x, int y);
 		void onAdded(MotionEntity entity);
 		void onDeleted(MotionEntity entity);
 		void onTouchUp();

+ 5 - 0
app/src/main/java/ch/threema/app/motionviews/widget/PathEntity.java

@@ -36,6 +36,11 @@ public class PathEntity extends MotionEntity {
 	@Override
 	protected void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {}
 
+	@Override
+	public boolean hasFixedPositionAndSize() {
+		return false;
+	}
+
 	@Override
 	public int getWidth() {
 		return 0;

+ 5 - 0
app/src/main/java/ch/threema/app/motionviews/widget/TextEntity.java

@@ -176,6 +176,11 @@ public class TextEntity extends MotionEntity {
 		}
 	}
 
+	@Override
+	public boolean hasFixedPositionAndSize() {
+		return false;
+	}
+
 	@Override
 	public int getWidth() {
 		return bitmap != null ? bitmap.getWidth() : 0;

+ 1 - 0
app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.java

@@ -196,6 +196,7 @@ public class SettingsAppearanceFragment extends ThreemaPreferenceFragment implem
 				String newLocale = newValue.toString();
 				if (newLocale != null && !newLocale.equals(oldLocale)) {
 					ConfigUtils.updateLocaleOverride(newValue);
+					ConfigUtils.updateAppContextLocale(ThreemaApplication.getAppContext(), newLocale);
 					ConfigUtils.recreateActivity(getActivity());
 				}
 				return true;

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

@@ -27,7 +27,9 @@ import org.slf4j.LoggerFactory;
 import java.util.LinkedList;
 import java.util.List;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.services.MessageService;
+import ch.threema.client.MessageAck;
 import ch.threema.client.MessageAckListener;
 import ch.threema.client.MessageId;
 import ch.threema.storage.models.MessageState;
@@ -40,18 +42,18 @@ public class MessageAckProcessor implements MessageAckListener {
 	private static final int ACK_LIST_MAX_ENTRIES = 20;
 
 	@Override
-	public void processAck(MessageId messageId) {
-		logger.info("Processing ACK for message {}", messageId);
+	public void processAck(@NonNull MessageAck ack) {
+		logger.info("Processing server ack for message ID {} from {}", ack.getMessageId(), ack.getRecipientId());
 
 		synchronized (ackedMessageIds) {
 			while (ackedMessageIds.size() >= ACK_LIST_MAX_ENTRIES) {
 				ackedMessageIds.remove(0);
 			}
-			ackedMessageIds.add(messageId);
+			ackedMessageIds.add(ack.getMessageId());
 		}
 
 		if (this.messageService != null) {
-			this.messageService.updateMessageStateAtOutboxed(messageId, MessageState.SENT, null);
+			this.messageService.updateMessageStateAtOutboxed(ack.getMessageId(), MessageState.SENT, null);
 		}
 	}
 

+ 1 - 1
app/src/main/java/ch/threema/app/receivers/AlarmManagerBroadcastReceiver.java

@@ -81,7 +81,7 @@ public class AlarmManagerBroadcastReceiver extends BroadcastReceiver {
 					if (wakeLock != null && wakeLock.isHeld()) {
 						wakeLock.release();
 					}
-				}).start();
+				}, "AlarmOnReceive").start();
 			} catch (Exception e) {
 				logger.error("Exception", e);
 			}

+ 5 - 0
app/src/main/java/ch/threema/app/receivers/WidgetProvider.java

@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
 
 import ch.threema.app.R;
 import ch.threema.app.activities.ComposeMessageActivity;
+import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.services.WidgetService;
 
@@ -54,10 +55,14 @@ public class WidgetProvider extends AppWidgetProvider {
 			Intent intent = new Intent(context, RecipientListBaseActivity.class);
 			PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
 
+			Intent titleIntent = new Intent(context, HomeActivity.class);
+			PendingIntent titlePendingIntent = PendingIntent.getActivity(context, 0, titleIntent, 0);
+
 			// Get the layout for the App Widget and attach an on-click listener
 			// to the button
 			RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_messages);
 			views.setOnClickPendingIntent(R.id.widget_edit, pendingIntent);
+			views.setOnClickPendingIntent(R.id.widget_title, titlePendingIntent);
 
 			// Set up the RemoteViews object to use a RemoteViews adapter.
 			// This adapter connects

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

@@ -119,7 +119,7 @@ public class FileServiceImpl implements FileService {
 	private static final Logger logger = LoggerFactory.getLogger(FileServiceImpl.class);
 
 	private final static String JPEG_EXTENSION = ".jpg";
-	private final static String MPEG_EXTENSION = ".mp4";
+	public final static String MPEG_EXTENSION = ".mp4";
 	public final static String VOICEMESSAGE_EXTENSION = ".aac";
 	private final static String THUMBNAIL_EXTENSION = "_T";
 	private final static String WALLPAPER_FILENAME = "/wallpaper" + JPEG_EXTENSION;

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

@@ -118,6 +118,9 @@ public interface GroupService extends AvatarService<GroupModel> {
 	Bitmap getNeutralAvatar(boolean highResolution);
 
 	boolean isGroupOwner(GroupModel groupModel);
+
+	int countMembers(@NonNull GroupModel groupModel);
+
 	boolean isGroupMember(GroupModel groupModel);
 
 	boolean removeMemberFromGroup(GroupLeaveMessage msg);

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

@@ -54,6 +54,7 @@ import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.InvalidEntryException;
+import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
@@ -61,7 +62,6 @@ import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.APIConnector;
 import ch.threema.client.AbstractGroupMessage;
@@ -1213,6 +1213,17 @@ public class GroupServiceImpl implements GroupService {
 		}
 	}
 
+	@Override
+	public int countMembers(@NonNull GroupModel groupModel) {
+		synchronized (this.groupIdentityCache) {
+			String[] existingIdentities = this.groupIdentityCache.get(groupModel.getId());
+			if (existingIdentities != null) {
+				return existingIdentities.length;
+			}
+		}
+		return (int) this.databaseServiceNew.getGroupMemberModelFactory().countMembers(groupModel.getId());
+	}
+
 	private boolean isGroupMember(GroupModel groupModel, String identity) {
 		if (!TestUtil.empty(identity)) {
 			for (String existingIdentity : this.getGroupIdentities(groupModel)) {
@@ -1229,6 +1240,7 @@ public class GroupServiceImpl implements GroupService {
 		return isGroupMember(groupModel, userService.getIdentity());
 	}
 
+	@Override
 	public List<GroupMemberModel> getGroupMembers(GroupModel groupModel) {
 		return this.databaseServiceNew.getGroupMemberModelFactory().getByGroupId(
 				groupModel.getId()

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

@@ -44,6 +44,7 @@ import ch.threema.client.AbstractMessage;
 import ch.threema.client.MessageId;
 import ch.threema.client.MessageTooLongException;
 import ch.threema.client.ProgressListener;
+import ch.threema.localcrypto.MasterKey;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -172,7 +173,8 @@ public interface MessageService {
 	boolean cancelMessageDownload(AbstractMessageModel messageModel);
 	void cancelMessageUpload(AbstractMessageModel messageModel);
 
-	void saveMessageQueue();
+	void saveMessageQueueAsync();
+	void saveMessageQueue(@NonNull MasterKey masterKey);
 
 	void removeAll() throws SQLException, IOException, ThreemaException;
 	void save(AbstractMessageModel messageModel);

+ 87 - 30
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -58,6 +58,7 @@ import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
 import java.sql.SQLException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
@@ -1725,9 +1726,7 @@ public class MessageServiceImpl implements MessageService {
 			this.fireOnCreatedMessage(messageModel);
 
 			if (canDownload(messageModel)) {
-				if (fileData.getFileSize() <= FILE_AUTO_DOWNLOAD_MAX_SIZE_ISO) {
-					downloadMediaMessage(messageModel, null);
-				}
+				downloadMediaMessage(messageModel, null);
 			}
 		}
 		else {
@@ -1787,15 +1786,28 @@ public class MessageServiceImpl implements MessageService {
 		return false;
 	}
 
+	/**
+	 * Check if the file in question should be auto-downloaded or not
+	 * This depends on file type, file size and user preference (settings)
+	 * @param messageModel AbstractMessageModel to check
+	 * @return true if file should be downloaded immediately, false otherwise
+	 */
 	private boolean canDownload(@NonNull AbstractMessageModel messageModel) {
 		MessageType type = MessageType.FILE;
+		FileDataModel fileDataModel = messageModel.getFileData();
 
-		if (messageModel.getFileData() != null && messageModel.getFileData().getRenderingType() != FileData.RENDERING_DEFAULT) {
+		if (fileDataModel == null) {
+			return false;
+		}
+
+		if (fileDataModel.getRenderingType() != FileData.RENDERING_DEFAULT) {
 			// treat media with default (file) rendering like a file for the sake of auto-download
 			if (messageModel.getMessageContentsType() == MessageContentsType.IMAGE) {
 				type = MessageType.IMAGE;
 			} else if (messageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
 				type = MessageType.VIDEO;
+			} else if (messageModel.getMessageContentsType() == MessageContentsType.AUDIO) {
+				type = MessageType.VOICEMESSAGE;
 			}
 		}
 
@@ -1803,16 +1815,28 @@ public class MessageServiceImpl implements MessageService {
 			ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
 			NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
 			if (activeNetwork != null) {
+				boolean canDownload = false;
+
 				switch (activeNetwork.getType()) {
 					case ConnectivityManager.TYPE_ETHERNET:
 						// fallthrough
 					case ConnectivityManager.TYPE_WIFI:
-						return preferenceService.getWifiAutoDownload().contains(String.valueOf(type.ordinal()));
+						canDownload = preferenceService.getWifiAutoDownload().contains(String.valueOf(type.ordinal()));
+						break;
 					case ConnectivityManager.TYPE_MOBILE:
-						return preferenceService.getMobileAutoDownload().contains(String.valueOf(type.ordinal()));
+						canDownload = preferenceService.getMobileAutoDownload().contains(String.valueOf(type.ordinal()));
+						break;
 					default:
 						break;
 				}
+
+				if (canDownload) {
+					// images and voice messages are always auto-downloaded regardless of size
+					return
+						type == MessageType.IMAGE ||
+						type == MessageType.VOICEMESSAGE ||
+						fileDataModel.getFileSize() <= FILE_AUTO_DOWNLOAD_MAX_SIZE_ISO;
+				}
 			}
 		}
 		return false;
@@ -2367,26 +2391,29 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
-	public void saveMessageQueue() {
+	public void saveMessageQueueAsync() {
 		MasterKey masterKey = ThreemaApplication.getMasterKey();
 		if (masterKey == null || masterKey.isLocked()) {
 			return;
 		}
 
-		new Thread(() -> {
-			synchronized (messageQueue) {
-				try {
-					FileOutputStream fileOutputStream = new FileOutputStream(getMessageQueueFile());
-					CipherOutputStream cos = masterKey.getCipherOutputStream(fileOutputStream);
-					if (cos != null) {
-						messageQueue.serializeToStream(cos);
-						logger.info("Queue saved. Size = {}", messageQueue.getQueueSize());
-					}
-				} catch (Exception e) {
-					logger.error("Exception", e);
+		new Thread(() -> saveMessageQueue(masterKey)).start();
+	}
+
+	@Override
+	public void saveMessageQueue(@NonNull MasterKey masterKey) {
+		synchronized (messageQueue) {
+			try {
+				FileOutputStream fileOutputStream = new FileOutputStream(getMessageQueueFile());
+				CipherOutputStream cos = masterKey.getCipherOutputStream(fileOutputStream);
+				if (cos != null) {
+					messageQueue.serializeToStream(cos);
+					logger.info("Queue saved. Size = {}", messageQueue.getQueueSize());
 				}
+			} catch (Exception e) {
+				logger.error("Exception", e);
 			}
-		});
+		}
 	}
 
 	@Override
@@ -3305,8 +3332,7 @@ public class MessageServiceImpl implements MessageService {
 	 * @param createIfNotExists
 	 * @return
 	 */
-	public SendMachine getSendMachine(AbstractMessageModel abstractMessageModel, boolean createIfNotExists)
-	{
+	public SendMachine getSendMachine(AbstractMessageModel abstractMessageModel, boolean createIfNotExists) {
 		synchronized (this.sendMachineInstances) {
 			//be sure to "generate" a unique key
 			String key = abstractMessageModel.getClass() + "-" + abstractMessageModel.getUid();
@@ -4015,15 +4041,21 @@ public class MessageServiceImpl implements MessageService {
 				// "regular" file messages
 				renderingType = FileData.RENDERING_DEFAULT;
 				break;
+			case TYPE_VIDEO:
+				if (renderingType == FileData.RENDERING_MEDIA) {
+					// videos in formats other than MP4 are always transcoded and result in an MP4 file
+					mimeType = MimeUtil.MIME_TYPE_VIDEO_MP4;
+				}
+				// fallthrough
 			default:
 				if (mediaItem.getImageScale() == PreferenceService.ImageScale_SEND_AS_FILE) {
 					// images with scale type "send as file" get the default rendering type and a file name
 					renderingType = FileData.RENDERING_DEFAULT;
 					mediaItem.setType(TYPE_FILE);
 				} else {
-					// unlike with "real" files we override the filename for regular images with a generic one to prevent privacy leaks
+					// unlike with "real" files we override the filename for regular (RENDERING_MEDIA) images and videos with a generic one to prevent privacy leaks
 					// this mimics the behavior of traditional image messages that did not have a filename at all
-					filename = FileUtil.getDefaultFilename(mimeType); // the internal temporary file name is of no use to the recipient
+					filename = FileUtil.getDefaultFilename(mimeType);
 				}
 				break;
 		}
@@ -4085,7 +4117,10 @@ public class MessageServiceImpl implements MessageService {
 
 		logger.info("Target bitrate = {}", targetBitrate);
 
-		if (needsTrimming || targetBitrate > 0) {
+		if (needsTrimming ||
+			targetBitrate > 0 ||
+			!MimeUtil.MIME_TYPE_VIDEO_MP4.equalsIgnoreCase(mediaItem.getMimeType())) {
+
 			logger.info("Video needs transcoding");
 
 			// set models to TRANSCODING state
@@ -4185,6 +4220,7 @@ public class MessageServiceImpl implements MessageService {
 			// remove original file and set transcoded file as new source file
 			deleteTemporaryFile(mediaItem);
 			mediaItem.setUri(Uri.fromFile(outputFile));
+			mediaItem.setMimeType(MimeUtil.MIME_TYPE_VIDEO_MP4);
 		} else {
 			logger.info("No transcoding necessary");
 		}
@@ -4246,20 +4282,41 @@ public class MessageServiceImpl implements MessageService {
 	@WorkerThread
 	private byte[] getContentData(MediaItem mediaItem) {
 		try (InputStream inputStream = StreamUtil.getFromUri(context, mediaItem.getUri())) {
-			if (inputStream != null && inputStream.available() > 0) {
-				final int fileLength;
-
-				fileLength = inputStream.available();
+			if (inputStream != null) {
+ 				int fileLength = inputStream.available();
 
 				if (fileLength > MAX_BLOB_SIZE) {
 					logger.info(context.getString(R.string.file_too_large));
+					RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), R.string.file_too_large, Toast.LENGTH_LONG).show());
 					return null;
 				}
 
+				if (fileLength == 0) {
+					// InputStream may not provide size
+					fileLength = MAX_BLOB_SIZE + 1;
+				}
+
 				if (ConfigUtils.checkAvailableMemory(fileLength + NaCl.BOXOVERHEAD)) {
+					byte[] fileData = new byte[fileLength + NaCl.BOXOVERHEAD];
+
 					try {
-						byte[] fileData = new byte[fileLength + NaCl.BOXOVERHEAD];
-						IOUtils.readFully(inputStream, fileData, NaCl.BOXOVERHEAD, fileLength);
+						int readCount = 0;
+						try {
+							readCount = IOUtils.read(inputStream, fileData, NaCl.BOXOVERHEAD, fileLength);
+						} catch (Exception e) {
+							// it's OK to get an EOF
+						}
+
+						if (readCount > MAX_BLOB_SIZE) {
+							logger.info(context.getString(R.string.file_too_large));
+							RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), R.string.file_too_large, Toast.LENGTH_LONG).show());
+							return null;
+						}
+
+						if (readCount < fileLength) {
+							return Arrays.copyOf(fileData, readCount + NaCl.BOXOVERHEAD);
+						}
+
 						return fileData;
 					} catch (OutOfMemoryError e) {
 						logger.error("Unable to create byte array", e);

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

@@ -402,6 +402,9 @@ public interface PreferenceService {
 	boolean getIsWorkHintTooltipShown();
 	void setIsWorkHintTooltipShown(boolean shown);
 
+	boolean getIsFaceBlurTooltipShown();
+	void setFaceBlurTooltipShown(boolean shown);
+
 	void setThreemaSafeEnabled(boolean value);
 	boolean getThreemaSafeEnabled();
 

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

@@ -1160,6 +1160,16 @@ public class PreferenceServiceImpl implements PreferenceService {
 		this.preferenceStore.save(this.getKeyName(R.string.preferences__tooltip_work_hint_shown), shown);
 	}
 
+	@Override
+	public boolean getIsFaceBlurTooltipShown() {
+		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__tooltip_face_blur_shown));
+	}
+
+	@Override
+	public void setFaceBlurTooltipShown(boolean shown) {
+		this.preferenceStore.save(this.getKeyName(R.string.preferences__tooltip_face_blur_shown), shown);
+	}
+
 	@Override
 	public void setThreemaSafeEnabled(boolean value) {
 		this.preferenceStore.save(this.getKeyName(R.string.preferences__threema_safe_enabled), value);

+ 50 - 4
app/src/main/java/ch/threema/app/stores/IdentityStore.java

@@ -27,6 +27,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.base.ThreemaException;
@@ -43,9 +46,12 @@ public class IdentityStore implements IdentityStoreInterface {
 	private String publicNickname;
 	private final PreferenceStoreInterface preferenceStore;
 
+	private Map<KeyPair,NaCl> naClCache;
+
 	public IdentityStore(PreferenceStoreInterface preferenceStore) throws ThreemaException {
 
 		this.preferenceStore = preferenceStore;
+		this.naClCache = Collections.synchronizedMap(new HashMap<>());
 
 		this.identity = this.preferenceStore.getString(PreferenceStore.PREFS_IDENTITY);
 		if (this.identity == null) {
@@ -56,7 +62,7 @@ public class IdentityStore implements IdentityStoreInterface {
 
 		this.serverGroup = this.preferenceStore.getString(PreferenceStore.PREFS_SERVER_GROUP);
 		this.publicKey = this.preferenceStore.getBytes(PreferenceStore.PREFS_PUBLIC_KEY);
-		this.privateKey =this.preferenceStore.getBytes(PreferenceStore.PREFS_PRIVATE_KEY, true);
+		this.privateKey = this.preferenceStore.getBytes(PreferenceStore.PREFS_PRIVATE_KEY, true);
 		this.publicNickname = this.preferenceStore.getString(PreferenceStore.PREFS_PUBLIC_NICKNAME);
 
 		if (this.identity.length() == ProtocolDefines.IDENTITY_LEN &&
@@ -75,15 +81,18 @@ public class IdentityStore implements IdentityStoreInterface {
 
 	public byte[] encryptData(byte[] boxData, byte[] nonce, byte[] receiverPublicKey) {
 		if (privateKey != null) {
-			NaCl nacl = new NaCl(privateKey, receiverPublicKey);
+			NaCl nacl = getCachedNaCl(privateKey, receiverPublicKey);
 			return nacl.encrypt(boxData, nonce);
 		}
 		return null;
 	}
 
 	public byte[] decryptData(byte[] boxData, byte[] nonce, byte[] senderPublicKey) {
-		NaCl nacl = new NaCl(privateKey, senderPublicKey);
-		return nacl.decrypt(boxData, nonce);
+		if (privateKey != null) {
+			NaCl nacl = getCachedNaCl(privateKey, senderPublicKey);
+			return nacl.decrypt(boxData, nonce);
+		}
+		return null;
 	}
 
 	public String getIdentity() {
@@ -143,4 +152,41 @@ public class IdentityStore implements IdentityStoreInterface {
 				PreferenceStore.PREFS_PUBLIC_KEY,
 				PreferenceStore.PREFS_PRIVATE_KEY));
 	}
+
+	private NaCl getCachedNaCl(byte[] privateKey, byte[] publicKey) {
+		// Check for cached NaCl instance to save heavy Curve25519 computation
+		KeyPair hashKey = new KeyPair(privateKey, publicKey);
+		NaCl nacl = naClCache.get(hashKey);
+		if (nacl == null) {
+			nacl = new NaCl(privateKey, publicKey);
+			naClCache.put(hashKey, nacl);
+		}
+		return nacl;
+	}
+
+	private class KeyPair {
+		private final byte[] privateKey;
+		private final byte[] publicKey;
+
+		public KeyPair(byte[] privateKey, byte[] publicKey) {
+			this.privateKey = privateKey;
+			this.publicKey = publicKey;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (this == o) return true;
+			if (o == null || getClass() != o.getClass()) return false;
+			KeyPair keyPair = (KeyPair) o;
+			return Arrays.equals(privateKey, keyPair.privateKey) &&
+				Arrays.equals(publicKey, keyPair.publicKey);
+		}
+
+		@Override
+		public int hashCode() {
+			int result = Arrays.hashCode(privateKey);
+			result = 31 * result + Arrays.hashCode(publicKey);
+			return result;
+		}
+	}
 }

+ 7 - 1
app/src/main/java/ch/threema/app/stores/PreferenceStore.java

@@ -378,7 +378,13 @@ public class PreferenceStore implements PreferenceStoreInterface {
 				return null;
 			}
 		} else {
-			return this.sharedPreferences.getString(key, null);
+			String value = null;
+			try {
+				value = this.sharedPreferences.getString(key, null);
+			} catch (ClassCastException e) {
+				logger.error("Class cast exception", e);
+			}
+			return value;
 		}
 	}
 

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

@@ -98,7 +98,7 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	private PreferenceService preferenceService;
 	private ImageView avatarImage, avatarEditOverlay;
 	private WeakReference<AvatarEditListener> listenerRef = new WeakReference<>(null);
-	private boolean hires, isEditable;
+	private boolean hires, isEditable, isMyProfilePicture;
 
 	// the hosting fragment
 	private WeakReference<Fragment> fragmentRef = new WeakReference<>(null);
@@ -488,7 +488,11 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	private void setAvatarBitmap(@Nullable Bitmap bitmap) {
 		if (bitmap != null) {
 			if (hires) {
-				this.avatarImage.setImageBitmap(bitmap);
+				if (isMyProfilePicture) {
+					this.avatarImage.setImageDrawable(AvatarConverterUtil.convertToRound(getResources(), bitmap));
+				} else {
+					this.avatarImage.setImageBitmap(bitmap);
+				}
 			} else {
 				this.avatarImage.setImageDrawable(AvatarConverterUtil.convertToRound(getResources(), bitmap));
 			}
@@ -606,6 +610,11 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 		this.avatarEditOverlay.setOnClickListener(this);
 	}
 
+	public void setIsMyProfilePicture(boolean isMyProfilePicture) {
+		this.isMyProfilePicture = isMyProfilePicture;
+		setHires(true);
+	}
+
 	public void setDefaultAvatar(ContactModel contactModel, GroupModel groupModel) {
 		loadDefaultAvatar(contactModel, groupModel);
 	}

+ 68 - 0
app/src/main/java/ch/threema/app/ui/DebouncedOnMenuItemClickListener.java

@@ -0,0 +1,68 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.ui;
+
+import android.os.SystemClock;
+import android.view.MenuItem;
+import android.view.View;
+
+import java.util.Map;
+import java.util.WeakHashMap;
+
+/**
+ * A Debounced OnClickListener Rejects clicks that are too close together in time. This class is
+ * safe to use as an OnClickListener for multiple views, and will debounce each one separately.
+ */
+public abstract class DebouncedOnMenuItemClickListener implements MenuItem.OnMenuItemClickListener {
+
+	private final long minimumInterval;
+	private Map<MenuItem, Long> lastClickMap;
+
+	/**
+	 * Implement this in your subclass instead of onClick
+	 *
+	 * @param item The MenuItem that was clicked
+	 */
+	public abstract boolean onDebouncedMenuItemClick(MenuItem item);
+
+	/**
+	 * @param minimumIntervalMsec The minimum allowed time between clicks - any click sooner than
+	 * this after a previous click will be rejected
+	 */
+	public DebouncedOnMenuItemClickListener(long minimumIntervalMsec) {
+		this.minimumInterval = minimumIntervalMsec;
+		this.lastClickMap = new WeakHashMap<>();
+	}
+
+	@Override
+	public boolean onMenuItemClick(MenuItem item) {
+		Long previousClickTimestamp = lastClickMap.get(item);
+		long currentTimestamp = SystemClock.uptimeMillis();
+
+		lastClickMap.put(item, currentTimestamp);
+		if (previousClickTimestamp == null || (currentTimestamp - previousClickTimestamp > minimumInterval)) {
+			return onDebouncedMenuItemClick(item);
+		}
+		// mark as consumed
+		return true;
+	}
+}

+ 5 - 11
app/src/main/java/ch/threema/app/ui/LockingSwipeRefreshLayout.java

@@ -22,10 +22,10 @@
 package ch.threema.app.ui;
 
 import android.content.Context;
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import android.util.AttributeSet;
 import android.view.MotionEvent;
 
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import ch.threema.app.R;
 
 public class LockingSwipeRefreshLayout extends SwipeRefreshLayout {
@@ -53,16 +53,10 @@ public class LockingSwipeRefreshLayout extends SwipeRefreshLayout {
 
 	@Override
 	public boolean onInterceptTouchEvent(MotionEvent event) {
-
-		switch (event.getAction()) {
-			case MotionEvent.ACTION_DOWN:
-				if (MotionEvent.obtain(event).getX() > this.getWidth() - tolerancePx) {
-					return false;
-				}
-				break;
-
-			default:
-				break;
+		if (event.getAction() == MotionEvent.ACTION_DOWN) {
+			if (event.getX() > this.getWidth() - tolerancePx) {
+				return false;
+			}
 		}
 		return super.onInterceptTouchEvent(event);
 	}

+ 14 - 8
app/src/main/java/ch/threema/app/ui/PaintSelectionPopup.java

@@ -36,7 +36,6 @@ import ch.threema.app.utils.AnimationUtil;
 
 public class PaintSelectionPopup extends PopupWindow implements View.OnClickListener {
 
-	private static final String TAG = PaintSelectionPopup.class.toString();
 	public static final int TAG_REMOVE = 1;
 	public static final int TAG_FLIP = 2;
 	public static final int TAG_TO_FRONT = 3;
@@ -70,15 +69,22 @@ public class PaintSelectionPopup extends PopupWindow implements View.OnClickList
 		}
 	}
 
-	public void show(int x, int y) {
+	public void show(int x, int y, boolean allowReordering) {
 		this.removeView.setOnClickListener(this);
 		this.removeView.setTag(TAG_REMOVE);
 
-		this.flipView.setOnClickListener(this);
-		this.flipView.setTag(TAG_FLIP);
-
-		this.tofrontView.setOnClickListener(this);
-		this.tofrontView.setTag(TAG_TO_FRONT);
+		if (allowReordering) {
+			this.flipView.setVisibility(View.VISIBLE);
+			this.flipView.setOnClickListener(this);
+			this.flipView.setTag(TAG_FLIP);
+
+			this.tofrontView.setVisibility(View.VISIBLE);
+			this.tofrontView.setOnClickListener(this);
+			this.tofrontView.setTag(TAG_TO_FRONT);
+		} else {
+			this.flipView.setVisibility(View.GONE);
+			this.tofrontView.setVisibility(View.GONE);
+		}
 
 		if (this.paintSelectPopupListener != null) {
 			this.paintSelectPopupListener.onOpen();
@@ -89,7 +95,7 @@ public class PaintSelectionPopup extends PopupWindow implements View.OnClickList
 		getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
 			@Override
 			public void onGlobalLayout() {
-				getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this);
+				getContentView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
 
 				AnimationUtil.popupAnimateIn(getContentView());
 			}

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

@@ -708,6 +708,19 @@ public class ConfigUtils {
 		localeOverride = null;
 	}
 
+	/*
+	 * Update the app locale to avoid having to restart if relying on the app context to get resources
+	 */
+	public static void updateAppContextLocale(Context context, String lang) {
+		Configuration config = new Configuration();
+		if (!TextUtils.isEmpty(lang)) {
+			config.locale = new Locale(lang);
+		} else {
+			config.locale = Locale.getDefault();
+		}
+		context.getResources().updateConfiguration(config, null);
+	}
+
 	/*
 	 * Returns the height of the status bar (showing battery or network status) on top of the screen
 	 * DEPRECATED: use ViewCompat.setOnApplyWindowInsetsListener() on Lollipop+

+ 14 - 0
app/src/main/java/ch/threema/app/utils/FileUtil.java

@@ -685,4 +685,18 @@ public class FileUtil {
 		}
 		return filename;
 	}
+
+	/**
+	 * Try to get a file uri from a content uri to maintain access to a file across two activities.
+	 * NOTE: This hack will probably stop working in API 30
+	 * @param uri content uri to resolve
+	 * @return file uri, if a file path could be resolved
+	 */
+	public static Uri getFileUri(Uri uri) {
+		File file = new File(FileUtil.getRealPathFromURI(ThreemaApplication.getAppContext(), uri));
+		if (file.canRead()) {
+			return Uri.fromFile(file);
+		}
+		return uri;
+	}
 }

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

@@ -38,6 +38,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.backuprestore.BackupRestoreDataService;
+import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
@@ -50,6 +51,8 @@ import ch.threema.app.services.MessageService;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
+import ch.threema.storage.models.DistributionListMessageModel;
+import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.ServerMessageModel;
 import ch.threema.storage.models.WebClientSessionModel;
@@ -528,4 +531,21 @@ public class IntentDataUtil {
 
 		return intent;
 	}
+
+	public static Intent getJumpToMessageIntent(Context context, AbstractMessageModel messageModel) {
+		Intent intent = new Intent(context, ComposeMessageActivity.class);
+		intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+		if (messageModel instanceof GroupMessageModel) {
+			intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, ((GroupMessageModel) messageModel).getGroupId());
+		} else if (messageModel instanceof DistributionListMessageModel) {
+			intent.putExtra(ThreemaApplication.INTENT_DATA_DISTRIBUTION_LIST, ((DistributionListMessageModel) messageModel).getDistributionListId());
+		} else {
+			intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, messageModel.getIdentity());
+		}
+		intent.putExtra(ComposeMessageFragment.EXTRA_API_MESSAGE_ID, messageModel.getApiMessageId());
+		intent.putExtra(ComposeMessageFragment.EXTRA_SEARCH_QUERY, " ");
+
+		return intent;
+	}
 }

+ 0 - 7
app/src/main/java/ch/threema/app/utils/RuntimeUtil.java

@@ -75,13 +75,6 @@ public class RuntimeUtil {
 		}
 	}
 
-	/**
-	 * Run the specified runnable on the UI thread after a certain delay.
-	 */
-	public static void runOnUiThreadDelayed(final @NonNull Runnable runnable, long delayMillis) {
-		handler.postDelayed(runnable, delayMillis);
-	}
-
 	/**
 	 * Run the specified runnable in an async task.
 	 */

+ 9 - 8
app/src/main/java/ch/threema/app/video/VideoTranscoder.java

@@ -282,7 +282,6 @@ public class VideoTranscoder {
 		boolean audioEncoderDone = false;
 
 		boolean videoDecoderDone = false;
-		boolean audioDecoderDone = false;
 
 		boolean videoExtractorDone = false;
 		boolean audioExtractorDone = false;
@@ -348,8 +347,7 @@ public class VideoTranscoder {
 			}
 
 			// Poll output frames from the audio decoder.
-			if (shouldIncludeAudio() && !audioDecoderDone && mPendingAudioDecoderOutputBufferIndex == -1
-				&& (mEncoderOutputAudioFormat == null || muxing)) {
+			if (shouldIncludeAudio() && mPendingAudioDecoderOutputBufferIndex == -1 && (mEncoderOutputAudioFormat == null || muxing)) {
 				pollAudioFromDecoder(audioDecoderOutputBufferInfo);
 			}
 
@@ -387,7 +385,8 @@ public class VideoTranscoder {
 	 */
 	private void sanityChecks() {
 		if (mStats.videoDecodedFrameCount != mStats.videoEncodedFrameCount) {
-			throw new IllegalStateException("encoded and decoded video frame counts should match");
+			logger.info("Frame count mismatch videoDecodedFrameCount: {} videoEncodedFrameCount: {}", mStats.videoDecodedFrameCount, mStats.videoEncodedFrameCount);
+//			throw new IllegalStateException("encoded and decoded video frame counts should match");
 		}
 
 		if (mStats.videoDecodedFrameCount > mStats.videoExtractedFrameCount) {
@@ -594,9 +593,11 @@ public class VideoTranscoder {
 					0,
 					MediaCodec.BUFFER_FLAG_END_OF_STREAM);
 			} catch (Exception e) {
+				// On some Android versions, queueInputBuffers' native code throws an exception if
+				// BUFFER_FLAG_END_OF_STREAM is set on non-empty buffers.
 				mRetryCount++;
 				if (mRetryCount < 5) {
-					this.extractAndFeedDecoder(decoder, buffers, component);
+					return this.extractAndFeedDecoder(decoder, buffers, component);
 				} else {
 					mRetryCount = 0;
 					throw e;
@@ -833,13 +834,13 @@ public class VideoTranscoder {
 
 		mVideoEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
 
+		mStats.videoEncodedFrameCount++;
+
 		if ((videoEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
 			logger.debug("video encoder: EOS");
 			return true;
 		}
 
-		mStats.videoEncodedFrameCount++;
-
 		return false;
 	}
 
@@ -893,7 +894,7 @@ public class VideoTranscoder {
 				mPreviousPresentationTime = audioEncoderOutputBufferInfo.presentationTimeUs;
 				mMuxer.writeSampleData(mOutputAudioTrack, encoderOutputBuffer, audioEncoderOutputBufferInfo);
 			} else {
-				logger.debug("presentationTimeUs {} < previousPresentationTime {}",
+				logger.debug("audio encoder: presentationTimeUs {} < previousPresentationTime {}",
 					audioEncoderOutputBufferInfo.presentationTimeUs, mPreviousPresentationTime);
 			}
 		}

+ 18 - 11
app/src/main/java/ch/threema/app/voip/activities/CallActivity.java

@@ -27,8 +27,6 @@ import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 import android.app.AppOpsManager;
 import android.app.PictureInPictureParams;
-import android.bluetooth.BluetoothAdapter;
-import android.bluetooth.BluetoothHeadset;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -1023,7 +1021,9 @@ public class CallActivity extends ThreemaActivity implements
 			if (incomingVideo || outgoingVideo) {
 				// Make video views visible
 				this.videoViews.fullscreenVideoRenderer.setVisibility(View.VISIBLE);
-				this.commonViews.backgroundView.setVisibility(View.INVISIBLE);
+				if (this.commonViews.backgroundView != null) {
+					this.commonViews.backgroundView.setVisibility(View.INVISIBLE);
+				}
 
 				this.videoViews.switchCamButton.setVisibility(outgoingVideo &&
 					(voipStateService.getVideoContext() != null && voipStateService.getVideoContext().hasMultipleCameras()) ?
@@ -1043,7 +1043,9 @@ public class CallActivity extends ThreemaActivity implements
 				this.videoViews.fullscreenVideoRenderer.setVisibility(View.GONE);
 				this.videoViews.switchCamButton.setVisibility(View.GONE);
 				this.videoViews.pipButton.setVisibility(View.GONE);
-				this.commonViews.backgroundView.setVisibility(View.VISIBLE);
+				if (this.commonViews.backgroundView != null) {
+					this.commonViews.backgroundView.setVisibility(View.VISIBLE);
+				}
 			}
 		}
 	}
@@ -1877,7 +1879,7 @@ public class CallActivity extends ThreemaActivity implements
 		}
 	}
 
-	public void selectAudioDevice(VoipAudioManager.AudioDevice device) {
+	public void selectAudioDevice(@NonNull VoipAudioManager.AudioDevice device) {
 		final Intent intent = new Intent();
 		intent.setAction(VoipCallService.ACTION_SET_AUDIO_DEVICE);
 		intent.putExtra(VoipCallService.EXTRA_AUDIO_DEVICE, device);
@@ -1885,16 +1887,21 @@ public class CallActivity extends ThreemaActivity implements
 	}
 
 	/**
-	 * Override audio device selection but only if no headphone is connected
-	 * @param device
+	 * Override audio device selection, but only if no headphone (wired or bluetooth) is connected.
 	 */
-	public void setPreferredAudioDevice(VoipAudioManager.AudioDevice device) {
-		BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+	public void setPreferredAudioDevice(@NonNull VoipAudioManager.AudioDevice device) {
+		logger.info("setPreferredAudioDevice {}", device);
+
+		if (audioManager.isWiredHeadsetOn()) {
+			logger.info("Wired headset is connected, not overriding audio device selection");
+			return;
+		}
 
-		if (audioManager.isWiredHeadsetOn() || mBluetoothAdapter != null && mBluetoothAdapter.isEnabled()
-			&& mBluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED) {
+		if (this.audioManager.isBluetoothScoOn()) {
+			logger.info("Bluetooth headset is connected, not overriding audio device selection");
 			return;
 		}
+
 		selectAudioDevice(device);
 	}
 

+ 5 - 1
app/src/main/java/ch/threema/app/voip/services/VoipCallService.java

@@ -2143,12 +2143,14 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			if (this.peerConnectionClient != null) {
 				try (CloseableLock ignored = this.videoQualityNegotiation.read()) {
 					synchronized (this.capturingLock) {
+						// Start capturing
 						final VideoCapturer videoCapturer = this.peerConnectionClient.startCapturing(
 							this.commonVideoQualityProfile
 						);
 						this.isCapturing = true;
+
+						// Query cameras
 						if (videoCapturer instanceof CameraVideoCapturer) {
-							// query cameras
 							final VideoContext videoContext = this.voipStateService.getVideoContext();
 							if (videoContext != null) {
 								Pair<String,String> primaryCameraNames = VideoCapturerUtil.getPrimaryCameraNames(getAppContext());
@@ -2157,6 +2159,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 								videoContext.setCameraVideoCapturer((CameraVideoCapturer) videoCapturer);
 							}
 						}
+
+						// Notify listeners
 						VoipUtil.sendVoipBroadcast(getAppContext(), CallActivity.ACTION_OUTGOING_VIDEO_STARTED);
 					}
 				}

+ 1 - 1
app/src/main/java/ch/threema/base/Contact.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/base/ThreemaException.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/base/VerificationLevel.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 5 - 2
app/src/main/java/ch/threema/client/APIConnector.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,
@@ -1182,7 +1182,6 @@ public class APIConnector {
 		// Paging
 		request.put("page", filter.getPage());
 
-
 		String data = doPost(
 			workServerUrl + "directory",
 			request.toString());
@@ -1244,6 +1243,10 @@ public class APIConnector {
 						contact.has("last") ? contact.optString("last") : null,
 						contact.has("csi") ? contact.optString("csi") : null
 					);
+					JSONObject jsonResponseOrganization = contact.optJSONObject("org");
+					if (jsonResponseOrganization != null) {
+						directoryContact.organization.name = jsonResponseOrganization.optString("name");
+					}
 
 					JSONArray categoryArray = contact.optJSONArray("cat");
 					if (categoryArray != null) {

+ 1 - 1
app/src/main/java/ch/threema/client/AbstractGroupMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 94 - 65
app/src/main/java/ch/threema/client/AbstractMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,
@@ -87,28 +87,33 @@ public abstract class AbstractMessage {
 												IdentityStoreInterface identityStore,
 												boolean fetch) throws BadMessageException, MissingPublicKeyException {
 
-		if (!boxmsg.getToIdentity().equals(identityStore.getIdentity()))
-			throw new BadMessageException("TM001");     /* Message is not for my identity - cannot decode */
+		if (!boxmsg.getToIdentity().equals(identityStore.getIdentity())) {
+			throw new BadMessageException("Message is not for own identity, cannot decode");
+		}
 
 		/* obtain public key of sender */
 		byte[] senderPublicKey = contactStore.getPublicKeyForIdentity(boxmsg.getFromIdentity(), fetch);
 
-		if (senderPublicKey == null)
-			throw new MissingPublicKeyException("TM002 (" + boxmsg.getFromIdentity() + ")");    /* Cannot obtain public key for x */
+		if (senderPublicKey == null) {
+			throw new MissingPublicKeyException("Missing public key for ID " + boxmsg.getFromIdentity());
+		}
 
 		/* decrypt with our secret key */
 		byte[] data = identityStore.decryptData(boxmsg.getBox(), boxmsg.getNonce(), senderPublicKey);
-		if (data == null)
-			throw new BadMessageException("TM003 (" + boxmsg.getFromIdentity() + ")");  /* Decryption of message from x failed */
+		if (data == null) {
+			throw new BadMessageException("Decryption of message from " + boxmsg.getFromIdentity() + " failed");
+		}
 
-		if (data.length == 1)
-			throw new BadMessageException("TM004");     /* Empty message received */
+		if (data.length == 1) {
+			throw new BadMessageException("Empty message received");
+		}
 
 		/* remove padding */
 		int padbytes = data[data.length - 1] & 0xFF;
 		int realDataLength = data.length - padbytes;
-		if (realDataLength < 1)
-			throw new BadMessageException("TM005");     /* Bad message padding */
+		if (realDataLength < 1) {
+			throw new BadMessageException("Bad message padding");
+		}
 
 		// Check
 
@@ -120,8 +125,9 @@ public abstract class AbstractMessage {
 
 		switch (type) {
 			case ProtocolDefines.MSGTYPE_TEXT: {
-				if (realDataLength < 2)
-					throw new BadMessageException("TM006 (" + realDataLength + ")");    /* Wrong length x for text message */
+				if (realDataLength < 2) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for text message");
+				}
 
 				BoxTextMessage textmsg = new BoxTextMessage();
 				textmsg.setText(new String(data, 1, realDataLength - 1, UTF_8));
@@ -130,8 +136,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_IMAGE: {
-				if (realDataLength != (1 + ProtocolDefines.BLOB_ID_LEN + 4 + NaCl.NONCEBYTES))
-					throw new BadMessageException("TM007 (" + realDataLength + ")");    /* Wrong length x for image message */
+				if (realDataLength != (1 + ProtocolDefines.BLOB_ID_LEN + 4 + NaCl.NONCEBYTES)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for image message");
+				}
 
 				BoxImageMessage imagemsg = new BoxImageMessage();
 
@@ -152,8 +159,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_VIDEO: {
-				if (realDataLength != (1 + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM008 (" + realDataLength + ")");    /* Wrong length x for video message */
+				if (realDataLength != (1 + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for video message");
+				}
 
 				BoxVideoMessage videomsg = new BoxVideoMessage();
 
@@ -187,8 +195,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_LOCATION: {
-				if (realDataLength < 4)
-					throw new BadMessageException("TM009 (" + realDataLength + ")");    /* Wrong length x for location message */
+				if (realDataLength < 4) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for location message");
+				}
 
 				String locStr = new String(data, 1, realDataLength - 1, UTF_8);
 				String[] lines = locStr.split("\n");
@@ -196,25 +205,29 @@ public abstract class AbstractMessage {
 
 				logger.info("Raw location message: {}", locStr);
 
-				if (locArr.length < 2 || locArr.length > 3)
-					throw new BadMessageException("TM010");     /* Bad coordinate format in location message */
+				if (locArr.length < 2 || locArr.length > 3) {
+					throw new BadMessageException("Bad coordinate format in location message");
+				}
 
 				BoxLocationMessage locationmsg = new BoxLocationMessage();
 				locationmsg.setLatitude(Double.parseDouble(locArr[0]));
 				locationmsg.setLongitude(Double.parseDouble(locArr[1]));
 
-				if (locArr.length == 3)
+				if (locArr.length == 3) {
 					locationmsg.setAccuracy(Double.parseDouble(locArr[2]));
+				}
 
 				if (lines.length >= 2) {
 					locationmsg.setPoiName(lines[1]);
-					if (lines.length >= 3)
+					if (lines.length >= 3) {
 						locationmsg.setPoiAddress(lines[2].replace("\\n", "\n"));
+					}
 				}
 
 				if (locationmsg.getLatitude() < -90.0 || locationmsg.getLatitude() > 90.0 ||
-					locationmsg.getLongitude() < -180.0 || locationmsg.getLongitude() > 180.0)
-					throw new BadMessageException("TM011");     /* Invalid coordinate values in location message */
+					locationmsg.getLongitude() < -180.0 || locationmsg.getLongitude() > 180.0) {
+					throw new BadMessageException("Invalid coordinate values in location message");
+				}
 
 				msg = locationmsg;
 
@@ -222,8 +235,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_AUDIO: {
-				if (realDataLength != (1 + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM026 (" + realDataLength + ")");    /* Wrong length x for audio message */
+				if (realDataLength != (1 + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for audio message");
+				}
 
 				BoxAudioMessage audiomsg = new BoxAudioMessage();
 
@@ -251,8 +265,10 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_CREATE: {
-				if (realDataLength < (1 + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.IDENTITY_LEN) || ((realDataLength - 1 - ProtocolDefines.GROUP_ID_LEN) % ProtocolDefines.IDENTITY_LEN) != 0)
-					throw new BadMessageException("TM018 (" + realDataLength + ")");    /* Wrong length x for group create message */
+				if (realDataLength < (1 + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.IDENTITY_LEN) ||
+						((realDataLength - 1 - ProtocolDefines.GROUP_ID_LEN) % ProtocolDefines.IDENTITY_LEN) != 0) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group create message");
+				}
 
 				GroupCreateMessage groupcreatemsg = new GroupCreateMessage();
 				groupcreatemsg.setGroupCreator(boxmsg.getFromIdentity());
@@ -268,9 +284,8 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_REQUEST_SYNC: {
-
 				if (realDataLength != (1 + ProtocolDefines.GROUP_ID_LEN)) {
-					throw new BadMessageException("TM025 (" + realDataLength + ")");    /* Wrong length x for group request sync message*/
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group request sync message");
 				}
 
 				GroupRequestSyncMessage groupRequestSyncMessage = new GroupRequestSyncMessage();
@@ -283,8 +298,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_RENAME: {
-				if (realDataLength < (1 + ProtocolDefines.GROUP_ID_LEN))
-					throw new BadMessageException("TM019 (" + realDataLength + ")");    /* Wrong length x for group rename message */
+				if (realDataLength < (1 + ProtocolDefines.GROUP_ID_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group rename message");
+				}
 
 				GroupRenameMessage grouprenamemsg = new GroupRenameMessage();
 				grouprenamemsg.setGroupCreator(boxmsg.getFromIdentity());
@@ -296,8 +312,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_LEAVE: {
-				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN))
-					throw new BadMessageException("TM020 (" + realDataLength + ")");    /* Wrong length x for group leave message */
+				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group leave message");
+				}
 
 				GroupLeaveMessage groupleavemsg = new GroupLeaveMessage();
 				groupleavemsg.setGroupCreator(new String(data, 1, ProtocolDefines.IDENTITY_LEN, StandardCharsets.US_ASCII));
@@ -308,8 +325,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_TEXT: {
-				if (realDataLength < (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN))
-					throw new BadMessageException("TM017 (" + realDataLength + ")");    /* Wrong length x for group text message */
+				if (realDataLength < (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group text message");
+				}
 
 				GroupTextMessage grouptextmsg = new GroupTextMessage();
 				grouptextmsg.setGroupCreator(new String(data, 1, ProtocolDefines.IDENTITY_LEN, StandardCharsets.US_ASCII));
@@ -320,8 +338,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_SET_PHOTO: {
-				if (realDataLength != (1 + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM021 (" + realDataLength + ")");    /* Wrong length x for group set photo message */
+				if (realDataLength != (1 + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group set photo message");
+				}
 
 				GroupSetPhotoMessage groupsetphotomsg = new GroupSetPhotoMessage();
 				groupsetphotomsg.setGroupCreator(boxmsg.getFromIdentity());
@@ -345,7 +364,7 @@ public abstract class AbstractMessage {
 
 			case ProtocolDefines.MSGTYPE_GROUP_DELETE_PHOTO: {
 				if (realDataLength != (1 + ProtocolDefines.GROUP_ID_LEN)) {
-					throw new BadMessageException("TM046 (" + realDataLength + ")");    /* Wrong length x for group delete message*/
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group delete photo message");
 				}
 
 				GroupDeletePhotoMessage groupDeletePhotoMessage = new GroupDeletePhotoMessage();
@@ -358,8 +377,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_IMAGE: {
-				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM022 (" + realDataLength + ")");    /* Wrong length x for group image message */
+				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group image message");
+				}
 
 				int i = 1;
 
@@ -383,8 +403,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_VIDEO: {
-				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 2 + 2 * ProtocolDefines.BLOB_ID_LEN + 2 * 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM023 (" + realDataLength + ")");    /* Wrong length x for group video message */
+				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 2 + 2 * ProtocolDefines.BLOB_ID_LEN + 2 * 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group video message");
+				}
 
 				int i = 1;
 
@@ -416,8 +437,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_LOCATION: {
-				if (realDataLength < (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 3))
-					throw new BadMessageException("TM024 (" + realDataLength + ")");    /* Wrong length x for group location message */
+				if (realDataLength < (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 3)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group location message");
+				}
 
 				String locStr = new String(data, 1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN,
 					realDataLength - ProtocolDefines.IDENTITY_LEN - ProtocolDefines.GROUP_ID_LEN - 1, UTF_8);
@@ -426,8 +448,9 @@ public abstract class AbstractMessage {
 
 				logger.info("Raw location message: {}", locStr);
 
-				if (locArr.length < 2 || locArr.length > 3)
-					throw new BadMessageException("TM010");     /* Bad coordinate format in location message */
+				if (locArr.length < 2 || locArr.length > 3) {
+					throw new BadMessageException("Bad coordinate format in group location message");
+				}
 
 				GroupLocationMessage grouplocationmsg = new GroupLocationMessage();
 				grouplocationmsg.setGroupCreator(new String(data, 1, ProtocolDefines.IDENTITY_LEN, StandardCharsets.US_ASCII));
@@ -445,8 +468,9 @@ public abstract class AbstractMessage {
 				}
 
 				if (grouplocationmsg.getLatitude() < -90.0 || grouplocationmsg.getLatitude() > 90.0 ||
-					grouplocationmsg.getLongitude() < -180.0 || grouplocationmsg.getLongitude() > 180.0)
-					throw new BadMessageException("TM011");     /* Invalid coordinate values in location message */
+					grouplocationmsg.getLongitude() < -180.0 || grouplocationmsg.getLongitude() > 180.0) {
+					throw new BadMessageException("Invalid coordinate values in group location message");
+				}
 
 				msg = grouplocationmsg;
 
@@ -454,8 +478,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_GROUP_AUDIO: {
-				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM027 (" + realDataLength + ")");    /* Wrong length x for group audio message */
+				if (realDataLength != (1 + ProtocolDefines.IDENTITY_LEN + ProtocolDefines.GROUP_ID_LEN + 2 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for group audio message");
+				}
 
 				int i = 1;
 
@@ -569,8 +594,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_DELIVERY_RECEIPT: {
-				if (realDataLength < ProtocolDefines.MESSAGE_ID_LEN + 2 || ((realDataLength - 2) % ProtocolDefines.MESSAGE_ID_LEN) != 0)
-					throw new BadMessageException("TM012 (" + realDataLength + ")");    /* Wrong length x for delivery receipt */
+				if (realDataLength < ProtocolDefines.MESSAGE_ID_LEN + 2 || ((realDataLength - 2) % ProtocolDefines.MESSAGE_ID_LEN) != 0) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for delivery receipt");
+				}
 
 				DeliveryReceiptMessage receiptmsg = new DeliveryReceiptMessage();
 				receiptmsg.setReceiptType(data[1] & 0xFF);
@@ -588,8 +614,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_TYPING_INDICATOR: {
-				if (realDataLength != 2)
-					throw new BadMessageException("TM013 (" + realDataLength + ")");    /* Wrong length x for typing indicator */
+				if (realDataLength != 2) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for typing indicator");
+				}
 
 				TypingIndicatorMessage typingmsg = new TypingIndicatorMessage();
 				typingmsg.setTyping((data[1] & 0xFF) > 0);
@@ -598,8 +625,9 @@ public abstract class AbstractMessage {
 			}
 
 			case ProtocolDefines.MSGTYPE_CONTACT_SET_PHOTO: {
-				if (realDataLength != (1 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN))
-					throw new BadMessageException("TM044 (" + realDataLength + ")");    /* Wrong length x for contact set photo message */
+				if (realDataLength != (1 + ProtocolDefines.BLOB_ID_LEN + 4 + ProtocolDefines.BLOB_KEY_LEN)) {
+					throw new BadMessageException("Bad length (" + realDataLength + ") for contact set photo message");
+				}
 
 				ContactSetPhotoMessage contactSetPhotoMessage = new ContactSetPhotoMessage();
 				contactSetPhotoMessage.setFromIdentity(boxmsg.getFromIdentity());
@@ -621,7 +649,7 @@ public abstract class AbstractMessage {
 
 			case ProtocolDefines.MSGTYPE_CONTACT_DELETE_PHOTO: {
 				if (realDataLength != 1) {
-					throw new BadMessageException("TM045 (" + realDataLength + ")");    /* Wrong length x for contact delete photo */
+					throw new BadMessageException("Bad length (" + realDataLength + ") for contact delete photo message");
 				}
 				msg = new ContactDeletePhotoMessage();
 
@@ -630,7 +658,7 @@ public abstract class AbstractMessage {
 
 			case ProtocolDefines.MSGTYPE_CONTACT_REQUEST_PHOTO: {
 				if (realDataLength != 1) {
-					throw new BadMessageException("TM048 (" + realDataLength + ")");    /* Wrong length x for contact request photo */
+					throw new BadMessageException("Bad length (" + realDataLength + ") for contact request photo message");
 				}
 				msg = new ContactRequestPhotoMessage();
 
@@ -715,8 +743,9 @@ public abstract class AbstractMessage {
 			/* PKCS7 padding */
 			SecureRandom rnd = new SecureRandom();
 			int padbytes = rnd.nextInt(254) + 1;
-			if ((bos.size() + padbytes) < ProtocolDefines.MIN_MESSAGE_PADDED_LEN)
+			if ((bos.size() + padbytes) < ProtocolDefines.MIN_MESSAGE_PADDED_LEN) {
 				padbytes = ProtocolDefines.MIN_MESSAGE_PADDED_LEN - bos.size();
+			}
 			logger.debug("Adding {} padding bytes", padbytes);
 
 			byte[] paddata = new byte[padbytes];
@@ -729,8 +758,9 @@ public abstract class AbstractMessage {
 			/* obtain receiver's public key */
 			byte[] receiverPublicKey = contactStore.getPublicKeyForIdentity(toIdentity, false);
 
-			if (receiverPublicKey == null)
-				throw new ThreemaException("TM002 (" + toIdentity + ")");   /* Cannot obtain public key for x */
+			if (receiverPublicKey == null) {
+				throw new ThreemaException("Missing public key for ID " + toIdentity);
+			}
 
 			/* make random nonce */
 			// only save if the message is not a immediate message
@@ -738,9 +768,8 @@ public abstract class AbstractMessage {
 
 			/* sign/encrypt with our private key */
 			byte[] boxedData = identityStore.encryptData(boxData, nonce, receiverPublicKey);
-
 			if (boxedData == null) {
-				throw new ThreemaException("TM047"); /* encryption failed */
+				throw new ThreemaException("Data encryption failed");
 			}
 
 			/* make BoxedMessage */

+ 1 - 1
app/src/main/java/ch/threema/client/AppVersion.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/AsyncResolver.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2017-2020 Threema GmbH
+ * Copyright (c) 2017-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BadMessageException.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/Base32.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BlobLoader.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BlobUploader.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxAudioMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxImageMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxLocationMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxTextMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxVideoMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/BoxedMessage.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/ConnectionState.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

+ 1 - 1
app/src/main/java/ch/threema/client/ConnectionStateListener.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema Java Client
- * Copyright (c) 2013-2020 Threema GmbH
+ * Copyright (c) 2013-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác