Threema %!s(int64=5) %!d(string=hai) anos
pai
achega
ec29965c87
Modificáronse 100 ficheiros con 1912 adicións e 881 borrados
  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,

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio