Parcourir la source

Version 6.1.1-1084

Threema il y a 3 mois
Parent
commit
c7c2f41ac6
43 fichiers modifiés avec 678 ajouts et 527 suppressions
  1. 2 2
      app/build.gradle.kts
  2. 1 1
      app/src/libre/play/optimize-pngs.sh
  3. 3 3
      app/src/libre/play/release-notes/de/default.txt
  4. 3 3
      app/src/libre/play/release-notes/en-US/default.txt
  5. 1 1
      app/src/main/AndroidManifest.xml
  6. 40 41
      app/src/main/java/ch/threema/app/GlobalListeners.java
  7. 0 1
      app/src/main/java/ch/threema/app/activities/PinLockActivity.java
  8. 10 3
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  9. 3 1
      app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java
  10. 1 1
      app/src/main/java/ch/threema/app/asynctasks/EmptyOrDeleteConversationsAsyncTask.java
  11. 10 6
      app/src/main/java/ch/threema/app/compose/conversation/ConversationListItem.kt
  12. 3 3
      app/src/main/java/ch/threema/app/contactdetails/ContactDetailActivity.java
  13. 55 0
      app/src/main/java/ch/threema/app/debug/AndroidContactSyncLogger.kt
  14. 0 9
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  15. 1 1
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  16. 3 3
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  17. 39 18
      app/src/main/java/ch/threema/app/glide/ContactAvatarFetcher.kt
  18. 3 1
      app/src/main/java/ch/threema/app/glide/ContactAvatarLoader.kt
  19. 1 0
      app/src/main/java/ch/threema/app/home/HomeActivity.java
  20. 45 38
      app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java
  21. 1 0
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  22. 17 2
      app/src/main/java/ch/threema/app/services/ContactService.java
  23. 73 42
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  24. 1 9
      app/src/main/java/ch/threema/app/services/ConversationService.java
  25. 50 34
      app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java
  26. 3 1
      app/src/main/java/ch/threema/app/services/FileService.java
  27. 20 12
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  28. 7 5
      app/src/main/java/ch/threema/app/services/SynchronizeContactsService.java
  29. 46 54
      app/src/main/java/ch/threema/app/services/SynchronizeContactsServiceImpl.java
  30. 4 9
      app/src/main/java/ch/threema/app/systemupdates/updates/SystemUpdateToVersion12.java
  31. 1 1
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  32. 1 1
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  33. 1 1
      app/src/main/java/ch/threema/app/ui/MentionSpan.java
  34. 54 133
      app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java
  35. 1 2
      app/src/main/java/ch/threema/app/utils/StateBitmapUtil.java
  36. 34 24
      app/src/main/java/ch/threema/app/utils/SynchronizeContactsUtil.java
  37. 2 1
      app/src/main/java/ch/threema/app/webclient/services/instance/SessionInstanceServiceImpl.java
  38. 1 1
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/CleanReceiverConversationRequestHandler.java
  39. 121 55
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ModifyContactHandler.java
  40. 12 0
      app/src/main/java/ch/threema/data/models/ContactModel.kt
  41. 1 1
      app/src/main/res/values-ru/strings.xml
  42. 1 2
      app/src/main/res/values/styles.xml
  43. 2 1
      scripts/build-release.sh

+ 2 - 2
app/build.gradle.kts

@@ -47,7 +47,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.1.0"
+val appVersion = "6.1.1"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -56,7 +56,7 @@ val appVersion = "6.1.0"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1082
+val defaultVersionCode = 1084
 
 /**
  * Map with keystore paths (if found).

+ 1 - 1
app/src/libre/play/optimize-pngs.sh

@@ -1,4 +1,4 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-find listings/ -name "*.png" -exec optipng -o6 {} \;
+find listings/ -name "*.png" -exec pngquant --speed 1 --strip --force --ext .png {} \;

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

@@ -1,3 +1,3 @@
-- Version 6.1 ist die letzte Version, welche Android 5 und 6 unterstützt
-- Aktualisierung von Logo und Farbschema, um das neue Corporate Design zu widerspiegeln
-- Behebung eines Fehlers, der bei Benutzung des Web-Client unter älteren Android-Versionen auftreten konnte
+- Behebung eines möglichen Absturzes beim Öffnen des Archivs
+- Behebung eines Fehlers beim Auswählen von Text in Nachrichten
+- Behebung eines Fehlers beim Erstellen von Backups

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

@@ -1,3 +1,3 @@
-- Version 6.1 is the last version to support Android 5 and 6
-- Updated the logo and color scheme to reflect the new corporate design
-- Fixed a bug that could occur when using the web client with older Android versions
+- Fixed a possible crash that occurred when opening the archive
+- Fixed a bug that could occur when selecting text in messages
+- Fixed a bug that could occur when creating a backup

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

@@ -456,7 +456,7 @@
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:noHistory="true"
             android:theme="@style/Theme.Threema.WithToolbar.NoAnim"
-            android:windowSoftInputMode="stateHidden|adjustResize" />
+            android:windowSoftInputMode="stateAlwaysVisible|adjustPan" />
         <activity
             android:name=".activities.GroupAddActivity"
             android:configChanges="uiMode"

+ 40 - 41
app/src/main/java/ch/threema/app/GlobalListeners.java

@@ -61,8 +61,6 @@ import ch.threema.app.services.ConversationCategoryService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.preference.service.PreferenceService;
-import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.notification.NotificationService;
@@ -121,9 +119,9 @@ public class GlobalListeners {
     }
 
     @NonNull
-    private Context appContext;
+    private final Context appContext;
     @NonNull
-    private ServiceManager serviceManager;
+    private final ServiceManager serviceManager;
 
     public void setUp() {
         ListenerManager.groupListeners.add(new GroupListener() {
@@ -486,20 +484,28 @@ public class GlobalListeners {
         ListenerManager.contactListeners.add(new ContactListener() {
             @Override
             public void onModified(final @NonNull String identity) {
-                final ContactModel modifiedContactModel = serviceManager.getDatabaseService().getContactModelFactory().getByIdentity(identity);
+                final ContactModel modifiedContact = serviceManager.getDatabaseService().getContactModelFactory().getByIdentity(identity);
+                if (modifiedContact == null) {
+                    return;
+                }
+                final ch.threema.data.models.ContactModel modifiedContactModel = serviceManager.getModelRepositories().getContacts().getByIdentity(identity);
                 if (modifiedContactModel == null) {
                     return;
                 }
+
                 RuntimeUtil.runOnWorkerThread(() -> {
                     try {
                         final ConversationService conversationService = serviceManager.getConversationService();
                         final ContactService contactService = serviceManager.getContactService();
 
                         // Refresh conversation cache
-                        conversationService.updateContactConversation(modifiedContactModel);
+                        conversationService.updateContactConversation(modifiedContact);
                         conversationService.refresh(modifiedContactModel);
 
-                        ShortcutUtil.updatePinnedShortcut(contactService.createReceiver(modifiedContactModel));
+                        ContactMessageReceiver messageReceiver = contactService.createReceiver(modifiedContactModel);
+                        if (messageReceiver != null) {
+                            ShortcutUtil.updatePinnedShortcut(messageReceiver);
+                        }
                     } catch (ThreemaException e) {
                         logger.error("Exception", e);
                     }
@@ -510,10 +516,9 @@ public class GlobalListeners {
             public void onAvatarChanged(final @NonNull String identity) {
                 RuntimeUtil.runOnWorkerThread(() -> {
                     try {
-                        ContactService contactService = serviceManager.getContactService();
-                        ContactModel contactModel = contactService.getByIdentity(identity);
-                        if (contactModel != null) {
-                            ShortcutUtil.updatePinnedShortcut(contactService.createReceiver(contactModel));
+                        ContactMessageReceiver messageReceiver = serviceManager.getContactService().createReceiver(identity);
+                        if (messageReceiver != null) {
+                            ShortcutUtil.updatePinnedShortcut(messageReceiver);
                         }
                     } catch (ThreemaException e) {
                         logger.error("Exception", e);
@@ -536,10 +541,8 @@ public class GlobalListeners {
             @Override
             public void onAvatarSettingChanged() {
                 //reset the avatar cache!
-                if (serviceManager != null) {
-                    AvatarCacheService s = serviceManager.getAvatarCacheService();
-                    s.clear();
-                }
+                AvatarCacheService s = serviceManager.getAvatarCacheService();
+                s.clear();
             }
 
             @Override
@@ -699,33 +702,33 @@ public class GlobalListeners {
             public void onChange(boolean selfChange) {
                 super.onChange(selfChange);
 
-                if (!selfChange && serviceManager != null && !isRunning) {
+                logger.info("Contact name change observed");
+
+                if (selfChange || isRunning) {
+                    logger.info("Contact name change observer already running");
+                    return;
+                }
+
+                try {
                     this.isRunning = true;
                     onAndroidContactChangeLock.lock();
+                    logger.info("Starting to update all contact names from android contacts");
 
-                    boolean cont;
-                    //check if a sync is in progress.. wait!
-                    try {
-                        SynchronizeContactsService synchronizeContactService = serviceManager.getSynchronizeContactsService();
-                        cont = !synchronizeContactService.isSynchronizationInProgress();
-                    } catch (MasterKeyLockedException e) {
-                        logger.error("Exception", e);
-                        //do nothing
-                        cont = false;
+                    if (serviceManager.getSynchronizeContactsService().isSynchronizationInProgress()) {
+                        logger.warn("Aborting contact name change observer as a contact synchronization is currently in progress");
+                        return;
                     }
 
-                    if (cont) {
-                        PreferenceService preferencesService = serviceManager.getPreferenceService();
-                        if (preferencesService.isSyncContacts()) {
-                            try {
-                                ContactService c = serviceManager.getContactService();
-                                //update contact names if changed!
-                                c.updateAllContactNamesFromAndroidContacts();
-                            } catch (MasterKeyLockedException e) {
-                                logger.error("Exception", e);
-                            }
-                        }
+                    if (!serviceManager.getPreferenceService().isSyncContacts()) {
+                        logger.warn("Contact synchronization is not enabled. Aborting.");
+                        return;
                     }
+
+                    boolean success = serviceManager.getContactService().updateAllContactNamesFromAndroidContacts();
+                    logger.info("Finished updating contact names from android contacts (success={})", success);
+                } catch (MasterKeyLockedException masterKeyLockedException) {
+                    logger.error("Cantact name change observer could not be run successfully", masterKeyLockedException);
+                } finally {
                     this.isRunning = false;
                     onAndroidContactChangeLock.unlock();
                 }
@@ -786,7 +789,7 @@ public class GlobalListeners {
                 @NonNull final byte[] permanentKey,
                 @NonNull final String browser
             ) {
-                logger.info("WebClientListenerManager: onStarted", true);
+                logger.info("WebClientListenerManager: onStarted");
 
                 RuntimeUtil.runOnUiThread(() -> {
                     String toastText = appContext.getString(R.string.webclient_new_connection_toast);
@@ -925,10 +928,6 @@ public class GlobalListeners {
             ) {
                 try {
                     // Services
-                    if (serviceManager == null) {
-                        this.logger.error("Could not save voip status, servicemanager is null");
-                        return;
-                    }
                     final IdentityStore identityStore = serviceManager.getIdentityStore();
                     final ContactService contactService = serviceManager.getContactService();
                     final MessageService messageService = serviceManager.getMessageService();

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

@@ -103,7 +103,6 @@ public class PinLockActivity extends ThreemaActivity {
         numWrongConfirmAttempts = preferenceService.getLockoutAttempts();
 
         setContentView(R.layout.activity_pin_lock);
-        getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
 
         passwordEntry = findViewById(R.id.password_entry);
         passwordEntry.setOnEditorActionListener((v, actionId, event) -> {

+ 10 - 3
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -42,6 +42,8 @@ import com.google.i18n.phonenumbers.Phonenumber;
 
 import org.slf4j.Logger;
 
+import java.util.Set;
+
 import androidx.activity.EdgeToEdge;
 import androidx.annotation.NonNull;
 import androidx.annotation.Px;
@@ -978,12 +980,17 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
                 @Override
                 protected Void doInBackground(Void... params) {
                     try {
-                        final Account account = userService.getAccount(true);
+                        // 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);
 
-                        SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-                        SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
+                        SynchronizeContactsRoutine routine = serviceManager.getSynchronizeContactsService().instantiateSynchronization();
+                        if (routine == null) {
+                            logger.error("Cannot synchronize contacts as the routine is null");
+                            cancel(true);
+                            return null;
+                        }
 
                         routine.setOnStatusUpdate(new SynchronizeContactsRoutine.OnStatusUpdate() {
                             @Override

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

@@ -30,6 +30,8 @@ import android.os.Bundle;
 
 import org.slf4j.Logger;
 
+import java.util.Set;
+
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ListenerManager;
@@ -87,7 +89,7 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
                     return;
                 }
 
-                SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
+                SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization();
                 //update stats on finished to resolve the "every minute sync" bug
 
                 routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> {

+ 1 - 1
app/src/main/java/ch/threema/app/asynctasks/EmptyOrDeleteConversationsAsyncTask.java

@@ -192,7 +192,7 @@ public class EmptyOrDeleteConversationsAsyncTask extends AsyncTask<Void, Void, V
     }
 
     private void deleteContactConversation(@NonNull ContactModel contactModel) {
-        this.conversationService.delete(contactModel);
+        this.conversationService.delete(contactModel.getIdentity());
     }
 
     @WorkerThread

+ 10 - 6
app/src/main/java/ch/threema/app/compose/conversation/ConversationListItem.kt

@@ -22,7 +22,7 @@
 package ch.threema.app.compose.conversation
 
 import android.content.Context
-import androidx.annotation.ColorRes
+import androidx.annotation.ColorInt
 import androidx.annotation.DrawableRes
 import androidx.annotation.StringRes
 import androidx.compose.foundation.ExperimentalFoundationApi
@@ -48,12 +48,14 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.colorResource
@@ -245,6 +247,7 @@ private fun getLatestMessageStateIcon(
 
         val stateIconContentDescriptionRes: Int? = stateBitmapUtil.getStateDescription(messageState)
 
+        @ColorInt
         val tintOverride: Int? = when (messageState) {
             MessageState.SENDFAILED, MessageState.FS_KEY_MISMATCH -> stateBitmapUtil.warningColor
             else -> null
@@ -258,10 +261,11 @@ private fun getLatestMessageStateIcon(
     }
 }
 
+@Stable
 data class IconInfo(
     @DrawableRes val icon: Int,
     @StringRes val contentDescription: Int?,
-    @ColorRes val tintOverride: Int? = null,
+    @ColorInt val tintOverride: Int? = null,
 )
 
 private fun getAvatarContentDescription(
@@ -755,9 +759,9 @@ private fun SecondLineDefault(
             SpacerHorizontal(GridUnit.x0_5)
             Icon(
                 modifier = Modifier.size(GridUnit.x2_5),
-                tint = deliveryIcon.tintOverride?.let { tintOverrideRes ->
-                    colorResource(tintOverrideRes)
-                } ?: LocalContentColor.current,
+                tint = deliveryIcon.tintOverride
+                    ?.let(::Color)
+                    ?: LocalContentColor.current,
                 painter = painterResource(deliveryIcon.icon),
                 contentDescription = deliveryIcon.contentDescription?.let { contentDescriptionRes ->
                     stringResource(contentDescriptionRes)
@@ -1503,7 +1507,7 @@ private fun Preview_SendFailed() {
                 deliveryIcon = IconInfo(
                     icon = R.drawable.ic_baseline_key_off_24,
                     contentDescription = null,
-                    tintOverride = R.color.material_red,
+                    tintOverride = colorResource(R.color.material_red).toArgb(),
                 ),
                 unreadState = null,
                 isPinned = false,

+ 3 - 3
app/src/main/java/ch/threema/app/contactdetails/ContactDetailActivity.java

@@ -678,8 +678,8 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
     }
 
     private void openContactEditor() {
-        if (contact != null) {
-            if (!AndroidContactUtil.getInstance().openContactEditor(this, contact, REQUEST_CODE_CONTACT_EDITOR)) {
+        if (contactModel != null) {
+            if (!AndroidContactUtil.getInstance().openContactEditor(this, contactModel, REQUEST_CODE_CONTACT_EDITOR)) {
                 editName();
             }
         }
@@ -959,7 +959,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
                 break;
             case REQUEST_CODE_CONTACT_EDITOR:
                 try {
-                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel);
+                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel, null);
                     AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel);
                     this.avatarEditView.setContactModel(contact);
                 } catch (ThreemaException | SecurityException e) {

+ 55 - 0
app/src/main/java/ch/threema/app/debug/AndroidContactSyncLogger.kt

@@ -0,0 +1,55 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.debug
+
+import android.net.Uri
+import ch.threema.base.utils.LoggingUtil
+
+private val logger = LoggingUtil.getThreemaLogger("AndroidContactSyncLogger")
+
+/**
+ * This logger is used to count the number of identical contacts when importing them from the android contacts.
+ */
+class AndroidContactSyncLogger {
+    private val nameMap = mutableMapOf<Pair<String, String>, MutableList<Uri>>()
+
+    /**
+     * Add the first and last name that was received for a certain uri to the logger.
+     */
+    fun addSyncedNames(uri: Uri, firstName: String?, lastName: String?) {
+        nameMap.getOrPut(firstName.orEmpty() to lastName.orEmpty()) { mutableListOf() }.add(uri)
+    }
+
+    /**
+     * Check whether there were multiple contacts with the same name added.
+     */
+    fun logDuplicates() {
+        val duplicateNames = nameMap.values.filter { it.size > 1 }
+        duplicateNames.forEach { uriList ->
+            logger.warn("Got the same name for {} contacts (for {} different uris)", uriList.size, uriList.map { it.path }.toSet().size)
+        }
+
+        if (duplicateNames.isEmpty()) {
+            logger.info("All synchronized android contacts' names are unique")
+        }
+    }
+}

