Threema 4 vuotta sitten
vanhempi
sitoutus
bb0d73e534
100 muutettua tiedostoa jossa 2477 lisäystä ja 2962 poistoa
  1. 19 14
      app/build.gradle
  2. 5 1
      app/src/main/AndroidManifest.xml
  3. 5 6
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  4. 19 173
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  5. 0 23
      app/src/main/java/ch/threema/app/activities/ContactNotificationsActivity.java
  6. 12 1
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  7. 45 31
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  8. 48 18
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  9. 21 3
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  10. 2 5
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  11. 25 23
      app/src/main/java/ch/threema/app/activities/NotificationsActivity.java
  12. 58 43
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  13. 20 1
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  14. 0 3
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  15. 2 3
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java
  16. 3 0
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment0.java
  17. 114 64
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment1.java
  18. 2 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardRestoreMainActivity.java
  19. 13 13
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  20. 37 77
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  21. 11 4
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  22. 4 4
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java
  23. 29 3
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  24. 10 0
      app/src/main/java/ch/threema/app/adapters/ballot/BallotWizard1Adapter.java
  25. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  26. 2 1
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  27. 15 2
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  28. 8 6
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  29. 12 0
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  30. 0 5
      app/src/main/java/ch/threema/app/dialogs/CancelableHorizontalProgressDialog.java
  31. 0 183
      app/src/main/java/ch/threema/app/dialogs/DateSelectorDialog.java
  32. 12 15
      app/src/main/java/ch/threema/app/dialogs/ExpandableTextEntryDialog.java
  33. 1 8
      app/src/main/java/ch/threema/app/dialogs/GenericAlertDialog.java
  34. 0 5
      app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java
  35. 0 5
      app/src/main/java/ch/threema/app/dialogs/SelectorDialog.java
  36. 19 0
      app/src/main/java/ch/threema/app/dialogs/TextEntryDialog.java
  37. 117 0
      app/src/main/java/ch/threema/app/dialogs/TextWithCheckboxDialog.java
  38. 7 1
      app/src/main/java/ch/threema/app/dialogs/ThreemaDialogFragment.java
  39. 0 134
      app/src/main/java/ch/threema/app/dialogs/TimeSelectorDialog.java
  40. 0 5
      app/src/main/java/ch/threema/app/dialogs/WizardDialog.java
  41. 26 6
      app/src/main/java/ch/threema/app/fragments/BackupDataFragment.java
  42. 103 80
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  43. 4 1
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  44. 19 12
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  45. 57 54
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java
  46. 78 51
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  47. 0 90
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsAdapter.java
  48. 0 39
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsRepository.java
  49. 0 54
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsViewModel.java
  50. 0 93
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchGroupChatsAdapter.java
  51. 0 54
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchGroupChatsViewModel.java
  52. 30 19
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchRepository.java
  53. 15 6
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchViewModel.java
  54. 1 38
      app/src/main/java/ch/threema/app/jobs/WorkSyncService.java
  55. 5 15
      app/src/main/java/ch/threema/app/listeners/ContactCountListener.java
  56. 13 10
      app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java
  57. 34 44
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java
  58. 0 5
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerConfirmDialog.java
  59. 2 0
      app/src/main/java/ch/threema/app/managers/ListenerManager.java
  60. 4 1
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  61. 17 6
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  62. 0 2
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  63. 12 0
      app/src/main/java/ch/threema/app/notifications/NotificationBuilderWrapper.java
  64. 1 1
      app/src/main/java/ch/threema/app/notifications/NotificationChannelSettings.java
  65. 14 0
      app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.java
  66. 178 74
      app/src/main/java/ch/threema/app/preference/SettingsNotificationsFragment.java
  67. 8 125
      app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.java
  68. 5 1
      app/src/main/java/ch/threema/app/processors/MessageProcessor.java
  69. 112 57
      app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java
  70. 0 79
      app/src/main/java/ch/threema/app/routines/ValidateContactsIntegrationRoutine.java
  71. 2 1
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  72. 3 2
      app/src/main/java/ch/threema/app/services/BrowserDetectionServiceImpl.java
  73. 7 11
      app/src/main/java/ch/threema/app/services/ContactService.java
  74. 127 256
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  75. 111 50
      app/src/main/java/ch/threema/app/services/DownloadServiceImpl.java
  76. 3 0
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  77. 2 0
      app/src/main/java/ch/threema/app/services/GroupApiServiceImpl.java
  78. 19 0
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  79. 10 3
      app/src/main/java/ch/threema/app/services/MessageService.java
  80. 83 54
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  81. 1 1
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  82. 1 1
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  83. 0 4
      app/src/main/java/ch/threema/app/services/SynchronizeContactsService.java
  84. 28 14
      app/src/main/java/ch/threema/app/services/SynchronizeContactsServiceImpl.java
  85. 2 2
      app/src/main/java/ch/threema/app/services/UserService.java
  86. 0 5
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion12.java
  87. 97 0
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion65.java
  88. 16 14
      app/src/main/java/ch/threema/app/stores/ContactStore.java
  89. 37 1
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  90. 5 0
      app/src/main/java/ch/threema/app/ui/ControllerView.java
  91. 11 1
      app/src/main/java/ch/threema/app/ui/EmptyRecyclerView.java
  92. 2 12
      app/src/main/java/ch/threema/app/ui/MediaItem.java
  93. 404 530
      app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java
  94. 23 2
      app/src/main/java/ch/threema/app/utils/AnimationUtil.java
  95. 3 0
      app/src/main/java/ch/threema/app/utils/AvatarConverterUtil.java
  96. 1 1
      app/src/main/java/ch/threema/app/utils/BitmapUtil.java
  97. 32 21
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  98. 1 66
      app/src/main/java/ch/threema/app/utils/ContactUtil.java
  99. 8 5
      app/src/main/java/ch/threema/app/utils/ConversationNotificationUtil.java
  100. 2 3
      app/src/main/java/ch/threema/app/utils/DNDUtil.java

+ 19 - 14
app/build.gradle

@@ -10,6 +10,10 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
     apply plugin: 'com.huawei.agconnect'
 }
 
+// version codes
+def app_version = "4.56"
+def beta_suffix = ""
+
 /**
  * Return the git hash, if git is installed.
  */
@@ -82,9 +86,8 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 682
-        versionName "4.55"
-        resValue "string", "version_name_suffix", ""
+        versionCode 688
+        versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter
         resValue "string", "package_name", applicationId
@@ -146,13 +149,15 @@ android {
 
     flavorDimensions "default"
     productFlavors {
+
+
         none { }
         store_google {
             resValue "string", "shop_download_filename", ""
         }
         store_threema { }
         store_google_work {
-            versionName "4.55k"
+            versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.work"
             testApplicationId 'ch.threema.app.work.test'
             resValue "string", "package_name", applicationId
@@ -191,7 +196,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.55k"
+            versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
@@ -223,7 +228,7 @@ android {
             ]
         }
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
-            versionName "4.55r"
+            versionName "${app_version}r${beta_suffix}"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
 
@@ -260,7 +265,7 @@ android {
             resValue "string", "package_name", applicationId
         }
         hms_work {
-            versionName "4.55k"
+            versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.work.hms"
             testApplicationId 'ch.threema.app.work.test.hms'
             resValue "string", "package_name", applicationId
@@ -332,7 +337,7 @@ android {
             assets.srcDirs = ['assets']
             jniLibs.srcDirs = ['libs']
         }
-        store_google{
+        store_google {
             java.srcDir 'src/google_services_based/java'
         }
         store_google_work {
@@ -353,7 +358,7 @@ android {
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
         }
         sandbox_work {
-            java.srcDir 'src/google_services_based/java'
+            java.srcDirs = ['src/store_google_work/java', 'src/google_services_based/java']
             res.srcDir 'src/store_google_work/res'
             manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
         }
@@ -521,7 +526,6 @@ dependencies {
     implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.3'
     implementation 'com.takisoft.preferencex:preferencex:1.1.0'
-    implementation 'com.takisoft.preferencex:preferencex-datetimepicker:1.1.0'
     implementation 'me.zhanghai.android.fastscroll:library:1.1.5'
 
     // AndroidX / Jetpack support libraries
@@ -538,9 +542,10 @@ dependencies {
     implementation 'androidx.activity:activity:1.2.3'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.0.0"
-    implementation "androidx.camera:camera-lifecycle:1.0.0"
-    implementation "androidx.camera:camera-view:1.0.0-alpha24"
+    implementation "androidx.camera:camera-camera2:1.0.1"
+    implementation "androidx.camera:camera-lifecycle:1.0.1"
+    // camera-view >1.0.0-alpha25 requires compileSDK 30
+    implementation "androidx.camera:camera-view:1.0.0-alpha25"
     implementation 'androidx.multidex:multidex:2.0.1'
     implementation "androidx.lifecycle:lifecycle-viewmodel:2.3.1"
     implementation "androidx.lifecycle:lifecycle-livedata:2.3.1"
@@ -553,7 +558,7 @@ dependencies {
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
     implementation "androidx.paging:paging-runtime:3.0.0"
 
-    implementation 'com.google.android.material:material:1.3.0'
+    implementation 'com.google.android.material:material:1.4.0'
     implementation 'com.google.android.exoplayer:exoplayer-core:2.13.3'
     implementation 'com.google.android.exoplayer:exoplayer-ui:2.13.3'
     implementation 'com.google.protobuf:protobuf-javalite:3.9.1'

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

@@ -112,6 +112,9 @@
 	<uses-feature
 		android:name="android.hardware.bluetooth"
 		android:required="false"/>
+	<uses-feature
+		android:name="android.hardware.camera"
+		android:required="false"/>
 	<uses-feature
 		android:name="android.hardware.camera.any"
 		android:required="false"/>
@@ -702,7 +705,8 @@
 		<activity
 			android:name=".archive.ArchiveActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-			android:theme="@style/Theme.Threema.WithToolbar" />
+			android:theme="@style/Theme.Threema.WithToolbar"
+			android:windowSoftInputMode="adjustResize" />
 		<activity
 			android:name=".activities.MapActivity"
 			android:configChanges="orientation|keyboardHidden|screenSize|uiMode"

+ 5 - 6
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -203,6 +203,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String INTENT_DATA_EDITFOCUS = "editfocus";
 	public static final String INTENT_DATA_GROUP = "group";
 	public static final String INTENT_DATA_DISTRIBUTION_LIST = "distribution_list";
+	public static final String INTENT_DATA_ARCHIVE_FILTER = "archiveFilter";
 	public static final String INTENT_DATA_QRCODE = "qrcodestring";
 	public static final String INTENT_DATA_QRCODE_TYPE_OK = "qrcodetypeok";
 	public static final String INTENT_DATA_MESSAGE_ID = "messageid";
@@ -271,6 +272,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	private static HashMap<String, String> messageDrafts = new HashMap<>();
 
 	public static ExecutorService sendMessageExecutorService = Executors.newFixedThreadPool(4);
+	public static ExecutorService sendMessageSingleThreadExecutorService = Executors.newSingleThreadExecutor();
 
 	private static boolean checkAppReplacingState(Context context) {
 		// workaround https://code.google.com/p/android/issues/detail?id=56296
@@ -1116,10 +1118,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			@Override
 			public void onRemove(GroupModel groupModel) {
 				try {
-
-					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(groupModel);
-
-					serviceManager.getBallotService().remove(receiver);
 					serviceManager.getConversationService().removed(groupModel);
 					serviceManager.getNotificationService().cancel(new GroupMessageReceiver(groupModel, null, null, null, null));
 				} catch (ThreemaException e) {
@@ -1432,7 +1430,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						SynchronizeContactsService synchronizeContactService = serviceManager.getSynchronizeContactsService();
 						boolean inSyncProcess = synchronizeContactService != null && synchronizeContactService.isSynchronizationInProgress();
 						if (!inSyncProcess) {
-							contactService.validateContactAggregation(createdContactModel, true);
+// TODO
+//							contactService.validateContactAggregation(createdContactModel);
 						}
 					}
 				} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
@@ -1654,7 +1653,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								ContactService c = serviceManager.getContactService();
 								if (c != null) {
 									//update contact names if changed!
-									c.validateContactNames();
+									c.updateAllContactNamesAndAvatarsFromAndroidContacts();
 								}
 							} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 								logger.error("Exception", e);

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

@@ -26,16 +26,13 @@ import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 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;
 import android.os.Bundle;
 import android.os.Handler;
-import android.provider.ContactsContract;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
@@ -50,8 +47,6 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.File;
-import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
 
@@ -71,7 +66,6 @@ import ch.threema.app.adapters.ContactDetailAdapter;
 import ch.threema.app.dialogs.ContactEditDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
-import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
@@ -89,6 +83,7 @@ import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.ResumePauseHandler;
 import ch.threema.app.ui.TooltipPopup;
+import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
@@ -111,20 +106,16 @@ import static ch.threema.app.utils.QRScannerUtil.REQUEST_CODE_QR_SCANNER;
 public class ContactDetailActivity extends ThreemaToolbarActivity
 		implements LifecycleOwner,
 					GenericAlertDialog.DialogClickListener,
-		           ContactEditDialog.ContactEditDialogClickListener,
-		           SelectorDialog.SelectorDialogClickListener {
+		            ContactEditDialog.ContactEditDialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(ContactDetailActivity.class);
 
 	private static final String DIALOG_TAG_EDIT = "cedit";
-	private static final String DIALOG_TAG_LINK_UNLINK_SELECTOR = "lu";
 	private static final String DIALOG_TAG_DELETE_CONTACT = "deleteContact";
 	private static final String DIALOG_TAG_EXCLUDE_CONTACT = "excludeContact";
-	private static final String DIALOG_TAG_UNLINK_CONTACT = "unlinkContact";
 	private static final String DIALOG_TAG_DELETING_CONTACT = "dliC";
 	private static final String DIALOG_TAG_ADD_CONTACT = "dac";
 	private static final String DIALOG_TAG_CONFIRM_BLOCK = "block";
 
-	private static final int PERMISSION_REQUEST_WRITE_CONTACTS = 1;
 	private static final int PERMISSION_REQUEST_CAMERA = 2;
 
 	private static final String RUN_ON_ACTIVE_RELOAD = "reload";
@@ -245,14 +236,14 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		@Override
 		public void onMemberLeave(GroupModel group, String leftIdentity, int previousMemberCount) {
 			if (leftIdentity.equals(identity)) {
-				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate); ;
+				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 		}
 
 		@Override
 		public void onMemberKicked(GroupModel group, String kickedIdentity, int previousMemberCount) {
 			if (kickedIdentity.equals(identity)) {
-				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate); ;
+				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 		}
 
@@ -342,7 +333,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			return;
 		}
 
-
 		this.contactTitle = findViewById(R.id.contact_title);
 		this.workIcon = findViewById(R.id.work_icon);
 		ViewUtil.show(workIcon, contactService.showBadge(contact));
@@ -390,6 +380,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			}
 		});
 
+		if (preferenceService.isSyncContacts() && ContactUtil.isSynchronized(contact)) {
+			floatingActionButton.setContentDescription(getString(R.string.edit));
+			floatingActionButton.setImageResource(R.drawable.ic_outline_contacts_app_24);
+		}
+
 		if (getToolbar().getNavigationIcon() != null) {
 			getToolbar().getNavigationIcon().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
 		}
@@ -433,13 +428,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private ContactDetailAdapter setupAdapter() {
 		ContactDetailAdapter groupMembershipAdapter = new ContactDetailAdapter(this, this.groupList, contact);
 		groupMembershipAdapter.setOnClickListener(new ContactDetailAdapter.OnClickListener() {
-			@Override
-			public void onLinkedContactClick(View v) {
-				if (ConfigUtils.requestContactPermissions(ContactDetailActivity.this, null, PERMISSION_REQUEST_WRITE_CONTACTS)) {
-					linkOrUnlinkContact();
-				}
-			}
-
 			@Override
 			public void onItemClick(View v, GroupModel groupModel) {
 
@@ -485,22 +473,10 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 
 	private void openContactEditor() {
-		Uri contactUri = ContactUtil.getLinkedUri(this, contactService, contact);
-
-		if (contactUri != null) {
-			Intent intent = new Intent(Intent.ACTION_EDIT);
-			intent.setDataAndType(contactUri, ContactsContract.Contacts.CONTENT_ITEM_TYPE);
-			intent.putExtra("finishActivityOnSaveCompleted", true);
-
-			// make sure users are coming back to threema and not the external activity
-			intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
-			if (intent.resolveActivity(getPackageManager()) != null) {
-				startActivity(intent);
-			} else {
-				Toast.makeText(ContactDetailActivity.this, "No contact editor found on device.", Toast.LENGTH_SHORT).show();
+		if (contact != null) {
+			if (!AndroidContactUtil.getInstance().openContactEditor(this, contact)) {
+				editName();
 			}
-		} else {
-			editName();
 		}
 	}
 
@@ -536,43 +512,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		setScrimColor();
 	}
 
-	private void linkContact() {
-		// Creates a new Intent to insert or edit a contact
-		Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI);
-		if (intent.resolveActivity(getPackageManager()) != null) {
-			startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_PICK_CONTACT);
-		} else {
-			Toast.makeText(this, "No contact picker found on device", Toast.LENGTH_SHORT).show();
-		}
-	}
-
-	private void unlinkContact() {
-		GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
-				R.string.really_unlink_contact_title,
-				R.string.really_unlink_contact,
-				R.string.ok,
-				R.string.cancel);
-		dialogFragment.setData(contact);
-		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_UNLINK_CONTACT);
-	}
-
-	private void linkOrUnlinkContact() {
-		if (ContactUtil.isLinked(contact)) {
-			SelectorDialog.newInstance(
-					getString(R.string.synchronize_contact),
-					new ArrayList<String>(Arrays.asList(getResources().getStringArray(R.array.linked_contact_unlink_array))),
-					getString(R.string.cancel)).show(getSupportFragmentManager(), DIALOG_TAG_LINK_UNLINK_SELECTOR);
-		} else {
-			linkContact();
-		}
-	}
-
-	private void reallyUnlinkContact(ContactModel contactModel) {
-		if (contactService != null) {
-			contactService.unlink(contactModel);
-		}
-	}
-
 	@Override
 	public void onResume() {
 		if (this.resumePauseHandler != null) {
@@ -838,36 +777,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		QRScannerUtil.getInstance().initiateScan(this, false, null);
 	}
 
-	private void link(final String lookupKey) {
-		if (TestUtil.empty(lookupKey)) {
-			return;
-		}
-		GenericProgressDialog.newInstance(-1, R.string.please_wait).show(getSupportFragmentManager(), "pleaseWait");
-
-		new Thread(new Runnable() {
-			@Override
-			public void run() {
-				try {
-					contactService.link(contact, lookupKey);
-				} catch (final Exception e) {
-				 	RuntimeUtil.runOnUiThread(new Runnable() {
-						@Override
-						public void run() {
-							logger.error("Exception", e);
-						}
-					});
-				} finally {
-				 	RuntimeUtil.runOnUiThread(new Runnable() {
-						@Override
-						public void run() {
-							DialogUtil.dismissDialog(getSupportFragmentManager(), "pleaseWait", true);
-						}
-					});
-				}
-			}
-		}).start();
-	}
-
 	@Override
 	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
 		super.onActivityResult(requestCode, resultCode, intent);
@@ -882,41 +791,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 
 		switch (requestCode) {
-			case ACTIVITY_ID_PICK_CONTACT:
-				if (resultCode == RESULT_OK) {
-					Uri contactUri = intent.getData();
-					if (ContactUtil.isLinked(contact)) {
-						if (this.contactService != null) {
-							this.contactService.unlink(contact);
-						}
-
-					}
-					if (contactUri != null) {
-						Cursor cursor = getContentResolver().query(contactUri, new String[]{
-							ContactsContract.Contacts._ID,
-							ContactsContract.Contacts.LOOKUP_KEY
-						}, null, null, null);
-
-						//get the lookup key
-						if (cursor != null) {
-							String lookupKey = null;
-							int contactId = 0;
-							if (cursor.moveToFirst()) {
-								lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY));
-								contactId = cursor.getInt(cursor.getColumnIndex(ContactsContract.Contacts._ID));
-
-							}
-							cursor.close();
-							if (!TestUtil.empty(lookupKey)) {
-								if (contactId != 0) {
-									lookupKey += "/" + contactId;
-								}
-								this.link(lookupKey);
-							}
-						}
-					}
-				}
-				break;
 			case ACTIVITY_ID_GROUP_DETAIL:
 				// contacts may have been edited
 				this.groupList = this.groupService.getGroupsByIdentity(this.identity);
@@ -994,9 +868,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	@Override
 	public void onYes(String tag, Object data) {
 		switch (tag) {
-			case DIALOG_TAG_UNLINK_CONTACT:
-				reallyUnlinkContact((ContactModel) data);
-				break;
 			case DIALOG_TAG_DELETE_CONTACT:
 				deleteContact((ContactModel) data);
 				break;
@@ -1044,23 +915,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	}
 
-	@Override
-	public void onClick(String tag, int which, Object data) {
-		switch(which) {
-			case 0:
-				unlinkContact();
-				break;
-			case 1:
-				linkContact();
-				break;
-			default:
-				break;
-		}
-	}
-
-	@Override
-	public void onCancel(String tag) {}
-
 	@Override
 	public void onNo(String tag) {}
 
@@ -1068,21 +922,13 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
 										   @NonNull String permissions[], @NonNull int[] grantResults) {
-		switch (requestCode) {
-			case PERMISSION_REQUEST_WRITE_CONTACTS:
-				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-					linkOrUnlinkContact();
-				} else if (!shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS)) {
-					ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.permission_contacts_required);
-				}
-				break;
-			case PERMISSION_REQUEST_CAMERA:
-				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-					scanQR();
-				} else if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
-					ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.permission_camera_qr_required);
-				}
-				break;
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+		if (requestCode == PERMISSION_REQUEST_CAMERA) {
+			if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+				scanQR();
+			} else if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
+				ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.permission_camera_qr_required);
+			}
 		}
 	}
 

+ 0 - 23
app/src/main/java/ch/threema/app/activities/ContactNotificationsActivity.java

@@ -21,9 +21,6 @@
 
 package ch.threema.app.activities;
 
-import android.content.Intent;
-import android.media.RingtoneManager;
-import android.net.Uri;
 import android.os.Bundle;
 import android.view.View;
 
@@ -63,26 +60,6 @@ public class ContactNotificationsActivity extends NotificationsActivity {
 		this.conversationService.refresh(this.contactModel);
 	}
 
-	@Override
-	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
-		super.onActivityResult(requestCode, resultCode, intent);
-
-		switch (requestCode) {
-			case ACTIVITY_ID_PICK_NTOTIFICATION:
-				if (resultCode == RESULT_OK) {
-					Uri ringtoneUri = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
-					ringtoneService.setRingtone(uid, ringtoneUri);
-					backupSoundCustom = ringtoneUri;
-					refreshSettings();
-				}
-				break;
-			case ACTIVITY_ID_SETTINGS_NOTIFICATIONS:
-				refreshSettings();
-				updateUI();
-				break;
-		}
-	}
-
 	@Override
 	protected void setupButtons() {
 		super.setupButtons();

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

@@ -65,6 +65,7 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 	public static final String EXTRA_CONFIRM = "confirm";
 	public static final String EXTRA_CANCEL_LABEL = "cancel";
 	public static final String EXTRA_WIZARD = "wizard";
+	private static final String DIALOG_TAG_MIUI_WARNING = "miui";
 
 	private String name;
 	@StringRes private int cancelLabel;
@@ -89,6 +90,12 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 			setTheme(R.style.Theme_Threema_Translucent_Dark);
 		}
 
+		if (ConfigUtils.getMIUIVersion() >= 11) {
+			String bodyText = getString(R.string.miui_battery_optimization, getString(R.string.app_name));
+			GenericAlertDialog.newInstance(R.string.battery_optimizations_title, bodyText, R.string.ok, 0).show(getSupportFragmentManager(), DIALOG_TAG_MIUI_WARNING);
+			return;
+		}
+
 		if (ConfigUtils.checkManifestPermission(this, getPackageName(), "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS")) {
 			@SuppressLint("BatteryLife") Intent newIntent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + BuildConfig.APPLICATION_ID));
 
@@ -124,7 +131,7 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 	}
 
 	public static boolean isWhitelisted(Context context) {
-		// app is always whitelited in unit tests
+		// app is always whitelisted in unit tests
 		if (RuntimeUtil.isInTest()) {
 			return true;
 		}
@@ -133,6 +140,7 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 			try {
 				return powerManager.isIgnoringBatteryOptimizations(context.getPackageName());
 			} catch (Exception e) {
+				logger.error("Exception while checking if battery optimization is disabled", e);
 				// don't care about buggy phones not implementing this API
 				return true;
 			}
@@ -176,6 +184,9 @@ public class DisableBatteryOptimizationsActivity extends AppCompatActivity imple
 				setResult(RESULT_OK);
 				finish();
 				break;
+			case DIALOG_TAG_MIUI_WARNING:
+				setResult(RESULT_CANCELED);
+				finish();
 		}
 	}
 

+ 45 - 31
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -37,7 +37,6 @@ import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Handler;
 import android.text.format.DateUtils;
-import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
@@ -45,11 +44,9 @@ import android.view.ViewGroup;
 import android.widget.Chronometer;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
-import android.widget.TextView;
 import android.widget.Toast;
 
-import com.google.android.material.bottomnavigation.BottomNavigationItemView;
-import com.google.android.material.bottomnavigation.BottomNavigationMenuView;
+import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.bottomnavigation.BottomNavigationView;
 
 import org.slf4j.Logger;
@@ -94,6 +91,7 @@ import ch.threema.app.fragments.MessageSectionFragment;
 import ch.threema.app.fragments.MyIDFragment;
 import ch.threema.app.globalsearch.GlobalSearchActivity;
 import ch.threema.app.listeners.AppIconListener;
+import ch.threema.app.listeners.ContactCountListener;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.listeners.MessageListener;
 import ch.threema.app.listeners.ProfileListener;
@@ -303,15 +301,14 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		@Override
 		protected void onPostExecute(Integer count) {
 			if (activityWeakReference.get() != null) {
-				View bottomNavigationBadgeView = activityWeakReference.get().findViewById(R.id.navigation_badge_view);
-				if (bottomNavigationBadgeView != null) {
-					if (count > 0) {
-						bottomNavigationBadgeView.setVisibility(View.VISIBLE);
-						TextView tv = bottomNavigationBadgeView.findViewById(R.id.notification_badge);
-						tv.setText(String.valueOf(count));
-					} else {
-						bottomNavigationBadgeView.setVisibility(View.GONE);
+				BottomNavigationView bottomNavigationView = activityWeakReference.get().findViewById(R.id.bottom_navigation);
+				if (bottomNavigationView != null) {
+					BadgeDrawable badgeDrawable = bottomNavigationView.getOrCreateBadge(R.id.messages);
+					if (badgeDrawable.getVerticalOffset() == 0) {
+						badgeDrawable.setVerticalOffset(activityWeakReference.get().getResources().getDimensionPixelSize(R.dimen.bottom_nav_badge_offset_vertical));
 					}
+					badgeDrawable.setNumber(count);
+					badgeDrawable.setVisible(count > 0);
 				}
 			}
 		}
@@ -493,6 +490,26 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	};
 
+	private final ContactCountListener contactCountListener = new ContactCountListener() {
+		@Override
+		public void onNewContactsCountUpdated(int last24hoursCount) {
+			if (preferenceService != null && preferenceService.getShowUnreadBadge()) {
+				RuntimeUtil.runOnUiThread(() -> {
+					if (!isFinishing() && !isDestroyed() && !isChangingConfigurations()) {
+						BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation);
+						if (bottomNavigationView != null) {
+							BadgeDrawable badgeDrawable = bottomNavigationView.getOrCreateBadge(R.id.contacts);
+							if (badgeDrawable.getVerticalOffset() == 0) {
+								badgeDrawable.setVerticalOffset(getResources().getDimensionPixelSize(R.dimen.bottom_nav_badge_offset_vertical));
+							}
+							badgeDrawable.setVisible(last24hoursCount > 0);
+						}
+					}
+				});
+			}
+		}
+	};
+
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 		logger.debug("onCreate");
@@ -588,6 +605,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void showWhatsNew() {
+		final boolean skipWhatsNew = true; // set this to false if you want to show a What's New screen
+
 		if (preferenceService != null) {
 			if (!preferenceService.isLatestVersion(this)) {
 				// so the app has just been updated
@@ -597,19 +616,20 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				}
 
 				if (!ConfigUtils.isWorkBuild() && !RuntimeUtil.isInTest() && !isFinishing()) {
-/*
-					isWhatsNewShown = true; // make sure this is set to false if no whatsnew activity is shown - otherwise pin lock will be skipped once
+					if (skipWhatsNew) {
+						isWhatsNewShown = false;
+					} else {
+						isWhatsNewShown = true; // make sure this is set to false if whatsnew is skipped - otherwise pin unlock will not be shown once
 
-					// Do not show whatsnew for users of the previous 4.5x 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 < 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 {
-*/						isWhatsNewShown = false;
-//					}
+						if (previous < 650) {
+*/							Intent intent = new Intent(this, WhatsNewActivity.class);
+							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
+							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
+//						}
+					}
 					preferenceService.setLatestVersion(this);
 				}
 			}
@@ -769,6 +789,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		ListenerManager.profileListeners.remove(this.profileListener);
 		ListenerManager.voipCallListeners.remove(this.voipCallListener);
 		ListenerManager.conversationListeners.remove(this.conversationListener);
+		ListenerManager.contactCountListener.remove(this.contactCountListener);
 
 		if (serviceManager != null) {
 			ThreemaConnection threemaConnection = serviceManager.getConnection();
@@ -870,6 +891,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			ListenerManager.profileListeners.add(this.profileListener);
 			ListenerManager.voipCallListeners.add(this.voipCallListener);
 			ListenerManager.conversationListeners.add(this.conversationListener);
+			ListenerManager.contactCountListener.add(this.contactCountListener);
 		} else {
 		 	RuntimeUtil.runOnUiThread(() -> showErrorTextAndExit(getString(R.string.service_manager_not_available)));
 		}
@@ -1097,15 +1119,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			bottomNavigationView.setSelectedItemId(initialItemId);
 		});
 
-		/// hack to display badge counters
 		if (preferenceService.getShowUnreadBadge()) {
-			BottomNavigationMenuView bottomNavigationMenuView = (BottomNavigationMenuView) bottomNavigationView.getChildAt(0);
-			View v = bottomNavigationMenuView.getChildAt(Integer.valueOf(FRAGMENT_TAG_MESSAGES));
-			BottomNavigationItemView itemView = (BottomNavigationItemView) v;
-
-			View bottomNavigationViewBadge = LayoutInflater.from(this).inflate(R.layout.bottom_navigation_badge, bottomNavigationMenuView, false);
-			itemView.addView(bottomNavigationViewBadge);
-
 			new UpdateBottomNavigationBadgeTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
 		}
 

+ 48 - 18
app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java

@@ -21,9 +21,12 @@
 
 package ch.threema.app.activities;
 
+import android.Manifest;
 import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
 import android.content.DialogInterface;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.content.res.TypedArray;
 import android.os.AsyncTask;
@@ -98,7 +101,6 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	private MediaGalleryAdapter mediaGalleryAdapter;
 	private MessageReceiver messageReceiver;
 	private String actionBarTitle;
-	private ActionBar actionBar;
 	private SpinnerMessageFilter spinnerMessageFilter;
 	private MediaGallerySpinnerAdapter spinnerAdapter;
 	private List<AbstractMessageModel> values;
@@ -124,16 +126,18 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		}
 	});
 
-	private final int TYPE_ALL = 0;
-	private final int TYPE_IMAGE = 1;
-	private final int TYPE_VIDEO = 2;
-	private final int TYPE_AUDIO = 3;
-	private final int TYPE_FILE = 4;
+	private static final int TYPE_ALL = 0;
+	private static final int TYPE_IMAGE = 1;
+	private static final int TYPE_VIDEO = 2;
+	private static final int TYPE_AUDIO = 3;
+	private static final int TYPE_FILE = 4;
 
 	private static final String DELETE_MESSAGES_CONFIRM_TAG = "reallydelete";
 	private static final String DIALOG_TAG_DELETING_MEDIA = "dmm";
 
-	private class SpinnerMessageFilter implements MessageService.MessageFilter {
+	private static final int PERMISSION_REQUEST_SAVE_MESSAGE = 88;
+
+	private static class SpinnerMessageFilter implements MessageService.MessageFilter {
 		private @MessageContentsType int[] filter = null;
 
 		public void setFilterByType(int spinnerMessageType) {
@@ -288,24 +292,24 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 
 		processIntent(getIntent());
 
-		this.actionBar = getSupportActionBar();
-		if (this.actionBar == null) {
+		ActionBar actionBar = getSupportActionBar();
+		if (actionBar == null) {
 			logger.debug("no action bar");
 			finish();
 			return false;
 		}
-		this.actionBar.setDisplayHomeAsUpEnabled(true);
-		this.actionBar.setDisplayShowTitleEnabled(false);
+		actionBar.setDisplayHomeAsUpEnabled(true);
+		actionBar.setDisplayShowTitleEnabled(false);
 
 		// add text view if contact list is empty
 		this.mediaTypeArray = getResources().obtainTypedArray(R.array.media_gallery_spinner);
 		this.spinnerAdapter = new MediaGallerySpinnerAdapter(
-				this.actionBar.getThemedContext(), getResources().getStringArray(R.array.media_gallery_spinner),
+				actionBar.getThemedContext(), getResources().getStringArray(R.array.media_gallery_spinner),
 				this.actionBarTitle);
 
-		this.actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
-		this.actionBar.setListNavigationCallbacks(spinnerAdapter, this);
-		this.actionBar.setSelectedNavigationItem(this.currentType);
+		actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
+		actionBar.setListNavigationCallbacks(spinnerAdapter, this);
+		actionBar.setSelectedNavigationItem(this.currentType);
 
 		this.spinnerMessageFilter = new SpinnerMessageFilter();
 		this.spinnerMessageFilter.setFilterByType(this.currentType);
@@ -448,7 +452,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		}
 	}
 
-	private List<AbstractMessageModel> getMessages(MessageReceiver receiver) {
+	private List<AbstractMessageModel> getMessages(MessageReceiver<AbstractMessageModel> receiver) {
 		List<AbstractMessageModel> values = null;
 		try {
 			values = receiver.loadMessages(this.spinnerMessageFilter);
@@ -541,8 +545,10 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	}
 
 	private void saveMessages() {
-		fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
-		actionMode.finish();
+		if (ConfigUtils.requestStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
+			fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
+			actionMode.finish();
+		}
 	}
 
 	private List<AbstractMessageModel> getSelectedMessages() {
@@ -725,5 +731,29 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 			}
 		}
 	}
+
+	@Override
+	@TargetApi(23)
+	public void onRequestPermissionsResult(int requestCode,
+	                                       @NonNull String[] permissions, @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+		if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+			switch (requestCode) {
+				case PERMISSION_REQUEST_SAVE_MESSAGE:
+					fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
+					break;
+			}
+		} else {
+			switch (requestCode) {
+				case PERMISSION_REQUEST_SAVE_MESSAGE:
+					if (!shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+						ConfigUtils.showPermissionRationale(this, gridView, R.string.permission_storage_required);
+					}
+					break;
+			}
+		}
+		actionMode.finish();
+	}
 }
 

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

@@ -65,6 +65,7 @@ import androidx.fragment.app.FragmentTransaction;
 import androidx.viewpager.widget.PagerAdapter;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.fragments.mediaviews.AudioViewFragment;
 import ch.threema.app.fragments.mediaviews.FileViewFragment;
@@ -95,7 +96,9 @@ import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.MessageType;
 
 
-public class MediaViewerActivity extends ThreemaToolbarActivity {
+public class MediaViewerActivity extends ThreemaToolbarActivity implements
+	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
+
 	private static final Logger logger = LoggerFactory.getLogger(MediaViewerActivity.class);
 
 	private static final int PERMISSION_REQUEST_SAVE_MESSAGE = 1;
@@ -439,12 +442,26 @@ public class MediaViewerActivity extends ThreemaToolbarActivity {
 
 	private void shareMedia() {
 		AbstractMessageModel messageModel = this.getCurrentMessageModel();
+		ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(
+			getString(R.string.share_media),
+			R.string.add_caption_hint, messageModel.getCaption(),
+			R.string.next, R.string.cancel, true);
+		alertDialog.setData(messageModel);
+		alertDialog.show(getSupportFragmentManager(), null);
+	}
+
+	@Override
+	public void onYes(String tag, Object data, String text) {
+		AbstractMessageModel messageModel = (AbstractMessageModel) data;
 		Uri shareUri = fileService.copyToShareFile(messageModel, currentMediaFile);
 		messageService.shareMediaMessages(this,
-				new ArrayList<>(Collections.singletonList(messageModel)),
-				new ArrayList<>(Collections.singletonList(shareUri)));
+			new ArrayList<>(Collections.singletonList(messageModel)),
+			new ArrayList<>(Collections.singletonList(shareUri)), text);
 	}
 
+	@Override
+	public void onNo(String tag) {}
+
 	public void viewMediaInGallery() {
 		AbstractMessageModel messageModel = this.getCurrentMessageModel();
 		Uri shareUri = fileService.copyToShareFile(messageModel, currentMediaFile);
@@ -786,6 +803,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity {
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
 	                                       @NonNull String permissions[], @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		switch (requestCode) {
 			case PERMISSION_REQUEST_SAVE_MESSAGE:
 				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {

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

@@ -343,13 +343,10 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
 		if (contacts.size() > 0) {
 			if (snackbar == null) {
 				snackbar = SnackbarUtil.make(rootView, "", Snackbar.LENGTH_INDEFINITE, 4);
-				snackbar.getView().setBackgroundColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
+				snackbar.setBackgroundTint(ConfigUtils.getColorFromAttribute(this, R.attr.colorAccent));
 				snackbar.getView().getLayoutParams().width = AppBarLayout.LayoutParams.MATCH_PARENT;
 			}
-			TextView textView = snackbar.getView().findViewById(com.google.android.material.R.id.snackbar_text);
-			if (textView != null) {
-				textView.setTextColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorOnSecondary));
-			}
+			snackbar.setTextColor(ConfigUtils.getColorFromAttribute(this, R.attr.colorOnSecondary));
 			snackbar.setText(getMemberNames());
 			if (!snackbar.isShown()) {
 				snackbar.show();

+ 25 - 23
app/src/main/java/ch/threema/app/activities/NotificationsActivity.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.activities;
 
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
 import android.content.Intent;
 import android.graphics.PorterDuff;
 import android.media.RingtoneManager;
@@ -37,6 +39,8 @@ import android.widget.ImageView;
 import android.widget.ScrollView;
 import android.widget.TextView;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.UiThread;
 import androidx.appcompat.widget.AppCompatRadioButton;
 import ch.threema.app.R;
@@ -88,6 +92,20 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 	private int[] animCenterLocation = {0, 0};
 	protected String uid;
 
+	private final ActivityResultLauncher<Intent> ringtonePickerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+				Uri uri = result.getData().getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+				onRingtoneSelected(DIALOG_TAG_RINGTONE_SELECTOR, uri);
+			}
+		});
+
+	private final ActivityResultLauncher<Intent> ringtoneSettingsLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			refreshSettings();
+			updateUI();
+		});
+
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		if (!this.requiredInstances()) {
@@ -336,7 +354,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 				Intent intent = new Intent(this, SettingsActivity.class);
 				intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, SettingsNotificationsFragment.class.getName());
 				intent.putExtra(PreferenceActivity.EXTRA_NO_HEADERS, true );
-				startActivityForResult(intent, ACTIVITY_ID_SETTINGS_NOTIFICATIONS);
+				ringtoneSettingsLauncher.launch(intent);
 				overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
 				break;
 			default:
@@ -361,7 +379,6 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 	}
 
 	protected void pickRingtone(String uniqueId) {
-
 		Uri existingUri = this.ringtoneService.getRingtoneFromUniqueId(uniqueId);
 		if (existingUri != null && existingUri.getPath().equals("null")) {
 			existingUri = null;
@@ -372,12 +389,17 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 
 		Uri defaultUri = this.ringtoneService.getDefaultContactRingtone();
 
-		RingtoneSelectorDialog.newInstance(getString(R.string.prefs_notification_sound),
+		try {
+			Intent intent = RingtoneUtil.getRingtonePickerIntent(RingtoneManager.TYPE_NOTIFICATION, existingUri == null ? defaultUri : existingUri, defaultUri);
+			ringtonePickerLauncher.launch(intent);
+		} catch (ActivityNotFoundException e) {
+			RingtoneSelectorDialog.newInstance(getString(R.string.prefs_notification_sound),
 				RingtoneManager.TYPE_NOTIFICATION,
 				existingUri,
 				defaultUri,
 				true,
 				true).show(getSupportFragmentManager(), DIALOG_TAG_RINGTONE_SELECTOR);
+		}
 	}
 
 	protected void onDone() {
@@ -434,26 +456,6 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 		outState.putIntArray(BUNDLE_ANIMATION_CENTER, this.animCenterLocation);
 	}
 
-	@Override
-	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
-		super.onActivityResult(requestCode, resultCode, intent);
-
-		switch (requestCode) {
-			case ACTIVITY_ID_PICK_NTOTIFICATION:
-				if (resultCode == RESULT_OK) {
-					Uri ringtoneUri = intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
-					ringtoneService.setRingtone(uid, ringtoneUri);
-					backupSoundCustom = ringtoneUri;
-					refreshSettings();
-				}
-				break;
-			case ACTIVITY_ID_SETTINGS_NOTIFICATIONS:
-				refreshSettings();
-				updateUI();
-				break;
-		}
-	}
-
 	@Override
 	public void onRingtoneSelected(String tag, Uri ringtone) {
 		ringtoneService.setRingtone(uid, ringtone);

+ 58 - 43
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -82,6 +82,8 @@ import ch.threema.app.adapters.FilterableListAdapter;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.dialogs.TextWithCheckboxDialog;
+import ch.threema.app.dialogs.ThreemaDialogFragment;
 import ch.threema.app.fragments.DistributionListFragment;
 import ch.threema.app.fragments.GroupListFragment;
 import ch.threema.app.fragments.RecentListFragment;
@@ -126,6 +128,7 @@ import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	CancelableHorizontalProgressDialog.ProgressDialogClickListener,
 	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
+	TextWithCheckboxDialog.TextWithCheckboxDialogClickListener,
 	SearchView.OnQueryTextListener {
 
 	private static final Logger logger = LoggerFactory.getLogger(RecipientListBaseActivity.class);
@@ -149,7 +152,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	private MenuItem searchMenuItem;
 	private ThreemaSearchView searchView;
 
-	private boolean isForward, hideUi, hideRecents, multiSelect;
+	private boolean hideUi, hideRecents, multiSelect;
 	private String captionText;
 	private final List<MediaItem> mediaItems = new ArrayList<>();
 	private final List<MessageReceiver> recipientMessageReceivers = new ArrayList<>();
@@ -217,7 +220,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void resetValues() {
-		isForward = hideUi = hideRecents = false;
+		hideUi = hideRecents = false;
 		mediaItems.clear();
 		originalMessageModels.clear();
 		tabs.clear();
@@ -499,7 +502,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							finish();
 						}
 					}
-					this.isForward = intent.getBooleanExtra(ThreemaApplication.INTENT_DATA_IS_FORWARD, false);
 
 					if (!TestUtil.empty(identity)) {
 						prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(contactService.getByIdentity(identity))));
@@ -525,7 +527,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						final ContactModel contactModel = ContactLookupUtil.phoneNumberToContact(this, contactService, uri.getSchemeSpecificPart());
 
 						if (contactModel != null) {
-							prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)));
+							prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
 							return;
 						} else {
 							finish();
@@ -566,7 +568,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 									if (contactModel == null) {
 										addNewContact(targetIdentity);
 									} else {
-										prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)));
+										prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
 									}
 								}
 							}
@@ -607,7 +609,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					}
 				} else if (action.equals(ThreemaApplication.INTENT_ACTION_FORWARD)) {
 					// internal forward using message id instead of media URI
-					isForward = true;
 					ArrayList<Integer> messageIds = IntentDataUtil.getAbstractMessageIds(intent);
 					String originalMessageType = IntentDataUtil.getAbstractMessageType(intent);
 
@@ -727,12 +728,12 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
 						Snackbar.make(rootView, R.string.contact_not_found, Snackbar.LENGTH_LONG).show();
 					} else {
-						prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)));
+						prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)), false);
 					}
 				}
 			}.execute();
 		} else {
-			prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)));
+			prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
 		}
 	}
 
@@ -771,12 +772,12 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		return messageReceiver;
 	}
 
-	private void prepareComposeIntent(ArrayList<Object> models) {
+	private void prepareComposeIntent(ArrayList<Object> recipients, boolean keepOriginalCaptions) {
 		Intent intent = null;
 		MessageReceiver messageReceiver = null;
-		ArrayList<MessageReceiver> messageReceivers = new ArrayList<>(models.size());
+		ArrayList<MessageReceiver> messageReceivers = new ArrayList<>(recipients.size());
 
-		for (Object model : models) {
+		for (Object model : recipients) {
 			messageReceiver = getMessageReceiver(model);
 			if (validateSendingPermission(messageReceiver)) {
 				messageReceivers.add(messageReceiver);
@@ -785,8 +786,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
 		intent = IntentDataUtil.getComposeIntentForReceivers(this, messageReceivers);
 
-		if (originalMessageModels != null && originalMessageModels.size() > 0) {
-			this.forwardMessages(messageReceivers.toArray(new MessageReceiver[0]), intent);
+		if (originalMessageModels.size() > 0) {
+			this.forwardMessages(messageReceivers.toArray(new MessageReceiver[0]), intent, keepOriginalCaptions);
 		} else {
 			this.sendSharedMedia(messageReceivers.toArray(new MessageReceiver[0]), intent);
 		}
@@ -797,14 +798,14 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, mediaItems.get(0).getCaption());
 			startComposeActivity(intent);
 		} else if (messageReceivers.length > 1 || mediaItems.size() > 0) {
-			messageService.sendMediaAsync(mediaItems, Arrays.asList(messageReceivers));
+			messageService.sendMediaSingleThread(mediaItems, Arrays.asList(messageReceivers));
 			startComposeActivity(intent);
 		} else {
 			startComposeActivity(intent);
 		}
 	}
 
-	void forwardSingleMessage(final MessageReceiver[] messageReceivers, final int i, final Intent intent) {
+	void forwardSingleMessage(final MessageReceiver[] messageReceivers, final int i, final Intent intent, final boolean keepOriginalCaptions) {
 		final AbstractMessageModel messageModel = originalMessageModels.get(i);
 		fileService.loadDecryptedMessageFile(messageModel, new FileService.OnDecryptedFileComplete() {
 			@Override
@@ -816,15 +817,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					if (decryptedFile != null) {
 						uri = Uri.fromFile(decryptedFile);
 					}
+
+					String caption = keepOriginalCaptions ? messageModel.getCaption() : captionText;
+
 					switch (messageModel.getType()) {
 						case IMAGE:
-							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_IMAGE, null, FileData.RENDERING_MEDIA, null);
+							sendForwardedMedia(messageReceivers, uri, caption, MediaItem.TYPE_IMAGE, null, FileData.RENDERING_MEDIA, null);
 							break;
 						case VIDEO:
-							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_VIDEO, null, FileData.RENDERING_MEDIA, null);
+							sendForwardedMedia(messageReceivers, uri, caption, MediaItem.TYPE_VIDEO, null, FileData.RENDERING_MEDIA, null);
 							break;
 						case VOICEMESSAGE:
-							sendForwardedMedia(messageReceivers, uri, captionText, MediaItem.TYPE_VOICEMESSAGE, MimeUtil.MIME_TYPE_AUDIO_AAC, FileData.RENDERING_MEDIA, null);
+							sendForwardedMedia(messageReceivers, uri, caption, MediaItem.TYPE_VOICEMESSAGE, MimeUtil.MIME_TYPE_AUDIO_AAC, FileData.RENDERING_MEDIA, null);
 							break;
 						case FILE:
 							int mediaType = MediaItem.TYPE_FILE;
@@ -832,19 +836,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							int renderingType = messageModel.getFileData().getRenderingType();
 
 							if (messageModel.getFileData().getRenderingType() != FileData.RENDERING_DEFAULT) {
-								if (MimeUtil.isVideoFile(mimeType)) {
-									mediaType = MediaItem.TYPE_VIDEO;
-								} else if (MimeUtil.isImageFile(mimeType)) {
-									if (MimeUtil.isGifFile(mimeType)) {
-										mediaType = MediaItem.TYPE_GIF;
-									} else {
-										mediaType = MediaItem.TYPE_IMAGE;
-									}
-								} else if (MimeUtil.isAudioFile(mimeType)) {
-									mediaType = MediaItem.TYPE_VOICEMESSAGE;
-								}
+								mediaType = MimeUtil.getMediaTypeFromMimeType(mimeType);
 							}
-							sendForwardedMedia(messageReceivers, uri, captionText, mediaType, mimeType, renderingType, messageModel.getFileData().getFileName());
+							sendForwardedMedia(messageReceivers, uri, caption, mediaType, mimeType, renderingType, messageModel.getFileData().getFileName());
 							break;
 						case LOCATION:
 							sendLocationMessage(messageReceivers, messageModel.getLocationData());
@@ -858,7 +852,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					}
 				}
 				if (i < originalMessageModels.size() - 1) {
-					forwardSingleMessage(messageReceivers, i+1, intent);
+					forwardSingleMessage(messageReceivers, i+1, intent, keepOriginalCaptions);
 				} else {
 					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_MULTISEND, true);
 					startComposeActivity(intent);
@@ -873,10 +867,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 	}
 
 	@UiThread
-	private void forwardMessages(final MessageReceiver[] messageReceivers, final Intent intent) {
+	private void forwardMessages(final MessageReceiver[] messageReceivers, final Intent intent, boolean keepOriginalCaptions) {
 		CancelableHorizontalProgressDialog.newInstance(R.string.sending_messages, 0, 0, originalMessageModels.size()).show(getSupportFragmentManager(), DIALOG_TAG_MULTISEND);
 
-		forwardSingleMessage(messageReceivers, 0, intent);
+		forwardSingleMessage(messageReceivers, 0, intent, keepOriginalCaptions);
 	}
 
 	private void startComposeActivityAsync(final Intent intent) {
@@ -903,13 +897,13 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
-	public void prepareForwardingOrSharing(final ArrayList<Object> models) {
+	public void prepareForwardingOrSharing(final ArrayList<Object> recipients) {
 		if (mediaItems.size() > 0 || originalMessageModels.size() > 0) {
 			String recipientName = "";
 
 			if (!((mediaItems.size() == 1 && MimeUtil.isTextFile(mediaItems.get(0).getMimeType()))
 				|| (originalMessageModels.size() == 1 && originalMessageModels.get(0).getType() == MessageType.TEXT))) {
-				for (Object model : models) {
+				for (Object model : recipients) {
 					if (recipientName.length() > 0) {
 						recipientName += ", ";
 					}
@@ -923,10 +917,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					}
 				}
 
-				if (isForward) {
+				if (originalMessageModels.size() > 0) {
 					// forwarded content of any type
 					String presetCaption = null;
 					boolean expandable = false;
+					boolean hasCaptions = false;
 
 					if (originalMessageModels.size() == 1) {
 						presetCaption = originalMessageModels.get(0).getCaption();
@@ -936,10 +931,22 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 							originalMessageModels.get(0).getType() == MessageType.FILE) {
 							expandable = true;
 						}
+					} else {
+						for (AbstractMessageModel messageModel: originalMessageModels) {
+							if (messageModel.getCaption() != null && !TextUtils.isEmpty(messageModel.getCaption())) {
+								hasCaptions = true;
+								break;
+							}
+						}
 					}
 
-					ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_forward, recipientName), R.string.add_caption_hint, presetCaption, R.string.send, R.string.cancel, expandable);
-					alertDialog.setData(models);
+					ThreemaDialogFragment alertDialog;
+					if (!expandable) {
+						alertDialog = TextWithCheckboxDialog.newInstance(getString(R.string.really_forward, recipientName), hasCaptions ? R.string.forward_captions : 0, R.string.send, R.string.cancel);
+					} else {
+						alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_forward, recipientName), R.string.add_caption_hint, presetCaption, R.string.send, R.string.cancel, expandable);
+					}
+					alertDialog.setData(recipients);
 					alertDialog.show(getSupportFragmentManager(), null);
 				} else {
 					// content shared by external apps may be referred to by content URIs which will not survive this activity. so in order to be able to use them later we have to copy these files to a local directory first
@@ -962,7 +969,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 								if (numEditableMedia == mediaItems.size()) { // all files are images or videos
 									// all files are either images or videos => redirect to SendMediaActivity
 									recipientMessageReceivers.clear();
-									for (Object model : models) {
+									for (Object model : recipients) {
 										MessageReceiver messageReceiver = getMessageReceiver(model);
 										if (validateSendingPermission(messageReceiver)) {
 											recipientMessageReceivers.add(messageReceiver);
@@ -978,7 +985,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 								} else {
 									// mixed media
 									ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, mediaItems.size() == 1);
-									alertDialog.setData(models);
+									alertDialog.setData(recipients);
 									alertDialog.show(getSupportFragmentManager(), null);
 								}
 							}, ContextCompat.getMainExecutor(getApplicationContext()));
@@ -993,7 +1000,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		}
 
 		// fallback to starting new chat
-		prepareComposeIntent(models);
+		prepareComposeIntent(recipients, false);
 	}
 
 	@Override
@@ -1156,7 +1163,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 				mediaItem.setImageScale(PreferenceService.ImageScale_ORIGINAL);
 			}
 		}
-		messageService.sendMediaAsync(Collections.singletonList(mediaItem), Arrays.asList(messageReceivers));
+		messageService.sendMediaSingleThread(Collections.singletonList(mediaItem), Arrays.asList(messageReceivers));
 	}
 
 	@WorkerThread
@@ -1239,7 +1246,15 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					mediaItem.setCaption(text);
 				}
 			}
-			prepareComposeIntent((ArrayList<Object>) data);
+			prepareComposeIntent((ArrayList<Object>) data, false);
+		}
+	}
+
+	// return from TextWithCheckboxDialog
+	@Override
+	public void onYes(String tag, Object data, boolean checked) {
+		if (data instanceof ArrayList) {
+			prepareComposeIntent((ArrayList<Object>) data, checked);
 		}
 	}
 

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

@@ -232,7 +232,6 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			return false;
 		}
 		actionBar.setDisplayHomeAsUpEnabled(true);
-		actionBar.setTitle("");
 
 		DeadlineListService hiddenChatsListService;
 		try {
@@ -471,6 +470,25 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 			this.captionEditText.setPadding(getResources().getDimensionPixelSize(R.dimen.no_emoji_button_padding_left), this.captionEditText.getPaddingTop(), this.captionEditText.getPaddingRight(), this.captionEditText.getPaddingBottom());
 		}
 
+		String recipients = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_TEXT);
+		if (!TestUtil.empty(recipients)) {
+			this.captionEditText.setHint(getString(R.string.send_to, recipients));
+			this.captionEditText.addTextChangedListener(new TextWatcher() {
+				@Override
+				public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+
+				@Override
+				public void onTextChanged(CharSequence s, int start, int before, int count) { }
+
+				@Override
+				public void afterTextChanged(Editable s) {
+					if (s == null || s.length() == 0) {
+						captionEditText.setHint(getString(R.string.send_to, recipients));
+					}
+				}
+			});
+		}
+
 		SendButton sendButton = findViewById(R.id.send_button);
 		sendButton.setOnClickListener(new DebouncedOnClickListener(500) {
 			@Override
@@ -660,6 +678,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 	@Override
 	public boolean onCreateOptionsMenu(Menu menu) {
+		getToolbar().setTitle(R.string.send_media);
 		getMenuInflater().inflate(R.menu.activity_send_media, menu);
 
 		settingsItem = menu.findItem(R.id.settings);

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

@@ -59,16 +59,13 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 	final static public int ACTIVITY_ID_GROUP_ADD = 20028;
 	final static public int ACTIVITY_ID_GROUP_DETAIL = 20029;
 	final static public int ACTIVITY_ID_CHANGE_PASSPHRASE_UNLOCK = 20032;
-	final static public int ACTIVITY_ID_PICK_CONTACT = 20034;
 	final static public int ACTIVITY_ID_MEDIA_VIEWER = 20035;
 	public static final int ACTIVITY_ID_CREATE_BALLOT = 20037;
 	final static public int ACTIVITY_ID_ID_SECTION = 20041;
 	final static public int ACTIVITY_ID_BACKUP_PICKER = 20042;
 	final static public int ACTIVITY_ID_COPY_BALLOT = 20043;
-	public static final int ACTIVITY_ID_PICK_NTOTIFICATION = 20045;
 	public static final int ACTIVITY_ID_CHECK_LOCK = 20046;
 	public static final int ACTIVITY_ID_PICK_FILE = 20047;
-	public static final int ACTIVITY_ID_SETTINGS_NOTIFICATIONS = 20048;
 	public static final int ACTIVITY_ID_PAINT = 20049;
 	public static final int ACTIVITY_ID_PICK_MEDIA = 20050;
 

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

@@ -21,7 +21,6 @@
 
 package ch.threema.app.activities;
 
-import android.content.Intent;
 import android.content.res.Configuration;
 import android.os.Bundle;
 import android.view.View;
@@ -48,8 +47,8 @@ public class WhatsNewActivity extends ThreemaAppCompatActivity {
 		((TextView) findViewById(R.id.whatsnew_body)).setText(getString(R.string.whatsnew_headline, getString(R.string.app_name)));
 
 		findViewById(R.id.next_text).setOnClickListener(v -> {
-			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));
-			overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
+//			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));
+//			overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
 			finish();
 		});
 

+ 3 - 0
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment0.java

@@ -130,6 +130,9 @@ public class BallotWizardFragment0 extends BallotWizardFragment implements Ballo
 	@Override
 	public void onMissingTitle() {
 		this.textInputLayout.setError(getString(R.string.title_cannot_be_empty));
+		this.editText.setFocusableInTouchMode(true);
+		this.editText.setFocusable(true);
+		this.editText.requestFocus();
 	}
 
 	@Override

+ 114 - 64
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment1.java

@@ -23,7 +23,9 @@ package ch.threema.app.activities.ballot;
 
 import android.os.Bundle;
 import android.text.Editable;
+import android.text.InputType;
 import android.text.TextWatcher;
+import android.text.format.DateFormat;
 import android.text.format.DateUtils;
 import android.view.KeyEvent;
 import android.view.LayoutInflater;
@@ -34,9 +36,11 @@ import android.widget.EditText;
 import android.widget.ImageButton;
 import android.widget.TextView;
 
+import com.google.android.material.datepicker.MaterialDatePicker;
+import com.google.android.material.timepicker.MaterialTimePicker;
+
 import java.util.Calendar;
 import java.util.Collections;
-import java.util.Date;
 import java.util.List;
 
 import androidx.annotation.NonNull;
@@ -45,26 +49,29 @@ import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.adapters.ballot.BallotWizard1Adapter;
-import ch.threema.app.dialogs.DateSelectorDialog;
-import ch.threema.app.dialogs.TimeSelectorDialog;
+import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 
-public class BallotWizardFragment1 extends BallotWizardFragment implements DateSelectorDialog.DateSelectorDialogListener, TimeSelectorDialog.TimeSelectorDialogListener, BallotWizardActivity.BallotWizardCallback {
+import static com.google.android.material.timepicker.TimeFormat.CLOCK_12H;
+import static com.google.android.material.timepicker.TimeFormat.CLOCK_24H;
+
+public class BallotWizardFragment1 extends BallotWizardFragment implements BallotWizardActivity.BallotWizardCallback, BallotWizard1Adapter.OnChoiceListener, TextEntryDialog.TextEntryDialogClickListener {
 	private static final String DIALOG_TAG_SELECT_DATE = "selectDate";
 	private static final String DIALOG_TAG_SELECT_TIME = "selectTime";
 	private static final String DIALOG_TAG_SELECT_DATETIME = "selectDateTime";
+	private static final String DIALOG_TAG_EDIT_ANSWER = "editAnswer";
 
 	private RecyclerView choiceRecyclerView;
 	private List<BallotChoiceModel> ballotChoiceModelList;
 	private BallotWizard1Adapter listAdapter = null;
 	private ImageButton createChoiceButton;
-	private ImageButton addDateButton, addDateTimeButton;
 	private EditText createChoiceEditText;
-	private Date originalDate = null;
+	private Long originalTimeInUtc = null;
 	private LinearLayoutManager choiceRecyclerViewLayoutManager;
 	private int lastVisibleBallotPosition;
+	private int editItemPosition = -1;
 
 	@Override
 	public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -167,23 +174,52 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 		});
 		this.createChoiceButton.setEnabled(false);
 
-		this.addDateButton = rootView.findViewById(R.id.add_date);
-		this.addDateButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				DateSelectorDialog dialog = DateSelectorDialog.newInstance(originalDate);
-				dialog.setTargetFragment(BallotWizardFragment1.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_SELECT_DATE);
+		ImageButton addDateButton = rootView.findViewById(R.id.add_date);
+		addDateButton.setOnClickListener(v -> {
+			final MaterialDatePicker<Long> datePicker = MaterialDatePicker.Builder.datePicker()
+				.setTitleText(R.string.select_date)
+				.setSelection(originalTimeInUtc != null ? originalTimeInUtc : MaterialDatePicker.todayInUtcMilliseconds())
+				.build();
+			datePicker.addOnPositiveButtonClickListener(selection -> {
+				Long date = datePicker.getSelection();
+				if (date != null) {
+					originalTimeInUtc = date;
+					createDateChoice(false);
+				}
+			});
+			if (isAdded()) {
+				datePicker.show(getParentFragmentManager(), DIALOG_TAG_SELECT_DATE);
 			}
 		});
 
-		this.addDateTimeButton = rootView.findViewById(R.id.add_time);
-		this.addDateTimeButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				DateSelectorDialog dialog = DateSelectorDialog.newInstance(originalDate);
-				dialog.setTargetFragment(BallotWizardFragment1.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_SELECT_DATETIME);
+		ImageButton addDateTimeButton = rootView.findViewById(R.id.add_time);
+		addDateTimeButton.setOnClickListener(v -> {
+			final MaterialDatePicker<Long> datePicker = MaterialDatePicker.Builder.datePicker()
+				.setTitleText(R.string.select_date)
+				.setSelection(originalTimeInUtc != null ? originalTimeInUtc : MaterialDatePicker.todayInUtcMilliseconds())
+				.build();
+			datePicker.addOnPositiveButtonClickListener(selection -> {
+				Long date = datePicker.getSelection();
+				if (date != null) {
+					originalTimeInUtc = date;
+					final MaterialTimePicker timePicker = new MaterialTimePicker.Builder()
+						.setTitleText(R.string.select_time)
+						.setHour(0)
+						.setMinute(0)
+						.setTimeFormat(DateFormat.is24HourFormat(getContext()) ? CLOCK_24H : CLOCK_12H)
+						.build();
+					timePicker.addOnPositiveButtonClickListener(v1 -> {
+						originalTimeInUtc += timePicker.getHour() * DateUtils.HOUR_IN_MILLIS;
+						originalTimeInUtc += timePicker.getMinute() * DateUtils.MINUTE_IN_MILLIS;
+						createDateChoice(true);
+					});
+					if (isAdded()) {
+						timePicker.show(getParentFragmentManager(), DIALOG_TAG_SELECT_TIME);
+					}
+				}
+			});
+			if (isAdded()) {
+				datePicker.show(getParentFragmentManager(), DIALOG_TAG_SELECT_DATETIME);
 			}
 		});
 
@@ -192,23 +228,76 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 		return rootView;
 	}
 
+	private void createDateChoice(boolean showTime) {
+		if (createChoiceEditText != null) {
+			int format = DateUtils.FORMAT_UTC | DateUtils.FORMAT_ABBREV_WEEKDAY | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE;
+			if (showTime) {
+				format |= DateUtils.FORMAT_SHOW_TIME;
+			}
+			if (!isSameYear(originalTimeInUtc)) {
+				format |= DateUtils.FORMAT_SHOW_YEAR;
+			}
+			String dateString = DateUtils.formatDateTime(getContext(), originalTimeInUtc, format);
+			createChoiceEditText.setText(dateString);
+			createChoice();
+		}
+	}
+
 	private void initAdapter() {
 		if(this.getBallotActivity() != null) {
 			this.ballotChoiceModelList = this.getBallotActivity().getBallotChoiceModelList();
 			this.listAdapter = new BallotWizard1Adapter(this.ballotChoiceModelList);
-			this.listAdapter.setOnChoiceListener(this::removeChoice);
+			this.listAdapter.setOnChoiceListener(this);
 			this.choiceRecyclerView.setAdapter(this.listAdapter);
 		}
 	}
 
-	private void removeChoice(int position) {
+	@Override
+	public void onEditClicked(int position) {
+		this.editItemPosition = position;
+		TextEntryDialog alertDialog = TextEntryDialog.newInstance(
+			R.string.edit_answer, 0,
+			R.string.ok,
+			R.string.cancel,
+			ballotChoiceModelList.get(position).getName(),
+			InputType.TYPE_CLASS_TEXT,
+			TextEntryDialog.INPUT_FILTER_TYPE_NONE,
+			5);
+		alertDialog.setTargetFragment(this, 0);
+		alertDialog.show(getFragmentManager(), DIALOG_TAG_EDIT_ANSWER);
+	}
+
+	@Override
+	public void onYes(String tag, String text) {
+		if (!TestUtil.empty(text)) {
+			synchronized (ballotChoiceModelList) {
+				if (editItemPosition != -1) {
+					ballotChoiceModelList.get(editItemPosition).setName(text);
+					listAdapter.notifyItemChanged(editItemPosition);
+				}
+				editItemPosition = -1;
+			}
+		}
+		createChoiceEditText.requestFocus();
+	}
+
+	@Override
+	public void onNeutral(String tag) {
+	}
+
+	@Override
+	public void onNo(String tag) {
+		createChoiceEditText.requestFocus();
+	}
+
+	@Override
+	public void onRemoveClicked(int position) {
 		synchronized (ballotChoiceModelList) {
 			ballotChoiceModelList.remove(position);
 			listAdapter.notifyItemRemoved(position);
 		}
 	}
 
-
 	/**
 	 * Create a new Choice with a Input Alert.
 	 */
@@ -255,48 +344,9 @@ public class BallotWizardFragment1 extends BallotWizardFragment implements DateS
 		initAdapter();
 	}
 
-	@Override
-	public void onDateSet(String tag, Date date) {
-		if (date != null) {
-			originalDate = date;
-
-			if (DIALOG_TAG_SELECT_DATETIME.equals(tag)) {
-				TimeSelectorDialog dialog = TimeSelectorDialog.newInstance(date);
-				dialog.setTargetFragment(BallotWizardFragment1.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_SELECT_TIME);
-			} else if (DIALOG_TAG_SELECT_DATE.equals(tag) && this.createChoiceEditText != null) {
-				int format = DateUtils.FORMAT_ABBREV_WEEKDAY | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE;
-				if (!isSameYear(date)) {
-					format |= DateUtils.FORMAT_SHOW_YEAR;
-				}
-				String dateString = DateUtils.formatDateTime(getActivity(), date.getTime(), format);
-
-				this.createChoiceEditText.setText(dateString);
-				createChoice();
-			}
-		}
-	}
-
-	@Override
-	public void onTimeSet(String tag, Date date) {
-		if (this.createChoiceEditText != null && date != null) {
-			// date and time
-			int format = DateUtils.FORMAT_ABBREV_WEEKDAY | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME;
-			if (!isSameYear(date)) {
-				format |= DateUtils.FORMAT_SHOW_YEAR;
-			}
-			String dateString = DateUtils.formatDateTime(getActivity(), date.getTime(), format);
-			this.createChoiceEditText.setText(dateString);
-			createChoice();
-		}
-	}
-
-	@Override
-	public void onCancel(String tag, Date date) {}
-
-	private boolean isSameYear(Date date) {
+	private boolean isSameYear(long dateInMillis) {
 		Calendar cal = Calendar.getInstance();
-		cal.setTime(date);
+		cal.setTimeInMillis(dateInMillis);
 		Calendar cal1 = Calendar.getInstance();
 
 		return cal1.get(Calendar.YEAR) == cal.get(Calendar.YEAR);

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

@@ -321,7 +321,8 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 
 					if (file != null) {
 						performDataBackupRestore(file);
-						file.deleteOnExit();
+					} else {
+						SimpleStringAlertDialog.newInstance(R.string.importing_files, R.string.importing_files_failed).show(getSupportFragmentManager(), "ifail");
 					}
 				});
 			}).start();

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

@@ -43,6 +43,7 @@ import java.util.Map;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
 import ch.threema.app.adapters.decorators.AnimGifChatAdapterDecorator;
@@ -213,6 +214,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	 * remove the contact saved stuff and update the list
 	 * @param contactModel
 	 */
+	@UiThread
 	public void resetCachedContactModelData(ContactModel contactModel) {
 		if(contactModel != null && this.decoratorHelper != null) {
 			if(this.decoratorHelper.getContactCache().remove(contactModel.getIdentity())
@@ -278,19 +280,17 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					case VOICEMESSAGE:
 						return o ? TYPE_AUDIO_SEND : TYPE_AUDIO_RECV;
 					case FILE:
-						if (m.getFileData() != null) {
-							String mimeType = m.getFileData().getMimeType();
-							int renderingType = m.getFileData().getRenderingType();
-							if (MimeUtil.isGifFile(mimeType)) {
-								return o ? TYPE_ANIMGIF_SEND : TYPE_ANIMGIF_RECV;
-							} else if (MimeUtil.isAudioFile(mimeType) && renderingType == FileData.RENDERING_MEDIA) {
-								return o ? TYPE_AUDIO_SEND : TYPE_AUDIO_RECV;
-							} else if (renderingType == FileData.RENDERING_MEDIA || renderingType == FileData.RENDERING_STICKER) {
-								if (MimeUtil.isImageFile(mimeType)) {
-									return o ? TYPE_FILE_MEDIA_SEND : TYPE_FILE_MEDIA_RECV;
-								} else if (MimeUtil.isVideoFile(mimeType)) {
-									return o ? TYPE_FILE_VIDEO_SEND : TYPE_FILE_MEDIA_RECV;
-								}
+						String mimeType = m.getFileData().getMimeType();
+						int renderingType = m.getFileData().getRenderingType();
+						if (MimeUtil.isGifFile(mimeType)) {
+							return o ? TYPE_ANIMGIF_SEND : TYPE_ANIMGIF_RECV;
+						} else if (MimeUtil.isAudioFile(mimeType) && renderingType == FileData.RENDERING_MEDIA) {
+							return o ? TYPE_AUDIO_SEND : TYPE_AUDIO_RECV;
+						} else if (renderingType == FileData.RENDERING_MEDIA || renderingType == FileData.RENDERING_STICKER) {
+							if (MimeUtil.isImageFile(mimeType)) {
+								return o ? TYPE_FILE_MEDIA_SEND : TYPE_FILE_MEDIA_RECV;
+							} else if (MimeUtil.isVideoFile(mimeType)) {
+								return o ? TYPE_FILE_VIDEO_SEND : TYPE_FILE_MEDIA_RECV;
 							}
 						}
 						return o ? TYPE_FILE_SEND : TYPE_FILE_RECV;

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

@@ -21,6 +21,7 @@
 
 package ch.threema.app.adapters;
 
+import android.Manifest;
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.drawable.Drawable;
@@ -28,10 +29,8 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.CheckBox;
-import android.widget.CompoundButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
-import android.widget.RelativeLayout;
 import android.widget.TextView;
 
 import org.slf4j.Logger;
@@ -39,18 +38,19 @@ import org.slf4j.LoggerFactory;
 
 import java.util.List;
 
+import androidx.annotation.NonNull;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FingerPrintService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
+import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.utils.AndroidContactUtil;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
-import ch.threema.app.utils.NameUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
@@ -60,14 +60,14 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private static final int TYPE_HEADER = 0;
 	private static final int TYPE_ITEM = 1;
 
-	private Context context;
+	private final Context context;
 	private GroupService groupService;
+	private PreferenceService preferenceService;
 	private FingerPrintService fingerprintService;
 	private IdListService excludeFromSyncListService;
-	private ContactService contactService;
 	private IdListService blackListIdentityService;
-	private ContactModel contactModel;
-	private List<GroupModel> values;
+	private final ContactModel contactModel;
+	private final List<GroupModel> values;
 	private OnClickListener onClickListener;
 
 	public static class ItemHolder extends RecyclerView.ViewHolder {
@@ -87,14 +87,11 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	public class HeaderHolder extends RecyclerView.ViewHolder {
 		private final VerificationLevelImageView verificationLevelImageView;
 		private final TextView threemaIdView, fingerprintView;
-		private final ImageView linkedContactAvatar, verificationLevelIconView;
-		private final TextView linkedContactName;
-		private final ImageView linkedContactTypeIcon;
 		private final CheckBox synchronize;
-		private final View nicknameContainer;
+		private final View nicknameContainer, synchronizeContainer;
+		private final ImageView syncSourceIcon;
 		private final TextView publicNickNameView;
 		private final LinearLayout groupMembershipTitle;
-		private final RelativeLayout linkedContactContainer;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -103,26 +100,15 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			this.threemaIdView = itemView.findViewById(R.id.threema_id);
 			this.fingerprintView = itemView.findViewById(R.id.key_fingerprint);
 			this.verificationLevelImageView = itemView.findViewById(R.id.verification_level_image);
-			this.verificationLevelIconView = itemView.findViewById(R.id.verification_information_icon);
-			this.linkedContactContainer = itemView.findViewById(R.id.linked_contact_container);
-			this.linkedContactAvatar = itemView.findViewById(R.id.linked_contact);
-			this.linkedContactName = itemView.findViewById(R.id.linked_name);
-			this.linkedContactTypeIcon = itemView.findViewById(R.id.linked_type_icon);
+			ImageView verificationLevelIconView = itemView.findViewById(R.id.verification_information_icon);
 			this.synchronize = itemView.findViewById(R.id.synchronize_contact);
+			this.synchronizeContainer = itemView.findViewById(R.id.synchronize_contact_container);
 			this.nicknameContainer = itemView.findViewById(R.id.nickname_container);
 			this.publicNickNameView = itemView.findViewById(R.id.public_nickname);
 			this.groupMembershipTitle = itemView.findViewById(R.id.group_members_title_container);
+			this.syncSourceIcon = itemView.findViewById(R.id.sync_source_icon);
 
-			this.linkedContactContainer.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					if (onClickListener != null) {
-						onClickListener.onLinkedContactClick(v);
-					}
-				}
-			});
-
-			this.verificationLevelIconView.setOnClickListener(new View.OnClickListener() {
+			verificationLevelIconView.setOnClickListener(new View.OnClickListener() {
 				@Override
 				public void onClick(View v) {
 					if (onClickListener != null) {
@@ -131,7 +117,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				}
 			});
 		}
-
 	}
 
 	public ContactDetailAdapter(Context context, List<GroupModel> values, ContactModel contactModel) {
@@ -143,16 +128,17 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		try {
 			this.groupService = serviceManager.getGroupService();
 			this.fingerprintService = serviceManager.getFingerPrintService();
-			this.contactService = serviceManager.getContactService();
 			this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
+			this.preferenceService = serviceManager.getPreferenceService();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}
 	}
 
+	@NonNull
 	@Override
-	public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
 		if (viewType == TYPE_ITEM) {
 			View v = LayoutInflater.from(parent.getContext())
 					.inflate(R.layout.item_contact_detail, parent, false);
@@ -168,7 +154,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	}
 
 	@Override
-	public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
+	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
 		if (holder instanceof ItemHolder) {
 			ItemHolder itemHolder = (ItemHolder) holder;
 			final GroupModel groupModel = getItem(position);
@@ -181,12 +167,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			} else {
 				itemHolder.statusView.setImageResource(R.drawable.ic_group_filled);
 			}
-			itemHolder.view.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					onClickListener.onItemClick(v, groupModel);
-				}
-			});
+			itemHolder.view.setOnClickListener(v -> onClickListener.onItemClick(v, groupModel));
 		} else {
 			HeaderHolder headerHolder = (HeaderHolder) holder;
 
@@ -212,21 +193,28 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			headerHolder.verificationLevelImageView.setContactModel(contactModel);
 			headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
 
-			if (ContactUtil.isSynchronized(contactModel)) {
-				headerHolder.synchronize.setVisibility(View.VISIBLE);
+			if (preferenceService.isSyncContacts() && ContactUtil.isSynchronized(contactModel) &&
+				ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS)) {
+				headerHolder.synchronizeContainer.setVisibility(View.VISIBLE);
+
+				Drawable icon = AndroidContactUtil.getInstance().getAccountIcon(contactModel);
+				if (icon != null) {
+					headerHolder.syncSourceIcon.setImageDrawable(icon);
+					headerHolder.syncSourceIcon.setVisibility(View.VISIBLE);
+				} else {
+					headerHolder.syncSourceIcon.setVisibility(View.INVISIBLE);
+				}
+
 				headerHolder.synchronize.setChecked(excludeFromSyncListService.has(contactModel.getIdentity()));
-				headerHolder.synchronize.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
-					@Override
-					public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
-						if (isChecked) {
-							excludeFromSyncListService.add(contactModel.getIdentity());
-						} else {
-							excludeFromSyncListService.remove(contactModel.getIdentity());
-						}
+				headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
+					if (isChecked) {
+						excludeFromSyncListService.add(contactModel.getIdentity());
+					} else {
+						excludeFromSyncListService.remove(contactModel.getIdentity());
 					}
 				});
 			} else {
-				headerHolder.synchronize.setVisibility(View.GONE);
+				headerHolder.synchronizeContainer.setVisibility(View.GONE);
 			}
 
 			String nicknameString = contactModel.getPublicNickName();
@@ -236,33 +224,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				headerHolder.nicknameContainer.setVisibility(View.GONE);
 			}
 
-			Bitmap avatar = null;
-			if (ContactUtil.isLinked(contactModel)) {
-				avatar = contactService.getAvatar(contactModel, false);
-
-				headerHolder.linkedContactName.setText(NameUtil.getDisplayName(contactModel));
-
-				if (headerHolder.linkedContactTypeIcon != null) {
-					Drawable icon = AndroidContactUtil.getInstance().getAccountIcon(contactModel.getIdentity());
-					if (icon != null) {
-						headerHolder.linkedContactTypeIcon.setImageDrawable(icon);
-						headerHolder.linkedContactTypeIcon.setVisibility(View.VISIBLE);
-					} else {
-						headerHolder.linkedContactTypeIcon.setVisibility(View.GONE);
-					}
-				}
-			} else {
-				headerHolder.linkedContactName.setText(R.string.touch_to_link);
-				if (headerHolder.linkedContactTypeIcon != null) {
-					headerHolder.linkedContactTypeIcon.setVisibility(View.GONE);
-				}
-			}
-			if (avatar != null) {
-				headerHolder.linkedContactAvatar.setImageBitmap(avatar);
-			} else {
-				headerHolder.linkedContactAvatar.setImageResource(R.drawable.ic_contact);
-			}
-
 			if (values.size() > 0) {
 				headerHolder.groupMembershipTitle.setVisibility(View.VISIBLE);
 			} else {
@@ -297,7 +258,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	}
 
 	public interface OnClickListener {
-		void onLinkedContactClick(View v);
 		void onItemClick(View v, GroupModel groupModel);
 		void onVerificationInfoClick(View v);
 	}

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

@@ -33,6 +33,7 @@ import android.widget.ImageView;
 import android.widget.SectionIndexer;
 import android.widget.TextView;
 
+import org.jetbrains.annotations.NotNull;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -84,14 +85,14 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 	private List<ContactModel> values, ovalues, recentlyAdded = new ArrayList<>();
 	private ContactListFilter contactListFilter;
-	private AvatarListener avatarListener;
-	private Bitmap defaultContactImage;
+	private final AvatarListener avatarListener;
+	private final Bitmap defaultContactImage;
 	private final HashMap<String, Integer> alphaIndexer = new HashMap<String, Integer>();
 	private final HashMap<Integer, String> positionIndexer = new HashMap<Integer, String>();
 	private String[] sections;
 	private Integer[] counts;
-	private LayoutInflater inflater;
-	private Collator collator;
+	private final LayoutInflater inflater;
+	private final Collator collator;
 
 	public interface AvatarListener {
 		void onAvatarClick(View view, int position);
@@ -474,6 +475,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		}
 	}
 
+	@NotNull
 	@Override
 	public Filter getFilter() {
 		if (contactListFilter == null)
@@ -522,4 +524,9 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		}
 		return "";
 	}
+
+	@Override
+	public boolean isEmpty() {
+		return values != null && getCount() == 0;
+	}
 }

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

@@ -67,9 +67,9 @@ public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
 	private static final Logger logger = LoggerFactory.getLogger(MediaGalleryAdapter.class);
 
 	private final List<AbstractMessageModel> values;
-	private FileService fileService;
-	private ThumbnailCache thumbnailCache;
-	private LayoutInflater layoutInflater;
+	private final FileService fileService;
+	private final ThumbnailCache thumbnailCache;
+	private final LayoutInflater layoutInflater;
 	private final List<Integer> brokenThumbnails = new ArrayList<Integer>();
 	@ColorInt private final int foregroundColor;
 
@@ -94,7 +94,7 @@ public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
 		this.foregroundColor = ConfigUtils.getColorFromAttribute(context, R.attr.textColorSecondary);
 	}
 
-	private class MediaGalleryHolder extends AbstractListItemHolder {
+	private static class MediaGalleryHolder extends AbstractListItemHolder {
 		public ImageView imageView;
 		public ControllerView playButton;
 		public ProgressBar progressBar;

+ 29 - 3
app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java

@@ -58,6 +58,7 @@ import ch.threema.app.ui.AvatarListItemUtil;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.CountBoxView;
 import ch.threema.app.ui.DebouncedOnClickListener;
+import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -99,6 +100,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 	private final ItemClickListener clickListener;
 	private final List<ConversationModel> selectedChats = new ArrayList<>();
 	private String highlightUid;
+	private RecyclerView recyclerView;
 
 	private final TagModel starTagModel, unreadTagModel;
 
@@ -512,17 +514,37 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			// footer
 			Chip archivedChip = h.itemView.findViewById(R.id.archived_text);
 
-			int count = conversationService.getArchivedCount();
-			if (count > 0) {
+			int archivedCount = conversationService.getArchivedCount();
+			if (archivedCount > 0) {
 				archivedChip.setVisibility(View.VISIBLE);
 				archivedChip.setOnClickListener(v -> clickListener.onFooterClick(v));
-				archivedChip.setText(String.format(context.getString(R.string.num_archived_chats), count));
+				archivedChip.setText(String.format(context.getString(R.string.num_archived_chats), archivedCount));
+				if (recyclerView != null) {
+					((EmptyRecyclerView) recyclerView).setNumHeadersAndFooters(0);
+				}
 			} else {
 				archivedChip.setVisibility(View.GONE);
+				if (recyclerView != null) {
+					((EmptyRecyclerView) recyclerView).setNumHeadersAndFooters(1);
+				}
 			}
 		}
 	}
 
+	@Override
+	public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+		super.onAttachedToRecyclerView(recyclerView);
+
+		this.recyclerView = recyclerView;
+	}
+
+	@Override
+	public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+		this.recyclerView = null;
+
+		super.onDetachedFromRecyclerView(recyclerView);
+	}
+
 	public void toggleItemChecked(ConversationModel model, int position) {
 		if (selectedChats.contains(model)) {
 			selectedChats.remove(model);
@@ -541,6 +563,10 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		return selectedChats.size();
 	}
 
+	public void refreshFooter() {
+		notifyItemChanged(getItemCount() - 1);
+	}
+
 	public List<ConversationModel> getCheckedItems() {
 		return selectedChats;
 	}

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

@@ -40,6 +40,7 @@ import ch.threema.storage.models.ballot.BallotChoiceModel;
 public class BallotWizard1Adapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 
 	public interface OnChoiceListener {
+		void onEditClicked(int position);
 		void onRemoveClicked(int position);
 	}
 
@@ -47,11 +48,13 @@ public class BallotWizard1Adapter extends RecyclerView.Adapter<RecyclerView.View
 
 		public TextView name;
 		public ImageView removeButton;
+		public ImageView editButton;
 
 		public BallotAdminChoiceItemHolder(@NonNull View itemView) {
 			super(itemView);
 			name = itemView.findViewById(R.id.choice_name_readonly);
 			removeButton = itemView.findViewById(R.id.remove_button);
+			editButton = itemView.findViewById(R.id.edit_button);
 		}
 
 		public void bind(BallotChoiceModel choiceModel, OnChoiceListener onChoiceListener) {
@@ -64,8 +67,15 @@ public class BallotWizard1Adapter extends RecyclerView.Adapter<RecyclerView.View
 						}
 					});
 					removeButton.setVisibility(View.VISIBLE);
+					editButton.setOnClickListener(view -> {
+						if (onChoiceListener != null) {
+							onChoiceListener.onEditClicked(getAdapterPosition());
+						}
+					});
+					editButton.setVisibility(View.VISIBLE);
 				} else {
 					removeButton.setVisibility(View.GONE);
+					editButton.setVisibility(View.GONE);
 				}
 			}
 		}

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

@@ -129,7 +129,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		private int thumbnailWidth;
 		private final Fragment fragment;
 		protected int regularColor;
-		private final Map<String, ContactCache> contacts = new HashMap<String, ContactCache>();
+		private final Map<String, ContactCache> contacts = new HashMap<>();
 		private final Drawable stopwatchIcon;
 		private final int maxBubbleTextLength;
 		private final int maxQuoteTextLength;

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

@@ -122,7 +122,8 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 		if (content != null) {
 			if (holder.secondaryTextView instanceof EmojiConversationTextView) {
 				holder.secondaryTextView.setText(formatTextString(content.quotedText, this.filterString, helper.getMaxQuoteTextLength() + 8));
-				((EmojiConversationTextView) holder.secondaryTextView).setFade(content.quotedText.length() > helper.getMaxQuoteTextLength());
+				((EmojiConversationTextView) holder.secondaryTextView).setFade(
+					content.quotedText != null && content.quotedText.length() > helper.getMaxQuoteTextLength());
 			}
 
 			ContactModel contactModel = this.helper.getContactService().getByIdentity(content.identity);

+ 15 - 2
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -45,6 +45,7 @@ import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.activities.ThreemaToolbarActivity;
 import ch.threema.app.asynctasks.DeleteConversationsAsyncTask;
@@ -59,6 +60,7 @@ import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
@@ -114,12 +116,19 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		MaterialToolbar toolbar = findViewById(R.id.material_toolbar);
 		toolbar.setNavigationOnClickListener(view -> finish());
 		toolbar.setTitle(R.string.archived_chats);
+
+		String filterQuery = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_ARCHIVE_FILTER);
+
 		MenuItem filterMenu = toolbar.getMenu().findItem(R.id.menu_filter_archive);
 		ThreemaSearchView searchView = (ThreemaSearchView) filterMenu.getActionView();
 
 		if (searchView != null) {
 			searchView.setQueryHint(getString(R.string.hint_filter_list));
-			searchView.setOnQueryTextListener(this);
+			if (!TestUtil.empty(filterQuery)) {
+				filterMenu.expandActionView();
+				searchView.setQuery(filterQuery, false);
+			}
+			searchView.post(() -> searchView.setOnQueryTextListener(ArchiveActivity.this));
 		} else {
 			filterMenu.setVisible(false);
 		}
@@ -183,7 +192,11 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 
 		// Observe the LiveData, passing in this activity as the LifecycleOwner and the observer.
 		viewModel.getConversationModels().observe(this, conversationsObserver);
-		viewModel.onDataChanged();
+		if (!TestUtil.empty(filterQuery)) {
+			viewModel.filter(filterQuery);
+		} else {
+			viewModel.onDataChanged();
+		}
 
 		return true;
 	}

+ 8 - 6
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -555,7 +555,7 @@ public class BackupService extends Service {
 						.write(Tags.TAG_CONTACT_IDENTITY, contactModel.getIdentity())
 						.write(Tags.TAG_CONTACT_PUBLIC_KEY, Utils.byteArrayToHexString(contactModel.getPublicKey()))
 						.write(Tags.TAG_CONTACT_VERIFICATION_LEVEL, contactModel.getVerificationLevel().toString())
-						.write(Tags.TAG_CONTACT_ANDROID_CONTACT_ID, contactModel.getAndroidContactId())
+						.write(Tags.TAG_CONTACT_ANDROID_CONTACT_ID, contactModel.getAndroidContactLookupKey())
 						.write(Tags.TAG_CONTACT_THREEMA_ANDROID_CONTACT_ID, contactModel.getThreemaAndroidContactId())
 						.write(Tags.TAG_CONTACT_FIRST_NAME, contactModel.getFirstName())
 						.write(Tags.TAG_CONTACT_LAST_NAME, contactModel.getLastName())
@@ -568,11 +568,13 @@ public class BackupService extends Service {
 					// Back up contact profile pictures
 					if (this.config.backupAvatars()) {
 						try {
-							ZipUtil.addZipStream(
-								zipOutputStream,
-								this.fileService.getContactAvatarStream(contactModel),
-								Tags.CONTACT_AVATAR_FILE_PREFIX + contactModel.getIdentity()
-							);
+							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
+								ZipUtil.addZipStream(
+									zipOutputStream,
+									this.fileService.getContactAvatarStream(contactModel),
+									Tags.CONTACT_AVATAR_FILE_PREFIX + contactModel.getIdentity()
+								);
+							}
 						} catch (IOException e) {
 							// avatars are not THAT important, so we don't care if adding them fails
 							logger.warn("Could not back up avatar for contact {}: {}", contactModel.getIdentity(), e.getMessage());

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

@@ -72,6 +72,7 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.notifications.NotificationBuilderWrapper;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.BackupUtils;
@@ -85,6 +86,7 @@ import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.VerificationLevel;
+import ch.threema.client.GroupId;
 import ch.threema.client.ProtocolDefines;
 import ch.threema.client.ThreemaConnection;
 import ch.threema.client.Utils;
@@ -124,6 +126,7 @@ public class RestoreService extends Service {
 	private ContactService contactService;
 	private FileService fileService;
 	private UserService userService;
+	private GroupService groupService;
 	private DatabaseServiceNew databaseServiceNew;
 	private PreferenceService preferenceService;
 	private ThreemaConnection threemaConnection;
@@ -269,6 +272,7 @@ public class RestoreService extends Service {
 			databaseServiceNew = serviceManager.getDatabaseServiceNew();
 			contactService = serviceManager.getContactService();
 			userService = serviceManager.getUserService();
+			groupService = serviceManager.getGroupService();
 			preferenceService = serviceManager.getPreferenceService();
 			threemaConnection = serviceManager.getConnection();
 		} catch (Exception e) {
@@ -878,6 +882,14 @@ public class RestoreService extends Service {
 						for (GroupMemberModel groupMemberModel : groupMemberModels) {
 							databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
 						}
+
+						if (!groupModel.isDeleted()) {
+							if (groupService.isGroupOwner(groupModel)) {
+								groupService.sendSync(groupModel);
+							} else {
+								groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId())));
+							}
+						}
 					}
 				} catch (Exception x) {
 					if (writeToDb) {

+ 0 - 5
app/src/main/java/ch/threema/app/dialogs/CancelableHorizontalProgressDialog.java

@@ -51,7 +51,6 @@ public class CancelableHorizontalProgressDialog extends ThreemaDialogFragment {
 	private TextView progressPercent;
 	private ProgressBar progressBar;
 	private int max;
-	private Object object;
 
 	/**
 	 * Creates a DialogFragment with a horizontal progress bar and a percentage display below. Mimics deprecated system ProgressDialog behavior
@@ -115,10 +114,6 @@ public class CancelableHorizontalProgressDialog extends ThreemaDialogFragment {
 		this.activity = activity;
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	@NonNull
 	@Override
 	public Dialog onCreateDialog(Bundle savedInstanceState) {

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

@@ -1,183 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2016-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.dialogs;
-
-import android.app.Activity;
-import android.app.DatePickerDialog;
-import android.app.Dialog;
-import android.content.Context;
-import android.content.ContextWrapper;
-import android.content.DialogInterface;
-import android.content.res.Resources;
-import android.graphics.drawable.ColorDrawable;
-import android.os.Build;
-import android.os.Bundle;
-import android.widget.DatePicker;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Calendar;
-import java.util.Date;
-import java.util.IllegalFormatConversionException;
-
-import androidx.annotation.NonNull;
-import ch.threema.app.R;
-import ch.threema.app.utils.ConfigUtils;
-
-public class DateSelectorDialog extends ThreemaDialogFragment implements DatePickerDialog.OnDateSetListener {
-	private static final Logger logger = LoggerFactory.getLogger(DateSelectorDialog.class);
-
-	private Activity activity;
-	private DateSelectorDialogListener callback;
-	private Calendar calendar;
-	private Date originalDate;
-
-	public interface DateSelectorDialogListener {
-		void onDateSet(String tag, Date date);
-		void onCancel(String tag, Date date);
-	}
-
-	public static DateSelectorDialog newInstance(Date date) {
-		DateSelectorDialog dialog = new DateSelectorDialog();
-
-		Bundle args = new Bundle();
-		args.putSerializable("date", date);
-
-		dialog.setArguments(args);
-		return dialog;
-	}
-
-	@Override
-	public void onAttach(Activity activity) {
-		super.onAttach(activity);
-
-		this.activity = activity;
-	}
-
-	@Override
-	public void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		if (callback == null) {
-			try {
-				callback = (DateSelectorDialogListener) getTargetFragment();
-			} catch (ClassCastException e) {
-				//
-			}
-
-			// called from an activity rather than a fragment
-			if (callback == null) {
-				if (!(activity instanceof SelectorDialog.SelectorDialogClickListener)) {
-					throw new ClassCastException("Calling fragment must implement DateSelectorDialogClickListener interface");
-				}
-				callback = (DateSelectorDialogListener) activity;
-			}
-		}
-	}
-
-	@NonNull
-	@Override
-	public Dialog onCreateDialog(Bundle savedInstanceState) {
-		originalDate = (Date) getArguments().getSerializable("date");
-
-		calendar = Calendar.getInstance();
-
-		if (originalDate != null) {
-			calendar.setTime(originalDate);
-		}
-
-		int day = calendar.get(Calendar.DAY_OF_MONTH);
-		int month = calendar.get(Calendar.MONTH);
-		int year = calendar.get(Calendar.YEAR);
-
-		int style = R.style.Theme_Threema_Dialog;
-		if (ConfigUtils.getAppTheme(activity) == ConfigUtils.THEME_DARK) {
-			style = R.style.Theme_Threema_Dialog_Dark;
-		}
-
-		Context context = activity;
-		if (isBrokenSamsungDevice()) {
-			// workaround for http://stackoverflow.com/questions/28618405/datepicker-crashes-on-my-device-when-clicked-with-personal-app
-			context = new ContextWrapper(getActivity()) {
-
-				private Resources wrappedResources;
-
-				@Override
-				public Resources getResources() {
-					Resources r = super.getResources();
-					if(wrappedResources == null) {
-						wrappedResources = new Resources(r.getAssets(), r.getDisplayMetrics(), r.getConfiguration()) {
-							@NonNull
-							@Override
-							public String getString(int id, Object... formatArgs) throws Resources.NotFoundException {
-								try {
-									return super.getString(id, formatArgs);
-								} catch (IllegalFormatConversionException ifce) {
-									logger.debug("IllegalFormatConversionException om Samsung devices fixed.");
-									String template = super.getString(id);
-									template = template.replaceAll("%" + ifce.getConversion(), "%s");
-									return String.format(getConfiguration().locale, template, formatArgs);
-								}
-							}
-						};
-					}
-					return wrappedResources;
-				}
-			};
-		}
-
-		DatePickerDialog dialog = new DatePickerDialog(context, style, this, year, month, day);
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-			dialog.getWindow().setBackgroundDrawable(new ColorDrawable(0));
-		}
-
-		dialog.setButton(DialogInterface.BUTTON_NEGATIVE,
-				getActivity().getString(android.R.string.cancel),
-				new DialogInterface.OnClickListener() {
-					@Override
-					public void onClick(DialogInterface dialogInterface, int which) {
-						callback.onCancel(getTag(), originalDate);
-					}
-				});
-
-		return dialog;
-	}
-
-	@Override
-	public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) {
-		// see http://code.google.com/p/android/issues/detail?id=34833
-		if (view.isShown() && callback != null && calendar != null) {
-			calendar.clear();
-			calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth);
-			calendar.set(Calendar.MONTH, month);
-			calendar.set(Calendar.YEAR, year);
-			callback.onDateSet(this.getTag(), calendar.getTime());
-		}
-	}
-
-	private static boolean isBrokenSamsungDevice() {
-		return (Build.MANUFACTURER.equalsIgnoreCase("samsung") &&
-				Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
-				Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1);
-	}
-}

+ 12 - 15
app/src/main/java/ch/threema/app/dialogs/ExpandableTextEntryDialog.java

@@ -29,6 +29,7 @@ import android.text.Editable;
 import android.text.TextWatcher;
 import android.view.View;
 import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
@@ -43,14 +44,12 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ui.ComposeEditText;
 import ch.threema.app.utils.AnimationUtil;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
 
 public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 	private ExpandableTextEntryDialogClickListener callback;
 	private Activity activity;
 	private AlertDialog alertDialog;
-	private Object object;
 
 	public static ExpandableTextEntryDialog newInstance(String title, int hint, int positive, int negative, boolean expandable) {
 		ExpandableTextEntryDialog dialog = new ExpandableTextEntryDialog();
@@ -84,10 +83,6 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 		void onNo(String tag);
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
@@ -135,8 +130,6 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 		final ImageView expandButton = dialogView.findViewById(R.id.expand_button);
 		final LinearLayout addCaptionLayout = dialogView.findViewById(R.id.add_caption_intro);
 
-		ConfigUtils.themeImageView(activity, expandButton);
-
 		addCaptionLayout.setClickable(true);
 		addCaptionLayout.setOnClickListener(new View.OnClickListener() {
 			@Override
@@ -158,7 +151,7 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 			public void afterTextChanged(Editable s) {}
 		});
 
-		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(),  R.style.Threema_AlertDialog_MediaPicker);
+		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity,  getTheme());
 		builder.setView(dialogView);
 
 		if (!TestUtil.empty(title)) {
@@ -200,23 +193,27 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 
 	private void toggleLayout(ImageView button, View v) {
 		InputMethodManager imm = (InputMethodManager)v.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+		EditText editText = v.findViewById(R.id.caption_edittext);
 
 		if(v.isShown()){
 			AnimationUtil.slideUp(activity, v);
 			v.setVisibility(View.GONE);
 			button.setRotation(0);
-			if (imm != null) {
+			if (imm != null && editText != null) {
 				imm.hideSoftInputFromWindow(v.getWindowToken(), 0);
 			}
 		}
 		else{
 			v.setVisibility(View.VISIBLE);
-			AnimationUtil.slideDown(activity, v);
+			AnimationUtil.slideDown(activity, v, () -> {
+				if (editText != null) {
+					editText.requestFocus();
+					if (imm != null) {
+						imm.showSoftInput(editText, 0);
+					}
+				}
+			});
 			button.setRotation(90);
-			v.requestFocus();
-			if (imm != null) {
-				imm.showSoftInput(v, 0);
-			}
 		}
 	}
 }

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

@@ -37,13 +37,11 @@ import androidx.annotation.StringRes;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import androidx.fragment.app.Fragment;
-import ch.threema.app.R;
 import ch.threema.app.utils.TestUtil;
 
 public class GenericAlertDialog extends ThreemaDialogFragment {
 	private DialogClickListener callback;
 	private Activity activity;
-	private Object object;
 	private AlertDialog alertDialog;
 	private boolean isHtml;
 
@@ -189,7 +187,7 @@ public class GenericAlertDialog extends ThreemaDialogFragment {
 
 		final String tag = this.getTag();
 
-		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), R.style.Threema_AlertDialog_MediaPicker);
+		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme());
 		if (TestUtil.empty(titleString)) {
 			builder.setTitle(title);
 		} else {
@@ -226,11 +224,6 @@ public class GenericAlertDialog extends ThreemaDialogFragment {
 		callback.onNo(getTag(), object);
 	}
 
-	public GenericAlertDialog setData(Object o) {
-		object = o;
-		return this;
-	}
-
 	public GenericAlertDialog setTargetFragment(@Nullable Fragment fragment) {
 		setTargetFragment(fragment, 0);
 		return this;

+ 0 - 5
app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java

@@ -59,7 +59,6 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	protected boolean isLengthCheck = true;
 	protected int minLength, maxLength;
 	protected MaterialCheckBox checkBox;
-	protected Object object;
 
 	public static PasswordEntryDialog newInstance(@StringRes int title, @StringRes int message,
 	                                              @StringRes int hint,
@@ -121,10 +120,6 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 		void onNo(String tag);
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);

+ 0 - 5
app/src/main/java/ch/threema/app/dialogs/SelectorDialog.java

@@ -38,7 +38,6 @@ public class SelectorDialog extends ThreemaDialogFragment {
 	private SelectorDialogClickListener callback;
 	private SelectorDialogInlineClickListener inlineCallback;
 	private Activity activity;
-	private Object object;
 	private AlertDialog alertDialog;
 
 	public static SelectorDialog newInstance(String title, ArrayList<String> items, String negative, SelectorDialogInlineClickListener listener) {
@@ -103,10 +102,6 @@ public class SelectorDialog extends ThreemaDialogFragment {
 		void onNo(String tag);
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	@Override
 	public void onAttach(@NonNull Activity activity) {
 		super.onAttach(activity);

+ 19 - 0
app/src/main/java/ch/threema/app/dialogs/TextEntryDialog.java

@@ -76,6 +76,25 @@ public class TextEntryDialog extends ThreemaDialogFragment {
 		return dialog;
 	}
 
+	public static TextEntryDialog newInstance(@StringRes int title, @StringRes int message,
+	                                          @StringRes int positive, @StringRes int negative,
+	                                          String text, int inputType, int inputFilterType,
+	                                          int maxLines) {
+		TextEntryDialog dialog = new TextEntryDialog();
+		Bundle args = new Bundle();
+		args.putInt("title", title);
+		args.putInt("message", message);
+		args.putInt("positive", positive);
+		args.putInt("negative", negative);
+		args.putString("text", text);
+		args.putInt("inputType", inputType);
+		args.putInt("inputFilterType", inputFilterType);
+		args.putInt("maxLines", maxLines);
+
+		dialog.setArguments(args);
+		return dialog;
+	}
+
 	public static TextEntryDialog newInstance(@StringRes int title, @StringRes int message,
 	                                          @StringRes int positive, @StringRes int neutral, @StringRes int negative,
 	                                          String text, int inputType, int inputFilterType, int maxLength) {

+ 117 - 0
app/src/main/java/ch/threema/app/dialogs/TextWithCheckboxDialog.java

@@ -0,0 +1,117 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2018-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.dialogs;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.View;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatDialog;
+import androidx.appcompat.widget.AppCompatCheckBox;
+import ch.threema.app.R;
+
+/**
+ *  A dialog with a title and a checkbox
+ */
+public class TextWithCheckboxDialog extends ThreemaDialogFragment {
+	private TextWithCheckboxDialogClickListener callback;
+	private Activity activity;
+
+	public interface TextWithCheckboxDialogClickListener {
+		void onYes(String tag, Object data, boolean checked);
+	}
+
+	public static TextWithCheckboxDialog newInstance(String message, @StringRes int checkboxLabel, @StringRes int positive, @StringRes int negative) {
+		TextWithCheckboxDialog dialog = new TextWithCheckboxDialog();
+		Bundle args = new Bundle();
+		args.putString("message", message);
+		args.putInt("checkboxLabel", checkboxLabel);
+		args.putInt("positive", positive);
+		args.putInt("negative", negative);
+
+		dialog.setArguments(args);
+		return dialog;
+	}
+
+	@Override
+	public void onAttach(@NonNull Activity activity) {
+		super.onAttach(activity);
+
+		this.activity = activity;
+	}
+
+	@Override
+	public void onCreate(Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		if (callback == null) {
+			try {
+				callback = (TextWithCheckboxDialogClickListener) getTargetFragment();
+			} catch (ClassCastException e) {
+				//
+			}
+
+			// called from an activity rather than a fragment
+			if (callback == null) {
+				if (activity instanceof TextWithCheckboxDialogClickListener) {
+					callback = (TextWithCheckboxDialogClickListener) activity;
+				}
+			}
+		}
+	}
+
+	@NonNull
+	@Override
+	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
+		String message = getArguments().getString("message");
+		@StringRes int checkboxLabel = getArguments().getInt("checkboxLabel");
+		@StringRes int positive = getArguments().getInt("positive");
+		@StringRes int negative = getArguments().getInt("negative");
+
+		final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_text_with_checkbox, null);
+		final AppCompatCheckBox checkbox = dialogView.findViewById(R.id.checkbox);
+
+		final String tag = this.getTag();
+
+		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme())
+			.setTitle(message)
+			.setView(dialogView)
+			.setCancelable(false)
+			.setNegativeButton(negative, null)
+			.setPositiveButton(positive, (dialog, which) -> callback.onYes(tag, object, checkbox.isChecked()));
+
+		checkbox.setChecked(false);
+		if (checkboxLabel != 0) {
+			checkbox.setText(checkboxLabel);
+		} else {
+			checkbox.setVisibility(View.GONE);
+		}
+
+		setCancelable(false);
+
+		return builder.create();
+	}
+}

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

@@ -21,7 +21,6 @@
 
 package ch.threema.app.dialogs;
 
-import android.app.ProgressDialog;
 import android.os.Bundle;
 
 import androidx.annotation.Nullable;
@@ -30,6 +29,8 @@ import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
 
 public class ThreemaDialogFragment extends DialogFragment {
+	protected Object object;
+
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
@@ -72,4 +73,9 @@ public class ThreemaDialogFragment extends DialogFragment {
 			}
 		}
 	}
+
+	public ThreemaDialogFragment setData(Object o) {
+		object = o;
+		return this;
+	}
 }

+ 0 - 134
app/src/main/java/ch/threema/app/dialogs/TimeSelectorDialog.java

@@ -1,134 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2016-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.dialogs;
-
-import android.app.Activity;
-import android.app.Dialog;
-import android.app.TimePickerDialog;
-import android.content.DialogInterface;
-import android.graphics.drawable.ColorDrawable;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.format.DateFormat;
-import android.widget.TimePicker;
-
-import java.util.Calendar;
-import java.util.Date;
-
-import ch.threema.app.R;
-import ch.threema.app.utils.ConfigUtils;
-
-public class TimeSelectorDialog extends ThreemaDialogFragment implements TimePickerDialog.OnTimeSetListener {
-	private Activity activity;
-	private TimeSelectorDialogListener callback;
-	private Calendar calendar;
-	private Date originalDate;
-
-	public interface TimeSelectorDialogListener {
-		void onTimeSet(String tag, Date date);
-		void onCancel(String tag, Date date);
-	}
-
-	public static TimeSelectorDialog newInstance(Date date) {
-		TimeSelectorDialog dialog = new TimeSelectorDialog();
-
-		Bundle args = new Bundle();
-		args.putSerializable("date", date);
-
-		dialog.setArguments(args);
-		return dialog;
-	}
-
-	@Override
-	public void onAttach(Activity activity) {
-		super.onAttach(activity);
-
-		this.activity = activity;
-	}
-
-	@Override
-	public void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		if (callback == null) {
-			try {
-				callback = (TimeSelectorDialog.TimeSelectorDialogListener) getTargetFragment();
-			} catch (ClassCastException e) {
-				//
-			}
-
-			// called from an activity rather than a fragment
-			if (callback == null) {
-				if (!(activity instanceof SelectorDialog.SelectorDialogClickListener)) {
-					throw new ClassCastException("Calling fragment must implement DateSelectorDialogClickListener interface");
-				}
-				callback = (TimeSelectorDialog.TimeSelectorDialogListener) activity;
-			}
-		}
-	}
-
-	@Override
-	public Dialog onCreateDialog(Bundle savedInstanceState) {
-		originalDate = (Date) getArguments().getSerializable("date");
-
-		calendar = Calendar.getInstance();
-		int hour = calendar.get(Calendar.HOUR_OF_DAY);
-		int minute = calendar.get(Calendar.MINUTE);
-		int style = R.style.Theme_Threema_Dialog;
-
-		if (ConfigUtils.getAppTheme(activity) == ConfigUtils.THEME_DARK) {
-			style = R.style.Theme_Threema_Dialog_Dark;
-		}
-		final TimePickerDialog dialog = new TimePickerDialog(activity, style, this, hour, minute,
-				DateFormat.is24HourFormat(activity));
-
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-			dialog.getWindow().setBackgroundDrawable(new ColorDrawable(0));
-		}
-
-		dialog.setButton(DialogInterface.BUTTON_NEGATIVE,
-				getActivity().getString(android.R.string.cancel),
-				new DialogInterface.OnClickListener() {
-					@Override
-					public void onClick(DialogInterface dialogInterface, int which) {
-						callback.onCancel(getTag(), originalDate);
-					}
-				});
-
-		return dialog;
-	}
-
-	@Override
-	public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
-		// see http://code.google.com/p/android/issues/detail?id=34833
-		if (view.isShown() && callback != null && calendar != null) {
-			calendar.clear();
-			if (originalDate != null) {
-				calendar.setTime(originalDate);
-			}
-			calendar.set(Calendar.HOUR_OF_DAY, hourOfDay);
-			calendar.set(Calendar.MINUTE, minute);
-			callback.onTimeSet(this.getTag(), calendar.getTime());
-		}
-	}
-
-}

+ 0 - 5
app/src/main/java/ch/threema/app/dialogs/WizardDialog.java

@@ -41,7 +41,6 @@ public class WizardDialog extends ThreemaDialogFragment {
 
 	private WizardDialogCallback callback;
 	private Activity activity;
-	private Object object;
 
 	public static WizardDialog newInstance(int title, int positive, int negative) {
 		WizardDialog dialog = new WizardDialog();
@@ -76,10 +75,6 @@ public class WizardDialog extends ThreemaDialogFragment {
 		void onNo(String tag);
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);

+ 26 - 6
app/src/main/java/ch/threema/app/fragments/BackupDataFragment.java

@@ -178,10 +178,7 @@ public class BackupDataFragment extends Fragment implements
 			});
 
 			pathTextView = fragmentView.findViewById(R.id.backup_path);
-			pathTextView.setText(
-				backupUri == null ?
-				getString(R.string.not_set) :
-				backupUri.toString());
+			pathTextView.setText(getDirectoryName(backupUri));
 		}
 
 		Date backupDate = preferenceService.getLastDataBackupDate();
@@ -197,12 +194,35 @@ public class BackupDataFragment extends Fragment implements
 		return this.fragmentView;
 	}
 
+	/**
+	 * Get the name of a directory from an Uri selected with Intent.ACTION_OPEN_DOCUMENT_TREE
+	 * @param directoryTreeUri Uri returned by ACTION_OPEN_DOCUMENT_TREE
+	 * @return Name of directory, a localized string "not set" if an empty Uri was provided, or the complete Uri as a String as a fallback
+	 */
+	private @NonNull String getDirectoryName(Uri directoryTreeUri) {
+		if (directoryTreeUri == null) {
+			return getString(R.string.not_set);
+		} else {
+			try {
+				DocumentFile documentFile = DocumentFile.fromTreeUri(getContext(), directoryTreeUri);
+				if (documentFile != null && documentFile.isDirectory()) {
+					String name = documentFile.getName();
+					if (!TestUtil.empty(name)) {
+						return name;
+					}
+				}
+			} catch (Exception ignored) {}
+		}
+		return directoryTreeUri.toString();
+	}
+
 	private void showPathSelectionIntro() {
 		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.set_backup_path, R.string.set_backup_path_intro, R.string.ok, R.string.cancel);
 		dialog.setTargetFragment(this);
 		dialog.show(getFragmentManager(), DIALOG_TAG_PATH_INTRO);
 	}
 
+	@UiThread
 	private void launchDocumentTree() {
 		if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
 			try {
@@ -214,7 +234,7 @@ public class BackupDataFragment extends Fragment implements
 				startActivityForResult(i, REQUEST_CODE_DOCUMENT_TREE);
 			} catch (Exception e) {
 				Toast.makeText(getContext(), "Your device is missing an activity for Intent.ACTION_OPEN_DOCUMENT_TREE. Contact the manufacturer of the device.", Toast.LENGTH_SHORT).show();
-				logger.info("Broken device. No Activity for Intent.ACTION_OPEN_DOCUMENT_TREE");
+				logger.error("Broken device. No Activity for Intent.ACTION_OPEN_DOCUMENT_TREE", e);
 			}
 		} else {
 			Intent intent = new Intent(getContext(), FilePickerActivity.class);
@@ -388,7 +408,7 @@ public class BackupDataFragment extends Fragment implements
 							}
 							backupUri = treeUri;
 							preferenceService.setDataBackupUri(treeUri);
-							pathTextView.setText(treeUri.toString());
+							pathTextView.setText(getDirectoryName(treeUri));
 
 							if (launchedFromFAB) {
 								checkBatteryOptimizations();

+ 103 - 80
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -145,6 +145,7 @@ import ch.threema.app.adapters.ComposeMessageAdapter;
 import ch.threema.app.adapters.decorators.ChatAdapterDecorator;
 import ch.threema.app.asynctasks.EmptyChatAsyncTask;
 import ch.threema.app.cache.ThumbnailCache;
+import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.MessageDetailDialog;
@@ -258,7 +259,8 @@ public class ComposeMessageFragment extends Fragment implements
 	EmojiPicker.EmojiPickerListener,
 	MentionSelectorPopup.MentionSelectorListener,
 	OpenBallotNoticeView.VisibilityListener,
-	ThreemaToolbarActivity.OnSoftKeyboardChangedListener {
+	ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
+	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
 
 	private static final Logger logger = LoggerFactory.getLogger(ComposeMessageFragment.class);
 
@@ -504,13 +506,13 @@ public class ComposeMessageFragment extends Fragment implements
 			if (newMessage != null) {
 				RuntimeUtil.runOnUiThread(() -> {
 					if (newMessage.isOutbox()) {
-						if (addMessageToList(newMessage, true)) {
+						if (addMessageToList(newMessage)) {
 							if (!newMessage.isStatusMessage() && (newMessage.getType() != MessageType.VOIP_STATUS)) {
 								playSentSound();
 							}
 						}
 					} else {
-						if (addMessageToList(newMessage, true) && !isPaused) {
+						if (addMessageToList(newMessage) && !isPaused) {
 							if (!newMessage.isStatusMessage() && (newMessage.getType() != MessageType.VOIP_STATUS)) {
 								playReceivedSound();
 							}
@@ -637,7 +639,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
 		public void onModified(final ContactModel modifiedContactModel) {
-			updateContactModelData(modifiedContactModel);
+			RuntimeUtil.runOnUiThread(() -> updateContactModelData(modifiedContactModel));
 		}
 
 		@Override
@@ -649,12 +651,9 @@ public class ComposeMessageFragment extends Fragment implements
 		public void onRemoved(ContactModel removedContactModel) {
 			if (contactModel != null && contactModel.equals(removedContactModel)) {
 				// our contact has been removed. finish activity.
-				RuntimeUtil.runOnUiThread(new Runnable() {
-					@Override
-					public void run() {
-						if (activity != null) {
-							activity.finish();
-						}
+				RuntimeUtil.runOnUiThread(() -> {
+					if (activity != null) {
+						activity.finish();
 					}
 				});
 			}
@@ -1415,6 +1414,7 @@ public class ComposeMessageFragment extends Fragment implements
 			//clear all records to remove all references
 			if(this.composeMessageAdapter != null) {
 				this.composeMessageAdapter.clear();
+				this.composeMessageAdapter = null;
 			}
 
 		} catch (Exception x) {
@@ -2043,10 +2043,51 @@ public class ComposeMessageFragment extends Fragment implements
 			return;
 		}
 
+		// hide chat from view and prevent screenshots - may not work on some devices
+		if (this.hiddenChatsListService.has(this.messageReceiver.getUniqueIdString())) {
+			try {
+				getActivity().getWindow().addFlags(FLAG_SECURE);
+			} catch (Exception ignored) { }
+		}
+
 		// set wallpaper based on message receiver
 		this.setBackgroundWallpaper();
 
-		this.initConversationList();
+		this.initConversationList(intent.hasExtra(EXTRA_API_MESSAGE_ID) && intent.hasExtra(EXTRA_SEARCH_QUERY) ? (Runnable) () -> {
+				String apiMessageId = intent.getStringExtra(EXTRA_API_MESSAGE_ID);
+				String searchQuery = intent.getStringExtra(EXTRA_SEARCH_QUERY);
+
+				AbstractMessageModel targetMessageModel = messageService.getMessageModelByApiMessageId(apiMessageId, messageReceiver.getType());
+
+				if (targetMessageModel != null && !TestUtil.empty(apiMessageId) && !TestUtil.empty(searchQuery)) {
+					String identity;
+					if (targetMessageModel instanceof GroupMessageModel) {
+						identity = targetMessageModel.isOutbox() ? contactService.getMe().getIdentity() : targetMessageModel.getIdentity();
+					} else {
+						identity = targetMessageModel.getIdentity();
+					}
+
+					QuoteUtil.QuoteContent quoteContent = QuoteUtil.QuoteContent.createV2(
+						identity,
+						searchQuery,
+						searchQuery,
+						apiMessageId,
+						targetMessageModel,
+						messageReceiver.getType(),
+						null,
+						null
+					);
+
+					if (composeMessageAdapter != null) {
+						ComposeMessageAdapter.ConversationListFilter filter = (ComposeMessageAdapter.ConversationListFilter) composeMessageAdapter.getQuoteFilter(quoteContent);
+						searchV2Quote(apiMessageId, filter);
+
+						intent.removeExtra(EXTRA_API_MESSAGE_ID);
+					}
+				} else {
+					Toast.makeText(ThreemaApplication.getAppContext(), R.string.message_not_found, Toast.LENGTH_SHORT).show();
+				}
+		} : null);
 
 		// work around the problem that the same original intent may be sent
 		// each time a singleTop activity (like this one) is coming back to front
@@ -2093,49 +2134,6 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		ListenerManager.chatListener.handle(listener -> listener.onChatOpened(conversationUid));
-
-		if (this.hiddenChatsListService.has(this.messageReceiver.getUniqueIdString())) {
-			// hide chat from view and prevent screenshots - may not work on some devices
-			try {
-				getActivity().getWindow().addFlags(FLAG_SECURE);
-			} catch (Exception e) {
-				//
-			}
-		}
-
-		if (intent.hasExtra(EXTRA_API_MESSAGE_ID) && intent.hasExtra(EXTRA_SEARCH_QUERY)) {
-			String apiMessageId = intent.getStringExtra(EXTRA_API_MESSAGE_ID);
-			String searchQuery = intent.getStringExtra(EXTRA_SEARCH_QUERY);
-
-			AbstractMessageModel targetMessageModel = messageService.getMessageModelByApiMessageId(apiMessageId, messageReceiver.getType());
-
-			if (targetMessageModel != null && !TestUtil.empty(apiMessageId) && !TestUtil.empty(searchQuery)) {
-				String identity;
-				if (targetMessageModel instanceof GroupMessageModel) {
-					identity = targetMessageModel.isOutbox() ? contactService.getMe().getIdentity() : targetMessageModel.getIdentity();
-				} else {
-					identity = targetMessageModel.getIdentity();
-				}
-
-				QuoteUtil.QuoteContent quoteContent = QuoteUtil.QuoteContent.createV2(
-					identity,
-					searchQuery,
-					searchQuery,
-					apiMessageId,
-					targetMessageModel,
-					messageReceiver.getType(),
-					null,
-					null
-				);
-
-				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();
-			}
-		}
 	}
 
 	private boolean validateSendingPermission() {
@@ -2292,8 +2290,8 @@ public class ComposeMessageFragment extends Fragment implements
 	}
 
 	@UiThread
-	private boolean addMessageToList(AbstractMessageModel message, boolean removeUnreadBar) {
-		if (message == null || this.messageReceiver == null) {
+	private boolean addMessageToList(AbstractMessageModel message) {
+		if (message == null || this.messageReceiver == null || this.composeMessageAdapter == null) {
 			return false;
 		}
 
@@ -2309,9 +2307,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 		logger.debug("addMessageToList: started");
 
-		if (removeUnreadBar) {
-			this.composeMessageAdapter.removeFirstUnreadPosition();
-		}
+		this.composeMessageAdapter.removeFirstUnreadPosition();
 
 		// if previous message is from another date, add a date separator
 		synchronized (this.messageValues) {
@@ -2355,6 +2351,11 @@ public class ComposeMessageFragment extends Fragment implements
 			logger.debug("Update in progress");
 			return;
 		}
+
+		if (this.composeMessageAdapter == null) {
+			return;
+		}
+
 		this.composeMessageAdapter.notifyDataSetChanged();
 		this.convListView.post(new Runnable() {
 			@Override
@@ -2500,9 +2501,9 @@ public class ComposeMessageFragment extends Fragment implements
 	 * initialize conversation list and set the unread message count
 	 * @return number of unread messages
 	 */
-	@SuppressLint("StaticFieldLeak")
+	@SuppressLint({"StaticFieldLeak", "WrongThread"})
 	@UiThread
-	private void initConversationList() {
+	private void initConversationList(@Nullable Runnable runAfter) {
 		this.unreadCount = (int) this.messageReceiver.getUnreadMessagesCount();
 		if (this.unreadCount > MESSAGE_PAGE_SIZE) {
 			new AsyncTask<Void, Void, List<AbstractMessageModel>>() {
@@ -2561,10 +2562,16 @@ public class ComposeMessageFragment extends Fragment implements
 					valuesLoaded(values);
 					populateList(values);
 					DialogUtil.dismissDialog(getParentFragmentManager(), DIALOG_TAG_LOADING_MESSAGES, true);
+					if (runAfter != null) {
+						runAfter.run();
+					}
 				}
 			}.execute();
 		} else {
 			populateList(getNextRecords());
+			if (runAfter != null) {
+				runAfter.run();
+			}
 		}
 	}
 
@@ -2747,8 +2754,8 @@ public class ComposeMessageFragment extends Fragment implements
 					return;
 				}
 			}
-			convListView.setSelection(Integer.MAX_VALUE);
 		}
+		convListView.setSelection(Integer.MAX_VALUE);
 	}
 
 	private void setIdentityColors() {
@@ -3211,9 +3218,7 @@ public class ComposeMessageFragment extends Fragment implements
 					fileService.loadDecryptedMessageFiles(selectedMessages, new FileService.OnDecryptedFilesComplete() {
 						@Override
 						public void complete(ArrayList<Uri> uris) {
-							messageService.shareMediaMessages(activity,
-								new ArrayList<>(selectedMessages),
-								new ArrayList<>(uris));
+							shareMediaMessages(uris);
 						}
 
 						@Override
@@ -3241,9 +3246,7 @@ public class ComposeMessageFragment extends Fragment implements
 							if (messageModel.getType() == MessageType.FILE) {
 								filename = messageModel.getFileData().getFileName();
 							}
-							messageService.shareMediaMessages(activity,
-									new ArrayList<>(Collections.singletonList(messageModel)),
-									new ArrayList<>(Collections.singletonList(fileService.getShareFileUri(decryptedFile, filename))));
+							shareMediaMessages(Collections.singletonList(fileService.getShareFileUri(decryptedFile, filename)));
 						} else {
 							messageService.shareTextMessage(activity, messageModel);
 						}
@@ -3258,6 +3261,30 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
+	private void shareMediaMessages(List<Uri> uris) {
+		if (selectedMessages.size() == 1) {
+			ExpandableTextEntryDialog alertDialog = ExpandableTextEntryDialog.newInstance(
+				getString(R.string.share_media),
+				R.string.add_caption_hint, selectedMessages.get(0).getCaption(),
+				R.string.label_continue, R.string.cancel, true);
+			alertDialog.setData(uris);
+			alertDialog.setTargetFragment(this, 0);
+			alertDialog.show(getParentFragmentManager(), null);
+		} else {
+			messageService.shareMediaMessages(activity,
+				new ArrayList<>(selectedMessages),
+				new ArrayList<>(uris), null);
+		}
+	}
+
+	@Override
+	public void onYes(String tag, Object data, String text) {
+		List<Uri> uris = (List<Uri>) data;
+		messageService.shareMediaMessages(activity,
+			new ArrayList<>(selectedMessages),
+			new ArrayList<>(uris), text);
+	}
+
 	@Override
 	public void onContactSelected(String identity, int length, int insertPosition) {
 		Editable editable = this.messageText.getText();
@@ -4308,19 +4335,15 @@ public class ComposeMessageFragment extends Fragment implements
 			RuntimeUtil.runOnUiThread(this::updateToolbarTitle);
 	}
 
+	@UiThread
 	private void updateContactModelData(final ContactModel contactModel) {
-		if(this.composeMessageAdapter != null) {
-			RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					//update header
-					if(contactModel.getIdentity().equals(identity)) {
-						updateToolbarTitle();
-					}
+		//update header
+		if(contactModel.getIdentity().equals(identity)) {
+			updateToolbarTitle();
+		}
 
-					composeMessageAdapter.resetCachedContactModelData(contactModel);
-				}
-			});
+		if (composeMessageAdapter != null) {
+			composeMessageAdapter.resetCachedContactModelData(contactModel);
 		}
 	}
 
@@ -4440,7 +4463,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
-										   @NonNull String permissions[], @NonNull int[] grantResults) {
+	                                       @NonNull String[] permissions, @NonNull int[] grantResults) {
 		if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
 			switch (requestCode) {
 				case PERMISSION_REQUEST_SAVE_MESSAGE:

+ 4 - 1
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -689,6 +689,9 @@ public class ContactsSectionFragment
 	private void updateContactsCounter(int numContacts, @Nullable FetchResults counts) {
 		if (getActivity() != null && listView != null && isAdded()) {
 			if (contactsCounterChip != null) {
+				if (counts != null) {
+					ListenerManager.contactCountListener.handle(listener -> listener.onNewContactsCountUpdated(counts.last24h));
+				}
 				if (numContacts > 1) {
 					final StringBuilder builder = new StringBuilder();
 					builder.append(numContacts).append(" ").append(getString(R.string.title_section2));
@@ -1177,7 +1180,7 @@ public class ContactsSectionFragment
 
 		Intent messageIntent = new Intent(Intent.ACTION_SEND);
 		messageIntent.setType("text/plain");
-		@SuppressLint("WrongConstant") final List<ResolveInfo> messageApps = packageManager.queryIntentActivities(messageIntent, 0x00020000);
+		@SuppressLint({"WrongConstant", "InlinedApi"}) final List<ResolveInfo> messageApps = packageManager.queryIntentActivities(messageIntent, PackageManager.MATCH_ALL);
 
 		if (!messageApps.isEmpty()) {
 			ArrayList<BottomSheetItem> items = new ArrayList<>();

+ 19 - 12
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -994,6 +994,7 @@ public class MessageSectionFragment extends MainFragment
 			EmptyView emptyView = new EmptyView(activity);
 			emptyView.setup(R.string.no_recent_conversations);
 			((ViewGroup) recyclerView.getParent()).addView(emptyView);
+			recyclerView.setNumHeadersAndFooters(-1);
 			recyclerView.setEmptyView(emptyView);
 			recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
 				@Override
@@ -1081,7 +1082,9 @@ public class MessageSectionFragment extends MainFragment
 
 	@Override
 	public void onFooterClick(View view) {
-		AnimationUtil.startActivity(getActivity(), view,  new Intent(getActivity(), ArchiveActivity.class));
+		Intent intent = new Intent(getActivity(), ArchiveActivity.class);
+		intent.putExtra(ThreemaApplication.INTENT_DATA_ARCHIVE_FILTER, filterQuery);
+		AnimationUtil.startActivity(getActivity(), TestUtil.empty(filterQuery) ? view : null, intent);
 	}
 
 	private void editGroup(ConversationModel model, View view) {
@@ -1531,20 +1534,24 @@ public class MessageSectionFragment extends MainFragment
 
 							if (messageListAdapter != null) {
 								messageListAdapter.setData(conversationModels, changedPositions);
+								// make sure footer is refreshed
+								messageListAdapter.refreshFooter();
 							}
 
-							if (recyclerView != null && scrollToPosition != null) {
-								if (changedPositions != null && changedPositions.size() == 1) {
-									ConversationModel changedModel = changedPositions.get(0);
-
-									if (changedModel != null) {
-										final List<ConversationModel> copyOfModels = new ArrayList<>(conversationModels);
-										for (ConversationModel model : copyOfModels) {
-											if (model.equals(changedModel)) {
-												if (scrollToPosition > changedModel.getPosition()) {
-													recyclerView.scrollToPosition(changedModel.getPosition());
+							if (recyclerView != null) {
+								if (scrollToPosition != null) {
+									if (changedPositions != null && changedPositions.size() == 1) {
+										ConversationModel changedModel = changedPositions.get(0);
+
+										if (changedModel != null) {
+											final List<ConversationModel> copyOfModels = new ArrayList<>(conversationModels);
+											for (ConversationModel model : copyOfModels) {
+												if (model.equals(changedModel)) {
+													if (scrollToPosition > changedModel.getPosition()) {
+														recyclerView.scrollToPosition(changedModel.getPosition());
+													}
+													break;
 												}
-												break;
 											}
 										}
 									}

+ 57 - 54
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.java

@@ -31,10 +31,12 @@ import android.widget.ProgressBar;
 import android.widget.TextView;
 
 import com.google.android.material.bottomsheet.BottomSheetBehavior;
+import com.google.android.material.chip.Chip;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.constraintlayout.widget.ConstraintLayout;
@@ -70,24 +72,26 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 	private static final int QUERY_MIN_LENGTH = 2;
 	private static final long QUERY_TIMEOUT_MS = 500;
 
-	private GlobalSearchAdapter chatsAdapter, groupChatsAdapter;
-	private RecyclerView chatsRecyclerView, groupChatsRecyclerView;
-	private GlobalSearchViewModel chatsViewModel, groupChatsViewModel;
+	public static final int FILTER_CHATS = 0x1;
+	public static final int FILTER_GROUPS = 0x2;
+	public static final int FILTER_INCLUDE_ARCHIVED = 0x4;
+
+	private GlobalSearchAdapter chatsAdapter;
+	private GlobalSearchViewModel chatsViewModel;
 	private TextView emptyTextView;
 	private ProgressBar progressBar;
 	private DeadlineListService hiddenChatsListService;
 	private ContactService contactService;
 	private GroupService groupService;
 
+	private int filterFlags = FILTER_CHATS | FILTER_GROUPS | FILTER_INCLUDE_ARCHIVED;
 	private String queryText;
-	private Handler queryHandler = new Handler();
-	private Runnable queryTask = new Runnable() {
+	private final Handler queryHandler = new Handler();
+	private final Runnable queryTask = new Runnable() {
 		@Override
 		public void run() {
-			chatsViewModel.onQueryChanged(queryText);
+			chatsViewModel.onQueryChanged(queryText, filterFlags);
 			chatsAdapter.onQueryChanged(queryText);
-			groupChatsViewModel.onQueryChanged(queryText);
-			groupChatsAdapter.onQueryChanged(queryText);
 		}
 	};
 
@@ -106,14 +110,10 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 
 			queryHandler.removeCallbacksAndMessages(null);
 			if (queryText != null && queryText.length() >= QUERY_MIN_LENGTH) {
-				emptyTextView.setVisibility(View.GONE);
 				queryHandler.postDelayed(queryTask, QUERY_TIMEOUT_MS);
 			} else {
-				emptyTextView.setVisibility(View.VISIBLE);
-				chatsViewModel.onQueryChanged(null);
+				chatsViewModel.onQueryChanged(null, filterFlags);
 				chatsAdapter.onQueryChanged(null);
-				groupChatsViewModel.onQueryChanged(null);
-				groupChatsAdapter.onQueryChanged(null);
 			}
 		}
 
@@ -195,65 +195,68 @@ public class GlobalSearchActivity extends ThreemaToolbarActivity implements Thre
 		emptyTextView = findViewById(R.id.empty_text);
 		progressBar = findViewById(R.id.progress);
 
-		chatsAdapter = new GlobalSearchChatsAdapter(this, getString(R.string.chats));
-		chatsAdapter.setOnClickItemListener((messageModel, view) -> {
-			showMessage(messageModel, view);
-		});
+		chatsAdapter = new GlobalSearchAdapter(this);
+		chatsAdapter.setOnClickItemListener(this::showMessage);
 
-		groupChatsAdapter = new GlobalSearchGroupChatsAdapter(this, getString(R.string.title_tab_groups));
-		groupChatsAdapter.setOnClickItemListener((messageModel, view) -> {
-			showMessage(messageModel, view);
-		});
+		setupChip(R.id.chats, FILTER_CHATS);
+		setupChip(R.id.groups, FILTER_GROUPS);
+		setupChip(R.id.archived, FILTER_INCLUDE_ARCHIVED);
 
-		chatsRecyclerView = this.findViewById(R.id.recycler_chats);
+		RecyclerView chatsRecyclerView = this.findViewById(R.id.recycler_chats);
 		chatsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 		chatsRecyclerView.setItemAnimator(new DefaultItemAnimator());
 		chatsRecyclerView.setAdapter(chatsAdapter);
 
-		groupChatsRecyclerView = this.findViewById(R.id.recycler_groups);
-		groupChatsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
-		groupChatsRecyclerView.setItemAnimator(new DefaultItemAnimator());
-		groupChatsRecyclerView.setAdapter(groupChatsAdapter);
-
-		chatsViewModel = new ViewModelProvider(this).get(GlobalSearchChatsViewModel.class);
+		chatsViewModel = new ViewModelProvider(this).get(GlobalSearchViewModel.class);
 		chatsViewModel.getMessageModels().observe(this, messageModels -> {
-			messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
-				if (messageModel.getIdentity() != null) {
-					return !hiddenChatsListService.has(contactService.getUniqueIdString(messageModel.getIdentity()));
+			if (messageModels.size() > 0) {
+				messageModels = Functional.filter(messageModels, (IPredicateNonNull<AbstractMessageModel>) messageModel -> {
+					if (messageModel instanceof GroupMessageModel) {
+						if (((GroupMessageModel) messageModel).getGroupId() > 0) {
+							return !hiddenChatsListService.has(groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId()));
+						}
+					} else {
+						if (messageModel.getIdentity() != null) {
+							return !hiddenChatsListService.has(contactService.getUniqueIdString(messageModel.getIdentity()));
+						}
+					}
+					return true;
+				});
+			}
+
+			if (messageModels.size() == 0) {
+				if (queryText != null && queryText.length() >= QUERY_MIN_LENGTH) {
+					emptyTextView.setText(R.string.search_no_matches);
+				} else {
+					emptyTextView.setText(R.string.global_search_empty_view_text);
 				}
-				return true;
-			});
+				emptyTextView.setVisibility(View.VISIBLE);
+			} else {
+				emptyTextView.setVisibility(View.GONE);
+			}
 			chatsAdapter.setMessageModels(messageModels);
 		});
 
 		chatsViewModel.getIsLoading().observe(this, isLoading -> {
 			if (isLoading != null) {
-				if (isLoading) {
-					showProgressBar(true);
-				}
+				showProgressBar(isLoading);
 			}
 		});
+		return true;
+	}
 
-		groupChatsViewModel = new ViewModelProvider(this).get(GlobalSearchGroupChatsViewModel.class);
-		groupChatsViewModel.getMessageModels().observe(this, messageModels -> {
-			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);
-		});
-
-		groupChatsViewModel.getIsLoading().observe(this, isLoading -> {
-			if (isLoading != null) {
-				if (!isLoading) {
-					showProgressBar(false);
-				}
+	private void setupChip(@IdRes int id, int flag) {
+		// https://github.com/material-components/material-components-android/issues/1419
+		Chip chip = findViewById(id);
+		chip.setChecked(true);
+		chip.setOnCheckedChangeListener((buttonView, isChecked) -> {
+			if (isChecked) {
+				filterFlags |= flag;
+			} else {
+				filterFlags  &= ~flag;
 			}
+			chatsViewModel.onQueryChanged(queryText, filterFlags);
 		});
-
-		return true;
 	}
 
 	private void showMessage(AbstractMessageModel messageModel, View view) {

+ 78 - 51
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2019-2021 Threema GmbH
+ * Copyright (c) 2020-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,
@@ -36,32 +36,40 @@ import java.util.List;
 import androidx.annotation.NonNull;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiImageSpan;
 import ch.threema.app.emojis.EmojiMarkupUtil;
+import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
+import ch.threema.app.ui.AvatarListItemUtil;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
+import ch.threema.app.utils.LocaleUtil;
+import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.storage.models.AbstractMessageModel;
+import ch.threema.storage.models.ContactModel;
+import ch.threema.storage.models.GroupMessageModel;
+import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.data.LocationDataModel;
 
-public abstract class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	private static final Logger logger = LoggerFactory.getLogger(GlobalSearchAdapter.class);
+	private static final String FLOW_CHARACTER = "\u25BA\uFE0E";
 
-	private static final int TYPE_HEADER = 0;
-	private static final int TYPE_ITEM = 1;
-	protected static final String FLOW_CHARACTER = "\u25BA\uFE0E";
+	private GroupService groupService;
+	private ContactService contactService;
 
-	protected final Context context;
-	protected GlobalSearchAdapter.OnClickItemListener onClickItemListener;
-	protected String queryString;
-	protected String headerString;
-	protected List<AbstractMessageModel> messageModels; // Cached copy of AbstractMessageModels
+	private final Context context;
+	private OnClickItemListener onClickItemListener;
+	private String queryString;
+	private List<AbstractMessageModel> messageModels; // Cached copy of AbstractMessageModels
 
-	static class ItemHolder extends RecyclerView.ViewHolder {
-		protected final TextView titleView;
-		protected final TextView dateView;
-		protected final TextView snippetView;
+	private static class ItemHolder extends RecyclerView.ViewHolder {
+		private final TextView titleView;
+		private final TextView dateView;
+		private final TextView snippetView;
 		private final AvatarView avatarView;
 		AvatarListItemHolder avatarListItemHolder;
 
@@ -78,36 +86,66 @@ public abstract class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerV
 		}
 	}
 
-	public class HeaderHolder extends RecyclerView.ViewHolder {
-		protected final TextView headerText;
-
-		public HeaderHolder(View view) {
-			super(view);
+	GlobalSearchAdapter(Context context) {
+		this.context = context;
 
-			this.headerText = itemView.findViewById(R.id.header_text);
+		try {
+			this.groupService = ThreemaApplication.getServiceManager().getGroupService();
+			this.contactService = ThreemaApplication.getServiceManager().getContactService();
+		} catch (Exception e) {
+			logger.error("Unable to get Services", e);
 		}
 	}
 
-	GlobalSearchAdapter(Context context, String headerString) {
-		this.headerString = headerString;
-		this.context = context;
-	}
-
 	@NonNull
 	@Override
 	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
-		if (viewType == TYPE_ITEM) {
-			View v = LayoutInflater.from(parent.getContext())
-				.inflate(R.layout.item_global_search, parent, false);
+		View v = LayoutInflater.from(parent.getContext())
+			.inflate(R.layout.item_global_search, parent, false);
+
+		return new ItemHolder(v);
+	}
+
+	@Override
+	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+		ItemHolder itemHolder = (ItemHolder) holder;
 
-			return new GlobalSearchAdapter.ItemHolder(v);
-		} else if (viewType == TYPE_HEADER) {
-			View v = LayoutInflater.from(parent.getContext())
-				.inflate(R.layout.header_global_search, parent, false);
+		if (messageModels != null) {
+			AbstractMessageModel current = getItem(position);
 
-			return new GlobalSearchAdapter.HeaderHolder(v);
+			if (current instanceof GroupMessageModel) {
+				final ContactModel contactModel = current.isOutbox() ? this.contactService.getMe() : this.contactService.getByIdentity(current.getIdentity());
+				final GroupModel groupModel = groupService.getById(((GroupMessageModel) current).getGroupId());
+				AvatarListItemUtil.loadAvatar(position, groupModel, null, groupService, itemHolder.avatarListItemHolder);
+
+				String groupName = NameUtil.getDisplayName(groupModel, groupService);
+				itemHolder.titleView.setText(
+					String.format("%s %s %s", NameUtil.getDisplayNameOrNickname(contactModel, true), FLOW_CHARACTER, groupName)
+				);
+			} else {
+				final ContactModel contactModel = this.contactService.getByIdentity(current.getIdentity());
+				AvatarListItemUtil.loadAvatar(position, current.isOutbox() ? contactService.getMe() : contactModel, null, contactService, itemHolder.avatarListItemHolder);
+
+				String name = NameUtil.getDisplayNameOrNickname(context, current, contactService);
+				itemHolder.titleView.setText(
+					current.isOutbox() ?
+						name + " " + FLOW_CHARACTER + " " + NameUtil.getDisplayNameOrNickname(contactModel, true) :
+						name
+				);
+			}
+			itemHolder.dateView.setText(LocaleUtil.formatDateRelative(context, current.getCreatedAt().getTime()));
+
+			setSnippetToTextView(current, itemHolder);
+
+			if (this.onClickItemListener != null) {
+				itemHolder.itemView.setOnClickListener(v -> onClickItemListener.onClick(current, itemHolder.itemView));
+			}
+		} else {
+			// Covers the case of data not being ready yet.
+			itemHolder.titleView.setText("No data");
+			itemHolder.dateView.setText("");
+			itemHolder.snippetView.setText("");
 		}
-		throw new RuntimeException("no matching item type");
 	}
 
 	/**
@@ -143,12 +181,13 @@ public abstract class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerV
 		return fullText;
 	}
 
+
 	void setMessageModels(List<AbstractMessageModel> messageModels){
 		this.messageModels = messageModels;
 		notifyDataSetChanged();
 	}
 
-	protected void setSnippetToTextView(AbstractMessageModel current, ItemHolder itemHolder) {
+	private void setSnippetToTextView(AbstractMessageModel current, ItemHolder itemHolder) {
 		String snippetText = null;
 		if (!TestUtil.empty(this.queryString)) {
 			switch (current.getType()) {
@@ -197,34 +236,22 @@ public abstract class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerV
 		}
 	}
 
-	protected AbstractMessageModel getItem(int position) {
-		return messageModels.get(position - 1);
+	private AbstractMessageModel getItem(int position) {
+		return messageModels.get(position);
 	}
 
 	// getItemCount() is called many times, and when it is first called,
 	// messageModels has not been updated (means initially, it's null, and we can't return null).
 	@Override
 	public int getItemCount() {
-		if (messageModels != null && messageModels.size() > 0) {
-			return messageModels.size() + 1; // account for header
+		if (messageModels != null) {
+			return messageModels.size();
 		}
 		else {
 			return 0;
 		}
 	}
 
-	@Override
-	public int getItemViewType(int position) {
-		if (isPositionHeader(position))
-			return TYPE_HEADER;
-
-		return TYPE_ITEM;
-	}
-
-	private boolean isPositionHeader(int position) {
-		return position == 0;
-	}
-
 	void setOnClickItemListener(OnClickItemListener onClickItemListener) {
 		this.onClickItemListener = onClickItemListener;
 	}

+ 0 - 90
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsAdapter.java

@@ -1,90 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2020-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.globalsearch;
-
-import android.content.Context;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.ui.AvatarListItemUtil;
-import ch.threema.app.utils.LocaleUtil;
-import ch.threema.app.utils.NameUtil;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ContactModel;
-
-public class GlobalSearchChatsAdapter extends GlobalSearchAdapter {
-	private static final Logger logger = LoggerFactory.getLogger(GlobalSearchChatsAdapter.class);
-
-	private ContactService contactService;
-
-	GlobalSearchChatsAdapter(Context context, String headerString) {
-		super(context, headerString);
-		try {
-			this.contactService = ThreemaApplication.getServiceManager().getContactService();
-		} catch (Exception e) {
-			logger.error("Unable to get ContactService", e);
-		}
-	}
-
-	@Override
-	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
-		if (holder instanceof GlobalSearchAdapter.ItemHolder) {
-			GlobalSearchAdapter.ItemHolder itemHolder = (GlobalSearchAdapter.ItemHolder) holder;
-
-			if (messageModels != null) {
-				AbstractMessageModel current = getItem(position);
-
-				final ContactModel contactModel = this.contactService.getByIdentity(current.getIdentity());
-				// load avatars asynchronously
-				AvatarListItemUtil.loadAvatar(position, current.isOutbox() ? contactService.getMe() : contactModel, null, contactService, itemHolder.avatarListItemHolder);
-
-				String name = NameUtil.getDisplayNameOrNickname(context, current, contactService);
-				itemHolder.titleView.setText(
-					current.isOutbox() ?
-						name + " " + FLOW_CHARACTER + " " + NameUtil.getDisplayNameOrNickname(contactModel, true) :
-						name
-					);
-				itemHolder.dateView.setText(LocaleUtil.formatDateRelative(context, current.getCreatedAt().getTime()));
-
-				setSnippetToTextView(current, itemHolder);
-
-				if (this.onClickItemListener != null) {
-					itemHolder.itemView.setOnClickListener(v -> onClickItemListener.onClick(current, itemHolder.itemView));
-				}
-			} else {
-				// Covers the case of data not being ready yet.
-				itemHolder.titleView.setText("No data");
-				itemHolder.dateView.setText("");
-				itemHolder.snippetView.setText("");
-			}
-		} else {
-			GlobalSearchAdapter.HeaderHolder headerHolder = (GlobalSearchAdapter.HeaderHolder) holder;
-
-			headerHolder.headerText.setText(headerString);
-		}
-	}
-}

+ 0 - 39
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsRepository.java

@@ -1,39 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2020-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.globalsearch;
-
-import android.app.Application;
-
-import java.util.List;
-
-import androidx.lifecycle.MutableLiveData;
-import ch.threema.storage.models.AbstractMessageModel;
-
-public class GlobalSearchChatsRepository extends GlobalSearchRepository {
-	private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>();
-
-	GlobalSearchChatsRepository(Application application) { super(application); }
-
-	List<AbstractMessageModel> getMessagesForText(String queryString) {
-		return messageService.getContactMessagesForText(queryString);
-	}
-}

+ 0 - 54
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchChatsViewModel.java

@@ -1,54 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.globalsearch;
-
-import android.app.Application;
-
-import androidx.lifecycle.LiveData;
-
-/**
- * The ViewModel's role is to provide data to the UI and survive configuration changes.
- * A ViewModel acts as a communication center between the Repository and the UI.
- *
- * Never pass context into ViewModel instances. Do not store Activity, Fragment, or View instances or
- * their Context in the ViewModel.
- */
-
-public class GlobalSearchChatsViewModel extends GlobalSearchViewModel {
-	protected GlobalSearchChatsRepository repository;
-
-	public GlobalSearchChatsViewModel(Application application) {
-		super(application);
-		repository = new GlobalSearchChatsRepository(application);
-		messageModels = repository.getMessageModels();
-	}
-
-	@Override
-	public void onQueryChanged(String query) {
-		repository.onQueryChanged(query);
-	}
-
-	@Override
-	LiveData<Boolean> getIsLoading() {
-		return repository.getIsLoading();
-	}
-}

+ 0 - 93
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchGroupChatsAdapter.java

@@ -1,93 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2020-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.globalsearch;
-
-import android.content.Context;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.GroupService;
-import ch.threema.app.ui.AvatarListItemUtil;
-import ch.threema.app.utils.LocaleUtil;
-import ch.threema.app.utils.NameUtil;
-import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.GroupMessageModel;
-import ch.threema.storage.models.GroupModel;
-
-public class GlobalSearchGroupChatsAdapter extends GlobalSearchAdapter {
-	private static final Logger logger = LoggerFactory.getLogger(GlobalSearchGroupChatsAdapter.class);
-
-	private GroupService groupService;
-	private ContactService contactService;
-
-	GlobalSearchGroupChatsAdapter(Context context, String headerString) {
-		super(context, headerString);
-		try {
-			this.groupService = ThreemaApplication.getServiceManager().getGroupService();
-			this.contactService = ThreemaApplication.getServiceManager().getContactService();
-		} catch (Exception e) {
-			logger.error("Unable to get GroupService", e);
-		}
-	}
-
-	@Override
-	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
-		if (holder instanceof GlobalSearchGroupChatsAdapter.ItemHolder) {
-			GlobalSearchGroupChatsAdapter.ItemHolder itemHolder = (GlobalSearchGroupChatsAdapter.ItemHolder) holder;
-
-			if (messageModels != null) {
-				GroupMessageModel current = (GroupMessageModel) getItem(position);
-
-				final ContactModel contactModel = current.isOutbox() ? this.contactService.getMe() : this.contactService.getByIdentity(current.getIdentity());
-				final GroupModel groupModel = groupService.getById(current.getGroupId());
-				// load avatars asynchronously
-				AvatarListItemUtil.loadAvatar(position, groupModel, null, groupService, itemHolder.avatarListItemHolder);
-
-				String groupName = NameUtil.getDisplayName(groupModel, groupService);
-				itemHolder.titleView.setText(
-					String.format("%s %s %s", NameUtil.getDisplayNameOrNickname(contactModel, true), FLOW_CHARACTER, groupName)
-				);
-				itemHolder.dateView.setText(LocaleUtil.formatDateRelative(context, current.getCreatedAt().getTime()));
-
-				setSnippetToTextView(current, itemHolder);
-
-				if (this.onClickItemListener != null) {
-					itemHolder.itemView.setOnClickListener(v -> onClickItemListener.onClick(current, itemHolder.itemView));
-				}
-			} else {
-				// Covers the case of data not being ready yet.
-				itemHolder.titleView.setText("No data");
-				itemHolder.dateView.setText("");
-				itemHolder.snippetView.setText("");
-			}
-		} else {
-			GlobalSearchGroupChatsAdapter.HeaderHolder headerHolder = (GlobalSearchGroupChatsAdapter.HeaderHolder) holder;
-
-			headerHolder.headerText.setText(headerString);
-		}
-	}
-}

+ 0 - 54
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchGroupChatsViewModel.java

@@ -1,54 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.globalsearch;
-
-import android.app.Application;
-
-import androidx.lifecycle.LiveData;
-
-/**
- * The ViewModel's role is to provide data to the UI and survive configuration changes.
- * A ViewModel acts as a communication center between the Repository and the UI.
- *
- * Never pass context into ViewModel instances. Do not store Activity, Fragment, or View instances or
- * their Context in the ViewModel.
- */
-
-public class GlobalSearchGroupChatsViewModel extends GlobalSearchViewModel {
-	protected GlobalSearchGroupChatsRepository repository;
-
-	public GlobalSearchGroupChatsViewModel(Application application) {
-		super(application);
-		repository = new GlobalSearchGroupChatsRepository(application);
-		messageModels = repository.getMessageModels();
-	}
-
-	@Override
-	public void onQueryChanged(String query) {
-		repository.onQueryChanged(query);
-	}
-
-	@Override
-	LiveData<Boolean> getIsLoading() {
-		return repository.getIsLoading();
-	}
-}

+ 30 - 19
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchRepository.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2019-2021 Threema GmbH
+ * Copyright (c) 2020-2021 Threema GmbH
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, version 3,
@@ -22,10 +22,10 @@
 package ch.threema.app.globalsearch;
 
 import android.annotation.SuppressLint;
-import android.app.Application;
 import android.os.AsyncTask;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import androidx.annotation.Nullable;
@@ -38,20 +38,14 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.AbstractMessageModel;
 
-/**
- * A Repository is a class that abstracts access to multiple data sources.
- *
- * The Repository is not part of the Architecture Components libraries, but is a
- * suggested best practice for code separation and architecture. A Repository class
- * handles data operations. It provides a clean API to the rest of the app for app data.
- */
-abstract class GlobalSearchRepository {
+public class GlobalSearchRepository {
 	private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>();
 	private MutableLiveData<List<AbstractMessageModel>> messageModels;
-	protected MessageService messageService;
+	private MessageService messageService;
+
 	private String queryString = "";
 
-	GlobalSearchRepository(Application application) {
+	GlobalSearchRepository() {
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 		if (serviceManager != null) {
 			messageService = null;
@@ -65,7 +59,7 @@ abstract class GlobalSearchRepository {
 					@Nullable
 					@Override
 					public List<AbstractMessageModel> getValue() {
-						return getMessagesForText(queryString);
+						return getMessagesForText(queryString, GlobalSearchActivity.FILTER_CHATS | GlobalSearchActivity.FILTER_GROUPS | GlobalSearchActivity.FILTER_INCLUDE_ARCHIVED);
 					}
 				};
 			}
@@ -76,9 +70,27 @@ abstract class GlobalSearchRepository {
 		return messageModels;
 	}
 
+	List<AbstractMessageModel> getMessagesForText(String queryString, int filterFlags) {
+		List<AbstractMessageModel> messageModels = new ArrayList<>();
+
+		boolean includeArchived = (filterFlags & GlobalSearchActivity.FILTER_INCLUDE_ARCHIVED) == GlobalSearchActivity.FILTER_INCLUDE_ARCHIVED;
+		if ((filterFlags & GlobalSearchActivity.FILTER_CHATS) == GlobalSearchActivity.FILTER_CHATS) {
+			messageModels.addAll(messageService.getContactMessagesForText(queryString, includeArchived));
+		}
+
+		if ((filterFlags & GlobalSearchActivity.FILTER_GROUPS) == GlobalSearchActivity.FILTER_GROUPS) {
+			messageModels.addAll(messageService.getGroupMessagesForText(queryString, includeArchived));
+		}
+
+		if (messageModels.size() > 0) {
+			Collections.sort(messageModels, (o1, o2) -> o2.getCreatedAt().compareTo(o1.getCreatedAt()));
+		}
+		return messageModels;
+	}
+
 	@SuppressLint("StaticFieldLeak")
-	public void onQueryChanged(String query) {
-		this.queryString = query;
+	void onQueryChanged(String query, int filterFlags) {
+		queryString = query;
 
 		new AsyncTask<String, Void, Void>() {
 			@Override
@@ -86,9 +98,10 @@ abstract class GlobalSearchRepository {
 				if (messageService != null) {
 					if (TestUtil.empty(query)) {
 						messageModels.postValue(new ArrayList<>());
+						isLoading.postValue(false);
 					} else {
 						isLoading.postValue(true);
-						messageModels.postValue(getMessagesForText(query));
+						messageModels.postValue(getMessagesForText(query, filterFlags));
 						isLoading.postValue(false);
 					}
 				}
@@ -97,9 +110,7 @@ abstract class GlobalSearchRepository {
 		}.execute();
 	}
 
-	public LiveData<Boolean> getIsLoading() {
+	LiveData<Boolean> getIsLoading() {
 		return isLoading;
 	}
-
-	abstract List<AbstractMessageModel> getMessagesForText(String queryString);
 }

+ 15 - 6
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchViewModel.java

@@ -37,17 +37,26 @@ import ch.threema.storage.models.AbstractMessageModel;
  * their Context in the ViewModel.
  */
 
-abstract class GlobalSearchViewModel extends AndroidViewModel {
-	protected LiveData<List<AbstractMessageModel>> messageModels;
+public class GlobalSearchViewModel extends AndroidViewModel {
+	private final LiveData<List<AbstractMessageModel>> messageModels;
+
+	LiveData<List<AbstractMessageModel>> getMessageModels() {
+		return messageModels;
+	}
+
+	private final GlobalSearchRepository repository;
 
 	public GlobalSearchViewModel(Application application) {
 		super(application);
+		repository = new GlobalSearchRepository();
+		messageModels = repository.getMessageModels();
 	}
 
-	LiveData<List<AbstractMessageModel>> getMessageModels() {
-		return messageModels;
+	void onQueryChanged(String query, int filterFlags) {
+		repository.onQueryChanged(query, filterFlags);
 	}
 
-	abstract void onQueryChanged(String query);
-	abstract LiveData<Boolean> getIsLoading();
+	LiveData<Boolean> getIsLoading() {
+		return repository.getIsLoading();
+	}
 }

+ 1 - 38
app/src/main/java/ch/threema/app/jobs/WorkSyncService.java

@@ -49,7 +49,6 @@ import ch.threema.app.services.license.UserCredentials;
 import ch.threema.app.stores.IdentityStore;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.VerificationLevel;
 import ch.threema.client.APIConnector;
@@ -172,36 +171,8 @@ public class WorkSyncService extends FixedJobIntentService {
 				logger.debug("contacts: " + workData.workContacts.size());
 */				List<ContactModel> existingWorkContacts = this.contactService.getIsWork();
 
-				boolean requireCheckMultipleIdentities = false;
 				for (WorkContact workContact : workData.workContacts) {
-
-					ContactModel existingContact = this.contactService.getByIdentity(workContact.threemaId);
-
-					if (existingContact == null) {
-						existingContact = new ContactModel(workContact.threemaId, workContact.publicKey);
-						requireCheckMultipleIdentities = true;
-					} else {
-						//try to remove
-						for (int x = 0; x < existingWorkContacts.size(); x++) {
-							if (existingWorkContacts.get(x).getIdentity().equals(workContact.threemaId)) {
-								existingWorkContacts.remove(x);
-								break;
-							}
-						}
-					}
-
-					if (!ContactUtil.isLinked(existingContact)
-						&& (workContact.firstName != null
-						|| workContact.lastName != null)) {
-						existingContact.setFirstName(workContact.firstName);
-						existingContact.setLastName(workContact.lastName);
-					}
-					existingContact.setIsWork(true);
-					existingContact.setIsHidden(false);
-					if (existingContact.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
-						existingContact.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
-					}
-					this.contactService.save(existingContact);
+					contactService.addWorkContact(workContact, existingWorkContacts);
 				}
 
 				//downgrade work contacts
@@ -234,14 +205,6 @@ public class WorkSyncService extends FixedJobIntentService {
 						.storeWorkMDMSettings(workData.mdm);
 				}
 
-				// Check identity for type and state (only if new contacts added)
-				if (requireCheckMultipleIdentities) {
-					// force run
-					logger.debug("force run CheckIdentityStatesRoutine");
-					// TODO
-//					CheckIdentityStatesRoutine.start(true);
-				}
-
 				// update work info
 				new UpdateWorkInfoRoutine(
 					this,

+ 5 - 15
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchGroupChatsRepository.java → app/src/main/java/ch/threema/app/listeners/ContactCountListener.java

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2020-2021 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,
@@ -19,20 +19,10 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.globalsearch;
+package ch.threema.app.listeners;
 
-import android.app.Application;
+import androidx.annotation.AnyThread;
 
-import java.util.List;
-
-import ch.threema.storage.models.AbstractMessageModel;
-
-public class GlobalSearchGroupChatsRepository extends GlobalSearchRepository {
-	GlobalSearchGroupChatsRepository(Application application) {
-		super(application);
-	}
-
-	List<AbstractMessageModel> getMessagesForText(String queryString) {
-		return messageService.getGroupMessagesForText(queryString);
-	}
+public interface ContactCountListener {
+	@AnyThread void onNewContactsCountUpdated(int last24hoursCount);
 }

+ 13 - 10
app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java

@@ -35,9 +35,6 @@ import android.widget.ProgressBar;
 
 import com.mapbox.mapboxsdk.geometry.LatLng;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -49,18 +46,21 @@ import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import ch.threema.app.R;
 import ch.threema.app.activities.ThreemaActivity;
+import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.ThreemaEditText;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.NetworkUtil;
 
 import static ch.threema.app.locationpicker.PoiRepository.QUERY_MIN_LENGTH;
 import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
 import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LNG;
 
 public class LocationAutocompleteActivity extends ThreemaActivity {
-	private static final Logger logger = LoggerFactory.getLogger(LocationAutocompleteActivity.class);
+
+	private static final String DIALOG_TAG_NO_CONNECTION = "no_connection";
 
 	private static final long QUERY_TIMEOUT = 1000; // ms
 
@@ -159,9 +159,13 @@ public class LocationAutocompleteActivity extends ThreemaActivity {
 			places = newplaces;
 			refreshAdapter(places);
 
-			if (places.size() == 0 && (queryText != null && queryText.length() >= QUERY_MIN_LENGTH)) {
+			if (!NetworkUtil.isOnline()) {
+				SimpleStringAlertDialog.newInstance(R.string.send_location, R.string.internet_connection_required).show(getSupportFragmentManager(), DIALOG_TAG_NO_CONNECTION);
+			}
+			else if (places.size() == 0 && (queryText != null && queryText.length() >= QUERY_MIN_LENGTH)) {
 				emptyView.setup(R.string.lp_search_place_no_matches);
-			} else {
+			}
+			else {
 				emptyView.setup(R.string.lp_search_place_min_chars);
 			}
 		});
@@ -199,10 +203,9 @@ public class LocationAutocompleteActivity extends ThreemaActivity {
 
 	@Override
 	public boolean onOptionsItemSelected(MenuItem item) {
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				this.finish();
-				return true;
+		if (item.getItemId() == android.R.id.home) {
+			this.finish();
+			return true;
 		}
 		return super.onOptionsItemSelected(item);
 	}

+ 34 - 44
app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java

@@ -29,9 +29,7 @@ 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;
 import android.location.LocationManager;
 import android.os.AsyncTask;
 import android.os.Bundle;
@@ -336,15 +334,8 @@ public class LocationPickerActivity extends ThreemaActivity implements
 					@Override
 					public void onStyleLoaded(@NonNull Style style) {
 						// Map is set up and the style has loaded. Now you can add data or make other mapView adjustments
-						if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
-							setupLocationComponent(style);
-
-							if (locationComponent.getLastKnownLocation() == null) {
-								mapboxMap.animateCamera(CameraUpdateFactory.zoomTo(1));
-								showLocationNotAvailable();
-							}
-						}
-						updatePois();
+						setupLocationComponent(style);
+						zoomToCenter();
 					}
 				});
 				mapboxMap.getUiSettings().setAttributionEnabled(false);
@@ -523,6 +514,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 
 	private boolean checkLocationEnabled(LocationManager locationManager) {
 		if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && !locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+			setMapWithLocationFallback();
 			GenericAlertDialog.newInstance(R.string.send_location, R.string.location_services_disabled, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_ENABLE_LOCATION_SERVICES);
 			return false;
 		}
@@ -582,11 +574,12 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	private void zoomToCenter() {
 		if (checkLocationEnabled(locationManager)) {
 			if (locationComponent != null) {
-				locationComponent.setLocationComponentEnabled(true);
 				Location location = locationComponent.getLastKnownLocation();
 				if (location != null) {
 					moveCameraAndUpdatePOIs(new LatLng(location.getLatitude(), location.getLongitude()), true, -1);
-				} else {
+				}
+				else {
+					setMapWithLocationFallback();
 					showLocationNotAvailable();
 				}
 			}
@@ -594,7 +587,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	}
 
 	private void showLocationNotAvailable() {
-		RuntimeUtil.runOnUiThread(() -> Toast.makeText(LocationPickerActivity.this, R.string.unable_to_get_current_location, Toast.LENGTH_SHORT).show());
+		RuntimeUtil.runOnUiThread(() -> Toast.makeText(LocationPickerActivity.this, R.string.unable_to_get_current_location, Toast.LENGTH_LONG).show());
 	}
 
 	@Override
@@ -608,7 +601,9 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	}
 
 	@Override
-	public void onNo(String tag, Object data) { }
+	public void onNo(String tag, Object data) {
+		// don't bother, we just stay at the fallback Zuerich location
+	}
 
 	@Override
 	public void onOK(String tag, Object object) {
@@ -623,35 +618,8 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
 		if (requestCode == REQUEST_CODE_LOCATION_SETTINGS) {
 			if (checkLocationEnabled(locationManager)) {
-				if (locationComponent == null) {
-					mapView.post(new Runnable() {
-						@SuppressLint("MissingPermission")
-						@Override
-						public void run() {
-							Criteria criteria = new Criteria();
-							criteria.setAccuracy(Criteria.ACCURACY_MEDIUM);
-							locationManager.requestSingleUpdate(criteria, new LocationListener() {
-								@Override
-								public void onLocationChanged(Location location) {
-									setupLocationComponent(mapboxMap.getStyle());
-									zoomToCenter();
-								}
-
-								@Override
-								public void onStatusChanged(String provider, int status, Bundle extras) {}
-
-								@Override
-								public void onProviderEnabled(String provider) {}
-
-								@Override
-								public void onProviderDisabled(String provider) {}
-							}, null);
-						}
-					});
-				}
-				else {
-					zoomToCenter();
-				}
+				// init map again as it was skipped first time around in onCreate() without location permissions
+				initMap();
 			}
 		} else if (requestCode == REQUEST_CODE_PLACES) {
 			if (resultCode == RESULT_OK) {
@@ -691,6 +659,10 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			}
 		});
 
+		moveCamera(latLng, animate, zoomLevel);
+	}
+
+	private void moveCamera(LatLng latLng, boolean animate, int zoomLevel) {
 		CameraUpdate cameraUpdate = zoomLevel != -1 ?
 			CameraUpdateFactory.newLatLngZoom(latLng, zoomLevel) :
 			CameraUpdateFactory.newLatLng(latLng);
@@ -715,4 +687,22 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			});
 		}
 	}
+
+	private void setMapWithLocationFallback() {
+		mapView.post(() -> {
+			// try to get a last location from gps and network provider, else update POIS around Zuerich
+			Location location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
+			if (location == null) {
+				location = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
+			}
+			if (location == null) {
+				lastPosition = new LatLng(47.367302, 8.544616);
+			}
+			else {
+				lastPosition = new LatLng(location.getLatitude(), location.getLongitude());
+			}
+			moveCamera(lastPosition, true, 9);
+			updatePois();
+		});
+	}
 }

+ 0 - 5
app/src/main/java/ch/threema/app/locationpicker/LocationPickerConfirmDialog.java

@@ -54,7 +54,6 @@ public class LocationPickerConfirmDialog extends ThreemaDialogFragment {
 	private LocationConfirmDialogClickListener callback;
 	private Activity activity;
 	private String tag = null;
-	private Object object;
 
 	private static final Logger logger = LoggerFactory.getLogger(LocationPickerConfirmDialog.class);
 
@@ -70,10 +69,6 @@ public class LocationPickerConfirmDialog extends ThreemaDialogFragment {
 		return dialog;
 	}
 
-	public void setData(Object o) {
-		object = o;
-	}
-
 	public interface LocationConfirmDialogClickListener {
 		void onOK(String tag, Object object);
 		void onCancel(String tag);

+ 2 - 0
app/src/main/java/ch/threema/app/managers/ListenerManager.java

@@ -34,6 +34,7 @@ import ch.threema.app.listeners.AppIconListener;
 import ch.threema.app.listeners.BallotListener;
 import ch.threema.app.listeners.BallotVoteListener;
 import ch.threema.app.listeners.ChatListener;
+import ch.threema.app.listeners.ContactCountListener;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.ContactTypingListener;
@@ -187,4 +188,5 @@ public class ListenerManager {
 	public static final TypedListenerManager<MessagePlayerListener> messagePlayerListener = new TypedListenerManager<>();
 	public static final TypedListenerManager<NewSyncedContactsListener> newSyncedContactListener = new TypedListenerManager<>();
 	public static final TypedListenerManager<QRCodeScanListener> qrCodeScanListener = new TypedListenerManager<>();
+	public static final TypedListenerManager<ContactCountListener> contactCountListener = new TypedListenerManager<>();
 }

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

@@ -382,6 +382,7 @@ public class ServiceManager {
 					this.getApiService(),
 					this.getWallpaperService(),
 					this.getLicenseService(),
+					this.getExcludedSyncIdentitiesService(),
 					this.getAPIConnector());
 		}
 
@@ -694,7 +695,9 @@ public class ServiceManager {
 					this.getPreferenceService(),
 					this.getDeviceService(),
 					this.getFileService(),
-					this.getIdentityStore()
+					this.getIdentityStore(),
+					this.getBlackListService(),
+					this.getLicenseService()
 			);
 		}
 

+ 17 - 6
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -91,6 +91,7 @@ import ch.threema.app.utils.RuntimeUtil;
 
 import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
 import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
+import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
 
 public class MediaAttachActivity extends MediaSelectionBaseActivity implements View.OnClickListener,
 									MediaAttachAdapter.ItemClickListener,
@@ -300,7 +301,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			}
 			selectCounterButton.setText(String.format(LocaleUtil.getCurrentLocale(this), "%d", count));
 
-		} else if (BottomSheetBehavior.from(bottomSheetLayout).getState() == BottomSheetBehavior.STATE_EXPANDED) {
+		} else if (BottomSheetBehavior.from(bottomSheetLayout).getState() == STATE_EXPANDED) {
 			controlPanel.animate().translationY(
 				(float) controlPanel.getHeight() - getResources().getDimensionPixelSize(R.dimen.media_attach_control_panel_shadow_size)
 			).withEndAction(() -> {
@@ -365,7 +366,7 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 				break;
 			case R.id.attach_qr_code:
 				if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_QR_READER)) {
-					attachQR();
+					attachQR(v);
 				}
 				break;
 			case R.id.attach_contact:
@@ -424,10 +425,15 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 					attachFile();
 					break;
 				case PERMISSION_REQUEST_QR_READER:
-					attachQR();
+					attachQR(attachQRButton);
 					break;
 				case PERMISSION_REQUEST_ATTACH_FROM_GALLERY:
-					attachImageFromGallery();
+					if (preferenceService.isShowImageAttachPreviewsEnabled()) {
+						finish();
+						startActivity(getIntent());
+					} else {
+						attachImageFromGallery();
+					}
 					break;
 				case PERMISSION_REQUEST_ATTACH_FROM_EXTERNAL_CAMERA:
 					attachFromExternalCamera();
@@ -656,8 +662,13 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 		}
 	}
 
-	private void attachQR() {
-		QRScannerUtil.getInstance().initiateScan(this, true, null);
+	private void attachQR(View v) {
+		v.postDelayed(new Runnable() {
+			@Override
+			public void run() {
+				QRScannerUtil.getInstance().initiateScan(MediaAttachActivity.this, true, null);
+			}
+		}, 200);
 	}
 
 	private void prepareSendFileMessage(final ArrayList<Uri> uriList) {

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

@@ -226,8 +226,6 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 					messageModel.setApiMessageId(messageId.toString());
 				}
 
-				logger.info("Enqueue group file message ID {} to {}", fileMessage.getMessageId(), fileMessage.getToIdentity());
-
 				return fileMessage;
 			}
 		}, messageModel, identities);

+ 12 - 0
app/src/main/java/ch/threema/app/notifications/NotificationBuilderWrapper.java

@@ -35,10 +35,12 @@ import android.media.AudioManager;
 import android.net.Uri;
 import android.os.Build;
 import android.preference.PreferenceManager;
+import android.provider.Settings;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.InputStream;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
@@ -172,6 +174,16 @@ public class NotificationBuilderWrapper extends NotificationCompat.Builder {
 				if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(ringtone.getScheme())) {
 					// https://commonsware.com/blog/2016/09/07/notifications-sounds-android-7p0-aggravation.html
 					ThreemaApplication.getAppContext().grantUriPermission("com.android.systemui", ringtone, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+				} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(ringtone.getScheme())) {
+					// content://settings/system/notification_sound
+					if (!ringtone.equals(Settings.System.DEFAULT_NOTIFICATION_URI)) {
+						// check if ringtone is still available
+						try (InputStream ignored = context.getContentResolver().openInputStream(ringtone)) {
+						} catch (Exception e) {
+							// cannot open ringtone - fallback to default ringtone
+							ringtone = Settings.System.DEFAULT_NOTIFICATION_URI;
+						}
+					}
 				}
 				notificationChannelSettings.setSound(ringtone);
 			} else {

+ 1 - 1
app/src/main/java/ch/threema/app/notifications/NotificationChannelSettings.java

@@ -64,7 +64,7 @@ public class NotificationChannelSettings {
 	}
 
 	public String getDescription() {
-		if (ConfigUtils.isMIUI10()) {
+		if (ConfigUtils.getMIUIVersion() >= 10) {
 			return toString();
 		}
 		return description;

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

@@ -55,6 +55,8 @@ public class SettingsAppearanceFragment extends ThreemaPreferenceFragment implem
 	private SharedPreferences sharedPreferences;
 	private WallpaperService wallpaperService;
 	private FileService fileService;
+	private CheckBoxPreference showBadge;
+	private boolean showBadgeChecked = false;
 
 	@Override
 	public void onCreatePreferencesFix(@Nullable Bundle savedInstanceState, String rootKey) {
@@ -67,6 +69,9 @@ public class SettingsAppearanceFragment extends ThreemaPreferenceFragment implem
 
 		addPreferencesFromResource(R.xml.preference_appearance);
 
+		this.showBadge = (CheckBoxPreference) findPreference(getResources().getString(R.string.preferences__show_unread_badge));
+		this.showBadgeChecked = this.showBadge.isChecked();
+
 		CheckBoxPreference defaultColoredAvatar = (CheckBoxPreference) findPreference(getResources().getString(R.string.preferences__default_contact_picture_colored));
 		if(defaultColoredAvatar != null) {
 			defaultColoredAvatar.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@@ -325,4 +330,13 @@ public class SettingsAppearanceFragment extends ThreemaPreferenceFragment implem
 
 		super.onActivityResult(requestCode, resultCode, data);
 	}
+
+	@Override
+	public void onDetach() {
+		super.onDetach();
+
+		if (this.showBadge.isChecked() != this.showBadgeChecked) {
+			ConfigUtils.recreateActivity(getActivity());
+		}
+	}
 }

+ 178 - 74
app/src/main/java/ch/threema/app/preference/SettingsNotificationsFragment.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.preference;
 
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
 import android.content.ComponentName;
 import android.content.Intent;
 import android.content.SharedPreferences;
@@ -29,17 +31,21 @@ import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.provider.Settings;
+import android.text.format.DateFormat;
 import android.view.View;
 
-import com.takisoft.preferencex.TimePickerPreference;
+import com.google.android.material.timepicker.MaterialTimePicker;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.text.DateFormatSymbols;
 import java.util.Arrays;
+import java.util.Locale;
 import java.util.Set;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.core.app.NotificationManagerCompat;
@@ -58,6 +64,9 @@ import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RingtoneUtil;
 
+import static com.google.android.material.timepicker.TimeFormat.CLOCK_12H;
+import static com.google.android.material.timepicker.TimeFormat.CLOCK_24H;
+
 public class SettingsNotificationsFragment extends ThreemaPreferenceFragment implements GenericAlertDialog.DialogClickListener, RingtoneSelectorDialog.RingtoneSelectorDialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(SettingsNotificationsFragment.class);
 
@@ -76,15 +85,39 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 	private final String[] weekdays = new String[7];
 	private final String[] shortWeekdays = new String[7];
 	private final String[] weekday_values = new String[]{"0", "1", "2", "3", "4", "5", "6"};
-	private TimePickerPreference startPreference, endPreference;
+	private Preference startPreference, endPreference;
 
 	private Preference ringtonePreference, groupRingtonePreference, voiceRingtonePreference;
 
+	private final ActivityResultLauncher<Intent> voipRingtonePickerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+	result -> {
+		if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+			Uri uri = result.getData().getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+			onRingtoneSelected(DIALOG_TAG_VOIP_NOTIFICATION, uri);
+		}
+	});
+
+	private final ActivityResultLauncher<Intent> contactTonePickerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+				Uri uri = result.getData().getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+				onRingtoneSelected(DIALOG_TAG_CONTACT_NOTIFICATION, uri);
+			}
+		});
+
+	private final ActivityResultLauncher<Intent> groupTonePickerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+				Uri uri = result.getData().getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+				onRingtoneSelected(DIALOG_TAG_GROUP_NOTIFICATION, uri);
+			}
+		});
+
 	private void initWorkingTimePrefs() {
 		if (!ConfigUtils.isWorkBuild()) {
 			// remove preferences
-			PreferenceScreen preferenceScreen = (PreferenceScreen) findPreference("pref_key_notifications");
-			PreferenceCategory preferenceCategory = (PreferenceCategory) findPreference("pref_key_work_life_balance");
+			PreferenceScreen preferenceScreen = findPreference("pref_key_notifications");
+			PreferenceCategory preferenceCategory = findPreference("pref_key_work_life_balance");
 			preferenceScreen.removePreference(preferenceCategory);
 			return;
 		}
@@ -93,39 +126,77 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 		System.arraycopy(dfs.getWeekdays(), 1, weekdays, 0, 7);
 		System.arraycopy(dfs.getShortWeekdays(), 1, shortWeekdays, 0, 7);
 
-		MultiSelectListPreference multiSelectListPreference = (MultiSelectListPreference) findPreference(getString(R.string.preferences__working_days));
+		MultiSelectListPreference multiSelectListPreference = findPreference(getString(R.string.preferences__working_days));
 		multiSelectListPreference.setEntries(weekdays);
-		multiSelectListPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
-			@Override
-			public boolean onPreferenceChange(Preference preference, Object newValue) {
-				updateWorkingDaysSummary(preference, (Set<String>)newValue);
-				return true;
-			}
+		multiSelectListPreference.setOnPreferenceChangeListener((preference, newValue) -> {
+			updateWorkingDaysSummary(preference, (Set<String>)newValue);
+			return true;
 		});
 
 		updateWorkingDaysSummary(multiSelectListPreference, multiSelectListPreference.getValues());
 
-		startPreference = (TimePickerPreference) findPreference(getString(R.string.preferences__work_time_start));
-		startPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
-			@Override
-			public boolean onPreferenceChange(Preference preference, Object newValue) {
-				TimePickerPreference.TimeWrapper newTime = (TimePickerPreference.TimeWrapper) newValue;
-				int newTimeStamp = newTime.hour * 60 + newTime.minute;
-				int endTimeStamp = endPreference.getHourOfDay() * 60 + endPreference.getMinute();
-
-				return newTimeStamp < endTimeStamp;
+		startPreference = findPreference(getString(R.string.preferences__work_time_start));
+		updateTimeSummary(startPreference, R.string.prefs_work_time_start_sum);
+		startPreference.setOnPreferenceClickListener(preference -> {
+			int[] startTime = splitDateFromPrefs(R.string.preferences__work_time_start);
+
+			final MaterialTimePicker timePicker = new MaterialTimePicker.Builder()
+				.setTitleText(R.string.prefs_work_time_start)
+				.setHour(startTime != null ? startTime[0] : 0)
+				.setMinute(startTime != null ? startTime[1] : 0)
+				.setTimeFormat(DateFormat.is24HourFormat(getContext()) ? CLOCK_24H : CLOCK_12H)
+				.build();
+			timePicker.addOnPositiveButtonClickListener(v1 -> {
+				int[] endTime = splitDateFromPrefs(R.string.preferences__work_time_end);
+
+				if (endTime != null) {
+					int newTimeStamp = timePicker.getHour() * 60 + timePicker.getMinute();
+					int endTimeStamp = endTime[0] * 60 + endTime[1];
+
+					if (newTimeStamp >= endTimeStamp) {
+						return;
+					}
+				}
+				String newValue = String.format(Locale.US, "%02d:%02d", timePicker.getHour(), timePicker.getMinute());
+				sharedPreferences.edit().putString(getResources().getString(R.string.preferences__work_time_start), newValue).apply();
+				updateTimeSummary(startPreference, R.string.prefs_work_time_start_sum);
+			});
+			if (isAdded()) {
+				timePicker.show(getParentFragmentManager(), "startt");
 			}
+			return true;
 		});
-		endPreference = (TimePickerPreference) findPreference(getString(R.string.preferences__work_time_end));
-		endPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
-			@Override
-			public boolean onPreferenceChange(Preference preference, Object newValue) {
-				TimePickerPreference.TimeWrapper newTime = (TimePickerPreference.TimeWrapper) newValue;
-				int newTimeStamp = newTime.hour * 60 + newTime.minute;
-				int startTimeStamp = startPreference.getHourOfDay() * 60 + startPreference.getMinute();
 
-				return newTimeStamp > startTimeStamp;
+		endPreference = findPreference(getString(R.string.preferences__work_time_end));
+		updateTimeSummary(endPreference, R.string.prefs_work_time_end_sum);
+		endPreference.setOnPreferenceClickListener(preference -> {
+			int[] endTime = splitDateFromPrefs(R.string.preferences__work_time_end);
+
+			final MaterialTimePicker timePicker = new MaterialTimePicker.Builder()
+				.setTitleText(R.string.prefs_work_time_end)
+				.setHour(endTime != null ? endTime[0] : 0)
+				.setMinute(endTime != null ? endTime[1] : 0)
+				.setTimeFormat(DateFormat.is24HourFormat(getContext()) ? CLOCK_24H : CLOCK_12H)
+				.build();
+			timePicker.addOnPositiveButtonClickListener(v1 -> {
+				int[] startTime = splitDateFromPrefs(R.string.preferences__work_time_start);
+
+				if (startTime != null) {
+					int newTimeStamp = timePicker.getHour() * 60 + timePicker.getMinute();
+					int startTimeStamp = startTime[0] * 60 + startTime[1];
+
+					if (newTimeStamp <= startTimeStamp) {
+						return;
+					}
+				}
+				String newValue = String.format(Locale.US, "%02d:%02d", timePicker.getHour(), timePicker.getMinute());
+				sharedPreferences.edit().putString(getResources().getString(R.string.preferences__work_time_end), newValue).apply();
+				updateTimeSummary(endPreference, R.string.prefs_work_time_end_sum);
+			});
+			if (isAdded()) {
+				timePicker.show(getParentFragmentManager(), "endt");
 			}
+			return true;
 		});
 	}
 
@@ -145,14 +216,33 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 		preference.setSummary(summary);
 	}
 
+	@Nullable
+	private int[] splitDateFromPrefs(@StringRes int key) {
+		String value = sharedPreferences.getString(getString(key), null);
+		if (value == null) {
+			return null;
+		}
+		try {
+			String[] hourMinuteString = value.split(":");
+			int[] hourMinuteInt = new int[2];
+			hourMinuteInt[0] = Integer.parseInt(hourMinuteString[0]);
+			hourMinuteInt[1] = Integer.parseInt(hourMinuteString[1]);
+
+			return hourMinuteInt;
+		} catch (Exception e) {
+			return null;
+		}
+	}
+
 	@Override
 	public void onCreatePreferencesFix(@Nullable Bundle savedInstanceState, String rootKey) {
 		sharedPreferences = getPreferenceManager().getSharedPreferences();
 
 		addPreferencesFromResource(R.xml.preference_notifications);
 
-		if (!ConfigUtils.isMIUI10()) {
-			PreferenceScreen preferenceScreen = (PreferenceScreen) findPreference("pref_key_notifications");
+		int miuiVersion = ConfigUtils.getMIUIVersion();
+		if (miuiVersion < 10) {
+			PreferenceScreen preferenceScreen = findPreference("pref_key_notifications");
 			preferenceScreen.removePreference(findPreference("pref_key_miui"));
 		}
 
@@ -168,52 +258,32 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 		voiceRingtonePreference = findPreference(getResources().getString(R.string.preferences__voip_ringtone));
 		updateRingtoneSummary(voiceRingtonePreference, sharedPreferences.getString(getResources().getString(R.string.preferences__voip_ringtone), ""));
 
-		ringtonePreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
-			@Override
-			public boolean onPreferenceClick(Preference preference) {
-				RingtoneSelectorDialog dialog = RingtoneSelectorDialog.newInstance(getString(R.string.prefs_notification_sound),
-					RingtoneManager.TYPE_NOTIFICATION,
-					getRingtoneFromRingtonePref(R.string.preferences__notification_sound),
-					null,
-					true,
-					true);
-				dialog.setTargetFragment(SettingsNotificationsFragment.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_CONTACT_NOTIFICATION);
-				return true;
-			}
+		ringtonePreference.setOnPreferenceClickListener(preference -> {
+			chooseRingtone(RingtoneManager.TYPE_NOTIFICATION,
+				getRingtoneFromRingtonePref(R.string.preferences__notification_sound),
+				null,
+				getString(R.string.prefs_notification_sound),
+				DIALOG_TAG_CONTACT_NOTIFICATION);
+			return true;
 		});
-		groupRingtonePreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
-			@Override
-			public boolean onPreferenceClick(Preference preference) {
-				RingtoneSelectorDialog dialog = RingtoneSelectorDialog.newInstance(getString(R.string.prefs_notification_sound),
-					RingtoneManager.TYPE_NOTIFICATION,
-					getRingtoneFromRingtonePref(R.string.preferences__group_notification_sound),
-					null,
-					true,
-					true);
-				dialog.setTargetFragment(SettingsNotificationsFragment.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_GROUP_NOTIFICATION);
-				return true;
-			}
+		groupRingtonePreference.setOnPreferenceClickListener(preference -> {
+			chooseRingtone(RingtoneManager.TYPE_NOTIFICATION,
+				getRingtoneFromRingtonePref(R.string.preferences__group_notification_sound),
+				null,
+				getString(R.string.prefs_notification_sound),
+				DIALOG_TAG_GROUP_NOTIFICATION);
+			return true;
 		});
 		voiceRingtonePreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
 			@Override
 			public boolean onPreferenceClick(Preference preference) {
-
-				RingtoneSelectorDialog dialog = RingtoneSelectorDialog.newInstance(getString(R.string.prefs_voice_call_sound),
-					RingtoneManager.TYPE_RINGTONE,
-					getRingtoneFromRingtonePref(R.string.preferences__voip_ringtone),
-					RingtoneUtil.THREEMA_CALL_RINGTONE_URI,
-					true,
-					true);
-				dialog.setTargetFragment(SettingsNotificationsFragment.this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_VOIP_NOTIFICATION);
+				chooseRingtone(RingtoneManager.TYPE_RINGTONE, getRingtoneFromRingtonePref(R.string.preferences__voip_ringtone), RingtoneUtil.THREEMA_CALL_RINGTONE_URI, getString(R.string.prefs_voice_call_sound), DIALOG_TAG_VOIP_NOTIFICATION);
 				return true;
 			}
 		});
 
 		if (ConfigUtils.isWorkRestricted()) {
-			CheckBoxPreference notificationPreview = (CheckBoxPreference) findPreference(getString(R.string.preferences__notification_preview));
+			CheckBoxPreference notificationPreview = findPreference(getString(R.string.preferences__notification_preview));
 
 			Boolean value = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__disable_message_preview));
 			if (value != null) {
@@ -222,20 +292,50 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 			}
 		}
 
-		if (ConfigUtils.isMIUI10()) {
-			ShowOnceDialog.newInstance(R.string.miui_notification_title, R.string.miui_notification_body).show(getFragmentManager(), DIALOG_TAG_MIUI_NOTICE);
+		if (miuiVersion >= 10) {
+			ShowOnceDialog.newInstance(
+				R.string.miui_notification_title,
+				miuiVersion >= 12 ?
+				R.string.miui12_notification_body:
+				R.string.miui_notification_body).show(getFragmentManager(), DIALOG_TAG_MIUI_NOTICE);
 
 			Preference miuiPreference = findPreference("pref_key_miui");
-			miuiPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
-				@Override
-				public boolean onPreferenceClick(Preference preference) {
-					openMIUINotificationSettings();
-					return true;
-				}
+			miuiPreference.setOnPreferenceClickListener(preference -> {
+				openMIUINotificationSettings();
+				return true;
 			});
 		}
 	}
 
+	private void chooseRingtone(final int type, final Uri currentUri, final Uri defaultUri, final String title, final String tag) {
+		try {
+			Intent intent = RingtoneUtil.getRingtonePickerIntent(type, currentUri, defaultUri);
+
+			switch (tag) {
+				case DIALOG_TAG_VOIP_NOTIFICATION:
+					voipRingtonePickerLauncher.launch(intent);
+					break;
+				case DIALOG_TAG_CONTACT_NOTIFICATION:
+					contactTonePickerLauncher.launch(intent);
+					break;
+				case DIALOG_TAG_GROUP_NOTIFICATION:
+					groupTonePickerLauncher.launch(intent);
+					break;
+			}
+		} catch (ActivityNotFoundException e) {
+			RingtoneSelectorDialog dialog = RingtoneSelectorDialog.newInstance(
+				title,
+				type,
+				currentUri,
+				defaultUri,
+				true,
+				true);
+			dialog.setTargetFragment(SettingsNotificationsFragment.this, 0);
+			dialog.show(getFragmentManager(), tag);
+		}
+
+	}
+
 	@Override
 	public void onViewCreated(View view, Bundle savedInstanceState) {
 		preferenceFragmentCallbackInterface.setToolbarTitle(R.string.prefs_notifications);
@@ -271,6 +371,10 @@ public class SettingsNotificationsFragment extends ThreemaPreferenceFragment imp
 		preference.setSummary(summary);
 	}
 
+	private void updateTimeSummary(Preference preference, @StringRes int defaultSummary) {
+		preference.setSummary(sharedPreferences.getString(preference.getKey(), getString(defaultSummary)));
+	}
+
 	private void openMIUINotificationSettings() {
 		ComponentName cn = new ComponentName("com.android.settings", "com.android.settings.Settings$NotificationFilterActivity");
 		Bundle bundle = new Bundle();

+ 8 - 125
app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.java

@@ -49,8 +49,6 @@ import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.routines.ValidateContactsIntegrationRoutine;
-import ch.threema.app.services.ContactService;
 import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -58,7 +56,6 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.SynchronizeContactsUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
 
 public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implements CancelableHorizontalProgressDialog.ProgressDialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(SettingsPrivacyFragment.class);
@@ -68,15 +65,12 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 	private static final String DIALOG_TAG_DISABLE_SYNC = "dissync";
 
 	private static final int PERMISSION_REQUEST_CONTACTS = 1;
-	private static final int PERMISSION_REQUEST_VALIDATE_CONTACTS = 2;
 
 	private ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 	private SynchronizeContactsService synchronizeContactsService;
 	private TwoStatePreference contactSyncPreference;
-	private Preference validateContacts;
-	private CheckBoxPreference disableScreenshot, showBadge;
-	private boolean runIntegrationAfterSync = false;
-	private boolean disableScreenshotChecked = false, showBadgeChecked = false;
+	private CheckBoxPreference disableScreenshot;
+	private boolean disableScreenshotChecked = false;
 
 	private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
 		@Override
@@ -85,6 +79,7 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 				@Override
 				public void run() {
 					updateView();
+					GenericProgressDialog.newInstance(R.string.wizard1_sync_contacts, R.string.please_wait).show(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS);
 				}
 			});
 		}
@@ -95,7 +90,9 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 				@Override
 				public void run() {
 					updateView();
-					validateRunAfterIntegration();
+					if(SettingsPrivacyFragment.this.isAdded()) {
+						DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS, true);
+					}
 				}
 			});
 		}
@@ -106,7 +103,6 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 				@Override
 				public void run() {
 					updateView();
-
 					if(SettingsPrivacyFragment.this.isAdded()) {
 						DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS, true);
 					}
@@ -129,8 +125,6 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 
 		this.disableScreenshot = (CheckBoxPreference) findPreference(getString(R.string.preferences__hide_screenshots));
 		this.disableScreenshotChecked = this.disableScreenshot.isChecked();
-		this.showBadge = (CheckBoxPreference) findPreference(getResources().getString(R.string.preferences__show_unread_badge));
-		this.showBadgeChecked = this.showBadge.isChecked();
 
 		this.contactSyncPreference = (TwoStatePreference) findPreference(getResources().getString(R.string.preferences__sync_contacts));
 		CheckBoxPreference blockUnknown = (CheckBoxPreference) findPreference(getString(R.string.preferences__block_unknown));
@@ -197,17 +191,6 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 			}
 		});
 
-		this.validateContacts = findPreference(getResources().getString(R.string.preferences__validate_contacts));
-		this.validateContacts.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
-			@Override
-			public boolean onPreferenceClick(Preference preference) {
-				if (ConfigUtils.requestContactPermissions(getActivity(), SettingsPrivacyFragment.this, PERMISSION_REQUEST_VALIDATE_CONTACTS)) {
-					runContactIntegration(false);
-				}
-				return true;
-			}
-		});
-
 		if (Build.VERSION.SDK_INT < 29) {
 			PreferenceCategory preferenceCategory = (PreferenceCategory) findPreference("pref_key_other");
 			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
@@ -219,94 +202,6 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 		this.updateView();
 	}
 
-	private void runContactIntegration(final boolean quiet) {
-		final ContactService contactService;
-
-		if(!this.requireInstances()) {
-			return;
-		}
-
-		try {
-			contactService = serviceManager.getContactService();
-		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-			logger.error("Exception", e);
-			return;
-		}
-
-		//disable
-		contactSyncPreference.setEnabled(false);
-
-		new Thread(new Runnable() {
-			@Override
-			public void run() {
-				ValidateContactsIntegrationRoutine validateContactsIntegrationRoutine = new ValidateContactsIntegrationRoutine(contactService,
-						new ValidateContactsIntegrationRoutine.OnStatusUpdate() {
-							@Override
-							public void init(final int records) {
-								RuntimeUtil.runOnUiThread(new Runnable() {
-									@Override
-									public void run() {
-										if (!quiet) {
-											CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.prefs_validate_contacts_loading, 0, 0, records);
-											dialog.setTargetFragment(SettingsPrivacyFragment.this, 0);
-											dialog.show(getFragmentManager(), DIALOG_TAG_VALIDATE);
-										}
-									}
-								});
-							}
-
-							@Override
-							public void progress(final int record, ContactModel contact) {
-								RuntimeUtil.runOnUiThread(() -> DialogUtil.updateProgress(getFragmentManager(), DIALOG_TAG_VALIDATE, record + 1));
-							}
-
-							@Override
-							public void error(final Exception x) {
-								RuntimeUtil.runOnUiThread(new Runnable() {
-									@Override
-									public void run() {
-										DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_VALIDATE, true);
-										DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS, true);
-										updateView();
-										logger.error("Exception", x);
-									}
-								});
-							}
-
-							@Override
-							public void finished() {
-								if (isAdded()) {
-									//very bad stuff, sleep 1 sec to be sure the dialogs created
-									try {
-										Thread.sleep(1000);
-									} catch (InterruptedException e) {
-										//do nothing
-									}
-
-									RuntimeUtil.runOnUiThread(new Runnable() {
-										@Override
-										public void run() {
-											updateView();
-											DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_VALIDATE, true);
-											DialogUtil.dismissDialog(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS, true);
-										}
-									});
-								}
-							}
-						});
-
-				validateContactsIntegrationRoutine.run();
-			}
-		}).start();
-	}
-
-	private void validateRunAfterIntegration() {
-		if(this.runIntegrationAfterSync) {
-			this.runIntegrationAfterSync = false;
-			this.runContactIntegration(true);
-		}
-	}
-
 	private void updateView() {
 		if(this.synchronizeContactsService.isSynchronizationInProgress()) {
 			//disable switcher
@@ -372,11 +267,7 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 
 	private void launchContactsSync() {
 		//start a Sync
-		if(synchronizeContactsService.instantiateSynchronizationAndRun()) {
-			this.runIntegrationAfterSync = true;
-			//show loading dialog
-			GenericProgressDialog.newInstance(R.string.wizard1_sync_contacts, R.string.please_wait).show(getFragmentManager(), DIALOG_TAG_SYNC_CONTACTS);
-		}
+		synchronizeContactsService.instantiateSynchronizationAndRun();
 	}
 
 	private boolean disableSync() {
@@ -426,8 +317,7 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 	public void onDetach() {
 		super.onDetach();
 
-		if (this.disableScreenshot.isChecked() != this.disableScreenshotChecked
-			|| this.showBadge.isChecked() != this.showBadgeChecked) {
+		if (this.disableScreenshot.isChecked() != this.disableScreenshotChecked) {
 			ConfigUtils.recreateActivity(getActivity());
 		}
 	}
@@ -443,13 +333,6 @@ public class SettingsPrivacyFragment extends ThreemaPreferenceFragment implement
 					disableSync();
 				}
 				break;
-			case PERMISSION_REQUEST_VALIDATE_CONTACTS:
-				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-					runContactIntegration(false);
-				} else {
-					disableSync();
-				}
-				break;
 		}
 	}
 

+ 5 - 1
app/src/main/java/ch/threema/app/processors/MessageProcessor.java

@@ -39,6 +39,7 @@ import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.ballot.BallotVoteResult;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.MessageDiskSizeUtil;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.client.AbstractGroupMessage;
@@ -126,7 +127,10 @@ public class MessageProcessor implements MessageProcessorInterface {
 
 		AbstractMessage msg;
 		try {
-			// check first, if contact of incoming message is a already known
+			if (ConfigUtils.isWorkBuild() && preferenceService.isBlockUnknown()) {
+				contactService.createWorkContact(boxmsg.getFromIdentity());
+			}
+
 			// try to fetch the key - throws MissingPublicKeyException if contact is blocked or fetching failed
 			msg = AbstractMessage.decodeFromBox(
 					boxmsg,

+ 112 - 57
app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java

@@ -22,9 +22,9 @@
 package ch.threema.app.routines;
 
 import android.Manifest;
+import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.Context;
-import android.content.pm.PackageManager;
 import android.database.Cursor;
 import android.os.Build;
 import android.provider.ContactsContract;
@@ -38,15 +38,18 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-import androidx.core.content.ContextCompat;
+import androidx.annotation.RequiresPermission;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.LocaleService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.stores.MatchTokenStore;
 import ch.threema.app.utils.AndroidContactUtil;
+import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.VerificationLevel;
@@ -64,15 +67,17 @@ public class SynchronizeContactsRoutine implements Runnable {
 	private final LocaleService localeService;
 	private final ContentResolver contentResolver;
 	private final IdListService excludedSyncList;
-	private DeviceService deviceService;
+	private final DeviceService deviceService;
 	private final PreferenceService preferenceService;
 	private final IdentityStoreInterface identityStore;
+	private final IdListService blackListIdentityService;
+	private final LicenseService<?> licenseService;
 
 	private OnStatusUpdate onStatusUpdate;
-	private List<OnFinished> onFinished = new ArrayList<OnFinished>();
-	private List<OnStarted> onStarted = new ArrayList<OnStarted>();
+	private final List<OnFinished> onFinished = new ArrayList<OnFinished>();
+	private final List<OnStarted> onStarted = new ArrayList<OnStarted>();
 
-	private List<String> processingIdentities = new ArrayList<String>();
+	private final List<String> processingIdentities = new ArrayList<>();
 	private boolean abort = false;
 	private boolean running = false;
 
@@ -91,15 +96,17 @@ public class SynchronizeContactsRoutine implements Runnable {
 	}
 
 	public SynchronizeContactsRoutine(Context context,
-									  APIConnector apiConnector,
-									  ContactService contactService,
-									  UserService userService,
-									  LocaleService localeService,
-									  ContentResolver contentResolver,
-									  IdListService excludedSyncList,
-									  DeviceService deviceService,
+	                                  APIConnector apiConnector,
+	                                  ContactService contactService,
+	                                  UserService userService,
+	                                  LocaleService localeService,
+	                                  ContentResolver contentResolver,
+	                                  IdListService excludedSyncList,
+	                                  DeviceService deviceService,
 	                                  PreferenceService preferenceService,
-	                                  IdentityStoreInterface identityStore) {
+	                                  IdentityStoreInterface identityStore,
+	                                  IdListService blackListIdentityService,
+	                                  LicenseService<?> licenseService) {
 		this.context = context;
 		this.apiConnector = apiConnector;
 		this.userService = userService;
@@ -110,6 +117,8 @@ public class SynchronizeContactsRoutine implements Runnable {
 		this.deviceService = deviceService;
 		this.preferenceService = preferenceService;
 		this.identityStore = identityStore;
+		this.licenseService = licenseService;
+		this.blackListIdentityService = blackListIdentityService;
 	}
 
 	public SynchronizeContactsRoutine addProcessIdentity(String identity) {
@@ -128,21 +137,23 @@ public class SynchronizeContactsRoutine implements Runnable {
 	}
 
 	public boolean fullSync() {
-		return this.processingIdentities == null || this.processingIdentities.size() == 0;
+		return this.processingIdentities.size() == 0;
 	}
 
 	@Override
+	@RequiresPermission(Manifest.permission.WRITE_CONTACTS)
 	public void run() {
+		logger.info("SynchronizeContacts run started.");
 
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
-				ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
+		if (!ConfigUtils.isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)) {
+			logger.info("No contacts permission. Aborting.");
 			return;
 		}
 
 		this.running = true;
 
 		for(OnStarted s: this.onStarted) {
-			s.started(this.processingIdentities == null || this.processingIdentities.size() == 0);
+			s.started(this.processingIdentities.size() == 0);
 		}
 
 		boolean success = false;
@@ -172,15 +183,22 @@ public class SynchronizeContactsRoutine implements Runnable {
 					emails, phoneNumbers, this.localeService.getCountryIsoCode(), false, identityStore, matchTokenStore);
 
 			final List<String> preSynchronizedIdentities = new ArrayList<>();
+			HashMap<String, Long> existingRawContacts = new HashMap<>();
 
 			if(this.fullSync()) {
 				List<String> synchronizedIdentities = this.contactService.getSynchronizedIdentities();
 				if (synchronizedIdentities != null) {
 					preSynchronizedIdentities.addAll(synchronizedIdentities);
 				}
+
+				existingRawContacts = AndroidContactUtil.getInstance().getAllThreemaRawContacts();
+				if (existingRawContacts != null) {
+					logger.debug("Number of existing raw contacts {}", existingRawContacts.size());
+				}
 			}
 
 			//looping result and create/update contacts
+			ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
 			for (Map.Entry<String, APIConnector.MatchIdentityResult> id : foundIds.entrySet()) {
 				if(this.abort) {
 					//abort!
@@ -200,7 +218,7 @@ public class SynchronizeContactsRoutine implements Runnable {
 					continue;
 				}
 
-				if(this.processingIdentities != null && this.processingIdentities.size() > 0 && !this.processingIdentities.contains(id.getKey())) {
+				if(this.processingIdentities.size() > 0 && !this.processingIdentities.contains(id.getKey())) {
 					continue;
 				}
 
@@ -210,7 +228,7 @@ public class SynchronizeContactsRoutine implements Runnable {
 				final ContactMatchKeyEmail matchKeyEmail = (ContactMatchKeyEmail)id.getValue().refObjectEmail;
 				final ContactMatchKeyPhone matchKeyPhone = (ContactMatchKeyPhone)id.getValue().refObjectMobileNo;
 
-				int contactId;
+				long contactId;
 				String lookupKey;
 				if (matchKeyEmail != null) {
 					contactId = matchKeyEmail.contactId;
@@ -238,20 +256,69 @@ public class SynchronizeContactsRoutine implements Runnable {
 					contact.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
 				}
 
-				//update contact name
 				contact.setIsSynchronized(true);
 				contact.setIsHidden(false);
-				contact.setAndroidContactId(contactId > 0 ? lookupKey + "/" + contactId : lookupKey); // It can optionally also have a "/" and last known contact ID appended after that. This "complete" format is an important optimization and is highly recommended.
-				AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
-				AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
+				if (contactId > 0L) {
+					contact.setAndroidContactLookupKey(lookupKey + "/" + contactId); // It can optionally also have a "/" and last known contact ID appended after that. This "complete" format is an important optimization and is highly recommended.
+				}
 
-				//save the contact
-				this.contactService.save(contact);
+				if (fullSync()) {
+					Long parentContactId = existingRawContacts.get(contact.getIdentity());
+
+					if (parentContactId == null || parentContactId != contactId) {
+						if (contactId > 0L) {
+							// raw contact does not exist yet, create it
+							boolean supportsVoiceCalls = ContactUtil.canReceiveVoipMessages(contact, this.blackListIdentityService)
+								&& ConfigUtils.isCallsEnabled(context, preferenceService, licenseService);
+
+							// create a raw contact for our stuff and aggregate it
+							AndroidContactUtil.getInstance().createThreemaRawContact(
+								contentProviderOperations,
+								matchKeyEmail != null ?
+									matchKeyEmail.rawContactId :
+									matchKeyPhone.rawContactId,
+								contact,
+								supportsVoiceCalls);
+
+							// delete entry after processing - remaining ("stray") entries will be deleted from raw contacts after this run
+							existingRawContacts.remove(contact.getIdentity());
+						}
+					} else {
+						// all good - we can delete the hash
+						existingRawContacts.remove(contact.getIdentity());
+					}
+				}
 
+				try {
+					AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
+					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
+
+					// save the contact
+					this.contactService.save(contact);
+				} catch (ThreemaException e) {
+					existingRawContacts.put(contact.getIdentity(), 0L);
+					logger.error("Contact lookup Exception", e);
+				}
+			}
+
+			if (contentProviderOperations.size() > 0) {
+				try {
+					context.getContentResolver().applyBatch(
+						ContactsContract.AUTHORITY,
+						contentProviderOperations);
+				} catch (Exception e) {
+					logger.error("Error during raw contact creation! ", e);
+				}
+				contentProviderOperations.clear();
+			}
+
+			// delete remaining / stray raw contacts
+			if (existingRawContacts.size() > 0) {
+				AndroidContactUtil.getInstance().deleteThreemaRawContacts(existingRawContacts);
 			}
 
 			if (preSynchronizedIdentities.size() > 0) {
-				logger.debug("degrade contact(s). found " + String.valueOf(preSynchronizedIdentities.size()) + " not synchronized contacts");
+				logger.debug("Degrade contact(s). found {} synchronized contacts that are not synchronized" , preSynchronizedIdentities.size());
 
 				List<ContactModel> contactModels = this.contactService.getByIdentities(preSynchronizedIdentities);
 				modifiedCount += this.contactService.save(
@@ -260,33 +327,20 @@ public class SynchronizeContactsRoutine implements Runnable {
 							@Override
 							public boolean process(ContactModel contactModel) {
 								contactModel.setIsSynchronized(false);
-								if(contactModel.getVerificationLevel() == VerificationLevel.SERVER_VERIFIED) {
-									contactModel.setVerificationLevel(VerificationLevel.UNVERIFIED);
-								}
 								return true;
 							}
 						}
 				);
 			}
-
-			//after all, check integration
-			if(this.fullSync()) {
-				new ValidateContactsIntegrationRoutine(
-						this.contactService,
-						null
-				).run();
-			}
-
 			success = true;
 		} catch (final Exception x) {
-			logger.debug("failed");
 			success = false;
 			logger.error("Exception", x);
 			if (this.onStatusUpdate != null) {
 				this.onStatusUpdate.error(x);
 			}
 		} finally {
-			logger.debug("finished [success=" + success + ", modified=" + modifiedCount + ", inserted =" + insertedContacts.size() + ", deleted =" + deletedCount + "]");
+			logger.debug("Finished [success=" + success + ", modified=" + modifiedCount + ", inserted =" + insertedContacts.size() + ", deleted =" + deletedCount + "]");
 			for (OnFinished f : this.onFinished) {
 				f.finished(success, modifiedCount, insertedContacts, deletedCount);
 			}
@@ -308,10 +362,6 @@ public class SynchronizeContactsRoutine implements Runnable {
 		return this;
 	}
 
-	public void removeOnFinished(OnFinished onFinished) {
-		this.onFinished.remove(onFinished);
-	}
-
 	private Map<String, ContactMatchKeyPhone> readPhoneNumbers() {
 		Map<String, ContactMatchKeyPhone> phoneNumbers = new HashMap<>();
 		String selection;
@@ -325,6 +375,7 @@ public class SynchronizeContactsRoutine implements Runnable {
 		try (Cursor phonesCursor = this.contentResolver.query(
 			ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
 			new String[]{
+				ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID,
 				ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
 				ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY,
 				ContactsContract.CommonDataKinds.Phone.NUMBER,
@@ -334,20 +385,22 @@ public class SynchronizeContactsRoutine implements Runnable {
 			null)) {
 
 			if (phonesCursor != null && phonesCursor.getCount() > 0) {
-				int idColumnIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID);
+				final int rawContactIdIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID);
+				final int idColumnIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID);
 				final int lookupKeyColumnIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY);
 				final int phoneNumberIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER);
 
 				while (phonesCursor.moveToNext()) {
-					int contactId = phonesCursor.getInt(idColumnIndex);
+					long rawContactId = phonesCursor.getLong(rawContactIdIndex);
+					long contactId = phonesCursor.getLong(idColumnIndex);
 					String lookupKey = phonesCursor.getString(lookupKeyColumnIndex);
 					String phoneNumber = phonesCursor.getString(phoneNumberIndex);
 
-//					logger.debug("id: " + contactId + " phone: " + phoneNumber + " lookupKey: " + lookupKey);
 					if (lookupKey != null && !TestUtil.empty(phoneNumber)) {
 						ContactMatchKeyPhone matchKey = new ContactMatchKeyPhone();
 						matchKey.contactId = contactId;
 						matchKey.lookupKey = lookupKey;
+						matchKey.rawContactId = rawContactId;
 						matchKey.phoneNumber = phoneNumber;
 						phoneNumbers.put(phoneNumber, matchKey);
 					}
@@ -369,6 +422,7 @@ public class SynchronizeContactsRoutine implements Runnable {
 		try (Cursor emailsCursor = this.contentResolver.query(
 			ContactsContract.CommonDataKinds.Email.CONTENT_URI,
 			new String[]{
+				ContactsContract.CommonDataKinds.Email.RAW_CONTACT_ID,
 				ContactsContract.CommonDataKinds.Email.CONTACT_ID,
 				ContactsContract.CommonDataKinds.Email.LOOKUP_KEY,
 				ContactsContract.CommonDataKinds.Email.DATA
@@ -378,20 +432,22 @@ public class SynchronizeContactsRoutine implements Runnable {
 			null)) {
 
 			if (emailsCursor != null && emailsCursor.getCount() > 0) {
-				int idColumnIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.CONTACT_ID);
+				final int rawContactIdIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.RAW_CONTACT_ID);
+				final int idColumnIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.CONTACT_ID);
 				final int lookupKeyColumnIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.LOOKUP_KEY);
 				final int emailIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.DATA);
 
 				while (emailsCursor.moveToNext()) {
-					int contactId = emailsCursor.getInt(idColumnIndex);
+					long rawContactId = emailsCursor.getLong(rawContactIdIndex);
+					long contactId = emailsCursor.getLong(idColumnIndex);
 					String lookupKey = emailsCursor.getString(lookupKeyColumnIndex);
 					String email = emailsCursor.getString(emailIndex);
 
-//					logger.debug("id: " + contactId + " email: " + email + " lookupKey: " + lookupKey);
 					if (lookupKey != null && !TestUtil.empty(email)) {
 						ContactMatchKeyEmail matchKey = new ContactMatchKeyEmail();
 						matchKey.contactId = contactId;
 						matchKey.lookupKey = lookupKey;
+						matchKey.rawContactId = rawContactId;
 						matchKey.email = email;
 						emails.put(email, matchKey);
 					}
@@ -402,18 +458,17 @@ public class SynchronizeContactsRoutine implements Runnable {
 		return emails;
 	}
 
-
-	private class ContactMatchKey {
-		int contactId;
+	private static class ContactMatchKey {
+		long contactId;
 		String lookupKey;
+		long rawContactId;
 	}
 
-	private class ContactMatchKeyEmail extends ContactMatchKey {
+	private static class ContactMatchKeyEmail extends ContactMatchKey {
 		String email;
 	}
 
-	private class ContactMatchKeyPhone extends ContactMatchKey {
+	private static class ContactMatchKeyPhone extends ContactMatchKey {
 		String phoneNumber;
 	}
-
 }

+ 0 - 79
app/src/main/java/ch/threema/app/routines/ValidateContactsIntegrationRoutine.java

@@ -1,79 +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.routines;
-
-import java.util.List;
-
-import ch.threema.app.services.ContactService;
-import ch.threema.app.utils.AndroidContactUtil;
-import ch.threema.storage.models.ContactModel;
-
-public class ValidateContactsIntegrationRoutine implements Runnable {
-	private final ContactService contactService;
-	private final OnStatusUpdate onStatusUpdate;
-
-	public interface OnStatusUpdate {
-		void init(final int records);
-		void progress(final int record, final ContactModel contact);
-		void error(final Exception x);
-		void finished();
-	}
-
-	public ValidateContactsIntegrationRoutine(ContactService contactService, OnStatusUpdate onStatusUpdate) {
-		this.contactService = contactService;
-		this.onStatusUpdate = onStatusUpdate;
-	}
-
-
-	@Override
-	public void run() {
-		try {
-			List<ContactModel> contacts = this.contactService.getAll(true, true);
-			if(this.onStatusUpdate != null) {
-				this.onStatusUpdate.init(contacts.size());
-			}
-
-			AndroidContactUtil.getInstance().startCache();
-
-			for(int n = 0; n < contacts.size(); n++) {
-				ContactModel contactModel = contacts.get(n);
-				if(contactModel != null) {
-					if(this.onStatusUpdate != null) {
-						this.onStatusUpdate.progress(n, contactModel);
-					}
-					this.contactService.validateContactAggregation(contactModel, true);
-				}
-			}
-
-			AndroidContactUtil.getInstance().stopCache();
-		}
-		catch (Exception x) {
-			if(this.onStatusUpdate != null) {
-				this.onStatusUpdate.error(x);
-			}
-		}
-		if(this.onStatusUpdate != null) {
-			this.onStatusUpdate.finished();
-		}
-	}
-
-}

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

@@ -40,6 +40,7 @@ import androidx.annotation.Nullable;
 import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
 import ch.threema.app.R;
 import ch.threema.app.stores.IdentityStore;
+import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.AvatarConverterUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ColorUtil;
@@ -291,7 +292,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 				if (!ContactUtil.isChannelContact(contactModel)) {
 					// regular contacts
 
-					Uri contactUri = ContactUtil.getAndroidContactUri(this.context, contactModel);
+					Uri contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contactModel);
 					if (contactUri != null) {
 						// address book contact
 						try {

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

@@ -30,13 +30,14 @@ final public class BrowserDetectionServiceImpl implements BrowserDetectionServic
 					&& desc.contains("chrome") && desc.contains("safari")
 					&& desc.contains("opr")) {
 				return Browser.OPERA;
-			} else if (desc.contains("chrome") && desc.contains("webkit") && !desc.contains("edge")) {
+			} else if (desc.contains("chrome") && desc.contains("webkit") && !desc.contains("edge")
+				&& !desc.contains("edg")) {
 				return Browser.CHROME;
 			} else if (desc.contains("mozilla") && desc.contains("firefox")) {
 				return Browser.FIREFOX;
 			} else if (desc.contains("safari") && desc.contains("applewebkit") && !desc.contains("chrome")) {
 				return Browser.SAFARI;
-			} else if (desc.contains("edge")) {
+			} else if (desc.contains("edge") || desc.contains("edg")) {
 				return Browser.EDGE;
 			}
 		}

+ 7 - 11
app/src/main/java/ch/threema/app/services/ContactService.java

@@ -39,6 +39,7 @@ import ch.threema.client.AbstractMessage;
 import ch.threema.client.ContactDeletePhotoMessage;
 import ch.threema.client.ContactRequestPhotoMessage;
 import ch.threema.client.ContactSetPhotoMessage;
+import ch.threema.client.work.WorkContact;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.access.AccessModel;
 
@@ -117,6 +118,9 @@ public interface ContactService extends AvatarService<ContactModel> {
 
 	List<String> getSynchronizedIdentities();
 
+	@Nullable
+	List<String> getIdentitiesByVerificationLevel(VerificationLevel verificationLevel);
+
 	@Nullable
 	ContactModel getByPublicKey(byte[] publicKey);
 
@@ -189,20 +193,10 @@ public interface ContactService extends AvatarService<ContactModel> {
 	 */
 	void updatePublicNickName(AbstractMessage msg);
 
-
-	/**
-	 * update all contact names with a android linkBallot
-	 * @return
-	 */
-	boolean validateContactNames();
-
-	boolean validateContactAggregation(ContactModel contactModel, boolean autoCorrect);
+	boolean updateAllContactNamesAndAvatarsFromAndroidContacts();
 
 	void removeAllThreemaContactIds();
 
-	boolean link(ContactModel contact, String lookupKey);
-	boolean unlink(ContactModel contact);
-
 	boolean rebuildColors();
 
 	@Deprecated
@@ -226,4 +220,6 @@ public interface ContactService extends AvatarService<ContactModel> {
 
 	void setName(ContactModel contact, String firstName, String lastName);
 	String getAndroidContactLookupUriString(ContactModel contactModel);
+	@Nullable ContactModel addWorkContact(@NonNull WorkContact workContact, @Nullable List<ContactModel> existingWorkContacts);
+	void createWorkContact(@NonNull String identity);
 }

+ 127 - 256
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -68,8 +68,6 @@ 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.ContactListener;
-import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.ContactTypingListener;
 import ch.threema.app.listeners.ProfileListener;
 import ch.threema.app.managers.ListenerManager;
@@ -77,6 +75,7 @@ import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.routines.UpdateBusinessAvatarRoutine;
 import ch.threema.app.routines.UpdateFeatureLevelRoutine;
 import ch.threema.app.services.license.LicenseService;
+import ch.threema.app.services.license.UserCredentials;
 import ch.threema.app.stores.ContactStore;
 import ch.threema.app.stores.IdentityStore;
 import ch.threema.app.utils.AndroidContactUtil;
@@ -101,6 +100,7 @@ import ch.threema.client.IdentityType;
 import ch.threema.client.MessageQueue;
 import ch.threema.client.ProtocolDefines;
 import ch.threema.client.ThreemaFeature;
+import ch.threema.client.work.WorkContact;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseUtil;
 import ch.threema.storage.QueryBuilder;
@@ -123,6 +123,7 @@ public class ContactServiceImpl implements ContactService {
 	private final MessageQueue messageQueue;
 	private final IdentityStore identityStore;
 	private final PreferenceService preferenceService;
+	private final IdListService excludeFromSyncListService;
 	private final Map<String, ContactModel> contactModelCache;
 	private final IdListService blackListIdentityService, profilePicRecipientsService;
 	private DeadlineListService mutedChatsListService, hiddenChatsListService;
@@ -184,6 +185,7 @@ public class ContactServiceImpl implements ContactService {
 			ApiService apiService,
 			WallpaperService wallpaperService,
 			LicenseService licenseService,
+			IdListService excludeFromSyncListService,
 			APIConnector apiConnector) {
 
 		this.context = context;
@@ -204,6 +206,7 @@ public class ContactServiceImpl implements ContactService {
 		this.apiService = apiService;
 		this.wallpaperService = wallpaperService;
 		this.licenseService = licenseService;
+		this.excludeFromSyncListService = excludeFromSyncListService;
 		this.apiConnector = apiConnector;
 		this.typingTimer = new Timer();
 		this.typingTimerTasks = new HashMap<>();
@@ -540,6 +543,7 @@ public class ContactServiceImpl implements ContactService {
 	}
 
 	@Override
+	@Nullable
 	public List<String> getSynchronizedIdentities() {
 		Cursor c = this.databaseServiceNew.getReadableDatabase().rawQuery("" +
 				"SELECT identity FROM contacts " +
@@ -558,6 +562,26 @@ public class ContactServiceImpl implements ContactService {
 		return null;
 	}
 
+	@Override
+	@Nullable
+	public List<String> getIdentitiesByVerificationLevel(VerificationLevel verificationLevel) {
+		Cursor c = this.databaseServiceNew.getReadableDatabase().rawQuery("" +
+				"SELECT identity FROM contacts " +
+				"WHERE verificationLevel = ?",
+			new String[]{String.valueOf(verificationLevel.getCode())});
+
+		if(c != null) {
+			List<String> identities = new ArrayList<>();
+			while(c.moveToNext()) {
+				identities.add(c.getString(0));
+			}
+			c.close();
+			return identities;
+		}
+
+		return null;
+	}
+
 	@Override
 	@Nullable
 	public ContactModel getByPublicKey(byte[] publicKey) {
@@ -710,7 +734,7 @@ public class ContactServiceImpl implements ContactService {
 	}
 
 	@Override
-	public boolean remove(ContactModel model, boolean removeLink) {
+	public boolean remove(@NonNull ContactModel model, boolean removeLink) {
 		String uniqueIdString = getUniqueIdString(model);
 
 		clearAvatarCache(model);
@@ -744,10 +768,8 @@ public class ContactServiceImpl implements ContactService {
 
 		}
 
-		//remove android Contact
-		if(removeLink) {
-			//split contact
-			AndroidContactUtil.getInstance().deleteRawContactByIdentity(model.getIdentity());
+		if (removeLink) {
+			AndroidContactUtil.getInstance().deleteThreemaRawContact(model);
 		}
 
 		return true;
@@ -979,15 +1001,12 @@ public class ContactServiceImpl implements ContactService {
 	@Override
 	public void removeAll() {
 		for(ContactModel model: this.find(null)) {
-			//remove all
-			//do not remove link!
 			this.remove(model, false);
 		}
 	}
 
 	@Override
 	public ContactMessageReceiver createReceiver(ContactModel contact) {
-//		logger.debug("MessageReceiver", "create ContactMessageReceiver");
 		return new ContactMessageReceiver(contact,
 				this,
 				this.databaseServiceNew,
@@ -1019,270 +1038,42 @@ public class ContactServiceImpl implements ContactService {
 	}
 
 	@Override
-	public boolean validateContactNames() {
-		List<ContactModel> androidContacts = this.getAll(true, true);
+	public boolean updateAllContactNamesAndAvatarsFromAndroidContacts() {
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+			ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
+			return false;
+		}
 
+		List<ContactModel> androidContacts = this.getAll(true, true);
 		if(androidContacts != null) {
 			for(ContactModel c: androidContacts) {
-				if(AndroidContactUtil.getInstance().isAndroidContactNameMaster(c)) {
-					if (AndroidContactUtil.getInstance().updateNameByAndroidContact(c)) {
-						// update avatar
-						AndroidContactUtil.getInstance().updateAvatarByAndroidContact(c);
-						//update contact
-						this.save(c);
-						this.contactStore.reset(c);
-					}
-				}
-			}
-		}
-
-		return true;
-	}
-
-	/**
-	 *
-	 * @param contactModel
-	 * @param autoCorrect
-	 * @return
-	 */
-	@Override
-	public boolean validateContactAggregation(ContactModel contactModel, boolean autoCorrect) {
-		try {
-			boolean isAggregated = false; // whether the Threema raw contact is aggregated with a contact
-			boolean clear = false;
-			if (contactModel != null) {
-
-				//do not validate business ids
-				if(ContactUtil.isChannelContact(contactModel)) {
-					return true;
-				}
-
-				// do not validate hidden contacts
-				if (contactModel.isHidden()) {
-					return true;
-				}
-
-				logger.debug("starting validate integration for " + contactModel.toString());
-				if (this.preferenceService != null && this.preferenceService.isSyncContacts()) {
-					logger.debug("sync active, check integration");
-
-					String lookupKey = AndroidContactUtil.getInstance().getRawContactLookupKeyByIdentity(contactModel.getIdentity());
-					isAggregated = !TestUtil.empty(lookupKey);
-
-					if (isAggregated) {
-						logger.debug("found android contact, lookup key " + lookupKey);
-					} else {
-						logger.debug("no android contact found");
-					}
-
-					if (!isAggregated && autoCorrect) {
-						if (contactModel.getAndroidContactId() != null) {
-							logger.debug("autocorrect");
-							//create an identity record
-							boolean supportsVoiceCalls = ContactUtil.canReceiveVoipMessages(contactModel, this.blackListIdentityService)
-								&& ConfigUtils.isCallsEnabled(context, preferenceService, licenseService);
-							lookupKey = AndroidContactUtil.getInstance().createThreemaAndroidContact(
-								contactModel.getIdentity(),
-								supportsVoiceCalls);
-
-							logger.debug("created android contact, lookup key " + lookupKey);
+				if(!TestUtil.empty(c.getAndroidContactLookupKey())) {
+					try {
+						if (AndroidContactUtil.getInstance().updateNameByAndroidContact(c)) {
+							AndroidContactUtil.getInstance().updateAvatarByAndroidContact(c);
+							this.save(c);
+							this.contactStore.reset(c);
 						}
-					}
-
-					if (!TestUtil.empty(lookupKey) && !TestUtil.compare(lookupKey, contactModel.getThreemaAndroidContactId())) {
-						logger.debug("set android contact lookup key to model");
-						contactModel.setThreemaAndroidContactId(lookupKey);
-						this.save(contactModel);
-						isAggregated = true;
-						clear = true;
-					}
-
-					if (isAggregated) {
-						if (!TestUtil.empty(contactModel.getAndroidContactId())) {
-							logger.debug("android contact link found, check");
-							boolean isJoined = AndroidContactUtil.getInstance().isThreemaAndroidContactJoined(
-									contactModel.getIdentity(),
-									contactModel.getAndroidContactId());
-
-							if (isJoined) {
-								logger.debug("join exist");
-							} else {
-								logger.debug("join not exist");
-							}
-							//require a joined contact!
-							if (!isJoined && autoCorrect) {
-
-								//joining required.
-								isAggregated = AndroidContactUtil.getInstance().joinThreemaAndroidContact(
-										contactModel.getIdentity(),
-										contactModel.getAndroidContactId());
-
-								logger.debug("auto correct, success = " + String.valueOf(isAggregated));
-								clear = true;
-							}
-						}
-					}
-				} else {
-					isAggregated = true;
-
-					logger.debug("sync not active, check integration");
-					if (!TestUtil.empty(contactModel.getThreemaAndroidContactId())) {
-						//split contact
-						if (!TestUtil.empty(contactModel.getAndroidContactId())) {
-							isAggregated = false;
-
-							if (autoCorrect) {
-								logger.debug("autocorrect, split");
-								isAggregated = AndroidContactUtil.getInstance().splitThreemaAndroidContact(
-										contactModel.getIdentity(),
-										contactModel.getAndroidContactId());
-							}
-						}
-
-						//get the threema contact lookup key
-						if (isAggregated) {
-							String lookupKey = AndroidContactUtil.getInstance().getRawContactLookupKeyByIdentity(contactModel.getIdentity());
-							if (!TestUtil.empty(lookupKey)) {
-
-								logger.debug("threema android contact exist");
-								isAggregated = false;
-								if (autoCorrect) {
-									logger.debug("autocorrect");
-									AndroidContactUtil.getInstance().deleteRawContactByIdentity(contactModel.getIdentity());
-									isAggregated = true;
-								}
-							}
-						}
-
-						//reset the link to the threema android contact
-						contactModel.setThreemaAndroidContactId(null);
-						this.save(contactModel);
-						clear = true;
+					} catch (ThreemaException e) {
+						logger.error("Exception", e);
 					}
 				}
 			}
-
-			if (clear && this.avatarCacheService != null) {
-				this.avatarCacheService.reset(contactModel);
-			}
-
-
-			return isAggregated;
-		}
-		catch (Exception e) {
-			//ignore exception
-			logger.error("Exception", e);
-			return false;
 		}
+		return true;
 	}
 
 	@Override
 	public void removeAllThreemaContactIds() {
 		for(ContactModel c: this.find(null)) {
-			c.setThreemaAndroidContactId(null);
 			if (c.isSynchronized()) {
-				c.setAndroidContactId(null);
+				c.setAndroidContactLookupKey(null);
 				c.setIsSynchronized(false);
-				// degrade verification level as this contact is no longer connected to an address book contact
-				if (c.getVerificationLevel() == VerificationLevel.SERVER_VERIFIED) {
-					c.setVerificationLevel(VerificationLevel.UNVERIFIED);
-				}
 			}
 			this.save(c);
 		}
 	}
 
-	@Override
-	public boolean link(final ContactModel contact, String lookupKey) {
-		if(TestUtil.required(contact, lookupKey)) {
-			if(!TestUtil.compare(contact.getAndroidContactId(), lookupKey)) {
-				contact.setAndroidContactId(lookupKey);
-				AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
-				boolean isAvatarChanged = AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
-
-				this.save(contact);
-				boolean success = this.validateContactAggregation(contact, true);
-				if (success) {
-					if(this.avatarCacheService != null) {
-						this.avatarCacheService.reset(contact);
-					}
-					ListenerManager.contactSettingsListeners.handle(new ListenerManager.HandleListener<ContactSettingsListener>() {
-						@Override
-						public void handle(ContactSettingsListener listener) {
-							listener.onNameFormatChanged();
-							listener.onAvatarSettingChanged();
-						}
-					});
-					if (isAvatarChanged) {
-						ListenerManager.contactListeners.handle(new ListenerManager.HandleListener<ContactListener>() {
-							@Override
-							public void handle(ContactListener listener) {
-								listener.onAvatarChanged(contact);
-							}
-						});
-					}
-
-					return true;
-				}
-			}
-		}
-		return false;
-	}
-
-	@Override
-	public boolean unlink(final ContactModel contact) {
-		if(contact != null) {
-			if(ContactUtil.isLinked(contact)) {
-				String androidContactId = contact.getAndroidContactId();
-
-				AndroidContactUtil.getInstance().splitThreemaAndroidContact(
-						contact.getIdentity(),
-						androidContactId);
-
-				// remove avatar
-				boolean isAvatarRemoved = this.fileService.removeAndroidContactAvatar(contact);
-
-				//reset the names
-				contact.setFirstName(null);
-				contact.setLastName(null);
-
-				//reset the link
-				contact.setAndroidContactId(null);
-				contact.setThreemaAndroidContactId(AndroidContactUtil.getInstance().getRawContactLookupKeyByIdentity(contact.getIdentity()));
-				contact.setIsSynchronized(false);
-
-				// degrade contact if server verified
-				if(contact.getVerificationLevel() == VerificationLevel.SERVER_VERIFIED) {
-					contact.setVerificationLevel(VerificationLevel.UNVERIFIED);
-				}
-
-				this.save(contact);
-
-				if(this.avatarCacheService != null) {
-					this.avatarCacheService.reset(contact);
-				}
-				ListenerManager.contactSettingsListeners.handle(new ListenerManager.HandleListener<ContactSettingsListener>() {
-					@Override
-					public void handle(ContactSettingsListener listener) {
-						listener.onNameFormatChanged();
-						listener.onAvatarSettingChanged();
-					}
-				});
-				if (isAvatarRemoved) {
-					ListenerManager.contactListeners.handle(new ListenerManager.HandleListener<ContactListener>() {
-						@Override
-						public void handle(ContactListener listener) {
-							listener.onAvatarChanged(contact);
-						}
-					});
-				}
-
-				return true;
-			}
-		}
-		return false;
-	}
-
 	@Override
 	public boolean rebuildColors() {
 		List<ContactModel> models = this.getAll(true, true);
@@ -1559,8 +1350,8 @@ public class ContactServiceImpl implements ContactService {
 		String contactLookupUri = null;
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			if (ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-				if (contactModel != null && contactModel.getAndroidContactId() != null) {
-					Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, contactModel.getAndroidContactId());
+				if (contactModel != null && contactModel.getAndroidContactLookupKey() != null) {
+					Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, contactModel.getAndroidContactLookupKey());
 					if (lookupUri != null) {
 						contactLookupUri = lookupUri.toString();
 					}
@@ -1569,4 +1360,84 @@ public class ContactServiceImpl implements ContactService {
 		}
 		return contactLookupUri;
 	}
+
+	/**
+	 * Create a ContactModel for the provided Work contact. If a ContactModel already exists, it will be updated with the data from the Work API,
+	 * namely. name, verification level, work status. If the contact was hidden (i.e. added by a group), it will be visible after this operation
+	 * @param workContact WorkContact object for the contact to add
+	 * @param existingWorkContacts An optional list of ContactModels. If a ContactModel already exists for workContact, the ContactModel will be removed from this list
+	 * @return ContactModel of created or updated contact or null if public key of provided WorkContact was invalid
+	 */
+	@Override
+	@Nullable
+	public ContactModel addWorkContact(@NonNull WorkContact workContact, @Nullable List<ContactModel> existingWorkContacts) {
+		if (!ConfigUtils.isWorkBuild()) {
+			return null;
+		}
+
+		if (workContact.publicKey == null || workContact.publicKey.length != NaCl.PUBLICKEYBYTES) {
+			// ignore work contact with invalid public key
+			return null;
+		}
+
+		if (workContact.threemaId != null && workContact.threemaId.equals(getMe().getIdentity())) {
+			// do not add our own ID as a contact
+			return null;
+		}
+
+		ContactModel contactModel = getByIdentity(workContact.threemaId);
+
+		if (contactModel == null) {
+			contactModel = new ContactModel(workContact.threemaId, workContact.publicKey);
+		} else if (existingWorkContacts != null) {
+			// try to remove from list of existing work contacts
+			for (int x = 0; x < existingWorkContacts.size(); x++) {
+				if (existingWorkContacts.get(x).getIdentity().equals(workContact.threemaId)) {
+					existingWorkContacts.remove(x);
+					break;
+				}
+			}
+		}
+
+		if (!ContactUtil.isLinked(contactModel)
+			&& (workContact.firstName != null
+			|| workContact.lastName != null)) {
+			contactModel.setFirstName(workContact.firstName);
+			contactModel.setLastName(workContact.lastName);
+		}
+		contactModel.setIsWork(true);
+		contactModel.setIsHidden(false);
+		if (contactModel.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
+			contactModel.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+		}
+		this.save(contactModel);
+
+		return contactModel;
+	}
+
+	/**
+	 * Check if a contact for the provided identity exists, if not, try to fetch a contact from work api and add it to the contact database
+	 * @param identity Identity
+	 */
+	@Override
+	public void createWorkContact(@NonNull String identity) {
+		if (!ConfigUtils.isWorkBuild()) {
+			return;
+		}
+
+		if (contactStore.getPublicKeyForIdentity(identity, false) == null) {
+			LicenseService.Credentials credentials = this.licenseService.loadCredentials();
+			if ((credentials instanceof UserCredentials)) {
+				try {
+					List<WorkContact> workContacts = apiConnector.fetchWorkContacts(((UserCredentials) credentials).username, ((UserCredentials) credentials).password, new String[]{identity});
+					if (workContacts.size() > 0) {
+						WorkContact workContact = workContacts.get(0);
+						addWorkContact(workContact, null);
+					}
+				} catch (Exception e) {
+					logger.error("Error fetching work contact", e);
+				}
+			}
+		}
+	}
 }

+ 111 - 50
app/src/main/java/ch/threema/app/services/DownloadServiceImpl.java

@@ -23,7 +23,6 @@ package ch.threema.app.services;
 
 import android.content.Context;
 import android.os.PowerManager;
-import android.util.SparseArray;
 
 import com.neilalexander.jnacl.NaCl;
 
@@ -35,7 +34,10 @@ import java.io.BufferedOutputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.BuildConfig;
@@ -50,11 +52,73 @@ public class DownloadServiceImpl implements DownloadService {
 	private static final String TAG = "DownloadService";
 	private static final String WAKELOCK_TAG = BuildConfig.APPLICATION_ID + ":" + TAG;
 	private static final int DOWNLOAD_WAKELOCK_TIMEOUT = 10 * 1000;
-	private final SparseArray<BlobLoader> blobLoaders = new SparseArray<>();
+	private final ArrayList<Download> downloads = new ArrayList<>();
 	private final FileService fileService;
 	private final ApiService apiService;
-	private PowerManager powerManager;
+	private final PowerManager powerManager;
 
+	private final static class Download {
+		int messageModelId;
+		byte[] blobId;
+		BlobLoader blobLoader;
+
+		public Download(int messageModelId, byte[] blobId, BlobLoader blobLoader) {
+			this.messageModelId = messageModelId;
+			this.blobId = blobId;
+			this.blobLoader = blobLoader;
+		}
+	}
+
+	private @Nullable ArrayList<Download> getDownloadsByMessageModelId(int messageModelId) {
+		ArrayList<Download> matchingDownloads = new ArrayList<>();
+		for (Download download: this.downloads) {
+			if (download.messageModelId == messageModelId) {
+				matchingDownloads.add(download);
+			}
+		}
+		return matchingDownloads.size() > 0 ? matchingDownloads : null;
+	}
+
+	private @Nullable Download getDownloadByBlobId(@NonNull byte[] blobId) {
+		for (Download download: this.downloads) {
+			if (Arrays.equals(blobId, download.blobId)) {
+				return download;
+			}
+		}
+		return null;
+	}
+
+	private boolean removeDownloadByBlobId(@NonNull byte[] blobId) {
+		synchronized (this.downloads) {
+			Download download = getDownloadByBlobId(blobId);
+			if (download != null) {
+				logger.info("Blob {} remove downloader", Utils.byteArrayToHexString(blobId));
+				downloads.remove(download);
+				return true;
+			}
+			return false;
+		}
+	}
+
+	private boolean removeDownloadByMessageModelId(int messageModelId, boolean cancel) {
+		synchronized (this.downloads) {
+			ArrayList<Download> matchingDownloads = getDownloadsByMessageModelId(messageModelId);
+			if (matchingDownloads != null) {
+				for (Download download: matchingDownloads) {
+					logger.info("Blob {} remove downloader for message {}. Cancel = {}",
+						Utils.byteArrayToHexString(download.blobId),
+						messageModelId,
+						cancel);
+					if (cancel) {
+						download.blobLoader.cancel();
+					}
+					this.downloads.remove(download);
+				}
+				return true;
+			}
+			return false;
+		}
+	}
 
 	public DownloadServiceImpl(Context context, FileService fileService, ApiService apiService) {
 		this.fileService = fileService;
@@ -65,20 +129,22 @@ public class DownloadServiceImpl implements DownloadService {
 	@Override
 	@WorkerThread
 	@Nullable
-	public byte[] download(int id, byte[] blobId, boolean markAsDown, ProgressListener progressListener) {
+	public byte[] download(int messageModelId, final byte[] blobId, boolean markAsDown, ProgressListener progressListener) {
 		PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG);
 		try {
 			if (wakeLock != null) {
 				wakeLock.acquire(DOWNLOAD_WAKELOCK_TIMEOUT);
 				logger.info("Acquire download wakelock");
 			};
-			logger.info("Download blob for message {}", id);
 
 			if (blobId == null) {
 				logger.warn("Blob ID is null");
 				return null;
 			}
 
+			String blobIdHex = Utils.byteArrayToHexString(blobId);
+			logger.info("Blob {} for message {} download requested", blobIdHex, messageModelId);
+
 			byte[] imageBlob = null;
 			File downloadFile = this.getTemporaryDownloadFile(blobId);
 			boolean downloadSuccess = false;
@@ -87,7 +153,7 @@ public class DownloadServiceImpl implements DownloadService {
 				//check if a temporary file exist
 				if (downloadFile.exists()) {
 					if (downloadFile.length() >= NaCl.BOXOVERHEAD) {
-						logger.warn("Blob download file for message {} already exists", id);
+						logger.warn("Blob {} download file already exists", blobIdHex);
 						try (FileInputStream fileInputStream = new FileInputStream(downloadFile)) {
 							return IOUtils.toByteArray(fileInputStream);
 						}
@@ -97,13 +163,14 @@ public class DownloadServiceImpl implements DownloadService {
 					}
 				}
 
-				BlobLoader blobLoader = null;
-				synchronized (this.blobLoaders) {
-					if (this.blobLoaders.get(id) == null) {
+				BlobLoader blobLoader;
+				synchronized (this.downloads) {
+					if (getDownloadByBlobId(blobId) == null) {
 						blobLoader = this.apiService.createLoader(blobId);
-						this.blobLoaders.append(id, blobLoader);
+						this.downloads.add(new Download(messageModelId, blobId, blobLoader));
+						logger.info("Blob {} downloader created", blobIdHex);
 					} else {
-						logger.info("Loader for message {} already exists. Not adding again.", id);
+						logger.info("Blob {} downloader already exists. Not adding again", blobIdHex);
 						return null;
 					}
 				}
@@ -114,14 +181,14 @@ public class DownloadServiceImpl implements DownloadService {
 
 
 				// load image from server
-				logger.info("Fetching blob for message {}", id);
+				logger.info("Blob {} now fetching", blobIdHex);
 				imageBlob = blobLoader.load(false);
 
 				if (imageBlob != null) {
-					synchronized (this.blobLoaders) {
+					synchronized (this.downloads) {
 						//check if loader already existing in array (otherwise its canceled)
-						if (this.blobLoaders.get(id) != null) {
-							logger.debug("Write blob to file");
+						if (getDownloadByBlobId(blobId) != null) {
+							logger.info("Blob {} now saving", blobIdHex);
 							//write to temporary file
 							FileUtil.createNewFileOrLog(downloadFile, logger);
 							if (downloadFile.isFile()) {
@@ -135,14 +202,18 @@ public class DownloadServiceImpl implements DownloadService {
 
 									//ok download saved, set as down if set
 									if (markAsDown) {
-										logger.info("Marking message {} as downloaded", id);
-										final BlobLoader loader = this.blobLoaders.get(id);
+										logger.info("Blob {} scheduled for marking as downloaded", blobIdHex);
 										try {
 											new Thread(() -> {
-												if (loader != null) {
-													loader.markAsDown(blobId);
+												synchronized (this.downloads) {
+													Download download = getDownloadByBlobId(blobId);
+													if (download != null) {
+														if (download.blobLoader != null) {
+															download.blobLoader.markAsDown(download.blobId);
+														}
+														logger.info("Blob {} marked as downloaded", blobIdHex);
+													}
 												}
-												logger.info("Marked message {} as downloaded", id);
 											}, "MarkAsDownThread").start();
 										} catch (Exception ignored) {
 											// markAsDown thread failed
@@ -166,15 +237,19 @@ public class DownloadServiceImpl implements DownloadService {
 			}
 
 			if (downloadSuccess) {
-				logger.info("Blob for message {} successfully downloaded. Size = {}", id, imageBlob.length);
+				logger.info("Blob {} successfully downloaded. Size = {}", blobIdHex, imageBlob.length);
 			} else {
-				logger.warn("Blob download for message {} failed.", id);
+				logger.warn("Blob {} download failed.", blobIdHex);
 			}
 
 			if (imageBlob == null) {
-				synchronized (this.blobLoaders) {
+				synchronized (this.downloads) {
 					// download failed. remove loader
-					this.blobLoaders.remove(id);
+					Download download = getDownloadByBlobId(blobId);
+					if (download != null) {
+						logger.info("Blob {} remove downloader. Download failed.", blobIdHex);
+						this.downloads.remove(download);
+					}
 				}
 			}
 			return imageBlob;
@@ -187,11 +262,9 @@ public class DownloadServiceImpl implements DownloadService {
 	}
 
 	@Override
-	public void complete(int id, byte[] blobId) {
-		synchronized (this.blobLoaders) {
-			// success has been signalled. remove loader
-			this.blobLoaders.remove(id);
-		}
+	public void complete(int messageModelId, byte[] blobId) {
+		// success has been signalled. remove loader
+		removeDownloadByBlobId(blobId);
 
 		// remove temp file
 		File f = this.getTemporaryDownloadFile(blobId);
@@ -201,40 +274,28 @@ public class DownloadServiceImpl implements DownloadService {
 	}
 
 	@Override
-	public boolean cancel(int id) {
-		synchronized (this.blobLoaders) {
-			BlobLoader l = this.blobLoaders.get(id);
-			if(l != null) {
-				logger.debug("cancel blob loader");
-				l.cancel();
-				this.blobLoaders.remove(id);
-				return true;
-			}
-		}
-
-		return false;
+	public boolean cancel(int messageModelId) {
+		return removeDownloadByMessageModelId(messageModelId, true);
 	}
 
 	@Override
 	public boolean isDownloading(int messageModelId) {
-		synchronized (this.blobLoaders) {
-			return this.blobLoaders.get(messageModelId) != null;
+		synchronized (this.downloads) {
+			return getDownloadsByMessageModelId(messageModelId) != null;
 		}
 	}
 
 	@Override
 	public boolean isDownloading() {
-		synchronized (this.blobLoaders) {
-			return this.blobLoaders.size() > 0;
+		synchronized (this.downloads) {
+			return this.downloads.size() > 0;
 		}
 	}
 
 	@Override
-	public void error(int id) {
-		synchronized (this.blobLoaders) {
-			// error has been signalled. remove loader
-			this.blobLoaders.remove(id);
-		}
+	public void error(int messageModelId) {
+		// error has been signalled. remove loaders for this MessageModel
+		removeDownloadByMessageModelId(messageModelId, false);
 	}
 
 	private File getTemporaryDownloadFile(byte[] blobId) {

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

@@ -21,6 +21,7 @@
 
 package ch.threema.app.services;
 
+import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.ContentResolver;
 import android.content.ContentValues;
@@ -74,6 +75,7 @@ import javax.crypto.CipherOutputStream;
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresPermission;
 import androidx.annotation.WorkerThread;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.content.res.AppCompatResources;
@@ -1438,6 +1440,7 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@SuppressLint("StaticFieldLeak")
+	@RequiresPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
 	@Override
 	public void saveMedia(final AppCompatActivity activity, final View feedbackView, final CopyOnWriteArrayList<AbstractMessageModel> selectedMessages, final boolean quiet) {
 		new AsyncTask<Void, Integer, Integer>() {

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

@@ -129,6 +129,8 @@ public class GroupApiServiceImpl implements GroupApiService {
 			}
 		}
 
+		logger.info("Enqueue group message ID {} to {} receivers", messageId, pendingGroupMessages.size());
+
 		//fire queued first!
 		if(queued != null) {
 			for (AbstractGroupMessage groupMessage : pendingGroupMessages) {

+ 19 - 0
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -56,10 +56,13 @@ import ch.threema.app.activities.GroupDetailActivity;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.InvalidEntryException;
+import ch.threema.app.exceptions.NoIdentityException;
 import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.BitmapUtil;
@@ -82,6 +85,7 @@ import ch.threema.client.IdentityState;
 import ch.threema.client.MessageId;
 import ch.threema.client.ProtocolDefines;
 import ch.threema.client.Utils;
+import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.GroupRequestSyncLogModelFactory;
 import ch.threema.storage.models.ContactModel;
@@ -271,6 +275,21 @@ public class GroupServiceImpl implements GroupService {
 
 	@Override
 	public boolean remove(final GroupModel groupModel, boolean silent) {
+		ServiceManager serviceManager= ThreemaApplication.getServiceManager();
+		if (serviceManager != null) {
+			try {
+				// cannot assign ballot service fixed in the constructor because of circular dependency
+				ThreemaApplication.getServiceManager().getBallotService().remove(createReceiver(groupModel));
+			} catch (MasterKeyLockedException | FileSystemNotPresentException | NoIdentityException e) {
+				logger.error("Exception removing ballot models", e);
+				return false;
+			}
+		}
+		else {
+			logger.error("Missing serviceManager, cannot delete ballot models for group");
+			return false;
+		}
+
 		this.databaseServiceNew.getGroupMemberModelFactory().deleteByGroupId(groupModel.getId());
 		for(GroupMessageModel messageModel: this.databaseServiceNew.getGroupMessageModelFactory().getByGroupIdUnsorted(groupModel.getId())) {
 			//remove all message identity models

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

@@ -128,6 +128,13 @@ public interface MessageService {
 	void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers);
 	@AnyThread
 	void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers, @Nullable MessageServiceImpl.SendResultListener sendResultListener);
+
+	@AnyThread
+	void sendMediaSingleThread(
+		@NonNull List<MediaItem> mediaItems,
+		@NonNull List<MessageReceiver> messageReceivers
+	);
+
 	@WorkerThread
 	AbstractMessageModel sendMedia(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers, @Nullable MessageServiceImpl.SendResultListener sendResultListener);
 
@@ -163,8 +170,8 @@ public interface MessageService {
 	@WorkerThread
 	List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver);
 	List<AbstractMessageModel> getMessageForBallot(BallotModel ballotModel);
-	List<AbstractMessageModel> getContactMessagesForText(String query);
-	List<AbstractMessageModel> getGroupMessagesForText(String query);
+	List<AbstractMessageModel> getContactMessagesForText(String query, boolean includeArchived);
+	List<AbstractMessageModel> getGroupMessagesForText(String query, boolean includeArchived);
 
 	MessageModel getContactMessageModel(final Integer id, boolean lazy);
 	GroupMessageModel getGroupMessageModel(final Integer id, boolean lazy);
@@ -193,7 +200,7 @@ public interface MessageService {
 	 */
 	long getTotalMessageCount();
 
-	boolean shareMediaMessages(Context context, ArrayList<AbstractMessageModel> models, ArrayList<Uri> shareFileUris);
+	boolean shareMediaMessages(Context context, ArrayList<AbstractMessageModel> models, ArrayList<Uri> shareFileUris, String caption);
 	boolean viewMediaMessage(Context context, AbstractMessageModel model, Uri uri);
 	boolean shareTextMessage(Context context, AbstractMessageModel model);
 	AbstractMessageModel getMessageModelFromId(int id, String type);

+ 83 - 54
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -951,8 +951,7 @@ public class MessageServiceImpl implements MessageService {
 		return null;
 	}
 
-	private AbstractMessageModel getAbstractMessageModelByApiIdAndOutbox(final MessageId apiMessageId)
-	{
+	private AbstractMessageModel getAbstractMessageModelByApiIdAndOutbox(final MessageId apiMessageId) {
 		//contact message cache
 		synchronized (this.contactMessageCache) {
 			AbstractMessageModel messageModel = Functional.select(this.contactMessageCache, new IPredicateNonNull<MessageModel>() {
@@ -1246,8 +1245,6 @@ public class MessageServiceImpl implements MessageService {
 		MessageModel existingModel = this.databaseServiceNew.getMessageModelFactory()
 				.getByApiMessageIdAndIdentity(message.getMessageId(), message.getFromIdentity());
 
-		logger.info("processIncomingContactMessage: {} - A", message.getMessageId());
-
 		if (existingModel != null) {
 			//first search in cache
 			MessageModel savedMessageModel;
@@ -1326,9 +1323,11 @@ public class MessageServiceImpl implements MessageService {
 					receipt.getMessageId(), receipt.getReceiptMessageIds()[0], receipt.getToIdentity());
 				this.messageQueue.enqueue(receipt);
 			}
+
+			logger.info("processIncomingContactMessage: {} SUCCESS - Message ID = {}", message.getMessageId(), messageModel.getId());
 			return true;
 		}
-
+		logger.info("processIncomingContactMessage: {} FAILED", message.getMessageId());
 		return false;
 	}
 
@@ -1425,7 +1424,11 @@ public class MessageServiceImpl implements MessageService {
 			messageModel = this.saveGroupMessage((GroupFileMessage) message, messageModel);
 		}
 
-		logger.info("processIncomingGroupMessage: {} success = {}", message.getMessageId(), messageModel != null);
+		if (messageModel != null) {
+			logger.info("processIncomingGroupMessage: {} SUCCESS - Message ID = {}", message.getMessageId(), messageModel.getId());
+		} else {
+			logger.info("processIncomingGroupMessage: {} FAILED", message.getMessageId());
+		}
 
 		return messageModel != null;
 	}
@@ -1738,42 +1741,7 @@ public class MessageServiceImpl implements MessageService {
 			receiver.saveLocalModel(messageModel);
 		}
 
-		boolean hasThumbnail = fileData.getThumbnailBlobId() != null;
-
-		if(hasThumbnail) {
-			logger.info("Downloading thumbnail of message " + message.getMessageId());
-			//download thumbnail
-			final AbstractMessageModel messageModel1 = messageModel;
-
-			//use download service!
-			byte[] thumbnailBlob = this.downloadService.download(
-					messageModel.getId(),
-					fileData.getThumbnailBlobId(),
-					!(message instanceof AbstractGroupMessage),
-					new ProgressListener() {
-						@Override
-						public void updateProgress(int progress) {
-							updateMessageLoadingProgress(messageModel1, progress);
-						}
-
-						@Override
-						public void onFinished(boolean success) {
-							setMessageLoadingFinished(messageModel1, success);
-						}
-					});
-
-			byte[] thumbnail = NaCl.symmetricDecryptData(thumbnailBlob, fileData.getEncryptionKey(), ProtocolDefines.FILE_THUMBNAIL_NONCE);
-
-			try {
-				fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
-			} catch (Exception e) {
-				downloadService.error(messageModel.getId());
-				logger.info("Error writing thumbnail for message " + message.getMessageId());
-				throw e;
-			}
-
-			this.downloadService.complete(messageModel.getId(), fileData.getThumbnailBlobId());
-		}
+		downloadThumbnail(fileData, messageModel);
 
 		messageModel.setSaved(true);
 		receiver.saveLocalModel(messageModel);
@@ -1792,6 +1760,46 @@ public class MessageServiceImpl implements MessageService {
 		return messageModel;
 	}
 
+	public void downloadThumbnail(FileData fileData, AbstractMessageModel messageModel) throws Exception {
+		if (fileData.getThumbnailBlobId() != null) {
+			logger.info("Downloading thumbnail of message " + messageModel.getApiMessageId());
+			final AbstractMessageModel messageModel1 = messageModel;
+			byte[] thumbnailBlob = this.downloadService.download(
+				messageModel.getId(),
+				fileData.getThumbnailBlobId(),
+				!(messageModel instanceof GroupMessageModel),
+				new ProgressListener() {
+					@Override
+					public void updateProgress(int progress) {
+						updateMessageLoadingProgress(messageModel1, progress);
+					}
+
+					@Override
+					public void onFinished(boolean success) {
+						setMessageLoadingFinished(messageModel1, success);
+					}
+				});
+
+			if (thumbnailBlob == null) {
+				downloadService.error(messageModel.getId());
+				logger.info("Error downloading thumbnail for message " + messageModel.getApiMessageId());
+				throw new ThreemaException("Error downloading thumbnail");
+			}
+
+			byte[] thumbnail = NaCl.symmetricDecryptData(thumbnailBlob, fileData.getEncryptionKey(), ProtocolDefines.FILE_THUMBNAIL_NONCE);
+
+			try {
+				fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
+			} catch (Exception e) {
+				downloadService.error(messageModel.getId());
+				logger.info("Error writing thumbnail for message " + messageModel.getApiMessageId());
+				throw e;
+			}
+
+			this.downloadService.complete(messageModel.getId(), fileData.getThumbnailBlobId());
+		}
+	}
+
 	private GroupMessageModel saveGroupMessage(GroupTextMessage message, GroupMessageModel messageModel) throws Exception {
 		GroupModel groupModel = this.groupService.getGroup(message);
 
@@ -1862,7 +1870,7 @@ public class MessageServiceImpl implements MessageService {
 				type = MessageType.IMAGE;
 			} else if (messageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
 				type = MessageType.VIDEO;
-			} else if (messageModel.getMessageContentsType() == MessageContentsType.AUDIO) {
+			} else if (messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
 				type = MessageType.VOICEMESSAGE;
 			}
 		}
@@ -2418,13 +2426,13 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
-	public List<AbstractMessageModel> getContactMessagesForText(String query) {
-		return this.databaseServiceNew.getMessageModelFactory().getMessagesByText(query);
+	public List<AbstractMessageModel> getContactMessagesForText(String query, boolean includeArchived) {
+		return this.databaseServiceNew.getMessageModelFactory().getMessagesByText(query, includeArchived);
 	}
 
 	@Override
-	public List<AbstractMessageModel> getGroupMessagesForText(String query) {
-		return this.databaseServiceNew.getGroupMessageModelFactory().getMessagesByText(query);
+	public List<AbstractMessageModel> getGroupMessagesForText(String query, boolean includeArchived) {
+		return this.databaseServiceNew.getGroupMessageModelFactory().getMessagesByText(query, includeArchived);
 	}
 
 	private void readMessageQueue() {
@@ -2711,7 +2719,6 @@ public class MessageServiceImpl implements MessageService {
 		});
 	}
 
-
 	@Override
 	public boolean downloadMediaMessage(AbstractMessageModel mediaMessageModel, ProgressListener progressListener) throws Exception {
 		//TODO: create messageutil can download file method and unit test
@@ -3042,7 +3049,7 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	@Override
-	public boolean shareMediaMessages(final Context context, ArrayList<AbstractMessageModel> models, ArrayList<Uri> shareFileUris) {
+	public boolean shareMediaMessages(final Context context, ArrayList<AbstractMessageModel> models, ArrayList<Uri> shareFileUris, String caption) {
 		if (TestUtil.required(context, models, shareFileUris)) {
 			if (models.size() > 0 && shareFileUris.size() > 0) {
 				Intent intent;
@@ -3061,6 +3068,9 @@ public class MessageServiceImpl implements MessageService {
 					if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(shareFileUri.getScheme())) {
 						intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
 					}
+					if (!TestUtil.empty(caption)) {
+						intent.putExtra(Intent.EXTRA_TEXT, caption);
+					}
 				} else {
 					intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
 					intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, shareFileUris);
@@ -3473,7 +3483,7 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	/**
-	 * Send media messages of any kind to an arbitrary number of receivers
+	 * Send media messages of any kind to an arbitrary number of receivers using a thread pool
 	 * @param mediaItems List of MediaItems to be sent
 	 * @param messageReceivers List of MessageReceivers
 	 * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
@@ -3485,11 +3495,10 @@ public class MessageServiceImpl implements MessageService {
 	}
 
 	/**
-	 * Send media messages of any kind to an arbitrary number of receivers
+	 * Send media messages of any kind to an arbitrary number of receivers using a thread pool
 	 * @param mediaItems List of MediaItems to be sent
 	 * @param messageReceivers List of MessageReceivers
 	 * @param sendResultListener Listener to notify when messages are queued
-	 * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
 	 */
 	@AnyThread
 	@Override
@@ -3503,6 +3512,28 @@ public class MessageServiceImpl implements MessageService {
 		});
 	}
 
+	/**
+	 * Send media messages of any kind to an arbitrary number of receivers in a single thread i.e. one message after the other
+	 * @param mediaItems List of MediaItems to be sent
+	 * @param messageReceivers List of MessageReceivers
+	 */
+	@AnyThread
+	@Override
+	public void sendMediaSingleThread(
+		@NonNull final List<MediaItem> mediaItems,
+		@NonNull final List<MessageReceiver> messageReceivers) {
+		ThreemaApplication.sendMessageSingleThreadExecutorService.submit(() -> {
+			sendMedia(mediaItems, messageReceivers, null);
+		});
+	}
+
+	/**
+	 * Send media messages of any kind to an arbitrary number of receivers
+	 * @param mediaItems List of MediaItems to be sent
+	 * @param messageReceivers List of MessageReceivers
+	 * @param sendResultListener Listener to notify when messages are queued
+	 * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
+	 */
 	@WorkerThread
 	@Override
 	public @Nullable AbstractMessageModel sendMedia(
@@ -4463,6 +4494,4 @@ public class MessageServiceImpl implements MessageService {
 		return (item.getStartTimeMs() != 0 && item.getStartTimeMs() != TIME_UNDEFINED) ||
 			(item.getEndTimeMs() != TIME_UNDEFINED && item.getEndTimeMs() != item.getDurationMs());
 	}
-
-	/******************************************************************************************************/
 }

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

@@ -824,7 +824,7 @@ public class NotificationServiceImpl implements NotificationService {
 	}
 
 	private NotificationCompat.Action getMarkAsReadAction(PendingIntent markReadPendingIntent) {
-		return new NotificationCompat.Action.Builder(R.drawable.ic_mark_read, context.getString(R.string.mark_read_short), markReadPendingIntent)
+		return new NotificationCompat.Action.Builder(R.drawable.ic_mark_read_bitmap, context.getString(R.string.mark_read_short), markReadPendingIntent)
 			.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ).build();
 	}
 

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

@@ -1549,7 +1549,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 
 	@Override
 	public boolean getVoiceRecorderBluetoothDisabled() {
-		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__voicerecorder_bluetooth_disabled));
+		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__voicerecorder_bluetooth_disabled), true);
 	}
 
 	@Override

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

@@ -23,8 +23,6 @@ package ch.threema.app.services;
 
 import android.accounts.Account;
 
-import java.util.Date;
-
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 
 public interface SynchronizeContactsService {
@@ -37,10 +35,8 @@ public interface SynchronizeContactsService {
 	 */
 	SynchronizeContactsRoutine instantiateSynchronization(Account account);
 	boolean isSynchronizationInProgress();
-	Date getLatestFullSyncTime();
 	boolean isFullSyncInProgress();
 
 	boolean enableSync();
-	boolean disableSync();
 	boolean disableSync(Runnable runAfterRemovedAccount);
 }

+ 28 - 14
app/src/main/java/ch/threema/app/services/SynchronizeContactsServiceImpl.java

@@ -42,7 +42,10 @@ import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.routines.UpdateBusinessAvatarRoutine;
+import ch.threema.app.services.license.LicenseService;
+import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.ContactUtil;
+import ch.threema.base.VerificationLevel;
 import ch.threema.client.APIConnector;
 import ch.threema.client.IdentityStoreInterface;
 import ch.threema.storage.models.ContactModel;
@@ -62,7 +65,9 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 	private final PreferenceService preferenceService;
 	private final DeviceService deviceService;
 	private final Context context;
-	private FileService fileService;
+	private final FileService fileService;
+	private final IdListService blackListIdentityService;
+	private final LicenseService licenseService;
 
 	private Date latestFullSync;
 
@@ -74,7 +79,9 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 										  PreferenceService preferenceService,
 										  DeviceService deviceService,
 										  FileService fileService,
-	                                      IdentityStoreInterface identityStore) {
+	                                      IdentityStoreInterface identityStore,
+	                                      IdListService blackListIdentityService,
+	                                      LicenseService licenseService) {
 		this.excludedIdentityListService = excludedIdentityListService;
 		this.preferenceService = preferenceService;
 		this.deviceService = deviceService;
@@ -86,6 +93,8 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 		this.userService = userService;
 		this.localeService = localeService;
 		this.identityStore = identityStore;
+		this.licenseService = licenseService;
+		this.blackListIdentityService = blackListIdentityService;
 	}
 
 	@Override
@@ -162,6 +171,7 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 	public SynchronizeContactsRoutine instantiateSynchronization(Account account) {
 		logger.info("Running contact sync");
 		logger.debug("instantiateSynchronization with account {}", account);
+
 		final SynchronizeContactsRoutine routine =
 				new SynchronizeContactsRoutine(
 						this.context,
@@ -173,7 +183,9 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 						this.excludedIdentityListService,
 						this.deviceService,
 						this.preferenceService,
-						this.identityStore);
+						this.identityStore,
+						this.blackListIdentityService,
+						this.licenseService);
 
 		synchronized (this.pendingRoutines) {
 			this.pendingRoutines.add(routine);
@@ -210,11 +222,6 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 		return this.pendingRoutines.size() > 0;
 	}
 
-	@Override
-	public Date getLatestFullSyncTime() {
-		return this.latestFullSync;
-	}
-
 	@Override
 	public boolean isFullSyncInProgress() {
 		synchronized (this.pendingRoutines) {
@@ -251,6 +258,10 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 					this.pendingRoutines.get(n).abort();
 				}
 			}
+
+			int numDeleted = AndroidContactUtil.getInstance().deleteAllThreemaRawContacts();
+			logger.debug("*** deleted {} raw contacts", numDeleted);
+
 			if(!this.userService.removeAccount(new AccountManagerCallback<Boolean>() {
 				@Override
 				public void run(AccountManagerFuture<Boolean> future) {
@@ -270,6 +281,15 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 
 		if(contactService != null) {
 			contactService.removeAllThreemaContactIds();
+
+			// cleanup / degrade remaining identities that are still server verified
+			List<String> identities = contactService.getIdentitiesByVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+			if (identities != null && identities.size() > 0) {
+				for (ContactModel contactModel : contactService.getByIdentities(identities)) {
+					contactModel.setVerificationLevel(VerificationLevel.UNVERIFIED);
+					contactService.save(contactModel);
+				}
+			}
 		}
 
 		if(run != null) {
@@ -277,12 +297,6 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
 		}
 	}
 
-	@Override
-	public boolean disableSync() {
-		return this.disableSync(null);
-	}
-
-
 	private void finishedRoutine(final SynchronizeContactsRoutine routine) {
 		//remove from pending
 		synchronized (this.pendingRoutines) {

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

@@ -49,12 +49,12 @@ public interface UserService {
 	boolean enableAccountAutoSync(boolean enable);
 
 	/**
-	 * Remove the Android Account (all Android-Threema Contacts will be deleted)
+	 * Remove the Account for the Sync Adapter (all Android-Threema Contacts will be deleted)
 	 */
 	void removeAccount();
 
 	/**
-	 * Remove the Android Account, see {@removeAccount}
+	 * Remove the Account for the Sync Adapter, see {@removeAccount}
 	 * @param callback Callback after adding
 	 */
 	boolean removeAccount(AccountManagerCallback<Boolean> callback);

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

@@ -36,7 +36,6 @@ import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.routines.ValidateContactsIntegrationRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UpdateSystemService;
@@ -84,10 +83,6 @@ public class SystemUpdateToVersion12 implements UpdateSystemService.SystemUpdate
 		if(serviceManager != null) {
 			try {
 				ContactService contactService = serviceManager.getContactService();
-				ValidateContactsIntegrationRoutine validateContactsIntegration = new ValidateContactsIntegrationRoutine(contactService,null);
-				if(validateContactsIntegration != null) {
-					validateContactsIntegration.run();
-				}
 
 				PreferenceService preferenceService = serviceManager.getPreferenceService();
 				if(preferenceService != null && preferenceService.isSyncContacts()) {

+ 97 - 0
app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion65.java

@@ -0,0 +1,97 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2020-2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.services.systemupdate;
+
+import android.Manifest;
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.SQLException;
+
+import androidx.annotation.RequiresPermission;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.SynchronizeContactsService;
+import ch.threema.app.services.UpdateSystemService;
+import ch.threema.app.utils.AndroidContactUtil;
+import ch.threema.app.utils.ConfigUtils;
+import ch.threema.localcrypto.MasterKeyLockedException;
+
+public class SystemUpdateToVersion65 extends UpdateToVersion implements UpdateSystemService.SystemUpdate {
+	private static final Logger logger = LoggerFactory.getLogger(SystemUpdateToVersion65.class);
+	private Context context;
+
+	public SystemUpdateToVersion65(Context context) {
+		this.context = context;
+	}
+
+	@Override
+	public boolean runDirectly() throws SQLException {
+		return true;
+	}
+
+	@Override
+	public boolean runASync() {
+		if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
+			return true; // best effort
+		}
+
+		forceContactResync();
+
+		return true;
+	}
+
+	@RequiresPermission(Manifest.permission.WRITE_CONTACTS)
+	private void forceContactResync() {
+		logger.info("Force a contacts resync");
+
+		AndroidContactUtil androidContactUtil = AndroidContactUtil.getInstance();
+		androidContactUtil.deleteAllThreemaRawContacts();
+
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+		if (serviceManager != null) {
+			PreferenceService preferenceService = serviceManager.getPreferenceService();
+			if (preferenceService != null) {
+				if (preferenceService.isSyncContacts()) {
+					final SynchronizeContactsService synchronizeContactService;
+					try {
+						synchronizeContactService = serviceManager.getSynchronizeContactsService();
+						if(synchronizeContactService != null) {
+							synchronizeContactService.instantiateSynchronizationAndRun();
+						}
+					} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+						logger.error("Exception", e);
+					}
+				}
+			}
+		}
+	}
+
+	@Override
+	public String getText() {
+		return "force a contacts resync";
+	}
+}

+ 16 - 14
app/src/main/java/ch/threema/app/stores/ContactStore.java

@@ -82,29 +82,31 @@ public class ContactStore implements ContactStoreInterface {
 		Contact contact = this.getContactForIdentity(identity);
 
 		if (contact == null) {
-			if (fetch == true) {
+			if (fetch) {
 				try {
 					//check if identity is on black list
 					if(this.blackListService != null && this.blackListService.has(identity)) {
 						return null;
 					}
 
-					if(this.preferenceService != null && this.preferenceService.isSyncContacts()) {
-						//check if is on exclude list
-						if(this.excludeListService != null && !this.excludeListService.has(identity)) {
-							SynchronizeContactsUtil.startDirectly(identity);
-
-							//try to select again
-							contact = this.getContactForIdentity(identity);
-							if(contact != null) {
-								return contact.getPublicKey();
+					if (this.preferenceService != null) {
+						if (this.preferenceService.isSyncContacts()) {
+							//check if is on exclude list
+							if (this.excludeListService != null && !this.excludeListService.has(identity)) {
+								SynchronizeContactsUtil.startDirectly(identity);
+
+								//try to select again
+								contact = this.getContactForIdentity(identity);
+								if (contact != null) {
+									return contact.getPublicKey();
+								}
 							}
 						}
-					}
 
-					//do not fetch if block unknown is enabled
-					if(this.preferenceService.isBlockUnknown()) {
-						return null;
+						//do not fetch if block unknown is enabled
+						if (this.preferenceService.isBlockUnknown()) {
+							return null;
+						}
 					}
 
 					return this.fetchPublicKeyForIdentity(identity);

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

@@ -64,10 +64,13 @@ import androidx.appcompat.view.menu.MenuPopupHelper;
 import androidx.core.app.ActivityCompat;
 import androidx.fragment.app.Fragment;
 import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.ViewModelProvider;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.CropImageActivity;
+import ch.threema.app.listeners.ContactListener;
+import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.GroupService;
@@ -78,6 +81,8 @@ import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.FileUtil;
+import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
@@ -158,12 +163,33 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 		this.isEditable = true;
 	}
 
+	private final ContactListener contactListener = new ContactListener() {
+		@Override
+		public void onModified(ContactModel modifiedContactModel) {
+			RuntimeUtil.runOnUiThread(() -> loadAvatarForModel(modifiedContactModel, null));
+		}
+
+		@Override
+		public void onAvatarChanged(ContactModel contactModel) { }
+
+		@Override
+		public void onRemoved(ContactModel removedContactModel) { }
+
+		@Override
+		public boolean handle(String identity) {
+			if (avatarData.getContactModel() != null) {
+				return TestUtil.compare(avatarData.getContactModel().getIdentity(), identity);
+			}
+			return false;
+		}
+	};
+
 	/**
 	 * Load saved avatar for the specified model - do not call this if changes are to be deferred
 	 */
 	@SuppressLint("StaticFieldLeak")
 	@UiThread
-	public void loadAvatarForModel(ContactModel contactModel, GroupModel groupModel) {
+	public synchronized void loadAvatarForModel(ContactModel contactModel, GroupModel groupModel) {
 		new AsyncTask<Void, Void, Bitmap>() {
 			@Override
 			protected Bitmap doInBackground(Void... params) {
@@ -631,6 +657,16 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 		}
 	}
 
+	@Override
+	public void onCreate(@NonNull LifecycleOwner owner) {
+		ListenerManager.contactListeners.add(this.contactListener);
+	}
+
+	@Override
+	public void onDestroy(@NonNull LifecycleOwner owner) {
+		ListenerManager.contactListeners.remove(this.contactListener);
+	}
+
 	public interface AvatarEditListener {
 		void onAvatarSet(File avatarFile);
 		void onAvatarRemoved();

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

@@ -189,6 +189,11 @@ public class ControllerView extends FrameLayout {
 			progressBarIndeterminate.setVisibility(VISIBLE);
 		} else {
 			setVisibility(VISIBLE);
+			if (cancelable) {
+				status = STATUS_PROGRESSING;
+			} else {
+				status = STATUS_PROGRESSING_NO_CANCEL;
+			}
 		}
 		requestLayout();
 	}

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

@@ -35,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView;
  * Taken from http://stackoverflow.com/questions/27414173/equivalent-of-listview-setemptyview-in-recyclerview/27801394#27801394
  */
 public class EmptyRecyclerView extends RecyclerView {
+	private int numHeadersAndFooters = 0;
 	private WeakReference<View> emptyViewReference;
 	final private AdapterDataObserver observer = new AdapterDataObserver() {
 		@Override
@@ -70,7 +71,7 @@ public class EmptyRecyclerView extends RecyclerView {
 
 	void checkIfEmpty() {
 		if (emptyViewReference != null && emptyViewReference.get() != null && getAdapter() != null) {
-			final boolean emptyViewVisible = getAdapter().getItemCount() == 0;
+			final boolean emptyViewVisible = getAdapter().getItemCount() == numHeadersAndFooters;
 			emptyViewReference.get().setVisibility(emptyViewVisible ? VISIBLE : GONE);
 			setVisibility(emptyViewVisible ? INVISIBLE : VISIBLE);
 		}
@@ -94,6 +95,15 @@ public class EmptyRecyclerView extends RecyclerView {
 		checkIfEmpty();
 	}
 
+	/**
+	 * Specify how many header or footer views this recyclerview has. This number will be considered when determining the "empty" status of the list
+	 * @param numHeadersAndFooters Number of headers and / or footers
+	 */
+	public void setNumHeadersAndFooters(int numHeadersAndFooters) {
+		this.numHeadersAndFooters = numHeadersAndFooters;
+		checkIfEmpty();
+	}
+
 	public @Nullable View getEmptyView() {
 		return this.emptyViewReference.get();
 	}

+ 2 - 12
app/src/main/java/ch/threema/app/ui/MediaItem.java

@@ -92,18 +92,8 @@ public class MediaItem implements Parcelable {
 	public MediaItem(Uri uri, String mimeType, String caption) {
 		init();
 
-		if (MimeUtil.isImageFile(mimeType)) {
-			if (MimeUtil.isGifFile(mimeType)) {
-				this.type = TYPE_GIF;
-			} else {
-				this.type = TYPE_IMAGE;
-			}
-		} else if (MimeUtil.isVideoFile(mimeType)) {
-			this.type = TYPE_VIDEO;
-		} else if (MimeUtil.isAudioFile(mimeType) && mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_AAC)) {
-			this.type = TYPE_VOICEMESSAGE;
-		} else {
-			this.type = TYPE_FILE;
+		this.type = MimeUtil.getMediaTypeFromMimeType(mimeType);
+		if (this.type == TYPE_FILE) {
 			this.renderingType = FileData.RENDERING_DEFAULT;
 		}
 

+ 404 - 530
app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java

@@ -29,15 +29,15 @@ import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.ContentUris;
 import android.content.Context;
-import android.content.OperationApplicationException;
+import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Build;
-import android.os.RemoteException;
 import android.provider.ContactsContract;
+import android.widget.Toast;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -48,28 +48,30 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.regex.PatternSyntaxException;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresPermission;
+import androidx.annotation.WorkerThread;
 import androidx.core.content.ContextCompat;
 import androidx.core.util.Pair;
-import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.UserService;
+import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.ContactModel;
-import java8.util.J8Arrays;
-import java8.util.stream.Collectors;
 
 import static ch.threema.storage.models.ContactModel.DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY;
 
 public class AndroidContactUtil {
 	private static final Logger logger = LoggerFactory.getLogger(AndroidContactUtil.class);
+	private UserService userService;
+	private FileService fileService;
 
-	// Singleton stuff
 	private static AndroidContactUtil sInstance = null;
 
 	public static synchronized AndroidContactUtil getInstance() {
@@ -79,12 +81,27 @@ public class AndroidContactUtil {
 		return sInstance;
 	}
 
-	private static final String[] NAME_PROJECTION = new String[]{
+	private AndroidContactUtil() {
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+
+		if (serviceManager != null) {
+			this.userService = serviceManager.getUserService();
+			try {
+				this.fileService = serviceManager.getFileService();
+			} catch (FileSystemNotPresentException ignored) {}
+		}
+	}
+
+	private static final String[] NAME_PROJECTION = new String[] {
 			ContactsContract.Contacts.DISPLAY_NAME,
 			ContactsContract.Contacts.SORT_KEY_ALTERNATIVE,
 			ContactsContract.Contacts._ID
 	};
-	private static final String[] LOOKUP_KEY_PROJECTION = new String[]{ContactsContract.Contacts.LOOKUP_KEY};
+
+	private static final String[] RAW_CONTACT_PROJECTION = new String[] {
+		ContactsContract.RawContacts.CONTACT_ID,
+		ContactsContract.RawContacts.SYNC1,
+	};
 
 	private static final String[] STRUCTURED_NAME_FIELDS = new String[] {
 			ContactsContract.CommonDataKinds.StructuredName.PREFIX,
@@ -95,423 +112,88 @@ public class AndroidContactUtil {
 			ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME
 	};
 
-	private interface JoinContactQuery {
-		int _ID = 0;
-		int CONTACT_ID = 1;
-		int DISPLAY_NAME_SOURCE = 2;
-	}
-
 	private final ContentResolver contentResolver = ThreemaApplication.getAppContext().getContentResolver();
-	private Map<String, String> identityLookupCache = null;
-	private final Object identityLookupCacheLock = new Object();
 
 	private @Nullable Account getAccount() {
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if(serviceManager != null) {
-			UserService userService = serviceManager.getUserService();
-			if(userService != null) {
-				return userService.getAccount();
-			}
-		}
-		return null;
-	}
-
-	private FileService getFileService() {
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if(serviceManager != null) {
-			try {
-				return serviceManager.getFileService();
-			} catch (FileSystemNotPresentException ignored) {}
-		}
-		return null;
-	}
-
-	private Long getContactId(String lookupKey) {
-		if(TestUtil.empty(lookupKey)) {
+		if (userService == null) {
+			logger.info("UserService not available");
 			return null;
 		}
-		Long id = null;
-		Uri uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey);
-		if(uri != null) {
-			Cursor cursor = this.contentResolver.query(
-					uri,
-					new String[]{
-							ContactsContract.Contacts._ID
-					},
-					null,
-					null,
-					null
-			);
-
-			if(cursor != null) {
-				if(cursor.moveToFirst()) {
-					id = cursor.getLong(0);
-				}
-				cursor.close();
-			}
-		}
-
-		return id;
+		return userService.getAccount();
 	}
 
-	public boolean isThreemaAndroidContactJoined(String identity, String androidContactLookupkey) {
-		String lookupKey = this.getRawContactLookupKeyByIdentity(identity);
-
-		if(!TestUtil.empty(lookupKey)) {
-			Long threemaContactId = this.getContactId(lookupKey);
-			Long androidContactId = this.getContactId(androidContactLookupkey);
+	private static class ContactName {
+		final String firstName;
+		final String lastName;
 
-			return TestUtil.compare(threemaContactId, androidContactId);
+		public ContactName(String firstName, String lastName) {
+			this.firstName = firstName;
+			this.lastName = lastName;
 		}
-
-		return false;
 	}
 
-
 	/**
-	 * copy of ContactSaveService joinContacts (Google Code)
+	 * Return a valid uri to the given contact that can be used to build an intent for the contact app
+	 * It is safe to call this method if permission to access contacts is not granted
+	 *
+	 * @param contactModel ContactModel for which to get the Android contact URI
+	 * @return a valid uri pointing to the android contact or null if permission was not granted, no android contact is linked or android contact could not be looked up
 	 */
-	private boolean joinContacts(long contactId1, long contactId2) {
-		if (contactId1 == -1 || contactId2 == -1) {
-			logger.debug("Invalid arguments for joinContacts request");
-			return false;
-		}
-
-		final ContentResolver resolver = this.contentResolver;
-
-		// Load raw contact IDs for all raw contacts involved - currently edited and selected
-		// in the join UIs
-		Cursor c = resolver.query(ContactsContract.RawContacts.CONTENT_URI,
-				new String[] {
-					ContactsContract.RawContacts._ID,
-					ContactsContract.RawContacts.CONTACT_ID,
-					ContactsContract.RawContacts.DISPLAY_NAME_SOURCE
-				},
-				ContactsContract.RawContacts.CONTACT_ID + "=? OR " + ContactsContract.RawContacts.CONTACT_ID + "=?",
-				new String[]{String.valueOf(contactId1), String.valueOf(contactId2)}, null);
-
-		long rawContactIds[];
-		long verifiedNameRawContactId = -1;
-
-		if(c != null) {
-			try {
-				if (c.getCount() == 0) {
-					return false;
-				}
-				int maxDisplayNameSource = -1;
-				rawContactIds = new long[c.getCount()];
-				for (int i = 0; i < rawContactIds.length; i++) {
-					c.moveToPosition(i);
-					long rawContactId = c.getLong(JoinContactQuery._ID);
-					rawContactIds[i] = rawContactId;
-					int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE);
-					if (nameSource > maxDisplayNameSource) {
-						maxDisplayNameSource = nameSource;
-					}
-				}
-
-				for (int i = 0; i < rawContactIds.length; i++) {
-					c.moveToPosition(i);
-					if (c.getLong(JoinContactQuery.CONTACT_ID) == contactId1) {
-						int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE);
-						if (nameSource == maxDisplayNameSource
-								&& (verifiedNameRawContactId == -1)) {
-							verifiedNameRawContactId = c.getLong(JoinContactQuery._ID);
-						}
-					}
-				}
-			} finally {
-				c.close();
-			}
-		}
-		else {
-			return false;
-		}
-
-		// For each pair of raw contacts, insert an aggregation exception
-		ArrayList<ContentProviderOperation> operations = new ArrayList<>();
-		for (int i = 0; i < rawContactIds.length; i++) {
-			for (int j = 0; j < rawContactIds.length; j++) {
-				if (i != j) {
-					ContentProviderOperation.Builder builder =
-							ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI);
-					builder.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER);
-					builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, rawContactIds[i]);
-					builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, rawContactIds[j]);
-					operations.add(builder.build());
-				}
-			}
-		}
-
-		//mark as SUPER PRIMARY
-		if (verifiedNameRawContactId != -1) {
-			operations.add(
-					ContentProviderOperation.newUpdate(ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, verifiedNameRawContactId))
-							.withValue(ContactsContract.Data.IS_SUPER_PRIMARY, 1)
-							.build());
-		}
-
-		boolean success = false;
-		// split aggregation excemptions into chunks of 200 operations
-		final int chunkSize = 200;
-		int size = operations.size();
-
-		for (int i = 0; i < size; i += chunkSize) {
-			int end = Math.min(size, i + chunkSize) - 1;
-
-			try {
-				resolver.applyBatch(ContactsContract.AUTHORITY, new ArrayList<>(operations.subList(i, end)));
-				success = true;
-			} catch (RemoteException e) {
-				logger.error("RemoteException: Failed to apply aggregation exception batch", e);
-			} catch (OperationApplicationException e) {
-				logger.error("OperationApplicationException: Failed to apply aggregation exception batch", e);
-			}
-		}
-		return success;
-	}
-
-	public boolean joinThreemaAndroidContact(String identity, String androidContactLookupkey) {
-		boolean success = false;
-
-		String lookupKey = this.getRawContactLookupKeyByIdentity(identity);
-
-		if(!TestUtil.empty(lookupKey)) {
-			Long threemaContactId = this.getContactId(lookupKey);
-			Long androidContactId = this.getContactId(androidContactLookupkey);
-			if (TestUtil.required(threemaContactId, androidContactId)) {
-				success = this.joinContacts(androidContactId, threemaContactId);
-			}
-		}
-		return success;
-	}
-
-	public boolean splitThreemaAndroidContact(String identity, String androidContactLookupkey) {
-		boolean success = false;
-		Account account = this.getAccount();
-		if(account == null) {
-			return false;
+	@Nullable
+	public Uri getAndroidContactUri(@Nullable ContactModel contactModel) {
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+			ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
+			return null;
 		}
 
-		String lookupKey = this.getRawContactLookupKeyByIdentity(identity);
-
-		if(!TestUtil.empty(lookupKey)) {
-			Long threemaContactId = this.getContactId(lookupKey);
-			Long androidContactId = this.getContactId(androidContactLookupkey);
-
-			//are the same... ok
-			if(TestUtil.compare(threemaContactId, androidContactId)) {
-				Cursor cursor = this.contentResolver.query(
-						ContactsContract.RawContacts.CONTENT_URI,
-						new String[]{
-								ContactsContract.RawContacts._ID,
-								ContactsContract.RawContacts.ACCOUNT_NAME,
-								ContactsContract.RawContacts.ACCOUNT_TYPE,
-								ContactsContract.RawContacts.SYNC1
-						},
-						ContactsContract.RawContacts.CONTACT_ID + "=?",
-						new String[]{String.valueOf(threemaContactId)},
-						null);
-
-				if(cursor != null) {
-					List<Long> rawContactIds = new ArrayList<>();
-					List<Long> accountContactIds = new ArrayList<>();
-
-					while(cursor.moveToNext()) {
-						long id = cursor.getLong(0);
-						String accountName = cursor.getString(1);
-						String accountType = cursor.getString(2);
-						String accountIdentity = cursor.getString(3);
-
-						if(TestUtil.compare(accountName, account.name) &&
-								TestUtil.compare(accountType, account.type) &&
-								TestUtil.compare(accountIdentity, identity)) {
-							accountContactIds.add(id);
-						}
-						else {
-							rawContactIds.add(id);
-						}
-					}
-					cursor.close();
-					// For each pair of raw contacts, insert an aggregation exception
-					ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
-
-					for (long joinId: rawContactIds) {
-						for(long accountId: accountContactIds) {
-							if (joinId != accountId) {
-								try {
-									ContentProviderOperation.Builder builder =
-											ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI);
-									builder.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_SEPARATE);
-									builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, joinId);
-									builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, accountId);
-									operations.add(builder.build());
-								} catch (Exception e) {
-									logger.error("Exception", e);
-								}
-								//reset name of threema account
+		if (contactModel != null) {
+			String contactLookupKey = contactModel.getAndroidContactLookupKey();
 
-							}
-						}
-					}
+			if (!TestUtil.empty(contactLookupKey)) {
+				Uri contactLookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, contactLookupKey);
 
+				if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) {
 					try {
-						this.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations);
-						success = true;
-					} catch (RemoteException | OperationApplicationException e) {
+						contactLookupUri = ContactsContract.Contacts.lookupContact(contentResolver, contactLookupUri);
+					} catch (Exception e) {
 						logger.error("Exception", e);
-					}
-
-					//reset name to identity
-//					this.updateNameOfThreemaContact(identity, identity, true);
-				}
-			}
-		}
-
-		return success;
-	}
-
-	public void startCache() {
-		this.stopCache();
-		this.identityLookupCache = new HashMap<String, String>();
-	}
-
-	public void stopCache() {
-		if(this.identityLookupCache != null) {
-			synchronized (this.identityLookupCacheLock) {
-				this.identityLookupCache.clear();
-				this.identityLookupCache = null;
-			}
-		}
-	}
-
-	public Drawable getAccountIcon(String identity) {
-		Account myAccount = this.getAccount();
-		if(myAccount == null) {
-			return null;
-		}
-
-		String lookupKey = this.getRawContactLookupKeyByIdentity(identity);
-		if(TestUtil.empty(lookupKey)) {
-			return null;
-		}
-
-		AccountManager accountManager = AccountManager.get(ThreemaApplication.getAppContext());
-		List<AuthenticatorDescription> descriptions = new ArrayList<AuthenticatorDescription>();
-
-		//add google as first!
-		for(AuthenticatorDescription des: accountManager.getAuthenticatorTypes()) {
-
-			if("com.google".equals(des.type)) {
-				//first
-				descriptions.add(0, des);
-			}
-			else if(!TestUtil.compare(BuildConfig.APPLICATION_ID, des.type)) {
-				descriptions.add(des);
-			}
-
-		}
-
-		AuthenticatorDescription[] descriptionResult = new AuthenticatorDescription[descriptions.size()];
-		Long contactId = this.getContactId(lookupKey);
-		Drawable fallback = null;
-
-		//select joined contact (excluding threema contact)
-		if(contactId != null) {
-			Cursor cursor = this.contentResolver.query(
-					ContactsContract.RawContacts.CONTENT_URI,
-					new String[]{
-							ContactsContract.RawContacts.ACCOUNT_TYPE
-					},
-					ContactsContract.RawContacts.CONTACT_ID + " = ? AND "
-					+ ContactsContract.RawContacts.ACCOUNT_TYPE + " != ?",
-					new String[]{
-							String.valueOf(contactId),
-							BuildConfig.APPLICATION_ID
-					},
-					null
-			);
-			if(cursor != null) {
-
-				while(cursor.moveToNext()) {
-					String type = cursor.getString(0);
-
-					for(int n = 0; n < descriptions.size(); n++) {
-						AuthenticatorDescription description = descriptions.get(n);
-						if (description.type.equals(type)) {
-							descriptionResult[n] = description;
-						}
+						return null;
 					}
 				}
-
-				cursor.close();
+				return contactLookupUri;
 			}
 		}
-
-		for(AuthenticatorDescription des: descriptionResult) {
-			if(des != null) {
-				PackageManager pm = ThreemaApplication.getAppContext().getPackageManager();
-				Drawable drawable = pm.getDrawable(des.packageName, des.iconId, null);
-				if(drawable != null) {
-					if(des.type.equals(myAccount.type)) {
-						fallback = drawable;
-					}
-					else {
-						return drawable;
-					}
-				}
-			}
-		}
-
-		//if no icon found, display the icon of the phone or contacts app
-		if( fallback == null) {
-			for (String namespace : new String[]{
-				"com.android.phone",
-				"com.android.providers.contacts"
-			}) {
-				try {
-					fallback = ThreemaApplication.getAppContext().getPackageManager().getApplicationIcon(namespace);
-					break;
-				} catch (PackageManager.NameNotFoundException x) {
-					//
-				}
-			}
-		}
-		return fallback;
-	}
-
-	private class ContactName {
-		final String firstName;
-		final String lastName;
-
-		public ContactName(String firstName, String lastName) {
-			this.firstName = firstName;
-			this.lastName = lastName;
-		}
+		return null;
 	}
 
+	/**
+	 * Update the avatar for the specified contact from Android's contact database, if any
+	 * If there's no avatar for this Android contact, any current avatar on file will be deleted
+	 *
+	 * It is safe to call this method even if permission to read contacts is not given
+	 *
+	 * @param contactModel ContactModel
+	 * @return true if setting or deleting the avatar was successful, false otherwise
+	 */
 	public boolean updateAvatarByAndroidContact(ContactModel contactModel) {
-		FileService fileService = getFileService();
-
 		if (fileService == null) {
+			logger.info("FileService not available");
 			return false;
 		}
 
-		String androidContactId = contactModel.getAndroidContactId();
+		String androidContactId = contactModel.getAndroidContactLookupKey();
 
 		if(TestUtil.empty(androidContactId)) {
 			return false;
 		}
 
-		Uri contactUri = ContactUtil.getAndroidContactUri(ThreemaApplication.getAppContext(), contactModel);
+		Uri contactUri = getAndroidContactUri(contactModel);
 		if (contactUri == null) {
 			return false;
 		}
 
 		Bitmap bitmap = null;
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
-			ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-
+		if (ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS)) {
 			bitmap = AvatarConverterUtil.convert(ThreemaApplication.getAppContext(), contactUri);
 		}
 
@@ -535,49 +217,71 @@ public class AndroidContactUtil {
 		return false;
 	}
 
-	public boolean updateNameByAndroidContact(@NonNull ContactModel contactModel) {
-		Uri namedContactUri = ContactUtil.getAndroidContactUri(ThreemaApplication.getAppContext(), contactModel);
+	/**
+	 * Update the name of this contact according to the name of the Android contact
+	 * Note that the ContactModel needs to be saved to the ContactStore to apply the changes
+	 *
+	 * @param contactModel ContactModel
+	 * @return true if setting the name was successful, false otherwise
+	 */
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
+	public boolean updateNameByAndroidContact(@NonNull ContactModel contactModel) throws ThreemaException {
+		Uri namedContactUri = getAndroidContactUri(contactModel);
 		if(TestUtil.required(contactModel, namedContactUri)) {
 			ContactName contactName = this.getContactName(namedContactUri);
-			if(TestUtil.required(contactModel, contactName)) {
-				if(!TestUtil.compare(contactModel.getFirstName(), contactName.firstName)
-						|| !TestUtil.compare(contactModel.getLastName(), contactName.lastName)) {
-					//changed... update
-					contactModel.setFirstName(contactName.firstName);
-					contactModel.setLastName(contactName.lastName);
-					return true;
-				}
+
+			if (contactName == null) {
+				throw new ThreemaException("Unable to get contact name");
+			}
+
+			if(!TestUtil.compare(contactModel.getFirstName(), contactName.firstName)
+					|| !TestUtil.compare(contactModel.getLastName(), contactName.lastName)) {
+				contactModel.setFirstName(contactName.firstName);
+				contactModel.setLastName(contactName.lastName);
+				return true;
 			}
 		}
 		return false;
 	}
 
+	/**
+	 * Get the contact name for a system contact specified by the specified Uri
+	 * 	 First we will consider the Structured Name of the contact
+	 * 	 If the Structured Name is lacking either a first name, a last name, or both, we will fall back to the Display Name
+	 * 	 If there's still neither first nor last name available, we will resort to the alternative representation of the full name (for Western names, it is the one using the "last, first" format)
+	 *
+	 * @param contactUri Uri pointing to the contact
+	 * @return ContactName object containing first and last name or null if lookup failed
+	 */
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
+	@Nullable
 	private ContactName getContactName(Uri contactUri) {
-		if(!TestUtil.required(this.contentResolver)) {
+		if (!TestUtil.required(this.contentResolver)) {
 			return null;
 		}
 
-		Cursor nameCursor = this.contentResolver.query(
+		ContactName contactName = null;
+		Cursor nameCursor = null;
+		try {
+			nameCursor = this.contentResolver.query(
 				contactUri,
 				NAME_PROJECTION,
 				null,
 				null,
 				null);
 
-		ContactName contactName = null;
-		if(nameCursor != null) {
-			if(nameCursor.moveToFirst()) {
+			if (nameCursor != null && nameCursor.moveToFirst()) {
 				long contactId = nameCursor.getLong(nameCursor.getColumnIndex(ContactsContract.Contacts._ID));
-				contactName = this.getContactNameFromId(contactId);
+				contactName = this.getContactNameFromContactId(contactId);
 
 				// fallback
-				if (contactName == null || (contactName.firstName == null && contactName.lastName == null)) {
+				if (contactName.firstName == null && contactName.lastName == null) {
 					//lastname, firstname
 					String alternativeSortKey = nameCursor.getString(nameCursor.getColumnIndex(ContactsContract.Contacts.SORT_KEY_ALTERNATIVE));
 
 					if (!TestUtil.empty(alternativeSortKey)) {
 						String[] lastNameFirstName = alternativeSortKey.split(",");
-						if (lastNameFirstName != null && lastNameFirstName.length == 2) {
+						if (lastNameFirstName.length == 2) {
 							String lastName = lastNameFirstName[0].trim();
 							String firstName = lastNameFirstName[1].trim();
 
@@ -585,16 +289,34 @@ public class AndroidContactUtil {
 								contactName = new ContactName(firstName, lastName);
 							}
 						}
+					} else {
+						// no contact name found
+						return null;
 					}
 				}
+			} else {
+				logger.debug("Contact not found: {}", contactUri.toString());
+			}
+		} catch (PatternSyntaxException e) {
+			logger.error("Exception", e);
+		} finally {
+			if (nameCursor != null) {
+				nameCursor.close();
 			}
-			nameCursor.close();
 		}
-
 		return contactName;
 	}
 
-	private ContactName getContactNameFromId(long contactId) {
+	/**
+	 * Get the contact name for a system contact specified by contactId
+	 * - First we will consider the Structured Name of the contact
+	 * - If the Structured Name is lacking either a first name, a last name, or both, we will fall back to the Display Name
+	 *
+	 * @param contactId Id of the Android contact
+	 * @return ContactName object containing first and last name
+	 */
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
+	private @NonNull ContactName getContactNameFromContactId(long contactId) {
 		Map<String, String> structure = this.getStructuredNameByContactId(contactId);
 
 		String firstName = structure.get(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME);
@@ -639,28 +361,12 @@ public class AndroidContactUtil {
 			return new ContactName(contactFirstName.toString(), contactLastName.toString());
 		}
 
-		final Pair<String, String> firstLastName = getFirstLastNameFromDisplayName(displayName);
+		final Pair<String, String> firstLastName = NameUtil.getFirstLastNameFromDisplayName(displayName);
 		return new ContactName(firstLastName.first, firstLastName.second);
 	}
 
-	/**
-	 * Extract first and last name from display name.
-	 *
-	 * If the displayName is empty or null, then empty strings will be returned for first/last name.
-	 */
-	private static @NonNull Pair<String, String> getFirstLastNameFromDisplayName(@Nullable String displayName) {
-		final String[] parts = displayName == null ? null : displayName.split(" ");
-		if (parts == null || parts.length == 0) {
-			return new Pair<>("", "");
-		}
-		final String firstName = parts[0];
-		final String lastName = J8Arrays.stream(parts)
-			.skip(1)
-			.collect(Collectors.joining(" "));
-		return new Pair<>(firstName, lastName);
-	}
-
-	private  Map<String, String> getStructuredNameByContactId(long id) {
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
+	private @NonNull Map<String, String> getStructuredNameByContactId(long id) {
 		Map<String, String> structuredName = new TreeMap<String, String>();
 
 		Cursor cursor = this.contentResolver.query(
@@ -686,158 +392,163 @@ public class AndroidContactUtil {
 	}
 
 	/**
-	 * Create a raw contact for the given identity. Put the identity into the SYNC1 column and set data records for messaging and calling
-	 * @param identity
-	 * @param supportsVoiceCalls
-	 * @return LOOKUP_KEY of the newly created raw contact or null if no contact has been created
+	 * Add ContentProviderOperations to create a raw contact for the given identity to a provided List of ContentProviderOperations.
+	 * Put the identity into the SYNC1 column and set data records for messaging and calling
+	 *
+	 * @param contentProviderOperations List of ContentProviderOperations to add this operation to
+	 * @param systemRawContactId The raw contact that matched the criteria for aggregation (i.e. email or phone number)
+	 * @param contactModel ContactModel to create a raw contact for
+	 * @param supportsVoiceCalls Whether the user has voice calls enabled
 	 */
-	public String createThreemaAndroidContact(String identity, boolean supportsVoiceCalls) {
+	@RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
+	public void createThreemaRawContact(@NonNull List<ContentProviderOperation> contentProviderOperations, long systemRawContactId, @NonNull ContactModel contactModel, boolean supportsVoiceCalls) {
+		String identity = contactModel.getIdentity();
 		Context context = ThreemaApplication.getAppContext();
 		Account account = this.getAccount();
 		if (!TestUtil.required(account, identity)) {
-			//do nothing
-			return null;
-		}
-
-		String threemaContactLookupKey = this.getRawContactLookupKeyByIdentity(identity);
-
-		//alread exist
-		if(!TestUtil.empty(threemaContactLookupKey)) {
-			return threemaContactLookupKey;
+			return;
 		}
 
-		ArrayList<ContentProviderOperation> insertOperationList = new ArrayList<ContentProviderOperation>();
-
+		int backReference = contentProviderOperations.size();
 		logger.debug("Adding contact: " + identity);
 
-		logger.debug("   Create our RawContact");
+		logger.debug("Create our RawContact");
 		ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI);
 		builder.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name);
 		builder.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type);
 		builder.withValue(ContactsContract.RawContacts.SYNC1, identity);
-		insertOperationList.add(builder.build());
+		contentProviderOperations.add(builder.build());
 
 		Uri insertUri = ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();
-/*
-		logger.debug("   Create a Data record of type 'Nickname' for our RawContact");
-		builder = ContentProviderOperation.newInsert(insertUri);
-		builder.withValueBackReference(ContactsContract.CommonDataKinds.Nickname.RAW_CONTACT_ID, 0);
-		builder.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE);
-		builder.withValue(ContactsContract.CommonDataKinds.Nickname.NAME, identity);
-		builder.withValue(ContactsContract.CommonDataKinds.Nickname.TYPE, ContactsContract.CommonDataKinds.Nickname.TYPE_CUSTOM);
-		builder.withValue(ContactsContract.CommonDataKinds.Nickname.LABEL, context.getString(R.string.title_threemaid));
-		insertOperationList.add(builder.build());
-*/
-		logger.debug("   Create a Data record of custom type");
+
+		logger.debug("Create a Data record of custom type");
 		builder = ContentProviderOperation.newInsert(insertUri);
-		builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0);
+		builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, backReference);
 		builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.contacts_mime_type));
-		//DATA1 have to be the identity to fetch in the activity
 		builder.withValue(ContactsContract.Data.DATA1, identity);
 		builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
 		builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_message_to, identity));
 		builder.withYieldAllowed(true);
-		insertOperationList.add(builder.build());
+		contentProviderOperations.add(builder.build());
 
 		if (supportsVoiceCalls) {
-			logger.debug("   Create a Data record of custom type for call");
+			logger.debug("Create a Data record of custom type for call");
 			builder = ContentProviderOperation.newInsert(insertUri);
-			builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0);
+			builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, backReference);
 			builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.call_mime_type));
 			builder.withValue(ContactsContract.Data.DATA1, identity);
 			builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
 			builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_call_with, identity));
 			builder.withYieldAllowed(true);
-			insertOperationList.add(builder.build());
+			contentProviderOperations.add(builder.build());
 		}
 
-		try {
-			context.getContentResolver().applyBatch(
-					ContactsContract.AUTHORITY,
-					insertOperationList);
+		builder = ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI);
+			builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, systemRawContactId);
+			builder.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, backReference);
+			builder.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER);
+			contentProviderOperations.add(builder.build());
+	}
 
-			//get the created or updated contact id
-			return this.getRawContactLookupKeyByIdentity(identity);
-		} catch (Exception e) {
-			logger.error("Error during raw contact creation! ", e);
+	/**
+	 * Delete the raw contact where the given identity matches the entry in the contact's SYNC1 column
+	 * It's safe to call this method without contacts permission
+	 *
+	 * @param contactModel ContactModel whose raw contact we want to be deleted
+	 * @return number of raw contacts deleted
+	 */
+	public int deleteThreemaRawContact(@NonNull ContactModel contactModel) {
+		if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
+			return 0;
 		}
 
-		return null;
-	}
+		Account account = this.getAccount();
+		if (account == null) {
+			return 0;
+		}
+
+		Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
+			.buildUpon()
+			.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+			.appendQueryParameter(ContactsContract.RawContacts.SYNC1, contactModel.getIdentity())
+			.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
+			.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();
 
+		try {
+			return contentResolver.delete(rawContactUri, null, null);
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
+		return 0;
+	}
 
 	/**
-	 * Check if a raw contact exists where the given identity matches the entry in the contact's SYNC1 column
-	 * @param identity Threema identity to look for
-	 * @return LOOKUP_KEY of the matching Raw Contact or null if none is found
+	 * Delete all raw contacts specified in rawContacts Map
+	 *
+	 * @param rawContacts HashMap of the rawContacts to delete. The key of the map entry contains the identity
+	 * @return Number of raw contacts that were supposed to be deleted. Does not necessarily represent the real number of deleted raw contacts.
 	 */
-	public String getRawContactLookupKeyByIdentity(String identity) {
+	public int deleteThreemaRawContacts(@NonNull HashMap<String, Long> rawContacts) {
+		if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
+			return 0;
+		}
+
 		Account account = this.getAccount();
 		if (account == null) {
-			//do nothing
-			return null;
+			return 0;
 		}
 
-		if(this.identityLookupCache != null) {
-			synchronized (this.identityLookupCacheLock) {
-				String res = this.identityLookupCache.get(identity);
-				if(res != null) {
-					return res;
-				}
-			}
+		if (rawContacts.isEmpty()) {
+			return 0;
 		}
 
-		String lookupKey = null;
+		ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
 
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
-				ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-			//get linked contact!
-			Cursor c1 = null;
-			try {
-				c1 = ThreemaApplication.getAppContext().getContentResolver().query(
-					ContactsContract.Data.CONTENT_URI,
-					LOOKUP_KEY_PROJECTION,
-					ContactsContract.RawContacts.ACCOUNT_NAME + "=? and "
-						+ ContactsContract.RawContacts.ACCOUNT_TYPE + "=? and "
-						+ ContactsContract.RawContacts.SYNC1 + "=?", new String[]{
-						String.valueOf(account.name),
-						String.valueOf(account.type),
-						String.valueOf(identity),
-					},
-					null);
-
-				if (c1 != null) {
-					if (c1.moveToFirst()) {
-						lookupKey = c1.getString(0);
-					}
-				}
-			} catch (Exception e) {
-				// JollaPhone crashes within this query
-				logger.error("Exception", e);
-			} finally {
-				if (c1 != null) {
-					c1.close();
-				}
+		for (Map.Entry<String, Long> rawContact : rawContacts.entrySet()) {
+			if (!TestUtil.empty(rawContact.getKey())) {
+				ContentProviderOperation.Builder builder = ContentProviderOperation.newDelete(
+					ContactsContract.RawContacts.CONTENT_URI
+						.buildUpon()
+						.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+						.appendQueryParameter(ContactsContract.RawContacts.SYNC1, rawContact.getKey())
+						.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
+						.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
+				);
+				contentProviderOperations.add(builder.build());
 			}
+		}
 
-			if (this.identityLookupCache != null) {
-				synchronized (this.identityLookupCacheLock) {
-					this.identityLookupCache.put(identity, lookupKey);
-				}
+		int operationCount = contentProviderOperations.size();
+		if (operationCount > 0) {
+			try {
+				ThreemaApplication.getAppContext().getContentResolver().applyBatch(
+					ContactsContract.AUTHORITY,
+					contentProviderOperations);
+			} catch (Exception e) {
+				logger.error("Error during raw contact deletion! ", e);
 			}
+			contentProviderOperations.clear();
 		}
-		return lookupKey;
+
+		logger.debug("Deleted {} raw contacts", operationCount);
+
+		return operationCount;
 	}
 
 	/**
-	 * Delete the raw contact where the given identity matches the entry in the contact's SYNC1 column
-	 * @param identity Threema identity to look for
+	 * Delete all raw contacts associated with Threema (including stray ones)
+	 * Safe to be called without permission
+	 *
+	 * @return number of raw contacts deleted
 	 */
-	public void deleteRawContactByIdentity(String identity) {
+	public int deleteAllThreemaRawContacts() {
+		if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
+			return  0;
+		}
+
 		Account account = this.getAccount();
 		if (account == null) {
-			//do nothing
-			return;
+			return 0;
 		}
 
 		Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
@@ -847,20 +558,183 @@ public class AndroidContactUtil {
 			.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();
 
 		try {
-			contentResolver.delete(rawContactUri,
-				ContactsContract.RawContacts.SYNC1 + " = ?", new String[]{
-					identity
-				});
+			return contentResolver.delete(rawContactUri, null, null);
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
+		return 0;
+	}
+
+	/**
+	 * Get a list of all Threema raw contacts from the contact database. This may include "stray" contacts.
+	 *
+	 * @return HashMap containing identity as key and android contact id as value
+	 */
+	@Nullable
+	public HashMap<String, Long> getAllThreemaRawContacts() {
+		if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
+			return null;
+		}
+
+		Account account = this.getAccount();
+		if (account == null) {
+			return null;
+		}
+
+		Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
+			.buildUpon()
+			.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
+			.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();
+
+		HashMap<String, Long> rawContacts = new HashMap<>();
+		Cursor cursor = null;
+		try {
+			cursor = contentResolver.query(rawContactUri, RAW_CONTACT_PROJECTION, null, null, null);
+			if (cursor != null){
+				while (cursor.moveToNext()) {
+					Long contactId = cursor.getLong(0);
+					String identity = cursor.getString(1);
+					rawContacts.put(identity, contactId);
+				}
+			}
 		} catch (Exception e) {
 			logger.error("Exception", e);
+		} finally {
+			if (cursor != null) {
+				cursor.close();
+			}
 		}
+		return rawContacts;
 	}
 
-	public boolean isAndroidContactNameMaster(ContactModel contactModel) {
-		if(contactModel != null) {
-			return !TestUtil.empty(contactModel.getAndroidContactId());
+	/**
+	 * Get the "main" raw contact representing the Android contact specified by the lookup key
+	 * We consider the contact referenced as display name source for the Android contact as the "main" contact
+	 *
+	 * @param lookupKey The lookup key of the contact
+	 * @return ID of the raw contact or 0 if none is found
+	 */
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
+	public long getMainRawContact(String lookupKey) {
+		long rawContactId = 0;
+		Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey);
+
+		Cursor cursor = null;
+		try {
+			cursor = ThreemaApplication.getAppContext().getContentResolver().query(
+				lookupUri,
+				new String[]{
+					Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? ContactsContract.Contacts.NAME_RAW_CONTACT_ID : "name_raw_contact_id"
+				},
+				null,
+				null,
+				null);
+
+			if (cursor != null) {
+				if (cursor.moveToFirst()) {
+					rawContactId = cursor.getLong(0);
+				}
+			}
+		} finally {
+			if (cursor != null) {
+				cursor.close();
+			}
 		}
 
+		return rawContactId;
+	}
+
+	@RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.GET_ACCOUNTS})
+	@Nullable
+	@WorkerThread
+	public Drawable getAccountIcon(@NonNull ContactModel contactModel) {
+		final PackageManager pm = ThreemaApplication.getAppContext().getPackageManager();
+
+		Account myAccount = this.getAccount();
+		if (myAccount == null) {
+			return null;
+		}
+
+		if (!contactModel.isSynchronized() || contactModel.getAndroidContactLookupKey() == null) {
+			return null;
+		}
+
+		long nameSourceRawContactId = getMainRawContact(contactModel.getAndroidContactLookupKey());
+		if (nameSourceRawContactId == 0) {
+			return null;
+		}
+
+		AccountManager accountManager = AccountManager.get(ThreemaApplication.getAppContext());
+		AuthenticatorDescription[] descriptions = accountManager.getAuthenticatorTypes();
+
+		Drawable drawable = null;
+
+		Uri nameSourceRawContactUri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, nameSourceRawContactId);
+		Cursor cursor = null;
+		try {
+			cursor = this.contentResolver.query(
+				nameSourceRawContactUri,
+				new String[]{
+					ContactsContract.RawContacts.ACCOUNT_TYPE
+				}, null, null, null);
+			if (cursor != null) {
+				if (cursor.moveToNext()) {
+					String accountType = cursor.getString(0);
+					for (AuthenticatorDescription description : descriptions) {
+						if (description.type.equalsIgnoreCase(accountType)) {
+							drawable = pm.getDrawable(description.packageName, description.iconId, null);
+							break;
+						}
+					}
+				}
+			}
+		} finally {
+			if (cursor != null) {
+				cursor.close();
+			}
+		}
+
+		//if no icon found, display the icon of the phone or contacts app
+		if (drawable == null) {
+			for (String substitutePackageName : new String[]{
+				"com.android.contacts",
+				"com.android.providers.contacts",
+				"com.android.phone",
+			}) {
+				try {
+					drawable = pm.getApplicationIcon(substitutePackageName);
+					break;
+				} catch (PackageManager.NameNotFoundException x) {
+					//
+				}
+			}
+		}
+		return drawable;
+	}
+
+	/**
+	 * Open the system's contact editor for the provided Threema contact
+	 * @param context Context
+	 * @param contact Threema contact
+	 * @return true if the contact is linked with a system contact (even if no app is available for an ACTION_EDIT intent in the system), false otherwise
+	 */
+	public boolean openContactEditor(Context context, ContactModel contact) {
+		Uri contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contact);
+
+		if (contactUri != null) {
+			Intent intent = new Intent(Intent.ACTION_EDIT);
+			intent.setDataAndType(contactUri, ContactsContract.Contacts.CONTENT_ITEM_TYPE);
+			intent.putExtra("finishActivityOnSaveCompleted", true);
+
+			// make sure users are coming back to threema and not the external activity
+			intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
+			if (intent.resolveActivity(context.getPackageManager()) != null) {
+				context.startActivity(intent);
+			} else {
+				Toast.makeText(context, "No contact editor found on device.", Toast.LENGTH_SHORT).show();
+			}
+			return true;
+		}
 		return false;
 	}
 }

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

@@ -50,6 +50,8 @@ import android.widget.LinearLayout;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.core.app.ActivityCompat;
 import androidx.core.app.ActivityOptionsCompat;
 import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
@@ -478,10 +480,29 @@ public class AnimationUtil {
 		}
 	}
 
+	public static void slideDown(@NonNull Context context, @NonNull View v) {
+		slideDown(context, v, null);
+	}
 
-	public static void slideDown(Context context, View v){
+	public static void slideDown(@NonNull Context context, @NonNull View v, @Nullable Runnable onEndRunnable) {
 		Animation a = AnimationUtils.loadAnimation(context, R.anim.slide_down);
-		if (a != null){
+		if (a != null) {
+			if (onEndRunnable != null) {
+				a.setAnimationListener(new Animation.AnimationListener() {
+					@Override
+					public void onAnimationStart(Animation animation) {
+					}
+
+					@Override
+					public void onAnimationEnd(Animation animation) {
+						onEndRunnable.run();
+					}
+
+					@Override
+					public void onAnimationRepeat(Animation animation) {
+					}
+				});
+			}
 			a.reset();
 			if(v != null){
 				v.clearAnimation();

+ 3 - 0
app/src/main/java/ch/threema/app/utils/AvatarConverterUtil.java

@@ -21,6 +21,7 @@
 
 package ch.threema.app.utils;
 
+import android.Manifest;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
@@ -46,6 +47,7 @@ import java.io.InputStream;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresPermission;
 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
 import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
@@ -80,6 +82,7 @@ public class AvatarConverterUtil {
 		return iconOffset;
 	}
 
+	@RequiresPermission(Manifest.permission.READ_CONTACTS)
 	public static Bitmap convert(Context context, Uri contactUri) {
 		Bitmap source = null;
 		SampleResult sampleResult;

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

@@ -435,7 +435,7 @@ public class BitmapUtil {
 			this.rotation = rotation;
 		}
 
-		public int getFlip() {
+		public @BitmapUtil.FlipType int getFlip() {
 			return flip;
 		}
 

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

@@ -136,9 +136,9 @@ public class ConfigUtils {
 
 	private static int appTheme = THEME_NONE;
 	private static String localeOverride = null;
-	private static Integer primaryColor = null, accentColor = null;
+	private static Integer primaryColor = null, accentColor = null, miuiVersion = null;
 	private static int emojiStyle = 0;
-	private static Boolean isTablet = null, isBiggerSingleEmojis = null, isMIUI10 = null, hasNoMapboxSupport = null;
+	private static Boolean isTablet = null, isBiggerSingleEmojis = null, hasNoMapboxSupport = null;
 	private static int preferredThumbnailWidth = -1, preferredAudioMessageWidth = -1;
 
 	private static final float[] NEGATIVE_MATRIX = {
@@ -272,22 +272,34 @@ public class ConfigUtils {
 		return Build.MANUFACTURER.equalsIgnoreCase("Xiaomi");
 	}
 
-	public static boolean isMIUI10() {
+	/**
+	 * return current MIUI version level or 0 if no Xiaomi device or MIUI version is not recognized or not relevant
+	 * @return MIUI version level or 0
+	 */
+	public static int getMIUIVersion() {
 		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isXiaomiDevice()) {
-			return false;
+			return 0;
 		}
-		if (isMIUI10 == null) {
+		if (miuiVersion == null) {
+			miuiVersion = 0;
+
 			try {
 				Class<?> c = Class.forName("android.os.SystemProperties");
 				Method get = c.getMethod("get", String.class);
-				String miuiVersion = (String) get.invoke(c, "ro.miui.ui.version.name");
-
-				isMIUI10 = miuiVersion != null && (miuiVersion.startsWith("V10") || miuiVersion.startsWith("V11"));
-			} catch (Exception e) {
-				return false;
-			}
+				String version = (String) get.invoke(c, "ro.miui.ui.version.name");
+
+				if (version != null) {
+					if (version.startsWith("V10")) {
+						miuiVersion = 10;
+					} else if (version.startsWith("V11")) {
+						miuiVersion = 11;
+					} else if (version.startsWith("V12") || version.startsWith("V13")) {
+						miuiVersion = 12;
+					}
+				}
+			} catch (Exception ignored) { }
 		}
-		return isMIUI10;
+		return miuiVersion;
 	}
 
 	public static boolean canCreateV2Quotes() {
@@ -449,19 +461,13 @@ public class ConfigUtils {
 
 	/**
 	 * Get full user-facing application version string including alpha/beta version suffix
+	 * Deprecated! use getAppVersion()
 	 * @param context
 	 * @return version string
 	 */
+	@Deprecated
 	public static String getFullAppVersion(@NonNull Context context) {
-		String suffix = context.getString(R.string.version_name_suffix);
-
-		String versionString = getAppVersion(context);
-
-		if (!TestUtil.empty(suffix)) {
-			versionString += "-" + suffix;
-		}
-
-		return versionString;
+		return getAppVersion(context);
 	}
 
 	/**
@@ -852,6 +858,11 @@ public class ConfigUtils {
 		imageView.setColorFilter(new ColorMatrixColorFilter(NEGATIVE_MATRIX));
 	}
 
+	public static boolean isPermissionGranted(@NonNull Context context, @NonNull String permission) {
+		return Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
+			ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
+	}
+
 	/**
 	 * Request all possibly required permissions of Contacts group
 	 * @param activity Activity context for onRequestPermissionsResult callback

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

@@ -21,14 +21,10 @@
 
 package ch.threema.app.utils;
 
-import android.Manifest;
 import android.content.Context;
 import android.content.Intent;
-import android.content.pm.PackageManager;
 import android.database.Cursor;
 import android.graphics.drawable.Drawable;
-import android.net.Uri;
-import android.os.Build;
 import android.provider.ContactsContract;
 
 import org.slf4j.Logger;
@@ -38,10 +34,8 @@ import java.util.Date;
 
 import androidx.annotation.DrawableRes;
 import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.content.ContextCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
@@ -52,53 +46,6 @@ public class ContactUtil {
 
 	public static final int CHANNEL_NAME_MAX_LENGTH_BYTES = 256;
 
-	/**
-	 * return a valid uri to the given contact
-	 *
-	 * @param context context
-	 * @param contactModel contactmodel
-	 * @return null or a valid uri
-	 */
-	public static Uri getAndroidContactUri(Context context, ContactModel contactModel) {
-		if (contactModel != null) {
-			String contactLookupKey = contactModel.getAndroidContactId();
-
-			if (TestUtil.empty(contactLookupKey)) {
-				contactLookupKey = contactModel.getThreemaAndroidContactId();
-			}
-
-			if (!TestUtil.empty(contactLookupKey)) {
-				return getAndroidContactUri(context, contactLookupKey);
-			}
-		}
-		return null;
-	}
-
-	private static Uri getAndroidContactUri(Context context, String lookupKey) {
-		Uri contactLookupUri = null;
-
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
-				ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-			contactLookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey);
-
-			if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) {
-				if (contactLookupUri != null) {
-					// Some implementations of QuickContactActivity/Contact App Intents ( (e.g. 4.0.4 on Samsung or HTC Android 4-5) have troubles dealing
-					// with CONTENT_LOOKUP_URI style URIs - they crash with a NumberFormatException. To avoid this,
-					// we need to lookup the proper (number-only) contact ID.
-					try {
-						contactLookupUri = ContactsContract.Contacts.lookupContact(context.getContentResolver(), contactLookupUri);
-					} catch (Exception e) {
-						// Could be an old (non-lookup) URI - ignore
-						logger.error("Exception", e);
-					}
-				}
-			}
-		}
-
-		return contactLookupUri;
-	}
-
 	/**
 	 * check if this contact is *currently* linked to an android contact
 	 * @param contact
@@ -106,7 +53,7 @@ public class ContactUtil {
 	 */
 	public static boolean isLinked(ContactModel contact) {
 		return contact != null
-				&& !TestUtil.empty(contact.getAndroidContactId());
+				&& !TestUtil.empty(contact.getAndroidContactLookupKey());
 	}
 
 	/**
@@ -142,18 +89,6 @@ public class ContactUtil {
 		return contact != null && contact.isSynchronized();
 	}
 
-	public static Uri getLinkedUri(Context context, ContactService contactService, ContactModel contact) {
-		contactService.validateContactAggregation(contact, true);
-		final Uri contactUri = ContactUtil.getAndroidContactUri(context, contact);
-
-		if (TestUtil.required(contactUri) && TestUtil.required(contact)) {
-			if (AndroidContactUtil.getInstance().isAndroidContactNameMaster(contact)) {
-				return contactUri;
-			}
-		}
-		return null;
-	}
-
 	/**
 	 * return true on channel-type contact (i.e. gateway, threema broadcast)
 	 *

+ 8 - 5
app/src/main/java/ch/threema/app/utils/ConversationNotificationUtil.java

@@ -234,7 +234,7 @@ public class ConversationNotificationUtil {
 		if (messageModel.getMessageContentsType() == MessageContentsType.IMAGE ||
 			messageModel.getMessageContentsType() == MessageContentsType.VIDEO
 		) {
-			if (messageModel.getFileData() != null && messageModel.getFileData().getRenderingType() != FileData.RENDERING_MEDIA) {
+			if (messageModel.getType() == MessageType.FILE && messageModel.getFileData().getRenderingType() != FileData.RENDERING_MEDIA) {
 				return null;
 			}
 
@@ -244,10 +244,13 @@ public class ConversationNotificationUtil {
 					//get file service directly...
 					//... its the evil way!
 					FileService f = null;
-					try {
-						f = ThreemaApplication.getServiceManager().getFileService();
-					} catch (FileSystemNotPresentException e) {
-						logger.error("FileSystemNotPresentException", e);
+
+					if (ThreemaApplication.getServiceManager() != null) {
+						try {
+							f = ThreemaApplication.getServiceManager().getFileService();
+						} catch (FileSystemNotPresentException e) {
+							logger.error("FileSystemNotPresentException", e);
+						}
 					}
 
 					if (f != null) {

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

@@ -193,12 +193,11 @@ public class DNDUtil {
 		}
 
 		Uri contactUri;
-		String lookupKey = contactModel.getThreemaAndroidContactId() != null ?
-				contactModel.getThreemaAndroidContactId() : contactModel.getAndroidContactId();
+		String lookupKey = contactModel.getAndroidContactLookupKey();
 
 		if (lookupKey != null) {
 			try {
-				contactUri = ContactUtil.getAndroidContactUri(context, contactModel);
+				contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contactModel);
 			} catch (Exception e) {
 				logger.error("Could not get Android contact URI", e);
 				return false;

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä