Threema 3 тижнів тому
батько
коміт
51aa2c2694
100 змінених файлів з 1908 додано та 1815 видалено
  1. 2 2
      SECURITY.md
  2. 2 2
      app/build.gradle.kts
  3. 12 0
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  4. 2 1
      app/src/libre/play/release-notes/de/default.txt
  5. 2 1
      app/src/libre/play/release-notes/en-US/default.txt
  6. 3 6
      app/src/main/java/ch/threema/app/GlobalListeners.java
  7. 7 4
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.kt
  8. 6 1
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  9. 27 22
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  10. 0 110
      app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java
  11. 133 0
      app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.kt
  12. 24 15
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  13. 6 3
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  14. 8 2
      app/src/main/java/ch/threema/app/adapters/poll/PollWizard1Adapter.java
  15. 3 1
      app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactChangeMonitor.kt
  16. 14 1
      app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactFeatureModule.kt
  17. 1 1
      app/src/main/java/ch/threema/app/androidcontactsync/read/AndroidContactLookupException.kt
  18. 1 1
      app/src/main/java/ch/threema/app/androidcontactsync/read/AndroidContactReadException.kt
  19. 9 0
      app/src/main/java/ch/threema/app/androidcontactsync/read/CursorCreateException.kt
  20. 1 1
      app/src/main/java/ch/threema/app/androidcontactsync/read/LookupInfoException.kt
  21. 3 10
      app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactCursor.kt
  22. 21 3
      app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactCursorProvider.kt
  23. 0 1
      app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactReader.kt
  24. 101 0
      app/src/main/java/ch/threema/app/androidcontactsync/read/ThreemaRawContactCursor.kt
  25. 38 0
      app/src/main/java/ch/threema/app/androidcontactsync/read/ThreemaRawContactReader.kt
  26. 121 0
      app/src/main/java/ch/threema/app/androidcontactsync/synchronization/AndroidContactMatcher.kt
  27. 100 0
      app/src/main/java/ch/threema/app/androidcontactsync/synchronization/IdentityLookupClient.kt
  28. 125 0
      app/src/main/java/ch/threema/app/androidcontactsync/synchronization/ThreemaRawContactManager.kt
  29. 14 0
      app/src/main/java/ch/threema/app/androidcontactsync/types/AndroidContact.kt
  30. 12 0
      app/src/main/java/ch/threema/app/androidcontactsync/types/ThreemaRawContact.kt
  31. 338 0
      app/src/main/java/ch/threema/app/androidcontactsync/usecases/SynchronizeAndroidContactsUseCase.kt
  32. 73 0
      app/src/main/java/ch/threema/app/androidcontactsync/write/ThreemaRawContactWriter.kt
  33. 2 3
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  34. 3 4
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  35. 46 69
      app/src/main/java/ch/threema/app/camera/CameraFragment.kt
  36. 18 6
      app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt
  37. 10 10
      app/src/main/java/ch/threema/app/connection/CspD2mDualConnectionSupplier.kt
  38. 0 2
      app/src/main/java/ch/threema/app/di/DependencyContainer.kt
  39. 2 11
      app/src/main/java/ch/threema/app/di/SessionScopeContainer.kt
  40. 13 9
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt
  41. 3 6
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewAdapter.kt
  42. 2 21
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt
  43. 4 6
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  44. 14 13
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt
  45. 7 2
      app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java
  46. 5 6
      app/src/main/java/ch/threema/app/fragments/wizard/WizardFragment3.java
  47. 3 4
      app/src/main/java/ch/threema/app/listeners/SynchronizeContactsListener.java
  48. 11 0
      app/src/main/java/ch/threema/app/location/LocationAutocompleteActivity.java
  49. 1 0
      app/src/main/java/ch/threema/app/managers/ListenerManager.java
  50. 0 24
      app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.java
  51. 13 0
      app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.kt
  52. 0 92
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java
  53. 91 0
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.kt
  54. 4 13
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java
  55. 1 10
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachViewModel.java
  56. 4 4
      app/src/main/java/ch/threema/app/mediaattacher/MediaRepository.java
  57. 3 3
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  58. 0 20
      app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java
  59. 0 169
      app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java
  60. 142 0
      app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.kt
  61. 1 9
      app/src/main/java/ch/threema/app/notifications/NotificationExtensions.kt
  62. 3 4
      app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.kt
  63. 3 3
      app/src/main/java/ch/threema/app/preference/SettingsSecurityFragment.kt
  64. 8 5
      app/src/main/java/ch/threema/app/profilepicture/CheckedProfilePicture.kt
  65. 0 550
      app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java
  66. 6 1
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  67. 50 42
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  68. 3 12
      app/src/main/java/ch/threema/app/services/SynchronizeContactsService.java
  69. 14 140
      app/src/main/java/ch/threema/app/services/SynchronizeContactsServiceImpl.java
  70. 2 0
      app/src/main/java/ch/threema/app/services/UserService.java
  71. 3 2
      app/src/main/java/ch/threema/app/services/messageplayer/AudioMessagePlayer.java
  72. 25 22
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java
  73. 1 0
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java
  74. 9 5
      app/src/main/java/ch/threema/app/services/notification/NotificationServiceImpl.kt
  75. 3 2
      app/src/main/java/ch/threema/app/systemupdates/updates/SystemUpdateToVersion12.java
  76. 5 3
      app/src/main/java/ch/threema/app/tasks/OutgoingFileMessageTask.kt
  77. 12 6
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  78. 0 8
      app/src/main/java/ch/threema/app/ui/MediaItem.java
  79. 1 2
      app/src/main/java/ch/threema/app/usecases/contacts/WatchContactAvatarIterationUseCase.kt
  80. 1 2
      app/src/main/java/ch/threema/app/usecases/conversations/WatchAvatarIterationsUseCase.kt
  81. 6 6
      app/src/main/java/ch/threema/app/utils/AdaptiveIconUtil.kt
  82. 3 115
      app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java
  83. 1 0
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  84. 1 2
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  85. 2 2
      app/src/main/java/ch/threema/app/utils/ShortcutUtil.java
  86. 18 21
      app/src/main/java/ch/threema/app/utils/StreamUtil.kt
  87. 0 72
      app/src/main/java/ch/threema/app/utils/SynchronizeContactsUtil.java
  88. 1 1
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt
  89. 37 36
      app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt
  90. 1 1
      app/src/main/java/ch/threema/app/webclient/services/instance/message/updater/ReceiverUpdateHandler.kt
  91. 3 4
      app/src/main/java/ch/threema/app/webclient/services/instance/message/updater/ReceiversUpdateHandler.java
  92. 4 0
      app/src/main/java/ch/threema/app/webviews/SimpleWebViewActivity.kt
  93. 25 27
      app/src/main/java/ch/threema/data/repositories/GroupModelRepository.kt
  94. 6 0
      app/src/main/java/ch/threema/data/storage/DatabaseBackend.kt
  95. 13 0
      app/src/main/java/ch/threema/data/storage/SqliteDatabaseBackend.kt
  96. 7 0
      app/src/main/java/ch/threema/logging/ErrorReporting.kt
  97. 15 5
      app/src/main/java/ch/threema/storage/factories/AbstractMessageModelFactory.java
  98. 0 4
      app/src/main/java/ch/threema/storage/models/data/MessageDataInterface.java
  99. 3 0
      app/src/main/java/ch/threema/storage/models/data/MessageDataInterface.kt
  100. 1 6
      app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java

+ 2 - 2
SECURITY.md