+ 0 - 9
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -3706,15 +3706,6 @@ public class ComposeMessageFragment extends Fragment implements
             for (Map.Entry<String, Integer> entry : colorIndices.entrySet()) {
                 String memberIdentity = entry.getKey();
                 int memberColorIndex = entry.getValue();
-                // If the ID color index is -1, the correct index is calculated and stored into the database
-                if (memberColorIndex < 0) {
-                    ContactModel member = contactService.getByIdentity(memberIdentity);
-                    if (member != null) {
-                        member.initializeIdColor();
-                        memberColorIndex = member.getIdColorIndex();
-                        contactService.save(member);
-                    }
-                }
                 colors.put(memberIdentity,
                     darkTheme ?
                         ColorUtil.getInstance().getIDColorDark(memberColorIndex) :

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

@@ -315,7 +315,7 @@ public class ContactsSectionFragment
         @Override
         public void onStarted(SynchronizeContactsRoutine startedRoutine) {
             //only show loading on "full sync"
-            if (resumePauseHandler != null && swipeRefreshLayout != null && startedRoutine.fullSync()) {
+            if (resumePauseHandler != null && swipeRefreshLayout != null && startedRoutine.isFullSync()) {
                 resumePauseHandler.runOnActive(RUN_ON_ACTIVE_SHOW_LOADING, runIfActiveShowLoading);
             }
         }

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

@@ -261,14 +261,14 @@ public class MessageSectionFragment extends MainFragment
     private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
         @Override
         public void onStarted(SynchronizeContactsRoutine startedRoutine) {
-            if (startedRoutine.fullSync()) {
+            if (startedRoutine.isFullSync()) {
                 currentFullSyncs++;
             }
         }
 
         @Override
         public void onFinished(SynchronizeContactsRoutine finishedRoutine) {
-            if (finishedRoutine.fullSync()) {
+            if (finishedRoutine.isFullSync()) {
                 currentFullSyncs--;
 
                 logger.debug("synchronizeContactsListener.onFinished");
@@ -278,7 +278,7 @@ public class MessageSectionFragment extends MainFragment
 
         @Override
         public void onError(SynchronizeContactsRoutine finishedRoutine) {
-            if (finishedRoutine.fullSync()) {
+            if (finishedRoutine.isFullSync()) {
                 currentFullSyncs--;
                 logger.debug("synchronizeContactsListener.onError");
                 refreshListEvent();

+ 39 - 18
app/src/main/java/ch/threema/app/glide/ContactAvatarFetcher.kt

@@ -28,20 +28,27 @@ import ch.threema.app.R
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.services.AvatarCacheServiceImpl
 import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
 import ch.threema.app.utils.AndroidContactUtil
 import ch.threema.app.utils.AvatarConverterUtil
 import ch.threema.app.utils.ColorUtil
 import ch.threema.app.utils.ContactUtil
-import ch.threema.storage.models.ContactModel
+import ch.threema.data.models.ContactModel
+import ch.threema.data.repositories.ContactModelRepository
 import com.bumptech.glide.Priority
 import com.bumptech.glide.load.data.DataFetcher
 
 /**
  * This class is used to get the avatars from the database or create the default avatars. The results of the loaded bitmaps will be cached by glide (if possible).
+ * While the name suggests that it can only deal with contacts, it can actually also deal with the user's own profile picture.
+ * TODO(ANDR-4021): Consider properly generalizing or splitting this class, such that it does no need to
+ * rely on "fake" contact models for the user itself
  */
 class ContactAvatarFetcher(
     context: Context,
+    private val userService: UserService?,
     private val contactService: ContactService?,
+    private val contactModelRepository: ContactModelRepository?,
     private val contactAvatarConfig: AvatarCacheServiceImpl.ContactAvatarConfig,
     private val preferenceService: PreferenceService?,
 ) : AvatarFetcher(context) {
@@ -61,7 +68,6 @@ class ContactAvatarFetcher(
     }
 
     override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
-        val contactModel = contactAvatarConfig.model
         val highRes = contactAvatarConfig.options.highRes
         // Show profile picture from contact (if set)
         val profilePicReceive: Boolean
@@ -90,16 +96,28 @@ class ContactAvatarFetcher(
         }
         val backgroundColor = getBackgroundColor(contactAvatarConfig.options)
 
-        val avatar = if (defaultAvatar) {
-            buildDefaultAvatar(contactModel, highRes, backgroundColor)
+        // TODO(ANDR-4021): It should be possible to load the user's own profile picture without having to extract the identity from a "fake" contact
+        val identity = contactAvatarConfig.model?.identity
+        val avatar = if (identity != null && userService?.isMe(identity) == true) {
+            getUserDefinedProfilePicture(identity, highRes)
+                ?: if (returnDefaultIfNone) {
+                    buildDefaultAvatar(contactModel = null, highRes, backgroundColor)
+                } else {
+                    null
+                }
         } else {
-            loadContactAvatar(
-                contactModel,
-                highRes,
-                profilePicReceive,
-                returnDefaultIfNone,
-                backgroundColor,
-            )
+            val contactModel = identity?.let { contactModelRepository?.getByIdentity(identity) }
+            if (defaultAvatar) {
+                buildDefaultAvatar(contactModel, highRes, backgroundColor)
+            } else {
+                loadContactAvatar(
+                    contactModel,
+                    highRes,
+                    profilePicReceive,
+                    returnDefaultIfNone,
+                    backgroundColor,
+                )
+            }
         }
 
         callback.onDataReady(avatar)
@@ -124,7 +142,7 @@ class ContactAvatarFetcher(
         }
 
         // Try the user defined profile picture
-        getUserDefinedProfilePicture(contactModel, highRes)?.let {
+        getUserDefinedProfilePicture(contactModel.identity, highRes)?.let {
             return it
         }
 
@@ -156,11 +174,11 @@ class ContactAvatarFetcher(
     }
 
     private fun getUserDefinedProfilePicture(
-        contactModel: ContactModel,
+        identity: String,
         highRes: Boolean,
     ): Bitmap? {
         return try {
-            var result = fileService?.getUserDefinedProfilePicture(contactModel.identity)
+            var result = fileService?.getUserDefinedProfilePicture(identity)
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
             }
@@ -174,7 +192,7 @@ class ContactAvatarFetcher(
         contactModel: ContactModel,
         highRes: Boolean,
     ): Bitmap? {
-        if (ContactUtil.isGatewayContact(contactModel) || AndroidContactUtil.getInstance()
+        if (ContactUtil.isGatewayContact(contactModel.identity) || AndroidContactUtil.getInstance()
                 .getAndroidContactUri(contactModel) == null
         ) {
             return null
@@ -196,10 +214,13 @@ class ContactAvatarFetcher(
         highRes: Boolean,
         backgroundColor: Int,
     ): Bitmap {
-        val color = contactService?.getAvatarColor(contactModel)
-            ?: ColorUtil.getInstance().getCurrentThemeGray(context)
+        val color = contactService?.getAvatarColor(contactModel) ?: ColorUtil.getInstance().getCurrentThemeGray(context)
         val drawable =
-            if (ContactUtil.isGatewayContact(contactModel)) contactBusinessAvatar else contactDefaultAvatar
+            if (contactModel != null && ContactUtil.isGatewayContact(contactModel.identity)) {
+                contactBusinessAvatar
+            } else {
+                contactDefaultAvatar
+            }
         return if (highRes) {
             buildDefaultAvatarHighRes(drawable, color, backgroundColor)
         } else {

+ 3 - 1
app/src/main/java/ch/threema/app/glide/ContactAvatarLoader.kt

@@ -31,7 +31,9 @@ import com.bumptech.glide.signature.ObjectKey
 
 class ContactAvatarLoader(val context: Context) :
     ModelLoader<AvatarCacheServiceImpl.ContactAvatarConfig, Bitmap> {
+    private val userService = ThreemaApplication.getServiceManager()?.userService
     private val contactService = ThreemaApplication.getServiceManager()?.contactService
+    private val contactModelRepository = ThreemaApplication.getServiceManager()?.modelRepositories?.contacts
     private val preferenceService = ThreemaApplication.getServiceManager()?.preferenceService
 
     override fun buildLoadData(
@@ -42,7 +44,7 @@ class ContactAvatarLoader(val context: Context) :
     ): ModelLoader.LoadData<Bitmap> {
         return ModelLoader.LoadData(
             ObjectKey(config),
-            ContactAvatarFetcher(context, contactService, config, preferenceService),
+            ContactAvatarFetcher(context, userService, contactService, contactModelRepository, config, preferenceService),
         )
     }
 

+ 1 - 0
app/src/main/java/ch/threema/app/home/HomeActivity.java

@@ -1378,6 +1378,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
                         return null;
                     }
 
+                    // TODO(ANDR-4021): don't create a fake contact model here
                     Bitmap bitmap = contactService.getAvatar(
                         // Create "fake" contact model for own user
                         ContactModel.create(userService.getIdentity(), userService.getPublicKey()),

+ 45 - 38
app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java

@@ -42,8 +42,10 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import ch.threema.app.asynctasks.AddContactBackgroundTask;
+import ch.threema.app.debug.AndroidContactSyncLogger;
 import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DeviceService;
@@ -93,7 +95,8 @@ public class SynchronizeContactsRoutine implements Runnable {
     private final List<OnFinished> onFinished = new ArrayList<>();
     private final List<OnStarted> onStarted = new ArrayList<>();
 
-    private final List<String> processingIdentities = new ArrayList<>();
+    @NonNull
+    private final Set<String> processingIdentities;
     private boolean abort = false;
     private boolean running = false;
 
@@ -123,7 +126,8 @@ public class SynchronizeContactsRoutine implements Runnable {
         DeviceService deviceService,
         PreferenceService preferenceService,
         IdentityStoreInterface identityStore,
-        BlockedIdentitiesService blockedIdentitiesService
+        BlockedIdentitiesService blockedIdentitiesService,
+        @NonNull Set<String> processingIdentities
     ) {
         this.context = context;
         this.apiConnector = apiConnector;
@@ -137,13 +141,7 @@ public class SynchronizeContactsRoutine implements Runnable {
         this.preferenceService = preferenceService;
         this.identityStore = identityStore;
         this.blockedIdentitiesService = blockedIdentitiesService;
-    }
-
-    public SynchronizeContactsRoutine addProcessIdentity(String identity) {
-        if (!TestUtil.isEmptyOrNull(identity) && !this.processingIdentities.contains(identity)) {
-            this.processingIdentities.add(identity);
-        }
-        return this;
+        this.processingIdentities = processingIdentities;
     }
 
     public void abort() {
@@ -154,7 +152,11 @@ public class SynchronizeContactsRoutine implements Runnable {
         return this.running;
     }
 
-    public boolean fullSync() {
+    /**
+     * 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();
     }
 
@@ -171,7 +173,7 @@ public class SynchronizeContactsRoutine implements Runnable {
         this.running = true;
 
         for (OnStarted s : this.onStarted) {
-            s.started(this.fullSync());
+            s.started(this.isFullSync());
         }
 
         boolean success = false;
@@ -179,6 +181,7 @@ public class SynchronizeContactsRoutine implements Runnable {
         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 {
@@ -210,20 +213,20 @@ public class SynchronizeContactsRoutine implements Runnable {
             preferenceService.setPhoneNumberSyncHashCode(phoneNumbersHash);
             preferenceService.setTimeOfLastContactSync(System.currentTimeMillis());
 
-            logger.info("Attempting to sync contacts {} - {}", emails.size(), phoneNumbers.size());
+            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> foundIds = this.apiConnector.matchIdentities(
+            Map<String, APIConnector.MatchIdentityResult> matchIdentityResults = this.apiConnector.matchIdentities(
                 emails, phoneNumbers, this.localeService.getCountryIsoCode(), false, identityStore, matchTokenStore);
 
             // remove own ID
             if (userService != null) {
-                foundIds.remove(userService.getIdentity());
+                matchIdentityResults.remove(userService.getIdentity());
             }
 
             final List<String> preSynchronizedIdentities = new ArrayList<>();
-            if (this.fullSync()) {
+            if (this.isFullSync()) {
                 List<String> synchronizedIdentities = this.contactService.getSynchronizedIdentities();
                 if (synchronizedIdentities != null) {
                     preSynchronizedIdentities.addAll(synchronizedIdentities);
@@ -234,14 +237,17 @@ public class SynchronizeContactsRoutine implements Runnable {
             if (existingRawContacts == null) {
                 throw new ThreemaException("No permission or no account");
             }
-            logger.info("Number of existing raw contacts {}", existingRawContacts.size());
+            logger.info("Number of existing raw contacts: {}", existingRawContacts.size());
+
+            logger.info("Number of IDs matching phone or email: {}", matchIdentityResults.size());
 
-            //looping result and create/update contacts
-            logger.info("Number of IDs matching phone or email {}", foundIds.size());
+            AndroidContactSyncLogger androidContactSyncLogger = new AndroidContactSyncLogger();
 
             ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
-            for (Map.Entry<String, APIConnector.MatchIdentityResult> id : foundIds.entrySet()) {
+            for (Map.Entry<String, APIConnector.MatchIdentityResult> matchIdentityResultEntry : matchIdentityResults.entrySet()) {
                 boolean isNewContact = false;
+                String identity = matchIdentityResultEntry.getKey();
+                APIConnector.MatchIdentityResult result = matchIdentityResultEntry.getValue();
 
                 if (this.abort) {
                     //abort!
@@ -252,22 +258,24 @@ public class SynchronizeContactsRoutine implements Runnable {
                 }
 
                 // Do not sync contacts on exclude list
-                if (this.excludedSyncIdentitiesService.isExcluded(id.getKey())) {
-                    logger.info("Identity {} is on exclude list", id.getKey());
+                if (this.excludedSyncIdentitiesService.isExcluded(identity)) {
+                    logger.info("Identity {} is on exclude list", identity);
                     continue;
                 }
 
-                if (!this.processingIdentities.isEmpty() && !this.processingIdentities.contains(id.getKey())) {
+                // 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.fullSync()) {
+                if (this.isFullSync()) {
                     // remove if list contains this key
-                    preSynchronizedIdentities.remove(id.getKey());
+                    preSynchronizedIdentities.remove(identity);
                 }
 
-                final ContactMatchKeyEmail matchKeyEmail = (ContactMatchKeyEmail) id.getValue().refObjectEmail;
-                final ContactMatchKeyPhone matchKeyPhone = (ContactMatchKeyPhone) id.getValue().refObjectMobileNo;
+                final ContactMatchKeyEmail matchKeyEmail = (ContactMatchKeyEmail) result.refObjectEmail;
+                final ContactMatchKeyPhone matchKeyPhone = (ContactMatchKeyPhone) result.refObjectMobileNo;
 
                 long contactId;
                 String lookupKey;
@@ -280,21 +288,18 @@ public class SynchronizeContactsRoutine implements Runnable {
                 }
 
                 //try to get the contact
-                ContactModel contact = contactModelRepository.getByIdentity(
-                    id.getKey()
-                );
+                ContactModel contact = contactModelRepository.getByIdentity(identity);
                 ContactModelData data = contact != null ? contact.getData().getValue() : null;
                 //contact does not exist, create a new one
                 if (contact == null || data == null) {
-                    APIConnector.MatchIdentityResult result = id.getValue();
                     ContactModelData contactModelData = ContactModelData.javaCreate(
-                        id.getKey(),
+                        identity,
                         result.publicKey,
                         new Date(),
                         "",
                         "",
                         null,
-                        ContactModelData.getIdColorIndexInt(id.getKey()),
+                        ContactModelData.getIdColorIndexInt(identity),
                         VerificationLevel.SERVER_VERIFIED,
                         WorkVerificationLevel.NONE,
                         IdentityType.NORMAL,   // TODO(ANDR-3044): Fetch identity type
@@ -317,7 +322,7 @@ public class SynchronizeContactsRoutine implements Runnable {
                         .runSynchronously();
 
                     if (contact == null) {
-                        logger.error("Could not create contact with identity {}", id.getKey());
+                        logger.error("Could not create contact with identity {}", identity);
                         continue;
                     }
 
@@ -330,14 +335,14 @@ public class SynchronizeContactsRoutine implements Runnable {
                     insertedContacts.add(contact);
 
                     isNewContact = true;
-                    logger.info("Inserting new Threema contact {}", id.getKey());
+                    logger.info("Inserting new Threema contact {}", identity);
                 }
                 contact.setAndroidLookupKey(lookupKey + "/" + contactId);
 
                 try {
                     boolean createNewRawContact;
 
-                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contact); // throws an exception if no name can be determined
+                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contact, androidContactSyncLogger); // throws an exception if no name can be determined
                     AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
 
                     contact.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT);
@@ -384,12 +389,14 @@ public class SynchronizeContactsRoutine implements Runnable {
                     if (isNewContact) {
                         // probably not a valid contact
                         insertedContacts.remove(contact);
-                        logger.info("Ignore Threema contact {} due to missing name", id.getKey());
+                        logger.info("Ignore Threema contact {} due to missing name", identity);
                     }
                     logger.error("Contact lookup Exception", e);
                 }
             }
 
+            androidContactSyncLogger.logDuplicates();
+
             if (!contentProviderOperations.isEmpty()) {
                 try {
                     ConfigUtils.applyToContentResolverInBatches(
@@ -401,7 +408,7 @@ public class SynchronizeContactsRoutine implements Runnable {
                 contentProviderOperations.clear();
             }
 
-            if (this.fullSync()) {
+            if (this.isFullSync()) {
                 // delete remaining / stray raw contacts
                 if (!existingRawContacts.isEmpty()) {
                     AndroidContactUtil.getInstance().deleteThreemaRawContacts(existingRawContacts);
@@ -426,7 +433,7 @@ public class SynchronizeContactsRoutine implements Runnable {
                 this.onStatusUpdate.error(x);
             }
         } finally {
-            logger.info("Finished [success = {}, inserted = {}, deleted = {}, modifiedRawContacts = {}, newRawContacts = {}] fullSync = {}", success, insertedContacts.size(), deletedCount, modifiedRawContactCount, newRawContactCount, this.fullSync());
+            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);
             }

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

@@ -366,6 +366,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
      */
     public class ContactAvatarConfig extends AvatarConfig<ContactModel> {
 
+        // TODO(ANDR-4021): This class needs a way to represent the user itself, or there needs to be another AvatarConfig that does that
         private ContactAvatarConfig(@Nullable ContactModel model, @NonNull AvatarOptions options) {
             super(model, options);
         }

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

@@ -27,6 +27,7 @@ import java.util.Date;
 import java.util.List;
 import java.util.Set;
 
+import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
@@ -377,6 +378,13 @@ public interface ContactService extends AvatarService<ContactModel> {
 
     AccessModel getAccess(@Nullable String identity);
 
+    /**
+     * Get the color for the contact's default avatar. This not only depends on the contact itself
+     * but also on the preferences.
+     * @return the color that should be used for the default avatar of the contact
+     */
+    @ColorInt int getAvatarColor(@Nullable ch.threema.data.models.ContactModel contactModel);
+
     void setIsTyping(String identity, boolean isTyping);
 
     boolean isTyping(String identity);
@@ -409,6 +417,13 @@ public interface ContactService extends AvatarService<ContactModel> {
     @Nullable
     ContactMessageReceiver createReceiver(@NonNull String identity);
 
+    /**
+     * Update all contact names from android contacts. This changes the existing contact models if
+     * the contact has a new name in the android address book.
+     *
+     * @return true if the contacts have been tried to update, false if there was an issue with the
+     * permission
+     */
     boolean updateAllContactNamesFromAndroidContacts();
 
     void removeAllSystemContactLinks();
@@ -446,7 +461,7 @@ public interface ContactService extends AvatarService<ContactModel> {
      * @throws MasterKeyLockedException when the master key is locked
      */
     boolean setUserDefinedProfilePicture(
-        @Nullable ContactModel contactModel,
+        @NonNull String identity,
         @Nullable byte[] avatar,
         @NonNull TriggerSource triggerSource
     ) throws IOException, MasterKeyLockedException;
@@ -458,7 +473,7 @@ public interface ContactService extends AvatarService<ContactModel> {
      * @return true if the removal succeeded
      */
     boolean removeUserDefinedProfilePicture(
-        @Nullable ContactModel contactModel,
+        @NonNull String identity,
         @NonNull TriggerSource triggerSource
     );
 

+ 73 - 42
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -65,6 +65,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.AddOrUpdateWorkIdentityBackgroundTask;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
+import ch.threema.app.debug.AndroidContactSyncLogger;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
@@ -110,6 +111,7 @@ import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel;
 import ch.threema.storage.models.GroupMemberModel;
+import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.ValidationMessage;
 import ch.threema.storage.models.access.AccessModel;
 import java8.util.function.Consumer;
@@ -127,6 +129,7 @@ public class ContactServiceImpl implements ContactService {
     private final DatabaseService databaseService;
     private final UserService userService;
     private final IdentityStore identityStore;
+    @NonNull
     private final PreferenceService preferenceService;
     // NOTE: The contact model cache will become unnecessary once everything uses the new data
     // layer, since that data layer has caching built-in.
@@ -181,7 +184,7 @@ public class ContactServiceImpl implements ContactService {
         DatabaseService databaseService,
         UserService userService,
         IdentityStore identityStore,
-        PreferenceService preferenceService,
+        @NonNull PreferenceService preferenceService,
         @NonNull BlockedIdentitiesService blockedIdentitiesService,
         IdListService profilePicRecipientsService,
         FileService fileService,
@@ -860,12 +863,21 @@ public class ContactServiceImpl implements ContactService {
 
     @Override
     public @ColorInt int getAvatarColor(@Nullable ContactModel contact) {
-        if ((this.preferenceService == null || this.preferenceService.isDefaultContactPictureColored()) && contact != null) {
+        if (this.preferenceService.isDefaultContactPictureColored() && contact != null) {
             return contact.getThemedColor(context);
         }
         return ColorUtil.getInstance().getCurrentThemeGray(this.context);
     }
 
+    @Override
+    public @ColorInt int getAvatarColor(@Nullable ch.threema.data.models.ContactModel contactModel) {
+        ContactModelData contactModelData = contactModel != null ? contactModel.getData().getValue() : null;
+        if (this.preferenceService.isDefaultContactPictureColored() && contactModelData != null) {
+            return contactModelData.getThemedColor(context);
+        }
+        return ColorUtil.getInstance().getCurrentThemeGray(this.context);
+    }
+
     @AnyThread
     @Override
     public void loadAvatarIntoImage(
@@ -920,39 +932,36 @@ public class ContactServiceImpl implements ContactService {
             return false;
         }
 
-        // TODO(ANDR-3172): Get all contacts via contact model repository
-        this.getAll().stream()
-            .map(m -> contactModelRepository.getByIdentity(m.getIdentity()))
-            .filter(Objects::nonNull)
+        AndroidContactSyncLogger androidContactSyncLogger = new AndroidContactSyncLogger();
+
+        this.contactModelRepository.getAll()
+            .stream()
             .filter(contactModel -> {
-                ContactModelData data = contactModel.getData().getValue();
-                return data != null && data.isLinkedToAndroidContact();
+                ContactModelData contactModelData = contactModel.getData().getValue();
+                return contactModelData != null && contactModelData.isLinkedToAndroidContact();
             })
             .forEach(contactModel -> {
                 try {
-                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel);
+                    AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel, androidContactSyncLogger);
                 } catch (ThreemaException e) {
                     logger.error("Unable to update contact name", e);
                 }
             });
+
+        androidContactSyncLogger.logDuplicates();
+
         return true;
     }
 
     @Override
     public void removeAllSystemContactLinks() {
-        // TODO(ANDR-3172): Get all contacts via contact model repository
-        this.getAll()
+        this.contactModelRepository.getAll()
             .stream()
-            .filter(ContactModel::isLinkedToAndroidContact)
-            .map(m -> contactModelRepository.getByIdentity(m.getIdentity()))
-            .filter(Objects::nonNull)
-            .forEach(contactModel -> {
-                try {
-                    contactModel.removeAndroidContactLink();
-                } catch (ModelDeletedException e) {
-                    logger.info("Could not set android lookup key as model has been deleted");
-                }
-            });
+            .filter(contactModel -> {
+                ContactModelData contactModelData = contactModel.getData().getValue();
+                return contactModelData != null && contactModelData.isLinkedToAndroidContact();
+            })
+            .forEach(ch.threema.data.models.ContactModel::removeAndroidContactLink);
     }
 
     @Override
@@ -984,17 +993,26 @@ public class ContactServiceImpl implements ContactService {
 
     @Override
     public boolean setUserDefinedProfilePicture(
-        @Nullable final ContactModel contactModel,
+        @NonNull final String identity,
         @Nullable byte[] avatar,
         @NonNull TriggerSource triggerSource
     ) throws IOException, MasterKeyLockedException {
-        if (contactModel != null && avatar != null) {
-            if (this.fileService.writeUserDefinedProfilePicture(contactModel.getIdentity(), avatar)) {
-                if (triggerSource != TriggerSource.SYNC && multiDeviceManager.isMultiDeviceActive()) {
-                    taskCreator.scheduleUserDefinedProfilePictureUpdate(contactModel.getIdentity());
-                }
-                return this.onUserDefinedProfilePictureSet(contactModel);
+        ContactModel contactModel = getByIdentity(identity);
+        if (contactModel == null) {
+            logger.error("Cannot set user defined profile for unknown identity {}", identity);
+            return false;
+        }
+
+        if (avatar == null) {
+            logger.error("Cannot set avatar that is null for identity {}", identity);
+            return false;
+        }
+
+        if (this.fileService.writeUserDefinedProfilePicture(contactModel.getIdentity(), avatar)) {
+            if (triggerSource != TriggerSource.SYNC && multiDeviceManager.isMultiDeviceActive()) {
+                taskCreator.scheduleUserDefinedProfilePictureUpdate(contactModel.getIdentity());
             }
+            return this.onUserDefinedProfilePictureSet(contactModel);
         }
         return false;
     }
@@ -1012,23 +1030,19 @@ public class ContactServiceImpl implements ContactService {
 
     @Override
     public boolean removeUserDefinedProfilePicture(
-        @Nullable final ContactModel contactModel,
+        @NonNull final String identity,
         @NonNull TriggerSource triggerSource
     ) {
-        if (contactModel == null) {
-            logger.warn("Could not remove user defined profile picture as contact model is null");
-            return false;
-        }
-        if (userService.isMe(contactModel.getIdentity())) {
+        if (userService.isMe(identity)) {
             logger.error("The user's profile picture cannot be removed using the contact service");
             return false;
         }
 
-        if (this.fileService.removeUserDefinedProfilePicture(contactModel.getIdentity())) {
+        if (this.fileService.removeUserDefinedProfilePicture(identity)) {
             if (triggerSource != TriggerSource.SYNC && multiDeviceManager.isMultiDeviceActive()) {
-                taskCreator.scheduleUserDefinedProfilePictureUpdate(contactModel.getIdentity());
+                taskCreator.scheduleUserDefinedProfilePictureUpdate(identity);
             }
-            ListenerManager.contactListeners.handle(listener -> listener.onAvatarChanged(contactModel.getIdentity()));
+            ListenerManager.contactListeners.handle(listener -> listener.onAvatarChanged(identity));
             return true;
         }
 
@@ -1145,7 +1159,13 @@ public class ContactServiceImpl implements ContactService {
         // Check if contact is known after contact synchronization (if enabled)
         if (preferenceService.isSyncContacts()) {
             // Synchronize contact
-            SynchronizeContactsUtil.startDirectly(identity);
+            try {
+                SynchronizeContactsUtil.startDirectly(identity);
+            } catch (SecurityException exception) {
+                logger.error("Could not check whether the identity is a known android contact");
+                // We still continue because a missing contacts permission should not prevent
+                // fetching and caching a new contact.
+            }
 
             // Check again locally
             if (contactStore.getContactForIdentity(identity) != null) {
@@ -1366,13 +1386,24 @@ public class ContactServiceImpl implements ContactService {
     @NonNull
     public Set<String> getRemovedContacts() {
         /*
-            SELECT identity FROM contacts AS co WHERE acquaintanceLevel = 1 AND NOT EXISTS (
-                SELECT 1 FROM group_member AS gm WHERE gm.identity = co.identity
+            SELECT identity FROM contacts AS co WHERE acquaintanceLevel = 1 AND (
+                NOT EXISTS (
+                    SELECT 1 FROM group_member AS gm WHERE gm.identity = co.identity
+                ) AND NOT EXISTS (
+                    SELECT 1 FROM m_group AS g WHERE g.creatorIdentity = co.identity
+                )
             );
          */
         final @NonNull String query = "SELECT " + ContactModel.COLUMN_IDENTITY + " FROM " + ContactModel.TABLE + " AS co WHERE "
-            + ContactModel.COLUMN_ACQUAINTANCE_LEVEL + " = " + AcquaintanceLevel.GROUP.ordinal() + " AND NOT EXISTS ("
-            + "SELECT 1 FROM " + GroupMemberModel.TABLE + " AS gm WHERE gm." + GroupMemberModel.COLUMN_IDENTITY + " = co." + ContactModel.COLUMN_IDENTITY + ");";
+            + ContactModel.COLUMN_ACQUAINTANCE_LEVEL + " = " + AcquaintanceLevel.GROUP.ordinal() + " AND ( "
+            + "NOT EXISTS ("
+            + " SELECT 1 FROM " + GroupMemberModel.TABLE + " AS gm WHERE"
+            + " gm." + GroupMemberModel.COLUMN_IDENTITY + " = co." + ContactModel.COLUMN_IDENTITY
+            + " ) AND NOT EXISTS ("
+            + " SELECT 1 FROM " + GroupModel.TABLE + " AS g WHERE"
+            + " g." + GroupModel.COLUMN_CREATOR_IDENTITY + " = co." + ContactModel.COLUMN_IDENTITY
+            + " )"
+            + ");";
         final @Nullable Cursor cursor = this.databaseService
             .getReadableDatabase()
             .rawQuery(query);

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

@@ -118,7 +118,7 @@ public interface ConversationService {
     /**
      * refresh conversation
      */
-    ConversationModel refresh(ContactModel contactModel);
+    ConversationModel refresh(ch.threema.data.models.ContactModel contactModel);
 
     /**
      * refresh conversation
@@ -211,14 +211,6 @@ public interface ConversationService {
      */
     int empty(@NonNull DistributionListModel distributionListModel);
 
-    /**
-     * Delete the contact conversation by removing all messages, setting `lastUpdate` to null
-     * and removing it from the cache.
-     *
-     * @return the number of removed messages.
-     */
-    int delete(@NonNull ContactModel contactModel);
-
     /**
      * Delete the contact conversation by removing all messages, setting `lastUpdate` to null and
      * removing it from the cache.

+ 50 - 34
app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java

@@ -425,9 +425,12 @@ public class ConversationServiceImpl implements ConversationService {
                 ((DistributionListMessageReceiver) messageReceiver).getDistributionList()
             );
         } else if (messageReceiver instanceof ContactMessageReceiver) {
-            new ContactConversationModelParser().markConversationAsRead(
-                ((ContactMessageReceiver) messageReceiver).getContact()
-            );
+            ch.threema.data.models.ContactModel contactModel = ((ContactMessageReceiver) messageReceiver).getContactModel();
+            if (contactModel != null) {
+                new ContactConversationModelParser().markConversationAsRead(contactModel);
+            } else {
+                logger.error("Could not mark conversation as read because the contact model is null");
+            }
         }
     }
 
@@ -458,7 +461,10 @@ public class ConversationServiceImpl implements ConversationService {
     }
 
     @Override
-    public synchronized ConversationModel refresh(ContactModel contactModel) {
+    public synchronized ConversationModel refresh(@Nullable ch.threema.data.models.ContactModel contactModel) {
+        if (contactModel == null) {
+            return null;
+        }
         return new ContactConversationModelParser()
             .refresh(contactModel);
     }
@@ -479,18 +485,7 @@ public class ConversationServiceImpl implements ConversationService {
     public synchronized ConversationModel refresh(@NonNull MessageReceiver receiver) {
         switch (receiver.getType()) {
             case MessageReceiver.Type_CONTACT:
-                // TODO(ANDR-3139): This comparison should be removed once we use the new contact
-                //  model in the webclient. This will allow us to use the new model in the message
-                //  receiver as well.
-                ContactModel providedModel = ((ContactMessageReceiver) receiver).getContact();
-                String identity = providedModel.getIdentity();
-                ContactModel cachedModel = contactService.getByIdentity(identity);
-                if (providedModel != cachedModel) {
-                    logger.warn("Several contact model instances are in use. Resetting cache.");
-                    contactService.invalidateCache(identity);
-                    cachedModel = contactService.getByIdentity(identity);
-                }
-                return this.refresh(cachedModel);
+                return this.refresh(((ContactMessageReceiver) receiver).getContactModel());
             case MessageReceiver.Type_GROUP:
                 return this.refresh(((GroupMessageReceiver) receiver).getGroup());
             case MessageReceiver.Type_DISTRIBUTION_LIST:
@@ -503,7 +498,7 @@ public class ConversationServiceImpl implements ConversationService {
     @Override
     public synchronized ConversationModel setIsTyping(ContactModel contact, boolean isTyping) {
         ContactConversationModelParser p = new ContactConversationModelParser();
-        ConversationModel conversationModel = p.getCached(contact);
+        ConversationModel conversationModel = p.getCached(contact.getIdentity());
         if (conversationModel != null) {
             conversationModel.isTyping = isTyping;
             this.fireOnModifiedConversation(conversationModel);
@@ -631,7 +626,7 @@ public class ConversationServiceImpl implements ConversationService {
 
     @Override
     public synchronized int empty(@NonNull GroupModel groupModel) {
-        final ConversationModel conversationModel = new GroupConversationModelParser().getCached(groupModel);
+        final ConversationModel conversationModel = new GroupConversationModelParser().getCached(groupModel.getId());
         if (conversationModel != null) {
             return this.empty(conversationModel, true);
         }
@@ -641,7 +636,7 @@ public class ConversationServiceImpl implements ConversationService {
 
     @Override
     public synchronized int empty(@NonNull DistributionListModel distributionListModel) {
-        final ConversationModel conversationModel = new DistributionListConversationModelParser().getCached(distributionListModel);
+        final ConversationModel conversationModel = new DistributionListConversationModelParser().getCached(distributionListModel.getId());
         if (conversationModel != null) {
             return this.empty(conversationModel, true);
         }
@@ -649,11 +644,6 @@ public class ConversationServiceImpl implements ConversationService {
         return 0;
     }
 
-    @Override
-    public synchronized int delete(@NonNull ContactModel contactModel) {
-        return delete(contactModel.getIdentity());
-    }
-
     @Override
     public synchronized int delete(@NonNull String identity) {
         // Empty chat (if it isn't already empty)
@@ -676,7 +666,7 @@ public class ConversationServiceImpl implements ConversationService {
     @Override
     public synchronized void removeFromCache(@NonNull GroupModel groupModel) {
         // Remove from cache and notify listeners
-        final ConversationModel conversationModel = new GroupConversationModelParser().getCached(groupModel);
+        final ConversationModel conversationModel = new GroupConversationModelParser().getCached(groupModel.getId());
         if (conversationModel != null) {
             this.removeFromCache(conversationModel);
         }
@@ -685,7 +675,7 @@ public class ConversationServiceImpl implements ConversationService {
     @Override
     public synchronized void removeFromCache(@NonNull DistributionListModel distributionListModel) {
         // Remove from cache and notify listeners
-        final ConversationModel conversationModel = new DistributionListConversationModelParser().getCached(distributionListModel);
+        final ConversationModel conversationModel = new DistributionListConversationModelParser().getCached(distributionListModel.getId());
         if (conversationModel != null) {
             this.removeFromCache(conversationModel);
         }
@@ -738,7 +728,7 @@ public class ConversationServiceImpl implements ConversationService {
         return null;
     }
 
-    private abstract class ConversationModelParser<I, M extends AbstractMessageModel, R extends ReceiverModel> {
+    private abstract class ConversationModelParser<I, M extends AbstractMessageModel, R> {
         /**
          * Return whether the specified identifier belongs to the specified conversation.
          */
@@ -780,11 +770,10 @@ public class ConversationServiceImpl implements ConversationService {
         protected abstract I getIdentifier(R receiverModel);
 
         /**
-         * Return the cached conversation for the specified {@param receiverModel}.
+         * Get the last update flag from the receiver model
          */
-        public final @Nullable ConversationModel getCached(final @NonNull R receiverModel) {
-            return this.getCached(this.getIdentifier(receiverModel));
-        }
+        @Nullable
+        protected abstract Date getLastUpdate(@Nullable R receiverModel);
 
         /**
          * Return the cached conversation for the specified {@param messageModel}.
@@ -875,7 +864,7 @@ public class ConversationServiceImpl implements ConversationService {
                 }
 
                 // Refresh lastUpdate
-                model.lastUpdate = receiverModel.getLastUpdate();
+                model.lastUpdate = getLastUpdate(receiverModel);
 
                 // Refresh notificationTriggerPolicyOverride
                 if (model.isGroupConversation() && receiverModel instanceof GroupModel && model.getGroup() != null) {
@@ -1137,7 +1126,7 @@ public class ConversationServiceImpl implements ConversationService {
         }
     }
 
-    private class ContactConversationModelParser extends ConversationModelParser<String, MessageModel, ContactModel> {
+    private class ContactConversationModelParser extends ConversationModelParser<String, MessageModel, ch.threema.data.models.ContactModel> {
         @Override
         public boolean belongsTo(ConversationModel conversationModel, String identity) {
             return conversationModel.getContact() != null &&
@@ -1223,9 +1212,18 @@ public class ConversationServiceImpl implements ConversationService {
         }
 
         @Override
-        protected String getIdentifier(ContactModel contactModel) {
+        protected String getIdentifier(ch.threema.data.models.ContactModel contactModel) {
             return contactModel != null ? contactModel.getIdentity() : null;
         }
+
+        @Nullable
+        @Override
+        protected Date getLastUpdate(@Nullable ch.threema.data.models.ContactModel receiverModel) {
+            if (receiverModel == null) {
+                return null;
+            }
+            return contactService.getLastUpdate(receiverModel.getIdentity());
+        }
     }
 
     private class GroupConversationModelParser extends ConversationModelParser<Integer, GroupMessageModel, GroupModel> {
@@ -1320,6 +1318,15 @@ public class ConversationServiceImpl implements ConversationService {
         protected Integer getIdentifier(GroupModel groupModel) {
             return groupModel != null ? groupModel.getId() : null;
         }
+
+        @Nullable
+        @Override
+        protected Date getLastUpdate(@Nullable GroupModel receiverModel) {
+            if (receiverModel == null) {
+                return null;
+            }
+            return receiverModel.getLastUpdate();
+        }
     }
 
 
@@ -1412,6 +1419,15 @@ public class ConversationServiceImpl implements ConversationService {
         protected Long getIdentifier(DistributionListModel distributionListModel) {
             return distributionListModel != null ? distributionListModel.getId() : null;
         }
+
+        @Nullable
+        @Override
+        protected Date getLastUpdate(@Nullable DistributionListModel receiverModel) {
+            if (receiverModel == null) {
+                return null;
+            }
+            return receiverModel.getLastUpdate();
+        }
     }
 
 

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

@@ -46,9 +46,9 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ResettableInputStream;
 import ch.threema.base.ThreemaException;
+import ch.threema.data.models.ContactModel;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.data.models.GroupModel;
 
 public interface FileService {
@@ -137,6 +137,8 @@ public interface FileService {
 
     boolean hasContactDefinedProfilePicture(@NonNull String identity);
 
+    boolean hasAndroidDefinedProfilePicture(@NonNull String identity);
+
     /**
      * decrypt a file and save into a new one
      */

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

@@ -36,7 +36,6 @@ import android.os.Build;
 import android.os.Environment;
 import android.provider.MediaStore;
 import android.text.TextUtils;
-import android.text.format.DateUtils;
 import android.view.View;
 import android.webkit.MimeTypeMap;
 import android.widget.Toast;
@@ -108,17 +107,17 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.Base32;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModel;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.data.models.GroupModel;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.MessageType;
 
 import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME;
 import static ch.threema.app.services.MessageServiceImpl.THUMBNAIL_SIZE_PX;
 import static ch.threema.app.utils.StreamUtilKt.orEmpty;
-import static ch.threema.common.TimeExtensionsKt.now;
 
 public class FileServiceImpl implements FileService {
     private static final Logger logger = LoggingUtil.getThreemaLogger("FileServiceImpl");
@@ -455,16 +454,22 @@ public class FileServiceImpl implements FileService {
 
     @Override
     public boolean hasUserDefinedProfilePicture(@NonNull String identity) {
-        File avatar = getContactAvatarFile(identity);
+        File profilePictureFile = getContactAvatarFile(identity);
 
-        return avatar != null && avatar.exists();
+        return profilePictureFile != null && profilePictureFile.exists();
     }
 
     @Override
     public boolean hasContactDefinedProfilePicture(@NonNull String identity) {
-        File avatar = getContactPhotoFile(identity);
+        File profilePictureFile = getContactPhotoFile(identity);
 
-        return avatar != null && avatar.exists();
+        return profilePictureFile != null && profilePictureFile.exists();
+    }
+
+    @Override
+    public boolean hasAndroidDefinedProfilePicture(@NonNull String identity) {
+        File profilePictureFile = getAndroidContactAvatarFile(identity);
+        return profilePictureFile != null && profilePictureFile.exists();
     }
 
     private File getPictureFile(File path, String prefix, String identity) {
@@ -1092,16 +1097,19 @@ public class FileServiceImpl implements FileService {
             throw new MasterKeyLockedException("no masterkey or locked");
         }
 
+        ContactModelData contactModelData = contactModel.getData().getValue();
+        if (contactModelData == null) {
+            logger.error("Contact model data is null");
+            return null;
+        }
+
         long now = System.currentTimeMillis();
-        long expiration = contactModel.getLocalAvatarExpires() != null ? contactModel.getLocalAvatarExpires().getTime() : 0;
+        long expiration = contactModelData.localAvatarExpires != null ? contactModelData.localAvatarExpires.getTime() : 0;
         if (expiration < now) {
             ServiceManager serviceManager = ThreemaApplication.getServiceManager();
             if (serviceManager != null) {
                 try {
-                    if (AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel)) {
-                        ContactService contactService = serviceManager.getContactService();
-                        contactService.save(contactModel);
-                    }
+                    AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel);
                 } catch (SecurityException e) {
                     logger.error("Could not update avatar by android contact", e);
                 }

+ 7 - 5
app/src/main/java/ch/threema/app/services/SynchronizeContactsService.java

@@ -23,18 +23,20 @@ package ch.threema.app.services;
 
 import android.accounts.Account;
 
+import java.util.Set;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 
 public interface SynchronizeContactsService {
+    @Nullable
     SynchronizeContactsRoutine instantiateSynchronization();
 
     boolean instantiateSynchronizationAndRun();
 
-    /**
-     * @param account
-     * @return new instance of a routine
-     */
-    SynchronizeContactsRoutine instantiateSynchronization(Account account);
+    @Nullable
+    SynchronizeContactsRoutine instantiateSynchronization(@NonNull Set<String> processingIdentities);
 
     boolean isSynchronizationInProgress();
 

+ 46 - 54
app/src/main/java/ch/threema/app/services/SynchronizeContactsServiceImpl.java

@@ -30,11 +30,13 @@ 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.collections.Functional;
-import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.preference.service.PreferenceService;
@@ -112,40 +114,42 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
                     ListenerManager.newSyncedContactListener.handle(listener -> listener.onNew(createdContacts))
                 );
 
-                new Thread(new Runnable() {
-                    @Override
-                    public void run() {
+                new Thread(() -> {
+                    try {
                         sync.run();
+                    } catch (SecurityException exception) {
+                        logger.error("Could not run contact sync", exception);
+                        return;
+                    }
 
-                        //get all business accounts
-                        //disable contact changed event handler
-                        boolean enableState = ListenerManager.contactListeners.isEnabled();
-                        try {
-                            if (enableState) {
-                                ListenerManager.contactListeners.enabled(false);
-                            }
+                    //get all business accounts
+                    //disable contact changed event handler
+                    boolean enableState = ListenerManager.contactListeners.isEnabled();
+                    try {
+                        if (enableState) {
+                            ListenerManager.contactListeners.enabled(false);
+                        }
 
-                            for (ContactModel contactModel : contactService.getAll()) {
-                                if (ContactUtil.isGatewayContact(contactModel)) {
-                                    ch.threema.data.models.ContactModel model = contactModelRepository.getByIdentity(contactModel.getIdentity());
-                                    if (model == null) {
-                                        logger.error("Could not get contact model with identity {}", contactModel.getIdentity());
-                                        continue;
-                                    }
-                                    UpdateBusinessAvatarRoutine.start(model, fileService, contactService, apiService, true);
+                        for (ContactModel contactModel : contactService.getAll()) {
+                            if (ContactUtil.isGatewayContact(contactModel)) {
+                                ch.threema.data.models.ContactModel model = contactModelRepository.getByIdentity(contactModel.getIdentity());
+                                if (model == null) {
+                                    logger.error("Could not get contact model with identity {}", contactModel.getIdentity());
+                                    continue;
                                 }
+                                UpdateBusinessAvatarRoutine.start(model, fileService, contactService, apiService, true);
                             }
-                            //fore update business account avatars
-                        } catch (Exception x) {
-                            //log exception and ignore
-                            logger.error("Ignoring exception", x);
-                        } finally {
-                            //enable contact listener again
-                            ListenerManager.contactListeners.enabled(enableState);
                         }
+                        //fore update business account avatars
+                    } catch (Exception x) {
+                        //log exception and ignore
+                        logger.error("Ignoring exception", x);
+                    } finally {
+                        //enable contact listener again
+                        ListenerManager.contactListeners.enabled(enableState);
+                    }
 
 
-                    }
                 }, "SynchronizeContactsRoutine").start();
                 return true;
             } else {
@@ -156,19 +160,21 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
     }
 
     @Override
+    @Nullable
     public SynchronizeContactsRoutine instantiateSynchronization() {
-        Account account = this.userService.getAccount();
-        if (account != null) {
-            return this.instantiateSynchronization(account);
-        }
-        return null;
+        return this.instantiateSynchronization(Collections.emptySet());
     }
 
-
     @Override
-    public SynchronizeContactsRoutine instantiateSynchronization(Account account) {
+    @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");
-        logger.debug("instantiateSynchronization with account {}", account);
 
         final SynchronizeContactsRoutine routine =
             new SynchronizeContactsRoutine(
@@ -183,26 +189,17 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
                 this.deviceService,
                 this.preferenceService,
                 this.identityStore,
-                this.blockedIdentitiesService
+                this.blockedIdentitiesService,
+                processingIdentities
             );
 
         synchronized (this.pendingRoutines) {
             this.pendingRoutines.add(routine);
         }
 
-        routine.addOnFinished(new SynchronizeContactsRoutine.OnFinished() {
-            @Override
-            public void finished(boolean success, long modifiedAccounts, List<ch.threema.data.models.ContactModel> createdContacts, long deletedAccounts) {
-                finishedRoutine(routine);
-            }
-        });
+        routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> finishedRoutine(routine));
 
-        ListenerManager.synchronizeContactsListeners.handle(new ListenerManager.HandleListener<SynchronizeContactsListener>() {
-            @Override
-            public void handle(SynchronizeContactsListener listener) {
-                listener.onStarted(routine);
-            }
-        });
+        ListenerManager.synchronizeContactsListeners.handle(listener -> listener.onStarted(routine));
 
         return routine;
     }
@@ -215,12 +212,7 @@ public class SynchronizeContactsServiceImpl implements SynchronizeContactsServic
     @Override
     public boolean isFullSyncInProgress() {
         synchronized (this.pendingRoutines) {
-            return Functional.select(this.pendingRoutines, new IPredicateNonNull<SynchronizeContactsRoutine>() {
-                @Override
-                public boolean apply(@NonNull SynchronizeContactsRoutine routine) {
-                    return routine != null && routine.running() && routine.fullSync();
-                }
-            }) != null;
+            return Functional.select(this.pendingRoutines, routine -> routine.running() && routine.isFullSync()) != null;
         }
     }
 

+ 4 - 9
app/src/main/java/ch/threema/app/systemupdates/updates/SystemUpdateToVersion12.java

@@ -38,21 +38,16 @@ public class SystemUpdateToVersion12 implements SystemUpdate {
 
     @Override
     public void run() {
-        SynchronizeContactsUtil.startDirectly();
-
-        ContactService contactService;
         try {
-            contactService = serviceManager.getContactService();
-        } catch (MasterKeyLockedException e) {
-            throw new RuntimeException("Failed to get contact service", e);
+            SynchronizeContactsUtil.startDirectly();
+        } catch (SecurityException exception) {
+            // Nothing to do
         }
 
         PreferenceService preferenceService = serviceManager.getPreferenceService();
         if (preferenceService.isSyncContacts()) {
             UserService userService = serviceManager.getUserService();
-            if (userService != null) {
-                userService.enableAccountAutoSync(true);
-            }
+            userService.enableAccountAutoSync(true);
         }
     }
 

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

@@ -858,7 +858,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
             String profilePic = user.optString(TAG_SAFE_USER_PROFILE_PIC, null);
             if (profilePic != null) {
                 try {
-                    contactService.setUserDefinedProfilePicture(contactModel, Base64.decode(profilePic), TriggerSource.LOCAL);
+                    contactService.setUserDefinedProfilePicture(identity, Base64.decode(profilePic), TriggerSource.LOCAL);
                 } catch (Exception e) {
                     // base 64 decoding or avatar setting failed - forget about the pic
                 }

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

@@ -400,7 +400,7 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
                             userService.removeUserProfilePicture(TriggerSource.LOCAL);
                         } else {
                             contactService.removeUserDefinedProfilePicture(
-                                avatarData.getContactModel(), TriggerSource.LOCAL
+                                contactModel.getIdentity(), TriggerSource.LOCAL
                             );
                         }
                     } else if (avatarData.getGroupModel() != null) {

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

@@ -91,7 +91,7 @@ public class MentionSpan extends ReplacementSpan {
 
     @NonNull
     private String getMentionLabelText(CharSequence text, int start, int end) {
-        final String identity = text.subSequence(start + 2, end - 1).toString();
+        final String identity = text.toString().substring(start + 2, end - 1);
 
         String label = NameUtil.getQuoteName(identity, this.contactService, this.userService);
         if (label.length() > LABEL_MAX_LENGTH) {

+ 54 - 133
app/src/main/java/ch/threema/app/utils/AndroidContactUtil.java

@@ -63,13 +63,14 @@ import java.util.regex.PatternSyntaxException;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.debug.AndroidContactSyncLogger;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.UserService;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModel;
 import ch.threema.data.models.ContactModelData;
-import ch.threema.storage.models.ContactModel;
 
 public class AndroidContactUtil {
     private static final Logger logger = LoggingUtil.getThreemaLogger("AndroidContactUtil");
@@ -153,39 +154,7 @@ public class AndroidContactUtil {
      * @return a valid uri pointing to the android contact or null if permission was not granted, no android contact is linked or android contact could not be looked up
      */
     @Nullable
-    public Uri getAndroidContactUri(@Nullable ContactModel contactModel) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
-            ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
-            return null;
-        }
-
-        if (contactModel != null) {
-            final String androidContactLookupKey = contactModel.getAndroidContactLookupKey();
-            if (androidContactLookupKey != null) {
-                Uri contactLookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, androidContactLookupKey);
-                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) {
-                    try {
-                        contactLookupUri = ContactsContract.Contacts.lookupContact(contentResolver, contactLookupUri);
-                    } catch (Exception e) {
-                        logger.error("Could not lookup the contact with identity {}", contactModel.getIdentity(), e);
-                        return null;
-                    }
-                }
-                return contactLookupUri;
-            }
-        }
-        return null;
-    }
-
-    /**
-     * 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
-     *
-     * @param contactModel ContactModel for which to get the Android contact URI
-     * @return a valid uri pointing to the android contact or null if permission was not granted, no android contact is linked or android contact could not be looked up
-     */
-    @Nullable
-    public Uri getAndroidContactUri(@NonNull ch.threema.data.models.ContactModel contactModel) {
+    public Uri getAndroidContactUri(@NonNull ContactModel contactModel) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
             ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
             return null;
@@ -218,58 +187,9 @@ public class AndroidContactUtil {
      * It is safe to call this method even if permission to read contacts is not given
      *
      * @param contactModel ContactModel
-     * @return true if setting or deleting the avatar was successful, false otherwise
-     */
-    @RequiresPermission(Manifest.permission.READ_CONTACTS)
-    public boolean updateAvatarByAndroidContact(@NonNull ContactModel contactModel) {
-        if (fileService == null) {
-            logger.info("FileService not available");
-            return false;
-        }
-
-        final String androidContactLookupKey = contactModel.getAndroidContactLookupKey();
-        if (androidContactLookupKey == null) {
-            return false;
-        }
-
-        // contactUri will be null if permission is not granted
-        Uri contactUri = getAndroidContactUri(contactModel);
-        if (contactUri != null) {
-            Bitmap bitmap = AvatarConverterUtil.convert(ThreemaApplication.getAppContext(), contactUri);
-
-            if (bitmap != null) {
-                try {
-                    fileService.writeAndroidDefinedProfilePicture(contactModel.getIdentity(), BitmapUtil.bitmapToByteArray(bitmap, Bitmap.CompressFormat.PNG, 100));
-                    contactModel.setLocalAvatarExpires(new Date(System.currentTimeMillis() + DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY));
-                    return true;
-                } catch (Exception e) {
-                    logger.error("Could not write android contact avatar of contact {}", contactModel.getIdentity(), e);
-                }
-            } else {
-                // delete old avatar
-                boolean success = fileService.removeAndroidDefinedProfilePicture(contactModel.getIdentity());
-                if (success) {
-                    contactModel.setLocalAvatarExpires(new Date(System.currentTimeMillis() + DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY));
-                    return true;
-                }
-            }
-        }
-
-        logger.debug("Unable to get avatar for {} lookupKey = {} contactUri = {}", contactModel.getIdentity(), androidContactLookupKey, contactUri);
-        return false;
-    }
-
-    /**
-     * Update the avatar for the specified contact from Android's contact database, if any.
-     * If there's no avatar for this Android contact, any current avatar file will be deleted.
-     * <p>
-     * It is safe to call this method even if permission to read contacts is not given
-     *
-     * @param contactModel ContactModel
-     * @return true if setting or deleting the avatar was successful, false otherwise
      */
     @RequiresPermission(Manifest.permission.READ_CONTACTS)
-    public void updateAvatarByAndroidContact(@NonNull ch.threema.data.models.ContactModel contactModel) {
+    public void updateAvatarByAndroidContact(@NonNull ContactModel contactModel) {
         if (fileService == null) {
             logger.info("FileService not available");
             return;
@@ -283,6 +203,7 @@ public class AndroidContactUtil {
 
         final String androidContactLookupKey = data.androidContactLookupKey;
         if (androidContactLookupKey == null) {
+            logger.warn("Cannot update avatar of contact {} because there is no lookup key", contactModel.getIdentity());
             return;
         }
 
@@ -301,6 +222,10 @@ public class AndroidContactUtil {
                 }
             } else {
                 // delete old avatar
+                if (!fileService.hasAndroidDefinedProfilePicture(contactModel.getIdentity())) {
+                    return;
+                }
+                logger.info("Removing android defined profile picture for identity {}", contactModel.getIdentity());
                 boolean success = fileService.removeAndroidDefinedProfilePicture(contactModel.getIdentity());
                 if (success) {
                     contactModel.setLocalAvatarExpires(new Date(System.currentTimeMillis() + DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY));
@@ -316,28 +241,41 @@ public class AndroidContactUtil {
      * Update the name of this contact according to the name of the Android contact
      *
      * @param contactModel ContactModel
+     * @param androidContactSyncLogger this logger can be provided to log duplicate contact names
      */
     @RequiresPermission(Manifest.permission.READ_CONTACTS)
-    public void updateNameByAndroidContact(@NonNull ch.threema.data.models.ContactModel contactModel) throws ThreemaException {
+    public void updateNameByAndroidContact(
+        @NonNull ContactModel contactModel,
+        @Nullable AndroidContactSyncLogger androidContactSyncLogger
+    ) throws ThreemaException {
         ContactModelData data = contactModel.getData().getValue();
         if (data == null) {
             logger.warn("Contact model data is null");
             return;
         }
         Uri namedContactUri = getAndroidContactUri(contactModel);
-        if (namedContactUri != null) {
-            ContactName contactName = getContactName(namedContactUri);
-
-            if (contactName == null) {
-                logger.info("Unable to get contact name for {} lookupKey = {} namedUri = {}", contactModel.getIdentity(), data.androidContactLookupKey, namedContactUri);
-                // remove contact link to unresolvable contact
-                contactModel.removeAndroidContactLink();
-                throw new ThreemaException("Unable to get contact name");
-            }
+        if (namedContactUri == null) {
+            logger.info("Unable to get android contact uri for {} lookupKey = {}", contactModel.getIdentity(), data.androidContactLookupKey);
+            return;
+        }
 
+        ContactName contactName = getContactName(namedContactUri);
+
+        if (contactName == null) {
+            logger.info("Unable to get contact name for {} lookupKey = {} namedUri = {}", contactModel.getIdentity(), data.androidContactLookupKey, namedContactUri);
+            // remove contact link to unresolvable contact
+            contactModel.removeAndroidContactLink();
+            throw new ThreemaException("Unable to get contact name");
+        }
+
+        if (androidContactSyncLogger != null) {
+            androidContactSyncLogger.addSyncedNames(namedContactUri, contactName.firstName, contactName.lastName);
+        }
+
+        if (!data.firstName.equals(contactName.firstName) || !data.lastName.equals(contactName.lastName)) {
+            logger.info("Updating name of contact. identity={}, lookupKey={}, namedUri={}",
+                contactModel.getIdentity(), data.androidContactLookupKey, namedContactUri);
             contactModel.setNameFromLocal(contactName.firstName, contactName.lastName);
-        } else {
-            logger.info("Unable to get android contact uri for {} lookupKey = {}", contactModel.getIdentity(), data.androidContactLookupKey);
         }
     }
 
@@ -501,7 +439,8 @@ public class AndroidContactUtil {
     public void createThreemaRawContact(@NonNull List<ContentProviderOperation> contentProviderOperations, long systemRawContactId, @NonNull String identity, boolean supportsVoiceCalls) {
         Context context = ThreemaApplication.getAppContext();
         Account account = this.getAccount();
-        if (!TestUtil.required(account, identity)) {
+        if (account == null) {
+            logger.error("Cannot create threema raw contact as the account is null");
             return;
         }
 
@@ -510,7 +449,7 @@ public class AndroidContactUtil {
         }
 
         int backReference = contentProviderOperations.size();
-        logger.debug("Adding contact: " + identity);
+        logger.debug("Adding contact: {}", identity);
 
         /* Create our RawContact */
         ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI);
@@ -701,9 +640,7 @@ public class AndroidContactUtil {
             .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();
 
         ListMultimap<String, RawContactInfo> rawContacts = ArrayListMultimap.create();
-        Cursor cursor = null;
-        try {
-            cursor = contentResolver.query(rawContactUri, RAW_CONTACT_PROJECTION, null, null, null);
+        try (Cursor cursor = contentResolver.query(rawContactUri, RAW_CONTACT_PROJECTION, null, null, null)) {
             if (cursor != null) {
                 while (cursor.moveToNext()) {
                     long rawContactId = cursor.getLong(0);
@@ -714,10 +651,6 @@ public class AndroidContactUtil {
             }
         } catch (Exception e) {
             logger.error("Exception", e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
         }
         return rawContacts;
     }
@@ -734,26 +667,20 @@ public class AndroidContactUtil {
         long rawContactId = 0;
         Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey);
 
-        Cursor cursor = null;
-        try {
-            cursor = ThreemaApplication.getAppContext().getContentResolver().query(
-                lookupUri,
-                new String[]{
-                    ContactsContract.Contacts.NAME_RAW_CONTACT_ID
-                },
-                null,
-                null,
-                null);
+        try (Cursor cursor = ThreemaApplication.getAppContext().getContentResolver().query(
+            lookupUri,
+            new String[]{
+                ContactsContract.Contacts.NAME_RAW_CONTACT_ID
+            },
+            null,
+            null,
+            null)) {
 
             if (cursor != null) {
                 if (cursor.moveToFirst()) {
                     rawContactId = cursor.getLong(0);
                 }
             }
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
         }
 
         return rawContactId;
@@ -785,13 +712,11 @@ public class AndroidContactUtil {
         Drawable drawable = null;
 
         Uri nameSourceRawContactUri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, nameSourceRawContactId);
-        Cursor cursor = null;
-        try {
-            cursor = this.contentResolver.query(
-                nameSourceRawContactUri,
-                new String[]{
-                    ContactsContract.RawContacts.ACCOUNT_TYPE
-                }, null, null, null);
+        try (Cursor cursor = this.contentResolver.query(
+            nameSourceRawContactUri,
+            new String[]{
+                ContactsContract.RawContacts.ACCOUNT_TYPE
+            }, null, null, null)) {
             if (cursor != null) {
                 if (cursor.moveToNext()) {
                     String accountType = cursor.getString(0);
@@ -803,10 +728,6 @@ public class AndroidContactUtil {
                     }
                 }
             }
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
         }
 
         //if no icon found, display the icon of the phone or contacts app
@@ -831,11 +752,11 @@ public class AndroidContactUtil {
      * Open the system's contact editor for the provided Threema contact
      *
      * @param context Context
-     * @param contact Threema contact
+     * @param contactModel Threema contact
      * @return true if the contact is linked with a system contact (even if no app is available for an ACTION_EDIT intent in the system), false otherwise
      */
-    public boolean openContactEditor(Context context, ContactModel contact, int requestCode) {
-        Uri contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contact);
+    public boolean openContactEditor(Context context, ContactModel contactModel, int requestCode) {
+        Uri contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contactModel);
 
         if (contactUri != null) {
             Intent intent = new Intent(Intent.ACTION_EDIT);

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

@@ -29,7 +29,6 @@ import java.util.EnumMap;
 import java.util.Map;
 
 import androidx.annotation.ColorInt;
-import androidx.annotation.ColorRes;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
@@ -85,7 +84,7 @@ public class StateBitmapUtil {
         this.warningColor = context.getResources().getColor(R.color.material_red);
     }
 
-    @ColorRes
+    @ColorInt
     public int getWarningColor() {
         return warningColor;
     }

+ 34 - 24
app/src/main/java/ch/threema/app/utils/SynchronizeContactsUtil.java

@@ -21,16 +21,21 @@
 
 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.preference.service.PreferenceService;
 import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
@@ -38,30 +43,37 @@ import ch.threema.localcrypto.MasterKeyLockedException;
 public class SynchronizeContactsUtil {
     private static final Logger logger = LoggingUtil.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) {
-            routine.run();
+        if (routine == null) {
+            logger.error("Could not start synchronize contacts routine directly");
+            return;
         }
+        routine.run();
     }
 
-    public static void startDirectly(String forIdentity) {
-        logger.info("Starting single contact sync for identity {}", forIdentity);
-        SynchronizeContactsRoutine routine = getSynchronizeContactsRoutine();
-        if (routine != null) {
-            routine.addProcessIdentity(forIdentity);
-            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 = ThreemaApplication.getServiceManager();
         if (serviceManager == null) {
+            logger.error("Cannot get synchronize contacts service as service manager is null");
             return null;
         }
         try {
-            SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-            return synchronizeContactsService;
+            return serviceManager.getSynchronizeContactsService();
         } catch (MasterKeyLockedException e) {
             //do nothing
             logger.error("Exception", e);
@@ -70,23 +82,24 @@ public class SynchronizeContactsUtil {
         return null;
     }
 
+    @Nullable
     private static SynchronizeContactsRoutine getSynchronizeContactsRoutine() {
+        return getSynchronizeContactsRoutine(Set.of());
+    }
+
+    @Nullable
+    private static SynchronizeContactsRoutine getSynchronizeContactsRoutine(Set<String> identities) {
         ServiceManager serviceManager = ThreemaApplication.getServiceManager();
         if (serviceManager == null) {
+            logger.error("Cannot get synchronize contacts routine as service manager is unavailable");
             return null;
         }
 
-        PreferenceService preferenceService = serviceManager.getPreferenceService();
-        if (preferenceService == null) {
-            return null;
-        }
-
-
-        if (preferenceService.isSyncContacts()) {
+        if (serviceManager.getPreferenceService().isSyncContacts()) {
             SynchronizeContactsService synchronizeContactsService = getSynchronizeContactsService();
 
             if (synchronizeContactsService != null) {
-                return synchronizeContactsService.instantiateSynchronization();
+                return synchronizeContactsService.instantiateSynchronization(identities);
             }
         }
 
@@ -96,10 +109,7 @@ public class SynchronizeContactsUtil {
     public static boolean isRestrictedProfile(Context context) {
         UserManager um = (UserManager) context.getSystemService(Context.USER_SERVICE);
         Bundle restrictions = um.getUserRestrictions();
-        if (restrictions.getBoolean(UserManager.DISALLOW_MODIFY_ACCOUNTS, false)) {
-            // cannot add accounts or modify sync profiles
-            return true;
-        }
-        return false;
+        // cannot add accounts or modify sync profiles
+        return restrictions.getBoolean(UserManager.DISALLOW_MODIFY_ACCOUNTS, false);
     }
 }

+ 2 - 1
app/src/main/java/ch/threema/app/webclient/services/instance/SessionInstanceServiceImpl.java

@@ -473,7 +473,8 @@ public class SessionInstanceServiceImpl implements SessionInstanceService {
 
         updateDispatcher.addReceiver(new ModifyContactHandler(
             updateDispatcher,
-            services.contact
+            services.contact,
+            services.contactModelRepository
         ));
         updateDispatcher.addReceiver(new ModifyGroupHandler(
             updateDispatcher,

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

@@ -99,7 +99,7 @@ public class CleanReceiverConversationRequestHandler extends MessageReceiver {
             ConversationModel conversationModel = null;
             switch (receiver.getType()) {
                 case ContactMessageReceiver.Type_CONTACT:
-                    conversationModel = this.conversationService.refresh(((ContactMessageReceiver) receiver).getContact());
+                    conversationModel = this.conversationService.refresh(((ContactMessageReceiver) receiver).getContactModel());
                     this.conversationService.empty(conversationModel, true);
                     break;
                 case ContactMessageReceiver.Type_GROUP:

+ 121 - 55
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ModifyContactHandler.java

@@ -33,6 +33,8 @@ import java.lang.annotation.RetentionPolicy;
 import java.util.Map;
 
 import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.StringDef;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.dialogs.ContactEditDialog;
@@ -46,8 +48,10 @@ import ch.threema.app.webclient.exceptions.ConversionException;
 import ch.threema.app.webclient.services.instance.MessageDispatcher;
 import ch.threema.app.webclient.services.instance.MessageReceiver;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModel;
+import ch.threema.data.models.ContactModelData;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.taskmanager.TriggerSource;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -73,15 +77,23 @@ public class ModifyContactHandler extends MessageReceiver {
     private @interface ErrorCode {
     }
 
+    @NonNull
     private final MessageDispatcher dispatcher;
+    @NonNull
     private final ContactService contactService;
+    @NonNull
+    private final ContactModelRepository contactModelRepository;
 
     @AnyThread
-    public ModifyContactHandler(MessageDispatcher dispatcher,
-                                ContactService contactService) {
+    public ModifyContactHandler(
+        @NonNull MessageDispatcher dispatcher,
+        @NonNull ContactService contactService,
+        @NonNull ContactModelRepository contactModelRepository
+    ) {
         super(Protocol.SUB_TYPE_CONTACT);
         this.dispatcher = dispatcher;
         this.contactService = contactService;
+        this.contactModelRepository = contactModelRepository;
     }
 
     @Override
@@ -98,88 +110,142 @@ public class ModifyContactHandler extends MessageReceiver {
         final String identity = args.get(Protocol.ARGUMENT_IDENTITY).asStringValue().toString();
         final String temporaryId = args.get(Protocol.ARGUMENT_TEMPORARY_ID).asStringValue().toString();
 
-        // TODO(ANDR-3139): Use new contact model
         // Validate identity
-        final ContactModel contactModel = this.contactService.getByIdentity(identity);
-        if (contactModel == null) {
+        final ContactModel contactModel = contactModelRepository.getByIdentity(identity);
+        final ContactModelData contactModelData = contactModel != null ? contactModel.getData().getValue() : null;
+        if (contactModelData == null) {
             this.failed(identity, temporaryId, Protocol.ERROR_INVALID_CONTACT);
             return;
         }
-        if (contactModel.isLinkedToAndroidContact()) {
+        if (contactModelData.isLinkedToAndroidContact()) {
             this.failed(identity, temporaryId, Protocol.ERROR_NOT_ALLOWED_LINKED);
             return;
         }
 
-        contactModel.setAcquaintanceLevel(AcquaintanceLevel.DIRECT);
+        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT);
 
         // Process data
         final Map<String, Value> data = this.getData(message, false);
 
-        if (data.containsKey(FIELD_FIRST_NAME)) {
-            final String firstName = this.getValueString(data.get(FIELD_FIRST_NAME));
+        String nameErrorCode = updateNameFromData(contactModel, data);
+        if (nameErrorCode != null) {
+            failed(identity, temporaryId, nameErrorCode);
+            return;
+        }
+
+        String profilePictureErrorCode = updateProfilePictureFromData(contactModel, data);
+        if (profilePictureErrorCode != null) {
+            failed(identity, temporaryId, profilePictureErrorCode);
+            return;
+        }
+
+        contactService.invalidateCache(identity);
+
+        // Return updated contact
+        this.success(identity, temporaryId, contactService.getByIdentity(identity));
+    }
+
+    /**
+     * Update the contact's name from the given data. If an error occurs, the error message is
+     * returned. If null is returned, the name has been successfully updated or no update was
+     * needed.
+     */
+    @Nullable
+    private String updateNameFromData(@NonNull ContactModel contactModel, @NonNull Map<String, Value> data) {
+        boolean firstNameChanged = data.containsKey(FIELD_FIRST_NAME);
+        boolean lastNameChanged = data.containsKey(FIELD_LAST_NAME);
+
+        String firstName;
+        String lastName;
+
+        if (firstNameChanged) {
+            logger.info("First name is set");
+            firstName = this.getValueString(data.get(FIELD_FIRST_NAME));
             if (firstName.getBytes(UTF_8).length > Protocol.LIMIT_BYTES_FIRST_NAME) {
-                this.failed(identity, temporaryId, Protocol.ERROR_VALUE_TOO_LONG);
-                return;
+                return Protocol.ERROR_VALUE_TOO_LONG;
             }
-            contactModel.setFirstName(firstName);
+        } else {
+            ContactModelData contactModelData = contactModel.getData().getValue();
+            if (contactModelData == null) {
+                logger.error("Cannot get existing first name as the contact model data is null");
+                return Protocol.ERROR_INTERNAL;
+            }
+            firstName = contactModelData.firstName;
         }
-        if (data.containsKey(FIELD_LAST_NAME)) {
-            final String lastName = this.getValueString(data.get(FIELD_LAST_NAME));
+        if (lastNameChanged) {
+            logger.info("Last name is set");
+            lastName = this.getValueString(data.get(FIELD_LAST_NAME));
             if (lastName.getBytes(UTF_8).length > Protocol.LIMIT_BYTES_LAST_NAME) {
-                this.failed(identity, temporaryId, Protocol.ERROR_VALUE_TOO_LONG);
-                return;
+                return Protocol.ERROR_VALUE_TOO_LONG;
+            }
+        } else {
+            ContactModelData contactModelData = contactModel.getData().getValue();
+            if (contactModelData == null) {
+                logger.error("Cannot get existing last name as the contact model data is null");
+                return Protocol.ERROR_INTERNAL;
             }
-            contactModel.setLastName(lastName);
+            lastName = contactModelData.lastName;
+        }
+
+        contactModel.setNameFromLocal(firstName, lastName);
+
+        return null;
+    }
+
+    /**
+     * Update the contact's profile picture from the given data. If an error occurs, the error
+     * message is returned. If null is returned, the profile picture has been successfully updated
+     * or no update was needed.
+     */
+    private String updateProfilePictureFromData(@NonNull ContactModel contactModel, @NonNull Map<String, Value> data) {
+        if (!data.containsKey(Protocol.ARGUMENT_AVATAR)) {
+            logger.info("Profile picture hasn't been changed");
+            return null;
+        }
+
+        if (ContactUtil.isGatewayContact(contactModel.getIdentity())) {
+            return Protocol.ERROR_NOT_ALLOWED_BUSINESS;
         }
 
-        // Update avatar
-        if (data.containsKey(Protocol.ARGUMENT_AVATAR)) {
-            if (ContactUtil.isGatewayContact(contactModel)) {
-                this.failed(identity, temporaryId, Protocol.ERROR_NOT_ALLOWED_BUSINESS);
-                return;
+        try {
+            final Value avatarValue = data.get(Protocol.ARGUMENT_AVATAR);
+            if (avatarValue == null || avatarValue.isNilValue()) {
+                // Clear avatar
+                this.contactService.removeUserDefinedProfilePicture(contactModel.getIdentity(), TriggerSource.LOCAL);
             } else {
-                try {
-                    final Value avatarValue = data.get(Protocol.ARGUMENT_AVATAR);
-                    if (avatarValue == null || avatarValue.isNilValue()) {
-                        // Clear avatar
-                        this.contactService.removeUserDefinedProfilePicture(contactModel, TriggerSource.LOCAL);
-                    } else {
-                        // Set avatar
-                        final byte[] bmp = avatarValue.asBinaryValue().asByteArray();
-                        if (bmp.length > 0) {
-                            Bitmap avatar = BitmapFactory.decodeByteArray(bmp, 0, bmp.length);
-                            // Resize to max allowed size
-                            avatar = BitmapUtil.resizeBitmap(avatar,
-                                ContactEditDialog.CONTACT_AVATAR_WIDTH_PX,
-                                ContactEditDialog.CONTACT_AVATAR_HEIGHT_PX);
-                            this.contactService.setUserDefinedProfilePicture(
-                                contactModel,
-                                // Without quality loss
-                                BitmapUtil.bitmapToByteArray(avatar, Bitmap.CompressFormat.PNG,
-                                    100),
-                                TriggerSource.LOCAL
-                            );
-                        }
+                // Set avatar
+                final byte[] bmp = avatarValue.asBinaryValue().asByteArray();
+                if (bmp.length > 0) {
+                    Bitmap avatar = BitmapFactory.decodeByteArray(bmp, 0, bmp.length);
+                    // Resize to max allowed size
+                    avatar = BitmapUtil.resizeBitmap(avatar,
+                        ContactEditDialog.CONTACT_AVATAR_WIDTH_PX,
+                        ContactEditDialog.CONTACT_AVATAR_HEIGHT_PX);
+                    boolean success = this.contactService.setUserDefinedProfilePicture(
+                        contactModel.getIdentity(),
+                        // Without quality loss
+                        BitmapUtil.bitmapToByteArray(avatar, Bitmap.CompressFormat.PNG,
+                            100),
+                        TriggerSource.LOCAL
+                    );
+                    if (!success) {
+                        logger.error("Failed to set profile picture");
+                        return Protocol.ERROR_INTERNAL;
                     }
-                } catch (Exception e) {
-                    logger.error("Failed to save avatar", e);
-                    this.failed(identity, temporaryId, Protocol.ERROR_INTERNAL);
-                    return;
                 }
             }
+        } catch (Exception e) {
+            logger.error("Error while setting profile picture", e);
+            return Protocol.ERROR_INTERNAL;
         }
 
-        // Save the contact model
-        this.contactService.save(contactModel);
-
-        // Return updated contact
-        this.success(identity, temporaryId, contactModel);
+        return null;
     }
 
     /**
      * Respond with the modified contact model.
      */
-    private void success(String threemaId, String temporaryId, ContactModel contact) {
+    private void success(String threemaId, String temporaryId, ch.threema.storage.models.ContactModel contact) {
         logger.debug("Respond modify contact success");
         try {
             this.send(this.dispatcher,

+ 12 - 0
app/src/main/java/ch/threema/data/models/ContactModel.kt

@@ -21,6 +21,7 @@
 
 package ch.threema.data.models
 
+import android.content.Context
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.managers.ListenerManager
@@ -30,6 +31,7 @@ import ch.threema.app.services.DeadlineListService.DEADLINE_INDEFINITE_EXCEPT_ME
 import ch.threema.app.tasks.ReflectContactSyncUpdateImmediateTask
 import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.utils.ColorUtil
+import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.runtimeAssert
 import ch.threema.base.utils.LoggingUtil
@@ -220,6 +222,16 @@ data class ContactModelData(
      */
     fun colorIndexInt(): Int = colorIndex.toInt()
 
+    fun getThemedColor(context: Context): Int = if (ConfigUtils.isTheDarkSide(context)) {
+        getColorDark()
+    } else {
+        getColorLight()
+    }
+
+    private fun getColorDark(): Int = ColorUtil.getInstance().getIDColorDark(colorIndex.toInt())
+
+    private fun getColorLight(): Int = ColorUtil.getInstance().getIDColorLight(colorIndex.toInt())
+
     /**
      * Return the [featureMask] as [BigInteger].
      */

+ 1 - 1
app/src/main/res/values-ru/strings.xml

@@ -214,7 +214,7 @@
     <string name="enter_code_sum">Введите код, который был указан в SMS или продиктован в телефонном звонке.</string>
     <string name="no_matching_contacts">Контакты не найдены.</string>
     <string name="code_invalid">Введён неправильный код.</string>
-    <string name="try_again">Попробуйте ещё раз</string>
+    <string name="try_again">Попробовать ещё раз</string>
     <string name="decoding_message">Расшифровка сообщения</string>
     <string name="invalid_barcode">Неправильный тип штрих-кода</string>
     <string name="expired_barcode">Срок действия кода истёк. Отсканируйте его ещё раз непосредственно из приложения другого пользователя.</string>

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

@@ -189,8 +189,7 @@
 
     <style name="Threema.AlertDialogStyle" parent="@style/ThemeOverlay.Material3.MaterialAlertDialog">
         <item name="colorControlActivated">?attr/colorPrimary</item>
-        <item name="backgroundTint">?attr/colorSurfaceContainerLow</item>
-        <item name="android:background">?attr/colorSurfaceContainerLow</item>
+        <item name="android:backgroundTint">?attr/colorSurfaceContainerLow</item>
     </style>
 
     <style name="Threema.FingerprintAlertDialogStyle" parent="@style/Threema.AlertDialogStyle">

+ 2 - 1
scripts/build-release.sh

@@ -247,7 +247,8 @@ if [ $export_image -eq 1 ]; then
     log_major "Exporting docker image"
     docker image save -o "$targetdir/docker-image.tar" "$DOCKERIMAGE:$APP_VERSION_CODE"
     log_minor "Compressing docker image"
-    gzip "$targetdir/docker-image.tar"
+    gzip "${targetdir}/docker-image.tar"
+    chmod 644 "${targetdir}/docker-image.tar.gz"
 fi
 
 log_major "Done! You can find the resulting files in the '$releasedir' directory."