@@ -20,8 +20,8 @@ details, see [threema.com/contact](https://threema.com/en/contact) (section
 Every commit in this repository is cryptographically signed using the following
 PGP key:
 
-    pub   rsa4096 2016-09-06 [SC] [expires: 2026-09-04]
-          E7AD D991 4E26 0E8B 35DF  B506 65FD E935 573A CDA6
+    pub   rsa4096 2016-09-06 [SC] [expires: 2029-08-24]
+          E7AD D991 4E26 0E8B 35DF B506 65FD E935 573A CDA6
     uid           Threema Signing Key <dev@threema.ch>
 
 The public key can be found at <https://oss.threema.ch/65FDE935573ACDA6.pub>.

+ 2 - 2
app/build.gradle.kts

@@ -33,7 +33,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.5.2"
+val appVersion = "6.5.3"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -42,7 +42,7 @@ val appVersion = "6.5.2"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1212
+val defaultVersionCode = 1216
 
 /**
  * Map with keystore paths (if found).

+ 12 - 0
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -151,6 +151,18 @@ class GroupModelRepositoryTest {
         assertEquals(GroupModelDataFactory.toDataType(testGroup), fetchedGroup?.data)
     }
 
+    @Test
+    fun testGetByCreatorIdentity() {
+        val groupIdentity = GroupIdentity("TESTTEST", 42)
+        val testGroup = createTestDbGroup(groupIdentity)
+        databaseBackend.createGroup(testGroup)
+
+        val fetchedGroups = groupModelRepository.getByCreator(
+            groupIdentity.creatorIdentity,
+        )
+        assertEquals(listOf(GroupModelDataFactory.toDataType(testGroup)), fetchedGroups.map { it.data })
+    }
+
     @Test
     fun testGetByGroupIdentity() {
         val groupIdentityDefault = GroupIdentity("TESTTEST", 42)

+ 2 - 1
app/src/libre/play/release-notes/de/default.txt

@@ -1,2 +1,3 @@
-- Behebung eines Fehlers beim Drehen des Smartphones während der Nutzung der Kamera in Threema
+- Behebung eines Fehlers beim automatischen Herunterladen von Medien
+- Verbesserungen bei der Kontaktsynchronisation
 - Verbesserungen und Behebung verschiedener Fehler

+ 2 - 1
app/src/libre/play/release-notes/en-US/default.txt

@@ -1,2 +1,3 @@
-- Fixed a bug that occurred when rotating the smartphone while using Threema’s camera
+- Fixed a bug related to automatic file downloads
+- Improvements to contact synchronization
 - Various improvements and bug fixes

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

@@ -12,8 +12,6 @@ import org.slf4j.Logger;
 
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -24,7 +22,6 @@ import ch.threema.app.listeners.NewSyncedContactsListener;
 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.services.notification.NotificationService;
 import ch.threema.app.webclient.listeners.WebClientServiceListener;
 import ch.threema.app.webclient.manager.WebClientListenerManager;
@@ -98,17 +95,17 @@ public class GlobalListeners {
     @NonNull
     private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
         @Override
-        public void onStarted(SynchronizeContactsRoutine startedRoutine) {
+        public void onStarted() {
             androidContactChangeMonitor.stop();
         }
 
         @Override
-        public void onFinished(SynchronizeContactsRoutine finishedRoutine) {
+        public void onFinished() {
             androidContactChangeMonitor.start();
         }
 
         @Override
-        public void onError(SynchronizeContactsRoutine finishedRoutine) {
+        public void onError() {
             androidContactChangeMonitor.start();
         }
     };

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

@@ -97,6 +97,9 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
 
         // TODO(ANDR-4389): Improve the waiting mechanism
         waitUntilReady {
+            if (supportFragmentManager.isStateSaved) {
+                logger.warn("Fragment state already saved")
+            }
             initActivity(savedInstanceState)
             handleDeviceInsets()
         }
@@ -124,7 +127,7 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
                     putParcelable(AppConstants.INTENT_DATA_CONVERSATION_ID, conversationId)
                 },
             )
-            supportFragmentManager.runTransaction {
+            supportFragmentManager.runTransaction(allowStateLoss = true) {
                 add(R.id.messages, conversationsFragment!!, MESSAGES_FRAGMENT_TAG)
             }
         }
@@ -133,17 +136,17 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
         if (composeMessageFragment == null) {
             composeMessageFragment = ComposeMessageFragment()
             if (isHidden) {
-                supportFragmentManager.runTransaction {
+                supportFragmentManager.runTransaction(allowStateLoss = true) {
                     add(R.id.compose, composeMessageFragment!!, COMPOSE_FRAGMENT_TAG)
                     hide(composeMessageFragment!!)
                 }
             } else {
-                supportFragmentManager.runTransaction {
+                supportFragmentManager.runTransaction(allowStateLoss = true) {
                     add(R.id.compose, composeMessageFragment!!, COMPOSE_FRAGMENT_TAG)
                 }
             }
         } else if (!isHidden) {
-            supportFragmentManager.runTransaction {
+            supportFragmentManager.runTransaction(allowStateLoss = true) {
                 show(composeMessageFragment!!)
             }
         }

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

@@ -682,8 +682,13 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         super.onDestroy();
     }
 
+    @Nullable
     public AbstractMessageModel getMessageModel(int position) {
-        return messageModels.get(position);
+        try {
+            return messageModels.get(position);
+        } catch (IndexOutOfBoundsException e) {
+            return null;
+        }
     }
 
     public File[] getDecryptedFileCache() {

+ 27 - 22
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -35,6 +35,7 @@ import androidx.viewpager.widget.ViewPager;
 import ch.threema.android.LifecycleAwareAsyncTask;
 import ch.threema.app.R;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.WizardDialog;
@@ -43,7 +44,8 @@ import ch.threema.app.fragments.wizard.WizardFragment1;
 import ch.threema.app.fragments.wizard.WizardFragment2;
 import ch.threema.app.fragments.wizard.WizardFragment3;
 import ch.threema.app.fragments.wizard.WizardFragment4;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
+import ch.threema.app.listeners.SynchronizeContactsListener;
+import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.UserService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeServerInfo;
@@ -66,6 +68,7 @@ import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.api.LinkEmailException;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.domain.taskmanager.TriggerSource;
+import kotlin.Lazy;
 
 import static ch.threema.app.AppConstants.PHONE_LINKED_PLACEHOLDER;
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
@@ -108,6 +111,8 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
+    @NonNull
+    private final Lazy<SynchronizeAndroidContactsUseCase> synchronizeAndroidContactsUseCase = KoinJavaComponent.inject(SynchronizeAndroidContactsUseCase.class);
     private final CheckBadPasswordUseCase badPasswordUseCase = KoinJavaComponent.get(CheckBadPasswordUseCase.class);
 
     private static int lastPage = 0;
@@ -982,39 +987,39 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
                 @SuppressLint("MissingPermission")
                 @Override
                 protected Void doInBackground(Void params) {
-                    try {
-                        var userService = dependencies.getUserService();
-                        // We need to create an account if there is no account yet. Therefore, we need this call because of its side effect.
-                        userService.getAccount(true);
-                        //disable
-                        userService.enableAccountAutoSync(false);
-
-                        SynchronizeContactsRoutine routine = dependencies.getSynchronizeContactsService().instantiateSynchronization();
-                        if (routine == null) {
-                            logger.error("Cannot synchronize contacts as the routine is null");
-                            cancel();
-                            return null;
+                    var userService = dependencies.getUserService();
+                    var synchronizeContactsListener = new SynchronizeContactsListener() {
+                        @Override
+                        public void onFinished() {
+                            userService.enableAccountAutoSync(true);
                         }
 
-                        routine.setOnStatusUpdate(exception ->
+                        @Override
+                        public void onError() {
                             RuntimeUtil.runOnUiThread(
                                 () -> {
                                     final @Nullable WizardFragment4 wizardFragment4 = findWizardFragment4();
                                     if (wizardFragment4 != null) {
-                                        wizardFragment4.setContactsSyncInProgress(false, exception.getMessage());
+                                        wizardFragment4.setContactsSyncInProgress(false, getString(R.string.error));
                                     }
                                 }
-                            )
-                        );
+                            );
+                        }
+                    };
 
-                        //on finished, close the dialog
-                        routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) ->
-                            userService.enableAccountAutoSync(true)
-                        );
+                    try {
+                        ListenerManager.synchronizeContactsListeners.add(synchronizeContactsListener);
+
+                        // We need to create an account if there is no account yet. Therefore, we need this call because of its side effect.
+                        userService.getAccount(true);
+                        //disable
+                        userService.enableAccountAutoSync(false);
 
-                        routine.run();
+                        synchronizeAndroidContactsUseCase.getValue().callBlocking();
                     } catch (Exception e) {
                         logger.error("Exception", e);
+                    } finally {
+                        ListenerManager.synchronizeContactsListeners.remove(synchronizeContactsListener);
                     }
                     return null;
                 }

+ 0 - 110
app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java

@@ -1,110 +0,0 @@
-package ch.threema.app.adapters;
-
-import android.accounts.Account;
-import android.content.AbstractThreadedSyncAdapter;
-import android.content.ContentProviderClient;
-import android.content.Context;
-import android.content.SyncResult;
-import android.os.Bundle;
-
-import org.slf4j.Logger;
-
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.managers.ListenerManager;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.services.SynchronizeContactsService;
-import ch.threema.app.utils.IntentDataUtil;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
-public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
-    private static final Logger logger = getThreemaLogger("ContactsSyncAdapter");
-
-    private boolean isSyncEnabled = true;
-
-    public ContactsSyncAdapter(Context context, boolean autoInitialize) {
-        super(context, autoInitialize);
-    }
-
-    public void setSyncEnabled(boolean enabled) {
-        isSyncEnabled = enabled;
-    }
-
-    @Override
-    public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
-        logger.info("onPerformSync");
-
-        if (!isSyncEnabled) {
-            logger.info("Contact sync is disabled; retry later.");
-            // Workaround to trigger a soft error to retry the sync at a later moment
-            // See
-            //  - https://developer.android.com/reference/android/content/SyncResult#hasSoftError()
-            //  - https://developer.android.com/reference/android/content/SyncResult#SyncResult()
-            syncResult.stats.numIoExceptions++;
-            return;
-        }
-
-        try {
-            ServiceManager serviceManager = ServiceManager.get();
-
-            if (serviceManager == null) {
-                return;
-            }
-
-            if (serviceManager.getSynchronizedSettingsService().isSyncContacts()) {
-                logger.info("Start sync adapter run");
-                SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-
-                if (synchronizeContactsService.isFullSyncInProgress()) {
-                    logger.info("A full sync is already running");
-                    syncResult.stats.numUpdates = 0;
-                    syncResult.stats.numInserts = 0;
-                    syncResult.stats.numDeletes = 0;
-                    syncResult.stats.numEntries = 0;
-                    return;
-                }
-
-                SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization();
-                //update stats on finished to resolve the "every minute sync" bug
-
-                routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> {
-                    // let user know that contact was added
-                    ListenerManager.newSyncedContactListener.handle(listener -> listener.onNew(createdContacts));
-
-                    //hack to not schedule the next sync!
-                    syncResult.stats.numUpdates = 0;//modifiedAccounts;
-                    syncResult.stats.numInserts = 0;//createdAccounts;
-                    syncResult.stats.numDeletes = 0;//deletedAccounts;
-                    syncResult.stats.numEntries = 0;//createdAccounts;
-
-                    //send a broadcast to let others know that the list has changed
-                    LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext()).sendBroadcast(IntentDataUtil.createActionIntentContactsChanged());
-                });
-
-                // not in a thread: `onPerformSync` is already called in a background thread
-                routine.run();
-            }
-        } finally {
-            logger.debug(
-                "sync finished Sync [numEntries={}, updates={}, inserts={}, deletes={}",
-                syncResult.stats.numEntries,
-                syncResult.stats.numUpdates,
-                syncResult.stats.numInserts,
-                syncResult.stats.numDeletes
-            );
-        }
-    }
-
-    @Override
-    public void onSyncCanceled() {
-        logger.info("onSyncCanceled");
-        super.onSyncCanceled();
-    }
-
-    @Override
-    public void onSyncCanceled(Thread thread) {
-        logger.info("onSyncCanceled");
-        super.onSyncCanceled(thread);
-    }
-}

+ 133 - 0
app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.kt

@@ -0,0 +1,133 @@
+package ch.threema.app.adapters
+
+import android.Manifest
+import android.accounts.Account
+import android.annotation.SuppressLint
+import android.content.AbstractThreadedSyncAdapter
+import android.content.ContentProviderClient
+import android.content.Context
+import android.content.SyncResult
+import android.os.Bundle
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import ch.threema.app.ThreemaApplication.Companion.getAppContext
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase
+import ch.threema.app.di.injectNullableNonBinding
+import ch.threema.app.listeners.SynchronizeContactsListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.preference.service.SynchronizedSettingsService
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.IntentDataUtil
+import ch.threema.base.utils.getThreemaLogger
+import kotlinx.coroutines.runBlocking
+import org.koin.core.component.KoinComponent
+
+private val logger = getThreemaLogger("ContactsSyncAdapter")
+
+class ContactsSyncAdapter(context: Context, autoInitialize: Boolean) : AbstractThreadedSyncAdapter(context, autoInitialize), KoinComponent {
+    private val synchronizeAndroidContactsUseCase: SynchronizeAndroidContactsUseCase? by injectNullableNonBinding()
+    private val synchronizedSettingsService: SynchronizedSettingsService? by injectNullableNonBinding()
+
+    private var isSyncEnabled = true
+
+    fun setSyncEnabled(enabled: Boolean) {
+        isSyncEnabled = enabled
+    }
+
+    @SuppressLint("MissingPermission")
+    override fun onPerformSync(
+        account: Account?,
+        extras: Bundle?,
+        authority: String?,
+        provider: ContentProviderClient?,
+        syncResult: SyncResult,
+    ) {
+        logger.info("onPerformSync")
+
+        if (!isSyncEnabled) {
+            logger.info("Contact sync is disabled; retry later.")
+            // Workaround to trigger a soft error to retry the sync at a later moment
+            // See
+            //  - https://developer.android.com/reference/android/content/SyncResult#hasSoftError()
+            //  - https://developer.android.com/reference/android/content/SyncResult#SyncResult()
+            syncResult.stats.numIoExceptions++
+            return
+        }
+
+        when (synchronizedSettingsService?.isSyncContacts()) {
+            true -> Unit
+            false -> {
+                logger.warn("Aborting android contact synchronization because synchronization is disabled in settings")
+                return
+            }
+
+            null -> {
+                logger.error("Aborting android contact synchronization because synchronized settings service is null")
+                return
+            }
+        }
+
+        logger.info("Start sync adapter run")
+        if (SynchronizeAndroidContactsUseCase.isRunning) {
+            logger.info("A full sync is already running")
+            syncResult.stats.numUpdates = 0
+            syncResult.stats.numInserts = 0
+            syncResult.stats.numDeletes = 0
+            syncResult.stats.numEntries = 0
+            return
+        }
+
+        if (!ConfigUtils.isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)) {
+            logger.warn("No permission given for writing contacts. Aborting sync.")
+            return
+        }
+
+        val synchronizeAndroidContactsUseCase = synchronizeAndroidContactsUseCase
+            ?: run {
+                logger.error("Synchronize android contacts use case is null. Aborting sync.")
+                return
+            }
+
+        val synchronizedContactsListener = object : SynchronizeContactsListener {
+            override fun onFinished() {
+                // Hack to not schedule the next sync!
+                syncResult.stats.numUpdates = 0
+                syncResult.stats.numInserts = 0
+                syncResult.stats.numDeletes = 0
+                syncResult.stats.numEntries = 0
+
+                // Send a broadcast to let others know that the list has changed
+                LocalBroadcastManager.getInstance(getAppContext())
+                    .sendBroadcast(IntentDataUtil.createActionIntentContactsChanged())
+            }
+        }
+
+        try {
+            ListenerManager.synchronizeContactsListeners.add(synchronizedContactsListener)
+
+            // As we are running on a background thread, this is ok.
+            runBlocking {
+                synchronizeAndroidContactsUseCase.call()
+            }
+        } finally {
+            logger.debug(
+                "sync finished Sync [numEntries={}, updates={}, inserts={}, deletes={}",
+                syncResult.stats.numEntries,
+                syncResult.stats.numUpdates,
+                syncResult.stats.numInserts,
+                syncResult.stats.numDeletes,
+            )
+
+            ListenerManager.synchronizeContactsListeners.remove(synchronizedContactsListener)
+        }
+    }
+
+    override fun onSyncCanceled() {
+        logger.info("Sync has been cancelled")
+        super.onSyncCanceled()
+    }
+
+    override fun onSyncCanceled(thread: Thread?) {
+        logger.info("Sync has been cancelled on thread {}", thread)
+        super.onSyncCanceled(thread)
+    }
+}

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

@@ -29,10 +29,16 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.data.media.FileDataModel;
 
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import static ch.threema.common.JavaCompat.isNullOrEmpty;
+import static ch.threema.logging.ErrorReportingKt.logAndReportError;
+
+import org.slf4j.Logger;
 
 public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 
+    private static final Logger logger = getThreemaLogger("FileChatAdapterDecorator");
+
     private static final String LISTENER_TAG = "FileChatDecorator";
 
     public interface DownloadAlertDialogListener {
@@ -60,9 +66,12 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 
     @Override
     protected void configureChatMessage(final ComposeMessageHolder holder, Context context, final int position) {
-        FileMessagePlayer fileMessagePlayer = (FileMessagePlayer) messagePlayerFactory.create(getMessageModel(), null);
+        MessagePlayer messagePlayer = messagePlayerFactory.create(getMessageModel(), null);
+        if (!(messagePlayer instanceof FileMessagePlayer)) {
+            logAndReportError(logger, "Got MessagePlayer of wrong type: {}", messagePlayer.getClass());
+        }
 
-        holder.messagePlayer = fileMessagePlayer;
+        holder.messagePlayer = messagePlayer;
 
         FileDataModel fileData = getMessageModel().getFileData();
 
@@ -73,17 +82,17 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
             setControllerState(holder, fileData);
         });
 
-        setControllerClickListener(fileMessagePlayer, fileData, holder);
+        setControllerClickListener(messagePlayer, fileData, holder);
         setOnClickListener(view -> {
             if (
                 getMessageModel().getState() != MessageState.FS_KEY_MISMATCH &&
                     getMessageModel().getState() != MessageState.SENDFAILED
             ) {
-                prepareDownload(fileData, fileMessagePlayer);
+                prepareDownload(fileData, messagePlayer);
             }
         }, holder.messageBlockView);
 
-        configureFileMessagePlayer(fileMessagePlayer, holder, fileData, context, position);
+        configureFileMessagePlayer(messagePlayer, holder, fileData, context, position);
         configureBodyText(holder, fileData.getCaption());
         configureTertiaryText(holder, fileData);
         configureSecondaryText(holder, fileData);
@@ -150,14 +159,14 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
     }
 
     private void configureFileMessagePlayer(
-        @NonNull FileMessagePlayer fileMessagePlayer,
+        @NonNull MessagePlayer messagePlayer,
         @NonNull ComposeMessageHolder holder,
         @NonNull FileDataModel fileData,
         Context context,
         int position
     ) {
         Context applicationContext = context.getApplicationContext();
-        fileMessagePlayer
+        messagePlayer
             .addListener(LISTENER_TAG, new MessagePlayer.PlaybackListener() {
                 @Override
                 public void onPlay(AbstractMessageModel messageModel, boolean autoPlay) {
@@ -234,7 +243,7 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
     }
 
     private void setControllerClickListener(
-        @NonNull FileMessagePlayer fileMessagePlayer,
+        @NonNull MessagePlayer messagePlayer,
         @NonNull FileDataModel fileData,
         @NonNull ComposeMessageHolder holder
     ) {
@@ -251,13 +260,13 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
                         case ControllerView.STATUS_READY_TO_PLAY:
                         case ControllerView.STATUS_READY_TO_DOWNLOAD:
                         case ControllerView.STATUS_NONE:
-                            prepareDownload(fileData, fileMessagePlayer);
+                            prepareDownload(fileData, messagePlayer);
                             break;
                         case ControllerView.STATUS_PROGRESSING:
                             if (MessageUtil.isFileMessageBeingSent(getMessageModel())) {
                                 getMessageService().cancelMessageUpload(getMessageModel());
                             } else {
-                                fileMessagePlayer.cancel();
+                                messagePlayer.cancel();
                             }
                             break;
                         default:
@@ -269,20 +278,20 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
         }
     }
 
-    private void prepareDownload(final FileDataModel fileData, final FileMessagePlayer fileMessagePlayer) {
-        if (fileData != null && fileMessagePlayer != null) {
+    private void prepareDownload(final FileDataModel fileData, final MessagePlayer messagePlayer) {
+        if (fileData != null && messagePlayer != null) {
             if (fileData.isDownloaded()) {
-                fileMessagePlayer.open();
+                messagePlayer.open();
             } else {
                 final PreferenceService preferenceService = getPreferenceService();
 
                 if (preferenceService != null && !preferenceService.getFileSendInfoShown()) {
                     downloadAlertDialogListener.showPrepareDownloadDialog(() -> {
                         preferenceService.setFileSendInfoShown(true);
-                        fileMessagePlayer.open();
+                        messagePlayer.open();
                     });
                 } else {
-                    fileMessagePlayer.open();
+                    messagePlayer.open();
                 }
             }
         }

+ 6 - 3
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -269,9 +269,12 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
     }
 
     private void setControllerStateOutgoingMessage(@NonNull ComposeMessageHolder holder) {
-        logger.debug("**** Video MessageStatus: {}", getMessageModel().getState());
-
-        switch (getMessageModel().getState()) {
+        var messageState = getMessageModel().getState();
+        logger.debug("**** Video MessageState: {}", messageState);
+        if (messageState == null) {
+            return;
+        }
+        switch (messageState) {
             case TRANSCODING:
                 holder.controller.setTranscoding();
                 if (holder.transcoderView != null) {

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

@@ -40,13 +40,19 @@ public class PollWizard1Adapter extends RecyclerView.Adapter<RecyclerView.ViewHo
                 if (canEdit(choiceModel)) {
                     removeButton.setOnClickListener(view -> {
                         if (onChoiceListener != null) {
-                            onChoiceListener.onRemoveClicked(getAdapterPosition());
+                            int position = getBindingAdapterPosition();
+                            if (position != RecyclerView.NO_POSITION) {
+                                onChoiceListener.onRemoveClicked(position);
+                            }
                         }
                     });
                     removeButton.setVisibility(View.VISIBLE);
                     editButton.setOnClickListener(view -> {
                         if (onChoiceListener != null) {
-                            onChoiceListener.onEditClicked(getAdapterPosition());
+                            int position = getBindingAdapterPosition();
+                            if (position != RecyclerView.NO_POSITION) {
+                                onChoiceListener.onEditClicked(position);
+                            }
                         }
                     });
                     editButton.setVisibility(View.VISIBLE);

+ 3 - 1
app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactChangeMonitor.kt

@@ -8,6 +8,7 @@ import android.provider.ContactsContract
 import androidx.core.content.ContextCompat
 import ch.threema.app.GlobalListeners
 import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase
+import ch.threema.app.di.awaitAppFullyReady
 import ch.threema.app.di.injectNonBinding
 import ch.threema.app.preference.service.SynchronizedSettingsService
 import ch.threema.app.services.SynchronizeContactsService
@@ -27,13 +28,13 @@ private val logger = getThreemaLogger("AndroidContactChangeMonitor")
 // TODO(ANDR-4752): Convert this to a proper monitor.
 class AndroidContactChangeMonitor(
     private val appContext: Context,
-    private val updateContactNameUseCase: UpdateContactNameUseCase,
     dispatcherProvider: DispatcherProvider,
 ) : KoinComponent {
     private val synchronizeContactsService: SynchronizeContactsService by injectNonBinding()
     private val synchronizedSettingsService: SynchronizedSettingsService by injectNonBinding()
     private val contactModelRepository: ContactModelRepository by injectNonBinding()
 
+    private val updateContactNameUseCase: UpdateContactNameUseCase by injectNonBinding()
     private val coroutineScope = CoroutineScope(dispatcherProvider.io)
     private val mutex = Mutex()
 
@@ -44,6 +45,7 @@ class AndroidContactChangeMonitor(
             }
 
             coroutineScope.launch {
+                awaitAppFullyReady()
                 if (!mutex.tryLock()) {
                     return@launch
                 }

+ 14 - 1
app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactFeatureModule.kt

@@ -4,19 +4,32 @@ import ch.threema.app.androidcontactsync.read.AndroidContactReader
 import ch.threema.app.androidcontactsync.read.LookupInfoReader
 import ch.threema.app.androidcontactsync.read.RawContactCursorProvider
 import ch.threema.app.androidcontactsync.read.RawContactReader
+import ch.threema.app.androidcontactsync.read.ThreemaRawContactReader
+import ch.threema.app.androidcontactsync.synchronization.AndroidContactMatcher
+import ch.threema.app.androidcontactsync.synchronization.IdentityLookupClient
+import ch.threema.app.androidcontactsync.synchronization.ThreemaRawContactManager
 import ch.threema.app.androidcontactsync.usecases.GetAndroidContactNameUseCase
 import ch.threema.app.androidcontactsync.usecases.GetRawContactNameUseCase
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase
 import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase
+import ch.threema.app.androidcontactsync.write.ThreemaRawContactWriter
+import org.koin.core.module.dsl.factoryOf
 import org.koin.core.module.dsl.singleOf
 import org.koin.dsl.module
 
 val androidContactFeatureModule = module {
     singleOf(::AndroidContactChangeMonitor)
+    factoryOf(::AndroidContactMatcher)
     singleOf(::AndroidContactReader)
     singleOf(::GetAndroidContactNameUseCase)
     singleOf(::GetRawContactNameUseCase)
+    factoryOf(::IdentityLookupClient)
     singleOf(::LookupInfoReader)
-    singleOf(::RawContactCursorProvider)
+    factoryOf(::RawContactCursorProvider)
     singleOf(::RawContactReader)
+    factoryOf(::SynchronizeAndroidContactsUseCase)
+    factoryOf(::ThreemaRawContactManager)
+    factoryOf(::ThreemaRawContactReader)
+    factoryOf(::ThreemaRawContactWriter)
     singleOf(::UpdateContactNameUseCase)
 }

+ 1 - 1
app/src/main/java/ch/threema/app/androidcontactsync/read/AndroidContactLookupException.kt

@@ -1,6 +1,6 @@
 package ch.threema.app.androidcontactsync.read
 
-sealed class AndroidContactLookupException : Throwable() {
+sealed class AndroidContactLookupException : Exception() {
     /**
      * There was a failure in getting the lookup information.
      */

+ 1 - 1
app/src/main/java/ch/threema/app/androidcontactsync/read/AndroidContactReadException.kt

@@ -1,6 +1,6 @@
 package ch.threema.app.androidcontactsync.read
 
-sealed class AndroidContactReadException : Throwable() {
+sealed class AndroidContactReadException : Exception() {
     /**
      * The permission to read contacts is missing.
      */

+ 9 - 0
app/src/main/java/ch/threema/app/androidcontactsync/read/CursorCreateException.kt

@@ -0,0 +1,9 @@
+package ch.threema.app.androidcontactsync.read
+
+/**
+ * The cursor could not be created.
+ */
+class CursorCreateException(message: String? = null, cause: Throwable? = null) : Throwable(
+    message = message,
+    cause = cause,
+)

+ 1 - 1
app/src/main/java/ch/threema/app/androidcontactsync/read/LookupInfoException.kt

@@ -1,6 +1,6 @@
 package ch.threema.app.androidcontactsync.read
 
-sealed class LookupInfoException : Throwable() {
+sealed class LookupInfoException : Exception() {
     /**
      * The permission to read contacts is missing.
      */

+ 3 - 10
app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactCursor.kt

@@ -200,11 +200,12 @@ class RawContactCursor private constructor(
         }
 
         /**
-         * Create a raw contact cursor to lookup the raw contact with [lookupInfo].
+         * Create a raw contact cursor to look up the raw contact with [lookupInfo].
          *
          * @throws [CursorCreateException] if the cursor could not be created
          * @throws [SecurityException] if there is no permission to read the contacts
          */
+        @Throws(CursorCreateException::class, SecurityException::class)
         fun createRawContactCursorForLookup(contentResolver: ContentResolver, lookupInfo: LookupInfo): RawContactCursor {
             val contactIdSelection = "${ContactsContract.Data.CONTACT_ID} = ?"
 
@@ -242,15 +243,7 @@ class RawContactCursor private constructor(
             throw CursorCreateException(cause = e)
         }
 
-        /**
-         * The cursor could not be created.
-         */
-        class CursorCreateException(message: String? = null, cause: Throwable? = null) : Throwable(
-            message = message,
-            cause = cause,
-        )
-
-        sealed class CursorException : Throwable() {
+        sealed class CursorException : Exception() {
             /**
              * Thrown if the mime type [mimeType] of the current row is invalid.
              */

+ 21 - 3
app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactCursorProvider.kt

@@ -2,17 +2,19 @@ package ch.threema.app.androidcontactsync.read
 
 import android.content.Context
 import ch.threema.app.androidcontactsync.types.LookupInfo
+import ch.threema.app.services.UserService
 
 class RawContactCursorProvider(
     private val appContext: Context,
+    private val userService: UserService,
 ) {
     /**
      * Create a raw contact cursor that reads all raw contacts.
      *
-     * @throws [RawContactCursor.Companion.CursorCreateException] if the cursor could not be created
+     * @throws [CursorCreateException] if the cursor could not be created
      * @throws [SecurityException] if there is no permission to read the contacts
      */
-    @Throws(RawContactCursor.Companion.CursorCreateException::class, SecurityException::class)
+    @Throws(CursorCreateException::class, SecurityException::class)
     fun getRawContactCursor(): RawContactCursor =
         RawContactCursor.createRawContactCursor(
             contentResolver = appContext.contentResolver,
@@ -21,12 +23,28 @@ class RawContactCursorProvider(
     /**
      * Create a raw contact cursor that reads the contact with the given lookup information.
      *
-     * @throws [RawContactCursor.Companion.CursorCreateException] if the cursor could not be created
+     * @throws [CursorCreateException] if the cursor could not be created
      * @throws [SecurityException] if there is no permission to read the contacts
      */
+    @Throws(CursorCreateException::class, SecurityException::class)
     fun getRawContactCursorForLookup(lookupInfo: LookupInfo) =
         RawContactCursor.createRawContactCursorForLookup(
             contentResolver = appContext.contentResolver,
             lookupInfo = lookupInfo,
         )
+
+    /**
+     * Create a [ThreemaRawContactCursor] that reads all Threema contacts.
+     *
+     * @return null in case there is no account
+     *
+     * @throws [CursorCreateException] if the cursor could not be created
+     * @throws [SecurityException] if there is no permission to read the contacts
+     */
+    @Throws(CursorCreateException::class, SecurityException::class)
+    fun getThreemaRawContactCursor(): ThreemaRawContactCursor? =
+        ThreemaRawContactCursor.createRawContactCursor(
+            contentResolver = appContext.contentResolver,
+            account = userService.account ?: return null,
+        )
 }

+ 0 - 1
app/src/main/java/ch/threema/app/androidcontactsync/read/RawContactReader.kt

@@ -1,7 +1,6 @@
 package ch.threema.app.androidcontactsync.read
 
 import android.net.Uri
-import ch.threema.app.androidcontactsync.read.RawContactCursor.Companion.CursorCreateException
 import ch.threema.app.androidcontactsync.types.LookupInfo
 import ch.threema.app.androidcontactsync.types.RawContact
 import ch.threema.app.androidcontactsync.types.RawContactId

+ 101 - 0
app/src/main/java/ch/threema/app/androidcontactsync/read/ThreemaRawContactCursor.kt

@@ -0,0 +1,101 @@
+package ch.threema.app.androidcontactsync.read
+
+import android.accounts.Account
+import android.content.ContentResolver
+import android.database.Cursor
+import android.provider.ContactsContract
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import ch.threema.app.androidcontactsync.types.ContactId
+import ch.threema.app.androidcontactsync.types.RawContactId
+import ch.threema.app.androidcontactsync.types.ThreemaRawContact
+import ch.threema.domain.types.IdentityString
+
+class ThreemaRawContactCursor private constructor(
+    private val cursor: Cursor,
+    private val rawContactIdIndex: Int,
+    private val contactIdIndex: Int,
+    private val identityStringIndex: Int,
+) : AutoCloseable {
+    /**
+     * Move the cursor to the next row.
+     *
+     * @return true if the move succeeded and false if the cursor is already at the last position
+     */
+    fun moveToNext(): Boolean = cursor.moveToNext()
+
+    /**
+     * Get the [ThreemaRawContact] at the current position of the cursor.
+     *
+     * @throws [CursorException] in case not all required fields can be read
+     */
+    fun getThreemaRawContact(): ThreemaRawContact =
+        ThreemaRawContact(
+            rawContactId = getRawContactId(),
+            contactId = getContactId(),
+            identityString = getIdentityString(),
+        )
+
+    private fun getRawContactId(): RawContactId =
+        cursor.getLongOrNull(rawContactIdIndex)?.let(RawContactId::fromLong)
+            ?: throw CursorException("Could not read raw contact id")
+
+    private fun getContactId(): ContactId =
+        cursor.getLongOrNull(contactIdIndex)?.let(ContactId::fromLong)
+            ?: throw CursorException("Could not read contact id")
+
+    private fun getIdentityString(): IdentityString? =
+        cursor.getStringOrNull(identityStringIndex)
+
+    override fun close() {
+        cursor.close()
+    }
+
+    companion object {
+        private val projection = arrayOf(
+            ContactsContract.RawContacts._ID,
+            ContactsContract.RawContacts.CONTACT_ID,
+            ContactsContract.RawContacts.SYNC1,
+        )
+
+        /**
+         * Create a [ThreemaRawContactCursor].
+         *
+         * @throws [CursorCreateException] if the cursor could not be created
+         * @throws [SecurityException] if there is no permission to read the contacts
+         */
+        @Throws(CursorCreateException::class, SecurityException::class)
+        fun createRawContactCursor(contentResolver: ContentResolver, account: Account): ThreemaRawContactCursor {
+            val uri = ContactsContract.RawContacts.CONTENT_URI
+                .buildUpon()
+                .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
+                .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
+                .build()
+
+            val cursor = contentResolver.query(
+                uri,
+                projection,
+                null,
+                null,
+                null,
+            ) ?: throw CursorCreateException(message = "Created cursor is null")
+
+            return fromCursor(cursor)
+        }
+
+        private fun fromCursor(cursor: Cursor): ThreemaRawContactCursor = try {
+            with(cursor) {
+                ThreemaRawContactCursor(
+                    cursor = cursor,
+                    rawContactIdIndex = getColumnIndexOrThrow(ContactsContract.RawContacts._ID),
+                    contactIdIndex = getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID),
+                    identityStringIndex = getColumnIndexOrThrow(ContactsContract.RawContacts.SYNC1),
+                )
+            }
+        } catch (e: IllegalArgumentException) {
+            throw CursorCreateException(cause = e)
+        }
+    }
+
+    class CursorException(message: String) : Exception(message)
+}

+ 38 - 0
app/src/main/java/ch/threema/app/androidcontactsync/read/ThreemaRawContactReader.kt

@@ -0,0 +1,38 @@
+package ch.threema.app.androidcontactsync.read
+
+import ch.threema.app.androidcontactsync.types.ThreemaRawContact
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.DispatcherProvider
+import kotlin.jvm.Throws
+import kotlinx.coroutines.withContext
+
+private val logger = getThreemaLogger("ThreemaRawContactReader")
+
+class ThreemaRawContactReader(
+    private val dispatcherProvider: DispatcherProvider,
+    private val rawContactCursorProvider: RawContactCursorProvider,
+) {
+
+    /**
+     * Read all threema raw contacts. If there is no account and therefore also no threema raw contacts, an empty set is returned.
+     *
+     * @throws ThreemaRawContactReadException if reading the raw contacts fails
+     */
+    @Throws(ThreemaRawContactReadException::class)
+    suspend fun readAllThreemaRawContacts(): Set<ThreemaRawContact> = withContext(dispatcherProvider.io) {
+        try {
+            val threemaRawContacts = mutableSetOf<ThreemaRawContact>()
+            rawContactCursorProvider.getThreemaRawContactCursor()?.use { cursor ->
+                while (cursor.moveToNext()) {
+                    threemaRawContacts.add(cursor.getThreemaRawContact())
+                }
+            }
+            logger.info("Read {} threema raw contacts", threemaRawContacts.size)
+            threemaRawContacts
+        } catch (e: Exception) {
+            throw ThreemaRawContactReadException(cause = e)
+        }
+    }
+
+    class ThreemaRawContactReadException(cause: Throwable?) : Throwable(cause)
+}

+ 121 - 0
app/src/main/java/ch/threema/app/androidcontactsync/synchronization/AndroidContactMatcher.kt

@@ -0,0 +1,121 @@
+package ch.threema.app.androidcontactsync.synchronization
+
+import ch.threema.app.androidcontactsync.types.AndroidContact
+import ch.threema.app.androidcontactsync.types.EmailAddress
+import ch.threema.app.androidcontactsync.types.PhoneNumber
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.domain.types.Identity
+import ch.threema.logging.logAndReportError
+
+private val logger = getThreemaLogger("AndroidContactMatcher")
+
+/**
+ * Matches android contacts to Threema identities based on phone numbers and email addresses.
+ */
+class AndroidContactMatcher(
+    private val identityLookupClient: IdentityLookupClient,
+) {
+    /** The value that provided the match for an identity. */
+    sealed class MatchSource {
+        data class Phone(val phoneNumber: PhoneNumber) : MatchSource()
+        data class Email(val emailAddress: EmailAddress) : MatchSource()
+    }
+
+    data class MatchedIdentity(
+        val identity: Identity,
+        val matchSource: MatchSource,
+    )
+
+    class AndroidContactMatch(
+        val androidContactMatches: Map<AndroidContact, MatchedIdentity>,
+        val identityToPublicKey: Map<Identity, ByteArray>,
+    )
+
+    /**
+     * Matches the given android contacts to Threema identities via phone number and email address lookup.
+     *
+     * Only 1:1 matches are returned: an android contact is dropped if it matches zero or more than one identity, and an identity is dropped if it is
+     * matched by more than one android contact.
+     *
+     * @throws Exception if the identity lookup api call fails
+     */
+    fun matchContacts(androidContacts: Set<AndroidContact>): AndroidContactMatch {
+        logger.info("Trying to match {} android contacts to an identity", androidContacts.size)
+
+        val identityLookup = identityLookupClient.lookupPhoneAndEmail(
+            phoneNumbers = androidContacts.getAllPhoneNumbers(),
+            emailAddresses = androidContacts.getAllEmailAddresses(),
+        )
+
+        // We associate every android contact with the identities it matches, together with the phone number or email address that provided each
+        // match. It is possible that an android contact maps to zero identities, to exactly one or to more than one.
+        val androidContactToMatches = androidContacts.associateWith { androidContact ->
+            androidContact.findMatchingIdentities(identityLookup)
+        }
+
+        // Store all android contacts by identity, so we can later check how many contacts claim the same identity.
+        val identityToAndroidContacts = mutableMapOf<Identity, MutableSet<AndroidContact>>()
+        androidContactToMatches.forEach { (contact, matches) ->
+            matches.keys.forEach { identity ->
+                identityToAndroidContacts.getOrPut(identity) { mutableSetOf() }.add(contact)
+            }
+        }
+
+        // We only keep contacts that match exactly one identity, and only if that identity is not also matched by another contact.
+        val androidContactToMatchedIdentity = buildMap {
+            var skippedAndroidContacts = 0
+            androidContactToMatches.forEach { (androidContact, matches) ->
+                val (identity, matchSource) = when (matches.size) {
+                    0 -> return@forEach
+                    1 -> matches.entries.first()
+                    else -> {
+                        skippedAndroidContacts++
+                        return@forEach
+                    }
+                }
+
+                val numberOfClaimingContacts = identityToAndroidContacts[identity]?.size ?: 0
+                when (numberOfClaimingContacts) {
+                    0 -> logger.logAndReportError("Identity is claimed by zero contacts despite being present in set")
+                    1 -> put(androidContact, MatchedIdentity(identity, matchSource))
+                    else -> logger.info("Identity {} is claimed by {} contacts", identity, numberOfClaimingContacts)
+                }
+            }
+            if (skippedAndroidContacts > 0) {
+                logger.info("Skipped {} android contact(s) that matched several identities", skippedAndroidContacts)
+            }
+        }
+
+        logger.info("Matched {} out of {} android contacts to an identity", androidContactToMatchedIdentity.size, androidContacts.size)
+
+        return AndroidContactMatch(
+            androidContactMatches = androidContactToMatchedIdentity,
+            identityToPublicKey = identityLookup.identityToPublicKey,
+        )
+    }
+
+    private fun Set<AndroidContact>.getAllPhoneNumbers(): Set<PhoneNumber> =
+        flatMap { androidContact -> androidContact.getAllPhoneNumbers() }
+            .toSet()
+
+    private fun Set<AndroidContact>.getAllEmailAddresses(): Set<EmailAddress> =
+        flatMap { androidContact -> androidContact.getAllEmailAddresses() }
+            .toSet()
+
+    /**
+     * Finds identities matching this android contact's phone numbers and email addresses, together with the value that provided each match. If
+     * both a phone number and an email address match the same identity, the phone number is preferred.
+     */
+    private fun AndroidContact.findMatchingIdentities(identityLookup: IdentityLookupClient.IdentityLookup): Map<Identity, MatchSource> = buildMap {
+        getAllEmailAddresses().forEach { emailAddress ->
+            identityLookup.emailAddressToIdentity[emailAddress]?.let { identity ->
+                put(identity, MatchSource.Email(emailAddress))
+            }
+        }
+        getAllPhoneNumbers().forEach { phoneNumber ->
+            identityLookup.phoneNumberToIdentity[phoneNumber]?.let { identity ->
+                put(identity, MatchSource.Phone(phoneNumber)) // a phone match always takes precedence over an email match
+            }
+        }
+    }
+}

+ 100 - 0
app/src/main/java/ch/threema/app/androidcontactsync/synchronization/IdentityLookupClient.kt

@@ -0,0 +1,100 @@
+package ch.threema.app.androidcontactsync.synchronization
+
+import ch.threema.app.androidcontactsync.types.EmailAddress
+import ch.threema.app.androidcontactsync.types.PhoneNumber
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.services.LocaleService
+import ch.threema.app.stores.MatchTokenStore
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.domain.protocol.api.APIConnector
+import ch.threema.domain.stores.IdentityStore
+import ch.threema.domain.types.Identity
+import kotlin.collections.component1
+import kotlin.collections.component2
+
+private val logger = getThreemaLogger("IdentityLookupClient")
+
+/**
+ * Looks up identities on the server for a given set of phone numbers and email addresses.
+ */
+class IdentityLookupClient(
+    private val apiConnector: APIConnector,
+    private val identityStore: IdentityStore,
+    private val localeService: LocaleService,
+    private val preferenceService: PreferenceService,
+) {
+    /** Result of an identity lookup, keyed by the original phone number / email address. */
+    class IdentityLookup(
+        val phoneNumberToIdentity: Map<PhoneNumber, Identity>,
+        val emailAddressToIdentity: Map<EmailAddress, Identity>,
+        val identityToPublicKey: Map<Identity, ByteArray>,
+    )
+
+    /**
+     * Queries the server for identities matching the given phone numbers and email addresses.
+     * Entries with an ambiguous (non-unique) identity match are dropped and logged.
+     *
+     * @throws Exception if the api call fails
+     */
+    fun lookupPhoneAndEmail(
+        phoneNumbers: Set<PhoneNumber>,
+        emailAddresses: Set<EmailAddress>,
+    ): IdentityLookup {
+        logger.info("Looking up {} phone numbers and {} email addresses", phoneNumbers.size, emailAddresses.size)
+
+        val phoneToIdentities = mutableMapOf<String, MutableSet<Identity>>()
+        val emailToIdentities = mutableMapOf<String, MutableSet<Identity>>()
+
+        val identityToPublicKey = mutableMapOf<Identity, ByteArray>()
+
+        apiConnector.matchIdentities(
+            emailAddresses.map(EmailAddress::emailAddress).toSet(),
+            phoneNumbers.map(PhoneNumber::phoneNumber).toSet(),
+            localeService.countryIsoCode,
+            false,
+            identityStore,
+            MatchTokenStore(preferenceService),
+        ).forEach { (identityString, result) ->
+            val identity = Identity(identityString)
+
+            result.phoneNumber?.let { phoneNumber ->
+                phoneToIdentities.getOrPut(phoneNumber) { mutableSetOf() }.add(identity)
+            }
+
+            result.emailAddress?.let { emailAddress ->
+                emailToIdentities.getOrPut(emailAddress) { mutableSetOf() }.add(identity)
+            }
+
+            identityToPublicKey[identity]?.let { publicKey ->
+                check(publicKey.contentEquals(result.publicKey)) {
+                    "Api returned different public keys for the same identity"
+                }
+            }
+            identityToPublicKey[identity] = result.publicKey
+        }
+
+        val phoneToIdentity = resolveUniqueMatches(phoneToIdentities, ::PhoneNumber, "phone number")
+        val emailToIdentity = resolveUniqueMatches(emailToIdentities, ::EmailAddress, "email address")
+
+        logger.info("Received {} phone and {} email matches", phoneToIdentity.size, emailToIdentity.size)
+
+        return IdentityLookup(
+            phoneNumberToIdentity = phoneToIdentity,
+            emailAddressToIdentity = emailToIdentity,
+            identityToPublicKey = identityToPublicKey,
+        )
+    }
+
+    private fun <T> resolveUniqueMatches(
+        rawMatches: Map<String, MutableSet<Identity>>,
+        keyFactory: (String) -> T,
+        label: String,
+    ): Map<T, Identity> = buildMap {
+        rawMatches.forEach { (rawKey, identities) ->
+            when (identities.size) {
+                1 -> put(keyFactory(rawKey), identities.first())
+                else -> logger.warn("Received {} identities for the same {}", identities.size, label)
+            }
+        }
+    }
+}

+ 125 - 0
app/src/main/java/ch/threema/app/androidcontactsync/synchronization/ThreemaRawContactManager.kt

@@ -0,0 +1,125 @@
+package ch.threema.app.androidcontactsync.synchronization
+
+import android.Manifest
+import android.content.ContentProviderOperation
+import android.provider.ContactsContract
+import androidx.annotation.RequiresPermission
+import ch.threema.app.androidcontactsync.read.ThreemaRawContactReader
+import ch.threema.app.androidcontactsync.types.AndroidContact
+import ch.threema.app.androidcontactsync.types.ContactId
+import ch.threema.app.androidcontactsync.types.RawContact
+import ch.threema.app.androidcontactsync.types.ThreemaRawContact
+import ch.threema.app.androidcontactsync.write.ThreemaRawContactWriter
+import ch.threema.app.services.BlockedIdentitiesService
+import ch.threema.app.utils.AndroidContactUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.logging.logAndReportError
+
+private val logger = getThreemaLogger("ThreemaRawContactManager")
+
+class ThreemaRawContactManager(
+    private val blockedIdentitiesService: BlockedIdentitiesService,
+    private val threemaRawContactReader: ThreemaRawContactReader,
+    private val threemaRawContactWriter: ThreemaRawContactWriter,
+) {
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    suspend fun updateThreemaRawContacts(androidContactMatches: Map<AndroidContact, AndroidContactMatcher.MatchedIdentity>) {
+        // Note that we will remove all valid existing raw contacts from this map so that only invalid and stray raw contacts are left in the end.
+        val threemaRawContactsToDelete = readAllThreemaRawContacts()?.toMutableSet()
+        if (threemaRawContactsToDelete == null) {
+            logger.error("Could not get raw contacts")
+            return
+        }
+        logger.info("Read {} existing threema raw contacts", threemaRawContactsToDelete.size)
+
+        val threemaRawContactsToCreate = mutableMapOf<AndroidContact, AndroidContactMatcher.MatchedIdentity>()
+
+        androidContactMatches.forEach { (androidContact, matchedIdentity) ->
+            val identity = matchedIdentity.identity
+            val threemaRawContacts = threemaRawContactsToDelete.filter { threemaRawContact ->
+                threemaRawContact.identityString == identity.value
+            }.toSet()
+            if (threemaRawContacts.isNotEmpty() && threemaRawContacts.allBelongToContactId(androidContact.lookupInfo.contactId)) {
+                logger.info("Threema raw contact(s) for identity {} are up to date", identity)
+                // Note that we remove the raw contacts from the list, so that in the end only stray raw contacts are left.
+                threemaRawContactsToDelete.removeAll(threemaRawContacts)
+            } else {
+                if (threemaRawContacts.isEmpty()) {
+                    logger.info("No threema raw contacts exist yet for identity {}", identity)
+                } else {
+                    logger.warn(
+                        "Not all of the {} existing raw contacts for identity {} belong to the correct contact id",
+                        threemaRawContacts.size,
+                        identity,
+                    )
+                }
+
+                threemaRawContactsToCreate[androidContact] = matchedIdentity
+            }
+        }
+
+        createThreemaRawContacts(threemaRawContactsToCreate)
+        deleteThreemaRawContacts(threemaRawContactsToDelete)
+    }
+
+    private suspend fun readAllThreemaRawContacts(): Set<ThreemaRawContact>? = try {
+        threemaRawContactReader.readAllThreemaRawContacts()
+    } catch (e: ThreemaRawContactReader.ThreemaRawContactReadException) {
+        logger.error("Could not read threema raw contacts", e)
+        null
+    }
+
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    private fun createThreemaRawContacts(threemaRawContactsToCreate: Map<AndroidContact, AndroidContactMatcher.MatchedIdentity>) {
+        val contentProviderOperations = ArrayList<ContentProviderOperation>()
+
+        threemaRawContactsToCreate.forEach { (androidContact, matchedIdentity) ->
+            val identity = matchedIdentity.identity
+            val supportsVoiceCalls = ContactUtil.canReceiveVoipMessages(identity.value, this.blockedIdentitiesService) && ConfigUtils.isCallsEnabled()
+            val rawContactId = androidContact.findMatchedRawContact(matchedIdentity.matchSource).rawContactId
+
+            AndroidContactUtil.getInstance().createThreemaRawContact(
+                contentProviderOperations,
+                rawContactId.id.toLong(),
+                identity.value,
+                supportsVoiceCalls,
+            )
+        }
+
+        if (!contentProviderOperations.isEmpty()) {
+            try {
+                ConfigUtils.applyToContentResolverInBatches(
+                    ContactsContract.AUTHORITY,
+                    contentProviderOperations,
+                )
+            } catch (e: Exception) {
+                logger.logAndReportError("Error during raw contact creation", e)
+            }
+        }
+    }
+
+    private fun deleteThreemaRawContacts(threemaRawContactsToDelete: Set<ThreemaRawContact>) {
+        if (threemaRawContactsToDelete.isNotEmpty()) {
+            logger.info("Deleting {} raw contacts", threemaRawContactsToDelete.size)
+            threemaRawContactWriter.deleteThreemaRawContacts(threemaRawContactsToDelete)
+        }
+    }
+
+    private fun Collection<ThreemaRawContact>.allBelongToContactId(contactId: ContactId): Boolean =
+        all { threemaRawContact -> threemaRawContact.contactId == contactId }
+
+    /**
+     * Finds the raw contact holding the phone number or email address that provided the match. If several raw contacts hold the exact same value
+     * (e.g. a duplicated phone number), the one with the lowest raw contact id is used, for determinism.
+     */
+    private fun AndroidContact.findMatchedRawContact(matchSource: AndroidContactMatcher.MatchSource): RawContact {
+        val candidates = when (matchSource) {
+            is AndroidContactMatcher.MatchSource.Phone -> rawContacts.filter { matchSource.phoneNumber in it.phoneNumbers }
+            is AndroidContactMatcher.MatchSource.Email -> rawContacts.filter { matchSource.emailAddress in it.emailAddresses }
+        }
+        return candidates.minByOrNull(RawContact::rawContactId)
+            ?: error("No raw contact holds the matched value from $matchSource")
+    }
+}

+ 14 - 0
app/src/main/java/ch/threema/app/androidcontactsync/types/AndroidContact.kt

@@ -21,4 +21,18 @@ data class AndroidContact(
             "Every raw contact of an android contact must have a unique raw contact id"
         }
     }
+
+    /**
+     * Get all phone numbers of all raw contacts of this contact.
+     */
+    fun getAllPhoneNumbers(): Set<PhoneNumber> = rawContacts
+        .flatMap(RawContact::phoneNumbers)
+        .toSet()
+
+    /**
+     * Get all email addresses of all raw contacts of this contact.
+     */
+    fun getAllEmailAddresses(): Set<EmailAddress> = rawContacts
+        .flatMap(RawContact::emailAddresses)
+        .toSet()
 }

+ 12 - 0
app/src/main/java/ch/threema/app/androidcontactsync/types/ThreemaRawContact.kt

@@ -0,0 +1,12 @@
+package ch.threema.app.androidcontactsync.types
+
+import ch.threema.domain.types.IdentityString
+
+/**
+ * A raw contact that was created by threema.
+ */
+data class ThreemaRawContact(
+    val rawContactId: RawContactId,
+    val contactId: ContactId,
+    val identityString: IdentityString?,
+)

+ 338 - 0
app/src/main/java/ch/threema/app/androidcontactsync/usecases/SynchronizeAndroidContactsUseCase.kt

@@ -0,0 +1,338 @@
+package ch.threema.app.androidcontactsync.usecases
+
+import android.Manifest
+import android.content.Context
+import androidx.annotation.RequiresPermission
+import androidx.annotation.WorkerThread
+import ch.threema.app.androidcontactsync.read.AndroidContactReader
+import ch.threema.app.androidcontactsync.synchronization.AndroidContactMatcher
+import ch.threema.app.androidcontactsync.synchronization.ThreemaRawContactManager
+import ch.threema.app.androidcontactsync.types.AndroidContact
+import ch.threema.app.androidcontactsync.types.EmailAddress
+import ch.threema.app.androidcontactsync.types.PhoneNumber
+import ch.threema.app.androidcontactsync.types.RawContact
+import ch.threema.app.listeners.NewSyncedContactsListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.preference.service.SynchronizedSettingsService
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.ExcludedSyncIdentitiesService
+import ch.threema.app.services.UserService
+import ch.threema.app.utils.AndroidContactUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.TimeProvider
+import ch.threema.common.minus
+import ch.threema.data.IdentityProvider
+import ch.threema.data.datatypes.AndroidContactLookupInfo
+import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.models.ContactModel
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.repositories.ContactCreateException
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.models.AcquaintanceLevel
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
+import ch.threema.logging.logAndReportError
+import java.io.IOException
+import java.time.Instant
+import kotlin.time.Duration.Companion.hours
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+private val logger = getThreemaLogger("SynchronizeAndroidContactsUseCase")
+
+class SynchronizeAndroidContactsUseCase(
+    private val appContext: Context,
+    private val androidContactMatcher: AndroidContactMatcher,
+    private val androidContactReader: AndroidContactReader,
+    private val contactModelRepository: ContactModelRepository,
+    private val contactService: ContactService,
+    private val excludedSyncIdentitiesService: ExcludedSyncIdentitiesService,
+    private val getAndroidContactNameUseCase: GetAndroidContactNameUseCase,
+    private val identityProvider: IdentityProvider,
+    private val preferenceService: PreferenceService,
+    private val synchronizedSettingsService: SynchronizedSettingsService,
+    private val threemaRawContactManager: ThreemaRawContactManager,
+    private val timeProvider: TimeProvider,
+    private val userService: UserService,
+) {
+    // TODO(ANDR-4436): Remove this blocking call and solve this properly using coroutines.
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    @WorkerThread
+    @Deprecated("Use call instead")
+    fun callBlocking() = runBlocking {
+        call()
+    }
+
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    suspend fun call() = mutex.withLock {
+        ListenerManager.synchronizeContactsListeners.handle { it.onStarted() }
+        try {
+            synchronizeAndroidContacts()
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: IOException) {
+            logger.error("Could not synchronize android contacts due to network problems", e)
+            ListenerManager.synchronizeContactsListeners.handle { it.onError() }
+        } catch (e: Exception) {
+            logger.logAndReportError("Could not synchronize android contacts", e)
+            ListenerManager.synchronizeContactsListeners.handle { it.onError() }
+        }
+        ListenerManager.synchronizeContactsListeners.handle { it.onFinished() }
+    }
+
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    private suspend fun synchronizeAndroidContacts() {
+        logger.info("Synchronizing android contacts")
+
+        if (!ConfigUtils.isPermissionGranted(appContext, Manifest.permission.WRITE_CONTACTS)) {
+            logger.warn("No contacts permission. Aborting.")
+            return
+        }
+
+        if (!synchronizedSettingsService.isSyncContacts()) {
+            logger.error("Cannot synchronize android contacts as it is disabled")
+            return
+        }
+
+        val account = userService.getAccount()
+        if (account == null) {
+            logger.error("Cannot synchronize android contacts as no account exists")
+            return
+        }
+
+        val androidContacts = androidContactReader.readAllAndroidContacts()
+        val phoneNumbersHash = androidContacts.getPhoneNumbersHash()
+        val emailAddressesHash = androidContacts.getEmailAddressesHash()
+
+        if (isUnchangedPhoneNumbersHash(phoneNumbersHash) && isUnchangedEmailAddressesHash(emailAddressesHash) && !isGraceTimeReached()) {
+            logger.info("Android contacts are unchanged and grace time is not yet reached. Aborting.")
+            return
+        }
+
+        preferenceService.setPhoneNumberSyncHashCode(phoneNumbersHash)
+        preferenceService.setEmailSyncHashCode(emailAddressesHash)
+        preferenceService.setTimeOfLastContactSync(timeProvider.get())
+
+        logger.info("Attempting to synchronize {} android contacts", androidContacts.size)
+
+        val androidContactMatch = androidContactMatcher.matchContacts(androidContacts)
+        val androidContactMatches = androidContactMatch.androidContactMatches
+            .filterValues { matchedIdentity -> matchedIdentity.identity != identityProvider.getIdentity() }
+
+        applyResults(
+            androidContactMatches = androidContactMatches,
+            identityToPublicKey = androidContactMatch.identityToPublicKey,
+        )
+    }
+
+    @RequiresPermission(allOf = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS])
+    private suspend fun applyResults(
+        androidContactMatches: Map<AndroidContact, AndroidContactMatcher.MatchedIdentity>,
+        identityToPublicKey: Map<Identity, ByteArray>,
+    ) {
+        val preSynchronizedIdentities = contactService.synchronizedIdentities
+            .map(::Identity)
+            .toMutableSet()
+
+        val newlyCreatedContactIdentities = mutableSetOf<IdentityString>()
+        val synchronizedIdentities = mutableSetOf<Identity>()
+
+        androidContactMatches.forEach { (androidContact, matchedIdentity) ->
+            val identity = matchedIdentity.identity
+
+            if (excludedSyncIdentitiesService.isExcluded(identity.value)) {
+                logger.info("Identity {} is excluded from sync", identity)
+                return@forEach
+            }
+
+            val publicKey = identityToPublicKey[identity]
+            if (publicKey == null) {
+                logger.warn("No public key for identity {} found. Skipping contact.", identity)
+                return@forEach
+            }
+
+            preSynchronizedIdentities.remove(identity)
+
+            val contactModel = contactModelRepository.getByIdentity(identity)
+                ?.also { contactModel ->
+                    updateContact(
+                        contactModel = contactModel,
+                        publicKey = publicKey,
+                        androidContact = androidContact,
+                    )
+                }
+                ?: run {
+                    createContact(
+                        identity = identity,
+                        publicKey = publicKey,
+                        androidContact = androidContact,
+                    )
+                        ?.also {
+                            newlyCreatedContactIdentities.add(identity.value)
+                        }
+                }
+
+            if (contactModel != null) {
+                synchronizedIdentities.add(Identity(contactModel.identity))
+                AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel, appContext)
+            }
+        }
+
+        if (!preSynchronizedIdentities.isEmpty()) {
+            logger.info("Found {} synchronized contacts that are no longer synchronized", preSynchronizedIdentities.size)
+
+            preSynchronizedIdentities
+                .mapNotNull(contactModelRepository::getByIdentity)
+                .forEach(ContactModel::removeAndroidContactLink)
+        }
+
+        // We exclude identities where no contact model exists as we do not want to create raw contacts for contacts that do not exist in Threema
+        threemaRawContactManager.updateThreemaRawContacts(
+            androidContactMatches
+                .filterValues { matchedIdentity -> synchronizedIdentities.contains(matchedIdentity.identity) },
+        )
+
+        // Let the user know that new contact(s) was/were added
+        val newlyCreatedContacts = contactModelRepository.getByIdentities(newlyCreatedContactIdentities)
+        ListenerManager.newSyncedContactListener.handle { listener: NewSyncedContactsListener ->
+            listener.onNew(newlyCreatedContacts)
+        }
+    }
+
+    private suspend fun createContact(
+        identity: Identity,
+        publicKey: ByteArray,
+        androidContact: AndroidContact,
+    ): ContactModel? {
+        logger.info("Creating new contact for identity {} as result from android contact synchronization", identity)
+        val contactName = getAndroidContactNameUseCase.call(androidContact)
+        if (contactName == null) {
+            logger.warn("Could not get contact name for identity {}", identity)
+        }
+        val contactModelData = ContactModelData(
+            identity = identity.value,
+            publicKey = publicKey,
+            createdAt = timeProvider.get(),
+            lastUpdateAt = null,
+            firstName = contactName?.firstName ?: "",
+            lastName = contactName?.lastName ?: "",
+            nickname = null,
+            verificationLevel = VerificationLevel.SERVER_VERIFIED,
+            workVerificationLevel = WorkVerificationLevel.NONE,
+            identityType = IdentityType.REGULAR, // TODO(ANDR-3044): Fetch identity type
+            acquaintanceLevel = AcquaintanceLevel.DIRECT,
+            activityState = IdentityState.ACTIVE, // TODO(ANDR-3044): Fetch identity state
+            featureMask = 0u, // TODO(ANDR-3044): Fetch feature mask
+            syncState = ContactSyncState.IMPORTED,
+            readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+            typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+            conversationVisibility = ConversationVisibility.NORMAL,
+            androidContactLookupInfo = androidContact.getAndroidContactLookupInfo(),
+            localAvatarExpires = null,
+            isRestored = false,
+            profilePictureBlobId = null,
+            jobTitle = null,
+            department = null,
+            notificationTriggerPolicyOverride = null,
+            availabilityStatus = AvailabilityStatus.None,
+            workLastFullSyncAt = null,
+        )
+
+        return try {
+            contactModelRepository.createFromLocal(contactModelData)
+        } catch (e: ContactCreateException) {
+            logger.error("Could not create contact", e)
+            null
+        }
+    }
+
+    private fun updateContact(
+        contactModel: ContactModel,
+        publicKey: ByteArray,
+        androidContact: AndroidContact,
+    ) {
+        logger.info("Updating contact {} as a result of the android contact synchronization", contactModel.identity)
+        if (contactModel.data?.publicKey?.contentEquals(publicKey) != true) {
+            logger.warn("Cannot update contact due to different public keys")
+            if (contactModel.data?.verificationLevel == VerificationLevel.SERVER_VERIFIED) {
+                logger.warn("Downgrading verification level of contact {} because of public key mismatch", contactModel.identity)
+                contactModel.setVerificationLevelFromLocal(VerificationLevel.UNVERIFIED)
+            }
+            return
+        }
+
+        contactModel.setAndroidContactLookupKey(androidContact.getAndroidContactLookupInfo())
+        val contactName = getAndroidContactNameUseCase.call(androidContact)
+        if (contactName != null) {
+            contactModel.setNameFromLocal(
+                firstName = contactName.firstName,
+                lastName = contactName.lastName,
+            )
+        } else {
+            logger.warn("Could get contact name for identity {}", contactModel.identity)
+        }
+        if (contactModel.data?.verificationLevel == VerificationLevel.UNVERIFIED) {
+            contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
+        }
+    }
+
+    private fun isUnchangedPhoneNumbersHash(phoneNumbersHash: Int): Boolean =
+        preferenceService.getPhoneNumberSyncHashCode() == phoneNumbersHash
+
+    private fun isUnchangedEmailAddressesHash(emailAddressesHash: Int): Boolean =
+        preferenceService.getEmailSyncHashCode() == emailAddressesHash
+
+    private fun isGraceTimeReached(): Boolean =
+        timeProvider.get() - (preferenceService.getTimeOfLastContactSync() ?: Instant.EPOCH) >= 23.hours
+
+    /**
+     * Computes an order-independent hash of the distinct phone numbers across all raw contacts in this set, regardless of which
+     * AndroidContact/RawContact each number belongs to. Numbers are deduplicated: moving a number between raw contacts, or having it appear on
+     * multiple raw contacts, does not change the hash.
+     */
+    private fun Set<AndroidContact>.getPhoneNumbersHash(): Int = asSequence()
+        .flatMap(AndroidContact::rawContacts)
+        .flatMap(RawContact::phoneNumbers)
+        .map(PhoneNumber::phoneNumber)
+        .toSet()
+        .hashCode()
+
+    /**
+     * Computes an order-independent hash of the distinct email addresses across all raw contacts in this set, regardless of which
+     * AndroidContact/RawContact each email address belongs to. Email addresses are deduplicated: moving an email address between raw contacts, or
+     * having it appear on multiple raw contacts, does not change the hash.
+     */
+    private fun Set<AndroidContact>.getEmailAddressesHash(): Int = asSequence()
+        .flatMap(AndroidContact::rawContacts)
+        .flatMap(RawContact::emailAddresses)
+        .map(EmailAddress::emailAddress)
+        .toSet()
+        .hashCode()
+
+    private fun AndroidContact.getAndroidContactLookupInfo() = AndroidContactLookupInfo(
+        lookupKey = lookupInfo.lookupKey.key,
+        contactId = lookupInfo.contactId.id.toLong(),
+    )
+
+    companion object {
+        private val mutex = Mutex()
+
+        // TODO(ANDR-4436): The use case should not manage the lock state itself. We should introduce a dedicated handler for this (e.g. a
+        //  ContactSyncLockProvider).
+        @JvmStatic
+        val isRunning: Boolean
+            get() = mutex.isLocked
+    }
+}

+ 73 - 0
app/src/main/java/ch/threema/app/androidcontactsync/write/ThreemaRawContactWriter.kt

@@ -0,0 +1,73 @@
+package ch.threema.app.androidcontactsync.write
+
+import android.Manifest
+import android.content.ContentProviderOperation
+import android.content.Context
+import android.provider.ContactsContract
+import ch.threema.app.androidcontactsync.types.ThreemaRawContact
+import ch.threema.app.services.UserService
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.logging.logAndReportError
+
+private val logger = getThreemaLogger("ThreemaRawContactWriter")
+
+class ThreemaRawContactWriter(
+    private val appContext: Context,
+    private val userService: UserService,
+) {
+    /**
+     * Delete all provided [ThreemaRawContact]s.
+     *
+     * @param threemaRawContacts Set of the [ThreemaRawContact]s to delete.
+     */
+    fun deleteThreemaRawContacts(threemaRawContacts: Set<ThreemaRawContact>) {
+        logger.info("Request deletion of {} threema raw contacts", threemaRawContacts.size)
+        if (!ConfigUtils.isPermissionGranted(appContext, Manifest.permission.WRITE_CONTACTS)) {
+            logger.error("Cannot delete raw contacts if permission is not granted")
+            return
+        }
+
+        val account = userService.account ?: run {
+            logger.error("Cannot delete raw contacts if no account exists")
+            return
+        }
+
+        if (threemaRawContacts.isEmpty()) {
+            return
+        }
+
+        val contentProviderOperations = ArrayList<ContentProviderOperation>()
+
+        threemaRawContacts
+            .filter { threemaRawContact -> threemaRawContact.rawContactId.id != 0UL }
+            .forEach { threemaRawContact ->
+                try {
+                    val builder = ContentProviderOperation.newDelete(
+                        ContactsContract.RawContacts.CONTENT_URI
+                            .buildUpon()
+                            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+                            .appendQueryParameter(ContactsContract.RawContacts.SYNC1, threemaRawContact.identityString)
+                            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
+                            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
+                            .build(),
+                    )
+                        .withSelection(ContactsContract.RawContacts._ID + " = ?", arrayOf(threemaRawContact.rawContactId.id.toString()))
+
+                    contentProviderOperations.add(builder.build())
+                } catch (e: Exception) {
+                    logger.error("Failed to create new delete content provider operation", e)
+                }
+            }
+
+        if (contentProviderOperations.isNotEmpty()) {
+            try {
+                ConfigUtils.applyToContentResolverInBatches(ContactsContract.AUTHORITY, contentProviderOperations)
+            } catch (e: Exception) {
+                logger.logAndReportError("Error during raw contact deletion", e)
+            }
+        }
+
+        logger.info("Applied {} content provider operations", contentProviderOperations.size)
+    }
+}

+ 2 - 3
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -273,6 +273,8 @@ public class BackupService extends Service {
 
         isRunning = true;
 
+        notificationManagerCompat = NotificationManagerCompat.from(this);
+
         serviceManager = ServiceManager.get();
         if (serviceManager == null) {
             safeStopSelf();
@@ -293,10 +295,7 @@ public class BackupService extends Service {
         } catch (Exception e) {
             logger.error("Exception while setting up backup service", e);
             safeStopSelf();
-            return;
         }
-
-        notificationManagerCompat = NotificationManagerCompat.from(this);
     }
 
     @Override

+ 3 - 4
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -292,6 +292,9 @@ public class RestoreService extends Service implements ComponentCallbacks2 {
 
         isRunning = true;
 
+        notificationManagerCompat = NotificationManagerCompat.from(this);
+        activityManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
+
         serviceManager = ServiceManager.get();
         if (serviceManager == null) {
             stopSelf();
@@ -312,11 +315,7 @@ public class RestoreService extends Service implements ComponentCallbacks2 {
         } catch (Exception e) {
             logger.error("Could not instantiate all required services", e);
             stopSelf();
-            return;
         }
-
-        notificationManagerCompat = NotificationManagerCompat.from(this);
-        activityManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
     }
 
     @Override

+ 46 - 69
app/src/main/java/ch/threema/app/camera/CameraFragment.kt

@@ -74,11 +74,13 @@ import com.google.android.material.progressindicator.CircularProgressIndicator
 import java.io.File
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
+import kotlin.coroutines.resume
 import kotlin.math.floor
 import kotlin.time.Duration.Companion.milliseconds
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
 import org.koin.android.ext.android.inject
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
@@ -343,19 +345,19 @@ class CameraFragment : Fragment() {
         }
     }
 
-    /** Initialize CameraX, and prepare to bind the camera use cases  */
     private fun setUpCamera() {
-        val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
-        cameraProviderFuture.addListener({
-            // CameraProvider
-            cameraProvider = cameraProviderFuture.get()
-            if (cameraProvider == null) {
-                logger.warn("Camera provider is null")
-            }
+        lifecycleScope.launch {
+            val cameraProvider = getCameraProvider(requireContext())
+                ?: run {
+                    showToast(R.string.no_camera_installed)
+                    logger.warn("Camera provider is null")
+                    activity?.finish()
+                    return@launch
+                }
+
+            this@CameraFragment.cameraProvider = cameraProvider
 
-            // Select lensFacing depending on the available cameras
             if (viewModel.lensFacing == CameraSelector.LENS_FACING_BACK && !hasBackCamera()) {
-                // try front camera
                 viewModel.lensFacing = CameraSelector.LENS_FACING_FRONT
             }
 
@@ -381,9 +383,31 @@ class CameraFragment : Fragment() {
                 logger.info("Unable to bind camera use cases")
                 activity?.finish()
             }
-        }, ContextCompat.getMainExecutor(requireContext()))
+        }
     }
 
+    private suspend fun getCameraProvider(context: Context): ProcessCameraProvider? =
+        suspendCancellableCoroutine { continuation ->
+            val future = try {
+                ProcessCameraProvider.getInstance(context)
+            } catch (e: IllegalStateException) {
+                logger.error("Failed to initialize camera (getInstance)", e)
+                continuation.resume(null)
+                return@suspendCancellableCoroutine
+            }
+            future.addListener({
+                try {
+                    continuation.resume(future.get())
+                } catch (e: Exception) {
+                    logger.error("Failed to initialize camera (listener)", e)
+                    continuation.resume(null)
+                }
+            }, ContextCompat.getMainExecutor(context))
+            continuation.invokeOnCancellation {
+                future.cancel(true)
+            }
+        }
+
     /** Declare and bind preview, capture and analysis use cases */
     private fun bindCameraUseCases(): Boolean {
         if (previewView == null || previewView!!.display == null) {
@@ -563,68 +587,21 @@ class CameraFragment : Fragment() {
 
     private fun observeCameraState(cameraInfo: CameraInfo) {
         cameraInfo.cameraState.observe(viewLifecycleOwner) { cameraState ->
-            run {
-                when (cameraState.type) {
-                    CameraState.Type.PENDING_OPEN -> {
-                        // Ask the user to close other camera apps
-                        logger.debug("CameraState: Pending Open")
-                    }
-                    CameraState.Type.OPENING -> {
-                        // Show the Camera UI
-                        logger.debug("CameraState: Opening")
-                    }
-                    CameraState.Type.OPEN -> {
-                        // Setup Camera resources and begin processing
-                        logger.debug("CameraState: Open")
-                    }
-                    CameraState.Type.CLOSING -> {
-                        // Close camera UI
-                        logger.debug("CameraState: Closing")
-                    }
-                    CameraState.Type.CLOSED -> {
-                        // Free camera resources
-                        logger.debug("CameraState: Closed")
-                    }
-                }
-            }
-
             cameraState.error?.let { error ->
                 when (error.code) {
-                    // Open errors
-                    CameraState.ERROR_STREAM_CONFIG -> {
-                        // Make sure to setup the use cases properly
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Stream config error")
-                    }
-                    // Opening errors
-                    CameraState.ERROR_CAMERA_IN_USE -> {
-                        // Close the camera or ask user to close another camera app that's using the
-                        // camera
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Camera in use")
+                    CameraState.ERROR_CAMERA_IN_USE,
+                    CameraState.ERROR_MAX_CAMERAS_IN_USE,
+                    CameraState.ERROR_CAMERA_DISABLED,
+                    -> {
+                        showToast(R.string.no_camera_installed)
                     }
-                    CameraState.ERROR_MAX_CAMERAS_IN_USE -> {
-                        // Close another open camera in the app, or ask the user to close another
-                        // camera app that's using the camera
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Max cameras in use")
-                    }
-                    CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> {
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Other recoverable error")
-                    }
-                    // Closing errors
-                    CameraState.ERROR_CAMERA_DISABLED -> {
-                        // Ask the user to enable the device's cameras
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Camera disabled")
-                    }
-                    CameraState.ERROR_CAMERA_FATAL_ERROR -> {
-                        // Ask the user to reboot the device to restore camera function
-                        // TODO(ANDR-4447): Toast messages should be localized
-                        showToast("Fatal error")
+                    CameraState.ERROR_OTHER_RECOVERABLE_ERROR,
+                    CameraState.ERROR_STREAM_CONFIG,
+                    CameraState.ERROR_CAMERA_FATAL_ERROR,
+                    -> {
+                        logger.error("Camera failed {}", error)
+                        showToast(R.string.an_error_occurred)
                     }
-                    // Closed errors
                     CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> {
                         // Ask the user to disable the "Do Not Disturb" mode, then reopen the camera
                         // TODO(ANDR-4447): Toast messages should be localized

+ 18 - 6
app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt

@@ -8,9 +8,9 @@ import android.util.Size
 import android.view.MotionEvent
 import android.view.View
 import android.widget.TextView
-import android.widget.Toast
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraUnavailableException
 import androidx.camera.core.FocusMeteringAction
 import androidx.camera.core.ImageAnalysis
 import androidx.camera.core.ImageCapture
@@ -20,8 +20,10 @@ import androidx.camera.view.PreviewView
 import androidx.core.content.ContextCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsControllerCompat
+import ch.threema.android.ToastDuration
 import ch.threema.android.buildActivityIntent
 import ch.threema.android.buildIntent
+import ch.threema.android.showToast
 import ch.threema.app.R
 import ch.threema.app.activities.ThreemaActivity
 import ch.threema.app.ui.InsetSides
@@ -30,6 +32,7 @@ import ch.threema.app.ui.applyDeviceInsetsAsPadding
 import ch.threema.app.utils.SoundEffectPlayer
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.hasInCauseChain
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
 import org.koin.android.ext.android.inject
@@ -105,10 +108,19 @@ class QRScannerActivity : ThreemaActivity() {
         val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
         cameraProviderFuture.addListener(
             {
-                // CameraProvider
-                cameraProvider = cameraProviderFuture.get()
+                try {
+                    cameraProvider = cameraProviderFuture.get()
+                } catch (e: Exception) {
+                    if (e.hasInCauseChain<CameraUnavailableException>()) {
+                        showToast(R.string.no_camera_installed)
+                    } else {
+                        showToast(R.string.an_error_occurred)
+                    }
+                    logger.error("Failed to get camera provider", e)
+                    finish()
+                    return@addListener
+                }
 
-                // Build and bind the camera use cases
                 bindCameraUseCases()
             },
             ContextCompat.getMainExecutor(this),
@@ -124,7 +136,7 @@ class QRScannerActivity : ThreemaActivity() {
         }
 
         if (lensFacing == -1) {
-            Toast.makeText(this, R.string.no_camera_installed, Toast.LENGTH_SHORT).show()
+            showToast(R.string.no_camera_installed)
             logger.info("Back and front camera are unavailable")
             finish()
             return
@@ -162,7 +174,7 @@ class QRScannerActivity : ThreemaActivity() {
                                 logger.debug("Decoder Error")
                                 it.clearAnalyzer()
                                 runOnUiThread {
-                                    Toast.makeText(this, R.string.qr_code, Toast.LENGTH_LONG).show()
+                                    showToast(R.string.an_error_occurred, ToastDuration.LONG)
                                     returnData(
                                         qrCodeData = null,
                                         success = false,

+ 10 - 10
app/src/main/java/ch/threema/app/connection/CspD2mDualConnectionSupplier.kt

@@ -5,7 +5,6 @@ import ch.threema.app.BuildConfig
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.services.ServerAddressProviderService
 import ch.threema.app.startup.AppStartupMonitor
-import ch.threema.base.utils.AsyncResolver
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.connection.BaseServerConnectionConfiguration
@@ -16,6 +15,7 @@ import ch.threema.domain.protocol.connection.ServerConnection
 import ch.threema.domain.protocol.connection.ServerConnectionException
 import ch.threema.domain.protocol.connection.csp.CspConnectionConfiguration
 import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
+import ch.threema.domain.protocol.connection.csp.socket.HostResolver
 import ch.threema.domain.protocol.connection.csp.socket.ProxyAwareSocketFactory
 import ch.threema.domain.protocol.connection.d2m.D2mConnectionConfiguration
 import ch.threema.domain.protocol.connection.util.ConnectionLoggingUtil
@@ -122,15 +122,15 @@ class CspD2mDualConnectionSupplier(
     private fun createCspConnectionConfiguration(): CspConnectionConfiguration {
         logger.info("Create csp connection configuration")
         return CspConnectionConfiguration(
-            identityStore,
-            serverAddressProviderService.serverAddressProvider,
-            version,
-            isTestBuild,
-            deviceCookieManager,
-            incomingMessageProcessor,
-            taskManager,
-            AsyncResolver::getAllByName,
-            ProxyAwareSocketFactory::makeSocket,
+            identityStore = identityStore,
+            serverAddressProvider = serverAddressProviderService.serverAddressProvider,
+            version = version,
+            assertDispatcherContext = isTestBuild,
+            deviceCookieManager = deviceCookieManager,
+            incomingMessageProcessor = incomingMessageProcessor,
+            taskManager = taskManager,
+            hostResolver = HostResolver(),
+            socketFactory = ProxyAwareSocketFactory::makeSocket,
         )
     }
 

+ 0 - 2
app/src/main/java/ch/threema/app/di/DependencyContainer.kt

@@ -28,7 +28,6 @@ import ch.threema.app.services.ProfilePictureRecipientsService
 import ch.threema.app.services.RingtoneService
 import ch.threema.app.services.SensorService
 import ch.threema.app.services.ServerAddressProviderService
-import ch.threema.app.services.SynchronizeContactsService
 import ch.threema.app.services.UserService
 import ch.threema.app.services.WallpaperService
 import ch.threema.app.services.license.LicenseService
@@ -99,7 +98,6 @@ class DependencyContainer : KoinComponent {
     val serverAddressProviderService: ServerAddressProviderService by inject()
     val serverConnection: ServerConnection by inject()
     val sessionService: SessionService by inject()
-    val synchronizeContactsService: SynchronizeContactsService by inject()
     val synchronizedSettingsService: SynchronizedSettingsService by inject()
     val taskCreator: TaskCreator by inject()
     val taskManager: TaskManager by inject()

+ 2 - 11
app/src/main/java/ch/threema/app/di/SessionScopeContainer.kt

@@ -6,8 +6,7 @@ import androidx.annotation.VisibleForTesting
 import androidx.core.content.getSystemService
 import ch.threema.app.BuildFlavor
 import ch.threema.app.BuildFlavor.Companion.current
-import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase
-import ch.threema.app.apptaskexecutor.AppTaskExecutor
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase
 import ch.threema.app.backuprestore.ExportConversationService
 import ch.threema.app.backuprestore.ExportConversationServiceImpl
 import ch.threema.app.connection.CspD2mDualConnectionSupplier
@@ -457,20 +456,12 @@ class SessionScopeContainer(
 
     val synchronizeContactsService: SynchronizeContactsService by lazyWithClosedCheck {
         SynchronizeContactsServiceImpl(
-            appContext,
-            apiConnector,
             contactService,
             modelRepositories.contacts,
             userService,
-            localeService,
-            excludedSyncIdentitiesService,
-            get<PreferenceService>(),
             synchronizedSettingsService,
             deviceService,
-            identityStore,
-            blockedIdentitiesService,
-            get<AppTaskExecutor>(),
-            get<UpdateContactNameUseCase>(),
+            get<SynchronizeAndroidContactsUseCase>(),
         )
     }
 

+ 13 - 9
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt

@@ -20,10 +20,11 @@ import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
 import androidx.recyclerview.widget.RecyclerView
 import androidx.viewpager2.widget.ViewPager2
+import ch.threema.android.disableExitTransition
 import ch.threema.app.R
 import ch.threema.app.activities.ThreemaToolbarActivity
 import ch.threema.app.emojis.EmojiTextView
-import ch.threema.app.managers.ServiceManager
+import ch.threema.app.services.MessageService
 import ch.threema.app.services.UserService
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.app.utils.logScreenVisibility
@@ -50,6 +51,7 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
     }
 
     private val userService: UserService by inject()
+    private val messageService: MessageService by inject()
     private val myIdentity by lazy { userService.identity }
 
     private lateinit var viewPager: ViewPager2
@@ -78,9 +80,6 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
             overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
         }
 
-        val serviceManager = ServiceManager.require()
-        val messageService = serviceManager.messageService
-
         messageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService)
         messageModel?.let { message ->
 
@@ -347,11 +346,8 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
     override fun finish() {
         try {
             super.finish()
-            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-                @Suppress("DEPRECATION")
-                overridePendingTransition(0, 0)
-            }
-        } catch (ignored: Exception) {
+            disableExitTransition()
+        } catch (_: Exception) {
             // ignore
         }
     }
@@ -375,6 +371,14 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
         }
     }
 
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        // We don't want to persist any state, as the fragments have no meaningful state anyway, and they can't be re-instantiated as
+        // they don't have a default constructor. Currently, this does not matter anyway as the conversation screen itself is likely
+        // closed immediately after.
+        outState.clear()
+    }
+
     companion object {
         const val EXTRA_INITIAL_EMOJI = "extra_initial_emoji" // emoji to show initially
     }

+ 3 - 6
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewAdapter.kt

@@ -43,11 +43,8 @@ class EmojiReactionsOverviewAdapter(
         }
     }
 
-    override fun getItemCount(): Int {
-        return items.size
-    }
+    override fun getItemCount() = items.size
 
-    override fun createFragment(position: Int): Fragment {
-        return EmojiReactionsOverviewFragment(items[position].key, messageModel = messageModel)
-    }
+    override fun createFragment(position: Int): Fragment =
+        EmojiReactionsOverviewFragment(items[position].key, messageModel = messageModel)
 }

+ 2 - 21
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt

@@ -1,7 +1,6 @@
 package ch.threema.app.emojireactions
 
 import android.os.Bundle
-import android.os.Parcelable
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
@@ -31,13 +30,11 @@ import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
 
-private const val RECYCLER_VIEW_STATE = "recyclerViewState"
-
 private val logger = getThreemaLogger("EmojiReactionsOverviewFragment")
 
 class EmojiReactionsOverviewFragment(
-    val emojiSequence: String? = null,
-    val messageModel: AbstractMessageModel,
+    private val emojiSequence: String,
+    private val messageModel: AbstractMessageModel,
 ) : Fragment(), EmojiReactionsOverviewListAdapter.OnItemClickListener {
     init {
         logScreenVisibility(logger)
@@ -116,20 +113,4 @@ class EmojiReactionsOverviewFragment(
             }
         }
     }
-
-    override fun onSaveInstanceState(outState: Bundle) {
-        super.onSaveInstanceState(outState)
-        outState.putParcelable(
-            RECYCLER_VIEW_STATE,
-            recyclerView.layoutManager?.onSaveInstanceState(),
-        )
-    }
-
-    override fun onViewStateRestored(savedInstanceState: Bundle?) {
-        super.onViewStateRestored(savedInstanceState)
-        @Suppress("DEPRECATION")
-        savedInstanceState?.getParcelable<Parcelable>(RECYCLER_VIEW_STATE)?.let {
-            recyclerView.layoutManager?.onRestoreInstanceState(it)
-        }
-    }
 }

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

@@ -93,7 +93,6 @@ import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.preference.service.SynchronizedSettingsService;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.avatarcache.AvatarCacheService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.LockAppService;
@@ -309,22 +308,21 @@ public class ContactsSectionFragment
 
     private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
         @Override
-        public void onStarted(SynchronizeContactsRoutine startedRoutine) {
-            //only show loading on "full sync"
-            if (resumePauseHandler != null && swipeRefreshLayout != null && startedRoutine.isFullSync()) {
+        public void onStarted() {
+            if (resumePauseHandler != null && swipeRefreshLayout != null) {
                 resumePauseHandler.runOnActive(RUN_ON_ACTIVE_SHOW_LOADING, runIfActiveShowLoading);
             }
         }
 
         @Override
-        public void onFinished(SynchronizeContactsRoutine finishedRoutine) {
+        public void onFinished() {
             if (resumePauseHandler != null && swipeRefreshLayout != null) {
                 resumePauseHandler.runOnActive(RUN_ON_ACTIVE_HIDE_LOADING, runIfActiveClearCacheAndRefresh);
             }
         }
 
         @Override
-        public void onError(SynchronizeContactsRoutine finishedRoutine) {
+        public void onError() {
             if (resumePauseHandler != null && swipeRefreshLayout != null) {
                 resumePauseHandler.runOnActive(RUN_ON_ACTIVE_HIDE_LOADING, runIfActiveClearCacheAndRefresh);
             }

+ 14 - 13
app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt

@@ -113,13 +113,13 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
 
     private val smsVerificationListener: SMSVerificationListener = object : SMSVerificationListener {
         override fun onVerified() {
-            lifecycleScope.launch {
+            viewLifecycleOwner.lifecycleScope.launch {
                 updatePendingState(requireView(), false)
             }
         }
 
         override fun onVerificationStarted() {
-            lifecycleScope.launch {
+            viewLifecycleOwner.lifecycleScope.launch {
                 updatePendingState(requireView(), false)
             }
         }
@@ -414,7 +414,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
             userService.getEmailLinkingState() == UserService.LinkingState_PENDING ||
             userService.getMobileLinkingState() == UserService.LinkingState_PENDING
         ) {
-            lifecycleScope.launch {
+            viewLifecycleOwner.lifecycleScope.launch {
                 withContext(dispatcherProvider.worker) {
                     if (userService.emailLinkingState == UserService.LinkingState_PENDING) {
                         userService.checkEmailLinkState(TriggerSource.LOCAL)
@@ -426,10 +426,11 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         }
     }
 
-    private fun updatePendingStateTexts(fragmentView: View) {
+    private fun updatePendingStateTexts(fragmentView: View? = null) {
         if (!isAdded || isDetached || isRemoving) {
             return
         }
+        val fragmentView = fragmentView ?: requireView()
 
         updateLinkedEmailTextView(fragmentView)
         updateLinkedMobileTextView(fragmentView)
@@ -629,7 +630,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     }
 
     private fun setRevocationKey(revocationKey: String) {
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             showProgressDialog(R.string.revocation_key_title)
             try {
                 val success = withContext(dispatcherProvider.worker) {
@@ -639,7 +640,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
                     showToast(getString(R.string.error) + ": " + getString(R.string.revocation_key_not_set), ToastDuration.LONG)
                 }
             } finally {
-                updatePendingStateTexts(requireView())
+                updatePendingStateTexts()
                 dismissProgressDialog()
             }
         }
@@ -705,7 +706,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
                 logger.error("Failed to verify mobile number", e)
                 showMobileVerifyErrorDialog(getString(R.string.an_error_occurred))
             } finally {
-                updatePendingStateTexts(requireView())
+                updatePendingStateTexts()
             }
         }
     }
@@ -760,7 +761,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     }
 
     private fun linkWithEmail(emailAddress: String) {
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             try {
                 if (userService.getEmailLinkingState() != UserService.LinkingState_NONE && userService.getLinkedEmail() == emailAddress) {
                     showToast(R.string.email_already_linked, ToastDuration.LONG)
@@ -775,7 +776,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
                 showToast(R.string.an_error_occurred, ToastDuration.LONG)
             } finally {
                 dismissProgressDialog()
-                updatePendingStateTexts(requireView())
+                updatePendingStateTexts()
             }
         }
     }
@@ -815,7 +816,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     }
 
     private fun unlinkEmail() {
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             try {
                 if (userService.getEmailLinkingState() != UserService.LinkingState_NONE) {
                     showProgressDialog(R.string.unlinking_email)
@@ -828,13 +829,13 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
                 showToast(R.string.an_error_occurred, ToastDuration.LONG)
             } finally {
                 dismissProgressDialog()
-                updatePendingStateTexts(requireView())
+                updatePendingStateTexts()
             }
         }
     }
 
     private fun unlinkMobile() {
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             try {
                 withContext(dispatcherProvider.worker) {
                     userService.unlinkMobileNumber(TriggerSource.LOCAL)
@@ -843,7 +844,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
                 logger.error("Failed to unlink phone number", e)
                 showToast(R.string.an_error_occurred)
             } finally {
-                updatePendingStateTexts(requireView())
+                updatePendingStateTexts()
             }
         }
     }

+ 7 - 2
app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java

@@ -802,7 +802,9 @@ public class ComposeMessageFragment extends Fragment implements
         final @Nullable GroupModel currentGroupModel = groupModelRepository.getByGroupIdentity(groupIdentityOfChangedGroup);
         if (currentGroupModel != null) {
             messageReceiver = groupService.createReceiver(groupModel);
-            composeMessageAdapter.setMessageReceiver(messageReceiver);
+            if (composeMessageAdapter != null) {
+                composeMessageAdapter.setMessageReceiver(messageReceiver);
+            }
         }
         return true;
     }
@@ -2310,7 +2312,7 @@ public class ComposeMessageFragment extends Fragment implements
     }
 
     public void onNewIntent(Intent intent) {
-        logger.debug("onNewIntent");
+        logger.info("onNewIntent");
 
         if (!requiredInstances()) {
             return;
@@ -2320,6 +2322,9 @@ public class ComposeMessageFragment extends Fragment implements
             this.messagePlayerService.stopAll();
             this.messagePlayerService.release();
         }
+        if (thumbnailCache != null) {
+            thumbnailCache.flush();
+        }
 
         MediaController mediaController = getMedia3Controller();
         if (mediaController != null) {

+ 5 - 6
app/src/main/java/ch/threema/app/fragments/wizard/WizardFragment3.java

@@ -214,17 +214,16 @@ public class WizardFragment3 extends WizardFragment {
                     }
                     Collections.sort(results, new CountryNameComparator());
 
-
-                    Map<String, String> data = new HashMap<>(2);
-                    data.put("name", getString(R.string.new_wizard_select_country));
-                    data.put("prefix", "");
-                    results.add(data);
-
                     return results;
                 }
 
                 @Override
                 protected void onPostExecute(final ArrayList<Map<String, String>> result) {
+                    Map<String, String> data = new HashMap<>(2);
+                    data.put("name", getString(R.string.new_wizard_select_country));
+                    data.put("prefix", "");
+                    result.add(data);
+
                     countryListAdapter = new CountryListAdapter(android.R.layout.simple_spinner_dropdown_item, result);
                     countrySpinner.setAdapter(countryListAdapter);
                     countrySpinner.setSelection(countryListAdapter.getCount());

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

@@ -1,15 +1,14 @@
 package ch.threema.app.listeners;
 
 import androidx.annotation.AnyThread;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
 
 public interface SynchronizeContactsListener {
     @AnyThread
-    default void onStarted(SynchronizeContactsRoutine startedRoutine) {}
+    default void onStarted() {}
 
     @AnyThread
-    default void onFinished(SynchronizeContactsRoutine finishedRoutine) {}
+    default void onFinished() {}
 
     @AnyThread
-    default void onError(SynchronizeContactsRoutine finishedRoutine) {}
+    default void onError() {}
 }

+ 11 - 0
app/src/main/java/ch/threema/app/location/LocationAutocompleteActivity.java

@@ -35,9 +35,12 @@ import ch.threema.app.ui.ThreemaEditText;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
+
+import static ch.threema.app.startup.AppStartupUtilKt.waitUntilReady;
 import ch.threema.app.utils.NetworkUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.common.models.Coordinates;
+import kotlin.Unit;
 
 import static ch.threema.app.location.LocationAutocompleteViewModel.QUERY_MIN_LENGTH;
 import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
@@ -74,6 +77,14 @@ public class LocationAutocompleteActivity extends ThreemaActivity {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
 
+        // TODO(ANDR-4389): Improve the waiting mechanism
+        waitUntilReady(this, () -> {
+            onReady();
+            return Unit.INSTANCE;
+        });
+    }
+
+    private void onReady() {
         setContentView(R.layout.activity_location_autocomplete);
 
         MaterialToolbar toolbar = findViewById(R.id.toolbar);

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

@@ -72,6 +72,7 @@ public class ListenerManager {
         }
     }
 
+    // TODO(ANDR-4436): Remove this listener.
     public static final TypedListenerManager<SynchronizeContactsListener> synchronizeContactsListeners = new TypedListenerManager<>();
     public static final TypedListenerManager<ContactSettingsListener> contactSettingsListeners = new TypedListenerManager<>();
     public static final TypedListenerManager<SMSVerificationListener> smsVerificationListeners = new TypedListenerManager<>();

+ 0 - 24
app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.java

@@ -1,24 +0,0 @@
-package ch.threema.app.mediaattacher;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import ch.threema.app.R;
-
-public class DummyPreviewFragment extends PreviewFragment {
-
-    DummyPreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
-        super(mediaItem, mediaAttachViewModel);
-    }
-
-    @Override
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-        this.rootView = inflater.inflate(R.layout.fragment_dummy_preview, container, false);
-
-        return rootView;
-    }
-}

+ 13 - 0
app/src/main/java/ch/threema/app/mediaattacher/DummyPreviewFragment.kt

@@ -0,0 +1,13 @@
+package ch.threema.app.mediaattacher
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import ch.threema.app.R
+
+class DummyPreviewFragment : Fragment() {
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
+        inflater.inflate(R.layout.fragment_dummy_preview, container, false)
+}

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

@@ -1,92 +0,0 @@
-package ch.threema.app.mediaattacher;
-
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-
-import com.bumptech.glide.Glide;
-import com.bumptech.glide.request.target.CustomViewTarget;
-import com.bumptech.glide.request.transition.Transition;
-import com.davemorrissey.labs.subscaleview.ImageSource;
-import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
-
-import org.slf4j.Logger;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import ch.threema.app.R;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
-
-public class ImagePreviewFragment extends PreviewFragment {
-    private static final Logger logger = getThreemaLogger("ImagePreviewFragment");
-
-    private SubsamplingScaleImageView scaleImageView;
-    private ImageView imageView;
-
-    ImagePreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
-        super(mediaItem, mediaAttachViewModel);
-    }
-
-    @Override
-    public void onCreate(@Nullable Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-    }
-
-    @Override
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-        this.rootView = inflater.inflate(R.layout.fragment_image_preview, container, false);
-
-        return rootView;
-    }
-
-    @Override
-    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-
-        if (rootView != null) {
-            this.scaleImageView = rootView.findViewById(R.id.scale_image_view);
-            this.imageView = rootView.findViewById(R.id.image_view);
-
-            if (mediaAttachItem.getType() == MediaAttachItem.TYPE_GIF || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && mediaAttachItem.getType() == MediaAttachItem.TYPE_WEBP)) {
-                Glide.with(this).load(mediaAttachItem.getUri())
-                    .transition(withCrossFade())
-                    .optionalFitCenter()
-                    .error(R.drawable.ic_baseline_broken_image_200)
-                    .into(imageView);
-            } else {
-                Glide.with(this)
-                    .load(mediaAttachItem.getUri())
-                    .transition(withCrossFade())
-                    .optionalCenterInside()
-                    .error(R.drawable.ic_baseline_broken_image_200)
-                    .into(new CustomViewTarget<SubsamplingScaleImageView, Drawable>(scaleImageView) {
-                        @Override
-                        public void onLoadFailed(@Nullable Drawable errorDrawable) {
-                        }
-
-                        @Override
-                        public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
-                            scaleImageView.setImage(ImageSource.bitmap(((BitmapDrawable) resource).getBitmap()));
-
-                            scaleImageView.setVisibility(View.VISIBLE);
-                            imageView.setVisibility(View.GONE);
-                        }
-
-                        @Override
-                        protected void onResourceCleared(@Nullable Drawable placeholder) {
-                        }
-                    });
-            }
-
-        }
-    }
-}

+ 91 - 0
app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.kt

@@ -0,0 +1,91 @@
+package ch.threema.app.mediaattacher
+
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import ch.threema.app.R
+import ch.threema.app.utils.logScreenVisibility
+import ch.threema.base.utils.getThreemaLogger
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
+import com.bumptech.glide.request.target.CustomViewTarget
+import com.bumptech.glide.request.transition.Transition
+import com.davemorrissey.labs.subscaleview.ImageSource
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+
+private val logger = getThreemaLogger("ImagePreviewFragment")
+
+class ImagePreviewFragment(
+    private val mediaAttachItem: MediaAttachItem,
+) : Fragment() {
+    init {
+        logScreenVisibility(logger)
+        setRetainInstance(true)
+    }
+
+    private var rootView: View? = null
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View? {
+        rootView = inflater.inflate(R.layout.fragment_image_preview, container, false)
+        return rootView
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        val rootView = rootView ?: return
+
+        val scaleImageView: SubsamplingScaleImageView = rootView.findViewById(R.id.scale_image_view)
+        val imageView: ImageView = rootView.findViewById(R.id.image_view)
+
+        if (isAnimated()) {
+            Glide.with(this)
+                .load(mediaAttachItem.uri)
+                .transition(DrawableTransitionOptions.withCrossFade())
+                .optionalFitCenter()
+                .error(R.drawable.ic_baseline_broken_image_200)
+                .into(imageView)
+        } else {
+            Glide.with(this)
+                .load(mediaAttachItem.uri)
+                .transition(DrawableTransitionOptions.withCrossFade())
+                .optionalCenterInside()
+                .error(R.drawable.ic_baseline_broken_image_200)
+                .into(
+                    object : CustomViewTarget<SubsamplingScaleImageView?, Drawable>(scaleImageView) {
+                        override fun onLoadFailed(errorDrawable: Drawable?) = Unit
+
+                        override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
+                            if (resource is BitmapDrawable) {
+                                scaleImageView.setImage(ImageSource.bitmap(resource.bitmap))
+                                scaleImageView.isVisible = true
+                                imageView.isVisible = false
+                            } else {
+                                Glide.with(this@ImagePreviewFragment)
+                                    .load(resource)
+                                    .optionalFitCenter()
+                                    .error(R.drawable.ic_baseline_broken_image_200)
+                                    .into(imageView)
+                            }
+                        }
+
+                        override fun onResourceCleared(placeholder: Drawable?) = Unit
+                    },
+                )
+        }
+    }
+
+    private fun isAnimated(): Boolean =
+        mediaAttachItem.type == MediaAttachItem.TYPE_GIF ||
+            (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && mediaAttachItem.type == MediaAttachItem.TYPE_WEBP)
+}

+ 4 - 13
app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java

@@ -1,9 +1,6 @@
 package ch.threema.app.mediaattacher;
 
 import android.os.Bundle;
-import android.widget.Toast;
-
-import org.koin.android.compat.ViewModelCompat;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -13,17 +10,13 @@ import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
 import androidx.viewpager2.adapter.FragmentStateAdapter;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.MediaViewerActivity;
 
 public class ImagePreviewPagerAdapter extends FragmentStateAdapter {
-    private final MediaAttachViewModel mediaAttachViewModel;
     private List<MediaAttachItem> mediaAttachItems = new ArrayList<>();
 
     public ImagePreviewPagerAdapter(FragmentActivity mediaSelectionBaseActivity) {
         super(mediaSelectionBaseActivity);
-
-        this.mediaAttachViewModel = ViewModelCompat.getViewModel(mediaSelectionBaseActivity, MediaAttachViewModel.class);
     }
 
     @NonNull
@@ -35,21 +28,19 @@ public class ImagePreviewPagerAdapter extends FragmentStateAdapter {
             Bundle args = new Bundle();
             args.putBoolean(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, true);
 
-            PreviewFragment fragment = null;
+            Fragment fragment = null;
             if (mediaType == MediaAttachItem.TYPE_IMAGE || mediaType == MediaAttachItem.TYPE_GIF || mediaType == MediaAttachItem.TYPE_WEBP) {
-                fragment = new ImagePreviewFragment(mediaAttachItem, mediaAttachViewModel);
+                fragment = new ImagePreviewFragment(mediaAttachItem);
             } else if (mediaType == MediaAttachItem.TYPE_VIDEO) {
-                fragment = new VideoPreviewFragment(mediaAttachItem, mediaAttachViewModel);
+                fragment = new VideoPreviewFragment(mediaAttachItem);
             }
 
             if (fragment != null) {
                 fragment.setArguments(args);
                 return fragment;
-            } else {
-                Toast.makeText(ThreemaApplication.getAppContext(), "Unrecognized Preview Format", Toast.LENGTH_SHORT).show();
             }
         }
-        return new DummyPreviewFragment(mediaAttachItem, mediaAttachViewModel);
+        return new DummyPreviewFragment();
     }
 
     public void setMediaItems(List<MediaAttachItem> items) {

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

@@ -43,7 +43,6 @@ public class MediaAttachViewModel extends ViewModel {
     private final CompletableFuture<Void> initialLoadDone = new CompletableFuture<>();
 
     private static final String KEY_SELECTED_MEDIA = "selected_media";
-    private static final String KEY_TOOLBAR_TITLE = "toolbar_title";
     private static final String KEY_RECENT_QUERY = "recent_query_string";
     private static final String KEY_RECENT_QUERY_TYPE = "recent_query_type";
 
@@ -184,14 +183,6 @@ public class MediaAttachViewModel extends ViewModel {
         }
     }
 
-    public String getToolBarTitle() {
-        return savedState.get(KEY_TOOLBAR_TITLE);
-    }
-
-    public void setToolBarTitle(String toolBarTitle) {
-        savedState.set(KEY_TOOLBAR_TITLE, toolBarTitle);
-    }
-
     public String getLastQuery() {
         return savedState.get(KEY_RECENT_QUERY);
     }
@@ -200,7 +191,7 @@ public class MediaAttachViewModel extends ViewModel {
         return savedState.get(KEY_RECENT_QUERY_TYPE);
     }
 
-    public void setlastQuery(@MediaFilterQuery.FilterType int type, String labelQuery) {
+    public void setLastQuery(@MediaFilterQuery.FilterType int type, String labelQuery) {
         savedState.set(KEY_RECENT_QUERY, labelQuery);
         savedState.set(KEY_RECENT_QUERY_TYPE, type);
     }

+ 4 - 4
app/src/main/java/ch/threema/app/mediaattacher/MediaRepository.java

@@ -74,8 +74,8 @@ public class MediaRepository {
                 null
             )) {
                 addToMediaResults(imageCursor, mediaList, false);
-            } catch (SecurityException e) {
-                logger.debug("Cannot access content resolver", e);
+            } catch (SecurityException | IllegalArgumentException e) {
+                logger.warn("Failed to query images", e);
             }
 
             // Process videos
@@ -88,8 +88,8 @@ public class MediaRepository {
                 null
             )) {
                 addToMediaResults(videoCursor, mediaList, true);
-            } catch (SecurityException e) {
-                logger.debug("Cannot access content resolver", e);
+            } catch (SecurityException | IllegalArgumentException e) {
+                logger.warn("Failed to query videos", e);
             }
         } else {
             String addLimitQuery = "";

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

@@ -654,7 +654,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
     public void filterMediaByBucket(@NonNull String mediaBucket) {
         mediaAttachViewModel.setMediaByBucket(mediaBucket);
         menuTitle.setText(mediaBucket);
-        mediaAttachViewModel.setlastQuery(FILTER_MEDIA_BUCKET, mediaBucket);
+        mediaAttachViewModel.setLastQuery(FILTER_MEDIA_BUCKET, mediaBucket);
     }
 
     public void filterMediaByMediaAttachType(@NonNull String mediaAttachTypeTitle) {
@@ -674,13 +674,13 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
             mediaAttachViewModel.setMediaByType(mediaAttachType);
         }
         menuTitle.setText(mediaAttachTypeTitle);
-        mediaAttachViewModel.setlastQuery(FILTER_MEDIA_TYPE, mediaAttachTypeTitle);
+        mediaAttachViewModel.setLastQuery(FILTER_MEDIA_TYPE, mediaAttachTypeTitle);
     }
 
     public void filterMediaBySelectedItems() {
         mediaAttachViewModel.setSelectedMedia();
         menuTitle.setText(R.string.selected_media);
-        mediaAttachViewModel.setlastQuery(FILTER_MEDIA_SELECTED, null);
+        mediaAttachViewModel.setLastQuery(FILTER_MEDIA_SELECTED, null);
     }
 
     public String getMediaAttachTypeTitle(int mediaAttachType) {

+ 0 - 20
app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java

@@ -1,20 +0,0 @@
-package ch.threema.app.mediaattacher;
-
-import android.view.View;
-
-import androidx.fragment.app.Fragment;
-
-public abstract class PreviewFragment extends Fragment {
-    protected MediaAttachItem mediaAttachItem;
-    protected MediaAttachViewModel mediaAttachViewModel;
-    protected View rootView;
-    protected boolean isChecked = false;
-
-    public PreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
-        this.mediaAttachItem = mediaItem;
-        this.mediaAttachViewModel = mediaAttachViewModel;
-
-        setRetainInstance(true);
-    }
-}
-

+ 0 - 169
app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java

@@ -1,169 +0,0 @@
-package ch.threema.app.mediaattacher;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Toast;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.OptIn;
-import androidx.lifecycle.DefaultLifecycleObserver;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.media3.common.AudioAttributes;
-import androidx.media3.common.C;
-import androidx.media3.common.MediaItem;
-import androidx.media3.common.PlaybackException;
-import androidx.media3.common.Player;
-import androidx.media3.common.util.UnstableApi;
-import androidx.media3.exoplayer.ExoPlayer;
-import androidx.media3.ui.PlayerView;
-
-import com.alexvasilkov.gestures.GestureController;
-import com.alexvasilkov.gestures.State;
-import com.alexvasilkov.gestures.views.GestureFrameLayout;
-
-import org.slf4j.Logger;
-
-import ch.threema.app.R;
-import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.VideoUtil;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class VideoPreviewFragment extends PreviewFragment implements DefaultLifecycleObserver, Player.Listener, PreviewFragmentInterface {
-    private static final Logger logger = getThreemaLogger("VideoPreviewFragment");
-
-    private PlayerView videoView;
-    private ExoPlayer videoPlayer;
-    private GestureFrameLayout gestureFrameLayout;
-    private final GestureController.OnStateChangeListener onGestureStateChangeListener = new GestureController.OnStateChangeListener() {
-        @OptIn(markerClass = UnstableApi.class)
-        @Override
-        public void onStateChanged(State state) {
-            if (state.getZoom() > 1.05f || state.getZoom() < 0.95f) {
-                if (videoView != null && videoView.isControllerFullyVisible()) {
-                    videoView.hideController();
-                }
-            }
-        }
-
-        @Override
-        public void onStateReset(State oldState, State newState) {
-        }
-    };
-
-    VideoPreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
-        super(mediaItem, mediaAttachViewModel);
-    }
-
-    @Override
-    public void onCreate(@Nullable Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-    }
-
-    @Override
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-        this.rootView = inflater.inflate(R.layout.fragment_video_preview, container, false);
-
-        this.getViewLifecycleOwner().getLifecycle().addObserver(this);
-
-        return this.rootView;
-    }
-
-    @Override
-    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-
-        if (rootView != null) {
-            this.videoView = rootView.findViewById(R.id.video_view);
-            this.gestureFrameLayout = rootView.findViewById(R.id.video_gesture_frame);
-            this.gestureFrameLayout.getController().getSettings().setMaxZoom(2.5f);
-            this.gestureFrameLayout.getController().addOnStateChangeListener(onGestureStateChangeListener);
-        }
-    }
-
-    @Override
-    public void onCreate(@NonNull LifecycleOwner owner) {
-    }
-
-    @Override
-    public void onResume(@NonNull LifecycleOwner owner) {
-        if (this.videoPlayer == null) {
-            initializePlayer(true);
-        } else if (!this.videoPlayer.isPlaying()) {
-            this.videoPlayer.play();
-        }
-    }
-
-    @Override
-    public void onPause(@NonNull LifecycleOwner owner) {
-        if (this.videoPlayer != null) {
-            this.videoPlayer.pause();
-        }
-    }
-
-    @Override
-    public void setUserVisibleHint(boolean isVisibleToUser) {
-        super.setUserVisibleHint(isVisibleToUser);
-
-        if (isVisibleToUser) {
-            onResume(this);
-        } else {
-            onPause(this);
-        }
-    }
-
-    @Override
-    public void onDestroy(@NonNull LifecycleOwner owner) {
-        releasePlayer();
-        if (gestureFrameLayout != null) {
-            gestureFrameLayout.getController().removeOnStateChangeListener(onGestureStateChangeListener);
-        }
-    }
-
-    @Override
-    public void onPlayerError(@NonNull PlaybackException error) {
-        RuntimeUtil.runOnUiThread(() -> Toast.makeText(getContext(), "Exoplayer error: " + error.getErrorCodeName(), Toast.LENGTH_LONG).show());
-
-        releasePlayer();
-        initializePlayer(false);
-    }
-
-    @OptIn(markerClass = UnstableApi.class)
-    public void initializePlayer(boolean playWhenReady) {
-        try {
-            AudioAttributes audioAttributes = new AudioAttributes.Builder()
-                .setUsage(C.USAGE_MEDIA)
-                .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
-                .setAllowedCapturePolicy(C.ALLOW_CAPTURE_BY_NONE)
-                .build();
-
-            this.videoPlayer = VideoUtil.getExoPlayer(getContext());
-            this.videoPlayer.setAudioAttributes(audioAttributes, true);
-            this.videoPlayer.addListener(this);
-
-            this.videoView.setPlayer(videoPlayer);
-            this.videoView.setControllerHideOnTouch(true);
-            this.videoView.setControllerShowTimeoutMs(1500);
-            this.videoView.showController();
-
-            this.videoPlayer.setMediaItem(MediaItem.fromUri(this.mediaAttachItem.getUri()));
-            this.videoPlayer.setPlayWhenReady(playWhenReady);
-            this.videoPlayer.prepare();
-        } catch (OutOfMemoryError e) {
-            logger.error("Exception", e);
-        }
-    }
-
-    public void releasePlayer() {
-        if (videoPlayer != null) {
-            videoPlayer.stop();
-            videoPlayer.release();
-            videoPlayer = null;
-        }
-    }
-}

+ 142 - 0
app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.kt

@@ -0,0 +1,142 @@
+package ch.threema.app.mediaattacher
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.OptIn
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import ch.threema.android.ToastDuration
+import ch.threema.android.showToast
+import ch.threema.app.R
+import ch.threema.app.utils.VideoUtil
+import ch.threema.app.utils.logScreenVisibility
+import ch.threema.base.utils.getThreemaLogger
+import com.alexvasilkov.gestures.GestureController.OnStateChangeListener
+import com.alexvasilkov.gestures.State
+import com.alexvasilkov.gestures.views.GestureFrameLayout
+
+private val logger = getThreemaLogger("VideoPreviewFragment")
+
+class VideoPreviewFragment(
+    private val mediaAttachItem: MediaAttachItem,
+) : Fragment(),
+    Player.Listener,
+    PreviewFragmentInterface {
+    init {
+        logScreenVisibility(logger)
+        setRetainInstance(true)
+    }
+
+    private var rootView: View? = null
+    private var videoView: PlayerView? = null
+    private var videoPlayer: ExoPlayer? = null
+    private var gestureFrameLayout: GestureFrameLayout? = null
+
+    private val onGestureStateChangeListener: OnStateChangeListener =
+        object : OnStateChangeListener {
+            @OptIn(UnstableApi::class)
+            override fun onStateChanged(state: State) {
+                if (state.zoom !in 0.95f..1.05f) {
+                    if (videoView?.isControllerFullyVisible == true) {
+                        videoView?.hideController()
+                    }
+                }
+            }
+
+            override fun onStateReset(oldState: State?, newState: State?) {
+            }
+        }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        rootView = inflater.inflate(R.layout.fragment_video_preview, container, false)
+        viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
+            override fun onResume(owner: LifecycleOwner) {
+                initOrPlay()
+            }
+
+            override fun onPause(owner: LifecycleOwner) {
+                videoPlayer?.pause()
+            }
+
+            override fun onDestroy(owner: LifecycleOwner) {
+                releasePlayer()
+                gestureFrameLayout
+                    ?.controller
+                    ?.removeOnStateChangeListener(onGestureStateChangeListener)
+            }
+        })
+        return rootView
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        val rootView = rootView ?: return
+
+        videoView = rootView.findViewById(R.id.video_view)
+        gestureFrameLayout = rootView.findViewById<GestureFrameLayout>(R.id.video_gesture_frame)
+            ?.apply {
+                controller.settings.setMaxZoom(2.5f)
+                controller.addOnStateChangeListener(onGestureStateChangeListener)
+            }
+    }
+
+    private fun initOrPlay() {
+        val videoPlayer = videoPlayer
+        if (videoPlayer == null) {
+            initializePlayer(true)
+        } else if (!videoPlayer.isPlaying) {
+            videoPlayer.play()
+        }
+    }
+
+    override fun onPlayerError(error: PlaybackException) {
+        logger.error("Video playback error", error)
+        showToast(R.string.an_error_occurred, ToastDuration.LONG)
+
+        releasePlayer()
+        initializePlayer(false)
+    }
+
+    @OptIn(UnstableApi::class)
+    private fun initializePlayer(playWhenReady: Boolean) {
+        val audioAttributes = AudioAttributes.Builder()
+            .setUsage(C.USAGE_MEDIA)
+            .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+            .setAllowedCapturePolicy(C.ALLOW_CAPTURE_BY_NONE)
+            .build()
+
+        val videoPlayer = VideoUtil.getExoPlayer(requireContext())
+        videoPlayer.setAudioAttributes(audioAttributes, true)
+        videoPlayer.addListener(this)
+
+        videoView?.apply {
+            setPlayer(videoPlayer)
+            setControllerHideOnTouch(true)
+            setControllerShowTimeoutMs(1500)
+            showController()
+        }
+
+        videoPlayer.setMediaItem(MediaItem.fromUri(mediaAttachItem.uri))
+        videoPlayer.playWhenReady = playWhenReady
+        videoPlayer.prepare()
+        this.videoPlayer = videoPlayer
+    }
+
+    fun releasePlayer() {
+        videoPlayer?.stop()
+        videoPlayer?.release()
+        videoPlayer = null
+    }
+}

+ 1 - 9
app/src/main/java/ch/threema/app/notifications/NotificationExtensions.kt

@@ -2,7 +2,6 @@ package ch.threema.app.notifications
 
 import android.content.SharedPreferences
 import android.net.Uri
-import android.os.DeadObjectException
 import androidx.core.app.NotificationChannelCompat
 import androidx.core.app.NotificationChannelGroupCompat
 import androidx.core.app.NotificationManagerCompat
@@ -26,7 +25,7 @@ fun SharedPreferences.getRingtoneUri(key: String): Uri? =
  * If a group with [groupId] already exists and the name has changed, rename it to the provided group name
  */
 fun NotificationManagerCompat.createGroup(groupId: String, name: String) {
-    val existingGroup = findNotificationChannelGroup(groupId)
+    val existingGroup = getNotificationChannelGroupCompat(groupId)
     if (existingGroup != null && name == existingGroup.name) {
         return
     }
@@ -36,13 +35,6 @@ fun NotificationManagerCompat.createGroup(groupId: String, name: String) {
     createNotificationChannelGroup(notificationChannelGroup)
 }
 
-private fun NotificationManagerCompat.findNotificationChannelGroup(groupId: String): NotificationChannelGroupCompat? =
-    try {
-        getNotificationChannelGroupCompat(groupId)
-    } catch (e: DeadObjectException) {
-        notificationChannelGroupsCompat.firstOrNull { group -> group.id == groupId }
-    }
-
 fun NotificationManagerCompat.createChannel(
     channelId: String,
     channelName: String? = channelId,

+ 3 - 4
app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.kt

@@ -23,7 +23,6 @@ import ch.threema.app.managers.ListenerManager
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.preference.service.SynchronizedSettingsService
 import ch.threema.app.restrictions.AppRestrictions
-import ch.threema.app.routines.SynchronizeContactsRoutine
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.LockAppService
 import ch.threema.app.services.SynchronizeContactsService
@@ -61,7 +60,7 @@ class SettingsPrivacyFragment :
 
     private val synchronizeContactsListener: SynchronizeContactsListener =
         object : SynchronizeContactsListener {
-            override fun onStarted(startedRoutine: SynchronizeContactsRoutine) {
+            override fun onStarted() {
                 RuntimeUtil.runOnUiThread {
                     updateView()
                     GenericProgressDialog.newInstance(
@@ -71,7 +70,7 @@ class SettingsPrivacyFragment :
                 }
             }
 
-            override fun onFinished(finishedRoutine: SynchronizeContactsRoutine) {
+            override fun onFinished() {
                 RuntimeUtil.runOnUiThread {
                     updateView()
                     if (this@SettingsPrivacyFragment.isAdded) {
@@ -84,7 +83,7 @@ class SettingsPrivacyFragment :
                 }
             }
 
-            override fun onError(finishedRoutine: SynchronizeContactsRoutine) {
+            override fun onError() {
                 RuntimeUtil.runOnUiThread {
                     updateView()
                     if (this@SettingsPrivacyFragment.isAdded) {

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

@@ -85,7 +85,7 @@ class SettingsSecurityFragment : ThreemaPreferenceFragment(), PasswordEntryDialo
         if (result.resultCode == Activity.RESULT_OK && result.data != null) {
             val passphrase = PassphraseUnlockActivity.getPassphrase(result.data!!)
             if (passphrase != null) {
-                lifecycleScope.launch {
+                viewLifecycleOwner.lifecycleScope.launch {
                     // optimistically set the switch's state to false. It will get corrected later if the operation fails
                     masterKeySwitchPreference.setChecked(false)
                     try {
@@ -393,7 +393,7 @@ class SettingsSecurityFragment : ThreemaPreferenceFragment(), PasswordEntryDialo
     }
 
     private fun setBiometricLock() {
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             val result = appLockUtil.authenticate(
                 activity = requireActivity(),
                 title = getString(R.string.prefs_title_access_protection),
@@ -561,7 +561,7 @@ class SettingsSecurityFragment : ThreemaPreferenceFragment(), PasswordEntryDialo
     private fun removeAccessProtection() {
         logger.info("Removing lock mechanism and private marks")
         preferenceService.setArePrivateChatsHidden(false)
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             try {
                 if (conversationCategoryService.hasAnyPrivateMarks()) {
                     showProgressDialog()

+ 8 - 5
app/src/main/java/ch/threema/app/profilepicture/CheckedProfilePicture.kt

@@ -7,6 +7,7 @@ import ch.threema.app.utils.BitmapUtil
 import ch.threema.app.utils.ExifInterface
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import java.io.File
+import java.io.IOException
 import kotlinx.serialization.Serializable
 
 /**
@@ -40,11 +41,13 @@ class CheckedProfilePicture private constructor(override val bytes: ByteArray) :
          * converted to a jpeg of the correct dimensions. If this fails, null is returned.
          */
         @JvmStatic
-        fun getOrConvertFromFile(profilePictureFile: File?): CheckedProfilePicture? {
-            val profilePictureBytes = profilePictureFile?.readBytes() ?: return null
-
-            return getOrConvertFromBytes(profilePictureBytes)
-        }
+        fun getOrConvertFromFile(profilePictureFile: File?): CheckedProfilePicture? =
+            try {
+                profilePictureFile?.readBytes()
+            } catch (_: IOException) {
+                null
+            }
+                ?.let(::getOrConvertFromBytes)
 
         /**
          * Converts the [imageBytes] to a jpeg profile picture with the correct dimensions in case the [imageBytes] can be decoded as bitmap.

+ 0 - 550
app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java

@@ -1,550 +0,0 @@
-package ch.threema.app.routines;
-
-import android.Manifest;
-import android.content.ContentProviderOperation;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.database.Cursor;
-import android.provider.ContactsContract;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.OptIn;
-import androidx.annotation.RequiresPermission;
-
-import com.google.common.collect.ListMultimap;
-
-import org.slf4j.Logger;
-
-import java.math.BigInteger;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.CountDownLatch;
-
-import ch.threema.app.apptaskexecutor.AppTaskExecutor;
-import ch.threema.app.preference.service.SynchronizedSettingsService;
-import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase;
-import ch.threema.data.datatypes.AndroidContactLookupInfo;
-import ch.threema.app.asynctasks.AddContactBackgroundTask;
-import ch.threema.app.services.BlockedIdentitiesService;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.DeviceService;
-import ch.threema.app.services.ExcludedSyncIdentitiesService;
-import ch.threema.app.services.LocaleService;
-import ch.threema.app.preference.service.PreferenceService;
-import ch.threema.app.services.UserService;
-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.base.ThreemaException;
-
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-import static ch.threema.common.JavaCompat.isNullOrEmpty;
-
-import ch.threema.data.datatypes.AvailabilityStatus;
-import ch.threema.data.datatypes.ConversationVisibility;
-import ch.threema.data.models.ContactModel;
-import ch.threema.data.models.ContactModelData;
-import ch.threema.data.repositories.ContactModelRepository;
-import ch.threema.domain.models.AcquaintanceLevel;
-import ch.threema.domain.models.ContactSyncState;
-import ch.threema.domain.models.IdentityState;
-import ch.threema.domain.models.IdentityType;
-import ch.threema.domain.models.ReadReceiptPolicy;
-import ch.threema.domain.models.TypingIndicatorPolicy;
-import ch.threema.domain.models.VerificationLevel;
-import ch.threema.domain.models.WorkVerificationLevel;
-import ch.threema.domain.protocol.api.APIConnector;
-import ch.threema.domain.stores.IdentityStore;
-import ch.threema.data.datatypes.IdColor;
-import kotlin.Unit;
-import kotlinx.coroutines.DelicateCoroutinesApi;
-
-public class SynchronizeContactsRoutine implements Runnable {
-    private static final Logger logger = getThreemaLogger("SynchronizeContactsRoutine");
-
-    private final UserService userService;
-    private final Context context;
-    private final APIConnector apiConnector;
-    private final ContactService contactService;
-    private final ContactModelRepository contactModelRepository;
-    private final LocaleService localeService;
-    private final ContentResolver contentResolver;
-    @NonNull
-    private final ExcludedSyncIdentitiesService excludedSyncIdentitiesService;
-    private final DeviceService deviceService;
-    private final PreferenceService preferenceService;
-    private final SynchronizedSettingsService synchronizedSettingsService;
-    private final IdentityStore identityStore;
-    private final BlockedIdentitiesService blockedIdentitiesService;
-    @NonNull
-    private final AppTaskExecutor appTaskExecutor;
-    @NonNull
-    private final UpdateContactNameUseCase updateContactNameUseCase;
-
-    private OnStatusUpdate onStatusUpdate;
-    private final List<OnFinished> onFinished = new ArrayList<>();
-
-    @NonNull
-    private final Set<String> processingIdentities;
-    private boolean abort = false;
-    private boolean running = false;
-
-    public interface OnStatusUpdate {
-        void error(final Exception x);
-    }
-
-    public interface OnFinished {
-        void finished(boolean success, long modifiedAccounts, List<ContactModel> createdContacts, long deletedAccounts);
-    }
-
-    public SynchronizeContactsRoutine(
-        Context context,
-        APIConnector apiConnector,
-        ContactService contactService,
-        @NonNull ContactModelRepository contactModelRepository,
-        UserService userService,
-        LocaleService localeService,
-        ContentResolver contentResolver,
-        @NonNull ExcludedSyncIdentitiesService excludedSyncIdentitiesService,
-        DeviceService deviceService,
-        PreferenceService preferenceService,
-        @NonNull SynchronizedSettingsService synchronizedSettingsService,
-        IdentityStore identityStore,
-        BlockedIdentitiesService blockedIdentitiesService,
-        @NonNull AppTaskExecutor appTaskExecutor,
-        @NonNull UpdateContactNameUseCase updateContactNameUseCase,
-        @NonNull Set<String> processingIdentities
-    ) {
-        this.context = context;
-        this.apiConnector = apiConnector;
-        this.userService = userService;
-        this.contactService = contactService;
-        this.contactModelRepository = contactModelRepository;
-        this.localeService = localeService;
-        this.contentResolver = contentResolver;
-        this.excludedSyncIdentitiesService = excludedSyncIdentitiesService;
-        this.deviceService = deviceService;
-        this.preferenceService = preferenceService;
-        this.synchronizedSettingsService = synchronizedSettingsService;
-        this.identityStore = identityStore;
-        this.blockedIdentitiesService = blockedIdentitiesService;
-        this.appTaskExecutor = appTaskExecutor;
-        this.updateContactNameUseCase = updateContactNameUseCase;
-        this.processingIdentities = processingIdentities;
-    }
-
-    public void abort() {
-        this.abort = true;
-    }
-
-    public boolean running() {
-        return this.running;
-    }
-
-    /**
-     * Check whether all contacts are being synchronized. If it is not a full sync, then only some
-     * specific identities are updated.
-     */
-    public boolean isFullSync() {
-        return this.processingIdentities.isEmpty();
-    }
-
-    @OptIn(markerClass = DelicateCoroutinesApi.class)
-    @Override
-    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
-    public void run() {
-        logger.info("SynchronizeContacts run started.");
-
-        if (!ConfigUtils.isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)) {
-            logger.info("No contacts permission. Aborting.");
-            return;
-        }
-
-        this.running = true;
-
-        boolean success = false;
-
-        long deletedCount = 0;
-        long modifiedRawContactCount = 0;
-        long newRawContactCount = 0;
-        // The list of contacts that have been added in this run because they are found by phone or email.
-        List<ContactModel> insertedContacts = new ArrayList<>();
-
-        try {
-            if (!synchronizedSettingsService.isSyncContacts()) {
-                throw new ThreemaException("Sync is disabled in preferences, not allowed to call synchronizecontacts routine");
-            }
-
-            if (this.deviceService != null && !this.deviceService.isOnline()) {
-                throw new ThreemaException("No connection");
-            }
-
-            //read emails
-            final Map<String, ContactMatchKeyEmail> emails = this.readEmails();
-            final int emailsHash = emails.keySet().hashCode();
-
-            //read phone numbers
-            final Map<String, ContactMatchKeyPhone> phoneNumbers = this.readPhoneNumbers();
-            final int phoneNumbersHash = phoneNumbers.keySet().hashCode();
-
-            Instant timeOfLastContactSync = preferenceService.getTimeOfLastContactSync();
-            if (preferenceService.getEmailSyncHashCode() == emailsHash
-                && preferenceService.getPhoneNumberSyncHashCode() == phoneNumbersHash
-                && (timeOfLastContactSync != null && timeOfLastContactSync.isAfter(Instant.now().minus(23, ChronoUnit.DAYS)))) {
-                logger.info("System contacts are unchanged or grace time not yet reached. Not syncing.");
-                success = true;
-                return;
-            }
-
-            preferenceService.setEmailSyncHashCode(emailsHash);
-            preferenceService.setPhoneNumberSyncHashCode(phoneNumbersHash);
-            preferenceService.setTimeOfLastContactSync(Instant.now());
-
-            logger.info("Attempting to sync contacts: {} emails, {} phone numbers", emails.size(), phoneNumbers.size());
-
-            //send hashes to server and get result
-            MatchTokenStore matchTokenStore = new MatchTokenStore(this.preferenceService);
-            Map<String, APIConnector.MatchIdentityResult> matchIdentityResults = this.apiConnector.matchIdentities(
-                emails, phoneNumbers, this.localeService.getCountryIsoCode(), false, identityStore, matchTokenStore);
-
-            // remove own ID
-            if (userService != null) {
-                matchIdentityResults.remove(userService.getIdentity());
-            }
-
-            final List<String> preSynchronizedIdentities = new ArrayList<>();
-            if (this.isFullSync()) {
-                List<String> synchronizedIdentities = this.contactService.getSynchronizedIdentities();
-                if (synchronizedIdentities != null) {
-                    preSynchronizedIdentities.addAll(synchronizedIdentities);
-                }
-            }
-
-            ListMultimap<String, AndroidContactUtil.RawContactInfo> existingRawContacts = AndroidContactUtil.getInstance().getAllThreemaRawContacts();
-            if (existingRawContacts == null) {
-                throw new ThreemaException("No permission or no account");
-            }
-            logger.info("Number of existing raw contacts: {}", existingRawContacts.size());
-
-            logger.info("Number of IDs matching phone or email: {}", matchIdentityResults.size());
-
-            ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
-            for (Map.Entry<String, APIConnector.MatchIdentityResult> matchIdentityResultEntry : matchIdentityResults.entrySet()) {
-                String identity = matchIdentityResultEntry.getKey();
-                APIConnector.MatchIdentityResult result = matchIdentityResultEntry.getValue();
-
-                if (this.abort) {
-                    //abort!
-                    for (OnFinished f : this.onFinished) {
-                        f.finished(false, modifiedRawContactCount, insertedContacts, deletedCount);
-                    }
-                    return;
-                }
-
-                // Do not sync contacts on exclude list
-                if (this.excludedSyncIdentitiesService.isExcluded(identity)) {
-                    logger.info("Identity {} is on exclude list", identity);
-                    continue;
-                }
-
-                // If it is not a full sync and the current identity is not one of the identities to
-                // be updated in this run, we skip processing this identity.
-                if (!isFullSync() && !this.processingIdentities.contains(identity)) {
-                    continue;
-                }
-
-                if (this.isFullSync()) {
-                    // remove if list contains this key
-                    preSynchronizedIdentities.remove(identity);
-                }
-
-                final ContactMatchKeyEmail matchKeyEmail = (ContactMatchKeyEmail) result.refObjectEmail;
-                final ContactMatchKeyPhone matchKeyPhone = (ContactMatchKeyPhone) result.refObjectMobileNo;
-
-                long contactId;
-                String lookupKey;
-                if (matchKeyEmail != null) {
-                    contactId = matchKeyEmail.contactId;
-                    lookupKey = matchKeyEmail.lookupKey;
-                } else {
-                    contactId = matchKeyPhone.contactId;
-                    lookupKey = matchKeyPhone.lookupKey;
-                }
-
-                //try to get the contact
-                ContactModel contact = contactModelRepository.getByIdentity(identity);
-                ContactModelData data = contact != null ? contact.getData() : null;
-                //contact does not exist, create a new one
-                if (contact == null || data == null) {
-                    ContactModelData contactModelData = ContactModelData.javaCreate(
-                        identity,
-                        result.publicKey,
-                        Instant.now(),
-                        null,
-                        "",
-                        "",
-                        null,
-                        IdColor.ofIdentity(identity),
-                        VerificationLevel.SERVER_VERIFIED,
-                        WorkVerificationLevel.NONE,
-                        IdentityType.REGULAR,   // TODO(ANDR-3044): Fetch identity type
-                        AcquaintanceLevel.DIRECT,
-                        IdentityState.ACTIVE,  // TODO(ANDR-3044): Fetch identity state
-                        BigInteger.valueOf(0), // TODO(ANDR-3044): Fetch feature mask
-                        ContactSyncState.IMPORTED,
-                        ReadReceiptPolicy.DEFAULT,
-                        TypingIndicatorPolicy.DEFAULT,
-                        ConversationVisibility.NORMAL,
-                        null,
-                        null,
-                        false,
-                        null,
-                        null,
-                        null,
-                        null,
-                        AvailabilityStatus.None.INSTANCE,
-                        null
-                    );
-                    contact = new AddContactBackgroundTask(contactModelData, contactModelRepository)
-                        .runSynchronously();
-
-                    if (contact == null) {
-                        logger.error("Could not create contact with identity {}", identity);
-                        continue;
-                    }
-
-                    data = contact.getData();
-                    if (data == null) {
-                        logger.error("Contact data is null");
-                        continue;
-                    }
-
-                    insertedContacts.add(contact);
-
-                    logger.info("Inserting new Threema contact {}", identity);
-                }
-                contact.setAndroidContactLookupKey(
-                    new AndroidContactLookupInfo(lookupKey, contactId)
-                );
-
-                boolean createNewRawContact;
-
-                CountDownLatch latch = new CountDownLatch(1);
-                final ContactModel finalContactModel = contact;
-                appTaskExecutor.runInAppTask((continuation -> {
-                    // Note that continuation can only be used once to call a suspend function!
-                    updateContactNameUseCase.call(Set.of(finalContactModel), continuation);
-                    return Unit.INSTANCE;
-                })).invokeOnCompletion(throwable -> {
-                    latch.countDown();
-                    return Unit.INSTANCE;
-                });
-                latch.await();
-
-                AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact, context);
-
-                contact.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT);
-                if (data.verificationLevel == VerificationLevel.UNVERIFIED) {
-                    contact.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED);
-                }
-
-                List<AndroidContactUtil.RawContactInfo> rawContactInfos = existingRawContacts.get(contact.getIdentity());
-
-                if (rawContactInfos.isEmpty()) {
-                    // raw contact does not exist yet, create it
-                    createNewRawContact = true;
-                    newRawContactCount++;
-                } else {
-                    // a raw contact exists - check if it points to the correct parent
-                    createNewRawContact = true;
-                    for (AndroidContactUtil.RawContactInfo rawContactInfo : rawContactInfos) {
-                        if (rawContactInfo.contactId > 0L && rawContactInfo.contactId == contactId) {
-                            // all good - no change necessary
-                            createNewRawContact = false;
-                            existingRawContacts.remove(contact.getIdentity(), rawContactInfo);
-                            break;
-                        }
-                    }
-                    if (createNewRawContact) {
-                        modifiedRawContactCount++;
-                    }
-                }
-
-                if (createNewRawContact) {
-                    boolean supportsVoiceCalls = ContactUtil.canReceiveVoipMessages(contact.getIdentity(), this.blockedIdentitiesService)
-                        && ConfigUtils.isCallsEnabled();
-
-                    // create a raw contact for our stuff and aggregate it
-                    AndroidContactUtil.getInstance().createThreemaRawContact(
-                        contentProviderOperations,
-                        matchKeyEmail != null ?
-                            matchKeyEmail.rawContactId :
-                            matchKeyPhone.rawContactId,
-                        contact.getIdentity(),
-                        supportsVoiceCalls);
-                }
-            }
-
-            if (!contentProviderOperations.isEmpty()) {
-                try {
-                    ConfigUtils.applyToContentResolverInBatches(
-                        ContactsContract.AUTHORITY,
-                        contentProviderOperations);
-                } catch (Exception e) {
-                    logger.error("Error during raw contact creation! ", e);
-                }
-                contentProviderOperations.clear();
-            }
-
-            if (this.isFullSync()) {
-                // delete remaining / stray raw contacts
-                if (!existingRawContacts.isEmpty()) {
-                    AndroidContactUtil.getInstance().deleteThreemaRawContacts(existingRawContacts);
-                    logger.info("Deleted {} stray raw contacts", existingRawContacts.size());
-                }
-
-                if (!preSynchronizedIdentities.isEmpty()) {
-                    logger.info("Found {} synchronized contacts that are no longer synchronized", preSynchronizedIdentities.size());
-
-                    for (String identity : preSynchronizedIdentities) {
-                        ContactModel contactModel = contactModelRepository.getByIdentity(identity);
-                        if (contactModel != null) {
-                            contactModel.removeAndroidContactLink();
-                        }
-                    }
-                }
-            }
-            success = true;
-        } catch (final Exception x) {
-            logger.error("Exception", x);
-            if (this.onStatusUpdate != null) {
-                this.onStatusUpdate.error(x);
-            }
-        } finally {
-            logger.info("Finished [success = {}, inserted = {}, deleted = {}, modifiedRawContacts = {}, newRawContacts = {}] fullSync = {}", success, insertedContacts.size(), deletedCount, modifiedRawContactCount, newRawContactCount, this.isFullSync());
-            for (OnFinished f : this.onFinished) {
-                f.finished(success, modifiedRawContactCount, insertedContacts, deletedCount);
-            }
-            this.running = false;
-        }
-    }
-
-    public SynchronizeContactsRoutine setOnStatusUpdate(OnStatusUpdate onStatusUpdate) {
-        this.onStatusUpdate = onStatusUpdate;
-        return this;
-    }
-
-    public SynchronizeContactsRoutine addOnFinished(OnFinished onFinished) {
-        this.onFinished.add(onFinished);
-        return this;
-    }
-
-    private Map<String, ContactMatchKeyPhone> readPhoneNumbers() {
-        Map<String, ContactMatchKeyPhone> phoneNumbers = new HashMap<>();
-        String selection;
-
-        selection = ContactsContract.Contacts.HAS_PHONE_NUMBER + " > 0 AND " + ContactsContract.CommonDataKinds.Phone.IN_DEFAULT_DIRECTORY + " = 1";
-
-        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,
-            },
-            selection,
-            null,
-            null)) {
-
-            if (phonesCursor != null && phonesCursor.getCount() > 0) {
-                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()) {
-                    long rawContactId = phonesCursor.getLong(rawContactIdIndex);
-                    long contactId = phonesCursor.getLong(idColumnIndex);
-                    String lookupKey = phonesCursor.getString(lookupKeyColumnIndex);
-                    String phoneNumber = phonesCursor.getString(phoneNumberIndex);
-
-                    if (rawContactId > 0L && contactId > 0L && lookupKey != null && !isNullOrEmpty(phoneNumber)) {
-                        ContactMatchKeyPhone matchKey = new ContactMatchKeyPhone();
-                        matchKey.contactId = contactId;
-                        matchKey.lookupKey = lookupKey;
-                        matchKey.rawContactId = rawContactId;
-                        matchKey.phoneNumber = phoneNumber;
-                        phoneNumbers.put(phoneNumber, matchKey);
-                    }
-                }
-            }
-        }
-
-        return phoneNumbers;
-    }
-
-    private Map<String, ContactMatchKeyEmail> readEmails() {
-        Map<String, ContactMatchKeyEmail> emails = new HashMap<>();
-
-        String selection = ContactsContract.CommonDataKinds.Email.IN_DEFAULT_DIRECTORY + " = 1";
-
-        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
-            },
-            selection,
-            null,
-            null)) {
-
-            if (emailsCursor != null && emailsCursor.getCount() > 0) {
-                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()) {
-                    long rawContactId = emailsCursor.getLong(rawContactIdIndex);
-                    long contactId = emailsCursor.getLong(idColumnIndex);
-                    String lookupKey = emailsCursor.getString(lookupKeyColumnIndex);
-                    String email = emailsCursor.getString(emailIndex);
-
-                    if (rawContactId > 0L && contactId > 0L && lookupKey != null && !isNullOrEmpty(email)) {
-                        ContactMatchKeyEmail matchKey = new ContactMatchKeyEmail();
-                        matchKey.contactId = contactId;
-                        matchKey.lookupKey = lookupKey;
-                        matchKey.rawContactId = rawContactId;
-                        matchKey.email = email;
-                        emails.put(email, matchKey);
-                    }
-                }
-            }
-        }
-
-        return emails;
-    }
-
-    private static class ContactMatchKey {
-        long contactId;
-        String lookupKey;
-        long rawContactId;
-    }
-
-    private static class ContactMatchKeyEmail extends ContactMatchKey {
-        String email;
-    }
-
-    private static class ContactMatchKeyPhone extends ContactMatchKey {
-        String phoneNumber;
-    }
-}

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

@@ -857,8 +857,13 @@ public class GroupServiceImpl implements GroupService {
 
         identity = identity.toUpperCase();
 
-        final @NonNull List<Integer> groupIds = this.databaseService.getGroupMemberModelFactory().getGroupIdsByIdentity(identity);
+        final @NonNull List<Integer> memberGroupIds = this.databaseService.getGroupMemberModelFactory().getGroupIdsByIdentity(identity);
         final @NonNull List<Integer> groupIdsMissingInCache = new ArrayList<>();
+        final @NonNull List<Integer> groupIds = new ArrayList<>(memberGroupIds);
+
+        for (GroupModel groupModel : groupModelRepository.getByCreator(identity)) {
+            groupIds.add((int) groupModel.getDatabaseId());
+        }
 
         synchronized (this.groupModelCache) {
             for (int groupId : groupIds) {

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

@@ -1938,9 +1938,20 @@ public class MessageServiceImpl implements MessageService {
      */
     @Override
     public boolean shouldAutoDownload(@NonNull AbstractMessageModel messageModel) {
-        MessageType type = MessageType.FILE;
+        String serializedType = "8";
         FileDataModel fileDataModel = messageModel.getFileData();
 
+        // Temporary workaround to transform media types, already fixed by ANDR-5158
+        if (fileDataModel.getRenderingType() != FileData.RENDERING_DEFAULT) {
+            if (messageModel.getMessageContentsType() == MessageContentsType.IMAGE) {
+                serializedType = "1";
+            } else if (messageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
+                serializedType = "2";
+            } else if (messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
+                serializedType = "3";
+            }
+        }
+
         if (preferenceService != null) {
             ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
             NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
@@ -1951,10 +1962,10 @@ public class MessageServiceImpl implements MessageService {
                     case ConnectivityManager.TYPE_ETHERNET:
                         // fallthrough
                     case ConnectivityManager.TYPE_WIFI:
-                        canDownload = preferenceService.getWifiAutoDownload().contains(String.valueOf(type.serializedValue));
+                        canDownload = preferenceService.getWifiAutoDownload().contains(serializedType);
                         break;
                     case ConnectivityManager.TYPE_MOBILE:
-                        canDownload = preferenceService.getMobileAutoDownload().contains(String.valueOf(type.serializedValue));
+                        canDownload = preferenceService.getMobileAutoDownload().contains(serializedType);
                         break;
                     default:
                         break;
@@ -2582,7 +2593,7 @@ public class MessageServiceImpl implements MessageService {
 
     private void setDownloadCompleted(@NonNull AbstractMessageModel mediaMessageModel, @NonNull MediaMessageDataInterface data) {
         if (mediaMessageModel.getType() == MessageType.FILE) {
-            mediaMessageModel.getFileData().isDownloaded(true);
+            mediaMessageModel.getFileData().setDownloaded(true);
         }
         mediaMessageModel.writeDataModelToBody();
 
@@ -3574,7 +3585,7 @@ public class MessageServiceImpl implements MessageService {
             case TYPE_IMAGE_CAM:
                 // cam images will always be sent in their original size. no scaling needed but possibly rotate and flip
                 try (InputStream inputStream = getFromUri(context, mediaItem.getUri())) {
-                    if (inputStream != null && inputStream.available() > 0) {
+                    if (inputStream.available() > 0) {
                         bitmap = BitmapFactory.decodeStream(new BufferedInputStream(inputStream), null, null);
                         if (bitmap != null) {
                             bitmap = adjustBitmapOrientation(bitmap, mediaItem, metaData);
@@ -4298,54 +4309,51 @@ public class MessageServiceImpl implements MessageService {
      * @return byte array of the media data or null if error occured
      */
     @WorkerThread
+    @Nullable
     private byte[] getContentData(MediaItem mediaItem) {
         try (InputStream inputStream = getFromUri(context, mediaItem.getUri())) {
-            if (inputStream != null) {
-                int fileLength = inputStream.available();
+            int fileLength = inputStream.available();
+
+            if (fileLength > MAX_BLOB_SIZE) {
+                String errorMessage = context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB);
+                logger.info(errorMessage);
+                RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), errorMessage, Toast.LENGTH_LONG).show());
+                return null;
+            }
+
+            if (fileLength == 0) {
+                // InputStream may not provide size
+                fileLength = MAX_BLOB_SIZE + 1;
+            }
+
+            if (!ConfigUtils.checkAvailableMemory(fileLength + NaCl.BOX_OVERHEAD_BYTES)) {
+                logger.warn("Not enough memory to create byte array.");
+                return null;
+            }
+            byte[] fileData = new byte[fileLength + NaCl.BOX_OVERHEAD_BYTES];
+
+            try {
+                int readCount = 0;
+                try {
+                    readCount = copyTo(inputStream, fileData, NaCl.BOX_OVERHEAD_BYTES, fileLength);
+                } catch (Exception e) {
+                    // it's OK to get an EOF
+                }
 
-                if (fileLength > MAX_BLOB_SIZE) {
+                if (readCount > MAX_BLOB_SIZE) {
                     String errorMessage = context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB);
                     logger.info(errorMessage);
                     RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), errorMessage, Toast.LENGTH_LONG).show());
                     return null;
                 }
 
-                if (fileLength == 0) {
-                    // InputStream may not provide size
-                    fileLength = MAX_BLOB_SIZE + 1;
+                if (readCount < fileLength) {
+                    return Arrays.copyOf(fileData, readCount + NaCl.BOX_OVERHEAD_BYTES);
                 }
 
-                if (ConfigUtils.checkAvailableMemory(fileLength + NaCl.BOX_OVERHEAD_BYTES)) {
-                    byte[] fileData = new byte[fileLength + NaCl.BOX_OVERHEAD_BYTES];
-
-                    try {
-                        int readCount = 0;
-                        try {
-                            readCount = copyTo(inputStream, fileData, NaCl.BOX_OVERHEAD_BYTES, fileLength);
-                        } catch (Exception e) {
-                            // it's OK to get an EOF
-                        }
-
-                        if (readCount > MAX_BLOB_SIZE) {
-                            String errorMessage = context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB);
-                            logger.info(errorMessage);
-                            RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), errorMessage, Toast.LENGTH_LONG).show());
-                            return null;
-                        }
-
-                        if (readCount < fileLength) {
-                            return Arrays.copyOf(fileData, readCount + NaCl.BOX_OVERHEAD_BYTES);
-                        }
-
-                        return fileData;
-                    } catch (OutOfMemoryError e) {
-                        logger.error("Unable to create byte array", e);
-                    }
-                } else {
-                    logger.info("Not enough memory to create byte array.");
-                }
-            } else {
-                logger.info("Not enough memory to create byte array.");
+                return fileData;
+            } catch (OutOfMemoryError e) {
+                logger.error("Unable to create byte array", e);
             }
         } catch (IOException e) {
             logger.error("Unable to open file to send", e);

+ 3 - 12
app/src/main/java/ch/threema/app/services/SynchronizeContactsService.java

@@ -1,27 +1,18 @@
 package ch.threema.app.services;
 
-import java.util.Set;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
+import androidx.annotation.WorkerThread;
 import ch.threema.base.SessionScoped;
 
 @SessionScoped
+@Deprecated
 public interface SynchronizeContactsService {
-    @Nullable
-    SynchronizeContactsRoutine instantiateSynchronization();
 
     boolean instantiateSynchronizationAndRun();
 
-    @Nullable
-    SynchronizeContactsRoutine instantiateSynchronization(@NonNull Set<String> processingIdentities);
-
     boolean isSynchronizationInProgress();
 
-    boolean isFullSyncInProgress();
-
     boolean enableSyncFromLocal();
 
+    @WorkerThread
     boolean disableSyncFromLocal(Runnable runAfterRemovedAccount);
 }

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

@@ -3,177 +3,66 @@ package ch.threema.app.services;
 import android.accounts.Account;
 import android.accounts.AccountManagerCallback;
 import android.accounts.AccountManagerFuture;
-import android.content.ContentResolver;
-import android.content.Context;
 
 import org.slf4j.Logger;
 
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import ch.threema.app.androidcontactsync.read.RawContactReader;
-import ch.threema.app.apptaskexecutor.AppTaskExecutor;
-import ch.threema.app.listeners.SynchronizeContactsListener;
-import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase;
 import ch.threema.app.preference.service.SynchronizedSettingsService;
-import ch.threema.app.preference.service.PreferenceService;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase;
 import ch.threema.app.utils.AndroidContactUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.data.models.ModelDeletedException;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.models.VerificationLevel;
-import ch.threema.domain.protocol.api.APIConnector;
-import ch.threema.domain.stores.IdentityStore;
 
+@Deprecated
 public class SynchronizeContactsServiceImpl implements SynchronizeContactsService {
     private static final Logger logger = getThreemaLogger("SynchronizeContactsServiceImpl");
-
-    private final ContentResolver contentResolver;
-    private final APIConnector apiConnector;
     private final ContactService contactService;
     private final @NonNull ContactModelRepository contactModelRepository;
     private final UserService userService;
-    private final LocaleService localeService;
-    private final IdentityStore identityStore;
-
-    private final List<SynchronizeContactsRoutine> pendingRoutines = new ArrayList<>();
-    @NonNull
-    private final ExcludedSyncIdentitiesService excludedSyncIdentityListService;
-    @NonNull
-    private final PreferenceService preferenceService;
     @NonNull
     private final SynchronizedSettingsService synchronizedSettingsService;
     private final DeviceService deviceService;
-    private final Context context;
-    private final BlockedIdentitiesService blockedIdentitiesService;
     @NonNull
-    private final AppTaskExecutor appTaskExecutor;
-    @NonNull
-    private final UpdateContactNameUseCase updateContactNameUseCase;
+    private final SynchronizeAndroidContactsUseCase synchronizeAndroidContactsUseCase;
 
     public SynchronizeContactsServiceImpl(
-        Context context, APIConnector apiConnector,
         ContactService contactService,
         @NonNull ContactModelRepository contactModelRepository,
         UserService userService,
-        LocaleService localeService,
-        @NonNull ExcludedSyncIdentitiesService excludedSyncIdentityListService,
-        @NonNull PreferenceService preferenceService,
         @NonNull SynchronizedSettingsService synchronizedSettingsService,
         DeviceService deviceService,
-        IdentityStore identityStore,
-        @NonNull BlockedIdentitiesService blockedIdentitiesService,
-        @NonNull AppTaskExecutor appTaskExecutor,
-        @NonNull UpdateContactNameUseCase updateContactNameUseCase
-    ) {
-        this.excludedSyncIdentityListService = excludedSyncIdentityListService;
-        this.preferenceService = preferenceService;
+        @NonNull SynchronizeAndroidContactsUseCase synchronizeAndroidContactsUseCase
+        ) {
         this.synchronizedSettingsService = synchronizedSettingsService;
         this.deviceService = deviceService;
-        this.context = context;
-        this.contentResolver = context.getContentResolver();
-        this.apiConnector = apiConnector;
         this.contactService = contactService;
         this.contactModelRepository = contactModelRepository;
         this.userService = userService;
-        this.localeService = localeService;
-        this.identityStore = identityStore;
-        this.blockedIdentitiesService = blockedIdentitiesService;
-        this.appTaskExecutor = appTaskExecutor;
-        this.updateContactNameUseCase = updateContactNameUseCase;
+        this.synchronizeAndroidContactsUseCase = synchronizeAndroidContactsUseCase;
     }
 
     @Override
     public boolean instantiateSynchronizationAndRun() {
-        final SynchronizeContactsRoutine sync = this.instantiateSynchronization();
-
-        if (sync != null) {
             if (this.deviceService != null && this.deviceService.isOnline()) {
-                sync.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) ->
-                    // let user know that contact was added
-                    ListenerManager.newSyncedContactListener.handle(listener -> listener.onNew(createdContacts))
-                );
-
                 new Thread(() -> {
                     try {
-                        sync.run();
+                        synchronizeAndroidContactsUseCase.callBlocking();
                     } catch (SecurityException exception) {
                         logger.error("Could not run contact sync", exception);
                     }
                 }, "SynchronizeContactsRoutine").start();
                 return true;
-            } else {
-                this.finishedRoutine(sync);
             }
-        }
         return false;
     }
 
-    @Override
-    @Nullable
-    public SynchronizeContactsRoutine instantiateSynchronization() {
-        return this.instantiateSynchronization(Collections.emptySet());
-    }
-
-    @Override
-    @Nullable
-    public SynchronizeContactsRoutine instantiateSynchronization(@NonNull Set<String> processingIdentities) {
-        Account account = this.userService.getAccount();
-        if (account == null) {
-            logger.error("Not instantiating synchronize contacts routine due to missing account");
-            return null;
-        }
-
-        logger.info("Running contact sync");
-
-        final SynchronizeContactsRoutine routine =
-            new SynchronizeContactsRoutine(
-                this.context,
-                this.apiConnector,
-                this.contactService,
-                this.contactModelRepository,
-                this.userService,
-                this.localeService,
-                this.contentResolver,
-                this.excludedSyncIdentityListService,
-                this.deviceService,
-                this.preferenceService,
-                this.synchronizedSettingsService,
-                this.identityStore,
-                this.blockedIdentitiesService,
-                this.appTaskExecutor,
-                this.updateContactNameUseCase,
-                processingIdentities
-            );
-
-        synchronized (this.pendingRoutines) {
-            this.pendingRoutines.add(routine);
-        }
-
-        routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> finishedRoutine(routine));
-
-        ListenerManager.synchronizeContactsListeners.handle(listener -> listener.onStarted(routine));
-
-        return routine;
-    }
-
     @Override
     public boolean isSynchronizationInProgress() {
-        return !this.pendingRoutines.isEmpty();
-    }
-
-    @Override
-    public boolean isFullSyncInProgress() {
-        synchronized (this.pendingRoutines) {
-            return pendingRoutines.stream()
-                .anyMatch(routine -> routine.running() && routine.isFullSync());
-        }
+        return SynchronizeAndroidContactsUseCase.isRunning();
     }
 
     @Override
@@ -196,10 +85,12 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
     public boolean disableSyncFromLocal(final Runnable runAfterRemovedAccount) {
         logger.info("Disabling contact sync");
         if (this.userService != null) {
-            //cancel all syncs!
-            synchronized (this.pendingRoutines) {
-                for (int n = this.pendingRoutines.size() - 1; n >= 0; n--) {
-                    this.pendingRoutines.get(n).abort();
+            // TODO(ANDR-4436): This should be refactored.
+            while (SynchronizeAndroidContactsUseCase.isRunning()) {
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException e) {
+                    return false;
                 }
             }
 
@@ -244,21 +135,4 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
             run.run();
         }
     }
-
-    private void finishedRoutine(final SynchronizeContactsRoutine routine) {
-        //remove from pending
-        synchronized (this.pendingRoutines) {
-            this.pendingRoutines.remove(routine);
-        }
-
-        logger.info("Contact sync finished");
-
-        //fire on finished
-        ListenerManager.synchronizeContactsListeners.handle(new ListenerManager.HandleListener<SynchronizeContactsListener>() {
-            @Override
-            public void handle(SynchronizeContactsListener listener) {
-                listener.onFinished(routine);
-            }
-        });
-    }
 }

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

@@ -31,8 +31,10 @@ public interface UserService {
 
     void removeIdentity() throws Exception;
 
+    @Nullable
     Account getAccount();
 
+    @Nullable
     Account getAccount(boolean createIfNotExists);
 
     boolean checkAccount();

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

@@ -59,6 +59,7 @@ public class AudioMessagePlayer extends MessagePlayer {
     @NonNull
     private final NotificationPreferenceService notificationPreferenceService;
     private final ConversationCategoryService conversationCategoryService;
+    @Nullable
     private final ListenableFuture<MediaController> mediaControllerFuture;
     @NonNull
     private final GlobalEventBuses globalEventBuses;
@@ -72,7 +73,7 @@ public class AudioMessagePlayer extends MessagePlayer {
         @NonNull ConversationCategoryService conversationCategoryService,
         @NonNull GlobalEventBuses globalEventBuses,
         @NonNull MessageReceiver<?> messageReceiver,
-        @NonNull ListenableFuture<MediaController> mediaControllerFuture,
+        @Nullable ListenableFuture<MediaController> mediaControllerFuture,
         @NonNull AbstractMessageModel messageModel
     ) {
         super(context, messageService, fileService, messageReceiver, messageModel);
@@ -467,7 +468,7 @@ public class AudioMessagePlayer extends MessagePlayer {
 
     @Nullable
     public MediaController getMediaController() {
-        if (mediaControllerFuture.isDone()) {
+        if (mediaControllerFuture != null && mediaControllerFuture.isDone()) {
             try {
                 return mediaControllerFuture.get();
             } catch (ExecutionException e) {

+ 25 - 22
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java

@@ -235,7 +235,8 @@ public abstract class MessagePlayer {
         this.messageReceiver = messageReceiver;
 
         //init the state
-        if (this.getData() != null && this.getData().isDownloaded()) {
+        var data = getData();
+        if (data != null && data.isDownloaded()) {
             this.state = State_DOWNLOADED;
         }
     }
@@ -355,30 +356,27 @@ public abstract class MessagePlayer {
 
     public boolean open(final boolean autoPlay) {
         final MediaMessageDataInterface data = this.getData();
-        if (data != null) {
-            if (data.isDownloaded()) {
-                this.play(autoPlay);
-            } else {
-                this.download(new InternalListener() {
-                    @Override
-                    public void onComplete(boolean ok) {
-                        if (ok) {
-                            data.isDownloaded(true);
-                            messageService.save(setData(data));
-
-                            if (autoPlay ||
-                                getMessageModel().getFileData().getRenderingType() != RENDERING_MEDIA ||
-                                FileUtil.isAudioFile(getMessageModel().getFileData())) {
-                                open(autoPlay);
-                            }
-                        }
+        if (data == null) {
+            return false;
+        }
+        if (data.isDownloaded()) {
+            play(autoPlay);
+        } else {
+            download(ok -> {
+                if (ok) {
+                    data.setDownloaded(true);
+                    messageService.save(setData(data));
+
+                    if (autoPlay ||
+                        getMessageModel().getFileData().getRenderingType() != RENDERING_MEDIA ||
+                        FileUtil.isAudioFile(getMessageModel().getFileData())) {
+                        open(autoPlay);
                     }
-                }, autoPlay);
+                }
+            }, autoPlay);
 
-            }
-            return true;
         }
-        return false;
+        return true;
     }
 
     public float togglePlaybackSpeed(float currentSpeed) {
@@ -571,6 +569,11 @@ public abstract class MessagePlayer {
             @Override
             public void run() {
                 try {
+                    // TODO(ANDR-5276) Mark a message as read when the user manually starts the download.
+                    //   This serves as a workaround for the problem that the message model instance
+                    //   may be stale by the time the download completes. A stale message model
+                    //   might have outdated data, in particular an outdated 'read' flag.
+                    messageModel.setRead(true);
                     boolean success = messageService.downloadMediaMessage(messageModel, new ProgressListener() {
                         @Override
                         public void updateProgress(final int progress) {

+ 1 - 0
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java

@@ -200,6 +200,7 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
                     logger.debug("Keep downloading player {}", pair.getKey());
                 }
             }
+            messagePlayers.clear();
         }
     }
 

+ 9 - 5
app/src/main/java/ch/threema/app/services/notification/NotificationServiceImpl.kt

@@ -11,7 +11,6 @@ import android.content.Context
 import android.content.Intent
 import android.media.AudioManager
 import android.os.Build
-import android.os.DeadObjectException
 import android.provider.Settings
 import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationManagerCompat
@@ -803,8 +802,13 @@ class NotificationServiceImpl(
         )
     }
     private fun notificationExists(notificationId: Int): Boolean =
-        notificationManagerCompat.activeNotifications.any { notification ->
-            notification.id == notificationId
+        try {
+            notificationManagerCompat.activeNotifications.any { notification ->
+                notification.id == notificationId
+            }
+        } catch (e: Exception) {
+            logger.warn("Failed to check if notification exists", e)
+            false
         }
 
     private fun cancelConversationNotification(conversationNotification: ConversationNotification) {
@@ -1301,8 +1305,8 @@ class NotificationServiceImpl(
     private fun cancel(notificationId: Int, tag: String? = null) {
         try {
             notificationManagerCompat.cancel(tag, notificationId)
-        } catch (e: DeadObjectException) {
-            logger.error("Failed to cancel notification", e)
+        } catch (e: Exception) {
+            logger.warn("Failed to cancel notification {}", notificationId, e)
         }
     }
 

+ 3 - 2
app/src/main/java/ch/threema/app/systemupdates/updates/SystemUpdateToVersion12.java

@@ -2,20 +2,21 @@ package ch.threema.app.systemupdates.updates;
 
 import org.koin.java.KoinJavaComponent;
 
+import ch.threema.app.androidcontactsync.usecases.SynchronizeAndroidContactsUseCase;
 import ch.threema.app.preference.service.SynchronizedSettingsService;
 import ch.threema.app.services.UserService;
-import ch.threema.app.utils.SynchronizeContactsUtil;
 import kotlin.Lazy;
 
 public class SystemUpdateToVersion12 implements SystemUpdate {
 
+    private final Lazy<SynchronizeAndroidContactsUseCase> synchronizeAndroidContactsUseCaseLazy = KoinJavaComponent.inject(SynchronizeAndroidContactsUseCase.class);
     private final Lazy<SynchronizedSettingsService> synchronizedSettingsServiceLazy = KoinJavaComponent.inject(SynchronizedSettingsService.class);
     private final Lazy<UserService> userServiceLazy = KoinJavaComponent.inject(UserService.class);
 
     @Override
     public void run() {
         try {
-            SynchronizeContactsUtil.startDirectly();
+            synchronizeAndroidContactsUseCaseLazy.getValue().callBlocking();
         } catch (SecurityException exception) {
             // Nothing to do
         }

+ 5 - 3
app/src/main/java/ch/threema/app/tasks/OutgoingFileMessageTask.kt

@@ -94,20 +94,22 @@ class OutgoingFileMessageTask(
         messageModel: AbstractMessageModel,
     ): FileData {
         // In case there are recipients or multi device is active, we need a blob id and an
-        // encryption key. Otherwise the message will be invalid and cannot be sent. In case there
+        // encryption key. Otherwise, the message will be invalid and cannot be sent. In case there
         // is no recipient and multi device is not active, the message is sent in a notes group
         // where we do not upload the blob.
         if (recipientIdentities.minus(myIdentity).isNotEmpty() || isMultiDeviceActive) {
             // Validate that the blob id has the correct length
+            val blobId = this.blobId
             if (blobId == null || blobId.size != ProtocolDefines.BLOB_ID_LEN) {
                 logger.error("Invalid blob id of length {}", blobId?.size)
-                throw IllegalStateException("Invalid blob id")
+                error("Invalid blob id")
             }
 
             // Validate that the encryption key has the correct length
+            val encryptionKey = this.encryptionKey
             if (encryptionKey == null || encryptionKey.size != ProtocolDefines.BLOB_KEY_LEN) {
                 logger.error("Invalid encryption key of length {}", encryptionKey?.size)
-                throw IllegalStateException("Invalid blob encryption key")
+                error("Invalid blob encryption key")
             }
         }
 

+ 12 - 6
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java

@@ -31,7 +31,9 @@ import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
@@ -1129,14 +1131,18 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
     @Nullable
     public ArrayList<String> searchID(String phone, String email) {
         if (phone != null || email != null) {
-            Map<String, Object> phoneMap = new HashMap<>();
-            phoneMap.put(phone, null);
-
-            Map<String, Object> emailMap = new HashMap<>();
-            emailMap.put(email, null);
+            Set<String> phoneNumbers = Collections.singleton(phone);
+            Set<String> emailAddresses = Collections.singleton(email);
 
             try {
-                Map<String, APIConnector.MatchIdentityResult> results = apiConnector.matchIdentities(emailMap, phoneMap, localeService.getCountryIsoCode(), true, identityStore, null);
+                Map<String, APIConnector.MatchIdentityResult> results = apiConnector.matchIdentities(
+                    emailAddresses,
+                    phoneNumbers,
+                    localeService.getCountryIsoCode(),
+                    true,
+                    identityStore,
+                    null
+                );
                 if (!results.isEmpty()) {
                     return new ArrayList<>(results.keySet());
                 }

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

@@ -82,14 +82,6 @@ public class MediaItem implements Parcelable {
         private int rotation;
         private int flip;
 
-        public static Orientation getMixedOrientation(@NonNull Orientation first, @NonNull Orientation second) {
-            int mixedRotation = first.getRotation() + second.getRotation();
-            int mixedFlip = FLIP_NONE;
-            mixedFlip |= first.isHorizontalFlip() ^ second.isHorizontalFlip() ? FLIP_HORIZONTAL : FLIP_NONE;
-            mixedFlip |= first.isVerticalFlip() ^ second.isVerticalFlip() ? FLIP_VERTICAL : FLIP_NONE;
-            return new Orientation(mixedRotation, mixedFlip);
-        }
-
         public Orientation() {
             this(0, FLIP_NONE);
         }

+ 1 - 2
app/src/main/java/ch/threema/app/usecases/contacts/WatchContactAvatarIterationUseCase.kt

@@ -5,7 +5,6 @@ import ch.threema.app.eventbus.events.ContactEvent
 import ch.threema.app.listeners.ContactSettingsListener
 import ch.threema.app.listeners.SynchronizeContactsListener
 import ch.threema.app.managers.ListenerManager
-import ch.threema.app.routines.SynchronizeContactsRoutine
 import ch.threema.app.usecases.conversations.AvatarIteration
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.data.datatypes.ContactConversationId
@@ -79,7 +78,7 @@ class WatchContactAvatarIterationUseCase(
         // Catching event:
         // - The user set a profile picture for his synced contact via Android address book
         val synchronizeContactsListener = object : SynchronizeContactsListener {
-            override fun onFinished(finishedRoutine: SynchronizeContactsRoutine?) {
+            override fun onFinished() {
                 incrementAvatarIterationAndSend()
             }
         }

+ 1 - 2
app/src/main/java/ch/threema/app/usecases/conversations/WatchAvatarIterationsUseCase.kt

@@ -6,7 +6,6 @@ import ch.threema.app.eventbus.events.GroupEvent
 import ch.threema.app.listeners.ContactSettingsListener
 import ch.threema.app.listeners.SynchronizeContactsListener
 import ch.threema.app.managers.ListenerManager
-import ch.threema.app.routines.SynchronizeContactsRoutine
 import ch.threema.app.services.ConversationService
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.data.datatypes.ContactConversationId
@@ -98,7 +97,7 @@ class WatchAvatarIterationsUseCase(
 
         // Catching "The user set a profile picture for his synced contact via Android address book" event
         val synchronizeContactsListener = object : SynchronizeContactsListener {
-            override fun onFinished(finishedRoutine: SynchronizeContactsRoutine?) {
+            override fun onFinished() {
                 updateIterationsAndSend { iterations ->
                     addNewReceiversOfType(
                         iterations = iterations,

+ 6 - 6
app/src/main/java/ch/threema/app/utils/AdaptiveIconUtil.kt

@@ -17,19 +17,19 @@ object AdaptiveIconUtil {
      * Creates an adaptive icon from the given [bitmap].
      * The icon includes appropriate amounts of padding, such that on most systems it appears to fill the available
      * space of the used shape, by accepting small amounts of cropping.
-     * Returns `null` if adaptive icons are not supported by the system.
+     * Returns a regular (i.e. non-adaptive) icon, scaled down to an appropriate size, if adaptive icons are not supported by the system.
      */
     @JvmStatic
-    fun create(context: Context, bitmap: Bitmap): IconCompat? {
-        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
-            return null
-        }
-
+    fun create(context: Context, bitmap: Bitmap): IconCompat {
         val density = context.resources.displayMetrics.density
         val outerSize = (OUTER_SIZE * density).toInt()
         val innerSize = (INNER_SIZE * density).toInt()
         val offset = (outerSize - innerSize) / 2f
 
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+            return IconCompat.createWithBitmap(bitmap.scale(outerSize, outerSize))
+        }
+
         val options = BitmapFactory.Options()
         options.inPreferredConfig = Bitmap.Config.ARGB_8888
         val scaledBitmap = bitmap.scale(innerSize, innerSize, false)

+ 3 - 115
app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java

@@ -18,16 +18,11 @@ import android.net.Uri;
 import android.provider.ContactsContract;
 import android.widget.Toast;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ListMultimap;
-
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.time.Instant;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -43,7 +38,6 @@ import ch.threema.data.models.ContactModelData;
 import ch.threema.data.datatypes.AndroidContactLookupInfo;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-import static ch.threema.common.JavaCompat.isNullOrEmpty;
 import static ch.threema.storage.models.ContactModel.DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY;
 
 public class AndroidContactUtil {
@@ -61,12 +55,6 @@ public class AndroidContactUtil {
     private AndroidContactUtil() {
     }
 
-    private static final String[] RAW_CONTACT_PROJECTION = new String[]{
-        ContactsContract.RawContacts._ID,
-        ContactsContract.RawContacts.CONTACT_ID,
-        ContactsContract.RawContacts.SYNC1,
-    };
-
     private final ContentResolver contentResolver = ThreemaApplication.getAppContext().getContentResolver();
 
     private @Nullable Account getAccount() {
@@ -78,16 +66,6 @@ public class AndroidContactUtil {
         return userService.getAccount();
     }
 
-    public static class RawContactInfo {
-        public final long contactId;
-        public final long rawContactId;
-
-        RawContactInfo(long contactId, long rawContactId) {
-            this.contactId = contactId;
-            this.rawContactId = rawContactId;
-        }
-    }
-
     /**
      * 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 - null will be returned in that case
@@ -168,6 +146,7 @@ public class AndroidContactUtil {
     /**
      * 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
+     * TODO(ANDR-4326): Move this to ThreemaRawContactWriter.
      *
      * @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)
@@ -242,6 +221,7 @@ public class AndroidContactUtil {
     /**
      * Delete all raw contacts where the given identity matches the entry in the contact's SYNC1 column
      * It's safe to call this method without contacts permission
+     * TODO(ANDR-4326): Move this to ThreemaRawContactWriter.
      *
      * @param identity the identity of the contact whose raw contact we want to be deleted
      * @return number of raw contacts deleted
@@ -271,65 +251,10 @@ public class AndroidContactUtil {
         return 0;
     }
 
-    /**
-     * Delete all raw contacts specified in rawContacts Map
-     *
-     * @param rawContacts Map 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 int deleteThreemaRawContacts(@NonNull ListMultimap<String, RawContactInfo> rawContacts) {
-        if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
-            return 0;
-        }
-
-        Account account = this.getAccount();
-        if (account == null) {
-            return 0;
-        }
-
-        if (rawContacts.isEmpty()) {
-            return 0;
-        }
-
-        ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
-
-        for (Map.Entry<String, RawContactInfo> rawContact : rawContacts.entries()) {
-            if (!isNullOrEmpty(rawContact.getKey()) && rawContact.getValue().rawContactId != 0L) {
-                try {
-                    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())
-                        .withSelection(ContactsContract.RawContacts._ID + " = ?", new String[]{String.valueOf(rawContact.getValue().rawContactId)});
-
-                    contentProviderOperations.add(builder.build());
-                } catch (Exception e) {
-                    logger.error("Exception", e);
-                }
-            }
-        }
-
-        int operationCount = contentProviderOperations.size();
-        if (operationCount > 0) {
-            try {
-                ConfigUtils.applyToContentResolverInBatches(ContactsContract.AUTHORITY, contentProviderOperations);
-            } catch (Exception e) {
-                logger.error("Error during raw contact deletion! ", e);
-            }
-            contentProviderOperations.clear();
-        }
-
-        logger.debug("Deleted {} raw contacts", operationCount);
-
-        return operationCount;
-    }
-
     /**
      * Delete all raw contacts associated with Threema (including stray ones)
      * Safe to be called without permission
+     * TODO(ANDR-4326): Move this to ThreemaRawContactWriter.
      *
      * @return number of raw contacts deleted
      */
@@ -357,43 +282,6 @@ public class AndroidContactUtil {
         return 0;
     }
 
-    /**
-     * Get a list of all Threema raw contacts from the contact database. This may include "stray" contacts.
-     *
-     * @return List containing pairs of identity and android contact id, null if permissions have not been granted
-     */
-    @Nullable
-    public ListMultimap<String, RawContactInfo> 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();
-
-        ListMultimap<String, RawContactInfo> rawContacts = ArrayListMultimap.create();
-        try (Cursor cursor = contentResolver.query(rawContactUri, RAW_CONTACT_PROJECTION, null, null, null)) {
-            if (cursor != null) {
-                while (cursor.moveToNext()) {
-                    long rawContactId = cursor.getLong(0);
-                    long contactId = cursor.getLong(1);
-                    String identity = cursor.getString(2);
-                    rawContacts.put(identity, new RawContactInfo(contactId, rawContactId));
-                }
-            }
-        } catch (Exception e) {
-            logger.error("Exception", e);
-        }
-        return rawContacts;
-    }
-
     /**
      * 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

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

@@ -1376,6 +1376,7 @@ public class ConfigUtils {
 
     /**
      * Apply operations to content provider in small batches preventing TransactionTooLargeException
+     * TODO(ANDR-4326): Move this to ThreemaRawContactWriter and fix wrong back references in case several batches exist.
      *
      * @param authority                 Authority
      * @param contentProviderOperations Operations to apply in smaller batches

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

@@ -697,8 +697,7 @@ public class FileUtil {
     private static boolean isAnimatedWebPFile(@NonNull Uri uri) {
         try (InputStream inputStream = getFromUri(ThreemaApplication.getAppContext(), uri)) {
             byte[] buffer = new byte[34];
-            return inputStream != null
-                && inputStream.read(buffer) == 34
+            return inputStream.read(buffer) == 34
                 && Arrays.equals(Arrays.copyOfRange(buffer, 0, 4), new byte[]{'R', 'I', 'F', 'F'})
                 && Arrays.equals(Arrays.copyOfRange(buffer, 8, 12), new byte[]{'W', 'E', 'B', 'P'})
                 && Arrays.equals(Arrays.copyOfRange(buffer, 12, 15), new byte[]{'V', 'P', '8'})

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

@@ -298,7 +298,7 @@ public final class ShortcutUtil {
 
             if (commonShortcutInfo.bitmap != null) {
                 var adaptiveIcon = AdaptiveIconUtil.create(getContext(), commonShortcutInfo.bitmap);
-                shortcutInfoCompatBuilder.setIcon(adaptiveIcon != null ? adaptiveIcon : IconCompat.createWithBitmap(commonShortcutInfo.bitmap));
+                shortcutInfoCompatBuilder.setIcon(adaptiveIcon);
             }
 
             if (person != null) {
@@ -576,7 +576,7 @@ public final class ShortcutUtil {
                     getContext(),
                     messageReceiver.getConversationId().getObfuscated().value
                 )
-                    .setIcon(adaptiveIcon != null ? adaptiveIcon : IconCompat.createWithBitmap(avatarBitmap))
+                    .setIcon(adaptiveIcon)
                     .setIntent(getShareTargetShortcutIntent(messageReceiver))
                     .setShortLabel(messageReceiver.getShortName(contactNameFormat) != null ? messageReceiver.getShortName(contactNameFormat) : displayName)
                     .setLongLabel(displayName)

+ 18 - 21
app/src/main/java/ch/threema/app/utils/StreamUtil.kt

@@ -6,42 +6,39 @@ import android.net.Uri
 import ch.threema.base.utils.getThreemaLogger
 import java.io.FileInputStream
 import java.io.FileNotFoundException
+import java.io.IOException
 import java.io.InputStream
 
 private val logger = getThreemaLogger("StreamUtil")
 
-@Throws(FileNotFoundException::class)
-fun getFromUri(context: Context, uri: Uri?): InputStream? {
-    var inputStream: InputStream? = null
-
+@Throws(IOException::class)
+fun getFromUri(context: Context, uri: Uri?): InputStream {
     if (uri == null || uri.scheme == null) {
         throw FileNotFoundException()
     }
 
     if (ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true)) {
         try {
-            inputStream = context.contentResolver.openInputStream(uri)
+            context.contentResolver.openInputStream(uri)
+                ?.let { inputStream ->
+                    return inputStream
+                }
         } catch (_: FileNotFoundException) {
             logger.info("Unable to get an InputStream for this file using ContentResolver: $uri")
+        } catch (e: SecurityException) {
+            throw IOException(e)
         }
     }
 
-    if (inputStream == null) {
-        // try to open as local file if openInputStream fails for a content Uri
-        val filePath = FileUtil.getRealPathFromURI(context, uri)
-        val tmpPath = context.cacheDir.absolutePath
-        val appPath = context.applicationInfo.dataDir
+    // try to open as local file if openInputStream fails for a content Uri
+    val filePath = FileUtil.getRealPathFromURI(context, uri)
+        ?: throw FileNotFoundException()
+    val tmpPath = context.cacheDir.absolutePath
+    val appPath = context.applicationInfo.dataDir
 
-        inputStream = if (filePath != null) {
-            // do not allow sending of files from local directories - but allow tmp dir
-            if (!filePath.startsWith(appPath) || filePath.startsWith(tmpPath)) {
-                FileInputStream(filePath)
-            } else {
-                throw FileNotFoundException("File on private directory")
-            }
-        } else {
-            context.contentResolver.openInputStream(uri)
-        }
+    // do not allow sending of files from local directories - but allow tmp dir
+    if (filePath.startsWith(appPath) && !filePath.startsWith(tmpPath)) {
+        throw FileNotFoundException("File on private directory")
     }
-    return inputStream
+    return FileInputStream(filePath)
 }

+ 0 - 72
app/src/main/java/ch/threema/app/utils/SynchronizeContactsUtil.java

@@ -1,82 +1,10 @@
 package ch.threema.app.utils;
 
-import android.Manifest;
 import android.content.Context;
 import android.os.Bundle;
 import android.os.UserManager;
 
-import org.slf4j.Logger;
-
-import java.util.Set;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresPermission;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.services.SynchronizeContactsService;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
 public class SynchronizeContactsUtil {
-    private static final Logger logger = getThreemaLogger("SynchronizeContactsUtil");
-
-    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
-    public static void startDirectly() {
-        logger.info("Starting contact sync");
-        SynchronizeContactsRoutine routine = getSynchronizeContactsRoutine();
-        if (routine == null) {
-            logger.error("Could not start synchronize contacts routine directly");
-            return;
-        }
-        routine.run();
-    }
-
-    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
-    public static void startDirectly(@NonNull String identity) {
-        logger.info("Starting single contact sync for identity {}", identity);
-        SynchronizeContactsRoutine routine = getSynchronizeContactsRoutine(Set.of(identity));
-        if (routine == null) {
-            logger.error("Could not start synchronize contacts routine directly for identity {}", identity);
-            return;
-        }
-        routine.run();
-    }
-
-    @Nullable
-    private static SynchronizeContactsService getSynchronizeContactsService() {
-        ServiceManager serviceManager = ServiceManager.get();
-        if (serviceManager == null) {
-            logger.error("Cannot get synchronize contacts service as service manager is null");
-            return null;
-        }
-        return serviceManager.getSynchronizeContactsService();
-    }
-
-    @Nullable
-    private static SynchronizeContactsRoutine getSynchronizeContactsRoutine() {
-        return getSynchronizeContactsRoutine(Set.of());
-    }
-
-    @Nullable
-    private static SynchronizeContactsRoutine getSynchronizeContactsRoutine(Set<String> identities) {
-        ServiceManager serviceManager = ServiceManager.get();
-        if (serviceManager == null) {
-            logger.error("Cannot get synchronize contacts routine as service manager is unavailable");
-            return null;
-        }
-
-        if (serviceManager.getSynchronizedSettingsService().isSyncContacts()) {
-            SynchronizeContactsService synchronizeContactsService = getSynchronizeContactsService();
-
-            if (synchronizeContactsService != null) {
-                return synchronizeContactsService.instantiateSynchronization(identities);
-            }
-        }
-
-        return null;
-    }
-
     public static boolean isRestrictedProfile(Context context) {
         UserManager um = (UserManager) context.getSystemService(Context.USER_SERVICE);
         Bundle restrictions = um.getUserRestrictions();

+ 1 - 1
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt

@@ -312,7 +312,7 @@ internal class GroupCallControllerImpl(
                 myDisplayName = context.getString(R.string.me_myself_and_i),
                 sfuConnection = sfuConnection,
             ).process()
-            // This is only reached _after_ the call has been teared down
+            // This is only reached _after_ the call has been torn down
             callDisposedSignal.complete(Unit)
         } catch (e: Exception) {
             onError()

+ 37 - 36
app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt

@@ -7,6 +7,7 @@ import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
 import androidx.lifecycle.*
 import ch.threema.app.R
+import ch.threema.app.framework.BaseViewModel
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.notification.NotificationService
 import ch.threema.app.utils.AudioDevice
@@ -17,25 +18,31 @@ import ch.threema.app.voip.groupcall.*
 import ch.threema.app.voip.groupcall.sfu.*
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.DispatcherProvider
+import ch.threema.common.awaitNonNull
 import ch.threema.data.datatypes.LocalGroupId
 import ch.threema.data.datatypes.localGroupId
 import ch.threema.storage.models.group.GroupModelOld
 import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.take
 import org.webrtc.EglBase
 
 private val logger = getThreemaLogger("GroupCallViewModel")
 
-@UiThread
 class GroupCallViewModel(
     private val appContext: Context,
     private val groupService: GroupService,
     private val groupCallManager: GroupCallManager,
     private val notificationService: NotificationService,
     private val dispatcherProvider: DispatcherProvider,
-) : ViewModel(),
+) : BaseViewModel<Unit, Unit>(),
     GroupCallObserver {
 
+    override suspend fun initialize() {
+        // TODO(ANDR-5279): This view model needs to be migrated to properly manage its state
+        //  the way BaseViewModel intends.
+    }
+
     enum class ConnectingState {
         IDLE,
         INITIATED,
@@ -59,7 +66,10 @@ class GroupCallViewModel(
     private val startTime = MutableLiveData<Long?>()
 
     private var joinJob: Job? = null
-    private lateinit var callController: GroupCallController
+    private val callControllerFlow = MutableStateFlow<GroupCallController?>(null)
+
+    private suspend fun awaitCallController(): GroupCallController =
+        callControllerFlow.awaitNonNull()
 
     private lateinit var audioManager: CallAudioManager
 
@@ -172,16 +182,9 @@ class GroupCallViewModel(
         }
     }
 
-    @UiThread
-    fun leaveCall() {
-        if (joinJob?.isCompleted == true) {
-            viewModelScope.launch {
-                callController.leave()
-            }
-        } else {
-            joinJob?.cancel()
-            logger.info("Join call aborted")
-        }
+    fun leaveCall() = runAction {
+        joinJob?.cancel()
+        callControllerFlow.value?.leave()
         completableFinishEvent.complete(getFinishEvent(FinishEvent.Reason.LEFT))
         callRunning.postValue(false)
     }
@@ -210,6 +213,7 @@ class GroupCallViewModel(
                         completableFinishEvent.complete(mapExceptionToFinishEvent(e))
                         callRunning.postValue(false)
                     }
+                    joinJob = null
                 }
             }
         }
@@ -239,12 +243,12 @@ class GroupCallViewModel(
 
     @WorkerThread
     private suspend fun completeJoining(controller: GroupCallController) {
-        callController = controller
+        callControllerFlow.value = controller
         connectingState.postValue(ConnectingState.COMPLETED)
         audioManager = groupCallManager.getAudioManager()
         callRunning.postValue(true)
         withContext(dispatcherProvider.main) {
-            initialiseValues()
+            initializeValues()
         }
     }
 
@@ -253,25 +257,25 @@ class GroupCallViewModel(
         audioManager.selectAudioDevice(device)
     }
 
-    @UiThread
-    fun muteMicrophone(muted: Boolean) {
+    fun muteMicrophone(muted: Boolean) = runAction {
         try {
             logger.trace("Mute {}", muted)
+            val callController = awaitCallController()
             callController.microphoneActive = !muted
             microphoneActive.postValue(callController.microphoneActive)
         } catch (_: GroupCallController.CallAlreadyEndedException) {
-            return
+            return@runAction
         }
         triggerCaptureStateUpdate()
     }
 
-    @UiThread
-    fun muteCamera(muted: Boolean) {
+    fun muteCamera(muted: Boolean) = runAction {
         try {
+            val callController = awaitCallController()
             callController.cameraActive = !muted
             cameraActive.postValue(callController.cameraActive)
         } catch (_: GroupCallController.CallAlreadyEndedException) {
-            return
+            return@runAction
         }
         triggerCaptureStateUpdate()
         // If camera is turned on, then don't use earpiece as output anymore as it is not convenient
@@ -281,11 +285,11 @@ class GroupCallViewModel(
         }
     }
 
-    fun flipCamera() = viewModelScope.launch {
+    fun flipCamera() = runAction {
         try {
-            callController.flipCamera()
+            awaitCallController().flipCamera()
         } catch (_: GroupCallController.CallAlreadyEndedException) {
-            return@launch
+            return@runAction
         }
         cameraFlipEvents.postValue(Unit)
     }
@@ -311,7 +315,7 @@ class GroupCallViewModel(
     }
 
     @UiThread
-    private fun initialiseValues() {
+    private fun initializeValues() {
         observeCallLeftSignal()
         initMicrophoneState()
         initCameraState()
@@ -325,7 +329,7 @@ class GroupCallViewModel(
         viewModelScope.launch {
             completableFinishEvent.complete(
                 try {
-                    callController.callLeftSignal.await()
+                    awaitCallController().callLeftSignal.await()
                     getFinishEvent(FinishEvent.Reason.LEFT)
                 } catch (e: Exception) {
                     logger.error("Call left with exception", e)
@@ -361,32 +365,28 @@ class GroupCallViewModel(
         reason: FinishEvent.Reason,
         exception: Exception? = null,
     ): FinishEvent {
-        val description =
-            if (exception is GroupCallException && exception.callDescription != null) {
-                exception.callDescription
-            } else if (this::callController.isInitialized) {
-                callController.description
-            } else {
-                null
-            }
+        val description = (exception as? GroupCallException)?.callDescription
+            ?: callControllerFlow.value?.description
         return FinishEvent(reason, description)
     }
 
     @UiThread
     private fun initMicrophoneState() {
-        val enableMicrophone = microphoneActiveDefault ?: callController.microphoneActive
+        val enableMicrophone = microphoneActiveDefault
+            ?: (callControllerFlow.value?.microphoneActive == true)
         muteMicrophone(!enableMicrophone)
         microphoneActiveDefault = null
     }
 
     @UiThread
     private fun initCameraState() {
-        cameraActive.postValue(callController.cameraActive)
+        cameraActive.postValue(callControllerFlow.value?.cameraActive == true)
     }
 
     @UiThread
     private fun observeParticipants() {
         viewModelScope.launch {
+            val callController = awaitCallController()
             callController.participants.collect { participants ->
                 eglBaseAndParticipants.value = callController.eglBase to participants
             }
@@ -396,6 +396,7 @@ class GroupCallViewModel(
     @UiThread
     private fun observeCaptureStateUpdates() {
         viewModelScope.launch {
+            val callController = awaitCallController()
             launch {
                 callController.captureStateUpdates
                     .collect {

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/services/instance/message/updater/ReceiverUpdateHandler.kt

@@ -216,7 +216,7 @@ class ReceiverUpdateHandler @AnyThread constructor(
     }
 
     private fun onContactModified(identity: Identity) {
-        if (synchronizeContactsService.isFullSyncInProgress()) {
+        if (synchronizeContactsService.isSynchronizationInProgress()) {
             // A sync is currently in progress. This causes a *lot* of onModified
             // listeners to be called.
             // To avoid flooding the webclient with updates, we simply ignore the

+ 3 - 4
app/src/main/java/ch/threema/app/webclient/services/instance/message/updater/ReceiversUpdateHandler.java

@@ -10,7 +10,6 @@ import java.util.List;
 
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.preference.service.PreferenceService;
-import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.executor.HandlerExecutor;
 import ch.threema.app.webclient.Protocol;
@@ -154,12 +153,12 @@ public class ReceiversUpdateHandler extends MessageUpdater {
     @AnyThread
     private class SynchronizeContactsListener implements ch.threema.app.listeners.SynchronizeContactsListener {
         @Override
-        public void onStarted(SynchronizeContactsRoutine startedRoutine) {
+        public void onStarted() {
             logger.debug("Contact sync started");
         }
 
         @Override
-        public void onFinished(SynchronizeContactsRoutine finishedRoutine) {
+        public void onFinished() {
             logger.debug("Contact sync finished, sending receivers update");
             handler.post(new Runnable() {
                 @Override
@@ -171,7 +170,7 @@ public class ReceiversUpdateHandler extends MessageUpdater {
         }
 
         @Override
-        public void onError(SynchronizeContactsRoutine finishedRoutine) {
+        public void onError() {
             logger.warn("Contact sync error, sending receivers update");
             handler.post(new Runnable() {
                 @Override

+ 4 - 0
app/src/main/java/ch/threema/app/webviews/SimpleWebViewActivity.kt

@@ -33,6 +33,10 @@ abstract class SimpleWebViewActivity : ThreemaToolbarActivity(), DialogClickList
 
     public override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
+        // TODO(ANDR-4389): Improve this entrypoint
+        if (isFinishing) {
+            return
+        }
 
         val toolbar = findViewById<MaterialToolbar>(R.id.material_toolbar)
         toolbar.setNavigationOnClickListener { finish() }

+ 25 - 27
app/src/main/java/ch/threema/data/repositories/GroupModelRepository.kt

@@ -15,6 +15,7 @@ import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
+import ch.threema.data.storage.DbGroup
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.types.GroupDatabaseId
@@ -71,18 +72,21 @@ class GroupModelRepository(
             .mapNotNull { dbGroup ->
                 val groupIdentity = GroupIdentity(dbGroup.creatorIdentity, GroupId(dbGroup.groupId).toLong())
                 cache.getOrCreate(groupIdentity) {
-                    GroupModel(
-                        groupIdentity = groupIdentity,
-                        data = GroupModelDataFactory.toDataType(dbGroup),
-                        databaseBackend = databaseBackend,
-                        identityProvider = identityProvider,
-                        multiDeviceManager = multiDeviceManager,
-                        taskManager = taskManager,
-                        globalEventBuses = globalEventBuses,
-                    )
+                    createGroupModel(groupIdentity, dbGroup)
                 }
             }
 
+    private fun createGroupModel(groupIdentity: GroupIdentity, dbGroup: DbGroup) =
+        GroupModel(
+            groupIdentity = groupIdentity,
+            data = GroupModelDataFactory.toDataType(dbGroup),
+            databaseBackend = databaseBackend,
+            identityProvider = identityProvider,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+            globalEventBuses = globalEventBuses,
+        )
+
     /**
      * Get the group with the [groupDatabaseId]. Note that this call always accesses the database.
      * Use [getByGroupIdentity] or [getByCreatorIdentityAndId] to reduce database accesses.
@@ -96,15 +100,7 @@ class GroupModelRepository(
         val dbGroup = databaseBackend.getGroupByGroupDatabaseId(groupDatabaseId) ?: return null
         val groupIdentity = GroupIdentity(dbGroup.creatorIdentity, GroupId(dbGroup.groupId).toLong())
         return cache.getOrCreate(groupIdentity) {
-            GroupModel(
-                groupIdentity,
-                GroupModelDataFactory.toDataType(dbGroup),
-                databaseBackend,
-                identityProvider = identityProvider,
-                multiDeviceManager = multiDeviceManager,
-                taskManager = taskManager,
-                globalEventBuses = globalEventBuses,
-            )
+            createGroupModel(groupIdentity, dbGroup)
         }
     }
 
@@ -118,18 +114,20 @@ class GroupModelRepository(
     fun getByGroupIdentity(groupIdentity: GroupIdentity): GroupModel? {
         return cache.getOrCreate(groupIdentity) {
             val dbGroup = databaseBackend.getGroupByGroupIdentity(groupIdentity) ?: return@getOrCreate null
-            GroupModel(
-                groupIdentity,
-                GroupModelDataFactory.toDataType(dbGroup),
-                databaseBackend,
-                identityProvider = identityProvider,
-                multiDeviceManager = multiDeviceManager,
-                taskManager = taskManager,
-                globalEventBuses = globalEventBuses,
-            )
+            createGroupModel(groupIdentity, dbGroup)
         }
     }
 
+    @Synchronized
+    fun getByCreator(creatorIdentity: IdentityString): List<GroupModel> =
+        databaseBackend.getGroupsByCreator(Identity(creatorIdentity))
+            .mapNotNull { dbGroup ->
+                val groupIdentity = GroupIdentity(creatorIdentity, GroupId(dbGroup.groupId).toLong())
+                cache.getOrCreate(groupIdentity) {
+                    createGroupModel(groupIdentity, dbGroup)
+                }
+            }
+
     /**
      * Creates the given group. Note that this change is not reflected! The group is just persisted.
      *

+ 6 - 0
app/src/main/java/ch/threema/data/storage/DatabaseBackend.kt

@@ -4,6 +4,7 @@ import android.database.sqlite.SQLiteException
 import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.domain.types.GroupDatabaseId
+import ch.threema.domain.types.Identity
 import ch.threema.domain.types.IdentityString
 import java.time.Instant
 
@@ -91,6 +92,11 @@ interface DatabaseBackend {
      */
     fun getAllGroups(): Collection<DbGroup>
 
+    /**
+     * Get all groups of a given creator.
+     */
+    fun getGroupsByCreator(identity: Identity): Collection<DbGroup>
+
     /**
      * Return the group with the specified [groupDatabaseId].
      */

+ 13 - 0
app/src/main/java/ch/threema/data/storage/SqliteDatabaseBackend.kt

@@ -11,6 +11,7 @@ import androidx.core.database.getStringOrNull
 import androidx.sqlite.db.SupportSQLiteDatabase
 import androidx.sqlite.db.SupportSQLiteQueryBuilder
 import androidx.sqlite.db.transaction
+import ch.threema.android.map
 import ch.threema.app.BuildConfig
 import ch.threema.base.crypto.NaCl
 import ch.threema.base.utils.getThreemaLogger
@@ -30,6 +31,7 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.types.GroupDatabaseId
+import ch.threema.domain.types.Identity
 import ch.threema.domain.types.IdentityString
 import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.DatabaseUtil
@@ -41,6 +43,7 @@ import ch.threema.storage.models.IncomingGroupSyncRequestLogModel
 import ch.threema.storage.models.group.GroupMemberModel
 import ch.threema.storage.models.group.GroupMessageModel
 import ch.threema.storage.models.group.GroupModelOld
+import ch.threema.storage.runQuery
 import ch.threema.storage.runTransaction
 import java.time.Instant
 import java.util.Collections
@@ -812,6 +815,16 @@ class SqliteDatabaseBackend(
         }
     }
 
+    override fun getGroupsByCreator(identity: Identity): Collection<DbGroup> =
+        databaseProvider.readableDatabase.runQuery(
+            table = GroupModelOld.TABLE,
+            selection = "${GroupModelOld.COLUMN_CREATOR_IDENTITY} = ?",
+            selectionArgs = arrayOf(identity.value),
+        )
+            .map { cursor ->
+                cursor.getGroup()
+            }
+
     override fun getGroupByGroupDatabaseId(groupDatabaseId: GroupDatabaseId): DbGroup? =
         getGroup { queryBuilder ->
             queryBuilder.selection(

+ 7 - 0
app/src/main/java/ch/threema/logging/ErrorReporting.kt

@@ -1,5 +1,6 @@
 package ch.threema.logging
 
+import android.os.DeadObjectException
 import ch.threema.app.BuildConfig
 import ch.threema.base.isInTest
 import ch.threema.common.DispatcherProvider
@@ -48,6 +49,12 @@ private object ErrorRecorder : KoinComponent {
         }
             .map { it.toString() }
 
+        if (e is DeadObjectException || e?.cause is DeadObjectException) {
+            // DeadObjectExceptions are expected to occur occasionally and there's nothing we can do about them,
+            // so there is no point in creating error reports for them
+            return
+        }
+
         GlobalScope.launch(dispatcherProvider.io) {
             errorRecordStore.storeHandledError(
                 message = messageFormat.replace("{}", "%s"),

+ 15 - 5
app/src/main/java/ch/threema/storage/factories/AbstractMessageModelFactory.java

@@ -63,14 +63,14 @@ abstract class AbstractMessageModelFactory extends ModelFactory {
                 messageModel.setStatusMessage(cursorFactory.getBoolean(AbstractMessageModel.COLUMN_IS_STATUS_MESSAGE));
                 messageModel.setCaption(cursorFactory.getString(AbstractMessageModel.COLUMN_CAPTION));
                 messageModel.setQuotedMessageId(cursorFactory.getString(AbstractMessageModel.COLUMN_QUOTED_MESSAGE_API_MESSAGE_ID));
-                messageModel.setMessageContentsType(cursorFactory.getInt(AbstractMessageModel.COLUMN_MESSAGE_CONTENTS_TYPE));
-                messageModel.setMessageFlags(cursorFactory.getInt(AbstractMessageModel.COLUMN_MESSAGE_FLAGS));
+                messageModel.setMessageContentsType(getIntOrZero(cursorFactory.getInt(AbstractMessageModel.COLUMN_MESSAGE_CONTENTS_TYPE)));
+                messageModel.setMessageFlags(getIntOrZero(cursorFactory.getInt(AbstractMessageModel.COLUMN_MESSAGE_FLAGS)));
                 messageModel.setDeliveredAt(cursorFactory.getInstant(AbstractMessageModel.COLUMN_DELIVERED_AT));
                 messageModel.setReadAt(cursorFactory.getInstant(AbstractMessageModel.COLUMN_READ_AT));
                 messageModel.setEditedAt(cursorFactory.getInstant(AbstractMessageModel.COLUMN_EDITED_AT));
                 messageModel.setDeletedAt(cursorFactory.getInstant(AbstractMessageModel.COLUMN_DELETED_AT));
                 messageModel.setForwardSecurityMode(forwardSecurityMode);
-                messageModel.setDisplayTags(cursorFactory.getInt(AbstractMessageModel.COLUMN_DISPLAY_TAGS));
+                messageModel.setDisplayTags(getIntOrZero(cursorFactory.getInt(AbstractMessageModel.COLUMN_DISPLAY_TAGS)));
 
                 String stateString = cursorFactory.getString(AbstractMessageModel.COLUMN_STATE);
                 if (!isNullOrEmpty(stateString)) {
@@ -81,13 +81,23 @@ abstract class AbstractMessageModelFactory extends ModelFactory {
                     }
                 }
 
-                int type = cursorFactory.getInt(AbstractMessageModel.COLUMN_TYPE);
-                messageModel.setType(MessageType.deserialize(type));
+                @Nullable Integer type = cursorFactory.getInt(AbstractMessageModel.COLUMN_TYPE);
+                messageModel.setType(
+                    type != null
+                        ? MessageType.deserialize(type)
+                        : null
+                );
                 return false;
             }
         });
     }
 
+    // TODO(ANDR-5222): According to crash-reports, there are users who have messages in their DB with unexpected
+    //  null values. These messages should be cleanup up, such that this fallback here is no longer necessary.
+    private int getIntOrZero(@Nullable Integer integer) {
+        return integer != null ? integer : 0;
+    }
+
     ContentValues buildContentValues(AbstractMessageModel messageModel) {
         ensureMessageIdIsSetIfRequiredByType(messageModel);
         ContentValues contentValues = new ContentValues();

+ 0 - 4
app/src/main/java/ch/threema/storage/models/data/MessageDataInterface.java

@@ -1,4 +0,0 @@
-package ch.threema.storage.models.data;
-
-public interface MessageDataInterface {
-}

+ 3 - 0
app/src/main/java/ch/threema/storage/models/data/MessageDataInterface.kt

@@ -0,0 +1,3 @@
+package ch.threema.storage.models.data
+
+interface MessageDataInterface

+ 1 - 6
app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java

@@ -151,7 +151,7 @@ public class FileDataModel implements MediaMessageDataInterface {
     }
 
     @Override
-    public void isDownloaded(boolean isDownloaded) {
+    public void setDownloaded(boolean isDownloaded) {
         this.isDownloaded = isDownloaded;
         if (isDownloaded && metaData != null && metaData.containsKey(METADATA_KEY_LEGACY_NONCE)) {
             // Once the file is downloaded, we don't need the nonce anymore and can discard it
@@ -161,11 +161,6 @@ public class FileDataModel implements MediaMessageDataInterface {
         }
     }
 
-    @Override
-    public byte[] getNonce() {
-        return new byte[0];
-    }
-
     @NonNull
     public String getMimeType() {
         if (this.mimeType == null) {

Деякі файли не було показано, через те що забагато файлів було змінено