Threema 10 місяців тому
батько
коміт
ca14fac988
100 змінених файлів з 8875 додано та 4683 видалено
  1. 8 0
      README.md
  2. 8 0
      app/assets/license.html
  3. 97 47
      app/build.gradle
  4. BIN
      app/libs/arm64-v8a/libjnidispatch.so
  5. BIN
      app/libs/armeabi-v7a/libjnidispatch.so
  6. BIN
      app/libs/x86/libjnidispatch.so
  7. BIN
      app/libs/x86_64/libjnidispatch.so
  8. 4 0
      app/proguard-project.txt
  9. 207 8
      app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt
  10. 66 9
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  11. 154 44
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  12. 286 0
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  13. 334 0
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  14. 38 4
      app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt
  15. 45 17
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  16. 2 5
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  17. 33 5
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  18. 5 10
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  19. 109 53
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  20. 364 0
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  21. 33 2
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  22. 233 6
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  23. 52 4
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  24. 233 0
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  25. 1 1
      app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java
  26. 309 200
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  27. 11 1
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  28. 13 1
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  29. 254 0
      app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt
  30. 6 0
      app/src/foss_based/assets/license.html
  31. 4 1
      app/src/libre/play/release-notes/de/default.txt
  32. 4 1
      app/src/libre/play/release-notes/en-US/default.txt
  33. 9 1
      app/src/main/AndroidManifest.xml
  34. 653 645
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  35. 11 9
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  36. 97 59
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  37. 138 84
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  38. 1 1
      app/src/main/java/ch/threema/app/activities/ContactDetailViewModel.kt
  39. 4 4
      app/src/main/java/ch/threema/app/activities/ContactNotificationsActivity.java
  40. 70 54
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  41. 8 8
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  42. 88 63
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  43. 1 4
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  44. 1 6
      app/src/main/java/ch/threema/app/activities/MessageDetailsActivity.kt
  45. 0 117
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.java
  46. 99 0
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.kt
  47. 72 59
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  48. 1025 1034
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  49. 1 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java
  50. 11 5
      app/src/main/java/ch/threema/app/activities/wizard/WizardStartActivity.java
  51. 177 106
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  52. 6 3
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  53. 36 32
      app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java
  54. 1 1
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  55. 4 1
      app/src/main/java/ch/threema/app/adapters/UserListAdapter.java
  56. 0 108
      app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java
  57. 79 0
      app/src/main/java/ch/threema/app/asynctasks/AddContactBackgroundTask.kt
  58. 227 85
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt
  59. 244 0
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt
  60. 0 119
      app/src/main/java/ch/threema/app/asynctasks/DeleteContactAsyncTask.java
  61. 28 2
      app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java
  62. 368 0
      app/src/main/java/ch/threema/app/asynctasks/MarkContactAsDeletedBackgroundTask.kt
  63. 71 0
      app/src/main/java/ch/threema/app/asynctasks/SendToSupportBackgroundTask.kt
  64. 98 28
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  65. 147 29
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  66. 2 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  67. 7 1
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  68. 1 1
      app/src/main/java/ch/threema/app/camera/QRCodeAnalyer.kt
  69. 19 7
      app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt
  70. 2 9
      app/src/main/java/ch/threema/app/compose/common/interop/ComposeJavaBridge.kt
  71. 10 5
      app/src/main/java/ch/threema/app/compose/theme/ThreemaTheme.kt
  72. 375 0
      app/src/main/java/ch/threema/app/debug/PatternLibraryActivity.kt
  73. 21 2
      app/src/main/java/ch/threema/app/dialogs/CancelableHorizontalProgressDialog.java
  74. 11 8
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  75. 95 89
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  76. 34 17
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  77. 59 11
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.java
  78. 8 7
      app/src/main/java/ch/threema/app/fragments/UserMemberListFragment.java
  79. 8 7
      app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.java
  80. 8 7
      app/src/main/java/ch/threema/app/fragments/WorkUserMemberListFragment.java
  81. 7 16
      app/src/main/java/ch/threema/app/glide/AvatarOptions.java
  82. 13 18
      app/src/main/java/ch/threema/app/glide/ContactAvatarFetcher.kt
  83. 0 4
      app/src/main/java/ch/threema/app/glide/GroupAvatarFetcher.kt
  84. 2 1
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.kt
  85. 2 1
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  86. 46 36
      app/src/main/java/ch/threema/app/grouplinks/OutgoingGroupRequestActivity.java
  87. 1 1
      app/src/main/java/ch/threema/app/listeners/ContactListener.java
  88. 3 3
      app/src/main/java/ch/threema/app/listeners/GroupListener.java
  89. 1 1
      app/src/main/java/ch/threema/app/listeners/NewSyncedContactsListener.java
  90. 18 6
      app/src/main/java/ch/threema/app/managers/CoreServiceManager.kt
  91. 9 0
      app/src/main/java/ch/threema/app/managers/CoreServiceManagerImpl.kt
  92. 1067 1088
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  93. 47 26
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  94. 33 12
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  95. 3 4
      app/src/main/java/ch/threema/app/messagereceiver/MessageReceiver.java
  96. 28 17
      app/src/main/java/ch/threema/app/multidevice/LinkedDevicesActivity.kt
  97. 23 10
      app/src/main/java/ch/threema/app/multidevice/LinkedDevicesViewModel.kt
  98. 34 10
      app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt
  99. 112 113
      app/src/main/java/ch/threema/app/multidevice/MultiDeviceManagerImpl.kt
  100. 78 57
      app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt

+ 8 - 0
README.md

@@ -167,6 +167,14 @@ Prerequisites:
 - Android SDK
 - Android NDK
 - bash shell
+- protobuf compiler version 21.12
+- Rust compiler and cargo (including the target architectures)
+
+The best way to install all required target architectures for Rust is
+through [rustup](https://rustup.rs/):
+
+    TOOLCHAIN=$(grep channel domain/libthreema/rust-toolchain.toml | cut -d'"' -f2)
+    rustup target add --toolchain $TOOLCHAIN armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
 
 The application APK can be built using Gradle Wrapper:
 

+ 8 - 0
app/assets/license.html

@@ -203,6 +203,14 @@ SUCH DAMAGE.</p>
     of the authors and should not be interpreted as representing official policies,
     either expressed or implied, of the FreeBSD Project.</p>
 
+
+<h2>Fluent Emoji</h2>
+
+<p>Copyright (c) Microsoft Corporation</p>
+
+<p>Licensed under the MIT License (copy below).</p>
+
+
 <h2>Gesture Views</h2>
 
 <p>Copyright (c) 2022 Alex Vasilkov</p>

+ 97 - 47
app/build.gradle

@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 plugins {
     id 'org.sonarqube'
     id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
+    id 'org.mozilla.rust-android-gradle.rust-android' version "0.9.3"
 }
 
 apply plugin: 'com.android.application'
@@ -18,14 +19,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.6.2"
+def app_version = "5.7.0"
 
 // beta_suffix with leading dash (e.g. `-beta1`)
 // should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 
-def defaultVersionCode = 1014
+def defaultVersionCode = 1033
 
 /**
  * Return the git hash, if git is installed.
@@ -40,7 +41,8 @@ def getGitHash = { ->
             errorOutput = stderr
             ignoreExitValue true
         }
-    } catch (ignored) { /* If git binary is not found, carry on */ }
+    } catch (ignored) { /* If git binary is not found, carry on */
+    }
     def hash = stdout.toString().trim()
     return (hash.isEmpty()) ? "?" : hash
 }
@@ -61,17 +63,17 @@ def findKeystore = { name ->
             Properties props = new Properties()
             propertiesFile.withInputStream { props.load(it) }
             return [
-                storeFile: storePath,
+                storeFile    : storePath,
                 storePassword: props.storePassword,
-                keyAlias: props.keyAlias,
-                keyPassword: props.keyPassword,
+                keyAlias     : props.keyAlias,
+                keyPassword  : props.keyPassword,
             ]
         } else {
             return [
-                storeFile: storePath,
+                storeFile    : storePath,
                 storePassword: null,
-                keyAlias: null,
-                keyPassword: null,
+                keyAlias     : null,
+                keyPassword  : null,
             ]
         }
     }
@@ -81,11 +83,11 @@ def findKeystore = { name ->
  * Map with keystore paths (if found).
  */
 def keystores = [
-    debug: findKeystore("debug"),
-    release: findKeystore("threema"),
-    hms_release: findKeystore("threema_hms"),
+    debug         : findKeystore("debug"),
+    release       : findKeystore("threema"),
+    hms_release   : findKeystore("threema_hms"),
     onprem_release: findKeystore("onprem"),
-    blue_release: findKeystore("threema_blue"),
+    blue_release  : findKeystore("threema_blue"),
 ]
 
 android {
@@ -121,6 +123,7 @@ android {
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
         buildConfigField "boolean", "VIDEO_CALLS_ENABLED", "true"
+        // This public key is pinned for the chat server protocol.
         buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x45, (byte) 0x0b, (byte) 0x97, (byte) 0x57, (byte) 0x35, (byte) 0x27, (byte) 0x9f, (byte) 0xde, (byte) 0xcb, (byte) 0x33, (byte) 0x13, (byte) 0x64, (byte) 0x8f, (byte) 0x5f, (byte) 0xc6, (byte) 0xee, (byte) 0x9f, (byte) 0xf4, (byte) 0x36, (byte) 0x0e, (byte) 0xa9, (byte) 0x2a, (byte) 0x8c, (byte) 0x17, (byte) 0x51, (byte) 0xc6, (byte) 0x61, (byte) 0xe4, (byte) 0xc0, (byte) 0xd8, (byte) 0xc9, (byte) 0x09 }"
         buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0xda, (byte) 0x7c, (byte) 0x73, (byte) 0x79, (byte) 0x8f, (byte) 0x97, (byte) 0xd5, (byte) 0x87, (byte) 0xc3, (byte) 0xa2, (byte) 0x5e, (byte) 0xbe, (byte) 0x0a, (byte) 0x91, (byte) 0x41, (byte) 0x7f, (byte) 0x76, (byte) 0xdb, (byte) 0xcc, (byte) 0xcd, (byte) 0xda, (byte) 0x29, (byte) 0x30, (byte) 0xe6, (byte) 0xa9, (byte) 0x09, (byte) 0x0a, (byte) 0xf6, (byte) 0x2e, (byte) 0xba, (byte) 0x6f, (byte) 0x15 }"
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
@@ -129,12 +132,18 @@ android {
         buildConfigField "String", "WORK_SERVER_URL", "null"
         buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
         buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
-        buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}\""
-        buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}\""
-        buildConfigField "String", "BLOB_SERVER_DONE_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
-        buildConfigField "String", "BLOB_SERVER_DONE_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
-        buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "\"https://blobp-upload.threema.ch/upload\""
-        buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "\"https://ds-blobp-upload.threema.ch/upload\""
+
+        // Base blob url used for "download" and "done" calls
+        buildConfigField "String", "BLOB_SERVER_URL", "\"https://blobp-{blobIdPrefix8}.threema.ch\""
+        buildConfigField "String", "BLOB_SERVER_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix8}.threema.ch\""
+
+        // Specific blob url used for "upload" calls
+        buildConfigField "String", "BLOB_SERVER_URL_UPLOAD", "\"https://blobp-upload.threema.ch/upload\""
+        buildConfigField "String", "BLOB_SERVER_IPV6_URL_UPLOAD", "\"https://ds-blobp-upload.threema.ch/upload\""
+
+        // Base blob mirror url used for "download", "upload", "done"
+        buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
+
         buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-{backupIdPrefix8}.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
@@ -146,6 +155,7 @@ android {
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "MD_ENABLED", "false"
+        buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "SHOW_TIMESTAMPS_AND_TECHNICAL_INFO_IN_MESSAGE_DETAILS", "false"
@@ -158,11 +168,11 @@ android {
 
         // duplicated for manifest
         manifestPlaceholders = [
-            uriScheme: "threema",
-            contactActionUrl: "threema.id",
+            uriScheme         : "threema",
+            contactActionUrl  : "threema.id",
             groupLinkActionUrl: "threema.group",
-            actionUrl: "go.threema.ch",
-            callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.call",
+            actionUrl         : "go.threema.ch",
+            callMimeType      : "vnd.android.cursor.item/vnd.ch.threema.app.call",
         ]
 
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
@@ -210,15 +220,15 @@ android {
         variant.outputs.each { output ->
             def abi = output.getFilter("ABI")
             output.versionCodeOverride =
-                    abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
+                abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
         }
     }
 
     namespace 'ch.threema.app'
     flavorDimensions = ["default"]
     productFlavors {
-        none { }
-        store_google { }
+        none {}
+        store_google {}
         store_threema {
             resValue "string", "shop_download_filename", "Threema-update.apk"
         }
@@ -244,8 +254,8 @@ android {
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
 
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
+                uriScheme   : "threemawork",
+                actionUrl   : "work.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
         }
@@ -259,6 +269,7 @@ android {
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.call"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
+            // This public key is pinned for the chat server protocol.
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
@@ -267,6 +278,8 @@ android {
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
             buildConfigField "boolean", "MD_ENABLED", "true"
+
+            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
         }
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
@@ -280,6 +293,7 @@ android {
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWorkSandbox\""
+            // This public key is pinned for the chat server protocol.
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
 
@@ -293,6 +307,7 @@ android {
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
+            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
@@ -300,10 +315,9 @@ android {
 
             buildConfigField "boolean", "MD_ENABLED", "true"
 
-
             manifestPlaceholders = [
-                uriScheme       : "threemawork",
-                actionUrl       : "work.test.threema.ch",
+                uriScheme: "threemawork",
+                actionUrl: "work.test.threema.ch",
             ]
         }
         onprem {
@@ -325,12 +339,13 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "null"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "null"
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_DONE_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_DONE_IPV6_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "null"
+
+            buildConfigField "String", "BLOB_SERVER_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_URL_UPLOAD", "null"
+            buildConfigField "String", "BLOB_SERVER_IPV6_URL_UPLOAD", "null"
+            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "null"
+
             buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "new String[] {\"ek1qBp4DyRmLL9J5sCmsKSfwbsiGNB4veDAODjkwe/k=\", \"Hrk8aCjwKkXySubI7CZ3y9Sx+oToEHjNkGw98WSRneU=\", \"5pEn1T/5bhecNWrp9NgUQweRfgVtu/I8gRb3VxGP7k4=\"}"
             buildConfigField "String", "LOG_TAG", "\"3maop\""
 
@@ -339,12 +354,13 @@ android {
             buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
 
             manifestPlaceholders = [
-                uriScheme: "threemaonprem",
-                actionUrl: "onprem.threema.ch",
+                uriScheme   : "threemaonprem",
+                actionUrl   : "onprem.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
             ]
         }
-        blue { // Essentially like sandbox work, but with a different icon and application id, used for internal testing
+        blue {
+            // Essentially like sandbox work, but with a different icon and application id, used for internal testing
             versionName "${app_version}b${beta_suffix}"
             // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
             applicationId "ch.threema.app.red"
@@ -358,6 +374,7 @@ android {
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaBlue\""
+            // This public key is pinned for the chat server protocol.
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
@@ -369,13 +386,15 @@ android {
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mablue\""
 
+            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
+
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
 
             manifestPlaceholders = [
-                uriScheme: "threemablue",
-                actionUrl: "blue.threema.ch",
+                uriScheme   : "threemablue",
+                actionUrl   : "blue.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.blue.call",
             ]
         }
@@ -404,8 +423,8 @@ android {
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
 
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
+                uriScheme   : "threemawork",
+                actionUrl   : "work.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
         }
@@ -413,7 +432,7 @@ android {
             versionName "${app_version}l${beta_suffix}"
             applicationId "ch.threema.app.libre"
             testApplicationId 'ch.threema.app.libre.test'
-            resValue "string", "package_name",  applicationId
+            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Libre"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
         }
@@ -484,6 +503,7 @@ android {
         main {
             assets.srcDirs = ['assets']
             jniLibs.srcDirs = ['libs']
+            res.srcDir 'src/main/res-rendezvous'
         }
 
         // Based on Google services
@@ -645,6 +665,7 @@ android {
                 jvmArgs = jvmArgs + ['--add-opens=java.base/java.util=ALL-UNNAMED']
                 jvmArgs = jvmArgs + ['--add-opens=java.base/java.util.stream=ALL-UNNAMED']
                 jvmArgs = jvmArgs + ['--add-opens=java.base/java.lang=ALL-UNNAMED']
+                jvmArgs = jvmArgs + ['-Xmx4096m']
             }
             // By default, local unit tests throw an exception any time the code you are testing tries to access
             // Android platform APIs (unless you mock Android dependencies yourself or with a testing
@@ -799,7 +820,8 @@ dependencies {
 
     implementation 'com.google.android.material:material:1.12.0'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.39' // make sure to update this in domain's build.gradle as well
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.39'
+    // make sure to update this in domain's build.gradle as well
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
@@ -893,7 +915,7 @@ dependencies {
         'com.google.android.gms:play-services-base:18.0.1': [],
 
         // Firebase push
-        'com.google.firebase:firebase-messaging:23.1.2': [
+        'com.google.firebase:firebase-messaging:23.1.2'   : [
             [group: 'com.google.firebase', module: 'firebase-core'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
@@ -960,6 +982,34 @@ tasks.withType(KaptGenerateStubs).configureEach {
     }
 }
 
+// Define the cargo attributes. These will be used by the rust-android plugin that will create the
+// 'cargoBuild' task that builds native libraries that will be added to the apk. Note that the
+// kotlin bindings are created in the domain module. Building native libraries with rust-android
+// cannot be done in any other module than 'app'.
+cargo {
+    prebuiltToolchains = true
+    targetDirectory = "$projectDir/build/generated/source/libthreema"
+    module = "$projectDir/../domain/libthreema" // must contain Cargo.toml
+    libname = "libthreema" // must match the Cargo.toml's package name
+    profile = 'release'
+    pythonCommand = 'python3'
+    targets = ["x86_64", "arm64", "arm", "x86"]
+    features {
+        defaultAnd("uniffi")
+    }
+    extraCargoBuildArguments = ["--lib", "--target-dir", "$projectDir/build/generated/source/libthreema"]
+    verbose = false
+}
+
+afterEvaluate {
+    // The `cargoBuild` task isn't available until after evaluation.
+    android.applicationVariants.configureEach { variant ->
+        def variantName = "${variant.name.capitalize()}"
+        // Set the dependency so that cargoBuild is executed before the native libs are merged
+        tasks["merge${variantName}NativeLibs"].dependsOn(tasks["cargoBuild"])
+    }
+}
+
 sonarqube {
     properties {
         property "sonar.sources", "src/main/, ../scripts/, ../scripts-internal/"

BIN
app/libs/arm64-v8a/libjnidispatch.so


BIN
app/libs/armeabi-v7a/libjnidispatch.so


BIN
app/libs/x86/libjnidispatch.so


BIN
app/libs/x86_64/libjnidispatch.so


+ 4 - 0
app/proguard-project.txt

@@ -31,6 +31,10 @@
 -dontnote org.apache.commons.codec.**
 -dontnote org.apache.http.**
 
+# JNA library classes are needed for Uniffi Bindings
+-keep class com.sun.jna.** { *; }
+-keep class * implements com.sun.jna.** { *; }
+
 # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native
 -keepclasseswithmembernames class * {
     native <methods>;

+ 207 - 8
app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt

@@ -22,20 +22,219 @@
 package ch.threema.app
 
 import ch.threema.app.managers.CoreServiceManager
-import ch.threema.app.multidevice.MultiDeviceManagerImpl
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
+import ch.threema.app.stores.IdentityStore
 import ch.threema.app.stores.PreferenceStoreInterface
-import ch.threema.app.tasks.TaskArchiverImpl
-import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.app.tasks.TaskCreator
+import ch.threema.base.crypto.HashedNonce
+import ch.threema.base.crypto.Nonce
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.base.crypto.NonceScope
+import ch.threema.base.crypto.NonceStore
+import ch.threema.domain.helpers.TransactionAckTaskCodec
 import ch.threema.domain.models.AppVersion
+import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
+import ch.threema.domain.protocol.D2mProtocolDefines
+import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
+import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
+import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
+import ch.threema.domain.protocol.connection.data.D2dMessage
+import ch.threema.domain.protocol.connection.data.D2mProtocolVersion
+import ch.threema.domain.protocol.connection.data.DeviceId
+import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.protocol.multidevice.MultiDeviceKeys
+import ch.threema.domain.protocol.multidevice.MultiDeviceProperties
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import ch.threema.domain.taskmanager.QueueSendCompleteListener
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskArchiver
+import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 
 class TestCoreServiceManager(
     override val version: AppVersion,
     override val databaseService: DatabaseServiceNew,
     override val preferenceStore: PreferenceStoreInterface,
-    override val taskArchiver: TaskArchiverImpl,
-    override val deviceCookieManager: DeviceCookieManagerImpl,
-    override val taskManager: TaskManager,
-    override val multiDeviceManager: MultiDeviceManagerImpl,
-): CoreServiceManager
+    override val taskArchiver: TaskArchiver = TestTaskArchiver(),
+    override val deviceCookieManager: DeviceCookieManager = TestDeviceCookieManager(),
+    override val taskManager: TaskManager = TestTaskManager(TransactionAckTaskCodec()),
+    override val multiDeviceManager: MultiDeviceManager = TestMultiDeviceManager(),
+    override val identityStore: IdentityStore = IdentityStore(preferenceStore),
+    override val nonceFactory: NonceFactory = NonceFactory(TestNonceStore()),
+) : CoreServiceManager
+
+class TestTaskArchiver(initialTasks: List<Task<*, TaskCodec>> = emptyList()) : TaskArchiver {
+    private val archivedTasks: MutableList<Task<*, TaskCodec>> = initialTasks.toMutableList()
+
+    override fun addTask(task: Task<*, TaskCodec>) {
+        archivedTasks.add(task)
+    }
+
+    override fun removeTask(task: Task<*, TaskCodec>) {
+        val index = archivedTasks.indexOf(task)
+        if (index < 0) {
+            return
+        }
+
+        if (index == 0) {
+            archivedTasks.removeAt(index)
+        } else {
+            throw AssertionError("Task $index is removed, but it is not the oldest task")
+        }
+    }
+
+    override fun loadAllTasks(): List<Task<*, TaskCodec>> {
+        return archivedTasks
+    }
+}
+
+class TestDeviceCookieManager : DeviceCookieManager {
+    override fun obtainDeviceCookie() = ByteArray(16)
+    override fun changeIndicationReceived() {
+        // Nothing to do
+    }
+
+    override fun deleteDeviceCookie() {
+        // Nothing to do
+    }
+}
+
+class TestTaskManager(
+    val taskCodec: TaskCodec
+) : TaskManager {
+    private val taskQueue = Channel<QueueElement<Any>>()
+
+    private data class QueueElement<T>(
+        val task: Task<T, TaskCodec>,
+        val deferred: CompletableDeferred<T>,
+    )
+
+    init {
+        CoroutineScope(Dispatchers.Default).launch {
+            while (true) {
+                val (task, deferred) = taskQueue.receive()
+                try {
+                    deferred.complete(task.invoke(taskCodec))
+                } catch (e: Throwable) {
+                    deferred.completeExceptionally(e)
+                }
+            }
+        }
+    }
+
+    override fun <R> schedule(task: Task<R, TaskCodec>): Deferred<R> {
+        val deferred = CompletableDeferred<R>()
+        runBlocking {
+            @Suppress("UNCHECKED_CAST")
+            taskQueue.send(QueueElement(task, deferred) as QueueElement<Any>)
+        }
+        return deferred
+    }
+
+    override fun hasPendingTasks() = false
+
+    override fun addQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+        // Nothing to do
+    }
+
+    override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+        // Nothing to do
+    }
+}
+
+class TestMultiDeviceManager(
+    override val isMdDisabledOrSupportsFs: Boolean = true,
+    override val isMultiDeviceActive: Boolean = false,
+    override val propertiesProvider: MultiDevicePropertyProvider = TestMultiDevicePropertyProvider,
+    override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { },
+    override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf(),
+) : MultiDeviceManager {
+    override suspend fun activate(
+        deviceLabel: String,
+        contactService: ContactService,
+        userService: UserService,
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
+    ) {
+        throw AssertionError("Not supported")
+    }
+
+    override suspend fun deactivate(
+        userService: UserService,
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
+    ) {
+        throw AssertionError("Not supported")
+    }
+
+    override suspend fun setDeviceLabel(deviceLabel: String) {
+        throw AssertionError("Not supported")
+    }
+
+    override suspend fun linkDevice(
+        deviceJoinOfferUri: String,
+        taskCreator: TaskCreator,
+    ): Flow<DeviceLinkingStatus> {
+        throw AssertionError("Not supported")
+    }
+
+    override suspend fun purge(taskCreator: TaskCreator) {
+        throw AssertionError("Not supported")
+    }
+
+    override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
+        throw AssertionError("Not supported")
+    }
+}
+
+class TestNonceStore : NonceStore {
+    override fun exists(scope: NonceScope, nonce: Nonce) = false
+
+    override fun store(scope: NonceScope, nonce: Nonce) = true
+
+    override fun getCount(scope: NonceScope) = 0L
+
+    override fun getAllHashedNonces(scope: NonceScope): List<HashedNonce> = emptyList()
+
+    override fun addHashedNoncesChunk(
+        scope: NonceScope,
+        chunkSize: Int,
+        offset: Int,
+        nonces: MutableList<HashedNonce>,
+    ) {
+        // Nothing to do
+    }
+
+    override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
+
+}
+
+object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
+    override fun get() =
+        MultiDeviceProperties(
+            0u,
+            DeviceId(0u),
+            DeviceId(0u),
+            MultiDeviceKeys(ByteArray(D2mProtocolDefines.DGK_LENGTH_BYTES)),
+            D2dMessage.DeviceInfo(
+                D2dMessage.DeviceInfo.Platform.ANDROID,
+                "",
+                "",
+                ""
+            ),
+            D2mProtocolVersion(UInt.MIN_VALUE, UInt.MAX_VALUE)
+        ) { }
+}

+ 66 - 9
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -48,15 +48,18 @@ import java.util.List;
 import java.util.Objects;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.GrantPermissionRule;
 import ch.threema.app.DangerousTest;
-import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.DeleteAllContactsBackgroundTask;
+import ch.threema.app.asynctasks.DeleteContactServices;
 import ch.threema.app.backuprestore.BackupRestoreDataConfig;
-import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
@@ -65,9 +68,14 @@ import ch.threema.app.services.FileService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.ballot.BallotService;
+import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVRow;
+import ch.threema.app.utils.executor.BackgroundExecutor;
+import ch.threema.base.ThreemaException;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.identitybackup.IdentityBackupDecoder;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import java8.util.stream.StreamSupport;
@@ -86,6 +94,7 @@ public class BackupServiceTest {
 	private static @NonNull String TEST_IDENTITY;
 
 	// Services
+	private @NonNull ServiceManager serviceManager;
 	private @NonNull FileService fileService;
     private @NonNull MessageService messageService;
     private @NonNull ConversationService conversationService;
@@ -93,6 +102,10 @@ public class BackupServiceTest {
     private @NonNull ContactService contactService;
     private @NonNull DistributionListService distributionListService;
     private @NonNull BallotService ballotService;
+	private @NonNull APIConnector apiConnector;
+	private @NonNull ContactModelRepository contactModelRepository;
+
+	private final @NonNull BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
 
 	@Rule
 	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
@@ -112,7 +125,7 @@ public class BackupServiceTest {
 	 */
 	@Before
 	public void loadServices() throws Exception {
-		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
+		this.serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
 		this.fileService = serviceManager.getFileService();
 		this.messageService = serviceManager.getMessageService();
 		this.conversationService = serviceManager.getConversationService();
@@ -120,12 +133,14 @@ public class BackupServiceTest {
 		this.contactService = serviceManager.getContactService();
 		this.distributionListService = serviceManager.getDistributionListService();
 		this.ballotService = serviceManager.getBallotService();
+		this.apiConnector = serviceManager.getAPIConnector();
+		this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
 	}
 
 	/**
 	 * Return the list of backups for the TEST_IDENTITY identity.
 	 */
-	private @NonNull List<File> getUserBackups(@NonNull File backupPath) throws FileSystemNotPresentException {
+	private @NonNull List<File> getUserBackups(@NonNull File backupPath) {
 		if (backupPath.exists() && backupPath.isDirectory()) {
 			final File[] files = backupPath.listFiles(
 				(dir, name) -> name.startsWith("threema-backup_" + TEST_IDENTITY)
@@ -139,7 +154,7 @@ public class BackupServiceTest {
 	/**
 	 * Helper method: Create a backup with the specified config, return backup file.
 	 */
-	private @NonNull File doBackup(BackupRestoreDataConfig config) throws Exception {
+	private @NonNull File doBackup(BackupRestoreDataConfig config) {
 		// List old backups
 		final File backupPath = this.fileService.getBackupPath();
 		final List<File> initialBackupFiles = this.getUserBackups(backupPath);
@@ -250,18 +265,18 @@ public class BackupServiceTest {
         this.messageService.removeAll();
         this.conversationService.reset();
         this.groupService.removeAll();
-        this.contactService.removeAll();
+        this.backgroundExecutor.execute(getContactDeleteTask());
         this.distributionListService.removeAll();
         this.ballotService.removeAll();
 
         // Insert test data:
 	    // Contacts
-	    final ContactModel contact1 = this.contactService.createContactByIdentity("CDXVZ5E4", true);
+	    final ContactModel contact1 = createContact("CDXVZ5E4");
 	    contact1.setFirstName("Fritzli");
 	    contact1.setLastName("Bühler");
 	    this.contactService.save(contact1);
-	    final ContactModel contact2 = this.contactService.createContactByIdentity("DRMWZP3H", true);
-	    this.contactService.createContactByIdentity("ECHOECHO", true);
+	    final ContactModel contact2 = createContact("DRMWZP3H");
+	    createContact("ECHOECHO");
 	    // Messages contact 1
 	    this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
 	    this.messageService.sendText("Phở?", this.contactService.createReceiver(contact1));
@@ -330,5 +345,47 @@ public class BackupServiceTest {
         }
     }
 
+	@NonNull
+	@WorkerThread
+	private ContactModel createContact(@NonNull String identity) {
+		new BasicAddOrUpdateContactBackgroundTask(
+			identity,
+			ContactModel.AcquaintanceLevel.DIRECT,
+			TEST_IDENTITY,
+			apiConnector,
+			contactModelRepository,
+			AddContactRestrictionPolicy.CHECK,
+			ApplicationProvider.getApplicationContext(),
+			null
+		).runSynchronously();
+
+		ContactModel contactModel = contactService.getByIdentity(identity);
+		if (contactModel == null) {
+			throw new IllegalStateException("Contact is null after creating it");
+		}
+		return contactModel;
+	}
+
+	@NonNull
+	private DeleteAllContactsBackgroundTask getContactDeleteTask() throws ThreemaException {
+		return new DeleteAllContactsBackgroundTask(
+			serviceManager.getModelRepositories().getContacts(),
+			new DeleteContactServices(
+				serviceManager.getUserService(),
+				serviceManager.getContactService(),
+				serviceManager.getConversationService(),
+				serviceManager.getRingtoneService(),
+				serviceManager.getMutedChatsListService(),
+				serviceManager.getHiddenChatsListService(),
+				serviceManager.getProfilePicRecipientsService(),
+				serviceManager.getWallpaperService(),
+				serviceManager.getFileService(),
+				serviceManager.getExcludedSyncIdentitiesService(),
+				serviceManager.getDHSessionStore(),
+				serviceManager.getNotificationService(),
+				serviceManager.getDatabaseServiceNew()
+			)
+		);
+	}
 
 }

+ 154 - 44
app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt

@@ -21,15 +21,21 @@
 
 package ch.threema.app.contacts
 
+import android.os.Looper
+import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy
 import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
 import ch.threema.app.asynctasks.AlreadyVerified
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask
+import ch.threema.app.asynctasks.ContactCreated
 import ch.threema.app.asynctasks.ContactExists
-import ch.threema.app.asynctasks.Failed
-import ch.threema.app.asynctasks.ContactAddResult
 import ch.threema.app.asynctasks.ContactModified
-import ch.threema.app.asynctasks.Success
+import ch.threema.app.asynctasks.ContactResult
+import ch.threema.app.asynctasks.InvalidThreemaId
+import ch.threema.app.asynctasks.RemotePublicKeyMismatch
+import ch.threema.app.asynctasks.UserIdentity
+import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.data.TestDatabaseService
@@ -42,7 +48,6 @@ import ch.threema.domain.protocol.SSLSocketFactoryFactory
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
-import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.neilalexander.jnacl.NaCl
 import kotlinx.coroutines.runBlocking
@@ -51,20 +56,28 @@ import org.junit.Before
 import java.net.HttpURLConnection
 import kotlin.test.Test
 import kotlin.test.assertEquals
-import kotlin.test.assertTrue
 import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
 import kotlin.test.fail
 
 class AddOrUpdateContactBackgroundTaskTest {
 
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var databaseService: TestDatabaseService
+    private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
     @Before
     fun before() {
         databaseService = TestDatabaseService()
-        contactModelRepository = ModelRepositories(databaseService).contacts
+        val serviceManager = ThreemaApplication.requireServiceManager()
+        coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = serviceManager.preferenceStore,
+        )
+        contactModelRepository = ModelRepositories(coreServiceManager).contacts
     }
 
     @Test
@@ -78,19 +91,50 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 12
                     it.type = 0
-                    it.state = IdentityState.ACTIVE
+                    it.state = IdentityState.ACTIVE.value
                 }
             },
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
+                assertEquals(newIdentity, it.contactModel.identity)
+                val data = it.contactModel.data.value!!
+                assertEquals(newIdentity, data.identity)
+                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertEquals(12u, data.featureMask)
+                assertEquals(IdentityType.NORMAL, data.identityType)
+                assertEquals(IdentityState.ACTIVE, data.activityState)
+                assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
+                assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
+            }
+        )
+    }
+
+    @Test
+    fun testAddGroupContactSuccessful() {
+        val newIdentity = "01234567"
+
+        testAddingContact(
+            fetchIdentity = { identity ->
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 12
+                    it.type = 0
+                    it.state = IdentityState.ACTIVE.value
+                }
+            },
+            acquaintanceLevel = AcquaintanceLevel.GROUP,
+            runOnFinished = {
+                assertTrue(it is ContactCreated)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
-                assertEquals(ContactModel.State.ACTIVE, data.activityState)
+                assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
+                assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
             }
         )
     }
@@ -106,19 +150,20 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 127
                     it.type = 1
-                    it.state = IdentityState.INACTIVE
+                    it.state = IdentityState.INACTIVE.value
                 }
             },
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(127u, data.featureMask)
                 assertEquals(IdentityType.WORK, data.identityType)
-                assertEquals(ContactModel.State.INACTIVE, data.activityState)
+                assertEquals(IdentityState.INACTIVE, data.activityState)
                 assertEquals(VerificationLevel.FULLY_VERIFIED, data.verificationLevel)
+                assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
             },
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
@@ -134,11 +179,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 127
                     it.type = 1
-                    it.state = IdentityState.INACTIVE
+                    it.state = IdentityState.INACTIVE.value
                 }
             },
             {
-                assertTrue(it is Failed)
+                assertTrue(it is UserIdentity)
             },
             newIdentity = myIdentity,
             myIdentity = myIdentity,
@@ -154,11 +199,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 12
                     it.type = 0
-                    it.state = IdentityState.ACTIVE
+                    it.state = IdentityState.ACTIVE.value
                 }
             },
             {
-                assertTrue(it is Failed)
+                assertTrue(it is RemotePublicKeyMismatch)
             },
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES).also { it.fill(1) }
         )
@@ -171,7 +216,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 throw HttpConnectionException(HttpURLConnection.HTTP_NOT_FOUND, Exception())
             },
             {
-                assertTrue(it is Failed)
+                assertTrue(it is InvalidThreemaId)
             }
         )
     }
@@ -184,7 +229,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
         }
 
@@ -192,7 +237,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
             apiConnectorResult,
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             }
         )
 
@@ -215,7 +260,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = publicKey
                 it.featureMask = 12
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
         }
 
@@ -223,7 +268,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
             apiConnectorResult,
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             publicKey = publicKey,
         )
@@ -239,7 +284,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     }
 
     @Test
-    fun testAddGroupContact() {
+    fun testUpgradeGroupContact() {
         val newIdentity = "01234567"
 
         val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
@@ -248,7 +293,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
         }
 
@@ -256,7 +301,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
             apiConnectorResult,
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             newIdentity = newIdentity
         )
@@ -292,7 +337,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
         }
 
@@ -300,7 +345,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
             apiConnectorResult,
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             newIdentity = newIdentity
         )
@@ -310,14 +355,17 @@ class AddOrUpdateContactBackgroundTaskTest {
         // Assert that the verification level is unverified
         assertEquals(VerificationLevel.UNVERIFIED, contactModel.data.value!!.verificationLevel)
 
-        // When adding the contact again, it should be converted back to a direct contact
+        // When adding the contact again, it should be fully verified
         testAddingContact(
             apiConnectorResult,
             {
                 assertTrue(it is ContactModified)
                 assertTrue(it.verificationLevelChanged)
                 assertFalse(it.acquaintanceLevelChanged)
-                assertEquals(VerificationLevel.FULLY_VERIFIED, contactModel.data.value!!.verificationLevel)
+                assertEquals(
+                    VerificationLevel.FULLY_VERIFIED,
+                    contactModel.data.value!!.verificationLevel
+                )
             },
             newIdentity = newIdentity,
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
@@ -334,7 +382,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
         }
 
@@ -342,7 +390,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
             apiConnectorResult,
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             newIdentity = newIdentity
         )
@@ -371,10 +419,70 @@ class AddOrUpdateContactBackgroundTaskTest {
         )
     }
 
+    @Test
+    fun testThreadUsage() {
+        val identity = "01234567"
+        val myIdentity = "00000000"
+
+        testAddingContact(
+            {
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 12
+                    it.type = 0
+                    it.state = IdentityState.ACTIVE.value
+                }
+            },
+            {},
+            newIdentity = identity,
+            myIdentity = myIdentity,
+            publicKey = null,
+        )
+
+        val unusedAPIConnector = getTestApiConnector {
+            throw AssertionError("This must not be executed for this test")
+        }
+
+        val testThreadId = Thread.currentThread().id
+
+        val addTask = object : AddOrUpdateContactBackgroundTask<Boolean>(
+            identity = identity,
+            AcquaintanceLevel.DIRECT,
+            myIdentity = myIdentity,
+            unusedAPIConnector,
+            contactModelRepository,
+            AddContactRestrictionPolicy.CHECK,
+            ThreemaApplication.getAppContext(),
+            null,
+        ) {
+            override fun onBefore() {
+                assertEquals(testThreadId, Thread.currentThread().id)
+            }
+
+            override fun onContactAdded(result: ContactResult): Boolean {
+                assertTrue(result is ContactExists)
+                assertNotEquals(testThreadId, Thread.currentThread().id)
+                assertNotEquals(Looper.getMainLooper(), Looper.myLooper())
+                return true
+            }
+
+            override fun onFinished(result: Boolean) {
+                assertTrue(result)
+                assertEquals(Looper.getMainLooper(), Looper.myLooper())
+            }
+        }
+
+        runBlocking {
+            assertTrue(backgroundExecutor.executeDeferred(addTask).await())
+        }
+    }
+
     private fun testAddingContact(
         fetchIdentity: (identity: String) -> FetchIdentityResult,
-        runOnFinished: (result: ContactAddResult) -> Unit,
+        runOnFinished: (result: ContactResult) -> Unit,
         newIdentity: String = "01234567",
+        acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
         myIdentity: String = "00000000",
         publicKey: ByteArray? = null,
     ) {
@@ -386,19 +494,21 @@ class AddOrUpdateContactBackgroundTaskTest {
             fetchIdentity(it)
         }
 
-        val contactAdded = backgroundExecutor.executeDeferred(object : AddOrUpdateContactBackgroundTask(
-            newIdentity,
-            myIdentity,
-            apiConnector,
-            contactModelRepository,
-            AddContactRestrictionPolicy.CHECK,
-            ThreemaApplication.getAppContext(),
-            publicKey,
-        ) {
-            override fun onFinished(result: ContactAddResult) {
-                runOnFinished(result)
-            }
-        })
+        val contactAdded =
+            backgroundExecutor.executeDeferred(object : BasicAddOrUpdateContactBackgroundTask(
+                newIdentity,
+                acquaintanceLevel,
+                myIdentity,
+                apiConnector,
+                contactModelRepository,
+                AddContactRestrictionPolicy.CHECK,
+                ThreemaApplication.getAppContext(),
+                publicKey,
+            ) {
+                override fun onFinished(result: ContactResult) {
+                    runOnFinished(result)
+                }
+            })
 
         // Assert that the test is not stopped before running the background task completely
         runBlocking {

+ 286 - 0
app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt

@@ -0,0 +1,286 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.contacts
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDevicePropertyProvider
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy
+import ch.threema.app.asynctasks.ContactSyncPolicy
+import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
+import ch.threema.app.asynctasks.DeleteContactServices
+import ch.threema.app.managers.CoreServiceManager
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
+import ch.threema.app.tasks.ReflectContactSyncUpdateTask
+import ch.threema.app.tasks.TaskCreator
+import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.data.TestDatabaseService
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.helpers.TransactionAckTaskCodec
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
+import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
+import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.taskmanager.QueueSendCompleteListener
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import com.neilalexander.jnacl.NaCl
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@DangerousTest
+class MarkContactAsDeletedBackgroundTaskTest {
+
+    private val backgroundExecutor = BackgroundExecutor()
+    private lateinit var testTaskCodec: TransactionAckTaskCodec
+    private val testTaskManager = object : TaskManager {
+        val taskQueue: MutableList<Task<*, TaskCodec>> = mutableListOf()
+
+        override fun <R> schedule(task: Task<R, TaskCodec>): Deferred<R> {
+            taskQueue.add(task)
+            return CompletableDeferred()
+        }
+
+        override fun hasPendingTasks(): Boolean {
+            return taskQueue.isNotEmpty()
+        }
+
+        override fun addQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+            // Nothing to do
+        }
+
+        override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+            // Nothing to do
+        }
+
+    }
+    private lateinit var databaseService: TestDatabaseService
+    private val multiDeviceManager = object : MultiDeviceManager {
+        var multiDeviceEnabled = false
+
+        override val isMdDisabledOrSupportsFs: Boolean
+            get() = !multiDeviceEnabled
+        override val isMultiDeviceActive: Boolean
+            get() = multiDeviceEnabled
+        override val propertiesProvider = TestMultiDevicePropertyProvider
+        override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { }
+        override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf()
+
+        override suspend fun activate(
+            deviceLabel: String,
+            contactService: ContactService,
+            userService: UserService,
+            fsMessageProcessor: ForwardSecurityMessageProcessor,
+            taskCreator: TaskCreator,
+        ) {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun deactivate(
+            userService: UserService,
+            fsMessageProcessor: ForwardSecurityMessageProcessor,
+            taskCreator: TaskCreator,
+        ) {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun setDeviceLabel(deviceLabel: String) {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun linkDevice(
+            deviceJoinOfferUri: String,
+            taskCreator: TaskCreator,
+        ): Flow<DeviceLinkingStatus> {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun purge(taskCreator: TaskCreator) {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
+            MUST_NOT_BE_CALLED()
+        }
+
+    }
+    private lateinit var coreServiceManager: CoreServiceManager
+    private lateinit var contactModelRepository: ContactModelRepository
+    private lateinit var deleteContactServices: DeleteContactServices
+    private val testContactModelData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
+        createdAt = Date(),
+        firstName = "1234",
+        lastName = "5678",
+        nickname = null,
+        verificationLevel = VerificationLevel.FULLY_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 0u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        profilePictureBlobId = null,
+        jobTitle = null,
+        department = null,
+    )
+
+    @Before
+    fun before() {
+        databaseService = TestDatabaseService()
+        val serviceManager = ThreemaApplication.requireServiceManager()
+        testTaskCodec = TransactionAckTaskCodec()
+        coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = serviceManager.preferenceStore,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = testTaskManager,
+        )
+        deleteContactServices = DeleteContactServices(
+            serviceManager.userService,
+            serviceManager.contactService,
+            serviceManager.conversationService,
+            serviceManager.ringtoneService,
+            serviceManager.mutedChatsListService,
+            serviceManager.hiddenChatsListService,
+            serviceManager.profilePicRecipientsService,
+            serviceManager.wallpaperService,
+            serviceManager.fileService,
+            serviceManager.excludedSyncIdentitiesService,
+            serviceManager.dhSessionStore,
+            serviceManager.notificationService,
+            serviceManager.databaseServiceNew,
+        )
+        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+
+        // Add a contact "from sync". This has no side effects and does not reflect the contact.
+        contactModelRepository.createFromSync(testContactModelData)
+    }
+
+    @Test
+    fun testAcquaintanceLevelChange() = runTest {
+        val contactModel = contactModelRepository.getByIdentity(testContactModelData.identity)
+        // Assert that the contact exists as "direct" contact
+        assertNotNull(contactModel)
+        assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value?.acquaintanceLevel)
+
+        // Remove the contact
+        backgroundExecutor.executeDeferred(
+            MarkContactAsDeletedBackgroundTask(
+                setOf(testContactModelData.identity),
+                contactModelRepository,
+                deleteContactServices,
+                ContactSyncPolicy.INCLUDE,
+                AndroidContactLinkPolicy.REMOVE_LINK,
+            )
+        ).await()
+
+        // Assert that the contact's acquaintance level is "group" now
+        assertEquals(AcquaintanceLevel.GROUP, contactModel.data.value?.acquaintanceLevel)
+    }
+
+    @Test
+    fun testNoReflection() = runTest {
+        // Disable multi device
+        multiDeviceManager.multiDeviceEnabled = false
+
+        val contactModel = contactModelRepository.getByIdentity(testContactModelData.identity)
+        // Assert that the contact exists as "direct" contact
+        assertNotNull(contactModel)
+        assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value?.acquaintanceLevel)
+
+        // Remove the contact
+        backgroundExecutor.executeDeferred(
+            MarkContactAsDeletedBackgroundTask(
+                setOf(testContactModelData.identity),
+                contactModelRepository,
+                deleteContactServices,
+                ContactSyncPolicy.INCLUDE,
+                AndroidContactLinkPolicy.REMOVE_LINK,
+            )
+        ).await()
+
+        // Assert that the there was no task scheduled
+        assertTrue(testTaskManager.taskQueue.isEmpty())
+    }
+
+    @Test
+    fun testReflection() = runTest {
+        // Enable multi device
+        multiDeviceManager.multiDeviceEnabled = true
+
+        val contactModel = contactModelRepository.getByIdentity(testContactModelData.identity)
+        // Assert that the contact exists as "direct" contact
+        assertNotNull(contactModel)
+        assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value?.acquaintanceLevel)
+
+        // Mark the contact as deleted
+        backgroundExecutor.executeDeferred(
+            MarkContactAsDeletedBackgroundTask(
+                setOf(testContactModelData.identity),
+                contactModelRepository,
+                deleteContactServices,
+                ContactSyncPolicy.INCLUDE,
+                AndroidContactLinkPolicy.REMOVE_LINK,
+            )
+        ).await()
+
+        // Assert that a reflection task has been scheduled
+        val task = testTaskManager.taskQueue.removeFirstOrNull()
+        assertIs<ReflectContactSyncUpdateTask>(task)
+
+        // Assert that no other tasks have been scheduled
+        assertTrue(testTaskManager.taskQueue.isEmpty())
+    }
+}

+ 334 - 0
app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt

@@ -0,0 +1,334 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.contacts
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.processors.reflectedd2dsync.ReflectedContactSyncTask
+import ch.threema.data.TestDatabaseService
+import ch.threema.data.models.ContactModel
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.models.ContactModelData.Companion.getIdColorIndex
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.helpers.TransactionAckTaskCodec
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.protobuf.d2d.ContactSyncKt.create
+import ch.threema.protobuf.d2d.ContactSyncKt.update
+import ch.threema.protobuf.d2d.contactSync
+import ch.threema.protobuf.d2d.sync.ContactKt.notificationSoundPolicyOverride
+import ch.threema.protobuf.d2d.sync.ContactKt.readReceiptPolicyOverride
+import ch.threema.protobuf.d2d.sync.ContactKt.typingIndicatorPolicyOverride
+import ch.threema.protobuf.d2d.sync.MdD2DSync
+import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.ActivityState
+import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.ReadReceiptPolicyOverride
+import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.TypingIndicatorPolicyOverride
+import ch.threema.protobuf.d2d.sync.contact
+import ch.threema.protobuf.unit
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import com.google.protobuf.kotlin.toByteString
+import com.neilalexander.jnacl.NaCl
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.runner.RunWith
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+@RunWith(AndroidJUnit4::class)
+class ReflectedContactSyncTaskTest {
+
+    private lateinit var databaseService: TestDatabaseService
+    private lateinit var taskCodec: TransactionAckTaskCodec
+    private lateinit var coreServiceManager: TestCoreServiceManager
+    private lateinit var contactModelRepository: ContactModelRepository
+
+    private val initialContactModelData = ContactModelData(
+        identity = "01234567",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        nickname = "Nick",
+        colorIndex = getIdColorIndex("01234567"),
+        verificationLevel = VerificationLevel.UNVERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 511u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        profilePictureBlobId = null,
+        jobTitle = null,
+        department = null,
+    )
+
+    @Before
+    fun before() {
+        databaseService = TestDatabaseService()
+        taskCodec = TransactionAckTaskCodec()
+        coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            multiDeviceManager = TestMultiDeviceManager(
+                isMultiDeviceActive = true,
+                isMdDisabledOrSupportsFs = false,
+            ),
+            taskManager = TestTaskManager(taskCodec),
+        )
+        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+    }
+
+    @Test
+    fun testNewReflectedContact() {
+        val contact = contact {
+            identity = "01234567"
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES) { it.toByte() }.toByteString()
+            createdAt = Date().time
+            firstName = "0123"
+            // No last name provided
+            nickname = "nick"
+            verificationLevel = MdD2DSync.Contact.VerificationLevel.FULLY_VERIFIED
+            workVerificationLevel = MdD2DSync.Contact.WorkVerificationLevel.NONE
+            identityType = MdD2DSync.Contact.IdentityType.WORK
+            acquaintanceLevel = MdD2DSync.Contact.AcquaintanceLevel.DIRECT
+            activityState = ActivityState.INACTIVE
+            featureMask = 123
+            syncState = MdD2DSync.Contact.SyncState.IMPORTED
+            readReceiptPolicyOverride = readReceiptPolicyOverride {
+                default = unit {}
+            }
+            typingIndicatorPolicyOverride = typingIndicatorPolicyOverride {
+                policy = MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
+            }
+            notificationTriggerPolicyOverride =
+                MdD2DSync.Contact.NotificationTriggerPolicyOverride.getDefaultInstance()
+            notificationSoundPolicyOverride = notificationSoundPolicyOverride {
+                policy = MdD2DSync.NotificationSoundPolicy.MUTED
+            }
+            conversationCategory = MdD2DSync.ConversationCategory.DEFAULT
+            conversationVisibility = MdD2DSync.ConversationVisibility.NORMAL
+        }
+
+        testReflectedContactCreate(contact) { contactModel ->
+            val data = contactModel.data.value!!
+            assertEquals(contact.identity, data.identity)
+            assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES) { it.toByte() }, data.publicKey)
+            assertEquals(contact.createdAt, data.createdAt.time)
+            assertEquals(contact.firstName, data.firstName)
+            assertEquals("", data.lastName)
+            assertEquals(contact.nickname, data.nickname)
+            assertEquals(contact.verificationLevel.convert(), data.verificationLevel)
+            assertEquals(contact.workVerificationLevel.convert(), data.workVerificationLevel)
+            assertEquals(contact.identityType.convert(), data.identityType)
+            assertEquals(contact.acquaintanceLevel.convert(), data.acquaintanceLevel)
+            assertEquals(contact.activityState.convert(), data.activityState)
+            assertEquals(contact.featureMask, data.featureMask.toLong())
+            assertEquals(contact.syncState.convert(), data.syncState)
+            assertEquals(contact.readReceiptPolicyOverride.convert(), data.readReceiptPolicy)
+            assertEquals(contact.typingIndicatorPolicyOverride.convert(), data.typingIndicatorPolicy)
+        }
+    }
+
+    @Test
+    fun testReflectedNicknameChange() {
+        val newNickname = "new nickname"
+        testReflectedContactUpdate(
+            contact {
+                identity = "01234567"
+                nickname = newNickname
+            }
+        ) { contactModel ->
+            assertEquals(newNickname, contactModel.data.value?.nickname)
+        }
+    }
+
+    private fun testReflectedContactCreate(
+        contact: MdD2DSync.Contact,
+        assertContactCreated: (contactModel: ContactModel) -> Unit,
+    ) {
+        assertNull(contactModelRepository.getByIdentity(contact.identity))
+
+        ReflectedContactSyncTask(
+            contact.toContactSyncCreate(),
+            contactModelRepository,
+            ThreemaApplication.requireServiceManager(),
+        ).run()
+
+        // Assert that no transaction have been executed
+        assertZeroTransactionCount()
+
+        // Assert that no messages have been sent
+        assertNoMessagesSent()
+
+        // Assert that the create has been applied
+        val contactModel = contactModelRepository.getByIdentity(contact.identity)!!
+        assertContactCreated(contactModel)
+    }
+
+    private fun testReflectedContactUpdate(
+        contact: MdD2DSync.Contact,
+        assertUpdateApplied: (contactModel: ContactModel) -> Unit,
+    ) {
+        createContact(initialContactModelData.copy(identity = contact.identity))
+
+        ReflectedContactSyncTask(
+            contact.toContactSyncUpdate(),
+            contactModelRepository,
+            ThreemaApplication.requireServiceManager(),
+        ).run()
+
+        // Assert that no transaction have been executed
+        assertZeroTransactionCount()
+
+        // Assert that no messages have been sent
+        assertNoMessagesSent()
+
+        // Assert that update has been applied
+        val contactModel = contactModelRepository.getByIdentity(contact.identity)!!
+        assertUpdateApplied(contactModel)
+    }
+
+    private fun createContact(contactModelData: ContactModelData) {
+        runBlocking {
+            contactModelRepository.createFromLocal(contactModelData)
+        }
+        assertAndClearOneTransactionCount()
+    }
+
+    private fun assertAndClearOneTransactionCount() {
+        assertEquals(1, taskCodec.transactionBeginCount)
+        assertEquals(1, taskCodec.transactionCommitCount)
+
+        taskCodec.transactionBeginCount = 0
+        taskCodec.transactionCommitCount = 0
+    }
+
+    private fun assertZeroTransactionCount() {
+        assertEquals(0, taskCodec.transactionBeginCount)
+        assertEquals(0, taskCodec.transactionCommitCount)
+    }
+
+    private fun assertNoMessagesSent() {
+        assertTrue { taskCodec.outboundMessages.isEmpty() }
+    }
+
+    private fun MdD2DSync.Contact.toContactSyncCreate() = contactSync {
+        create = create {
+            contact = this@toContactSyncCreate
+        }
+    }
+
+    private fun MdD2DSync.Contact.toContactSyncUpdate() = contactSync {
+        update = update {
+            contact = this@toContactSyncUpdate
+        }
+    }
+
+    private fun MdD2DSync.Contact.VerificationLevel.convert(): VerificationLevel = when (this) {
+        MdD2DSync.Contact.VerificationLevel.FULLY_VERIFIED -> VerificationLevel.FULLY_VERIFIED
+        MdD2DSync.Contact.VerificationLevel.SERVER_VERIFIED -> VerificationLevel.SERVER_VERIFIED
+        MdD2DSync.Contact.VerificationLevel.UNVERIFIED -> VerificationLevel.UNVERIFIED
+        MdD2DSync.Contact.VerificationLevel.UNRECOGNIZED -> fail("Verification level is unrecognized")
+    }
+
+    private fun MdD2DSync.Contact.WorkVerificationLevel.convert(): WorkVerificationLevel =
+        when (this) {
+            MdD2DSync.Contact.WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED -> WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED
+            MdD2DSync.Contact.WorkVerificationLevel.NONE -> WorkVerificationLevel.NONE
+            MdD2DSync.Contact.WorkVerificationLevel.UNRECOGNIZED -> fail("Work verification level is unrecognized")
+        }
+
+    private fun MdD2DSync.Contact.IdentityType.convert(): IdentityType = when (this) {
+        MdD2DSync.Contact.IdentityType.REGULAR -> IdentityType.NORMAL
+        MdD2DSync.Contact.IdentityType.WORK -> IdentityType.WORK
+        MdD2DSync.Contact.IdentityType.UNRECOGNIZED -> fail("Identity type is unrecognized")
+    }
+
+    private fun MdD2DSync.Contact.AcquaintanceLevel.convert(): AcquaintanceLevel =
+        when (this) {
+            MdD2DSync.Contact.AcquaintanceLevel.DIRECT -> AcquaintanceLevel.DIRECT
+            MdD2DSync.Contact.AcquaintanceLevel.GROUP -> AcquaintanceLevel.GROUP
+            MdD2DSync.Contact.AcquaintanceLevel.UNRECOGNIZED -> fail("Acquaintance level is unrecognized")
+        }
+
+    private fun ActivityState.convert(): IdentityState = when (this) {
+        ActivityState.ACTIVE -> IdentityState.ACTIVE
+        ActivityState.INACTIVE -> IdentityState.INACTIVE
+        ActivityState.INVALID -> IdentityState.INVALID
+        ActivityState.UNRECOGNIZED -> fail("Activity state is unrecognized")
+    }
+
+    private fun MdD2DSync.Contact.SyncState.convert(): ContactSyncState = when (this) {
+        MdD2DSync.Contact.SyncState.INITIAL -> ContactSyncState.INITIAL
+        MdD2DSync.Contact.SyncState.IMPORTED -> ContactSyncState.IMPORTED
+        MdD2DSync.Contact.SyncState.CUSTOM -> ContactSyncState.CUSTOM
+        MdD2DSync.Contact.SyncState.UNRECOGNIZED -> fail("Sync state is unrecognized")
+    }
+
+    private fun ReadReceiptPolicyOverride.convert(): ReadReceiptPolicy = when (overrideCase) {
+        ReadReceiptPolicyOverride.OverrideCase.DEFAULT -> ReadReceiptPolicy.DEFAULT
+        ReadReceiptPolicyOverride.OverrideCase.POLICY -> when (policy) {
+            MdD2DSync.ReadReceiptPolicy.SEND_READ_RECEIPT -> ReadReceiptPolicy.SEND
+            MdD2DSync.ReadReceiptPolicy.DONT_SEND_READ_RECEIPT -> ReadReceiptPolicy.DONT_SEND
+            MdD2DSync.ReadReceiptPolicy.UNRECOGNIZED -> fail("Read receipt policy is unrecognized")
+            null -> fail("Read receipt policy is null")
+        }
+
+        ReadReceiptPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Read receipt policy override not set")
+        null -> fail("Read receipt policy override is null")
+    }
+
+    private fun TypingIndicatorPolicyOverride.convert(): TypingIndicatorPolicy =
+        when (overrideCase) {
+            TypingIndicatorPolicyOverride.OverrideCase.DEFAULT -> TypingIndicatorPolicy.DEFAULT
+            TypingIndicatorPolicyOverride.OverrideCase.POLICY -> when (policy) {
+                MdD2DSync.TypingIndicatorPolicy.SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.SEND
+                MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.DONT_SEND
+                MdD2DSync.TypingIndicatorPolicy.UNRECOGNIZED -> fail("Typing indicator policy is unrecognized")
+                null -> fail("Typing indicator policy is null")
+            }
+
+            TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
+            null -> fail("Typing indicator policy override is null")
+        }
+
+}

+ 38 - 4
app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt

@@ -26,11 +26,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.activities.HomeActivity
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy
+import ch.threema.app.asynctasks.ContactSyncPolicy
+import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask
+import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
+import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.domain.models.MessageId
@@ -42,6 +48,7 @@ import ch.threema.domain.protocol.csp.messages.GroupDeleteMessage
 import ch.threema.domain.protocol.csp.messages.GroupEditMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
+import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.factories.GroupMessageModelFactory
 import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
@@ -62,9 +69,28 @@ class EditHistoryTest : MessageProcessorProvider() {
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val groupService: GroupService by lazy { serviceManager.groupService }
-    private val messageModelFactory: MessageModelFactory by lazy { serviceManager.databaseServiceNew.messageModelFactory }
-    private val groupMessageModelFactory: GroupMessageModelFactory by lazy { serviceManager.databaseServiceNew.groupMessageModelFactory }
-    private val editHistoryDao: EditHistoryDao by lazy { EditHistoryDaoImpl(serviceManager.databaseServiceNew) }
+    private val databaseService: DatabaseServiceNew by lazy { serviceManager.databaseServiceNew }
+    private val messageModelFactory: MessageModelFactory by lazy { databaseService.messageModelFactory }
+    private val groupMessageModelFactory: GroupMessageModelFactory by lazy { databaseService.groupMessageModelFactory }
+    private val editHistoryDao: EditHistoryDao by lazy { EditHistoryDaoImpl(databaseService) }
+    private val contactModelRepository: ContactModelRepository by lazy { serviceManager.modelRepositories.contacts }
+    private val deleteContactServices: DeleteContactServices by lazy {
+        DeleteContactServices(
+            serviceManager.userService,
+            contactService,
+            serviceManager.conversationService,
+            serviceManager.ringtoneService,
+            serviceManager.mutedChatsListService,
+            serviceManager.hiddenChatsListService,
+            serviceManager.profilePicRecipientsService,
+            serviceManager.wallpaperService,
+            serviceManager.fileService,
+            serviceManager.excludedSyncIdentitiesService,
+            serviceManager.dhSessionStore,
+            serviceManager.notificationService,
+            databaseService,
+        )
+    }
 
     @Test
     fun testHistoryDeletedOnContactMessageDelete() = runTest {
@@ -248,7 +274,15 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         messageModel.assertHistorySize(1)
 
-        contactService.remove(contactA.contactModel)
+        BackgroundExecutor().executeDeferred(
+            MarkContactAsDeletedBackgroundTask(
+                setOf(messageModel.identity),
+                contactModelRepository,
+                deleteContactServices,
+                ContactSyncPolicy.INCLUDE,
+                AndroidContactLinkPolicy.KEEP,
+            )
+        ).await()
 
         messageModel.assertHistorySize(0)
     }

+ 45 - 17
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -155,14 +155,19 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         // The common group receive steps are not executed for group leave messages
     }
 
-    private suspend fun assertSuccessfulLeave(group: TestGroup, contact: TestContact, expectStateChange: Boolean = false) {
+    private suspend fun assertSuccessfulLeave(
+        group: TestGroup,
+        contact: TestContact,
+        expectStateChange: Boolean = false,
+    ) {
         launchActivity<HomeActivity>()
 
         serviceManager.groupService.resetCache(group.groupModel.id)
 
         assertEquals(
             group.members.map { it.identity },
-            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+            serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+        )
 
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
             .apply { start() }
@@ -182,7 +187,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         )
         assertEquals(
             group.members.map { it.identity }.filter { it != contact.identity },
-            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+            serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+        )
 
         // Assert that no message has been sent as a response to a group leave
         assertEquals(0, sentMessagesInsideTask.size)
@@ -200,9 +206,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
         serviceManager.groupService.resetCache(group.groupModel.id)
 
-        assertEquals(
-            expectedMemberList,
-            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
+        assertGroupIdentities(expectedMemberList, group)
+        assertMemberCount(expectedMemberList.size, group)
 
         val leaveTracker = GroupLeaveTracker(group, contact.identity).apply { start() }
 
@@ -215,13 +220,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
         serviceManager.groupService.resetCache(group.groupModel.id)
 
-        assertEquals(
-            expectedMemberList,
-            serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
-        assertEquals(
-            expectedMemberList.size,
-            serviceManager.groupService.countMembers(group.groupModel)
-        )
+        assertGroupIdentities(expectedMemberList, group)
+        assertMemberCount(expectedMemberList.size, group)
 
         if (shouldSendSyncRequest) {
             // Should send sync request to the group creator
@@ -245,6 +245,37 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             toIdentity = myContact.identity
         }
 
+    private fun assertGroupIdentities(expectedMemberList: List<String>, group: TestGroup) {
+        if (serviceManager.groupService.getByApiGroupIdAndCreator(
+                group.apiGroupId, group.groupCreator.identity
+            ) != null
+        ) {
+            // We check the expected members if the group is available in the database. If there is
+            // no such group, we do not need to perform this check as we would not be able to
+            // retrieve a group model.
+            assertEquals(
+                expectedMemberList,
+                serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+            )
+        }
+    }
+
+    private fun assertMemberCount(expectedMemberCount: Int, group: TestGroup) {
+        if (serviceManager.groupService.getByApiGroupIdAndCreator(
+                group.apiGroupId,
+                group.groupCreator.identity
+            ) != null
+        ) {
+            // We only check the expected members if the group is available in the database.
+            // Otherwise the check does not make sense as we would not be able to retrieve a group
+            // model.
+            assertEquals(
+                expectedMemberCount,
+                serviceManager.groupService.countMembers(group.groupModel)
+            )
+        }
+    }
+
     private class GroupLeaveTracker(
         private val group: TestGroup?,
         private val leavingIdentity: String?,
@@ -264,13 +295,11 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onNewMember(
                 group: GroupModel?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) = fail()
 
             override fun onMemberLeave(
                 groupModel: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
                 assertFalse(memberHasLeft)
                 group?.let {
@@ -284,7 +313,6 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onMemberKicked(
                 group: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) = fail()
 
             override fun onUpdate(groupModel: GroupModel?) = fail()
@@ -294,7 +322,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onGroupStateChanged(
                 groupModel: GroupModel?,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
                 if (!expectStateChange) {
                     fail()

+ 2 - 5
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -67,7 +67,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
         // Create group rename message
         val groupARenamed =
-            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed")
+            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed", myContact.identity)
 
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
 
@@ -103,7 +103,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
         // Create group rename message (from wrong sender)
         val groupARenamed =
-            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed")
+            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed", myContact.identity)
 
         val renameTracker = GroupRenameTracker(null).apply { start() }
 
@@ -215,7 +215,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onNewMember(
                 group: GroupModel?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) {
                 fail()
             }
@@ -223,7 +222,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onMemberLeave(
                 group: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
                 fail()
             }
@@ -231,7 +229,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onMemberKicked(
                 group: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
                 fail()
             }

+ 33 - 5
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -38,6 +38,7 @@ import org.junit.After
 import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
 import org.junit.Test
@@ -200,6 +201,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert initial group conversations
         assertGroupConversations(scenario, initialGroups)
 
+        // Assert that the user is a member of groupAB
+        val beforeKicked = serviceManager.groupService.getById(groupAB.groupModel.id)
+        assertNotNull(beforeKicked)
+        assertEquals(GroupModel.UserState.MEMBER, beforeKicked!!.userState)
+        assertTrue(serviceManager.groupService.isGroupMember(beforeKicked))
+
         val setupTracker = GroupSetupTracker(
             groupAB,
             myContact.identity,
@@ -217,6 +224,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Create message box from contact A (group creator)
         processMessage(message, groupAB.groupCreator.identityStore)
 
+        // Assert that the user state has been changed to 'kicked'
+        val afterKicked = serviceManager.groupService.getById(groupAB.groupModel.id)
+        assertNotNull(afterKicked)
+        assertEquals(GroupModel.UserState.KICKED, afterKicked!!.userState)
+        assertFalse(serviceManager.groupService.isGroupMember(afterKicked))
+
         // Assert that group conversations did not appear, disappear, or change their name
         assertGroupConversations(scenario, initialGroups)
 
@@ -287,7 +300,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             newAGroup.members,
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
-            "12345678, Me, ABCDEFGH",
+            "Me, 12345678, ABCDEFGH",
+            myContact.identity,
         )
 
         val setupTracker = GroupSetupTracker(
@@ -316,6 +330,22 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         setupTracker.assertAllKickedMembersRemoved()
         setupTracker.assertCreateLeave()
         setupTracker.stop()
+
+        // Assert that the group has the correct members
+        val group = serviceManager.groupService.getByApiGroupIdAndCreator(newGroup.apiGroupId, newGroup.groupCreator.identity)
+        assertNotNull(group!!)
+        val expectedMemberCount = newGroup.members.size
+        // Assert that there is one more member than member models (as the user is not stored into
+        // the database).
+        assertEquals(expectedMemberCount, serviceManager.databaseServiceNew.groupMemberModelFactory.getByGroupId(group.id).size + 1)
+        assertEquals(expectedMemberCount, serviceManager.databaseServiceNew.groupMemberModelFactory.countMembersWithoutUser(group.id).toInt() + 1)
+
+        // Assert that the group service returns the member lists including the user
+        assertEquals(expectedMemberCount, serviceManager.groupService.getMembers(group).size)
+        assertEquals(expectedMemberCount, serviceManager.groupService.getGroupIdentities(group).size)
+        assertEquals(expectedMemberCount, serviceManager.groupService.getMembersWithoutUser(group).size + 1)
+        assertEquals(expectedMemberCount, serviceManager.groupService.countMembers(group))
+        assertEquals(expectedMemberCount, serviceManager.groupService.countMembersWithoutUser(group) + 1)
     }
 
     /**
@@ -380,7 +410,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             newAGroup.members + TestContact(invalidMemberId), // Note that this ID is not valid
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
-            "12345678, Me, ABCDEFGH",
+            "Me, 12345678, ABCDEFGH",
+            myContact.identity,
         )
 
         val setupTracker = GroupSetupTracker(
@@ -459,7 +490,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             override fun onNewMember(
                 group: GroupModel?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) {
                 assertTrue("Did not expect member $newIdentity", newMembers.contains(newIdentity))
                 newMembersAdded.add(newIdentity!!)
@@ -468,13 +498,11 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             override fun onMemberLeave(
                 group: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) = fail()
 
             override fun onMemberKicked(
                 group: GroupModel?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
                 assertTrue(kickedMembers.contains(identity))
                 kickedMembersRemoved.add(identity!!)

+ 5 - 10
app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt

@@ -43,14 +43,12 @@ import ch.threema.domain.protocol.csp.messages.ballot.PollSetupMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.fail
 import org.junit.Test
 import java.util.Date
 
-@OptIn(ExperimentalCoroutinesApi::class)
 @DangerousTest
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
@@ -93,9 +91,9 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         }
 
         val pollSetupMessage = PollSetupMessage().also {
-            it.ballotCreator = ballotCreator
+            it.ballotCreatorIdentity = ballotCreator
             it.ballotId = ballotId
-            it.data = ballotData
+            it.ballotData = ballotData
         }.enrich()
 
         // Test a valid ballot setup message that opens a poll
@@ -103,12 +101,9 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
             voteMessage.ballotId = ballotId
-            voteMessage.ballotCreator = ballotCreator
-            voteMessage.ballotVotes.addAll(List(5) { index ->
-                BallotVote().also {
-                    it.id = index
-                    it.value = 0
-                }
+            voteMessage.ballotCreatorIdentity = ballotCreator
+            voteMessage.votes.addAll(List(5) { index ->
+                BallotVote(index, 0)
             })
         }.enrich()
 

+ 109 - 53
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -28,6 +28,7 @@ import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.FileService
@@ -36,18 +37,25 @@ import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ForwardSecurityStatusSender
+import ch.threema.base.crypto.HashedNonce
+import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceFactory
+import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryDHSessionStore
 import ch.threema.domain.helpers.InMemoryNonceStore
+import ch.threema.domain.models.BasicContact
 import ch.threema.domain.models.Contact
 import ch.threema.domain.models.GroupId
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
 import ch.threema.domain.protocol.ThreemaFeature
+import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.coders.MessageBox
@@ -59,7 +67,7 @@ import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityDataInit
 import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityEnvelopeMessage
 import ch.threema.domain.stores.ContactStore
 import ch.threema.domain.stores.IdentityStoreInterface
-import ch.threema.domain.taskmanager.ActiveTaskCodec
+import ch.threema.domain.taskmanager.ActiveTask
 import ch.threema.domain.taskmanager.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
@@ -87,31 +95,32 @@ open class MessageProcessorProvider {
     protected val contactB = TestContact("ABCDEFGH")
     protected val contactC = TestContact("TESTTEST")
 
-    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA, contactB), "MyGroup")
+    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA, contactB), "MyGroup", myContact.identity)
     protected val myGroupWithProfilePicture =
         TestGroup(
             GroupId(1),
             myContact,
             listOf(myContact, contactA),
             "MyGroupWithPicture",
-            byteArrayOf(0, 1, 2, 3)
+            byteArrayOf(0, 1, 2, 3),
+            myContact.identity
         )
     protected val groupA =
-        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA")
+        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
     protected val groupB =
-        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB")
+        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB", myContact.identity)
     protected val groupAB =
-        TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB")
+        TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB", myContact.identity)
     protected val groupAUnknown =
-        TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown")
+        TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown", myContact.identity)
     protected val groupALeft =
-        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft")
+        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft", myContact.identity)
     protected val myUnknownGroup =
-        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup")
+        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup", myContact.identity)
     protected val myLeftGroup =
-        TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup")
+        TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
     protected val newAGroup =
-        TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup")
+        TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup", myContact.identity)
 
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
     private val contactStore: ContactStore = InMemoryContactStore().apply {
@@ -128,11 +137,21 @@ open class MessageProcessorProvider {
         contactC.identity to contactC.identityStore,
     ).toMap()
 
-    private val forwardSecurityStatusListener = object : ForwardSecurityStatusSender(serviceManager.contactService, serviceManager.messageService, null) {
+    private val forwardSecurityStatusListener = object : ForwardSecurityStatusSender(
+        serviceManager.contactService,
+        serviceManager.messageService,
+        APIConnector(
+            false,
+            null,
+            false
+        ) { host -> ConfigUtils.getSSLSocketFactory(host) },
+        serviceManager.userService,
+        serviceManager.modelRepositories.contacts,
+    ) {
         override fun messageWithoutFSReceived(
             contact: Contact,
             session: DHSession,
-            message: AbstractMessage
+            message: AbstractMessage,
         ) {
             throw AssertionError("We do not accept messages without forward security")
         }
@@ -244,12 +263,6 @@ open class MessageProcessorProvider {
 
             override fun hasPendingTasks(): Boolean = false
 
-            @Deprecated(
-                "We should only be able to send and receive messages from within tasks.",
-                replaceWith = ReplaceWith("TaskManager#schedule")
-            )
-            override fun getMigrationTaskHandle(): ActiveTaskCodec = globalTaskCodec
-
             override fun addQueueSendCompleteListener(listener: QueueSendCompleteListener) {
                 // Nothing to do
             }
@@ -280,20 +293,29 @@ open class MessageProcessorProvider {
             // encapsulated message as we only want to initiate a new fs session. Therefore we just
             // need to send the first message, which is the init.
             val result =
-                myForwardSecurityMessageProcessor.makeMessage(it.contact, textMessage, globalTaskCodec)
+                myForwardSecurityMessageProcessor.runFsEncapsulationSteps(
+                    it.toBasicContact(),
+                    textMessage,
+                    nonceFactory.next(NonceScope.CSP),
+                    nonceFactory,
+                    globalTaskCodec,
+                )
 
             // Commit the dh session state
             myForwardSecurityMessageProcessor.commitSessionState(result)
 
             // Process the init message
-            val initCspMessage = result
-                .outgoingMessages
-                .first()
-                .apply { toIdentity = it.contact.identity }
-                .toCspMessage(myContact.identityStore, contactStore, nonceFactory, nonceFactory.next(false))
+            val (initMessage, initNonce) = result.outgoingMessages.first()
+
+            initMessage.toIdentity = it.contact.identity
+            val initCspMessage =
+                initMessage.toCspMessage(myContact.identityStore, contactStore, initNonce)
 
             val initMessageBox = MessageBox.parseBinary(initCspMessage.toOutgoingMessageData().data)
-            val init = MessageCoder(contactStore, it.identityStore).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
+            val init = MessageCoder(
+                contactStore,
+                it.identityStore
+            ).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
             runBlocking {
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                     myContact.contact,
@@ -390,9 +412,11 @@ open class MessageProcessorProvider {
             serviceManager.databaseServiceNew,
             serviceManager.preferenceStore,
             TaskArchiverImpl(serviceManager.databaseServiceNew.taskArchiveFactory),
-            serviceManager.deviceCookieManager as DeviceCookieManagerImpl,
+            serviceManager.deviceCookieManager,
             taskManager,
-            serviceManager.multiDeviceManager as MultiDeviceManagerImpl
+            serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
+            serviceManager.identityStore,
+            serviceManager.nonceFactory,
         )
 
         val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
@@ -427,7 +451,14 @@ open class MessageProcessorProvider {
         val contactStore = serviceManager.contactStore
         val fileService = serviceManager.fileService
 
-        initialContacts.forEach { addContactToDatabase(it, databaseService, contactStore, AcquaintanceLevel.GROUP) }
+        initialContacts.forEach {
+            addContactToDatabase(
+                it,
+                databaseService,
+                contactStore,
+                AcquaintanceLevel.GROUP
+            )
+        }
 
         initialGroups.forEach { addGroupToDatabase(it, databaseService, fileService) }
     }
@@ -443,7 +474,10 @@ open class MessageProcessorProvider {
                 .setFeatureMask(ThreemaFeature.FORWARD_SECURITY)
         )
 
-        contactStore.addCachedContact(testContact.contact)
+        contactStore.addCachedContact(testContact.toBasicContact())
+
+        // We trigger the listeners to invalidate the cache of the new contact model.
+        ListenerManager.contactListeners.handle { it.onModified(testContact.identity) }
     }
 
     private fun addGroupToDatabase(
@@ -454,7 +488,7 @@ open class MessageProcessorProvider {
         val groupModel = testGroup.groupModel
         databaseService.groupModelFactory.createOrUpdate(groupModel)
         testGroup.setLocalGroupId(groupModel.id)
-        testGroup.members.forEach { member ->
+        testGroup.members.filter { it.identity != myContact.identity }.forEach { member ->
             val memberModel = GroupMemberModel()
                 .setGroupId(groupModel.id)
                 .setIdentity(member.identity)
@@ -479,20 +513,9 @@ open class MessageProcessorProvider {
         )
 
         // Process the group message
-        val messageProcessor = serviceManager.let {
-            IncomingMessageProcessorImpl(
-                it.messageService,
-                it.nonceFactory,
-                it.forwardSecurityMessageProcessor,
-                it.contactService,
-                it.contactStore,
-                it.identityStore,
-                it.blockedContactsService,
-                it.preferenceService,
-                it
-            )
-        }
-        messageProcessor.processIncomingMessage(messageBox, localTaskCodec)
+        val messageProcessor = IncomingMessageProcessorImpl(serviceManager)
+
+        messageProcessor.processIncomingCspMessage(messageBox, localTaskCodec)
 
         // Assert that this message has been acked towards the server
         assertEquals(
@@ -505,6 +528,13 @@ open class MessageProcessorProvider {
         }
     }
 
+    /**
+     * Run a task with the local task codec.
+     */
+    protected fun <T> runTask(task: ActiveTask<T>): T = runBlocking {
+        task.invoke(localTaskCodec)
+    }
+
     /**
      * Create a message box from a user with the given identity store.
      */
@@ -514,19 +544,45 @@ open class MessageProcessorProvider {
         forwardSecurityMessageProcessor: ForwardSecurityMessageProcessor,
     ): MessageBox {
         val nonceFactory = NonceFactory(object : NonceStore {
-            override fun exists(nonce: ByteArray) = false
-            override fun store(nonce: ByteArray) = true
-            override fun getAllHashedNonces() = listOf<ByteArray>()
+            override fun exists(scope: NonceScope, nonce: Nonce) = false
+            override fun store(scope: NonceScope, nonce: Nonce) = true
+            override fun getAllHashedNonces(scope: NonceScope) = listOf<HashedNonce>()
+            override fun getCount(scope: NonceScope) = 0L
+            override fun addHashedNoncesChunk(scope: NonceScope, chunkSize: Int, offset: Int, nonces: MutableList<HashedNonce>) {}
+            override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
         })
 
-        val encapsulated = forwardSecurityMessageProcessor.makeMessage(
+        val encapsulated = forwardSecurityMessageProcessor.runFsEncapsulationSteps(
             contactStore.getContactForIdentityIncludingCache(
                 msg.toIdentity
-            )!!, msg, globalTaskCodec
-        ).outgoingMessages.last()
+            )!!.enhanceToBasicContact(),
+            msg,
+            nonceFactory.next(NonceScope.CSP),
+            nonceFactory,
+            globalTaskCodec
+        ).outgoingMessages.last().first
 
         val messageCoder = MessageCoder(contactStore, identityStore)
-        return messageCoder.encode(encapsulated, nonceFactory.next(false), nonceFactory)
+        return messageCoder.encode(encapsulated, nonceFactory.next(NonceScope.CSP).bytes)
     }
 
+    private fun Contact.enhanceToBasicContact() = BasicContact(
+        identity,
+        publicKey,
+        ThreemaFeature.Builder()
+            .audio(true)
+            .group(true)
+            .ballot(true)
+            .file(true)
+            .voip(true)
+            .videocalls(true)
+            .forwardSecurity(true)
+            .groupCalls(true)
+            .editMessages(true)
+            .deleteMessages(true)
+            .build().toULong(),
+        IdentityState.ACTIVE,
+        IdentityType.NORMAL,
+    )
+
 }

+ 364 - 0
app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt

@@ -0,0 +1,364 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.protocol
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.GroupService
+import ch.threema.app.services.IdListService
+import ch.threema.app.services.PreferenceService
+import ch.threema.app.services.PreferenceServiceImpl
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.data.TestDatabaseService
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.helpers.UnusedTaskCodec
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.GroupId
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.stores.ContactStore
+import ch.threema.storage.DatabaseServiceNew
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupMemberModel
+import ch.threema.storage.models.GroupModel
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import java.util.Date
+import kotlin.test.assertEquals
+
+@DangerousTest
+class IdentityBlockedStepsTest {
+
+    private lateinit var contactModelRepository: ContactModelRepository
+    private lateinit var contactStore: ContactStore
+    private lateinit var groupService: GroupService
+    private lateinit var blockUnknownPreferenceService: PreferenceService
+    private lateinit var noBlockPreferenceService: PreferenceService
+    private lateinit var blockedContactsService: IdListService
+
+    private val myContact = TestHelpers.TEST_CONTACT
+    private val knownContact = TestContact("12345678")
+    private val unknownContact = TestContact("TESTTEST")
+    private val specialContact = TestContact("*3MAPUSH")
+    private val explicitlyBlockedContact = TestContact("23456789")
+    private val inGroup = TestContact("ABCDEFGH")
+    private val inNoGroup = TestContact("********")
+    private val inLeftGroup = TestContact("--------")
+
+    @Before
+    fun setup() {
+        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
+
+        val serviceManager = ThreemaApplication.requireServiceManager()
+        val databaseService = TestDatabaseService()
+        val coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = serviceManager.preferenceStore,
+            taskManager = TestTaskManager(UnusedTaskCodec())
+        )
+        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+        contactStore = serviceManager.contactStore
+        groupService = serviceManager.groupService
+        blockedContactsService = serviceManager.blockedContactsService
+        blockedContactsService.add(explicitlyBlockedContact.identity)
+
+        blockUnknownPreferenceService = object : PreferenceServiceImpl(
+            ThreemaApplication.getAppContext(),
+            serviceManager.preferenceStore,
+        ) {
+            override fun isBlockUnknown(): Boolean {
+                return true
+            }
+        }
+
+        noBlockPreferenceService = object : PreferenceServiceImpl(
+            ThreemaApplication.getAppContext(),
+            serviceManager.preferenceStore,
+        ) {
+            override fun isBlockUnknown(): Boolean {
+                return false
+            }
+        }
+
+        addKnownContacts()
+        addGroups(serviceManager.databaseServiceNew)
+    }
+
+    @Test
+    fun testExplicitlyBlockedContact() {
+        assertEquals(
+            BlockState.EXPLICITLY_BLOCKED,
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService)
+        )
+        assertEquals(
+            BlockState.EXPLICITLY_BLOCKED,
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testImplicitlyBlockedContact() {
+        assertEquals(
+            BlockState.IMPLICITLY_BLOCKED,
+            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testImplicitlyBlockedSpecialContact() {
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testGroupContactWithGroup() {
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testGroupContactWithoutGroup() {
+        assertEquals(
+            BlockState.IMPLICITLY_BLOCKED,
+            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testGroupContactWithLeftGroup() {
+        assertEquals(
+            BlockState.IMPLICITLY_BLOCKED,
+            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService)
+        )
+    }
+
+    @Test
+    fun testKnownContact() {
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(knownContact.identity, blockUnknownPreferenceService),
+        )
+    }
+
+    @Test
+    fun testWithoutBlockingUnknown() {
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(knownContact.identity, noBlockPreferenceService),
+        )
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(unknownContact.identity, noBlockPreferenceService),
+        )
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(specialContact.identity, noBlockPreferenceService),
+        )
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService)
+        )
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService)
+        )
+        assertEquals(
+            BlockState.NOT_BLOCKED,
+            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService)
+        )
+
+    }
+
+    private fun runIdentityBlockedSteps(
+        identity: String,
+        preferenceService: PreferenceService,
+    ) = runIdentityBlockedSteps(
+        identity,
+        contactModelRepository,
+        contactStore,
+        groupService,
+        blockedContactsService,
+        preferenceService,
+    )
+
+    private fun addKnownContacts() = runBlocking {
+        contactModelRepository.createFromLocal(
+            ContactModelData(
+                knownContact.identity,
+                knownContact.publicKey,
+                Date(),
+                "",
+                "",
+                "",
+                0u,
+                VerificationLevel.UNVERIFIED,
+                WorkVerificationLevel.NONE,
+                IdentityType.NORMAL,
+                ContactModel.AcquaintanceLevel.DIRECT,
+                IdentityState.ACTIVE,
+                ContactSyncState.INITIAL,
+                0u,
+                ReadReceiptPolicy.DEFAULT,
+                TypingIndicatorPolicy.DEFAULT,
+                null,
+                null,
+                false,
+                null,
+                null,
+                null,
+            )
+        )
+        contactModelRepository.createFromLocal(
+            ContactModelData(
+                inGroup.identity,
+                inGroup.publicKey,
+                Date(),
+                "",
+                "",
+                "",
+                0u,
+                VerificationLevel.UNVERIFIED,
+                WorkVerificationLevel.NONE,
+                IdentityType.NORMAL,
+                ContactModel.AcquaintanceLevel.GROUP,
+                IdentityState.ACTIVE,
+                ContactSyncState.INITIAL,
+                0u,
+                ReadReceiptPolicy.DEFAULT,
+                TypingIndicatorPolicy.DEFAULT,
+                null,
+                null,
+                false,
+                null,
+                null,
+                null,
+            )
+        )
+        contactModelRepository.createFromLocal(
+            ContactModelData(
+                inNoGroup.identity,
+                inNoGroup.publicKey,
+                Date(),
+                "",
+                "",
+                "",
+                0u,
+                VerificationLevel.UNVERIFIED,
+                WorkVerificationLevel.NONE,
+                IdentityType.NORMAL,
+                ContactModel.AcquaintanceLevel.GROUP,
+                IdentityState.ACTIVE,
+                ContactSyncState.INITIAL,
+                0u,
+                ReadReceiptPolicy.DEFAULT,
+                TypingIndicatorPolicy.DEFAULT,
+                null,
+                null,
+                false,
+                null,
+                null,
+                null,
+            )
+        )
+        contactModelRepository.createFromLocal(
+            ContactModelData(
+                inLeftGroup.identity,
+                inLeftGroup.publicKey,
+                Date(),
+                "",
+                "",
+                "",
+                0u,
+                VerificationLevel.UNVERIFIED,
+                WorkVerificationLevel.NONE,
+                IdentityType.NORMAL,
+                ContactModel.AcquaintanceLevel.GROUP,
+                IdentityState.ACTIVE,
+                ContactSyncState.INITIAL,
+                0u,
+                ReadReceiptPolicy.DEFAULT,
+                TypingIndicatorPolicy.DEFAULT,
+                null,
+                null,
+                false,
+                null,
+                null,
+                null,
+            )
+        )
+    }
+
+    private fun addGroups(databaseService: DatabaseServiceNew) = runBlocking {
+        databaseService.groupModelFactory.apply {
+            create(
+                GroupModel()
+                    .setApiGroupId(GroupId(0))
+                    .setCreatorIdentity(myContact.identity)
+                    .setUserState(GroupModel.UserState.MEMBER)
+                    .setCreatedAt(Date())
+            )
+            create(
+                GroupModel()
+                    .setApiGroupId(GroupId(1))
+                    .setCreatorIdentity(myContact.identity)
+                    .setUserState(GroupModel.UserState.LEFT)
+                    .setCreatedAt(Date())
+            )
+        }
+        val memberGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
+            GroupId(0).toString(),
+            myContact.identity
+        )
+        val leftGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
+            GroupId(1).toString(),
+            myContact.identity
+        )
+        databaseService.groupMemberModelFactory.apply {
+            create(
+                GroupMemberModel()
+                    .setGroupId(memberGroup.id)
+                    .setIdentity(inGroup.identity)
+            )
+            create(
+                GroupMemberModel()
+                    .setGroupId(leftGroup.id)
+                    .setIdentity(inLeftGroup.identity)
+            )
+        }
+    }
+
+}

+ 33 - 2
app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java

@@ -29,13 +29,16 @@ import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.File;
 import java.io.IOException;
 import java.util.Date;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.group.GroupInviteService;
@@ -43,6 +46,7 @@ import ch.threema.app.services.group.GroupInviteServiceImpl;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteData;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.storage.DatabaseServiceNew;
@@ -96,7 +100,34 @@ public class GroupInviteServiceTest {
 
 			}
 
-			@Override
+            @Nullable
+            @Override
+            public byte[] getUserProfilePicture() {
+                return null;
+            }
+
+            @Override
+            public boolean setUserProfilePicture(@NonNull File userProfilePicture, @NonNull TriggerSource triggerSource) {
+                return false;
+            }
+
+            @Override
+            public boolean setUserProfilePicture(@NonNull byte[] userProfilePicture, @NonNull TriggerSource triggerSource) {
+                return false;
+            }
+
+            @Override
+            public void removeUserProfilePicture(@NonNull TriggerSource triggerSource) {
+
+            }
+
+            @NonNull
+            @Override
+            public ContactService.ProfilePictureUploadData uploadUserProfilePictureOrGetPreviousUploadData() {
+                return null;
+            }
+
+            @Override
 			public Account getAccount() {
 				return null;
 			}
@@ -228,7 +259,7 @@ public class GroupInviteServiceTest {
 
 			@Nullable
 			@Override
-			public String setPublicNickname(String publicNickname) {
+			public String setPublicNickname(String publicNickname, @NonNull TriggerSource triggerSource) {
 				return null;
 			}
 

+ 233 - 6
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -22,15 +22,25 @@
 package ch.threema.app.tasks
 
 import ch.threema.app.ThreemaApplication
-import ch.threema.domain.models.Contact
+import ch.threema.data.models.ContactModelData
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
 import com.neilalexander.jnacl.NaCl
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.fail
+import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import org.junit.Test
+import java.util.Date
 
 /**
  * These tests are useful to detect when a task cannot be created out of a persisted representation
@@ -187,8 +197,8 @@ class PersistableTasksTest {
 
     @Test
     fun testDeleteAndTerminateFSSessionsTask() {
-        // Add the contact '01234567' so that restoring the tasks works
-        serviceManager.contactStore.addCachedContact(Contact("01234567", ByteArray(NaCl.PUBLICKEYBYTES)))
+        // Add the contact '01234567' so that creating the tasks works
+        addTestIdentity()
 
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
@@ -225,10 +235,18 @@ class PersistableTasksTest {
     }
 
     @Test
-    fun testOutgoingDropDeviceTask() {
+    fun testOutboundIncomingContactMessageUpdateReadTask() {
         assertValidEncoding(
-            OutgoingDropDeviceTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingDropDeviceTask.OutgoingDropDeviceData\",\"deviceId\":0}"
+            OutboundIncomingContactMessageUpdateReadTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask.OutboundIncomingContactMessageUpdateReadData\",\"messageIds\":[[0,-1,2,3,4,5,6,7]],\"timestamp\":1704067200000,\"recipientIdentity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testOutboundIncomingGroupMessageUpdateReadTask() {
+        assertValidEncoding(
+            OutboundIncomingGroupMessageUpdateReadTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask.OutboundIncomingGroupMessageUpdateReadData\",\"messageIds\":[[0,-1,2,3,4,5,6,7]],\"timestamp\":1704067200000,\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\"}"
         )
     }
 
@@ -264,6 +282,215 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testReflectUserProfileNicknameSyncTask() {
+        assertValidEncoding(
+            ReflectUserProfileNicknameSyncTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileNicknameSyncTask.ReflectUserProfileNicknameSyncTaskData\",\"newNickname\":\"nick\"}"
+        )
+    }
+
+    @Test
+    fun testReflectUserProfilePictureSyncTask() {
+        assertValidEncoding(
+            ReflectUserProfilePictureSyncTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfilePictureSyncTask.ReflectUserProfilePictureSyncTaskData\"}"
+        )
+    }
+
+    @Test
+    fun testReflectUserProfileShareWithPolicySyncTask() {
+        assertValidEncoding(
+            ReflectUserProfileShareWithPolicySyncTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileShareWithPolicySyncTask.ReflectUserProfileShareWithPolicySyncTaskData\",\"newPolicy\":\"NOBODY\"}"
+        )
+    }
+
+    @Test
+    fun testReflectUserProfileShareWithAllowListSyncTask() {
+        assertValidEncoding(
+            ReflectUserProfileShareWithAllowListSyncTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask.ReflectUserProfileShareWithAllowListSyncTaskData\",\"allowedIdentities\":[\"01234567\", \"01234568\"]}"
+        )
+    }
+
+    @Test
+    fun testReflectNameUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"A\",\"lastName\":\"B\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"A\",\"lastName\":\"\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"\",\"lastName\":\"B\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testReflectReadReceiptPolicyUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"DEFAULT\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"SEND\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"DONT_SEND\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testReflectTypingIndicatorPolicyUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"DEFAULT\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"SEND\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"DONT_SEND\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testReflectActivityStateUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"ACTIVE\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"INACTIVE\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"INVALID\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testReflectFeatureMaskUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate.ReflectFeatureMaskUpdateData\",\"featureMask\":12345,\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testVerificationLevelUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"UNVERIFIED\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"SERVER_VERIFIED\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"FULLY_VERIFIED\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testWorkVerificationLevelUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.ReflectWorkVerificationLevelUpdateData\",\"workVerificationLevel\":\"NONE\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.ReflectWorkVerificationLevelUpdateData\",\"workVerificationLevel\":\"WORK_SUBSCRIPTION_VERIFIED\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testIdentityTypeUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData\",\"identityType\":\"NORMAL\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData\",\"identityType\":\"WORK\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testAcquaintanceLevelUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData\",\"acquaintanceLevel\":\"DIRECT\",\"identity\":\"01234567\"}"
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData\",\"acquaintanceLevel\":\"GROUP\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testUserDefinedProfilePictureUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate.ReflectUserDefinedProfilePictureUpdateData\",\"identity\":\"0BZYE2H9\"}"
+        )
+    }
+
+    @Test
+    fun testOnFSFeatureMaskDowngradedTask() {
+        // Add the contact '01234567' so that creating the tasks works
+        addTestIdentity()
+
+        assertValidEncoding(
+            OnFSFeatureMaskDowngradedTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OnFSFeatureMaskDowngradedTask.OnFSFeatureMaskDowngradedData\",\"identity\":\"01234567\"}"
+        )
+    }
+
+    private fun addTestIdentity() = runBlocking {
+        val identity = "01234567"
+        if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {
+            // If the contact already exists, we do not add it again
+            return@runBlocking
+        }
+
+        serviceManager.modelRepositories.contacts.createFromLocal(
+            ContactModelData(
+                identity = identity,
+                publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
+                createdAt = Date(42),
+                firstName = "0123",
+                lastName = "4567",
+                nickname = "01",
+                verificationLevel = VerificationLevel.SERVER_VERIFIED,
+                workVerificationLevel = WorkVerificationLevel.NONE,
+                identityType = IdentityType.NORMAL,
+                acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+                activityState = IdentityState.ACTIVE,
+                syncState = ContactSyncState.INITIAL,
+                featureMask = 0u,
+                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                androidContactLookupKey = null,
+                localAvatarExpires = null,
+                isRestored = false,
+                profilePictureBlobId = null,
+                jobTitle = null,
+                department = null,
+            )
+        )
+    }
+
     private fun <T> assertValidEncoding(expectedTaskClass: Class<T>, encodedTask: String) {
         val decodedTask = encodedTask.decodeToTask()
         assertNotNull(decodedTask)

+ 52 - 4
app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java

@@ -45,8 +45,12 @@ import ch.threema.app.services.UserService;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.models.Contact;
+import ch.threema.domain.models.BasicContact;
 import ch.threema.domain.models.GroupId;
+import ch.threema.domain.models.IdentityState;
+import ch.threema.domain.models.IdentityType;
 import ch.threema.domain.models.VerificationLevel;
+import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.stores.IdentityStoreInterface;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
@@ -103,6 +107,28 @@ public class TestHelpers {
 				null
 			);
 		}
+
+		@NonNull
+		public BasicContact toBasicContact() {
+			return BasicContact.javaCreate(
+				identity,
+				publicKey,
+				new ThreemaFeature.Builder()
+					.audio(true)
+					.group(true)
+					.ballot(true)
+					.file(true)
+					.voip(true)
+					.videocalls(true)
+					.forwardSecurity(true)
+					.groupCalls(true)
+					.editMessages(true)
+					.deleteMessages(true)
+					.build(),
+				IdentityState.ACTIVE,
+				IdentityType.NORMAL
+			);
+		}
 	}
 
 	public static final class TestGroup {
@@ -123,13 +149,20 @@ public class TestHelpers {
 		@Nullable
 		public final byte[] profilePicture;
 
+		/**
+		 * Note that the user identity is used to set the correct group user state.
+		 */
+		@NonNull
+		public final String userIdentity;
+
 		public TestGroup(
 			@NonNull GroupId apiGroupId,
 			@NonNull TestContact groupCreator,
 			@NonNull List<TestContact> members,
-			@NonNull String groupName
+			@NonNull String groupName,
+			@NonNull String userIdentity
 		) {
-			this(apiGroupId, groupCreator, members, groupName, null);
+			this(apiGroupId, groupCreator, members, groupName, null, userIdentity);
 		}
 
 		public TestGroup(
@@ -137,23 +170,38 @@ public class TestHelpers {
 			@NonNull TestContact groupCreator,
 			@NonNull List<TestContact> members,
 			@NonNull String groupName,
-			@Nullable byte[] profilePicture
+			@Nullable byte[] profilePicture,
+			@NonNull String userIdentity
 		) {
 			this.apiGroupId = apiGroupId;
 			this.groupCreator = groupCreator;
 			this.members = members;
 			this.groupName = groupName;
 			this.profilePicture = profilePicture;
+			this.userIdentity = userIdentity;
 		}
 
 		@NonNull
 		public GroupModel getGroupModel() {
+			boolean isMember = false;
+			for (TestContact member : members) {
+				if (member.identity.equals(userIdentity)) {
+					isMember = true;
+					break;
+				}
+			}
+			return getGroupModel(isMember ? GroupModel.UserState.MEMBER : GroupModel.UserState.LEFT);
+		}
+
+		@NonNull
+		private GroupModel getGroupModel(@NonNull GroupModel.UserState userState) {
 			return new GroupModel()
 				.setApiGroupId(apiGroupId)
 				.setCreatedAt(new Date())
 				.setName(this.groupName)
 				.setCreatorIdentity(this.groupCreator.identity)
-				.setId(localGroupId);
+				.setId(localGroupId)
+				.setUserState(userState);
 		}
 
 		public void setLocalGroupId(int localGroupId) {

+ 233 - 0
app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt

@@ -0,0 +1,233 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.utils
+
+import ch.threema.app.processors.MessageProcessorProvider
+import ch.threema.domain.models.MessageId
+import ch.threema.domain.protocol.csp.messages.AbstractMessage
+import ch.threema.domain.protocol.csp.messages.GroupTextMessage
+import ch.threema.domain.protocol.csp.messages.TextMessage
+import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode
+import ch.threema.domain.taskmanager.ActiveTask
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import org.junit.Before
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class BundledMessagesSendStepsTest : MessageProcessorProvider() {
+
+    private lateinit var outgoingCspMessageServices: OutgoingCspMessageServices
+
+    @Before
+    fun initialize() {
+        outgoingCspMessageServices = OutgoingCspMessageServices(
+            serviceManager.forwardSecurityMessageProcessor,
+            myContact.identityStore,
+            serviceManager.userService,
+            serviceManager.contactStore,
+            serviceManager.contactService,
+            serviceManager.modelRepositories.contacts,
+            serviceManager.groupService,
+            serviceManager.nonceFactory,
+            serviceManager.blockedContactsService,
+            serviceManager.preferenceService,
+            serviceManager.multiDeviceManager,
+        )
+    }
+
+    @Test
+    fun testContactMessage() {
+        runInsideOfATask { handle ->
+            val messageId = MessageId()
+            val createdAt = Date()
+            var hasBeenMarkedAsSent = false
+            var forwardSecurityModes: Map<String, ForwardSecurityMode>? = null
+            val outgoingCspMessageHandle = OutgoingCspMessageHandle(
+                contactA.toBasicContact(),
+                OutgoingCspContactMessageCreator(
+                    messageId,
+                    createdAt,
+                    contactA.identity,
+                ) { TextMessage().apply { text = "Test" } },
+                { hasBeenMarkedAsSent = true },
+                { stateMap -> forwardSecurityModes = stateMap },
+            )
+
+            handle.runBundledMessagesSendSteps(
+                outgoingCspMessageHandle,
+                outgoingCspMessageServices,
+            )
+            assertMessageHandleSent(outgoingCspMessageHandle) { message ->
+                message as TextMessage
+                assertEquals("Test", message.text)
+            }
+            assertTrue(sentMessagesInsideTask.isEmpty())
+            assertTrue(sentMessagesNewTask.isEmpty())
+
+            assertTrue(hasBeenMarkedAsSent)
+            assertEquals(1, forwardSecurityModes!!.keys.size)
+            forwardSecurityModes!!.values.forEach {
+                assertEquals(ForwardSecurityMode.FOURDH, it)
+            }
+        }
+    }
+
+    @Test
+    fun testGroupMessage() {
+        runInsideOfATask { handle ->
+            val messageId = MessageId()
+            val createdAt = Date()
+            val group = groupAB
+            var hasBeenMarkedAsSent = false
+            var forwardSecurityModes: Map<String, ForwardSecurityMode>? = null
+            val outgoingCspMessageHandle = OutgoingCspMessageHandle(
+                group.members.map { it.toBasicContact() }.toSet(),
+                OutgoingCspGroupMessageCreator(
+                    messageId,
+                    createdAt,
+                    group.groupModel,
+                ) { GroupTextMessage().apply { text = "Test" } },
+                { hasBeenMarkedAsSent = true },
+                { stateMap -> forwardSecurityModes = stateMap},
+            )
+
+            handle.runBundledMessagesSendSteps(
+                outgoingCspMessageHandle,
+                outgoingCspMessageServices,
+            )
+
+            assertMessageHandleSent(outgoingCspMessageHandle) { message ->
+                message as GroupTextMessage
+                assertEquals("Test", message.text)
+            }
+            assertTrue(sentMessagesInsideTask.isEmpty())
+            assertTrue(sentMessagesNewTask.isEmpty())
+
+            assertTrue(hasBeenMarkedAsSent)
+            assertEquals(group.members.size - 1, forwardSecurityModes!!.keys.size)
+            forwardSecurityModes!!.values.forEach {
+                assertEquals(ForwardSecurityMode.FOURDH, it)
+            }
+        }
+    }
+
+    @Test
+    fun testMultipleMessages() = runInsideOfATask { handle ->
+        val sentDates = mutableListOf<ULong>()
+        val handles = listOf(
+            OutgoingCspMessageHandle(
+                contactA.toBasicContact(),
+                OutgoingCspContactMessageCreator(
+                    MessageId(),
+                    Date(),
+                    contactA.identity,
+                ) {
+                    TextMessage().apply { text = "Test" }
+                },
+                { sentAt -> sentDates.add(sentAt) },
+            ),
+            OutgoingCspMessageHandle(
+                contactB.toBasicContact(),
+                OutgoingCspContactMessageCreator(
+                    MessageId(),
+                    Date(),
+                    contactA.identity,
+                ) {
+                    TextMessage().apply { text = "Test" }
+                },
+                { sentAt -> sentDates.add(sentAt) },
+            ),
+            OutgoingCspMessageHandle(
+                groupAB.members.map { it.toBasicContact() }.toSet(),
+                OutgoingCspGroupMessageCreator(
+                    MessageId(),
+                    Date(),
+                    groupAB.groupModel,
+                ) {
+                    GroupTextMessage().apply { text = "Test" }
+                },
+                { sentAt -> sentDates.add(sentAt) },
+            ),
+        )
+
+        handle.runBundledMessagesSendSteps(
+            handles,
+            outgoingCspMessageServices,
+        )
+
+        assertMessageHandleSent(handles[0]) { message ->
+            message as TextMessage
+            assertEquals("Test", message.text)
+        }
+        assertMessageHandleSent(handles[1]) { message ->
+            message as TextMessage
+            assertEquals("Test", message.text)
+        }
+        assertMessageHandleSent(handles[2]) { message ->
+            message as GroupTextMessage
+            assertEquals("Test", message.text)
+        }
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
+
+        assertEquals(handles.size, sentDates.size)
+        // We use the same sent at timestamp for all bundled messages
+        assertEquals(1, sentDates.toSet().size)
+    }
+
+    private fun assertMessageHandleSent(messageHandle: OutgoingCspMessageHandle, assertMessage: (AbstractMessage) -> Unit) {
+        val expectedReceivers = messageHandle.receivers
+            .map { it.identity }
+            .filter { it != myContact.identity }
+            .sorted()
+
+
+        val actualReceivers = sentMessagesInsideTask
+            .asSequence()
+            .take(expectedReceivers.size)
+            .sortedBy { it.toIdentity }
+            .onEach {
+                assertMessage(it)
+                assertEquals(messageHandle.messageCreator.messageId.messageIdLong, it.messageId.messageIdLong)
+                assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
+            }
+            .map { it.toIdentity }
+            .toList()
+
+        assertEquals(expectedReceivers, actualReceivers)
+
+        repeat(expectedReceivers.size) {
+            sentMessagesInsideTask.remove()
+        }
+    }
+
+    private fun <T> runInsideOfATask(runnable: suspend (handle: ActiveTaskCodec) -> T): T =
+        runTask(object : ActiveTask<T> {
+            override val type = "TestTask"
+
+            override suspend fun invoke(handle: ActiveTaskCodec) = runnable(handle)
+        })
+
+}

+ 1 - 1
app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java

@@ -80,7 +80,7 @@ public class MessageTest {
 			new HashMap<>()
 		);
 		final AbstractMessageModel messageModel = new MessageModel();
-		messageModel.setFileData(fileDataModel);
+		messageModel.setFileDataModel(fileDataModel);
 		messageModel.setCreatedAt(createdAt);
 		messageModel.setApiMessageId(messageId);
 		Message.maybePutFile(builder, "file", messageModel, fileDataModel);

+ 309 - 200
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -21,10 +21,17 @@
 
 package ch.threema.data.repositories
 
-import androidx.test.ext.junit.runners.AndroidJUnit4
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
 import ch.threema.data.TestDatabaseService
-import ch.threema.data.models.ModelDeletedException
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.models.ContactModelData.Companion.getIdColorIndex
+import ch.threema.domain.helpers.TransactionAckTaskCodec
+import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
@@ -32,84 +39,186 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import ch.threema.storage.models.ContactModel.State
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
 import com.neilalexander.jnacl.NaCl
-import junit.framework.TestCase.assertNotNull
 import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertThrows
 import org.junit.Before
 import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-import kotlin.test.assertFalse
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlin.test.fail
 
-@RunWith(AndroidJUnit4::class)
-class ContactModelRepositoryTest {
+@RunWith(value = Parameterized::class)
+class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
+    // Services where MD is disabled
     private lateinit var databaseService: TestDatabaseService
+    private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
+    // Services where MD is enabled
+    private lateinit var databaseServiceMd: TestDatabaseService
+    private lateinit var taskCodecMd: TransactionAckTaskCodec
+    private lateinit var coreServiceManagerMd: TestCoreServiceManager
+    private lateinit var contactModelRepositoryMd: ContactModelRepository
+
     private enum class TestTriggerSource {
         FROM_LOCAL,
         FROM_REMOTE,
     }
 
-    private val initialValuesSet = setOf(
-        InitialValues(),
-        InitialValues(publicKey = ByteArray(NaCl.PUBLICKEYBYTES) { it.toByte() }),
-        InitialValues(date = Date(42)),
-        InitialValues(identityType = IdentityType.WORK),
-        InitialValues(acquaintanceLevel = AcquaintanceLevel.GROUP),
-        InitialValues(activityState = State.INACTIVE),
-        InitialValues(featureMask = 64.toULong()),
-    )
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters()
+        fun initialValuesSet() = setOf(
+            getInitialContactModelData(),
+            getInitialContactModelData(publicKey = ByteArray(NaCl.PUBLICKEYBYTES) { it.toByte() }),
+            getInitialContactModelData(createdAt = Date(42)),
+            getInitialContactModelData(identityType = IdentityType.WORK),
+            getInitialContactModelData(acquaintanceLevel = AcquaintanceLevel.GROUP),
+            getInitialContactModelData(activityState = IdentityState.INACTIVE),
+            getInitialContactModelData(featureMask = 64.toULong()),
+        )
+
+        private fun getInitialContactModelData(
+            identity: String = "ABCDEFGH",
+            publicKey: ByteArray = ByteArray(NaCl.PUBLICKEYBYTES),
+            createdAt: Date = Date(),
+            firstName: String = "",
+            lastName: String = "",
+            nickname: String? = null,
+            verificationLevel: VerificationLevel = VerificationLevel.UNVERIFIED,
+            workVerificationLevel: WorkVerificationLevel = WorkVerificationLevel.NONE,
+            identityType: IdentityType = IdentityType.NORMAL,
+            acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
+            activityState: IdentityState = IdentityState.ACTIVE,
+            syncState: ContactSyncState = ContactSyncState.INITIAL,
+            featureMask: ULong = 0u,
+            readReceiptPolicy: ReadReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+            typingIndicatorPolicy: TypingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+            androidContactLookupKey: String? = null,
+            localAvatarExpires: Date? = null,
+            isRestored: Boolean = false,
+            profilePictureBlobId: ByteArray? = null,
+            jobTitle: String? = null,
+            department: String? = null,
+        ) = ContactModelData(
+            identity = identity,
+            publicKey = publicKey,
+            createdAt = createdAt,
+            firstName = firstName,
+            lastName = lastName,
+            nickname = nickname,
+            colorIndex = getIdColorIndex(identity),
+            verificationLevel = verificationLevel,
+            workVerificationLevel = workVerificationLevel,
+            identityType = identityType,
+            acquaintanceLevel = acquaintanceLevel,
+            activityState = activityState,
+            syncState = syncState,
+            featureMask = featureMask,
+            readReceiptPolicy = readReceiptPolicy,
+            typingIndicatorPolicy = typingIndicatorPolicy,
+            androidContactLookupKey = androidContactLookupKey,
+            localAvatarExpires = localAvatarExpires,
+            isRestored = isRestored,
+            profilePictureBlobId = profilePictureBlobId,
+            jobTitle = jobTitle,
+            department = department,
+        )
+    }
 
     @Before
     fun before() {
+        // Instantiate services where MD is disabled
         this.databaseService = TestDatabaseService()
-        this.contactModelRepository = ModelRepositories(databaseService).contacts
+        this.coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            taskManager = TestTaskManager(UnusedTaskCodec())
+        )
+        this.contactModelRepository = ModelRepositories(coreServiceManager).contacts
+
+        // Instantiate services where MD is enabled
+        this.databaseServiceMd = TestDatabaseService()
+        this.taskCodecMd = TransactionAckTaskCodec()
+        this.coreServiceManagerMd = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseServiceMd,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            multiDeviceManager = TestMultiDeviceManager(
+                isMultiDeviceActive = true,
+                isMdDisabledOrSupportsFs = false,
+            ),
+            taskManager = TestTaskManager(taskCodecMd)
+        )
+        this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd).contacts
     }
 
+    /**
+     * Test creation of a new contact from local.
+     */
     @Test
     fun createFromLocal() {
-        initialValuesSet.forEach { testCreateFromLocalOrRemote(it, TestTriggerSource.FROM_LOCAL) }
+        testCreateFromLocalOrRemote(contactModelData, TestTriggerSource.FROM_LOCAL)
     }
 
+    /**
+     * Test creation of a new contact from remote.
+     */
     @Test
     fun createFromRemote() {
-        initialValuesSet.forEach { testCreateFromLocalOrRemote(it, TestTriggerSource.FROM_REMOTE) }
+        testCreateFromLocalOrRemote(contactModelData, TestTriggerSource.FROM_REMOTE)
+    }
+
+    /**
+     * Test creation of a new contact from sync.
+     */
+    @Test
+    fun createFromSync() {
+        testCreateFromSync(contactModelData)
     }
 
+    /**
+     * Test creation of a new contact from local twice. The first time a new contact should be
+     * created. The second time a [ContactStoreException] should be thrown.
+     */
     @Test
     fun createFromLocalTwice() {
-        initialValuesSet.forEach {
-            testCreateFromLocalOrRemoteTwice(it, TestTriggerSource.FROM_LOCAL)
-        }
+        testCreateFromLocalOrRemoteTwice(contactModelData, TestTriggerSource.FROM_LOCAL)
     }
 
+    /**
+     * Test creation of a new contact from remote twice. The first time a new contact should be
+     * created. The second time a [ContactStoreException] should be thrown.
+     */
     @Test
     fun createFromRemoteTwice() {
-        initialValuesSet.forEach {
-            testCreateFromLocalOrRemoteTwice(it, TestTriggerSource.FROM_REMOTE)
-        }
+        testCreateFromLocalOrRemoteTwice(contactModelData, TestTriggerSource.FROM_REMOTE)
     }
 
+    /**
+     * Test creation of a new contact from sync twice. The first time a new contact should be
+     * created. The second time a [ContactStoreException] should be thrown.
+     */
     @Test
-    fun createFromSync() {
-        // TODO(ANDR-2835): Create contact from sync
+    fun createFromSyncTwice() {
+        testCreateFromSyncTwice(contactModelData)
     }
 
     @Test
     fun getByIdentityNotFound() {
         val model = contactModelRepository.getByIdentity("ABCDEFGH")
         assertNull(model)
+        val modelMd = contactModelRepositoryMd.getByIdentity("ABCDEFGH")
+        assertNull(modelMd)
     }
 
     @Test
@@ -119,219 +228,219 @@ class ContactModelRepositoryTest {
 
         // Create contact using "old model"
         databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
+        databaseServiceMd.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
 
         // Fetch contact using "new model"
-        val model = contactModelRepository.getByIdentity(identity)
-        assertNotNull(model!!)
+        val model = contactModelRepository.getByIdentity(identity)!!
+        val modelMd = contactModelRepositoryMd.getByIdentity(identity)!!
+        assertEquals(model.identity, modelMd.identity)
+        assertContentEquals(model.data.value, modelMd.data.value)
         assertTrue { model.identity == identity }
         assertTrue { model.data.value?.identity == identity }
         assertContentEquals(publicKey, model.data.value?.publicKey)
     }
 
-    @Test
-    fun deleteByIdentityNonExisting() {
-        // If model does not exist, no exception is thrown
-        contactModelRepository.deleteByIdentity("ABCDEFGH")
-        contactModelRepository.deleteByIdentity("ABCDEFGH")
-    }
-
-    @Test
-    fun deleteByIdentityExistingNotCached() {
-        // Create contact using "old model"
-        val identity = randomIdentity()
-        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
-
-        // Delete through repository
-        contactModelRepository.deleteByIdentity(identity)
-
-        // Ensure that contact is gone
-        val model = contactModelRepository.getByIdentity(identity)
-        assertNull(model)
-    }
-
-    @Test
-    fun deleteByIdentityExistingCached() {
-        // Create contact using "old model"
-        val identity = randomIdentity()
-        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
-
-        // Fetch model to ensure it's cached
-        val modelBeforeDeletion = contactModelRepository.getByIdentity(identity)
-        assertNotNull(modelBeforeDeletion)
-
-        // Delete through repository
-        contactModelRepository.deleteByIdentity(identity)
-
-        // Ensure that contact is gone
-        val modelAfterDeletion = contactModelRepository.getByIdentity(identity)
-        assertNull(modelAfterDeletion)
-    }
-
-    @Test
-    fun deleteExisting() {
-        // Create contact using "old model"
-        val identity = randomIdentity()
-        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
-
-        // Fetch model
-        val model = contactModelRepository.getByIdentity(identity)
-        assertNotNull(model!!)
-
-        // Data is present, mutating model is possible
-        assertNotNull(model.data.value)
-        model.setNicknameFromSync("testnick")
-
-        // Delete through repository
-        contactModelRepository.delete(model)
-
-        // Data is gone, mutating model throws exception
-        assertNull(model.data.value)
-        assertFailsWith(ModelDeletedException::class) {
-            model.setNicknameFromSync("testnick")
-        }
-
-        // Ensure that contact is not cached anymore
-        val modelAfterDeletion = contactModelRepository.getByIdentity(identity)
-        assertNull(modelAfterDeletion)
-    }
-
     private fun testCreateFromLocalOrRemote(
-        initialValues: InitialValues,
+        contactModelData: ContactModelData,
         triggerSource: TestTriggerSource,
     ) {
-        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+        assertNull(contactModelRepository.getByIdentity(contactModelData.identity))
+        assertNull(contactModelRepositoryMd.getByIdentity(contactModelData.identity))
 
-        val newModel = runBlocking {
+        val (newModel, newModelMd) = runBlocking {
             when (triggerSource) {
-                TestTriggerSource.FROM_LOCAL -> contactModelRepository.createFromLocal(
-                    initialValues.identity,
-                    initialValues.publicKey,
-                    initialValues.date,
-                    initialValues.identityType,
-                    initialValues.acquaintanceLevel,
-                    initialValues.activityState,
-                    initialValues.featureMask,
-                )
-
-                TestTriggerSource.FROM_REMOTE -> contactModelRepository.createFromRemote(
-                    initialValues.identity,
-                    initialValues.publicKey,
-                    initialValues.date,
-                    initialValues.identityType,
-                    initialValues.acquaintanceLevel,
-                    initialValues.activityState,
-                    initialValues.featureMask,
-                )
-
+                TestTriggerSource.FROM_LOCAL -> {
+                    contactModelRepository.createFromLocal(contactModelData) to
+                        contactModelRepositoryMd.createFromLocal(contactModelData)
+                }
+
+                TestTriggerSource.FROM_REMOTE -> {
+                    contactModelRepository.createFromRemote(
+                        contactModelData = contactModelData,
+                        handle = UnusedTaskCodec(),
+                    ) to
+                        contactModelRepositoryMd.createFromRemote(
+                            contactModelData = contactModelData,
+                            handle = taskCodecMd,
+                        )
+                }
             }
         }
 
-        // TODO(ANDR-3003): Test that transaction has been executed
+        // Assert that a transaction has been executed in the MD context
+        assertEquals(1, taskCodecMd.transactionBeginCount)
+        assertEquals(1, taskCodecMd.transactionCommitCount)
 
-        val queriedModel = contactModelRepository.getByIdentity(initialValues.identity)
+        val queriedModel = contactModelRepository.getByIdentity(contactModelData.identity)
         assertEquals(newModel, queriedModel)
 
-        assertDefaultValues(newModel, initialValues)
+        val queriedModelMd = contactModelRepositoryMd.getByIdentity(contactModelData.identity)
+        assertEquals(newModelMd, queriedModelMd)
 
-        contactModelRepository.deleteByIdentity(initialValues.identity)
+        assertContentEquals(contactModelData, newModel.data.value)
+        assertContentEquals(contactModelData, newModelMd.data.value)
 
-        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+        // Reset transaction count in case this test is run several times
+        taskCodecMd.transactionBeginCount = 0
+        taskCodecMd.transactionCommitCount = 0
     }
 
+    /**
+     * Insert the given [contactModelData] twice (from local or remote; depending on the given
+     * [triggerSource]): The first time this should create a new contact, the second time it should
+     * throw a [ContactStoreException].
+     */
     private fun testCreateFromLocalOrRemoteTwice(
-        initialValues: InitialValues,
+        contactModelData: ContactModelData,
         triggerSource: TestTriggerSource,
     ) {
-        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
-
-        val runCreation = when (triggerSource) {
-            TestTriggerSource.FROM_LOCAL -> suspend {
-                contactModelRepository.createFromLocal(
-                    initialValues.identity,
-                    initialValues.publicKey,
-                    initialValues.date,
-                    initialValues.identityType,
-                    initialValues.acquaintanceLevel,
-                    initialValues.activityState,
-                    initialValues.featureMask,
-                )
+        assertNull(contactModelRepository.getByIdentity(contactModelData.identity))
+        assertNull(contactModelRepositoryMd.getByIdentity(contactModelData.identity))
+
+        val (runCreation, runCreationMd) = when (triggerSource) {
+            TestTriggerSource.FROM_LOCAL -> {
+                suspend {
+                    contactModelRepository.createFromLocal(contactModelData)
+                } to
+                    suspend {
+                        contactModelRepositoryMd.createFromLocal(contactModelData)
+                    }
             }
 
-            TestTriggerSource.FROM_REMOTE -> suspend {
-                contactModelRepository.createFromRemote(
-                    initialValues.identity,
-                    initialValues.publicKey,
-                    initialValues.date,
-                    initialValues.identityType,
-                    initialValues.acquaintanceLevel,
-                    initialValues.activityState,
-                    initialValues.featureMask,
-                )
+            TestTriggerSource.FROM_REMOTE -> {
+                suspend {
+                    contactModelRepository.createFromRemote(
+                        contactModelData = contactModelData,
+                        handle = UnusedTaskCodec(),
+                    )
+                } to
+                    suspend {
+                        contactModelRepositoryMd.createFromRemote(
+                            contactModelData = contactModelData,
+                            handle = taskCodecMd,
+                        )
+                    }
             }
 
         }
 
         // Insert it for the first time
-        val newModel = runBlocking {
-            runCreation()
+        val (newModel, newModelMd) = runBlocking {
+            runCreation() to runCreationMd()
         }
 
-        // TODO(ANDR-3003): Test that transaction has been executed
+        // Assert that a transaction has been executed in the MD context
+        assertEquals(1, taskCodecMd.transactionBeginCount)
+        assertEquals(1, taskCodecMd.transactionCommitCount)
 
-        val queriedModel = contactModelRepository.getByIdentity(initialValues.identity)
+        val queriedModel = contactModelRepository.getByIdentity(contactModelData.identity)
         assertEquals(newModel, queriedModel)
 
-        assertDefaultValues(newModel, initialValues)
+        val queriedModelMd = contactModelRepositoryMd.getByIdentity(contactModelData.identity)
+        assertEquals(newModelMd, queriedModelMd)
+
+        assertContentEquals(contactModelData, newModel.data.value)
+        assertContentEquals(contactModelData, newModelMd.data.value)
 
         // Insert for the second time and assert that an exception is thrown
-        assertThrows(ContactCreateException::class.java) { runBlocking { runCreation() } }
+        assertThrows(ContactStoreException::class.java) { runBlocking { runCreation() } }
+        assertThrows(ContactReflectException::class.java) { runBlocking { runCreationMd() } }
 
-        contactModelRepository.deleteByIdentity(initialValues.identity)
+        // Assert that there is still only one transaction and therefore the transaction has not
+        // been executed again (due to precondition failure)
+        assertEquals(1, taskCodecMd.transactionBeginCount)
+        assertEquals(1, taskCodecMd.transactionCommitCount)
 
-        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+        // Reset transaction count in case this test is run several times
+        taskCodecMd.transactionBeginCount = 0
+        taskCodecMd.transactionCommitCount = 0
     }
 
-    private data class InitialValues(
-        val identity: String = "ABCDEFGH",
-        val publicKey: ByteArray = ByteArray(NaCl.PUBLICKEYBYTES),
-        val date: Date = Date(),
-        val identityType: IdentityType = IdentityType.NORMAL,
-        val acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
-        val activityState: State = State.ACTIVE,
-        val featureMask: ULong = 4.toULong(),
-    )
-
-    private fun assertDefaultValues(
-        contactModel: ch.threema.data.models.ContactModel,
-        initialValues: InitialValues,
-    ) {
-        assertEquals(initialValues.identity, contactModel.identity)
-        val data = contactModel.data.value!!
-
-        // Assert that the given properties match
-        assertArrayEquals(initialValues.publicKey, data.publicKey)
-        assertEquals(initialValues.date.time, data.createdAt.time)
-        assertEquals(initialValues.identityType, data.identityType)
-        assertEquals(initialValues.acquaintanceLevel, data.acquaintanceLevel)
-        assertEquals(initialValues.activityState, data.activityState)
-        assertEquals(initialValues.featureMask, data.featureMask)
-
-        // Assert that the rest is set to the default values
-        assertEquals("", data.firstName)
-        assertEquals("", data.lastName)
-        assertNull(data.nickname)
-        assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
-        assertEquals(WorkVerificationLevel.NONE, data.workVerificationLevel)
-        assertEquals(ContactSyncState.INITIAL, data.syncState)
-        assertEquals(ReadReceiptPolicy.DEFAULT, data.readReceiptPolicy)
-        assertEquals(TypingIndicatorPolicy.DEFAULT, data.typingIndicatorPolicy)
-        assertNull(data.androidContactLookupKey)
-        assertNull(data.localAvatarExpires)
-        assertFalse(data.isRestored)
-        assertNull(data.profilePictureBlobId)
-        assertEquals(
-            ContactModel(data.identity, data.publicKey).idColorIndex.toUByte(),
-            data.colorIndex
-        )
+    private fun testCreateFromSync(contactModelData: ContactModelData) {
+        // Assert that the contact does not exist yet
+        assertNull(contactModelRepositoryMd.getByIdentity(contactModelData.identity))
+
+        // Create the contact
+        val contactModel = contactModelRepositoryMd.createFromSync(contactModelData)
+
+        // Assert that no transactions were created and no messages were sent
+        assertEquals(0, taskCodecMd.transactionBeginCount)
+        assertEquals(0, taskCodecMd.transactionCommitCount)
+        assertTrue(taskCodecMd.outboundMessages.isEmpty())
+
+        // Assert that the contact data has been inserted correctly
+        val addedData = contactModel.data.value!!
+        assertContentEquals(contactModelData, addedData)
+
+        // Reset transaction count in case this test is run several times
+        taskCodecMd.transactionBeginCount = 0
+        taskCodecMd.transactionCommitCount = 0
+    }
+
+    /**
+     * Insert the given [contactModelData] twice from sync: The first time this should create a new
+     * contact, the second time it should throw a [ContactStoreException].
+     */
+    private fun testCreateFromSyncTwice(contactModelData: ContactModelData) {
+        // Assert that the contact does not exist yet
+        assertNull(contactModelRepositoryMd.getByIdentity(contactModelData.identity))
+
+        // Create the contact
+        val contactModel = contactModelRepositoryMd.createFromSync(contactModelData)
+
+        // Assert that no transactions were created and no messages were sent
+        assertEquals(0, taskCodecMd.transactionBeginCount)
+        assertEquals(0, taskCodecMd.transactionCommitCount)
+        assertTrue(taskCodecMd.outboundMessages.isEmpty())
+
+        // Assert that the contact data has been inserted correctly
+        val addedData = contactModel.data.value!!
+        assertContentEquals(contactModelData, addedData)
+
+        // Assert that the contact data cannot be inserted again (as it already exists)
+        assertThrows(ContactStoreException::class.java) {
+            contactModelRepositoryMd.createFromSync(contactModelData)
+        }
+
+        // Reset transaction count in case this test is run several times
+        taskCodecMd.transactionBeginCount = 0
+        taskCodecMd.transactionCommitCount = 0
+    }
+
+    private fun assertContentEquals(expected: ContactModelData?, actual: ContactModelData?) {
+        if (expected == null && actual == null) {
+            return
+        }
+
+        if (expected == null) {
+            fail("Actual data expected to be null")
+        }
+
+        if (actual == null) {
+            fail("Actual data expected to be non null")
+        }
+
+        assertEquals(expected.identity, actual.identity)
+        assertContentEquals(expected.publicKey, actual.publicKey)
+        assertEquals(expected.createdAt, actual.createdAt)
+        assertEquals(expected.firstName, actual.firstName)
+        assertEquals(expected.lastName, actual.lastName)
+        assertEquals(expected.nickname, actual.nickname)
+        assertEquals(expected.colorIndex, actual.colorIndex)
+        assertEquals(expected.verificationLevel, actual.verificationLevel)
+        assertEquals(expected.workVerificationLevel, actual.workVerificationLevel)
+        assertEquals(expected.identityType, actual.identityType)
+        assertEquals(expected.acquaintanceLevel, actual.acquaintanceLevel)
+        assertEquals(expected.activityState, actual.activityState)
+        assertEquals(expected.syncState, actual.syncState)
+        assertEquals(expected.featureMask, actual.featureMask)
+        assertEquals(expected.readReceiptPolicy, actual.readReceiptPolicy)
+        assertEquals(expected.typingIndicatorPolicy, actual.typingIndicatorPolicy)
+        // TODO(ANDR-2998): Assert that notification trigger and sound policy override are set
+        //  correctly
+        assertEquals(expected.androidContactLookupKey, actual.androidContactLookupKey)
+
+        // Just in case there are new fields added that are not explicitly compared here
+        assertEquals(expected, actual)
     }
 }

+ 11 - 1
app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt

@@ -21,9 +21,13 @@
 
 package ch.threema.data.repositories
 
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
+import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
@@ -41,7 +45,13 @@ class EditHistoryRepositoryTest {
     @Before
     fun before() {
         databaseService = TestDatabaseService()
-        editHistoryRepository = ModelRepositories(databaseService).editHistory
+        val testCoreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            taskManager = TestTaskManager(UnusedTaskCodec())
+        )
+        editHistoryRepository = ModelRepositories(testCoreServiceManager).editHistory
         editHistoryDao = EditHistoryDaoImpl(databaseService)
     }
 

+ 13 - 1
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -22,11 +22,15 @@
 package ch.threema.data.repositories
 
 import ch.threema.data.TestDatabaseService
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
 import ch.threema.data.storage.DbGroup
 import ch.threema.data.storage.SqliteDatabaseBackend
+import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.GroupId
 import ch.threema.storage.models.GroupModel
 import org.junit.Assert
@@ -40,6 +44,7 @@ import kotlin.test.assertTrue
 class GroupModelRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseBackend: DatabaseBackend
+    private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var groupModelRepository: GroupModelRepository
 
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
@@ -56,6 +61,7 @@ class GroupModelRepositoryTest {
             "Description",
             Date(),
             setOf("AAAAAAAA", "BBBBBBBB"),
+            GroupModel.UserState.MEMBER,
         )
     }
 
@@ -63,7 +69,13 @@ class GroupModelRepositoryTest {
     fun before() {
         this.databaseService = TestDatabaseService()
         this.databaseBackend = SqliteDatabaseBackend(databaseService)
-        this.groupModelRepository = ModelRepositories(databaseService).groups
+        this.coreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            taskManager = TestTaskManager(UnusedTaskCodec())
+        )
+        this.groupModelRepository = ModelRepositories(coreServiceManager).groups
     }
 
     @Test

+ 254 - 0
app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt

@@ -0,0 +1,254 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.storage
+
+import androidx.test.core.app.ApplicationProvider
+import ch.threema.app.ThreemaApplication
+import ch.threema.base.crypto.HashedNonce
+import ch.threema.base.crypto.Nonce
+import ch.threema.base.crypto.NonceScope
+import ch.threema.base.crypto.NonceStore
+import ch.threema.domain.stores.IdentityStoreInterface
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+class DatabaseNonceStoreTest {
+    private lateinit var tempDbFileName: String
+
+    private var _store: DatabaseNonceStore? = null
+    private val store: NonceStore
+        get() = _store!!
+
+    @Before
+    fun setup() {
+
+        tempDbFileName = "threema-nonce-test-${System.currentTimeMillis()}.db"
+        val identityStore = TestIdentityStore()
+        _store = DatabaseNonceStore(
+            ApplicationProvider.getApplicationContext(),
+            identityStore,
+            tempDbFileName
+        )
+    }
+
+    @After
+    fun teardown() {
+        _store!!.close()
+        _store = null
+        ApplicationProvider
+            .getApplicationContext<ThreemaApplication>()
+            .deleteDatabase(tempDbFileName)
+    }
+
+    @Test
+    fun testSameNonceWithDifferentScope() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+
+        // Assert the nonces do not exist in the store
+        nonces.forEach {
+            // Assert that nonce does not exist in store
+            assertFalse(store.exists(NonceScope.CSP, it))
+            assertFalse(store.exists(NonceScope.D2D, it))
+        }
+
+        // Assert that storing the nonces succeeds
+        nonces.forEach {
+            assertTrue(store.store(NonceScope.CSP, it))
+            assertTrue(store.store(NonceScope.D2D, it))
+        }
+
+        // Assert the nonces exist after the insert
+        nonces.forEach {
+            assertTrue(store.exists(NonceScope.CSP, it))
+            assertTrue(store.exists(NonceScope.D2D, it))
+        }
+    }
+
+    @Test
+    fun testExistsWithExistingNonce() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+
+        // Assert that storing the nonces succeeds
+        nonces.forEach {
+            // Assert that storing a nonce succeeds
+            assertTrue(store.store(NonceScope.CSP, it))
+            assertTrue(store.store(NonceScope.D2D, it))
+        }
+
+
+        // Assert the nonces exist after the insert
+        nonces.forEach {
+            assertTrue(store.exists(NonceScope.CSP, it))
+            assertTrue(store.exists(NonceScope.D2D, it))
+        }
+    }
+
+    @Test
+    fun testStoreWithExistingNonce() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+
+        // Assert the nonces do not exist in the store
+        nonces.forEach {
+            // Assert that nonce does not exist in store
+            assertFalse(store.exists(NonceScope.CSP, it))
+            assertFalse(store.exists(NonceScope.D2D, it))
+        }
+
+        // Assert that storing the nonces succeeds
+        nonces.forEach {
+            assertTrue(store.store(NonceScope.CSP, it))
+            assertTrue(store.store(NonceScope.D2D, it))
+        }
+
+        // Assert that storing the nonces again fails
+        nonces.forEach {
+            assertFalse(store.store(NonceScope.CSP, it))
+            assertFalse(store.store(NonceScope.D2D, it))
+        }
+    }
+
+    @Test
+    fun testBulkExport() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+        val expectedHashedNonces = nonces.map { hashNonce(it) }
+
+        // Assert that storing the nonces succeeds
+        nonces.forEach {
+            assertTrue(store.store(NonceScope.CSP, it))
+            assertTrue(store.store(NonceScope.D2D, it))
+        }
+
+        assertSameHashedNonces(expectedHashedNonces, store.getAllHashedNonces(NonceScope.CSP))
+        assertSameHashedNonces(expectedHashedNonces, store.getAllHashedNonces(NonceScope.D2D))
+    }
+
+    @Test
+    fun testBulkImportNotHashed() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+        val pseudoHashedNonces = nonces.map { HashedNonce(it.bytes) }
+
+        // Insert the unhashed nonces. As they are inserted as if they were already hashed,
+        // the store must not hash them again.
+        assertTrue(store.insertHashedNonces(NonceScope.CSP, pseudoHashedNonces))
+        assertTrue(store.insertHashedNonces(NonceScope.D2D, pseudoHashedNonces))
+
+        nonces.forEach {
+            // Assert that the nonce as inserted should exist
+            assertTrue(store.exists(NonceScope.CSP, it))
+            assertTrue(store.exists(NonceScope.D2D, it))
+        }
+    }
+
+    @Test
+    fun testBulkImportHashed() {
+        assertStoreEmpty()
+
+        val nonces = createNonces()
+        val hashedNonces = nonces.map { hashNonce(it) }
+
+        // Insert the hashed nonces. As they are inserted as if they were already hashed,
+        // the store must not hash them again.
+        assertTrue(store.insertHashedNonces(NonceScope.CSP, hashedNonces))
+        assertTrue(store.insertHashedNonces(NonceScope.D2D, hashedNonces))
+
+        // Assert that all unhashed nonces exist in the store
+        nonces.forEach {
+            // Assert that the nonce as inserted should exist
+            assertTrue(store.exists(NonceScope.CSP, it))
+            assertTrue(store.exists(NonceScope.D2D, it))
+        }
+    }
+
+    private fun assertSameHashedNonces(expected: Collection<HashedNonce>, actual: Collection<HashedNonce>) {
+        assertEquals(expected.size, actual.size)
+        expected.forEach { expectedNonce ->
+            // If `actual.contains(expectedNonce)` is used only referential equality is checked
+            // which will fail.
+            assertTrue(actual.find { it.bytes.contentEquals(expectedNonce.bytes) } != null)
+        }
+    }
+
+    private fun assertStoreEmpty() {
+        assertEquals(0, store.getCount(NonceScope.CSP))
+        assertEquals(0, store.getCount(NonceScope.D2D))
+    }
+
+    /**
+     * Create 256 sequential nonces, where the LSB acts as a counter.
+     */
+    private fun createNonces(): List<Nonce> {
+        return (0..255)
+            .map { ByteArray(23) + byteArrayOf(it.toByte()) }
+            .map { Nonce(it) }
+    }
+}
+
+fun hashNonce(nonce: Nonce): HashedNonce {
+    val mac = Mac.getInstance("HmacSHA256")
+    mac.init(SecretKeySpec(USER_IDENTITY.encodeToByteArray(), "HmacSHA256"))
+    return HashedNonce(mac.doFinal(nonce.bytes))
+}
+
+const val USER_IDENTITY = "01234567"
+
+private class TestIdentityStore : IdentityStoreInterface {
+    override fun getIdentity(): String = USER_IDENTITY
+
+    override fun encryptData(plaintext: ByteArray, nonce: ByteArray, receiverPublicKey: ByteArray): ByteArray
+        = throw UnsupportedOperationException()
+
+    override fun decryptData(ciphertext: ByteArray, nonce: ByteArray, senderPublicKey: ByteArray): ByteArray
+        = throw UnsupportedOperationException()
+
+    override fun calcSharedSecret(publicKey: ByteArray): ByteArray
+        = throw UnsupportedOperationException()
+
+    override fun getServerGroup(): String
+        = throw UnsupportedOperationException()
+
+    override fun getPublicKey(): ByteArray
+        = throw UnsupportedOperationException()
+
+    override fun getPrivateKey(): ByteArray
+        = throw UnsupportedOperationException()
+
+    override fun getPublicNickname(): String
+        = throw UnsupportedOperationException()
+
+    override fun storeIdentity(identity: String, serverGroup: String, publicKey: ByteArray, privateKey: ByteArray)
+        = throw UnsupportedOperationException()
+
+}

+ 6 - 0
app/src/foss_based/assets/license.html

@@ -171,6 +171,12 @@ SUCH DAMAGE.</p>
 
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
+<h2>Fluent Emoji</h2>
+
+<p>Copyright (c) Microsoft Corporation</p>
+
+<p>Licensed under the MIT License (copy below).</p>
+
 <h2>Gesture Views</h2>
 
 <p>Copyright (c) 2022 Alex Vasilkov</p>

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

@@ -1 +1,4 @@
-- Behebung eines Fehlers des Updaters der Shop Version
+- Behebung eines Fehlers der zu einem Crash auf einigen Motorola Geräten führt, wenn ein Chat geöffnet wird
+- Behebung eines Fehlers bei der Darstellung von Sprachnachrichten auf Tablets
+- Änderungen bei der Verarbeitung von Nachrichten als Vorbereitung für Multi Device auf Android
+- Weniger Statusnachrichten bei Umfragen

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

@@ -1 +1,4 @@
-- Fixed a bug in relation to the automatic updates of the threema shop version
+- Fixed a bug that crashes the app on some motorola devices when a chat is opened
+- Fixed a bug regarding the display of voice messages on tablets
+- Changes during message processing in preparation of multi device for android
+- Reduce number of status messages when using polls

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

@@ -605,7 +605,12 @@
 			android:launchMode="singleTop"
 			android:parentActivityName=".activities.HomeActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
-			android:configChanges="uiMode" />
+            android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" />
+		<activity
+			android:name=".multidevice.wizard.LinkNewDeviceWizardActivity"
+			android:theme="@style/Theme.Threema.Translucent"
+            android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
+	        android:windowSoftInputMode="adjustNothing"/>
 
 		<!-- VoIP activities -->
 		<activity
@@ -885,6 +890,9 @@
 			android:name=".activities.ProblemSolverActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:launchMode="singleTop"/>
+		<activity
+			android:name=".debug.PatternLibraryActivity"
+			android:theme="@style/Theme.Threema.Translucent" />
 
 		<!-- services -->
 		<service

Різницю між файлами не показано, бо вона завелика
+ 653 - 645
app/src/main/java/ch/threema/app/ThreemaApplication.java


+ 11 - 9
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -48,14 +48,14 @@ import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
-import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask;
 import ch.threema.app.asynctasks.AlreadyVerified;
-import ch.threema.app.asynctasks.ContactAddResult;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactResult;
+import ch.threema.app.asynctasks.ContactCreated;
 import ch.threema.app.asynctasks.ContactExists;
 import ch.threema.app.asynctasks.ContactModified;
 import ch.threema.app.asynctasks.Failed;
 import ch.threema.app.asynctasks.PolicyViolation;
-import ch.threema.app.asynctasks.Success;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.NewContactDialog;
@@ -74,6 +74,7 @@ import ch.threema.base.utils.Base64;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.protocol.api.APIConnector;
+import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ID;
 import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
@@ -237,8 +238,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			return;
 		}
 
-		backgroundExecutor.execute(new AddOrUpdateContactBackgroundTask(
+		backgroundExecutor.execute(new BasicAddOrUpdateContactBackgroundTask(
 			identity,
+			ContactModel.AcquaintanceLevel.DIRECT,
 			getMyIdentity(),
 			apiConnector,
 			contactModelRepository,
@@ -252,14 +254,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			}
 
 			@Override
-			public void onFinished(@NonNull ContactAddResult result) {
+			public void onFinished(@NonNull ContactResult result) {
 				if (isDestroyed()) {
 					return;
 				}
 
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
 
-				if (result instanceof Success) {
+				if (result instanceof ContactCreated) {
 					showContactAndFinish(identity, R.string.creating_contact_successful);
 				} else if (result instanceof ContactModified) {
 					if (((ContactModified) result).getAcquaintanceLevelChanged()) {
@@ -271,6 +273,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 					showContactAndFinish(identity, R.string.scan_duplicate);
 				} else if (result instanceof ContactExists) {
 					showContactAndFinish(identity, R.string.identity_already_exists);
+				} else if (result instanceof PolicyViolation) {
+					Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
+					finish();
 				} else if (result instanceof Failed) {
 					GenericAlertDialog.newInstance(
 						ConfigUtils.isOnPremBuild() ?
@@ -280,9 +285,6 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 						R.string.close,
 						0
 					).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
-				} else if (result instanceof PolicyViolation) {
-					Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
-					finish();
 				}
 			}
 		});

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

@@ -28,20 +28,32 @@ import android.widget.Toast;
 
 import org.slf4j.Logger;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.asynctasks.AddContactAsyncTask;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactAvailable;
+import ch.threema.app.asynctasks.ContactResult;
 import ch.threema.app.grouplinks.OutgoingGroupRequestActivity;
 import ch.threema.app.services.LockAppService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.HiddenChatUtil;
+import ch.threema.app.utils.LazyProperty;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
+import ch.threema.storage.models.ContactModel;
 
 public class AppLinksActivity extends ThreemaToolbarActivity {
+	private final static Logger logger = LoggingUtil.getThreemaLogger("AppLinksActivity");
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("AppLinksActivity");
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
@@ -91,40 +103,23 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         finish();
     }
 
-    private void handleContactUrl(String appLinkAction, Uri appLinkData) {
-        logger.info("Handle contact url");
-        final String threemaId = appLinkData.getLastPathSegment();
-        if (threemaId != null) {
-            if (threemaId.equalsIgnoreCase("compose")) {
-                Intent intent = new Intent(this, RecipientListActivity.class);
-                intent.setAction(appLinkAction);
-                intent.setData(appLinkData);
-                startActivity(intent);
-            } else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
-                new AddContactAsyncTask(null, null, threemaId, false, () -> {
-                    String text = appLinkData.getQueryParameter("text");
-
-                    Intent intent = new Intent(this, text != null ?
-                        ComposeMessageActivity.class :
-                        ContactDetailActivity.class);
-                    intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-                    intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, threemaId);
-                    intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
-
-                    if (text != null) {
-                        text = text.trim();
-                        intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
-                    }
-
-                    startActivity(intent);
-                }).execute();
-            } else {
-                Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
-            }
-        } else {
-            Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
-        }
-    }
+	private void handleContactUrl(String appLinkAction, Uri appLinkData) {
+		final String threemaId = appLinkData.getLastPathSegment();
+		if (threemaId != null) {
+			if (threemaId.equalsIgnoreCase("compose")) {
+				Intent intent = new Intent(this, RecipientListActivity.class);
+				intent.setAction(appLinkAction);
+				intent.setData(appLinkData);
+				startActivity(intent);
+			} else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
+				addNewContactAndOpenChat(threemaId, appLinkData);
+			} else {
+				Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
+			}
+		} else {
+			Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
+		}
+	}
 
     private void handleGroupLinkUrl(Uri appLinkData) {
         logger.info("Handle group link url");
@@ -139,27 +134,70 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         overridePendingTransition(0, 0);
     }
 
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        switch (requestCode) {
-            case ThreemaActivity.ACTIVITY_ID_CHECK_LOCK:
-                if (resultCode == RESULT_OK) {
-                    lockAppService.unlock(null);
-                    handleIntent();
-                } else {
-                    Toast.makeText(this, getString(R.string.pin_locked_cannot_send), Toast.LENGTH_LONG).show();
-                    finish();
-                }
-                break;
-            case ThreemaActivity.ACTIVITY_ID_UNLOCK_MASTER_KEY:
-                if (ThreemaApplication.getMasterKey().isLocked()) {
-                    finish();
-                } else {
-                    ConfigUtils.recreateActivity(this, AppLinksActivity.class, getIntent().getExtras());
-                }
-                break;
-            default:
-                super.onActivityResult(requestCode, resultCode, data);
-        }
-    }
+	@Override
+	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+		switch (requestCode) {
+			case ThreemaActivity.ACTIVITY_ID_CHECK_LOCK:
+				if (resultCode == RESULT_OK) {
+					lockAppService.unlock(null);
+					handleIntent();
+				} else {
+					Toast.makeText(this, getString(R.string.pin_locked_cannot_send), Toast.LENGTH_LONG).show();
+					finish();
+				}
+				break;
+			case ThreemaActivity.ACTIVITY_ID_UNLOCK_MASTER_KEY:
+				if (ThreemaApplication.getMasterKey().isLocked()) {
+					finish();
+				} else {
+					ConfigUtils.recreateActivity(this, AppLinksActivity.class, getIntent().getExtras());
+				}
+				break;
+			default:
+				super.onActivityResult(requestCode, resultCode, data);
+		}
+	}
+
+	private void addNewContactAndOpenChat(@NonNull String identity, @NonNull Uri appLinkData) {
+		UserService userService = serviceManager.getUserService();
+		APIConnector apiConnector = serviceManager.getAPIConnector();
+		ContactModelRepository contactModelRepository = serviceManager.getModelRepositories().getContacts();
+
+		backgroundExecutor.get().execute(
+			new BasicAddOrUpdateContactBackgroundTask(
+				identity,
+				ContactModel.AcquaintanceLevel.DIRECT,
+				userService.getIdentity(),
+				apiConnector,
+				contactModelRepository,
+				AddContactRestrictionPolicy.CHECK,
+				AppLinksActivity.this,
+				null
+			) {
+				@Override
+				public void onFinished(ContactResult result) {
+					if (!(result instanceof ContactAvailable)) {
+						logger.error("Could not add contact");
+						return;
+					}
+
+					String text = appLinkData.getQueryParameter("text");
+
+					Intent intent = new Intent(AppLinksActivity.this, text != null ?
+						ComposeMessageActivity.class :
+						ContactDetailActivity.class);
+					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
+
+					if (text != null) {
+						text = text.trim();
+						intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
+					}
+
+					startActivity(intent);
+				}
+			}
+		);
+	}
 }

+ 138 - 84
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -28,7 +28,6 @@ import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
@@ -46,9 +45,11 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import org.slf4j.Logger;
 
 import java.io.File;
+import java.lang.ref.WeakReference;
 import java.util.Date;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
@@ -64,9 +65,19 @@ import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.ContactDetailAdapter;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.AlreadyVerified;
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy;
+import ch.threema.app.asynctasks.ContactModified;
+import ch.threema.app.asynctasks.ContactResult;
+import ch.threema.app.asynctasks.ContactSyncPolicy;
+import ch.threema.app.asynctasks.DeleteContactServices;
+import ch.threema.app.asynctasks.DialogMarkContactAsDeletedBackgroundTask;
+import ch.threema.app.asynctasks.Failed;
+import ch.threema.app.asynctasks.LocalPublicKeyMismatch;
 import ch.threema.app.dialogs.ContactEditDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
-import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
@@ -87,7 +98,7 @@ import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
-import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QRScannerUtil;
@@ -95,11 +106,13 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.models.ContactModelData;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
@@ -116,7 +129,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private static final String DIALOG_TAG_EDIT = "cedit";
 	private static final String DIALOG_TAG_DELETE_CONTACT = "deleteContact";
 	private static final String DIALOG_TAG_EXCLUDE_CONTACT = "excludeContact";
-	private static final String DIALOG_TAG_DELETING_CONTACT = "dliC";
 	private static final String DIALOG_TAG_ADD_CONTACT = "dac";
 	private static final String DIALOG_TAG_CONFIRM_BLOCK = "block";
 
@@ -129,15 +141,20 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	// Services
 	private ContactService contactService;
+	private ContactModelRepository contactModelRepository;
 	private GroupService groupService;
 	private IdListService blockedContactsService, profilePicRecipientsService;
 	private DeadlineListService hiddenChatsListService;
 	private VoipStateService voipStateService;
+	private DeleteContactServices deleteContactServices;
+
+	private final @NonNull LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
 	// Data and state holders
 	private String identity;
 	@Deprecated
 	private ContactModel contact;
+	private ch.threema.data.models.ContactModel contactModel;
 	private @Nullable ContactDetailViewModel viewModel; // Initially null, until initialized
 	private List<GroupModel> groupList;
 	private boolean isReadonly;
@@ -153,7 +170,15 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private View workIcon;
 
 	private void refreshAdapter() {
-		contactDetailRecyclerView.setAdapter(setupAdapter());
+		if (viewModel == null) {
+			logger.error("View model is null. Cannot refresh adapter.");
+			return;
+		}
+
+		ContactModelData fetchedData = viewModel.getContact().getValue();
+		if (fetchedData != null) {
+			contactDetailRecyclerView.setAdapter(setupAdapter(fetchedData));
+		}
 	}
 
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
@@ -201,8 +226,8 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
-			if (!this.shouldHandleChange(contactModel.getIdentity())) {
+		public void onAvatarChanged(final @NonNull String identity) {
+			if (!this.shouldHandleChange(identity)) {
 				return;
 			}
 			RuntimeUtil.runOnUiThread(() -> updateProfilepicMenu());
@@ -237,21 +262,21 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			if (newIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String leftIdentity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String leftIdentity) {
 			if (leftIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String kickedIdentity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String kickedIdentity) {
 			if (kickedIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
@@ -302,11 +327,27 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		try {
 			this.contactService = serviceManager.getContactService();
 			modelRepositories = serviceManager.getModelRepositories();
+			contactModelRepository = modelRepositories.getContacts();
 			this.blockedContactsService = serviceManager.getBlockedContactsService();
 			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
 			this.groupService = serviceManager.getGroupService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
 			this.voipStateService = serviceManager.getVoipStateService();
+			this.deleteContactServices = new DeleteContactServices(
+				serviceManager.getUserService(),
+				contactService,
+				serviceManager.getConversationService(),
+				serviceManager.getRingtoneService(),
+				serviceManager.getMutedChatsListService(),
+				hiddenChatsListService,
+				profilePicRecipientsService,
+				serviceManager.getWallpaperService(),
+				serviceManager.getFileService(),
+				serviceManager.getExcludedSyncIdentitiesService(),
+				serviceManager.getDHSessionStore(),
+				serviceManager.getNotificationService(),
+				serviceManager.getDatabaseServiceNew()
+			);
 		} catch (Exception e) {
 			LogUtil.exception(e, this);
 			this.finish();
@@ -315,7 +356,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 		// Look up contact data
 		this.contact = this.contactService.getByIdentity(this.identity);
-		final ch.threema.data.models.ContactModel contactModel = modelRepositories.getContacts().getByIdentity(this.identity);
+		contactModel = modelRepositories.getContacts().getByIdentity(this.identity);
 		if (this.contact == null || contactModel == null) {
 			Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
 			this.finish();
@@ -371,7 +412,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 		// Set up contact detail recycler view
 		this.contactDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
-		this.contactDetailRecyclerView.setAdapter(setupAdapter());
 
 		// Set description for badge
 		this.workIcon.setContentDescription(
@@ -480,16 +520,17 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 
 	@UiThread
-	private ContactDetailAdapter setupAdapter() {
-		// By the time `setupAdapter` is called for the first time, the viewmodel should
-		// already be initialized.
-		final ContactDetailViewModel viewModel = Objects.requireNonNull(this.viewModel);
-		final ContactModelData contactModelData = Objects.requireNonNull(viewModel.getContact().getValue());
+	@Nullable
+	private ContactDetailAdapter setupAdapter(@NonNull ContactModelData contactModelData) {
+		if (viewModel == null) {
+			logger.error("View model is null");
+			return null;
+		}
 
 		final ContactDetailAdapter contactDetailAdapter = new ContactDetailAdapter(
 			this,
 			this.groupList,
-			contact,
+			viewModel.getContactModel(),
 			contactModelData,
 			Glide.with(this)
 		);
@@ -565,37 +606,25 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_CONTACT);
 	}
 
-	private void removeContactConfirmed(final boolean addToExcludeList, final ContactModel contactModel) {
-		new AsyncTask<Void, Void, Boolean>() {
-			@Override
-			protected void onPreExecute() {
-				GenericProgressDialog.newInstance(R.string.deleting_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DELETING_CONTACT);
-			}
-
-
-			@Override
-			protected Boolean doInBackground(Void... params) {
-				if (addToExcludeList) {
-					IdListService excludeFromSyncListService = ContactDetailActivity.this
-							.serviceManager.getExcludedSyncIdentitiesService();
-
-					if (excludeFromSyncListService != null) {
-						excludeFromSyncListService.add(contactModel.getIdentity());
-					}
-				}
-				return contactService.remove(contactModel);
-			}
-
-			@Override
-			protected void onPostExecute(Boolean success) {
-				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DELETING_CONTACT, true);
-				if (!success) {
-					Toast.makeText(ContactDetailActivity.this, "Failed to remove contact", Toast.LENGTH_SHORT).show();
-				} else {
+	private void removeContactConfirmed(final boolean addToExcludeList) {
+		backgroundExecutor.get().execute(
+			new DialogMarkContactAsDeletedBackgroundTask(
+				getSupportFragmentManager(),
+				new WeakReference<>(this),
+				Set.of(identity),
+				contactModelRepository,
+				deleteContactServices,
+				addToExcludeList ? ContactSyncPolicy.EXCLUDE : ContactSyncPolicy.INCLUDE,
+				AndroidContactLinkPolicy.REMOVE_LINK
+			) {
+				@Override
+				protected void onFinished() {
+					// TODO(ANDR-3051): Do not leave contact detail activity if contact could not be
+					//  deleted.
 					finishAndGoHome();
 				}
 			}
-		}.execute();
+		);
 	}
 
 	private void editName() {
@@ -658,7 +687,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		updateVoipCallMenuItem(null);
 
 		MenuItem galleryMenuItem = menu.findItem(R.id.menu_gallery);
-		if (hiddenChatsListService.has(contactService.getUniqueIdString(contact))) {
+		if (hiddenChatsListService.has(ContactUtil.getUniqueIdString(identity))) {
 			galleryMenuItem.setVisible(false);
 		}
 
@@ -709,7 +738,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		} else if (id == R.id.action_share_contact) {
 			ShareUtil.shareContact(this, contact);
 		} else if (id == R.id.menu_gallery) {
-			if (!hiddenChatsListService.has(contactService.getUniqueIdString(contact))) {
+			if (!hiddenChatsListService.has(ContactUtil.getUniqueIdString(identity))) {
 				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
 				startActivity(mediaGalleryIntent);
@@ -763,7 +792,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 					this.profilePicItem.setVisible(false);
 					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrGatewayContact(contact));
 					break;
-				case PreferenceService.PROFILEPIC_RELEASE_SOME:
+				case PreferenceService.PROFILEPIC_RELEASE_ALLOW_LIST:
 					if (!ContactUtil.isEchoEchoOrGatewayContact(contact)) {
 						if (profilePicRecipientsService != null && profilePicRecipientsService.has(this.identity)) {
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
@@ -819,42 +848,15 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 								this.serviceManager.getQRCodeService());
 
 				if (qrRes != null) {
-					if (qrRes.getExpirationDate() != null && qrRes.getExpirationDate().before(new Date())) {
-						SimpleStringAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode)).show(getSupportFragmentManager(), "expiredId");
-						return;
-					}
-
-					if(!TestUtil.compare(identity, qrRes.getIdentity())) {
-						SimpleStringAlertDialog.newInstance(
-								R.string.scan_id_mismatch_title,
-								getString(R.string.scan_id_mismatch_message)).show(getSupportFragmentManager(), "scanId");
-						return;
-					}
-					int contactVerification = this.contactService.updateContactVerification(identity, qrRes.getPublicKey());
-
-					//update the view
-					// this.updateVerificationLevelImage(this.verificationLevelImageView);
-
-					int txt;
-					switch (contactVerification) {
-						case ContactService.ContactVerificationResult_ALREADY_VERIFIED:
-							txt = R.string.scan_duplicate;
-							break;
-						case ContactService.ContactVerificationResult_VERIFIED:
-							txt = R.string.scan_successful;
-							break;
-						default:
-							txt = R.string.id_mismatch;
-					}
-					SimpleStringAlertDialog.newInstance(R.string.id_scanned, getString(txt)).show(getSupportFragmentManager(), "scanId");
+					applyQRCodeResult(qrRes);
 				}
 				break;
 			case REQUEST_CODE_CONTACT_EDITOR:
 				try {
-					AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
-					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
+					AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel);
+					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel);
 					this.avatarEditView.setContactModel(contact);
-				} catch (ThreemaException e) {
+				} catch (ThreemaException|SecurityException e) {
 					logger.info("Unable to update contact name or avatar after returning from ContactEditor");
 				}
 				break;
@@ -881,15 +883,67 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			dialogFragment.setData(contact);
 			dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_EXCLUDE_CONTACT);
 		} else {
-			removeContactConfirmed(false, contactModel);
+			removeContactConfirmed(false);
 		}
 	}
 
 	void unhideContact(ContactModel contactModel) {
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		onCreateLocal();
 	}
 
+	private void applyQRCodeResult(@NonNull QRCodeService.QRCodeContentResult qrRes) {
+		if (qrRes.getExpirationDate() != null && qrRes.getExpirationDate().before(new Date())) {
+			SimpleStringAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode)).show(getSupportFragmentManager(), "expiredId");
+			return;
+		}
+
+		if(!TestUtil.compare(identity, qrRes.getIdentity())) {
+			SimpleStringAlertDialog.newInstance(
+				R.string.scan_id_mismatch_title,
+				getString(R.string.scan_id_mismatch_message)).show(getSupportFragmentManager(), "scanId");
+			return;
+		}
+
+		AddOrUpdateContactBackgroundTask<String> task = new AddOrUpdateContactBackgroundTask<>(
+			identity,
+			ContactModel.AcquaintanceLevel.DIRECT,
+			contactService.getMe().getIdentity(),
+			serviceManager.getAPIConnector(),
+			contactModelRepository,
+			AddContactRestrictionPolicy.CHECK,
+			this,
+			qrRes.getPublicKey()
+		) {
+			@Override
+			public String onContactAdded(@NonNull ContactResult result) {
+				if (result instanceof AlreadyVerified) {
+					return getString(R.string.scan_duplicate);
+				} else if (result instanceof ContactModified) {
+					if (((ContactModified) result).getVerificationLevelChanged()) {
+						return getString(R.string.scan_successful);
+					} else if (((ContactModified) result).getAcquaintanceLevelChanged()) {
+						logger.warn("Acquaintance level has changed instead of verification level");
+					}
+				} else if (result instanceof LocalPublicKeyMismatch) {
+					return getString(R.string.id_mismatch);
+				} else if (result instanceof Failed) {
+					return ((Failed) result).getMessage();
+				}
+				return null;
+			}
+
+			@Override
+			public void onFinished(@Nullable String result) {
+				if (result != null) {
+					SimpleStringAlertDialog.newInstance(R.string.id_scanned, result).show(getSupportFragmentManager(), "scanId");
+				}
+			}
+		};
+
+		backgroundExecutor.get().execute(task);
+	}
+
 	@Override
 	public void onYes(String tag, Object data) {
 		switch (tag) {
@@ -898,7 +952,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				deleteContact(contactModel);
 				break;
 			case DIALOG_TAG_EXCLUDE_CONTACT:
-				removeContactConfirmed(true, (ContactModel) data);
+				removeContactConfirmed(true);
 				break;
 			case DIALOG_TAG_ADD_CONTACT:
 				unhideContact(this.contact);
@@ -915,7 +969,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	public void onNo(String tag, Object data) {
 		switch (tag) {
 			case DIALOG_TAG_EXCLUDE_CONTACT:
-				removeContactConfirmed(false, (ContactModel) data);
+				removeContactConfirmed(false);
 				break;
 			case DIALOG_TAG_ADD_CONTACT:
 				finish();

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

@@ -29,7 +29,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
 import ch.threema.app.ThreemaApplication
 import ch.threema.data.models.ContactModel
 
-class ContactDetailViewModel(private val contactModel: ContactModel) : ViewModel() {
+class ContactDetailViewModel(val contactModel: ContactModel) : ViewModel() {
     val contact = contactModel.liveData()
 
     /**

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

@@ -25,25 +25,25 @@ import android.os.Bundle;
 import android.view.View;
 
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 
 public class ContactNotificationsActivity extends NotificationsActivity {
-	private String identity;
 	private ContactModel contactModel;
 
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		this.identity = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_CONTACT);
-		if (TestUtil.isEmptyOrNull(this.identity)) {
+		String identity = getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_CONTACT);
+		if (TestUtil.isEmptyOrNull(identity)) {
 			finish();
 			return;
 		}
 
 		this.contactModel = contactService.getByIdentity(identity);
-		this.uid = contactService.getUniqueIdString(contactModel);
+		this.uid = ContactUtil.getUniqueIdString(identity);
 
 		refreshSettings();
 	}

+ 70 - 54
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -21,8 +21,6 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
-
 import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
 import android.content.Intent;
@@ -38,18 +36,6 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.IntDef;
-import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.lifecycle.LiveData;
-import androidx.paging.LivePagedListBuilder;
-import androidx.paging.PagedList;
-import androidx.recyclerview.widget.DefaultItemAnimator;
-import androidx.recyclerview.widget.LinearLayoutManager;
-
 import com.google.android.material.chip.Chip;
 import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.progressindicator.LinearProgressIndicator;
@@ -62,24 +48,41 @@ import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.annotation.ColorInt;
+import androidx.annotation.IntDef;
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.lifecycle.LiveData;
+import androidx.paging.LivePagedListBuilder;
+import androidx.paging.PagedList;
+import androidx.recyclerview.widget.DefaultItemAnimator;
+import androidx.recyclerview.widget.LinearLayoutManager;
 import ch.threema.app.R;
 import ch.threema.app.adapters.DirectoryAdapter;
-import ch.threema.app.asynctasks.AddContactAsyncTask;
+import ch.threema.app.asynctasks.AddOrUpdateWorkContactBackgroundTask;
 import ch.threema.app.dialogs.MultiChoiceSelectorDialog;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.ui.DirectoryDataSourceFactory;
 import ch.threema.app.ui.DirectoryHeaderItemDecoration;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LogUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModel;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
 import ch.threema.domain.protocol.api.work.WorkOrganization;
 
+import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
+
 public class DirectoryActivity extends ThreemaToolbarActivity implements ThreemaSearchView.OnQueryTextListener, MultiChoiceSelectorDialog.SelectorDialogClickListener {
     private static final Logger logger = LoggingUtil.getThreemaLogger("DirectoryActivity");
 
@@ -95,8 +98,13 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
     private static final int EMPTY_STATE_SEARCHING = 1;
     private static final int EMPTY_STATE_RESULTS = 2;
 
-    private ContactService contactService;
-    private boolean sortByFirstName;
+	private ContactService contactService;
+	private UserService userService;
+	private ContactModelRepository contactModelRepository;
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+
+	private boolean sortByFirstName;
 
     private DirectoryAdapter directoryAdapter;
     private DirectoryDataSourceFactory directoryDataSourceFactory;
@@ -176,12 +184,14 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
             updateToolbarTitle(getString(R.string.directory_title));
         }
 
-        try {
-            this.contactService = serviceManager.getContactService();
-        } catch (Exception e) {
-            LogUtil.exception(e, this);
-            return false;
-        }
+		try {
+			this.contactService = serviceManager.getContactService();
+		} catch (Exception e) {
+			logger.error("Could not get contact service", e);
+			return false;
+		}
+		this.userService = serviceManager.getUserService();
+		this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
 
         if (preferenceService == null) {
             return false;
@@ -229,11 +239,11 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
                 launchContact(workDirectoryContact, position);
             }
 
-            @Override
-            public void onAdd(WorkDirectoryContact workDirectoryContact, final int position) {
-                addContact(workDirectoryContact, () -> directoryAdapter.notifyItemChanged(position));
-            }
-        });
+			@Override
+			public void onAdd(WorkDirectoryContact workDirectoryContact, final int position) {
+				addContact(workDirectoryContact, () -> directoryAdapter.notifyItemChanged(position));
+			}
+		});
 
         // initial page size
         PagedList.Config config = new PagedList.Config.Builder().setPageSize(API_DIRECTORY_PAGE_SIZE).build();
@@ -348,32 +358,38 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
         startActivity(intent);
     }
 
-    private void launchContact(final WorkDirectoryContact workDirectoryContact, final int position) {
-        if (workDirectoryContact.threemaId != null) {
-            if (contactService.getByIdentity(workDirectoryContact.threemaId) == null) {
-                addContact(workDirectoryContact, () -> {
-                    openContact(workDirectoryContact.threemaId);
-                    directoryAdapter.notifyItemChanged(position);
-                });
-            } else if (workDirectoryContact.threemaId.equalsIgnoreCase(contactService.getMe().getIdentity())) {
-                Toast.makeText(this, R.string.me_myself_and_i, Toast.LENGTH_LONG).show();
-            } else {
-                openContact(workDirectoryContact.threemaId);
-            }
-        } else {
-            Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
-        }
-    }
-
-    private void addContact(final WorkDirectoryContact workDirectoryContact, Runnable runAfter) {
+	private void launchContact(final WorkDirectoryContact workDirectoryContact, final int position) {
+		if (workDirectoryContact.threemaId != null) {
+			if (contactService.getByIdentity(workDirectoryContact.threemaId) == null) {
+				addContact(workDirectoryContact, () -> {
+					openContact(workDirectoryContact.threemaId);
+					directoryAdapter.notifyItemChanged(position);
+				});
+			} else if (workDirectoryContact.threemaId.equalsIgnoreCase(contactService.getMe().getIdentity())) {
+				Toast.makeText(this, R.string.me_myself_and_i, Toast.LENGTH_LONG).show();
+			} else {
+				openContact(workDirectoryContact.threemaId);
+			}
+		} else {
+			Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
+		}
+	}
+
+	private void addContact(final WorkDirectoryContact workDirectoryContact, Runnable runAfter) {
         logger.info("Add new work contact");
-        new AddContactAsyncTask(
-            workDirectoryContact.firstName,
-            workDirectoryContact.lastName,
-            workDirectoryContact.threemaId,
-            true,
-            runAfter).execute();
-    }
+		backgroundExecutor.get().execute(
+			new AddOrUpdateWorkContactBackgroundTask(
+				workDirectoryContact,
+				userService.getIdentity(),
+				contactModelRepository
+			) {
+				@Override
+				public void runAfter(ContactModel contactModel) {
+					runAfter.run();
+				}
+			}
+		);
+	}
 
     private DirectoryHeaderItemDecoration.HeaderCallback getSectionCallback() {
         return new DirectoryHeaderItemDecoration.HeaderCallback() {

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

@@ -230,9 +230,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
-			if (this.shouldHandleChange(contactModel.getIdentity())) {
-				this.onModified(contactModel.getIdentity());
+		public void onAvatarChanged(final @NonNull String identity) {
+			if (this.shouldHandleChange(identity)) {
+				this.onModified(identity);
 			}
 		}
 
@@ -263,12 +263,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String identity) {
 			if (identity.equals(myIdentity)) {
 				finish();
 			} else {
@@ -277,7 +277,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String identity) {
 			if (identity.equals(myIdentity)) {
 				finish();
 			} else {
@@ -482,7 +482,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 	private void setupAdapter() throws MasterKeyLockedException, FileSystemNotPresentException {
 		Runnable onCloneGroupRunnable = null;
-		if (groupService.isOrphanedGroup(groupModel) && groupService.getOtherMemberCount(groupModel) > 0) {
+		if (groupService.isOrphanedGroup(groupModel) && groupService.countMembersWithoutUser(groupModel) > 0) {
 			onCloneGroupRunnable = this::showCloneDialog;
 		}
 
@@ -597,7 +597,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 			boolean isMember = groupService.isGroupMember(groupModel);
 			boolean isCreator = groupService.isGroupCreator(groupModel);
-			boolean hasOtherMembers = groupService.getOtherMemberCount(groupModel) > 0;
+			boolean hasOtherMembers = groupService.countMembersWithoutUser(groupModel) > 0;
 
 			// The clone menu only makes sense if at least one other member is present
 			cloneMenu.setVisible(hasOtherMembers);

+ 88 - 63
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -21,8 +21,6 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
-
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.BroadcastReceiver;
@@ -52,21 +50,6 @@ import android.view.Window;
 import android.widget.ImageView;
 import android.widget.Toast;
 
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
-import androidx.annotation.AnyThread;
-import androidx.annotation.ColorInt;
-import androidx.annotation.IdRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.AppCompatImageView;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentTransaction;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-
 import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.badge.ExperimentalBadgeUtils;
@@ -87,12 +70,31 @@ import java.util.Set;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.stream.Collectors;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.AnyThread;
+import androidx.annotation.ColorInt;
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.AppCompatImageView;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.wizard.WizardBaseActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.archive.ArchiveActivity;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactAvailable;
+import ch.threema.app.asynctasks.ContactCreated;
+import ch.threema.app.asynctasks.ContactResult;
 import ch.threema.app.backuprestore.csv.BackupService;
 import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -100,7 +102,6 @@ import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SMSVerificationDialog;
 import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.ContactsSectionFragment;
 import ch.threema.app.fragments.MessageSectionFragment;
@@ -124,19 +125,20 @@ import ch.threema.app.push.PushService;
 import ch.threema.app.qrscanner.activity.BaseQrScannerActivity;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.ContactServiceImpl;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationTagService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ThreemaPushService;
 import ch.threema.app.services.UpdateSystemService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.license.LicenseService;
+import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.tasks.ApplicationUpdateStepsTask;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeService;
@@ -150,9 +152,11 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.PowermanagerUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.groupcall.GroupCallObserver;
@@ -160,10 +164,12 @@ import ch.threema.app.voip.groupcall.sfu.GroupCallController;
 import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
-import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.connection.ConnectionState;
 import ch.threema.domain.protocol.connection.ConnectionStateListener;
+import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -172,6 +178,8 @@ import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.TagModel;
 
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
+
 public class HomeActivity extends ThreemaAppCompatActivity implements
 	SMSVerificationDialog.SMSVerificationDialogCallback,
 	GenericAlertDialog.DialogClickListener,
@@ -218,6 +226,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private NotificationService notificationService;
 	private UserService userService;
 	private ContactService contactService;
+	private ContactModelRepository contactModelRepository;
+	private APIConnector apiConnector;
 	private LockAppService lockAppService;
 	private PreferenceService preferenceService;
 	private ConversationService conversationService;
@@ -225,6 +235,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
     private @Nullable IdentityPopup identityPopup = null;
 
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+
 	private enum UnsentMessageAction {
 		ADD,
 		REMOVE,
@@ -778,7 +791,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void showWhatsNew() {
-		final boolean skipWhatsNew = false; // set this to false if you want to show a What's New screen
+		final boolean skipWhatsNew = true; // set this to false if you want to show a What's New screen
 
 		if (preferenceService != null) {
 			if (!preferenceService.isLatestVersion(this)) {
@@ -1058,6 +1071,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			} catch (Exception e) {
 				//
 			}
+			this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
+			this.apiConnector = serviceManager.getAPIConnector();
 
 			if (preferenceService == null || notificationService == null || userService == null) {
 				finish();
@@ -1949,56 +1964,66 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			return;
 		}
 
-		new AsyncTask<Void, Void, Exception>() {
-			ContactModel newContactModel;
-
-			@Override
-			protected void onPreExecute() {
-				GenericProgressDialog.newInstance(R.string.threema_channel, R.string.please_wait).show(getSupportFragmentManager(), THREEMA_CHANNEL_IDENTITY);
-			}
-
-			@Override
-			protected Exception doInBackground(Void... params) {
-				try {
-					newContactModel = contactService.createContactByIdentity(THREEMA_CHANNEL_IDENTITY, true);
-				} catch (Exception e) {
-					return e;
+		backgroundExecutor.get().execute(
+			new BasicAddOrUpdateContactBackgroundTask(
+				THREEMA_CHANNEL_IDENTITY,
+				ContactModel.AcquaintanceLevel.DIRECT,
+				userService.getIdentity(),
+				apiConnector,
+				contactModelRepository,
+				AddContactRestrictionPolicy.IGNORE,
+				this,
+				ContactServiceImpl.THREEMA_PUBLIC_KEY
+			) {
+				@Override
+				public void onBefore() {
+					GenericProgressDialog.newInstance(R.string.threema_channel, R.string.please_wait).show(getSupportFragmentManager(), THREEMA_CHANNEL_IDENTITY);
 				}
-				return null;
-			}
 
-			@Override
-			protected void onPostExecute(Exception exception) {
-				DialogUtil.dismissDialog(getSupportFragmentManager(), THREEMA_CHANNEL_IDENTITY, true);
-
-				if (exception == null || exception instanceof EntryAlreadyExistsException) {
-					launchThreemaChannelChat();
+				@Override
+				public void onFinished(@NonNull ContactResult result) {
+					DialogUtil.dismissDialog(getSupportFragmentManager(), THREEMA_CHANNEL_IDENTITY, true);
+
+					if (result instanceof ContactAvailable) {
+						// In case the contact has been successfully created or it has been
+						// modified, already verified, or already exists, the threema channel chat
+						// is launched.
+						launchThreemaChannelChat();
+
+						// Send initial messages to threema channel only if the threema channel has
+						// been newly created as a contact and did not exist before.
+						if (result instanceof ContactCreated) {
+							new Thread(() -> {
+								try {
+									ContactModel threemaChannelModel = contactService.getByIdentity(THREEMA_CHANNEL_IDENTITY);
+									if (threemaChannelModel == null) {
+										logger.error("Threema channel model is null after adding it");
+										return;
+									}
 
-					if (exception == null) {
-						new Thread(() -> {
-							try {
-								MessageReceiver receiver = contactService.createReceiver(newContactModel);
-								if (!getResources().getConfiguration().locale.getLanguage().startsWith("de") && !getResources().getConfiguration().locale.getLanguage().startsWith("gsw")) {
+									MessageReceiver<?> receiver = contactService.createReceiver(threemaChannelModel);
+									if (!getResources().getConfiguration().locale.getLanguage().startsWith("de") && !getResources().getConfiguration().locale.getLanguage().startsWith("gsw")) {
+										Thread.sleep(1000);
+										messageService.sendText("en", receiver);
+										Thread.sleep(500);
+									}
 									Thread.sleep(1000);
-									messageService.sendText("en", receiver);
-									Thread.sleep(500);
+									messageService.sendText(THREEMA_CHANNEL_START_NEWS_COMMAND, receiver);
+									Thread.sleep(1500);
+									messageService.sendText(ConfigUtils.isWorkBuild() ? THREEMA_CHANNEL_WORK_COMMAND : THREEMA_CHANNEL_START_ANDROID_COMMAND, receiver);
+									Thread.sleep(1500);
+									messageService.sendText(THREEMA_CHANNEL_INFO_COMMAND, receiver);
+								} catch (Exception e) {
+									//
 								}
-								Thread.sleep(1000);
-								messageService.sendText(THREEMA_CHANNEL_START_NEWS_COMMAND, receiver);
-								Thread.sleep(1500);
-								messageService.sendText(ConfigUtils.isWorkBuild() ? THREEMA_CHANNEL_WORK_COMMAND : THREEMA_CHANNEL_START_ANDROID_COMMAND, receiver);
-								Thread.sleep(1500);
-								messageService.sendText(THREEMA_CHANNEL_INFO_COMMAND, receiver);
-							} catch (Exception e) {
-								//
-							}
-						}).start();
+							}).start();
+						}
+					} else {
+						Toast.makeText(HomeActivity.this, R.string.internet_connection_required, Toast.LENGTH_LONG).show();
 					}
-				} else {
-					Toast.makeText(HomeActivity.this, R.string.internet_connection_required, Toast.LENGTH_LONG).show();
 				}
 			}
-		}.execute();
+		);
 	}
 
 	private void launchThreemaChannelChat() {

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

@@ -903,7 +903,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 			@Override
 			protected void onPostExecute(List<FaceItem> faceItemList) {
-				if (faceItemList != null && faceItemList.size() > 0) {
+				if (faceItemList != null && !faceItemList.isEmpty()) {
 					motionView.post(() -> {
 						for (FaceItem faceItem : faceItemList) {
 							Layer layer = new Layer();
@@ -1703,9 +1703,6 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		finishWithoutChanges();
 	}
 
-	@Override
-	public void onNo(String tag, Object data) {}
-
 	/**
 	 * Finish activity with changes (result ok)
 	 */

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

@@ -40,7 +40,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLif
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.preference.PreferenceManager
 import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
@@ -171,16 +170,12 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
     }
 
     private fun initScreenContent() {
-        val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext())
-        val shouldUseDynamicColors = sharedPreferences.getBoolean("pref_dynamic_color", false)
 
         val editHistoryComposeView = findViewById<ComposeView>(R.id.message_details_compose_view)
         editHistoryComposeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
 
         editHistoryComposeView.setContent {
-            ThreemaTheme(
-                dynamicColor = shouldUseDynamicColors
-            ) {
+            ThreemaTheme {
                 val uiState: ChatMessageDetailsUiState by viewModel.uiState.collectAsStateWithLifecycle()
                 val messageModel: MessageUiModel = uiState.message
                 val editHistoryViewModel: EditHistoryViewModel = viewModel(

+ 0 - 117
app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.java

@@ -1,117 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-2024 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.activities;
-
-import android.os.Bundle;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import ch.threema.app.R;
-import ch.threema.app.services.IdListService;
-import ch.threema.app.utils.LogUtil;
-import ch.threema.storage.models.ContactModel;
-
-public class ProfilePicRecipientsActivity extends MemberChooseActivity {
-	private IdListService profilePicRecipientsService;
-
-	@Override
-	public void onCreate(final Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-	}
-
-	@Override
-	protected boolean initActivity(Bundle savedInstanceState) {
-		if (!super.initActivity(savedInstanceState)) {
-			return false;
-		}
-
-		try {
-			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
-		} catch (Exception e) {
-			LogUtil.exception(e, this);
-			return false;
-		}
-
-		initData(savedInstanceState);
-
-		return true;
-	}
-
-	@Override
-	protected int getNotice() {
-		return R.string.prefs_sum_receive_profilepics_recipients_list;
-	}
-
-	@Override
-	protected int getMode() {
-		return MODE_PROFILE_PIC_RECIPIENTS;
-	}
-
-	@Override
-	protected void initData(Bundle savedInstanceState) {
-		if (savedInstanceState == null) {
-			String[] ids = profilePicRecipientsService.getAll();
-
-			if (ids != null && ids.length > 0) {
-				preselectedIdentities = new ArrayList<>(Arrays.asList(ids));
-			}
-		}
-
-		updateToolbarTitle(R.string.profile_picture, R.string.title_choose_recipient);
-
-		initList();
-	}
-
-	@Override
-	protected void menuNext(List<ContactModel> selectedContacts) {
-		if (selectedContacts.size() > 0) {
-			List<String> ids = new ArrayList<>(selectedContacts.size());
-
-			for (ContactModel contactModel : selectedContacts) {
-				if (contactModel != null) {
-					ids.add(contactModel.getIdentity());
-				}
-			}
-
-			if (ids.size() > 0) {
-				profilePicRecipientsService.addAll(ids.toArray(new String[ids.size()]));
-				finish();
-				return;
-			}
-		}
-		profilePicRecipientsService.removeAll();
-		finish();
-	}
-
-	@Override
-	protected boolean enableOnBackPressedCallback() {
-		return true;
-	}
-
-	@Override
-	protected void handleOnBackPressed() {
-		this.menuNext(getSelectedContacts());
-	}
-}

+ 99 - 0
app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.kt

@@ -0,0 +1,99 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2024 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.activities
+
+import android.os.Bundle
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.IdListService
+import ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask
+import ch.threema.app.utils.LogUtil
+import ch.threema.app.utils.equalsIgnoreOrder
+import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.storage.models.ContactModel
+
+class ProfilePicRecipientsActivity : MemberChooseActivity() {
+
+    private lateinit var profilePicRecipientsService: IdListService
+    private lateinit var taskManager: TaskManager
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+    }
+
+    override fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (!super.initActivity(savedInstanceState)) {
+            return false
+        }
+
+        try {
+            this.profilePicRecipientsService = serviceManager.profilePicRecipientsService
+            this.taskManager = serviceManager.taskManager
+        } catch (exception: Exception) {
+            LogUtil.exception(exception, this)
+            return false
+        }
+
+        initData(savedInstanceState)
+
+        return true
+    }
+
+    override fun initData(savedInstanceState: Bundle?) {
+        if (savedInstanceState == null) {
+            val selectedIdentities: Array<String>? = profilePicRecipientsService.all
+            if (!selectedIdentities.isNullOrEmpty()) {
+                preselectedIdentities = ArrayList(listOf(*selectedIdentities))
+            }
+        }
+        updateToolbarTitle(R.string.profile_picture, R.string.title_choose_recipient)
+        initList()
+    }
+
+    override fun menuNext(selectedContacts: List<ContactModel?>) {
+        val oldAllowedIdentities: Array<String> = profilePicRecipientsService.all
+        val newAllowedIdentities: Array<String> = selectedContacts.mapNotNull { contactModel -> contactModel?.identity }.toTypedArray<String>()
+        profilePicRecipientsService.replaceAll(newAllowedIdentities)
+
+        // If data changed:
+        // sync new policy setting with newly set allow list values into device group (if md is active)
+        if (!oldAllowedIdentities.equalsIgnoreOrder(newAllowedIdentities)) {
+            taskManager.schedule(
+                ReflectUserProfileShareWithAllowListSyncTask(
+                    allowedIdentities = newAllowedIdentities.toList(),
+                    serviceManager = ThreemaApplication.requireServiceManager()
+                )
+            )
+        }
+        finish()
+    }
+
+    override fun getNotice(): Int = R.string.prefs_sum_receive_profilepics_recipients_list
+
+    override fun getMode(): Int = MODE_PROFILE_PIC_RECIPIENTS
+
+    override fun enableOnBackPressedCallback(): Boolean = true
+
+    override fun handleOnBackPressed() {
+        this.menuNext(selectedContacts)
+    }
+}

+ 72 - 59
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -21,12 +21,6 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_FILES;
-import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
-import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
-import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
-import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
-
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.ClipData;
@@ -55,22 +49,6 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Toast;
 
-import androidx.annotation.AnyThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
-import androidx.annotation.WorkerThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.SearchView;
-import androidx.core.app.ActivityCompat;
-import androidx.core.app.TaskStackBuilder;
-import androidx.core.content.ContextCompat;
-import androidx.core.content.pm.ShortcutManagerCompat;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentPagerAdapter;
-import androidx.viewpager.widget.ViewPager;
-
 import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.search.SearchBar;
 import com.google.android.material.snackbar.Snackbar;
@@ -87,6 +65,21 @@ import java.util.List;
 import java.util.concurrent.Executors;
 import java.util.stream.Collectors;
 
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.SearchView;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.TaskStackBuilder;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.pm.ShortcutManagerCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -94,6 +87,9 @@ import ch.threema.app.actions.LocationMessageSendAction;
 import ch.threema.app.actions.SendAction;
 import ch.threema.app.actions.TextMessageSendAction;
 import ch.threema.app.adapters.FilterableListAdapter;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactResult;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -106,6 +102,7 @@ import ch.threema.app.fragments.RecipientListFragment;
 import ch.threema.app.fragments.UserListFragment;
 import ch.threema.app.fragments.WorkUserListFragment;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DistributionListService;
@@ -124,14 +121,17 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -142,6 +142,12 @@ import ch.threema.storage.models.data.LocationDataModel;
 import ch.threema.storage.models.data.MessageContentsType;
 import java8.util.concurrent.CompletableFuture;
 
+import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_FILES;
+import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
+import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
+import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
+import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
+
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     CancelableHorizontalProgressDialog.ProgressDialogClickListener,
     ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
@@ -181,12 +187,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     private final List<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
     private boolean isInternallyForwardingMediaFiles = false;
 
-    private GroupService groupService;
-    private ContactService contactService;
-    private ConversationService conversationService;
-    private DistributionListService distributionListService;
-    private MessageService messageService;
-    private FileService fileService;
+	private GroupService groupService;
+	private ContactService contactService;
+	private ConversationService conversationService;
+	private DistributionListService distributionListService;
+	private MessageService messageService;
+	private FileService fileService;
+	private UserService userService;
+	private APIConnector apiConnector;
+	private ContactModelRepository contactModelRepository;
+
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
     private final Runnable copyExternalFilesRunnable = new Runnable() {
         @Override
@@ -276,6 +288,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             this.distributionListService = serviceManager.getDistributionListService();
             this.messageService = serviceManager.getMessageService();
             this.fileService = serviceManager.getFileService();
+            this.apiConnector = serviceManager.getAPIConnector();
+            this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
             userService = serviceManager.getUserService();
         } catch (Exception e) {
             logger.error("Exception", e);
@@ -838,36 +852,35 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         if (contactModel == null) {
             GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), "pro");
 
-            new AsyncTask<Void, Void, Void>() {
-                boolean fail = false;
-                ContactModel newContactModel = null;
-
-                @Override
-                protected Void doInBackground(Void... params) {
-                    try {
-                        newContactModel = contactService.createContactByIdentity(identity, false);
-                    } catch (Exception e) {
-                        fail = true;
-                    }
-                    return null;
-                }
-
-                @Override
-                protected void onPostExecute(Void result) {
-                    DialogUtil.dismissDialog(getSupportFragmentManager(), "pro", true);
-
-                    if (fail) {
-                        View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
-                        Snackbar.make(rootView, R.string.contact_not_found, Snackbar.LENGTH_LONG).show();
-                    } else {
-                        prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)), false);
-                    }
-                }
-            }.execute();
-        } else {
-            prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
-        }
-    }
+			backgroundExecutor.get().execute(
+				new BasicAddOrUpdateContactBackgroundTask(
+					identity,
+					ContactModel.AcquaintanceLevel.DIRECT,
+					userService.getIdentity(),
+					apiConnector,
+					contactModelRepository,
+					AddContactRestrictionPolicy.CHECK,
+					RecipientListBaseActivity.this,
+					null
+				) {
+					@Override
+					public void onFinished(ContactResult result) {
+						DialogUtil.dismissDialog(getSupportFragmentManager(), "pro", true);
+
+						ContactModel newContactModel = contactService.getByIdentity(identity);
+						if (newContactModel == null) {
+							View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
+							Snackbar.make(rootView, R.string.contact_not_found, Snackbar.LENGTH_LONG).show();
+						} else {
+							prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)), false);
+						}
+					}
+				}
+			);
+		} else {
+			prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
+		}
+	}
 
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {

+ 1025 - 1034
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -21,9 +21,6 @@
 
 package ch.threema.app.activities.wizard;
 
-import static ch.threema.app.ThreemaApplication.PHONE_LINKED_PLACEHOLDER;
-import static ch.threema.app.protocol.ApplicationSetupStepsKt.runApplicationSetupSteps;
-
 import android.Manifest;
 import android.accounts.Account;
 import android.annotation.SuppressLint;
@@ -39,13 +36,6 @@ import android.view.View;
 import android.widget.Button;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentStatePagerAdapter;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.viewpager.widget.ViewPager;
-
 import com.google.android.material.button.MaterialButton;
 import com.google.i18n.phonenumbers.NumberParseException;
 import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -53,17 +43,23 @@ import com.google.i18n.phonenumbers.Phonenumber;
 
 import org.slf4j.Logger;
 
-import java.util.List;
-
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactResult;
+import ch.threema.app.asynctasks.ContactAvailable;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.WizardDialog;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
-import ch.threema.app.exceptions.InvalidEntryException;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.fragments.wizard.WizardFragment0;
 import ch.threema.app.fragments.wizard.WizardFragment1;
 import ch.threema.app.fragments.wizard.WizardFragment2;
@@ -91,1028 +87,1023 @@ import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.utils.executor.BackgroundTask;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModel;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.api.LinkEmailException;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
+
+import static ch.threema.app.ThreemaApplication.PHONE_LINKED_PLACEHOLDER;
+import static ch.threema.app.protocol.ApplicationSetupStepsKt.runApplicationSetupSteps;
 
 public class WizardBaseActivity extends ThreemaAppCompatActivity implements
-		LifecycleOwner,
-		ViewPager.OnPageChangeListener,
-		View.OnClickListener,
-		WizardFragment1.OnSettingsChangedListener,
-		WizardFragment2.OnSettingsChangedListener,
-		WizardFragment3.OnSettingsChangedListener,
-		WizardFragment4.SettingsInterface,
-		WizardDialog.WizardDialogCallback {
-
-	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardBaseActivity");
-
-	public static final String EXTRA_NEW_IDENTITY_CREATED = "newIdentity";
-	private static final String DIALOG_TAG_USE_ID_AS_NICKNAME = "nd";
-	private static final String DIALOG_TAG_INVALID_ENTRY = "ie";
-	private static final String DIALOG_TAG_USE_ANONYMOUSLY = "ano";
-	private static final String DIALOG_TAG_THREEMA_SAFE = "sd";
-	private static final String DIALOG_TAG_PASSWORD_BAD = "pwb";
-	private static final String DIALOG_TAG_SYNC_CONTACTS_ENABLE = "scen";
-	private static final String DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE = "scmer";
-	private static final String DIALOG_TAG_APPLICATION_SETUP_RETRY = "app-setup-retry";
-
-	private static final int PERMISSION_REQUEST_READ_CONTACTS = 2;
-	private static final int NUM_PAGES = 5;
-	private static final long FINISH_DELAY = 3 * 1000;
-	private static final long DIALOG_DELAY = 200;
-
-	public static final boolean DEFAULT_SYNC_CONTACTS = false;
-	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
-	private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "pwPreset";
-
-	private static int lastPage = 0;
-	private ParallaxViewPager viewPager;
-	private MaterialButton prevButton, nextButton;
-	private Button finishButton;
-	private StepPagerStrip stepPagerStrip;
-	private String nickname, email, number, prefix, presetMobile, presetEmail, safePassword;
-	private ThreemaSafeServerInfo safeServerInfo = new ThreemaSafeServerInfo();
-	private boolean isSyncContacts = DEFAULT_SYNC_CONTACTS, userCannotChangeContactSync = false, skipWizard = false, readOnlyProfile = false;
-	private ThreemaSafeMDMConfig safeConfig;
-	private ServiceManager serviceManager;
-	private UserService userService;
-	private LocaleService localeService;
-	private PreferenceService preferenceService;
-	private ThreemaSafeService threemaSafeService;
-	private boolean errorRaised = false, isNewIdentity = false;
-	private WizardFragment4 fragment4;
-	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
-
-	private final Handler finishHandler = new Handler();
-	private final Handler dialogHandler = new Handler();
-
-	private final Runnable finishTask = new Runnable() {
-		@Override
-		public void run() {
-		 	RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					fragment4.setContactsSyncInProgress(false, null);
-					prepareThreemaSafe();
-				}
-			});
-		}
-	};
-
-	private Runnable showDialogDelayedTask(final int current, final int previous) {
-		return () -> {
-			RuntimeUtil.runOnUiThread(() -> {
-				if (current == WizardFragment2.PAGE_ID && previous == WizardFragment1.PAGE_ID && TestUtil.isEmptyOrNull(getSafePassword())) {
-					if (safeConfig.isBackupForced()) {
-						setPage(WizardFragment1.PAGE_ID);
-					} else if (!isReadOnlyProfile()) {
-						WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_disable_confirm, R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
-						wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_THREEMA_SAFE);
-					}
-				}
-
-				if (current == WizardFragment4.PAGE_ID && previous == WizardFragment3.PAGE_ID) {
-					if (!isReadOnlyProfile()) {
-						if ((!TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(presetMobile) && !localeService.validatePhoneNumber(getPhone())) ||
-								((!TestUtil.isEmptyOrNull(email) && TestUtil.isEmptyOrNull(presetEmail) && !Patterns.EMAIL_ADDRESS.matcher(email).matches()))) {
-							WizardDialog wizardDialog = WizardDialog.newInstance(ConfigUtils.isWorkBuild() ?
-									R.string.new_wizard_phone_email_invalid :
-									R.string.new_wizard_phone_invalid,
-									R.string.ok);
-							wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_INVALID_ENTRY);
-						}
-					}
-				}
-
-				if (current == WizardFragment4.PAGE_ID && previous == WizardFragment3.PAGE_ID) {
-					if (!isReadOnlyProfile()) {
-						boolean needConfirm;
-						if (ConfigUtils.isWorkBuild()) {
-							needConfirm = TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(email) && TestUtil.isEmptyOrNull(getPresetEmail()) && TestUtil.isEmptyOrNull(getPresetPhone());
-						} else {
-							if (ConfigUtils.isOnPremBuild()) {
-								needConfirm = false;
-							} else {
-								needConfirm = TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(getPresetPhone());
-							}
-						}
-						if (needConfirm) {
-							WizardDialog wizardDialog = WizardDialog.newInstance(
-									ConfigUtils.isWorkBuild() ?
-											R.string.new_wizard_anonymous_confirm :
-											R.string.new_wizard_anonymous_confirm_phone_only,
-									R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
-							wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_USE_ANONYMOUSLY);
-						}
-					}
-				}
-			});
-		};
-	}
-
-	@Override
-	protected void onCreate(Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		try {
-			serviceManager = ThreemaApplication.getServiceManager();
-			if (serviceManager != null) {
-				userService = serviceManager.getUserService();
-				localeService = serviceManager.getLocaleService();
-				preferenceService = serviceManager.getPreferenceService();
-				threemaSafeService = serviceManager.getThreemaSafeService();
-			}
-		} catch (Exception e) {
-			logger.error("Exception", e);
-			finish();
-			return;
-		}
-		if (userService == null || localeService == null || preferenceService == null) {
-			logger.error("Required services not available.");
-			finish();
-			return;
-		}
-
-		setContentView(R.layout.activity_wizard);
-
-		nextButton = findViewById(R.id.next_page_button);
-		nextButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				nextPage();
-			}
-		});
-
-		prevButton = findViewById(R.id.prev_page_button);
-		prevButton.setVisibility(View.GONE);
-		prevButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				prevPage();
-			}
-		});
-
-		stepPagerStrip = findViewById(R.id.strip);
-		stepPagerStrip.setPageCount(NUM_PAGES);
-		stepPagerStrip.setCurrentPage(WizardFragment0.PAGE_ID);
-
-		viewPager = findViewById(R.id.pager);
-		viewPager.addLayer(findViewById(R.id.layer0));
-		viewPager.addLayer(findViewById(R.id.layer1));
-
-		Intent intent = getIntent();
-		if (intent != null) {
-			isNewIdentity = intent.getBooleanExtra(EXTRA_NEW_IDENTITY_CREATED, false);
-		}
-
-		if (ConfigUtils.isWorkBuild()) {
-			performWorkSync();
-		} else {
-			setupConfig();
-		}
-	}
-
-	private void setupConfig() {
-		safeConfig = ThreemaSafeMDMConfig.getInstance();
-
-		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
-		viewPager.addOnPageChangeListener(this);
-
-		if (ConfigUtils.isWorkRestricted()) {
-			if (isSafeEnabled()) {
-				if (isSafeForced()) {
-					safePassword = safeConfig.getPassword();
-				}
-				safeServerInfo = safeConfig.getServerInfo();
-			}
-
-			String stringPreset;
-			Boolean booleanPreset;
-
-			stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__linked_email));
-			if (stringPreset != null) {
-				email = stringPreset;
-			}
-			stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__linked_phone));
-			if (stringPreset != null) {
-				splitMobile(stringPreset);
-			}
-			stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__nickname));
-			if (stringPreset != null) {
-				nickname = stringPreset;
-			} else {
-				nickname = userService.getIdentity();
-			}
-			booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__contact_sync));
-			if (booleanPreset != null) {
-				isSyncContacts = booleanPreset;
-				userCannotChangeContactSync = true;
-			}
-			booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__readonly_profile));
-			if (booleanPreset != null) {
-				readOnlyProfile = booleanPreset;
-			}
-			booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__skip_wizard));
-			if (booleanPreset != null) {
-				if (booleanPreset) {
-					skipWizard = true;
-					viewPager.post(() -> viewPager.setCurrentItem(WizardFragment4.PAGE_ID));
-				}
-			}
-		} else {
-			// ignore backup presets in restricted mode
-			if (!TestUtil.isEmptyOrNull(presetMobile)) {
-				splitMobile(presetMobile);
-			}
-			if (!TestUtil.isEmptyOrNull(presetEmail)) {
-				email = presetEmail;
-			}
-
-		}
-
-		// if the app is running in a restricted user profile, it s not possible to add accounts
-		if (SynchronizeContactsUtil.isRestrictedProfile(this)) {
-			userCannotChangeContactSync = true;
-			isSyncContacts = false;
-		}
-
-		presetMobile = this.userService.getLinkedMobile();
-		presetEmail = this.userService.getLinkedEmail();
-
-		if (ConfigUtils.isWorkRestricted()) {
-			// confirm the use of a managed password
-			if (!safeConfig.isBackupDisabled() && safeConfig.isBackupPasswordPreset()) {
-				WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_managed_password_confirm, R.string.accept, R.string.real_not_now, WizardDialog.Highlight.NONE);
-				wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_PRESET_CONFIRM);
-			}
-		}
-	}
-
-	/**
-	 * Perform an early synchronous fetch2. In case of failure due to rate-limiting, do not allow user to continue
-	 */
-	private void performWorkSync() {
-		GenericProgressDialog.newInstance(R.string.work_data_sync_desc,
-			R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC);
-
-		WorkSyncWorker.Companion.performOneTimeWorkSync(
-			this,
-			() -> {
-				// On success
-				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
-				setupConfig();
-			},
-			() -> {
-				// On fail
-				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
-				RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardBaseActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
-				logger.info("Unable to post work request for fetch2");
-				try {
-					userService.removeIdentity();
-				} catch (Exception e) {
-					logger.error("Unable to remove identity", e);
-				}
-				finishAndRemoveTask();
-			});
-	}
-
-	private void splitMobile(String phoneNumber) {
-		if (PHONE_LINKED_PLACEHOLDER.equals(phoneNumber)) {
-			prefix = "";
-			number = PHONE_LINKED_PLACEHOLDER;
-		} else {
-			try {
-				PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
-				Phonenumber.PhoneNumber numberProto = null;
-
-				numberProto = phoneNumberUtil.parse(phoneNumber, "");
-				prefix = "+" + numberProto.getCountryCode();
-				number = String.valueOf(numberProto.getNationalNumber());
-			} catch (NumberParseException e) {
-				logger.error("Exception", e);
-			}
-		}
-	}
-
-	@Override
-	protected void onDestroy() {
-		viewPager.removeOnPageChangeListener(this);
-
-		super.onDestroy();
-	}
-
-	/**
-	 * This method will be invoked when the current page is scrolled, either as part
-	 * of a programmatically initiated smooth scroll or a user initiated touch scroll.
-	 *
-	 * @param position             Position index of the first page currently being displayed.
-	 *                             Page position+1 will be visible if positionOffset is nonzero.
-	 * @param positionOffset       Value from [0, 1) indicating the offset from the page at position.
-	 * @param positionOffsetPixels Value in pixels indicating the offset from position.
-	 */
-	@Override
-	public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
-
-	}
-
-	/**
-	 * This method will be invoked when a new page becomes selected. Animation is not
-	 * necessarily complete.
-	 *
-	 * @param position Position index of the new selected page.
-	 */
-	@SuppressLint("StaticFieldLeak")
-	@Override
-	public void onPageSelected(int position) {
-		prevButton.setVisibility(position == WizardFragment0.PAGE_ID ? View.GONE : View.VISIBLE);
-		nextButton.setVisibility(position == NUM_PAGES - 1 ? View.GONE : View.VISIBLE);
-
-		stepPagerStrip.setCurrentPage(position);
-
-		if (position == WizardFragment1.PAGE_ID && safeConfig.isSkipBackupPasswordEntry()) {
-			if (lastPage == WizardFragment0.PAGE_ID) {
-				nextPage();
-			} else {
-				prevPage();
-			}
-			return;
-		}
-
-		if (position == WizardFragment2.PAGE_ID && lastPage == WizardFragment1.PAGE_ID) {
-			if (!TextUtils.isEmpty(safePassword)) {
-				new AsyncTask<Void, Void, Boolean>() {
-					@Override
-					protected Boolean doInBackground(Void... voids) {
-						return TextUtil.checkBadPassword(getApplicationContext(), safePassword);
-					}
-
-					@Override
-					protected void onPostExecute(Boolean isBad) {
-						if (isBad) {
-							Context context = WizardBaseActivity.this;
-							if (AppRestrictionUtil.isSafePasswordPatternSet(context)) {
-								WizardDialog wizardDialog = WizardDialog.newInstance(AppRestrictionUtil.getSafePasswordMessage(context), R.string.try_again);
-								wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD);
-							} else {
-								WizardDialog wizardDialog = WizardDialog.newInstance(R.string.password_bad_explain, R.string.continue_anyway, R.string.try_again, WizardDialog.Highlight.NEGATIVE);
-								wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD);
-							}
-						}
-					}
-				}.execute();
-			}
-		}
-
-		if (position > lastPage && position >= WizardFragment2.PAGE_ID && position <= WizardFragment4.PAGE_ID) {
-			// we delay dialogs for a few milliseconds to prevent stuttering of the page change animation
-			dialogHandler.removeCallbacks(showDialogDelayedTask(position, lastPage));
-			dialogHandler.postDelayed(showDialogDelayedTask(position, lastPage), DIALOG_DELAY);
-		}
-
-		lastPage = position;
-	}
-
-	/**
-	 * Called when the scroll state changes. Useful for discovering when the user
-	 * begins dragging, when the pager is automatically settling to the current page,
-	 * or when it is fully stopped/idle.
-	 *
-	 * @param state The new scroll state.
-	 * @see ViewPager#SCROLL_STATE_IDLE
-	 * @see ViewPager#SCROLL_STATE_DRAGGING
-	 * @see ViewPager#SCROLL_STATE_SETTLING
-	 */
-	@Override
-	public void onPageScrollStateChanged(int state) { }
-
-	/**
-	 * Called when a view has been clicked.
-	 *
-	 * @param v The view that was clicked.
-	 */
-	@Override
-	public void onClick(View v) {
-		if (v.equals(nextButton)) {
-			nextPage();
-		} else if (v.equals(prevButton)) {
-			prevPage();
-		}
-	}
-
-	@Override
-	public void onWizardFinished(WizardFragment4 fragment, Button finishButton) {
-		errorRaised = false;
-		fragment4 = fragment;
-
-		viewPager.lock(true);
-		this.finishButton = finishButton;
-
-		prevButton.setVisibility(View.GONE);
-		if (finishButton != null) {
-			finishButton.setEnabled(false);
-		}
-
-		userService.setPublicNickname(this.nickname);
-
-		askUserForContactSync();
-	}
-
-	private void askUserForContactSync() {
-		/* trigger a connection now - as application lifecycle was set to resumed state when there was no identity yet */
-		serviceManager.getLifetimeService().ensureConnection();
-
-		if (this.userCannotChangeContactSync) {
-			if (this.isSyncContacts) {
-				if (ConfigUtils.isPermissionGranted(this, Manifest.permission.READ_CONTACTS)) {
-					// Permission already granted, therefore continue by linking the phone
-					linkPhone();
-				} else {
-					// If permission is not yet granted, show a dialog to inform that contact sync
-					// has been force enabled by the administrator
-					WizardDialog wizardDialog = WizardDialog.newInstance(R.string.contact_sync_mdm_rationale, R.string.ok);
-					wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE);
-				}
-			} else {
-				linkPhone();
-			}
-		} else {
-			if (this.skipWizard) {
-				isSyncContacts = false;
-				this.serviceManager.getPreferenceService().setSyncContacts(false);
-				linkPhone();
-			} else {
-				WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_info_sync_contacts_dialog, R.string.yes, R.string.no, null);
-				wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_ENABLE);
-			}
-		}
-	}
-
-	private void requestContactSyncPermission() {
-		if (ConfigUtils.requestContactPermissions(this, null, PERMISSION_REQUEST_READ_CONTACTS)) {
-			// permission is already granted
-			this.isSyncContacts = true;
-			preferenceService.setSyncContacts(this.isSyncContacts);
-			linkPhone();
-		}
-		// continue to onRequestPermissionsResult
-	}
-
-	@Override
-	public void onNicknameSet(String nickname) {
-		this.nickname = nickname;
-	}
-
-	@Override
-	public void onPhoneSet(String phoneNumber) {
-		this.number = phoneNumber;
-	}
-
-	@Override
-	public void onPrefixSet(String prefix) {
-		this.prefix = prefix;
-	}
-
-	@Override
-	public void onEmailSet(String email) {
-		this.email = email;
-	}
-
-	@Override
-	public void onSafePasswordSet(final String password) {
-		safePassword = password;
-	}
-
-	@Override
-	public void onSafeServerInfoSet(ThreemaSafeServerInfo safeServerInfo) {
-		this.safeServerInfo = safeServerInfo;
-	}
-
-	@Override
-	public String getNickname() {
-		return this.nickname;
-	}
-
-	@Override
-	public String getPhone() {
-		if (PHONE_LINKED_PLACEHOLDER.equals(this.number)) {
-			return this.number;
-		}
-
-		String phone = this.prefix + this.number;
-
-		if (localeService.validatePhoneNumber(phone)) {
-			return serviceManager.getLocaleService().getNormalizedPhoneNumber(phone);
-		}
-		return "";
-	}
-
-	@Override
-	public String getNumber() {
-		return this.number;
-	}
-
-	@Override
-	public String getPrefix() {
-		return this.prefix;
-	}
-
-	@Override
-	public String getEmail() {
-		return (this.email != null && this.email.length() > 4) ? this.email : "";
-	}
-
-	@Override
-	public String getPresetPhone() {
-		return this.presetMobile;
-	}
-
-	@Override
-	public String getPresetEmail() {
-		return this.presetEmail;
-	}
-
-	@Override
-	public boolean getSafeForcePasswordEntry() {
-		return safeConfig.isBackupForced();
-	}
-
-	@Override
-	public boolean getSafeSkipBackupPasswordEntry() {
-		return safeConfig.isSkipBackupPasswordEntry();
-	}
-
-	@Override
-	public boolean isSafeEnabled() {
-		return !safeConfig.isBackupDisabled();
-	}
-
-	@Override
-	public boolean isSafeForced() {
-		return safeConfig.isBackupForced();
-	}
-
-	@Override
-	public String getSafePassword() {
-		return this.safePassword;
-	}
-
-	@Override
-	public ThreemaSafeServerInfo getSafeServerInfo() {
-		return this.safeServerInfo;
-	}
-
-	@Override
-	public boolean getSyncContacts() {
-		return this.isSyncContacts;
-	}
-
-	@Override
-	public boolean isReadOnlyProfile() {
-		return this.readOnlyProfile;
-	}
-
-	@Override
-	public boolean isSkipWizard() {
-		return this.skipWizard;
-	}
-
-	/**
-	 * Return whether the identity was just created
-	 * @return true if it's a new identity, false if the identity was restored
-	 */
-	public boolean isNewIdentity() {
-		return isNewIdentity;
-	}
-
-	@Override
-	public void onYes(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_USE_ID_AS_NICKNAME:
-				this.nickname = this.userService.getIdentity();
-				break;
-			case DIALOG_TAG_INVALID_ENTRY:
-			case DIALOG_TAG_PASSWORD_BAD:
-				prevPage();
-				break;
-			case DIALOG_TAG_THREEMA_SAFE:
-			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
-				break;
-			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
-			case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
-				requestContactSyncPermission();
-				break;
-			case DIALOG_TAG_APPLICATION_SETUP_RETRY:
-				runApplicationSetupStepsAndRestart();
-				break;
-		}
-	}
-
-	@Override
-	public void onNo(String tag) {
-		switch (tag) {
-			case DIALOG_TAG_USE_ID_AS_NICKNAME:
-				prevPage();
-				break;
-			case DIALOG_TAG_USE_ANONYMOUSLY:
-				setPage(WizardFragment3.PAGE_ID);
-				break;
-			case DIALOG_TAG_THREEMA_SAFE:
-				prevPage();
-				break;
-			case DIALOG_TAG_PASSWORD_BAD:
-				setPage(WizardFragment1.PAGE_ID);
-				break;
-			case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
-				isSyncContacts = false;
-				this.serviceManager.getPreferenceService().setSyncContacts(false);
-				linkPhone();
-				break;
-			case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
-				finish();
-				System.exit(0);
-				break;
-		}
-	}
-
-	@Override
-	protected boolean enableOnBackPressedCallback() {
-		return true;
-	}
-
-	@Override
-	protected void handleOnBackPressed() {
-		if (prevButton != null && prevButton.getVisibility() == View.VISIBLE) {
-			prevPage();
-		}
-	}
-
-	private class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
-		public ScreenSlidePagerAdapter(FragmentManager fm) {
-			super(fm, FragmentStatePagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
-		}
-
-		@Override
-		public Fragment getItem(int position) {
-			switch (position) {
-				case WizardFragment0.PAGE_ID:
-					return new WizardFragment0();
-				case WizardFragment1.PAGE_ID:
-					return new WizardFragment1();
-				case WizardFragment2.PAGE_ID:
-					return new WizardFragment2();
-				case WizardFragment3.PAGE_ID:
-					return new WizardFragment3();
-				case WizardFragment4.PAGE_ID:
-					return new WizardFragment4();
-				default:
-					break;
-			}
-			return null;
-		}
-
-		@Override
-		public int getCount() {
-			return NUM_PAGES;
-		}
-	}
-
-	public void nextPage() {
-		int currentItem = viewPager.getCurrentItem() + 1;
-		if (currentItem < NUM_PAGES) {
-			viewPager.setCurrentItem(currentItem);
-		}
-	}
-
-	public void prevPage() {
-		int currentItem = viewPager.getCurrentItem();
-		if (currentItem != 0) {
-			viewPager.setCurrentItem(currentItem - 1);
-		}
-	}
-
-	public void setPage(int page) {
-		viewPager.setCurrentItem(page);
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	private void linkEmail(final WizardFragment4 fragment) {
-		final String newEmail = getEmail();
-		if (TestUtil.isEmptyOrNull(newEmail)) {
-			initSyncAndFinish();
-			return;
-		}
-
-		boolean isNewEmail = (!(presetEmail != null && presetEmail.equals(newEmail)));
-
-		if ((userService.getEmailLinkingState() != UserService.LinkingState_LINKED) && isNewEmail) {
-			new AsyncTask<Void, Void, String>() {
-				@Override
-				protected void onPreExecute() {
-					fragment.setEmailLinkingInProgress(true);
-				}
-
-				@Override
-				protected String doInBackground(Void... params) {
-					try {
-						userService.linkWithEmail(email);
-					} catch (LinkEmailException e) {
-						logger.error("Exception", e);
-						return e.getMessage();
-					} catch (Exception e) {
-						logger.error("Exception", e);
-						return getString(R.string.internet_connection_required);
-					}
-					return null;
-				}
-
-				@Override
-				protected void onPostExecute(String result) {
-					if (result != null) {
-						fragment.setEmailLinkingAlert(result);
-						errorRaised = true;
-					} else {
-						fragment.setEmailLinkingInProgress(false);
-					}
-					initSyncAndFinish();
-				}
-			}.execute();
-		} else {
-			initSyncAndFinish();
-		}
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	private void linkPhone() {
-		final String phone = getPhone();
-		if (TestUtil.isEmptyOrNull(phone)) {
-			linkEmail(fragment4);
-			return;
-		}
-
-		boolean isNewPhoneNumber = (presetMobile == null || !presetMobile.equals(phone));
-
-		// start linking activity only if not already linked
-		if ((userService.getMobileLinkingState() != UserService.LinkingState_LINKED) && isNewPhoneNumber) {
-			new AsyncTask<Void, Void, String>() {
-				@Override
-				protected void onPreExecute() {
-					fragment4.setMobileLinkingInProgress(true);
-				}
-
-				@Override
-				protected String doInBackground(Void... params) {
-					try {
-						userService.linkWithMobileNumber(phone);
-					} catch (LinkMobileNoException e) {
-						logger.error("Exception", e);
-						return e.getMessage();
-					} catch (Exception e) {
-						logger.error("Exception", e);
-						return getString(R.string.internet_connection_required);
-					}
-					return null;
-				}
-
-				@Override
-				protected void onPostExecute(String result) {
-					if (result != null) {
-						fragment4.setMobileLinkingAlert(result);
-						errorRaised = true;
-					} else {
-						fragment4.setMobileLinkingInProgress(false);
-					}
-					linkEmail(fragment4);
-				}
-			}.execute();
-		} else {
-			linkEmail(fragment4);
-		}
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	private void addUser(final String id, final String first, final String last) {
-		new AsyncTask<Void, Void, Void>() {
-
-			@Override
-			protected Void doInBackground(Void... params) {
-				try {
-					ContactModel newUser = serviceManager.getContactService()
-							.createContactByIdentity(id, true);
-
-					if (newUser != null) {
-						newUser.setFirstName(first);
-						newUser.setLastName(last);
-						serviceManager.getContactService().save(newUser);
-					}
-
-				} catch (InvalidEntryException | MasterKeyLockedException | FileSystemNotPresentException e) {
-					logger.error("Exception", e);
-					//should not happen, ignore
-				} catch (EntryAlreadyExistsException | PolicyViolationException e) {
-					//ok, id already exists or adding IDs is prohibited, do nothing
-				}
-				return null;
-			}
-		}.execute();
-	}
-
-	private void runApplicationSetupStepsAndRestart() {
-		backgroundExecutor.execute(new BackgroundTask<Boolean>() {
-			@Override
-			public void runBefore() {
-				// Nothing to do
-			}
-
-			@Override
-			public Boolean runInBackground() {
-				return runApplicationSetupSteps(serviceManager, WizardBaseActivity.this);
-			}
-
-			@Override
-			public void runAfter(Boolean result) {
-				if (!Boolean.TRUE.equals(result)) {
-					WizardDialog.newInstance(R.string.application_setup_steps_failed, R.string.retry)
-						.show(getSupportFragmentManager(), DIALOG_TAG_APPLICATION_SETUP_RETRY);
-					return;
-				}
-
-				preferenceService.setWizardRunning(false);
-				preferenceService.setLatestVersion(WizardBaseActivity.this);
-
-				addUser(ThreemaApplication.ECHO_USER_IDENTITY, "Echo", "Test");
-
-				// Flush conversation cache (after a restore) to ensure that the conversation list
-				// will be loaded from the database to prevent the list being incomplete.
-				try {
-					serviceManager.getConversationService().reset();
-				} catch (Exception e) {
-					logger.error("Exception", e);
-				}
-
-				ConfigUtils.recreateActivity(WizardBaseActivity.this);
-			}
-		});
-	}
-
-	private void ensureMasterKeyWrite() {
-		// Write master key now if no passphrase has been set - don't leave it up to the MainActivity
-		if (!ThreemaApplication.getMasterKey().isProtected()) {
-			try {
-				ThreemaApplication.getMasterKey().setPassphrase(null);
-			} catch (Exception e) {
-				// better die if something went wrong as the master key may not have been saved
-				throw new RuntimeException(e);
-			}
-		}
-	}
-
-	@SuppressLint({"StaticFieldLeak", "MissingPermission"})
-	private void reallySyncContactsAndFinish() {
-		ensureMasterKeyWrite();
-
-		if (preferenceService.isSyncContacts()) {
-			new AsyncTask<Void, Void, Void>() {
-				@Override
-				protected void onPreExecute() {
-					fragment4.setContactsSyncInProgress(true, getString(R.string.wizard1_sync_contacts));
-				}
-
-				@SuppressLint("MissingPermission")
-				@Override
-				protected Void doInBackground(Void... params) {
-					try {
-						final Account account = userService.getAccount(true);
-						//disable
-						userService.enableAccountAutoSync(false);
-
-						SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-						SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
-
-						routine.setOnStatusUpdate(new SynchronizeContactsRoutine.OnStatusUpdate() {
-							@Override
-							public void newStatus(final long percent, final String message) {
-							 	RuntimeUtil.runOnUiThread(() -> fragment4.setContactsSyncInProgress(true, message));
-							}
-
-							@Override
-							public void error(final Exception x) {
-							 	RuntimeUtil.runOnUiThread(() -> fragment4.setContactsSyncInProgress(false, x.getMessage()));
-							}
-						});
-
-						//on finished, close the dialog
-						routine.addOnFinished(new SynchronizeContactsRoutine.OnFinished() {
-							@Override
-							public void finished(boolean success, long modifiedAccounts, List<ContactModel> createdContacts, long deletedAccounts) {
-								userService.enableAccountAutoSync(true);
-							}
-						});
-
-						routine.run();
-					} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-						logger.error("Exception", e);
-					}
-					return null;
-				}
-
-				@Override
-				protected void onPostExecute(Void result) {
-					finishHandler.removeCallbacks(finishTask);
-					finishHandler.postDelayed(finishTask, FINISH_DELAY);
-				}
-			}.execute();
-		} else {
-			userService.removeAccount();
-			prepareThreemaSafe();
-		}
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	private void prepareThreemaSafe() {
-		if (!TestUtil.isEmptyOrNull(getSafePassword())) {
-			new AsyncTask<Void, Void, byte[]>() {
-				@Override
-				protected void onPreExecute() {
-					fragment4.setThreemaSafeInProgress(true, getString(R.string.preparing_threema_safe));
-				}
-
-				@Override
-				protected byte[] doInBackground(Void... voids) {
-					return threemaSafeService.deriveMasterKey(getSafePassword(), userService.getIdentity());
-				}
-
-				@Override
-				protected void onPostExecute(byte[] masterkey) {
-					fragment4.setThreemaSafeInProgress(false, getString(R.string.menu_done));
-
-					if (masterkey != null) {
-						threemaSafeService.storeMasterKey(masterkey);
-						preferenceService.setThreemaSafeServerInfo(safeServerInfo);
-						threemaSafeService.setEnabled(true);
-						threemaSafeService.uploadNow(true);
-					} else {
-						Toast.makeText(WizardBaseActivity.this, R.string.safe_error_preparing, Toast.LENGTH_LONG).show();
-					}
-
-					runApplicationSetupStepsAndRestart();
-				}
-			}.execute();
-		} else {
-			// no password was set
-			// do not save mdm settings if backup is forced and no password was set - this will cause a password prompt later
-			if (!(ConfigUtils.isWorkRestricted() && ThreemaSafeMDMConfig.getInstance().isBackupForced())) {
-				threemaSafeService.storeMasterKey(new byte[0]);
-			}
-			runApplicationSetupStepsAndRestart();
-		}
-	}
-
-	private void initSyncAndFinish() {
-		if (!errorRaised || ConfigUtils.isWorkRestricted()) {
-			syncContactsAndFinish();
-		} else {
-			resetUi();
-		}
-	}
-
-	private void resetUi() {
-		// unlock UI to try again
-		viewPager.lock(false);
-		prevButton.setVisibility(View.VISIBLE);
-		if (finishButton != null) {
-			finishButton.setEnabled(true);
-		}
-	}
-
-	private void syncContactsAndFinish() {
-		/* trigger a connection now - as application lifecycle was set to resumed state when there was no identity yet */
-		serviceManager.getLifetimeService().ensureConnection();
-
-		if (this.isSyncContacts) {
-			preferenceService.setSyncContacts(true);
-			reallySyncContactsAndFinish();
-		} else {
-			preferenceService.setSyncContacts(false);
-			prepareThreemaSafe();
-		}
-	}
-
-	@Override
-	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
-		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
-		if (requestCode == PERMISSION_REQUEST_READ_CONTACTS) {
-			if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-				this.isSyncContacts = true;
-				linkPhone();
-			} else if (userCannotChangeContactSync) {
-				ConfigUtils.showPermissionRationale(this, (View) viewPager.getParent(), R.string.permission_contacts_sync_required);
-				resetUi();
-			} else {
-				this.isSyncContacts = false;
-				linkPhone();
-			}
-		}
-	}
+    LifecycleOwner,
+    ViewPager.OnPageChangeListener,
+    View.OnClickListener,
+    WizardFragment1.OnSettingsChangedListener,
+    WizardFragment2.OnSettingsChangedListener,
+    WizardFragment3.OnSettingsChangedListener,
+    WizardFragment4.SettingsInterface,
+    WizardDialog.WizardDialogCallback {
+
+    private static final Logger logger = LoggingUtil.getThreemaLogger("WizardBaseActivity");
+
+    public static final String EXTRA_NEW_IDENTITY_CREATED = "newIdentity";
+    private static final String DIALOG_TAG_USE_ID_AS_NICKNAME = "nd";
+    private static final String DIALOG_TAG_INVALID_ENTRY = "ie";
+    private static final String DIALOG_TAG_USE_ANONYMOUSLY = "ano";
+    private static final String DIALOG_TAG_THREEMA_SAFE = "sd";
+    private static final String DIALOG_TAG_PASSWORD_BAD = "pwb";
+    private static final String DIALOG_TAG_PASSWORD_BAD_WORK = "pwbw";
+    private static final String DIALOG_TAG_SYNC_CONTACTS_ENABLE = "scen";
+    private static final String DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE = "scmer";
+    private static final String DIALOG_TAG_APPLICATION_SETUP_RETRY = "app-setup-retry";
+
+    private static final int PERMISSION_REQUEST_READ_CONTACTS = 2;
+    private static final int NUM_PAGES = 5;
+    private static final long FINISH_DELAY = 3 * 1000;
+    private static final long DIALOG_DELAY = 200;
+
+    public static final boolean DEFAULT_SYNC_CONTACTS = false;
+    private static final String DIALOG_TAG_WORK_SYNC = "workSync";
+    private static final String DIALOG_TAG_PASSWORD_PRESET_CONFIRM = "pwPreset";
+
+    private static int lastPage = 0;
+    private ParallaxViewPager viewPager;
+    private MaterialButton prevButton, nextButton;
+    private Button finishButton;
+    private StepPagerStrip stepPagerStrip;
+    private String nickname, email, number, prefix, presetMobile, presetEmail, safePassword;
+    private ThreemaSafeServerInfo safeServerInfo = new ThreemaSafeServerInfo();
+    private boolean isSyncContacts = DEFAULT_SYNC_CONTACTS, userCannotChangeContactSync = false, skipWizard = false, readOnlyProfile = false;
+    private ThreemaSafeMDMConfig safeConfig;
+    private ServiceManager serviceManager;
+    private UserService userService;
+    private LocaleService localeService;
+    private PreferenceService preferenceService;
+    private ThreemaSafeService threemaSafeService;
+    private APIConnector apiConnector;
+    private ContactModelRepository contactModelRepository;
+    private boolean errorRaised = false, isNewIdentity = false;
+    private WizardFragment4 fragment4;
+    private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
+
+    private final Handler finishHandler = new Handler();
+    private final Handler dialogHandler = new Handler();
+
+    private final Runnable finishTask = new Runnable() {
+        @Override
+        public void run() {
+            RuntimeUtil.runOnUiThread(() -> {
+                fragment4.setContactsSyncInProgress(false, null);
+                prepareThreemaSafe();
+            });
+        }
+    };
+
+    private Runnable showDialogDelayedTask(final int current, final int previous) {
+        return () -> RuntimeUtil.runOnUiThread(() -> {
+            if (current == WizardFragment2.PAGE_ID && previous == WizardFragment1.PAGE_ID && TestUtil.isEmptyOrNull(getSafePassword())) {
+                if (safeConfig.isBackupForced()) {
+                    setPage(WizardFragment1.PAGE_ID);
+                } else if (!isReadOnlyProfile()) {
+                    WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_disable_confirm, R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
+                    wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_THREEMA_SAFE);
+                }
+            }
+
+            if (current == WizardFragment4.PAGE_ID && previous == WizardFragment3.PAGE_ID) {
+                if (!isReadOnlyProfile()) {
+                    if ((!TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(presetMobile) && !localeService.validatePhoneNumber(getPhone())) ||
+                        ((!TestUtil.isEmptyOrNull(email) && TestUtil.isEmptyOrNull(presetEmail) && !Patterns.EMAIL_ADDRESS.matcher(email).matches()))) {
+                        WizardDialog wizardDialog = WizardDialog.newInstance(ConfigUtils.isWorkBuild() ?
+                                R.string.new_wizard_phone_email_invalid :
+                                R.string.new_wizard_phone_invalid,
+                            R.string.ok);
+                        wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_INVALID_ENTRY);
+                    }
+                }
+            }
+
+            if (current == WizardFragment4.PAGE_ID && previous == WizardFragment3.PAGE_ID) {
+                if (!isReadOnlyProfile()) {
+                    boolean needConfirm;
+                    if (ConfigUtils.isWorkBuild()) {
+                        needConfirm = TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(email) && TestUtil.isEmptyOrNull(getPresetEmail()) && TestUtil.isEmptyOrNull(getPresetPhone());
+                    } else {
+                        if (ConfigUtils.isOnPremBuild()) {
+                            needConfirm = false;
+                        } else {
+                            needConfirm = TestUtil.isEmptyOrNull(number) && TestUtil.isEmptyOrNull(getPresetPhone());
+                        }
+                    }
+                    if (needConfirm) {
+                        WizardDialog wizardDialog = WizardDialog.newInstance(
+                            ConfigUtils.isWorkBuild() ?
+                                R.string.new_wizard_anonymous_confirm :
+                                R.string.new_wizard_anonymous_confirm_phone_only,
+                            R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
+                        wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_USE_ANONYMOUSLY);
+                    }
+                }
+            }
+        });
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        try {
+            serviceManager = ThreemaApplication.getServiceManager();
+            if (serviceManager != null) {
+                userService = serviceManager.getUserService();
+                localeService = serviceManager.getLocaleService();
+                preferenceService = serviceManager.getPreferenceService();
+                threemaSafeService = serviceManager.getThreemaSafeService();
+                apiConnector = serviceManager.getAPIConnector();
+                contactModelRepository = serviceManager.getModelRepositories().getContacts();
+            }
+        } catch (Exception e) {
+            logger.error("Exception", e);
+            finish();
+            return;
+        }
+        if (userService == null || localeService == null || preferenceService == null) {
+            logger.error("Required services not available.");
+            finish();
+            return;
+        }
+
+        setContentView(R.layout.activity_wizard);
+
+        nextButton = findViewById(R.id.next_page_button);
+        nextButton.setOnClickListener(v -> nextPage());
+
+        prevButton = findViewById(R.id.prev_page_button);
+        prevButton.setVisibility(View.GONE);
+        prevButton.setOnClickListener(v -> prevPage());
+
+        stepPagerStrip = findViewById(R.id.strip);
+        stepPagerStrip.setPageCount(NUM_PAGES);
+        stepPagerStrip.setCurrentPage(WizardFragment0.PAGE_ID);
+
+        viewPager = findViewById(R.id.pager);
+        viewPager.addLayer(findViewById(R.id.layer0));
+        viewPager.addLayer(findViewById(R.id.layer1));
+
+        Intent intent = getIntent();
+        if (intent != null) {
+            isNewIdentity = intent.getBooleanExtra(EXTRA_NEW_IDENTITY_CREATED, false);
+        }
+
+        if (ConfigUtils.isWorkBuild()) {
+            performWorkSync();
+        } else {
+            setupConfig();
+        }
+    }
+
+    private void setupConfig() {
+        safeConfig = ThreemaSafeMDMConfig.getInstance();
+
+        viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
+        viewPager.addOnPageChangeListener(this);
+
+        if (ConfigUtils.isWorkRestricted()) {
+            if (isSafeEnabled()) {
+                if (isSafeForced()) {
+                    safePassword = safeConfig.getPassword();
+                }
+                safeServerInfo = safeConfig.getServerInfo();
+            }
+
+            String stringPreset;
+            Boolean booleanPreset;
+
+            stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__linked_email));
+            if (stringPreset != null) {
+                email = stringPreset;
+            }
+            stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__linked_phone));
+            if (stringPreset != null) {
+                splitMobile(stringPreset);
+            }
+            stringPreset = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__nickname));
+            if (stringPreset != null) {
+                nickname = stringPreset;
+            } else {
+                nickname = userService.getIdentity();
+            }
+            booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__contact_sync));
+            if (booleanPreset != null) {
+                isSyncContacts = booleanPreset;
+                userCannotChangeContactSync = true;
+            }
+            booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__readonly_profile));
+            if (booleanPreset != null) {
+                readOnlyProfile = booleanPreset;
+            }
+            booleanPreset = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__skip_wizard));
+            if (booleanPreset != null) {
+                if (booleanPreset) {
+                    skipWizard = true;
+                    viewPager.post(() -> viewPager.setCurrentItem(WizardFragment4.PAGE_ID));
+                }
+            }
+        } else {
+            // ignore backup presets in restricted mode
+            if (!TestUtil.isEmptyOrNull(presetMobile)) {
+                splitMobile(presetMobile);
+            }
+            if (!TestUtil.isEmptyOrNull(presetEmail)) {
+                email = presetEmail;
+            }
+
+        }
+
+        // if the app is running in a restricted user profile, it s not possible to add accounts
+        if (SynchronizeContactsUtil.isRestrictedProfile(this)) {
+            userCannotChangeContactSync = true;
+            isSyncContacts = false;
+        }
+
+        presetMobile = this.userService.getLinkedMobile();
+        presetEmail = this.userService.getLinkedEmail();
+
+        if (ConfigUtils.isWorkRestricted()) {
+            // confirm the use of a managed password
+            if (!safeConfig.isBackupDisabled() && safeConfig.isBackupPasswordPreset()) {
+                WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_managed_password_confirm, R.string.accept, R.string.real_not_now, WizardDialog.Highlight.NONE);
+                wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_PRESET_CONFIRM);
+            }
+        }
+    }
+
+    /**
+     * Perform an early synchronous fetch2. In case of failure due to rate-limiting, do not allow user to continue
+     */
+    private void performWorkSync() {
+        GenericProgressDialog.newInstance(R.string.work_data_sync_desc,
+            R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC);
+
+        WorkSyncWorker.Companion.performOneTimeWorkSync(
+            this,
+            () -> {
+                // On success
+                DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+                setupConfig();
+            },
+            () -> {
+                // On fail
+                DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+                RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardBaseActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
+                logger.info("Unable to post work request for fetch2");
+                try {
+                    userService.removeIdentity();
+                } catch (Exception e) {
+                    logger.error("Unable to remove identity", e);
+                }
+                finishAndRemoveTask();
+            });
+    }
+
+    private void splitMobile(String phoneNumber) {
+        if (PHONE_LINKED_PLACEHOLDER.equals(phoneNumber)) {
+            prefix = "";
+            number = PHONE_LINKED_PLACEHOLDER;
+        } else {
+            try {
+                PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+                Phonenumber.PhoneNumber numberProto = phoneNumberUtil.parse(phoneNumber, "");
+                prefix = "+" + numberProto.getCountryCode();
+                number = String.valueOf(numberProto.getNationalNumber());
+            } catch (NumberParseException e) {
+                logger.error("Exception", e);
+            }
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        viewPager.removeOnPageChangeListener(this);
+
+        super.onDestroy();
+    }
+
+    /**
+     * This method will be invoked when the current page is scrolled, either as part
+     * of a programmatically initiated smooth scroll or a user initiated touch scroll.
+     *
+     * @param position             Position index of the first page currently being displayed.
+     *                             Page position+1 will be visible if positionOffset is nonzero.
+     * @param positionOffset       Value from [0, 1) indicating the offset from the page at position.
+     * @param positionOffsetPixels Value in pixels indicating the offset from position.
+     */
+    @Override
+    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+
+    }
+
+    /**
+     * This method will be invoked when a new page becomes selected. Animation is not
+     * necessarily complete.
+     *
+     * @param position Position index of the new selected page.
+     */
+    @SuppressLint("StaticFieldLeak")
+    @Override
+    public void onPageSelected(int position) {
+        prevButton.setVisibility(position == WizardFragment0.PAGE_ID ? View.GONE : View.VISIBLE);
+        nextButton.setVisibility(position == NUM_PAGES - 1 ? View.GONE : View.VISIBLE);
+
+        stepPagerStrip.setCurrentPage(position);
+
+        if (position == WizardFragment1.PAGE_ID && safeConfig.isSkipBackupPasswordEntry()) {
+            if (lastPage == WizardFragment0.PAGE_ID) {
+                nextPage();
+            } else {
+                prevPage();
+            }
+            return;
+        }
+
+        if (position == WizardFragment2.PAGE_ID && lastPage == WizardFragment1.PAGE_ID) {
+            if (!TextUtils.isEmpty(safePassword)) {
+                new AsyncTask<Void, Void, Boolean>() {
+                    @Override
+                    protected Boolean doInBackground(Void... voids) {
+                        return TextUtil.checkBadPassword(getApplicationContext(), safePassword);
+                    }
+
+                    @Override
+                    protected void onPostExecute(Boolean isBad) {
+                        if (isBad) {
+                            Context context = WizardBaseActivity.this;
+                            if (AppRestrictionUtil.isSafePasswordPatternSet(context)) {
+                                WizardDialog wizardDialog = WizardDialog.newInstance(AppRestrictionUtil.getSafePasswordMessage(context), R.string.try_again);
+                                wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD_WORK);
+                            } else {
+                                WizardDialog wizardDialog = WizardDialog.newInstance(R.string.password_bad_explain, R.string.continue_anyway, R.string.try_again, WizardDialog.Highlight.NEGATIVE);
+                                wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD);
+                            }
+                        }
+                    }
+                }.execute();
+            }
+        }
+
+        if (position > lastPage && position >= WizardFragment2.PAGE_ID && position <= WizardFragment4.PAGE_ID) {
+            // we delay dialogs for a few milliseconds to prevent stuttering of the page change animation
+            dialogHandler.removeCallbacks(showDialogDelayedTask(position, lastPage));
+            dialogHandler.postDelayed(showDialogDelayedTask(position, lastPage), DIALOG_DELAY);
+        }
+
+        lastPage = position;
+    }
+
+    /**
+     * Called when the scroll state changes. Useful for discovering when the user
+     * begins dragging, when the pager is automatically settling to the current page,
+     * or when it is fully stopped/idle.
+     *
+     * @param state The new scroll state.
+     * @see ViewPager#SCROLL_STATE_IDLE
+     * @see ViewPager#SCROLL_STATE_DRAGGING
+     * @see ViewPager#SCROLL_STATE_SETTLING
+     */
+    @Override
+    public void onPageScrollStateChanged(int state) {
+    }
+
+    /**
+     * Called when a view has been clicked.
+     *
+     * @param v The view that was clicked.
+     */
+    @Override
+    public void onClick(View v) {
+        if (v.equals(nextButton)) {
+            nextPage();
+        } else if (v.equals(prevButton)) {
+            prevPage();
+        }
+    }
+
+    @Override
+    public void onWizardFinished(WizardFragment4 fragment, Button finishButton) {
+        errorRaised = false;
+        fragment4 = fragment;
+
+        viewPager.lock(true);
+        this.finishButton = finishButton;
+
+        prevButton.setVisibility(View.GONE);
+        if (finishButton != null) {
+            finishButton.setEnabled(false);
+        }
+
+        userService.setPublicNickname(this.nickname, TriggerSource.LOCAL);
+
+        askUserForContactSync();
+    }
+
+    private void askUserForContactSync() {
+        /* trigger a connection now - as application lifecycle was set to resumed state when there was no identity yet */
+        serviceManager.getLifetimeService().ensureConnection();
+
+        if (this.userCannotChangeContactSync) {
+            if (this.isSyncContacts) {
+                if (ConfigUtils.isPermissionGranted(this, Manifest.permission.READ_CONTACTS)) {
+                    // Permission already granted, therefore continue by linking the phone
+                    linkPhone();
+                } else {
+                    // If permission is not yet granted, show a dialog to inform that contact sync
+                    // has been force enabled by the administrator
+                    WizardDialog wizardDialog = WizardDialog.newInstance(R.string.contact_sync_mdm_rationale, R.string.ok);
+                    wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE);
+                }
+            } else {
+                linkPhone();
+            }
+        } else {
+            if (this.skipWizard) {
+                isSyncContacts = false;
+                this.serviceManager.getPreferenceService().setSyncContacts(false);
+                linkPhone();
+            } else {
+                WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_info_sync_contacts_dialog, R.string.yes, R.string.no, null);
+                wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_SYNC_CONTACTS_ENABLE);
+            }
+        }
+    }
+
+    private void requestContactSyncPermission() {
+        if (ConfigUtils.requestContactPermissions(this, null, PERMISSION_REQUEST_READ_CONTACTS)) {
+            // permission is already granted
+            this.isSyncContacts = true;
+            preferenceService.setSyncContacts(this.isSyncContacts);
+            linkPhone();
+        }
+        // continue to onRequestPermissionsResult
+    }
+
+    @Override
+    public void onNicknameSet(String nickname) {
+        this.nickname = nickname;
+    }
+
+    @Override
+    public void onPhoneSet(String phoneNumber) {
+        this.number = phoneNumber;
+    }
+
+    @Override
+    public void onPrefixSet(String prefix) {
+        this.prefix = prefix;
+    }
+
+    @Override
+    public void onEmailSet(String email) {
+        this.email = email;
+    }
+
+    @Override
+    public void onSafePasswordSet(final String password) {
+        safePassword = password;
+    }
+
+    @Override
+    public void onSafeServerInfoSet(ThreemaSafeServerInfo safeServerInfo) {
+        this.safeServerInfo = safeServerInfo;
+    }
+
+    @Override
+    public String getNickname() {
+        return this.nickname;
+    }
+
+    @Override
+    public String getPhone() {
+        if (PHONE_LINKED_PLACEHOLDER.equals(this.number)) {
+            return this.number;
+        }
+
+        String phone = this.prefix + this.number;
+
+        if (localeService.validatePhoneNumber(phone)) {
+            return serviceManager.getLocaleService().getNormalizedPhoneNumber(phone);
+        }
+        return "";
+    }
+
+    @Override
+    public String getNumber() {
+        return this.number;
+    }
+
+    @Override
+    public String getPrefix() {
+        return this.prefix;
+    }
+
+    @Override
+    public String getEmail() {
+        return (this.email != null && this.email.length() > 4) ? this.email : "";
+    }
+
+    @Override
+    public String getPresetPhone() {
+        return this.presetMobile;
+    }
+
+    @Override
+    public String getPresetEmail() {
+        return this.presetEmail;
+    }
+
+    @Override
+    public boolean getSafeForcePasswordEntry() {
+        return safeConfig.isBackupForced();
+    }
+
+    @Override
+    public boolean getSafeSkipBackupPasswordEntry() {
+        return safeConfig.isSkipBackupPasswordEntry();
+    }
+
+    @Override
+    public boolean isSafeEnabled() {
+        return !safeConfig.isBackupDisabled();
+    }
+
+    @Override
+    public boolean isSafeForced() {
+        return safeConfig.isBackupForced();
+    }
+
+    @Override
+    public String getSafePassword() {
+        return this.safePassword;
+    }
+
+    @Override
+    public ThreemaSafeServerInfo getSafeServerInfo() {
+        return this.safeServerInfo;
+    }
+
+    @Override
+    public boolean getSyncContacts() {
+        return this.isSyncContacts;
+    }
+
+    @Override
+    public boolean isReadOnlyProfile() {
+        return this.readOnlyProfile;
+    }
+
+    @Override
+    public boolean isSkipWizard() {
+        return this.skipWizard;
+    }
+
+    /**
+     * Return whether the identity was just created
+     *
+     * @return true if it's a new identity, false if the identity was restored
+     */
+    public boolean isNewIdentity() {
+        return isNewIdentity;
+    }
+
+    @Override
+    public void onYes(String tag, Object data) {
+        switch (tag) {
+            case DIALOG_TAG_USE_ID_AS_NICKNAME:
+                this.nickname = this.userService.getIdentity();
+                break;
+            case DIALOG_TAG_PASSWORD_BAD_WORK:
+            case DIALOG_TAG_INVALID_ENTRY:
+                prevPage();
+                break;
+            case DIALOG_TAG_PASSWORD_BAD:
+            case DIALOG_TAG_THREEMA_SAFE:
+            case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+                break;
+            case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
+            case DIALOG_TAG_SYNC_CONTACTS_MDM_ENABLE_RATIONALE:
+                requestContactSyncPermission();
+                break;
+            case DIALOG_TAG_APPLICATION_SETUP_RETRY:
+                runApplicationSetupStepsAndRestart();
+                break;
+        }
+    }
+
+    @Override
+    public void onNo(String tag) {
+        switch (tag) {
+            case DIALOG_TAG_USE_ID_AS_NICKNAME:
+                prevPage();
+                break;
+            case DIALOG_TAG_USE_ANONYMOUSLY:
+                setPage(WizardFragment3.PAGE_ID);
+                break;
+            case DIALOG_TAG_THREEMA_SAFE:
+                prevPage();
+                break;
+            case DIALOG_TAG_PASSWORD_BAD:
+                setPage(WizardFragment1.PAGE_ID);
+                break;
+            case DIALOG_TAG_SYNC_CONTACTS_ENABLE:
+                isSyncContacts = false;
+                this.serviceManager.getPreferenceService().setSyncContacts(false);
+                linkPhone();
+                break;
+            case DIALOG_TAG_PASSWORD_PRESET_CONFIRM:
+                finish();
+                System.exit(0);
+                break;
+        }
+    }
+
+    @Override
+    protected boolean enableOnBackPressedCallback() {
+        return true;
+    }
+
+    @Override
+    protected void handleOnBackPressed() {
+        if (prevButton != null && prevButton.getVisibility() == View.VISIBLE) {
+            prevPage();
+        }
+    }
+
+    private static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
+        public ScreenSlidePagerAdapter(FragmentManager fm) {
+            super(fm, FragmentStatePagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+        }
+
+        @Override
+        public Fragment getItem(int position) {
+            switch (position) {
+                case WizardFragment0.PAGE_ID:
+                    return new WizardFragment0();
+                case WizardFragment1.PAGE_ID:
+                    return new WizardFragment1();
+                case WizardFragment2.PAGE_ID:
+                    return new WizardFragment2();
+                case WizardFragment3.PAGE_ID:
+                    return new WizardFragment3();
+                case WizardFragment4.PAGE_ID:
+                    return new WizardFragment4();
+                default:
+                    break;
+            }
+            return null;
+        }
+
+        @Override
+        public int getCount() {
+            return NUM_PAGES;
+        }
+    }
+
+    public void nextPage() {
+        int currentItem = viewPager.getCurrentItem() + 1;
+        if (currentItem < NUM_PAGES) {
+            viewPager.setCurrentItem(currentItem);
+        }
+    }
+
+    public void prevPage() {
+        int currentItem = viewPager.getCurrentItem();
+        if (currentItem != 0) {
+            viewPager.setCurrentItem(currentItem - 1);
+        }
+    }
+
+    public void setPage(int page) {
+        viewPager.setCurrentItem(page);
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    private void linkEmail(final WizardFragment4 fragment) {
+        final String newEmail = getEmail();
+        if (TestUtil.isEmptyOrNull(newEmail)) {
+            initSyncAndFinish();
+            return;
+        }
+
+        boolean isNewEmail = (!(presetEmail != null && presetEmail.equals(newEmail)));
+
+        if ((userService.getEmailLinkingState() != UserService.LinkingState_LINKED) && isNewEmail) {
+            new AsyncTask<Void, Void, String>() {
+                @Override
+                protected void onPreExecute() {
+                    fragment.setEmailLinkingInProgress(true);
+                }
+
+                @Override
+                protected String doInBackground(Void... params) {
+                    try {
+                        userService.linkWithEmail(email);
+                    } catch (LinkEmailException e) {
+                        logger.error("Exception", e);
+                        return e.getMessage();
+                    } catch (Exception e) {
+                        logger.error("Exception", e);
+                        return getString(R.string.internet_connection_required);
+                    }
+                    return null;
+                }
+
+                @Override
+                protected void onPostExecute(String result) {
+                    if (result != null) {
+                        fragment.setEmailLinkingAlert(result);
+                        errorRaised = true;
+                    } else {
+                        fragment.setEmailLinkingInProgress(false);
+                    }
+                    initSyncAndFinish();
+                }
+            }.execute();
+        } else {
+            initSyncAndFinish();
+        }
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    private void linkPhone() {
+        final String phone = getPhone();
+        if (TestUtil.isEmptyOrNull(phone)) {
+            linkEmail(fragment4);
+            return;
+        }
+
+        boolean isNewPhoneNumber = (presetMobile == null || !presetMobile.equals(phone));
+
+        // start linking activity only if not already linked
+        if ((userService.getMobileLinkingState() != UserService.LinkingState_LINKED) && isNewPhoneNumber) {
+            new AsyncTask<Void, Void, String>() {
+                @Override
+                protected void onPreExecute() {
+                    fragment4.setMobileLinkingInProgress(true);
+                }
+
+                @Override
+                protected String doInBackground(Void... params) {
+                    try {
+                        userService.linkWithMobileNumber(phone);
+                    } catch (LinkMobileNoException e) {
+                        logger.error("Exception", e);
+                        return e.getMessage();
+                    } catch (Exception e) {
+                        logger.error("Exception", e);
+                        return getString(R.string.internet_connection_required);
+                    }
+                    return null;
+                }
+
+                @Override
+                protected void onPostExecute(String result) {
+                    if (result != null) {
+                        fragment4.setMobileLinkingAlert(result);
+                        errorRaised = true;
+                    } else {
+                        fragment4.setMobileLinkingInProgress(false);
+                    }
+                    linkEmail(fragment4);
+                }
+            }.execute();
+        } else {
+            linkEmail(fragment4);
+        }
+    }
+
+    @WorkerThread
+    private void addContact(final String identity, final String first, final String last) {
+        new BasicAddOrUpdateContactBackgroundTask(
+            identity,
+            ch.threema.storage.models.ContactModel.AcquaintanceLevel.DIRECT,
+            userService.getIdentity(),
+            apiConnector,
+            contactModelRepository,
+            AddContactRestrictionPolicy.IGNORE,
+            WizardBaseActivity.this,
+            null
+        ) {
+            @Override
+            public void onFinished(ContactResult result) {
+                ContactModel contactModel;
+                if (result instanceof ContactAvailable) {
+                    contactModel = ((ContactAvailable) result).getContactModel();
+                } else {
+                    contactModel = null;
+                }
+
+                if (contactModel != null) {
+                    contactModel.setNameFromLocal(first, last);
+                }
+            }
+        }.runSynchronously();
+    }
+
+    private void runApplicationSetupStepsAndRestart() {
+        backgroundExecutor.execute(new BackgroundTask<Boolean>() {
+            @Override
+            public void runBefore() {
+                // Nothing to do
+            }
+
+            @Override
+            public Boolean runInBackground() {
+                Boolean applicationSetupResult = runApplicationSetupSteps(serviceManager);
+
+                addContact(ThreemaApplication.ECHO_USER_IDENTITY, "Echo", "Test");
+
+                return applicationSetupResult;
+            }
+
+            @Override
+            public void runAfter(Boolean result) {
+                if (!Boolean.TRUE.equals(result)) {
+                    WizardDialog.newInstance(R.string.application_setup_steps_failed, R.string.retry)
+                        .show(getSupportFragmentManager(), DIALOG_TAG_APPLICATION_SETUP_RETRY);
+                    return;
+                }
+
+                preferenceService.setWizardRunning(false);
+                preferenceService.setLatestVersion(WizardBaseActivity.this);
+
+                // Flush conversation cache (after a restore) to ensure that the conversation list
+                // will be loaded from the database to prevent the list being incomplete.
+                try {
+                    serviceManager.getConversationService().reset();
+                } catch (Exception e) {
+                    logger.error("Exception", e);
+                }
+
+                ConfigUtils.recreateActivity(WizardBaseActivity.this);
+            }
+        });
+    }
+
+    private void ensureMasterKeyWrite() {
+        // Write master key now if no passphrase has been set - don't leave it up to the MainActivity
+        if (!ThreemaApplication.getMasterKey().isProtected()) {
+            try {
+                ThreemaApplication.getMasterKey().setPassphrase(null);
+            } catch (Exception e) {
+                // better die if something went wrong as the master key may not have been saved
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    @SuppressLint({"StaticFieldLeak", "MissingPermission"})
+    private void reallySyncContactsAndFinish() {
+        ensureMasterKeyWrite();
+
+        if (preferenceService.isSyncContacts()) {
+            new AsyncTask<Void, Void, Void>() {
+                @Override
+                protected void onPreExecute() {
+                    fragment4.setContactsSyncInProgress(true, getString(R.string.wizard1_sync_contacts));
+                }
+
+                @SuppressLint("MissingPermission")
+                @Override
+                protected Void doInBackground(Void... params) {
+                    try {
+                        final Account account = userService.getAccount(true);
+                        //disable
+                        userService.enableAccountAutoSync(false);
+
+                        SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
+                        SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
+
+                        routine.setOnStatusUpdate(new SynchronizeContactsRoutine.OnStatusUpdate() {
+                            @Override
+                            public void newStatus(final long percent, final String message) {
+                                RuntimeUtil.runOnUiThread(() -> fragment4.setContactsSyncInProgress(true, message));
+                            }
+
+                            @Override
+                            public void error(final Exception x) {
+                                RuntimeUtil.runOnUiThread(() -> fragment4.setContactsSyncInProgress(false, x.getMessage()));
+                            }
+                        });
+
+                        //on finished, close the dialog
+                        routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> userService.enableAccountAutoSync(true));
+
+                        routine.run();
+                    } catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+                        logger.error("Exception", e);
+                    }
+                    return null;
+                }
+
+                @Override
+                protected void onPostExecute(Void result) {
+                    finishHandler.removeCallbacks(finishTask);
+                    finishHandler.postDelayed(finishTask, FINISH_DELAY);
+                }
+            }.execute();
+        } else {
+            userService.removeAccount();
+            prepareThreemaSafe();
+        }
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    private void prepareThreemaSafe() {
+        if (!TestUtil.isEmptyOrNull(getSafePassword())) {
+            new AsyncTask<Void, Void, byte[]>() {
+                @Override
+                protected void onPreExecute() {
+                    fragment4.setThreemaSafeInProgress(true, getString(R.string.preparing_threema_safe));
+                }
+
+                @Override
+                protected byte[] doInBackground(Void... voids) {
+                    return threemaSafeService.deriveMasterKey(getSafePassword(), userService.getIdentity());
+                }
+
+                @Override
+                protected void onPostExecute(byte[] masterkey) {
+                    fragment4.setThreemaSafeInProgress(false, getString(R.string.menu_done));
+
+                    if (masterkey != null) {
+                        threemaSafeService.storeMasterKey(masterkey);
+                        preferenceService.setThreemaSafeServerInfo(safeServerInfo);
+                        threemaSafeService.setEnabled(true);
+                        threemaSafeService.uploadNow(true);
+                    } else {
+                        Toast.makeText(WizardBaseActivity.this, R.string.safe_error_preparing, Toast.LENGTH_LONG).show();
+                    }
+
+                    runApplicationSetupStepsAndRestart();
+                }
+            }.execute();
+        } else {
+            // no password was set
+            // do not save mdm settings if backup is forced and no password was set - this will cause a password prompt later
+            if (!(ConfigUtils.isWorkRestricted() && ThreemaSafeMDMConfig.getInstance().isBackupForced())) {
+                threemaSafeService.storeMasterKey(new byte[0]);
+            }
+            runApplicationSetupStepsAndRestart();
+        }
+    }
+
+    private void initSyncAndFinish() {
+        if (!errorRaised || ConfigUtils.isWorkRestricted()) {
+            syncContactsAndFinish();
+        } else {
+            resetUi();
+        }
+    }
+
+    private void resetUi() {
+        // unlock UI to try again
+        viewPager.lock(false);
+        prevButton.setVisibility(View.VISIBLE);
+        if (finishButton != null) {
+            finishButton.setEnabled(true);
+        }
+    }
+
+    private void syncContactsAndFinish() {
+        /* trigger a connection now - as application lifecycle was set to resumed state when there was no identity yet */
+        serviceManager.getLifetimeService().ensureConnection();
+
+        if (this.isSyncContacts) {
+            preferenceService.setSyncContacts(true);
+            reallySyncContactsAndFinish();
+        } else {
+            preferenceService.setSyncContacts(false);
+            prepareThreemaSafe();
+        }
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        if (requestCode == PERMISSION_REQUEST_READ_CONTACTS) {
+            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                this.isSyncContacts = true;
+                linkPhone();
+            } else if (userCannotChangeContactSync) {
+                ConfigUtils.showPermissionRationale(this, (View) viewPager.getParent(), R.string.permission_contacts_sync_required);
+                resetUi();
+            } else {
+                this.isSyncContacts = false;
+                linkPhone();
+            }
+        }
+    }
 }

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

@@ -297,7 +297,7 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 
 			@Override
 			public Boolean runInBackground() {
-				return runApplicationSetupSteps(serviceManager, WizardSafeRestoreActivity.this);
+				return runApplicationSetupSteps(serviceManager);
 			}
 
 			@Override

+ 11 - 5
app/src/main/java/ch/threema/app/activities/wizard/WizardStartActivity.java

@@ -42,7 +42,7 @@ import static ch.threema.app.backuprestore.csv.RestoreService.RESTORE_COMPLETION
 
 public class WizardStartActivity extends WizardBackgroundActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardStartActivity");
-	boolean doFinish = false;
+	boolean nextActivityLaunched = false;
 
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
@@ -98,7 +98,12 @@ public class WizardStartActivity extends WizardBackgroundActivity {
 		return frameAnimation;
 	}
 
-	private void launchNextActivity(ActivityOptionsCompat options) {
+	private synchronized void launchNextActivity(ActivityOptionsCompat options) {
+        if (nextActivityLaunched) {
+            // If the next activity already has been launched, we can just return here.
+            return;
+        }
+
 		Intent intent;
 
 		if (userService != null && userService.hasIdentity()) {
@@ -122,13 +127,14 @@ public class WizardStartActivity extends WizardBackgroundActivity {
 			startActivity(intent);
 			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 		}
-		doFinish = true;
+		nextActivityLaunched = true;
 	}
 
 	@Override
 	public void onStop() {
 		super.onStop();
-		if (doFinish)
-			finish();
+		if (nextActivityLaunched) {
+            finish();
+        }
 	}
 }

+ 177 - 106
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -66,6 +66,9 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ViewUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.models.ContactModelData;
+import ch.threema.domain.models.ReadReceiptPolicy;
+import ch.threema.domain.models.TypingIndicatorPolicy;
+import ch.threema.domain.models.WorkVerificationLevel;
 import ch.threema.protobuf.csp.e2e.fs.Terminate;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
@@ -87,18 +90,17 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
     private static final int TYPE_HEADER = 0;
     private static final int TYPE_ITEM = 1;
 
-    private final Context context;
-    private ContactService contactService;
-    private GroupService groupService;
-    private PreferenceService preferenceService;
-    private IdListService excludeFromSyncListService;
-    private IdListService blockedContactsService;
-    @Deprecated
-    private final ContactModel contactModel;
-    private final @NonNull ContactModelData contactModelData;
-    private final List<GroupModel> values;
-    private OnClickListener onClickListener;
-    private final @NonNull RequestManager requestManager;
+	private final Context context;
+	private ContactService contactService;
+	private GroupService groupService;
+	private PreferenceService preferenceService;
+	private IdListService excludeFromSyncListService;
+	private IdListService blockedContactsService;
+	private final @NonNull ch.threema.data.models.ContactModel contactModel;
+	private final @NonNull ContactModelData contactModelData;
+	private final List<GroupModel> values;
+	private OnClickListener onClickListener;
+	private final @NonNull RequestManager requestManager;
 
     public static class ItemHolder extends RecyclerView.ViewHolder {
         public final @NonNull View view;
@@ -163,28 +165,34 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 return true;
             });
 
-            // When clicking ten times on the Threema ID, the clear forward security session button
-            // becomes visible.
-            threemaIdView.setOnClickListener(v -> {
-                onThreemaIDClickCount++;
-                if (onThreemaIDClickCount >= 10) {
-                    onThreemaIDClickCount = 0;
-                    clearForwardSecuritySection.setVisibility(View.VISIBLE);
-                    clearForwardSecurityButton.setOnClickListener(clearButton -> {
-                        try {
-                            ThreemaApplication.requireServiceManager()
-                                .getTaskCreator()
-                                .scheduleDeleteAndTerminateFSSessionsTaskAsync(
-                                    contactModel,
-                                    Terminate.Cause.RESET
-                                );
-                            Toast.makeText(clearButton.getContext(), R.string.forward_security_cleared, Toast.LENGTH_LONG).show();
-                        } catch (Exception e) {
-                            Toast.makeText(clearButton.getContext(), e.getMessage(), Toast.LENGTH_LONG).show();
-                        }
-                    });
-                }
-            });
+			// When clicking ten times on the Threema ID, the clear forward security session button
+			// becomes visible.
+			threemaIdView.setOnClickListener(v -> {
+				onThreemaIDClickCount++;
+				if (onThreemaIDClickCount >= 10) {
+					onThreemaIDClickCount = 0;
+					clearForwardSecuritySection.setVisibility(View.VISIBLE);
+					clearForwardSecurityButton.setOnClickListener(clearButton -> {
+						ContactModel contactModel = contactService.getByIdentity(contactModelData.identity);
+						if (contactModel == null) {
+							logger.error("Contact model is null. Cannot schedule fs session deletion task.");
+							return;
+						}
+
+						try {
+							ThreemaApplication.requireServiceManager()
+								.getTaskCreator()
+								.scheduleDeleteAndTerminateFSSessionsTaskAsync(
+									contactModel,
+									Terminate.Cause.RESET
+								);
+							Toast.makeText(clearButton.getContext(), R.string.forward_security_cleared, Toast.LENGTH_LONG).show();
+						} catch (Exception e) {
+							Toast.makeText(clearButton.getContext(), e.getMessage(), Toast.LENGTH_LONG).show();
+						}
+					});
+				}
+			});
 
             publicNickNameView.setOnLongClickListener(ignored -> {
                 copyTextToClipboard(contactModelData.nickname, R.string.contact_details_nickname_copied);
@@ -209,19 +217,19 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         }
     }
 
-    @UiThread
-    public ContactDetailAdapter(
-        Context context,
-        List<GroupModel> values,
-        ContactModel contactModel,
-        @NonNull ContactModelData contactModelData,
-        @NonNull RequestManager requestManager
-    ) {
-        this.context = context;
-        this.values = values;
-        this.contactModel = contactModel;
-        this.contactModelData = contactModelData;
-        this.requestManager = requestManager;
+	@UiThread
+	public ContactDetailAdapter(
+		Context context,
+		List<GroupModel>values,
+		@NonNull ch.threema.data.models.ContactModel contactModel,
+		@NonNull ContactModelData contactModelData,
+		@NonNull RequestManager requestManager
+	) {
+		this.context = context;
+		this.values = values;
+		this.contactModel = contactModel;
+		this.contactModelData = contactModelData;
+		this.requestManager = requestManager;
 
         try {
             ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
@@ -280,40 +288,45 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         } else {
             HeaderHolder headerHolder = (HeaderHolder) holder;
 
-            String identityAdditional = null;
-            switch (this.contactModelData.activityState) {
-                case ACTIVE:
-                    if (blockedContactsService.has(contactModelData.identity)) {
-                        identityAdditional = context.getString(R.string.blocked);
-                    }
-                    break;
-                case INACTIVE:
-                    identityAdditional = context.getString(R.string.contact_state_inactive);
-                    break;
-                case INVALID:
-                    identityAdditional = context.getString(R.string.contact_state_invalid);
-                    break;
-            }
-
-            final boolean shouldShowJobTitle = contactModel.isWork() && contactModelData.jobTitle != null && !contactModelData.jobTitle.isBlank();
+			String identityAdditional = null;
+			switch (this.contactModelData.activityState) {
+				case ACTIVE:
+					if (blockedContactsService.has(contactModelData.identity)) {
+						identityAdditional = context.getString(R.string.blocked);
+					}
+					break;
+				case INACTIVE:
+					identityAdditional = context.getString(R.string.contact_state_inactive);
+					break;
+				case INVALID:
+					identityAdditional = context.getString(R.string.contact_state_invalid);
+					break;
+			}
+
+            final boolean shouldShowJobTitle = contactModelData.workVerificationLevel == WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED
+                && contactModelData.jobTitle != null && !contactModelData.jobTitle.isBlank();
             ViewUtil.show(headerHolder.jobTitleHeaderView, shouldShowJobTitle);
             ViewUtil.show(headerHolder.jobTitleTextView, shouldShowJobTitle);
             if (shouldShowJobTitle) {
                 headerHolder.jobTitleTextView.setText(contactModelData.jobTitle);
             }
 
-            final boolean shouldShowDepartment = contactModel.isWork() && contactModelData.department != null && !contactModelData.department.isBlank();
+            final boolean shouldShowDepartment = contactModelData.workVerificationLevel == WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED
+                && contactModelData.department != null && !contactModelData.department.isBlank();
             ViewUtil.show(headerHolder.departmentHeaderView, shouldShowDepartment);
             ViewUtil.show(headerHolder.departmentTextView, shouldShowDepartment);
             if (shouldShowDepartment) {
                 headerHolder.departmentTextView.setText(contactModelData.department);
             }
 
-            headerHolder.threemaIdView.setText(
-                contactModelData.identity + (identityAdditional != null ? " (" + identityAdditional + ")" : "")
-            );
-            headerHolder.verificationLevelImageView.setContactModel(contactModel);
-            headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
+			headerHolder.threemaIdView.setText(
+				contactModelData.identity + (identityAdditional != null ? " (" + identityAdditional + ")" : "")
+			);
+			headerHolder.verificationLevelImageView.setVerificationLevel(
+				contactModelData.verificationLevel,
+				contactModelData.workVerificationLevel
+			);
+			headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
 
             boolean isSyncExcluded = excludeFromSyncListService.has(contactModelData.identity);
 
@@ -323,18 +336,18 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
             ) {
                 headerHolder.synchronizeContainer.setVisibility(View.VISIBLE);
 
-                Drawable icon = null;
-                try {
-                    icon = AndroidContactUtil.getInstance().getAccountIcon(contactModel);
-                } catch (SecurityException e) {
-                    logger.error("Could not access android account icon", e);
-                }
-                if (icon != null) {
-                    headerHolder.syncSourceIcon.setImageDrawable(icon);
-                    headerHolder.syncSourceIcon.setVisibility(View.VISIBLE);
-                } else {
-                    headerHolder.syncSourceIcon.setVisibility(View.GONE);
-                }
+				Drawable icon = null;
+				try {
+					icon = AndroidContactUtil.getInstance().getAccountIcon(contactModelData.androidContactLookupKey);
+				} catch (SecurityException e) {
+					logger.error("Could not access android account icon", e);
+				}
+				if (icon != null) {
+					headerHolder.syncSourceIcon.setImageDrawable(icon);
+					headerHolder.syncSourceIcon.setVisibility(View.VISIBLE);
+				} else {
+					headerHolder.syncSourceIcon.setVisibility(View.GONE);
+				}
 
                 headerHolder.synchronize.setChecked(isSyncExcluded);
                 headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
@@ -361,31 +374,10 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 headerHolder.groupMembershipTitle.setVisibility(View.GONE);
             }
 
-            final String[] choices = context.getResources().getStringArray(R.array.receipts_override_choices);
-            choices[0] = context.getString(R.string.receipts_override_choice_default,
-                choices[preferenceService.isReadReceipts() ? 1 : 2]);
-
-            ArrayAdapter<String> readReceiptsAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_dropdown_item, choices);
-            headerHolder.readReceiptsSpinner.setAdapter(readReceiptsAdapter);
-            headerHolder.readReceiptsSpinner.setText(choices[contactModel.getReadReceipts()], false);
-            headerHolder.readReceiptsSpinner.setOnItemClickListener((parent, view, position1, id) -> {
-                contactModel.setReadReceipts(position1);
-                contactService.save(contactModel);
-            });
-
-            final String[] typingChoices = context.getResources().getStringArray(R.array.receipts_override_choices);
-            typingChoices[0] = context.getString(R.string.receipts_override_choice_default,
-                typingChoices[preferenceService.isTypingIndicator() ? 1 : 2]);
-
-            ArrayAdapter<String> typingIndicatorAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_dropdown_item, typingChoices);
-            headerHolder.typingIndicatorsSpinner.setAdapter(typingIndicatorAdapter);
-            headerHolder.typingIndicatorsSpinner.setText(typingChoices[contactModel.getTypingIndicators()], false);
-            headerHolder.typingIndicatorsSpinner.setOnItemClickListener((parent, view, position12, id) -> {
-                contactModel.setTypingIndicators(position12);
-                contactService.save(contactModel);
-            });
-        }
-    }
+			initializeReadReceiptsSpinner(headerHolder);
+			initializeTypingIndicatorSpinner(headerHolder);
+		}
+	}
 
     @Override
     public int getItemCount() {
@@ -418,4 +410,83 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         void onVerificationInfoClick(View v);
     }
 
+	private void initializeReadReceiptsSpinner(@NonNull HeaderHolder headerHolder) {
+		final String[] choices = context.getResources().getStringArray(R.array.receipts_override_choices);
+		choices[0] = context.getString(R.string.receipts_override_choice_default,
+			choices[preferenceService.isReadReceipts() ? 1 : 2]);
+
+		int initialReadReceiptPosition;
+		switch (contactModelData.readReceiptPolicy) {
+			case SEND:
+				initialReadReceiptPosition = 1;
+				break;
+			case DONT_SEND:
+				initialReadReceiptPosition = 2;
+				break;
+			case DEFAULT:
+			default:
+				initialReadReceiptPosition = 0;
+				break;
+		}
+		ArrayAdapter<String> readReceiptsAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_dropdown_item, choices);
+		headerHolder.readReceiptsSpinner.setAdapter(readReceiptsAdapter);
+		headerHolder.readReceiptsSpinner.setText(choices[initialReadReceiptPosition], false);
+		headerHolder.readReceiptsSpinner.setOnItemClickListener((parent, view, readReceiptPosition, id) -> {
+			switch (readReceiptPosition) {
+				case 0:
+					contactModel.setReadReceiptPolicyFromLocal(ReadReceiptPolicy.DEFAULT);
+					break;
+				case 1:
+					contactModel.setReadReceiptPolicyFromLocal(ReadReceiptPolicy.SEND);
+					break;
+				case 2:
+					contactModel.setReadReceiptPolicyFromLocal(ReadReceiptPolicy.DONT_SEND);
+					break;
+				default:
+					logger.warn("Invalid position for read receipt policy: {}", readReceiptPosition);
+					break;
+			}
+		});
+	}
+
+	private void initializeTypingIndicatorSpinner(@NonNull HeaderHolder headerHolder) {
+		final String[] typingChoices = context.getResources().getStringArray(R.array.receipts_override_choices);
+		typingChoices[0] = context.getString(R.string.receipts_override_choice_default,
+			typingChoices[preferenceService.isTypingIndicator() ? 1 : 2]);
+
+		int initialTypingIndicatorPosition;
+		switch (contactModelData.typingIndicatorPolicy) {
+			case SEND:
+				initialTypingIndicatorPosition = 1;
+				break;
+			case DONT_SEND:
+				initialTypingIndicatorPosition = 2;
+				break;
+			case DEFAULT:
+			default:
+				initialTypingIndicatorPosition = 0;
+				break;
+		}
+
+		ArrayAdapter<String> typingIndicatorAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_dropdown_item, typingChoices);
+		headerHolder.typingIndicatorsSpinner.setAdapter(typingIndicatorAdapter);
+		headerHolder.typingIndicatorsSpinner.setText(typingChoices[initialTypingIndicatorPosition], false);
+		headerHolder.typingIndicatorsSpinner.setOnItemClickListener((parent, view, typingIndicatorPosition, id) -> {
+			switch (typingIndicatorPosition) {
+				case 0:
+					contactModel.setTypingIndicatorPolicyFromLocal(TypingIndicatorPolicy.DEFAULT);
+					break;
+				case 1:
+					contactModel.setTypingIndicatorPolicyFromLocal(TypingIndicatorPolicy.SEND);
+					break;
+				case 2:
+					contactModel.setTypingIndicatorPolicyFromLocal(TypingIndicatorPolicy.DONT_SEND);
+					break;
+				default:
+					logger.warn("Invalid position for typing indicator policy: {}", typingIndicatorPosition);
+					break;
+			}
+		});
+	}
+
 }

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

@@ -398,9 +398,12 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
         );
         AdapterUtil.styleContact(holder.contactTextBottomRight, contactModel);
 
-        if (holder.verificationLevelView != null) {
-            holder.verificationLevelView.setContactModel(contactModel);
-        }
+		if (holder.verificationLevelView != null) {
+			holder.verificationLevelView.setVerificationLevel(
+				contactModel.verificationLevel,
+				contactModel.getWorkVerificationLevel()
+			);
+		}
 
         ViewUtil.show(
             holder.blockedContactView,

+ 36 - 32
app/src/main/java/ch/threema/app/adapters/ContactsSyncAdapter.java

@@ -30,12 +30,9 @@ import android.os.Bundle;
 
 import org.slf4j.Logger;
 
-import java.util.List;
-
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
-import ch.threema.app.listeners.NewSyncedContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
@@ -43,19 +40,34 @@ import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
 
 public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSyncAdapter");
 
+	private boolean isSyncEnabled = true;
+
 	public ContactsSyncAdapter(Context context, boolean autoInitialize) {
 		super(context, autoInitialize);
 	}
 
+	public void setSyncEnabled(boolean enabled) {
+		isSyncEnabled = enabled;
+	}
+
 	@Override
 	public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
 		logger.info("onPerformSync");
 
+		if (!isSyncEnabled) {
+			logger.info("Contact sync is disabled; retry later.");
+			// Workaround to trigger a soft error to retry the sync at a later moment
+			// See
+			//  - https://developer.android.com/reference/android/content/SyncResult#hasSoftError()
+			//  - https://developer.android.com/reference/android/content/SyncResult#SyncResult()
+			syncResult.stats.numIoExceptions++;
+			return;
+		}
+
 		try {
 			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
@@ -66,10 +78,7 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 			if (serviceManager.getPreferenceService().isSyncContacts()) {
 				logger.info("Start sync adapter run");
 				SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-				if (synchronizeContactsService == null) {
-					return;
 
-				}
 				if (synchronizeContactsService.isFullSyncInProgress()) {
 					logger.info("A full sync is already running");
 					syncResult.stats.numUpdates = 0;
@@ -82,29 +91,21 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 				SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
 				//update stats on finished to resolve the "every minute sync" bug
 
-				routine.addOnFinished(new SynchronizeContactsRoutine.OnFinished() {
-					@Override
-					public void finished(boolean success, long modifiedAccounts, List<ContactModel> createdContacts, long deletedAccounts) {
-						// let user know that contact was added
-						ListenerManager.newSyncedContactListener.handle(new ListenerManager.HandleListener<NewSyncedContactsListener>() {
-							@Override
-							public void handle(NewSyncedContactsListener listener) {
-								listener.onNew(createdContacts);
-							}
-						});
-
-						//hack to not schedule the next sync!
-						syncResult.stats.numUpdates = 0;//modifiedAccounts;
-						syncResult.stats.numInserts = 0;//createdAccounts;
-						syncResult.stats.numDeletes = 0;//deletedAccounts;
-						syncResult.stats.numEntries = 0;//createdAccounts;
-
-						//send a broadcast to let others know that the list has changed
-						LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext()).sendBroadcast(IntentDataUtil.createActionIntentContactsChanged());
-					}
+				routine.addOnFinished((success, modifiedAccounts, createdContacts, deletedAccounts) -> {
+					// let user know that contact was added
+					ListenerManager.newSyncedContactListener.handle(listener -> listener.onNew(createdContacts));
+
+					//hack to not schedule the next sync!
+					syncResult.stats.numUpdates = 0;//modifiedAccounts;
+					syncResult.stats.numInserts = 0;//createdAccounts;
+					syncResult.stats.numDeletes = 0;//deletedAccounts;
+					syncResult.stats.numEntries = 0;//createdAccounts;
+
+					//send a broadcast to let others know that the list has changed
+					LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext()).sendBroadcast(IntentDataUtil.createActionIntentContactsChanged());
 				});
 
-				//not in a thread!
+				// not in a thread: `onPerformSync` is already called in a background thread
 				routine.run();
 			}
 		}
@@ -115,10 +116,13 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 			logger.debug("MasterKeyLockedException [" + e.getMessage() + "]");
 
 		}finally{
-			logger.debug("sync finished Sync [numEntries=" + String.valueOf(syncResult.stats.numEntries) +
-				", updates=" + String.valueOf(syncResult.stats.numUpdates) +
-				", inserts=" + String.valueOf(syncResult.stats.numInserts) +
-				", deletes=" + String.valueOf(syncResult.stats.numDeletes) + "]");
+			logger.debug(
+				"sync finished Sync [numEntries={}, updates={}, inserts={}, deletes={}",
+				syncResult.stats.numEntries,
+				syncResult.stats.numUpdates,
+				syncResult.stats.numInserts,
+				syncResult.stats.numDeletes
+			);
 		}
 	}
 

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

@@ -164,7 +164,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			boolean isOrphanedGroup = groupService.isOrphanedGroup(groupModel);
 			boolean isCreator = groupService.isGroupCreator(groupModel);
 			boolean isMember = groupService.isGroupMember(groupModel);
-			boolean hasOtherMembers = groupService.getOtherMemberCount(groupModel) > 0;
+			boolean hasOtherMembers = groupService.countMembersWithoutUser(groupModel) > 0;
 
 			if (isOrphanedGroup) {
 				// Show orphaned group notice

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

@@ -171,7 +171,10 @@ public class UserListAdapter extends FilterableListAdapter {
 				blockedContactsService != null && blockedContactsService.has(contactModel.getIdentity())
 		);
 
-		holder.verificationLevelView.setContactModel(contactModel);
+		holder.verificationLevelView.setVerificationLevel(
+			contactModel.verificationLevel,
+			contactModel.getWorkVerificationLevel()
+		);
 
 		String lastMessageDateString = null;
 		MessageReceiver messageReceiver = this.contactService.createReceiver(contactModel);

+ 0 - 108
app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java

@@ -1,108 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-2024 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.asynctasks;
-
-import android.os.AsyncTask;
-import android.widget.Toast;
-
-import org.slf4j.Logger;
-
-import androidx.annotation.NonNull;
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
-import ch.threema.app.exceptions.InvalidEntryException;
-import ch.threema.app.exceptions.PolicyViolationException;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.models.IdentityType;
-import ch.threema.domain.models.VerificationLevel;
-import ch.threema.storage.models.ContactModel;
-
-public class AddContactAsyncTask extends AsyncTask<Void, Void, Boolean> {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("AddContactAsyncTask");
-
-    private final Runnable runOnCompletion;
-    private final String firstName, lastName, threemaId;
-    private final boolean markAsWorkVerified;
-
-    public AddContactAsyncTask(String firstname, String lastname, String identity, boolean markAsWorkVerified, Runnable runOnCompletion) {
-        this.firstName = firstname;
-        this.lastName = lastname;
-        this.threemaId = identity.toUpperCase();
-        this.runOnCompletion = runOnCompletion;
-        this.markAsWorkVerified = markAsWorkVerified;
-    }
-
-    @Override
-    protected Boolean doInBackground(Void... params) {
-        try {
-            return addContact(ThreemaApplication.requireServiceManager().getContactService());
-        } catch (Exception e) {
-            logger.error("Could not add contact", e);
-            return null;
-        }
-    }
-
-    @Override
-    protected void onPostExecute(Boolean added) {
-        if (added != null) {
-            if (added) {
-                Toast.makeText(ThreemaApplication.getAppContext(), R.string.creating_contact_successful, Toast.LENGTH_SHORT).show();
-            }
-
-            if (runOnCompletion != null) {
-                runOnCompletion.run();
-            }
-        } else {
-            Toast.makeText(ThreemaApplication.getAppContext(), R.string.add_contact_failed, Toast.LENGTH_SHORT).show();
-        }
-    }
-
-    private boolean addContact(@NonNull ContactService contactService) throws InvalidEntryException, PolicyViolationException, EntryAlreadyExistsException {
-        if (contactService.getByIdentity(this.threemaId) != null) {
-            logger.info("Contact already exists");
-            return false;
-        }
-
-        boolean force = (ConfigUtils.isOnPremBuild() || ConfigUtils.isWorkBuild()) && markAsWorkVerified;
-
-        ContactModel contactModel = contactService.createContactByIdentity(this.threemaId, force);
-
-        if (this.firstName != null && this.lastName != null) {
-            contactModel.setFirstName(this.firstName);
-            contactModel.setLastName(this.lastName);
-            contactService.save(contactModel);
-        }
-
-        if (contactModel.getIdentityType() == IdentityType.WORK || markAsWorkVerified) {
-            contactModel.setIsWork(true);
-
-            if(contactModel.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
-                contactModel.verificationLevel = VerificationLevel.SERVER_VERIFIED;
-            }
-            contactService.save(contactModel);
-        }
-        return true;
-    }
-}

+ 79 - 0
app/src/main/java/ch/threema/app/asynctasks/AddContactBackgroundTask.kt

@@ -0,0 +1,79 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.asynctasks
+
+import androidx.annotation.WorkerThread
+import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.app.utils.executor.BackgroundTask
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.models.ContactModel
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.repositories.ContactCreateException
+import ch.threema.data.repositories.ContactModelRepository
+import kotlinx.coroutines.runBlocking
+
+private val logger = LoggingUtil.getThreemaLogger("AddContactBackgroundTask")
+
+/**
+ * This task simply adds the given contact model data if the contact does not exist. If there is a
+ * contact with the same identity already, adding the new data is aborted.
+ */
+class AddContactBackgroundTask(
+    private val contactModelData: ContactModelData,
+    private val contactModelRepository: ContactModelRepository,
+) : BackgroundTask<ContactModel?> {
+
+    /**
+     * Add the contact model data if the contact does not exist.
+     *
+     * @return the newly inserted contact model or null if it could not be inserted
+     */
+    @WorkerThread
+    fun runSynchronously(): ContactModel? {
+        runBefore()
+
+        runInBackground().let {
+            runAfter(it)
+            return it
+        }
+    }
+
+    /**
+     * Do not call this method directly. Use [runSynchronously] or run this task using a
+     * [BackgroundExecutor].
+     */
+    override fun runInBackground(): ContactModel? {
+        if (contactModelRepository.getByIdentity(contactModelData.identity) != null) {
+            logger.warn("Contact already exists")
+            return null
+        }
+
+        return try {
+            runBlocking {
+                contactModelRepository.createFromLocal(contactModelData)
+            }
+        } catch (e: ContactCreateException) {
+            logger.error("Contact could not be created", e)
+            null
+        }
+    }
+}

+ 227 - 85
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt

@@ -22,6 +22,8 @@
 package ch.threema.app.asynctasks
 
 import android.content.Context
+import androidx.annotation.StringRes
+import androidx.annotation.WorkerThread
 import ch.threema.app.R
 import ch.threema.app.utils.AppRestrictionUtil
 import ch.threema.app.utils.executor.BackgroundTask
@@ -29,11 +31,16 @@ 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.data.models.ContactModelData.Companion.getIdColorIndex
 import ch.threema.data.repositories.ContactCreateException
 import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
@@ -43,7 +50,7 @@ import kotlinx.coroutines.runBlocking
 import java.net.HttpURLConnection
 import java.util.Date
 
-private val logger = LoggingUtil.getThreemaLogger("AddContactBackgroundTask")
+private val logger = LoggingUtil.getThreemaLogger("AddOrUpdateContactBackgroundTask")
 
 /**
  * This background task should be used if a new identity should be added to the contacts. The task
@@ -54,28 +61,84 @@ private val logger = LoggingUtil.getThreemaLogger("AddContactBackgroundTask")
  * returns [Failed] if it doesn't match.
  *
  * This task also updates the contact if it already exists. This includes changing the acquaintance
- * level from group to direct or changing the verification level to fully verified.
+ * level to [acquaintanceLevel] and the verification level to fully verified (if [expectedPublicKey]
+ * is provided and matches).
  *
- * Note that this task can be overridden and the behavior can be adjusted by overwriting [onBefore]
- * and [onFinished].
+ * Note that this task can be overridden and the behavior can be adjusted by overwriting [onBefore],
+ * [onContactAdded], and [onFinished]. For tasks that do not need to perform any additional
+ * background work, the [BasicAddOrUpdateContactBackgroundTask] can be used.
  */
-open class AddOrUpdateContactBackgroundTask(
+abstract class AddOrUpdateContactBackgroundTask<T>(
     protected val identity: String,
+    protected val acquaintanceLevel: AcquaintanceLevel,
     private val myIdentity: String,
     private val apiConnector: APIConnector,
     private val contactModelRepository: ContactModelRepository,
     private val addContactRestrictionPolicy: AddContactRestrictionPolicy,
     private val context: Context,
     private val expectedPublicKey: ByteArray? = null,
-) : BackgroundTask<ContactAddResult> {
+) : BackgroundTask<T> {
 
+    /**
+     * Run this task synchronously on the same thread. Note that this performs network communication
+     * and must not be run on the main thread.
+     */
+    @WorkerThread
+    fun runSynchronously(): T {
+        runBefore()
+
+        val result = runInBackground()
+
+        runAfter(result)
+
+        return result
+    }
+
+    /**
+     * Do not call this method directly. This should only be called by the background executor.
+     */
     final override fun runBefore() {
         onBefore()
     }
 
-    final override fun runInBackground(): ContactAddResult {
+    /**
+     * Do not call this method directly. This should only be called by the background executor. If
+     * the task should be run on the same thread, use [runSynchronously].
+     */
+    final override fun runInBackground(): T {
+        val result = checkAndAddNewContact()
+
+        return onContactAdded(result)
+    }
+
+    /**
+     * Do not call this method directly. This should only be called by the background executor.
+     */
+    final override fun runAfter(result: T) {
+        onFinished(result)
+    }
+
+    /**
+     * This will be run before the contact is being fetched from the server.
+     */
+    open fun onBefore() = Unit
+
+    /**
+     * As soon as the contact has been added or an error occurred, this method is run with the
+     * provided result. Note that this method is run on the executor's background thread. The result
+     * of it will be passed to [onFinished].
+     */
+    abstract fun onContactAdded(result: ContactResult): T
+
+    /**
+     * This method is run on the UI thread after [onContactAdded] has been executed. Override this
+     * method for making UI changes after the contact has been added and processed.
+     */
+    open fun onFinished(result: T) = Unit
+
+    private fun checkAndAddNewContact(): ContactResult {
         if (identity == myIdentity) {
-            return failed(R.string.identity_already_exists)
+            return UserIdentity(context)
         }
 
         // Update contact if it exists
@@ -91,7 +154,7 @@ open class AddOrUpdateContactBackgroundTask(
         if (addContactRestrictionPolicy == AddContactRestrictionPolicy.CHECK
             && AppRestrictionUtil.isAddContactDisabled(context)
         ) {
-            return PolicyViolation
+            return PolicyViolation(context)
         }
 
         // Fetch the identity
@@ -102,15 +165,15 @@ open class AddOrUpdateContactBackgroundTask(
 
             when (e) {
                 is HttpConnectionException -> {
-                    if (e.errorCode == HttpURLConnection.HTTP_NOT_FOUND) {
-                        return failed(R.string.invalid_threema_id)
+                    return if (e.errorCode == HttpURLConnection.HTTP_NOT_FOUND) {
+                        InvalidThreemaId(context)
                     } else {
-                        return failed(R.string.connection_error)
+                        ConnectionError(context)
                     }
                 }
 
                 is NetworkException, is ThreemaException -> {
-                    return failed(R.string.connection_error)
+                    return ConnectionError(context)
                 }
 
                 else -> {
@@ -123,94 +186,86 @@ open class AddOrUpdateContactBackgroundTask(
         return addNewContact(result, expectedPublicKey)
     }
 
-    final override fun runAfter(result: ContactAddResult) {
-        onFinished(result)
-    }
-
-    /**
-     * This will be run before the contact is being fetched from the server.
-     */
-    open fun onBefore() {}
-
-    /**
-     * As soon as the contact has been added or an error occurred, this method is run with the
-     * provided result.
-     */
-    open fun onFinished(result: ContactAddResult) {}
-
     private fun addNewContact(
         result: FetchIdentityResult,
         expectedPublicKey: ByteArray?,
-    ): ContactAddResult {
+    ): ContactResult {
         val verificationLevel = if (expectedPublicKey != null) {
             if (expectedPublicKey.contentEquals(result.publicKey)) {
                 VerificationLevel.FULLY_VERIFIED
             } else {
-                return failed(R.string.id_mismatch)
+                return RemotePublicKeyMismatch(context)
             }
         } else {
             VerificationLevel.UNVERIFIED
         }
 
-        val identityType = when (result.type) {
-            0 -> IdentityType.NORMAL
-            1 -> IdentityType.WORK
-            else -> {
-                logger.warn("Identity fetch returned invalid identity type: {}", result.type)
-                IdentityType.NORMAL
-            }
-        }
-
-        val activityState = when (result.state) {
-            IdentityState.ACTIVE -> ch.threema.storage.models.ContactModel.State.ACTIVE
-            IdentityState.INACTIVE -> ch.threema.storage.models.ContactModel.State.INACTIVE
-            IdentityState.INVALID -> ch.threema.storage.models.ContactModel.State.INVALID
-            else -> {
-                logger.warn("Identity fetch returned invalid identity state: {}", result.state)
-                ch.threema.storage.models.ContactModel.State.ACTIVE
-            }
-        }
-
         return runBlocking {
             try {
                 val contactModel = contactModelRepository.createFromLocal(
-                    result.identity,
-                    result.publicKey,
-                    Date(),
-                    identityType,
-                    AcquaintanceLevel.DIRECT,
-                    activityState,
-                    result.featureMask.toULong(),
-                    verificationLevel,
+                    ContactModelData(
+                        identity = result.identity,
+                        publicKey = result.publicKey,
+                        createdAt = Date(),
+                        firstName = "",
+                        lastName = "",
+                        nickname = null,
+                        colorIndex = getIdColorIndex(result.identity),
+                        verificationLevel = verificationLevel,
+                        workVerificationLevel = WorkVerificationLevel.NONE,
+                        identityType = result.getIdentityType(),
+                        acquaintanceLevel = acquaintanceLevel,
+                        activityState = result.getIdentityState(),
+                        syncState = ContactSyncState.INITIAL,
+                        featureMask = result.featureMask.toULong(),
+                        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                        androidContactLookupKey = null,
+                        localAvatarExpires = null,
+                        isRestored = false,
+                        profilePictureBlobId = null,
+                        jobTitle = null,
+                        department = null,
+                    )
                 )
-                Success(contactModel)
+                ContactCreated(contactModel)
             } catch (e: ContactCreateException) {
                 logger.error("Could not insert new contact", e)
-                failed(R.string.add_contact_failed)
+
+                val existingContact = contactModelRepository.getByIdentity(identity)
+                if (existingContact != null) {
+                    ContactExists(existingContact)
+                } else {
+                    GenericFailure(context)
+                }
             }
         }
     }
 
-    private fun updateContact(contactModel: ContactModel, data: ContactModelData, expectedPublicKey: ByteArray?): ContactAddResult {
+    private fun updateContact(
+        contactModel: ContactModel,
+        currentData: ContactModelData,
+        expectedPublicKey: ByteArray?,
+    ): ContactResult {
         var verificationLevelChanged = false
         var contactVerifiedAgain = false
         var acquaintanceLevelChanged = false
 
         if (expectedPublicKey != null) {
-            if (expectedPublicKey.contentEquals(data.publicKey)) {
-                if (data.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
+            if (expectedPublicKey.contentEquals(currentData.publicKey)) {
+                if (currentData.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
                     contactModel.setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED)
                     verificationLevelChanged = true
                 } else {
                     contactVerifiedAgain = true
                 }
             } else {
-                return failed(R.string.id_mismatch)
+                return LocalPublicKeyMismatch(contactModel, context)
             }
         }
 
-        if (data.acquaintanceLevel == AcquaintanceLevel.GROUP) {
-            contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT)
+        if (currentData.acquaintanceLevel != acquaintanceLevel) {
+            contactModel.setAcquaintanceLevelFromLocal(acquaintanceLevel)
             acquaintanceLevelChanged = true
         }
 
@@ -223,8 +278,52 @@ open class AddOrUpdateContactBackgroundTask(
             else -> ContactExists(contactModel)
         }
     }
+}
+
+fun FetchIdentityResult.getIdentityType(): IdentityType = when (type) {
+    0 -> IdentityType.NORMAL
+    1 -> IdentityType.WORK
+    else -> {
+        logger.warn("Identity fetch returned invalid identity type: {}", type)
+        IdentityType.NORMAL
+    }
+}
+
+fun FetchIdentityResult.getIdentityState(): IdentityState = when (state) {
+    IdentityState.ACTIVE.value -> IdentityState.ACTIVE
+    IdentityState.INACTIVE.value -> IdentityState.INACTIVE
+    IdentityState.INVALID.value -> IdentityState.INVALID
+    else -> {
+        logger.warn("Identity fetch returned invalid identity state: {}", state)
+        IdentityState.ACTIVE
+    }
+}
 
-    private fun failed(stringId: Int) = Failed(context.getString(stringId))
+/**
+ * Use this task for creating a new contact when no additional background work is required after the
+ * contact has been created. The [ContactResult] is directly passed to [onFinished]. See
+ * [AddOrUpdateContactBackgroundTask] for more information about contact creation.
+ */
+open class BasicAddOrUpdateContactBackgroundTask(
+    identity: String,
+    acquaintanceLevel: AcquaintanceLevel,
+    myIdentity: String,
+    apiConnector: APIConnector,
+    contactModelRepository: ContactModelRepository,
+    addContactRestrictionPolicy: AddContactRestrictionPolicy,
+    context: Context,
+    expectedPublicKey: ByteArray? = null,
+) : AddOrUpdateContactBackgroundTask<ContactResult>(
+    identity,
+    acquaintanceLevel,
+    myIdentity,
+    apiConnector,
+    contactModelRepository,
+    addContactRestrictionPolicy,
+    context,
+    expectedPublicKey
+) {
+    final override fun onContactAdded(result: ContactResult): ContactResult = result
 }
 
 /**
@@ -246,14 +345,34 @@ enum class AddContactRestrictionPolicy {
 }
 
 /**
- * The result type of adding a contact.
+ * The result type of adding or updating a contact. The result is either [ContactAvailable] or
+ * [Failed] or both.
+ */
+sealed interface ContactResult
+
+/**
+ * The contact is now available. This is the case when the contact has successfully been added or if
+ * the contact already existed.
+ */
+sealed interface ContactAvailable : ContactResult {
+    val contactModel: ContactModel
+}
+
+/**
+ * Adding or updating the contact failed. Note that this does not necessarily mean that the contact
+ * does not exist. E.g., this result can indicate that the provided public key does not match.
  */
-sealed interface ContactAddResult
+sealed class Failed(context: Context, @StringRes resId: Int) : ContactResult {
+    /**
+     * A translated error message that can be shown to the user.
+     */
+    val message: String = context.getString(resId)
+}
 
 /**
- * The contact has been added successfully. The new contact is provided.
+ * The contact has been newly created. The new contact is provided.
  */
-data class Success(val contactModel: ContactModel) : ContactAddResult
+data class ContactCreated(override val contactModel: ContactModel) : ContactAvailable
 
 /**
  * The contact already existed and has now been updated.
@@ -262,42 +381,65 @@ data class ContactModified(
     /**
      * The updated contact model.
      */
-    val contactModel: ContactModel,
+    override val contactModel: ContactModel,
     /**
-     * If true, the acquaintance level changed from [AcquaintanceLevel.GROUP] to
-     * [AcquaintanceLevel.DIRECT].
+     * If true, the acquaintance level changed.
      */
     val acquaintanceLevelChanged: Boolean,
     /**
      * If true, the verification level has changed to [VerificationLevel.FULLY_VERIFIED].
      */
     val verificationLevelChanged: Boolean,
-) : ContactAddResult
-
-/**
- * The result type when adding the contact has failed.
- */
-interface Error : ContactAddResult
+) : ContactAvailable
 
 /**
  * The contact already exists. This is only returned, if no expected public key is given and the
  * contact already exists. This means, that neither the verification level nor the acquaintance
  * level did change.
  */
-data class ContactExists(val contactModel: ContactModel) : Error
+data class ContactExists(override val contactModel: ContactModel) : ContactAvailable
 
 /**
  * The contact already exists and has been fully verified before.
  */
-data class AlreadyVerified(val contactModel: ContactModel) : Error
+data class AlreadyVerified(override val contactModel: ContactModel) : ContactAvailable
+
+/**
+ * The locally stored public key of the contact does not match the provided public key.
+ */
+class LocalPublicKeyMismatch(
+    override val contactModel: ContactModel,
+    context: Context,
+) : Failed(context, R.string.id_mismatch), ContactAvailable
+
+/**
+ * The contact did not exist locally and the fetched public key from the threema server does not
+ * match the provided public key. This also means, that the contact is not available locally.
+ */
+class RemotePublicKeyMismatch(context: Context) : Failed(context, R.string.id_mismatch)
+
+/**
+ * The provided identity is invalid and the contact could not be added.
+ */
+class InvalidThreemaId(context: Context) : Failed(context, R.string.invalid_threema_id)
+
+/**
+ * The provided identity is the same as the user's identity and therefore the contact could not be
+ * added.
+ */
+class UserIdentity(context: Context) : Failed(context, R.string.identity_already_exists)
+
+/**
+ * The contact could not be added due to a connection error.
+ */
+class ConnectionError(context: Context) : Failed(context, R.string.connection_error)
 
 /**
- * Adding the contact failed. The [message] contains a (translated) error message that can be shown
- * to the user.
+ * A general error occurred while adding the contact.
  */
-data class Failed(val message: String) : Error
+class GenericFailure(context: Context) : Failed(context, R.string.add_contact_failed)
 
 /**
  * The contact could not be added since adding contacts is restricted.
  */
-object PolicyViolation : Error
+class PolicyViolation(context: Context) : Failed(context, R.string.disabled_by_policy_short)

+ 244 - 0
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt

@@ -0,0 +1,244 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.asynctasks
+
+import androidx.annotation.WorkerThread
+import ch.threema.app.services.license.LicenseService
+import ch.threema.app.services.license.UserCredentials
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.executor.BackgroundTask
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.models.ContactModel
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.models.ContactModelData.Companion.getIdColorIndex
+import ch.threema.data.repositories.ContactCreateException
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.protocol.api.APIConnector
+import ch.threema.domain.protocol.api.work.WorkContact
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import com.neilalexander.jnacl.NaCl
+import kotlinx.coroutines.runBlocking
+import java.util.Date
+
+private val logger = LoggingUtil.getThreemaLogger("AddOrUpdateWorkContactBackgroundTask")
+
+/**
+ * Creates the provided contact if it does not exist. If it already exists, then it is updated in
+ * case it wasn't a direct work contact that is at least server verified.
+ *
+ * This task does not do anything if it isn't a work build.
+ */
+open class AddOrUpdateWorkContactBackgroundTask(
+    /**
+     * The work contact information of the contact that will be added or updated.
+     */
+    private val workContact: WorkContact,
+    /**
+     * The user's identity.
+     */
+    private val myIdentity: String,
+    /**
+     * The contact model repository.
+     */
+    private val contactModelRepository: ContactModelRepository,
+) : BackgroundTask<ContactModel?> {
+
+    /**
+     * Add the work contact if the identity belongs to a work contact.
+     *
+     * @return the newly inserted contact model or null if it could not be inserted
+     */
+    @WorkerThread
+    fun runSynchronously(): ContactModel? {
+        runBefore()
+
+        runInBackground().let {
+            runAfter(it)
+            return it
+        }
+    }
+
+    @WorkerThread
+    override fun runInBackground(): ContactModel? {
+        if (!ConfigUtils.isWorkBuild()) {
+            return null
+        }
+
+        if (workContact.publicKey.size != NaCl.PUBLICKEYBYTES) {
+            // Ignore work contact with invalid public key
+            logger.warn(
+                "Work contact has invalid public key of size {}",
+                workContact.publicKey.size
+            )
+            return null
+        }
+
+        if (workContact.threemaId == myIdentity) {
+            // Do not add our own ID as a contact
+            logger.warn("Cannot add the user's identity as work contact")
+            return null
+        }
+
+        val contactModel = contactModelRepository.getByIdentity(workContact.threemaId)
+
+        return if (contactModel != null) {
+            updateContact(contactModel)
+            contactModel
+        } else {
+            createContact()
+        }
+    }
+
+    @WorkerThread
+    private fun createContact(): ContactModel? {
+        return runBlocking {
+            try {
+                contactModelRepository.createFromLocal(
+                    ContactModelData(
+                        identity = workContact.threemaId,
+                        publicKey = workContact.publicKey,
+                        createdAt = Date(),
+                        firstName = workContact.firstName ?: "",
+                        lastName = workContact.lastName ?: "",
+                        nickname = null,
+                        colorIndex = getIdColorIndex(workContact.threemaId),
+                        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+                        workVerificationLevel = WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED,
+                        identityType = IdentityType.WORK, // TODO(ANDR-3159): Fetch identity type
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                        activityState = IdentityState.ACTIVE, // TODO(ANDR-3159): Fetch identity state
+                        syncState = ContactSyncState.INITIAL,
+                        featureMask = 0u, // TODO(ANDR-3159): Fetch feature mask
+                        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                        androidContactLookupKey = null,
+                        localAvatarExpires = null,
+                        isRestored = false,
+                        profilePictureBlobId = null,
+                        jobTitle = workContact.jobTitle,
+                        department = workContact.department,
+                    )
+                )
+            } catch (e: ContactCreateException) {
+                logger.error("Could not create work contact", e)
+                null
+            }
+        }
+    }
+
+    private fun updateContact(contactModel: ContactModel) {
+        val data = contactModel.data.value ?: run {
+            logger.error("Contact has already been deleted")
+            return
+        }
+
+        // Update first and last name if the contact is not synchronized
+        if (
+            data.androidContactLookupKey == null
+            && (workContact.firstName != null || workContact.lastName != null)
+        ) {
+            contactModel.setNameFromLocal(workContact.firstName ?: "", workContact.lastName ?: "")
+        }
+
+        // Update work verification level
+        contactModel.setWorkVerificationLevelFromLocal(WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED)
+
+        // Update acquaintance level
+        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT)
+
+        // Update verification level (except it would be a downgrade)
+        if (data.verificationLevel == VerificationLevel.UNVERIFIED) {
+            contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
+        }
+    }
+}
+
+/**
+ * This task fetches the information whether the given identity is a work identity. If it is, then
+ * a new work contact is created or the existing contact is updated.
+ *
+ * This task does not do anything if it is not a work build.
+ */
+class AddOrUpdateWorkIdentityBackgroundTask(
+    private val identity: String,
+    private val myIdentity: String,
+    private val licenseService: LicenseService<*>,
+    private val apiConnector: APIConnector,
+    private val contactModelRepository: ContactModelRepository,
+) : BackgroundTask<ContactModel?> {
+
+    /**
+     * Add the work contact if the identity belongs to a work contact.
+     *
+     * @return the newly inserted contact model or null if it could not be inserted
+     */
+    @WorkerThread
+    fun runSynchronously(): ContactModel? {
+        runBefore()
+
+        runInBackground().let {
+            runAfter(it)
+            return it
+        }
+    }
+
+    @WorkerThread
+    override fun runInBackground(): ContactModel? {
+        if (!ConfigUtils.isWorkBuild()) {
+            return null
+        }
+
+        val credentials = licenseService.loadCredentials()
+
+        if (credentials !is UserCredentials) {
+            logger.error("No user credentials available")
+            return null
+        }
+
+        val workContact = apiConnector.fetchWorkContacts(
+            credentials.username,
+            credentials.password,
+            arrayOf(identity),
+        ).firstOrNull() ?: run {
+            logger.info("Identity {} is not a work contact", identity)
+            return null
+        }
+
+        if (workContact.threemaId != identity) {
+            logger.error("Received different identity from server: {} instead of {}", workContact.threemaId, identity)
+            return null
+        }
+
+        return AddOrUpdateWorkContactBackgroundTask(
+            workContact,
+            myIdentity,
+            contactModelRepository,
+        ).runSynchronously()
+    }
+}

+ 0 - 119
app/src/main/java/ch/threema/app/asynctasks/DeleteContactAsyncTask.java

@@ -1,119 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-2024 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.asynctasks;
-
-import android.os.AsyncTask;
-
-import java.util.Iterator;
-import java.util.Set;
-
-import androidx.fragment.app.FragmentManager;
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.utils.DialogUtil;
-import ch.threema.storage.models.ContactModel;
-
-public class DeleteContactAsyncTask extends AsyncTask<Void, Integer, Integer> {
-	private static final String DIALOG_TAG_DELETE_CONTACT = "dc";
-
-	private final Set<ContactModel> contacts;
-	private final ContactService contactService;
-	private final FragmentManager fragmentManager;
-	private final DeleteContactsPostRunnable runOnCompletion;
-	private boolean cancelled = false;
-
-	public static class DeleteContactsPostRunnable implements Runnable {
-		protected Integer failed;
-
-		protected void setFailed(Integer failed) {
-			this.failed = failed;
-		}
-
-		@Override
-		public void run() {}
-	}
-
-	public DeleteContactAsyncTask(FragmentManager fragmentManager,
-	                              Set<ContactModel> contacts,
-	                              ContactService contactService,
-	                              DeleteContactsPostRunnable runOnCompletion) {
-
-		this.contacts = contacts;
-		this.contactService = contactService;
-		this.fragmentManager = fragmentManager;
-		this.runOnCompletion = runOnCompletion;
-	}
-
-	@Override
-	protected void onPreExecute() {
-		CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.deleting_contact, R.string.cancel, contacts.size());
-		dialog.setOnCancelListener((dialog1, which) -> cancelled = true);
-		dialog.show(fragmentManager, DIALOG_TAG_DELETE_CONTACT);
-
-		ThreemaApplication.onAndroidContactChangeLock.lock();
-	}
-
-	@Override
-	protected Integer doInBackground(Void... params) {
-		int failed = 0, i = 0;
-		Iterator<ContactModel> checkedItemsIterator = contacts.iterator();
-		while (checkedItemsIterator.hasNext() && !cancelled) {
-			publishProgress(i++);
-
-			ContactModel contact = checkedItemsIterator.next();
-
-			if (contact == null || !contactService.remove(contact)) {
-				failed++;
-			}
-		}
-		return failed;
-	}
-
-	@Override
-	protected void onProgressUpdate(Integer... index) {
-		DialogUtil.updateProgress(fragmentManager, DIALOG_TAG_DELETE_CONTACT, index[0] + 1);
-	}
-
-	@Override
-	protected void onPostExecute(Integer failed) {
-		DialogUtil.dismissDialog(fragmentManager, DIALOG_TAG_DELETE_CONTACT, true);
-
-		// note: ContactListener.onRemoved() will be triggered by ContactStore.removeContact()
-
-		if (runOnCompletion != null) {
-			runOnCompletion.setFailed(failed);
-			runOnCompletion.run();
-		}
-
-		ThreemaApplication.onAndroidContactChangeLock.unlock();
-	}
-
-	@Override
-	protected void onCancelled(Integer integer) {
-		super.onCancelled(integer);
-
-		// Release the lock just in case this async task was cancelled
-		ThreemaApplication.onAndroidContactChangeLock.unlock();
-	}
-}

+ 28 - 2
app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java

@@ -29,6 +29,7 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.io.IOException;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentManager;
 import ch.threema.app.R;
@@ -40,11 +41,13 @@ import ch.threema.app.services.PassphraseService;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
 import ch.threema.app.utils.ShortcutUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
+import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseNonceStore;
+import ch.threema.storage.DatabaseServiceNew;
 
 public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("DeleteIdentityAsyncTask");
@@ -54,6 +57,7 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 	private final ServiceManager serviceManager;
 	private final FragmentManager fragmentManager;
 	private final Runnable runOnCompletion;
+	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
 
 	public DeleteIdentityAsyncTask(@Nullable FragmentManager fragmentManager,
 	                               @Nullable Runnable runOnCompletion) {
@@ -80,7 +84,7 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			serviceManager.getMessageService().removeAll();
 			serviceManager.getConversationService().reset();
 			serviceManager.getGroupService().removeAll();
-			serviceManager.getContactService().removeAll();
+			backgroundExecutor.execute(getDeleteAllContactsTask());
 			try {
 				serviceManager.getUserService().removeIdentity();
 			} catch (Exception ignored) {}
@@ -157,6 +161,28 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 		}
 	}
 
+	@NonNull
+	private DeleteAllContactsBackgroundTask getDeleteAllContactsTask() throws ThreemaException {
+		return new DeleteAllContactsBackgroundTask(
+			serviceManager.getModelRepositories().getContacts(),
+			new DeleteContactServices(
+				serviceManager.getUserService(),
+				serviceManager.getContactService(),
+				serviceManager.getConversationService(),
+				serviceManager.getRingtoneService(),
+				serviceManager.getMutedChatsListService(),
+				serviceManager.getHiddenChatsListService(),
+				serviceManager.getProfilePicRecipientsService(),
+				serviceManager.getWallpaperService(),
+				serviceManager.getFileService(),
+				serviceManager.getExcludedSyncIdentitiesService(),
+				serviceManager.getDHSessionStore(),
+				serviceManager.getNotificationService(),
+				serviceManager.getDatabaseServiceNew()
+			)
+		);
+	}
+
 	private void secureDelete(File file) {
 		try {
 			SecureDeleteUtil.secureDelete(file);

+ 368 - 0
app/src/main/java/ch/threema/app/asynctasks/MarkContactAsDeletedBackgroundTask.kt

@@ -0,0 +1,368 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.asynctasks
+
+import android.content.Context
+import android.widget.Toast
+import androidx.annotation.CallSuper
+import androidx.fragment.app.FragmentManager
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy.KEEP
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy.REMOVE_LINK
+import ch.threema.app.asynctasks.ContactSyncPolicy.EXCLUDE
+import ch.threema.app.asynctasks.ContactSyncPolicy.INCLUDE
+import ch.threema.app.dialogs.CancelableHorizontalProgressDialog
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.ConversationService
+import ch.threema.app.services.DeadlineListService
+import ch.threema.app.services.FileService
+import ch.threema.app.services.IdListService
+import ch.threema.app.services.RingtoneService
+import ch.threema.app.services.UserService
+import ch.threema.app.services.WallpaperService
+import ch.threema.app.services.notification.NotificationService
+import ch.threema.app.utils.AndroidContactUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
+import ch.threema.app.utils.DialogUtil
+import ch.threema.app.utils.RuntimeUtil
+import ch.threema.app.utils.ShortcutUtil
+import ch.threema.app.utils.executor.BackgroundTask
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.stores.DHSessionStoreException
+import ch.threema.domain.stores.DHSessionStoreInterface
+import ch.threema.storage.DatabaseServiceNew
+import ch.threema.storage.models.ContactModel
+import java.lang.ref.WeakReference
+
+private const val DIALOG_TAG_DELETE_CONTACT = "dc"
+
+private val logger = LoggingUtil.getThreemaLogger("DeleteContactBackgroundTask")
+
+/**
+ * The collection of required services to delete a contact.
+ */
+data class DeleteContactServices(
+    val userService: UserService,
+    val contactService: ContactService,
+    val conversationService: ConversationService,
+    val ringtoneService: RingtoneService,
+    val mutedChatsListService: DeadlineListService,
+    val hiddenChatsListService: DeadlineListService,
+    val profilePicRecipientsService: IdListService,
+    val wallpaperService: WallpaperService,
+    val fileService: FileService,
+    val excludeService: IdListService,
+    val dhSessionStore: DHSessionStoreInterface,
+    val notificationService: NotificationService,
+    val databaseService: DatabaseServiceNew,
+)
+
+/**
+ * The policy whether to exclude the contact from future contact syncs.
+ */
+enum class ContactSyncPolicy {
+    /**
+     * The contact won't be excluded from contact sync and may therefore re-appear the next time
+     * contact synchronization is executed.
+     */
+    INCLUDE,
+
+    /**
+     * The contact will be excluded from contact sync and won't re-appear by synchronizing the
+     * contacts.
+     */
+    EXCLUDE,
+}
+
+/**
+ * The policy whether to remove the link to Threema in android contacts (raw contact). Note that
+ * modifying the android contacts may be rate limited. Therefore, we should be sparing with android
+ * contact modifications.
+ */
+enum class AndroidContactLinkPolicy {
+    /**
+     * We keep the contact linked to Threema. Note that when deleting individual contacts, we should
+     * not keep them linked. Use this option primarily when the Threema 'account' is deleted anyway.
+     */
+    KEEP,
+
+    /**
+     * The link to Threema will be removed.
+     */
+    REMOVE_LINK,
+}
+
+/**
+ * This background task should be used to delete a contact from local. Note that this just sets the
+ * acquaintance level to group, removes the conversation, and still shown notifications.
+ */
+open class MarkContactAsDeletedBackgroundTask(
+    protected val contacts: Set<String>,
+    private val contactModelRepository: ContactModelRepository,
+    protected val deleteContactServices: DeleteContactServices,
+    private val syncPolicy: ContactSyncPolicy,
+    private val androidLinkPolicy: AndroidContactLinkPolicy,
+) : BackgroundTask<Set<String>>, CancelableHorizontalProgressDialog.ProgressDialogClickListener {
+    private var cancelled = false
+
+    @CallSuper
+    override fun runBefore() {
+        // Note that we need to lock the android contact change lock on the UI thread in order to be
+        // able to unlock it again. The reason is that the runAfter method is run on the UI thread.
+        // TODO(ANDR-2327): This is a hack that may be removed when we have implemented contact
+        //  import.
+        RuntimeUtil.runOnUiThread {
+            ThreemaApplication.onAndroidContactChangeLock.lock()
+        }
+    }
+
+    override fun runInBackground(): Set<String> {
+        val deletedIdentities = mutableSetOf<String>()
+        for ((index, identity) in contacts.withIndex()) {
+            try {
+                if (cancelled) {
+                    return deletedIdentities
+                }
+                try {
+                    updateProgress(index)
+                } catch (e: Exception) {
+                    logger.error("Could not update progress", e)
+                }
+
+                val success = markContactAsDeleted(identity)
+
+                when (androidLinkPolicy) {
+                    REMOVE_LINK -> AndroidContactUtil.getInstance()
+                        .deleteThreemaRawContact(identity)
+
+                    KEEP -> Unit
+                }
+
+                if (success) {
+                    deletedIdentities.add(identity)
+                }
+            } catch (e: Exception) {
+                logger.error("Could not delete contact {}", identity, e)
+            }
+        }
+
+        return deletedIdentities
+    }
+
+    @CallSuper
+    override fun runAfter(result: Set<String>) {
+        ThreemaApplication.onAndroidContactChangeLock.unlock()
+
+        when (syncPolicy) {
+            EXCLUDE -> {
+                for (deletedIdentity in result) {
+                    deleteContactServices.excludeService.add(deletedIdentity)
+                }
+            }
+
+            INCLUDE -> Unit
+        }
+
+        onFinished()
+    }
+
+    /**
+     * This method is run after the contacts have been deleted. Note that it is run independent of
+     * how many of them could be deleted.
+     */
+    protected open fun onFinished() = Unit
+
+    override fun onCancel(tag: String?, `object`: Any?) {
+        cancelled = true
+    }
+
+    private fun markContactAsDeleted(identity: String): Boolean {
+        val contactModel = contactModelRepository.getByIdentity(identity) ?: return false
+
+        // Note that the conversation needs to be deleted before the downgrade due to the old model
+        // that may still be cached and will be stored as the last update flag will be reset.
+        deleteContactServices.conversationService.delete(identity)
+
+        // Remove the old contact model from the cache to reduce the risk to it being stored to the
+        // database.
+        deleteContactServices.contactService.removeFromCache(identity)
+
+        // Cancel notifications
+        deleteContactServices.notificationService.cancel(identity)
+
+        contactModel.setAcquaintanceLevelFromLocal(ContactModel.AcquaintanceLevel.GROUP)
+
+        return true
+    }
+
+    /**
+     * This method is run before the deletion of a contact. Note that this is also called if the
+     * contact could not be deleted.
+     */
+    protected open fun updateProgress(progress: Int) {
+        // Nothing to do here
+    }
+}
+
+/**
+ * Use this task when all contacts should be deleted. Note that this does not remove the contact
+ * links as they may be rate limited. Therefore, this task should only be used when the account is
+ * deleted afterwards anyways. Otherwise some links in the android contacts may remain.
+ *
+ * Note: This must only be called if the app is being completely erased. If this task is executed
+ * while MD is enabled, this will lead to a de-sync.
+ */
+open class DeleteAllContactsBackgroundTask(
+    contactModelRepository: ContactModelRepository,
+    deleteContactServices: DeleteContactServices,
+) : MarkContactAsDeletedBackgroundTask(
+    deleteContactServices.contactService.all.map(ContactModel::identity).toSet(),
+    contactModelRepository,
+    deleteContactServices,
+    INCLUDE,
+    KEEP
+) {
+    override fun runAfter(result: Set<String>) {
+        if (contacts.size != result.size) {
+            logger.warn("Deleted {} contacts instead of {}.", result.size, contacts.size)
+        }
+
+        // Delete all contacts
+        deleteContactServices.databaseService.contactModelFactory.deleteAll()
+        contacts.forEach(::cleanContactLeftovers)
+
+        super.runAfter(result)
+    }
+
+    /**
+     * This cleans leftovers from a removed contact. This includes:
+     * - Contact Service Cache
+     * - Conversation
+     * - Custom ringtone setting
+     * - Mute preference
+     * - Hidden chat preference
+     * - Profile picture receive preference
+     * - Custom wallpaper
+     * - Android contact avatar
+     * - Contact avatar
+     * - Contact photo
+     * - Cancel notifications
+     * - Share target shortcut
+     * - Pinned shortcut
+     * - FS sessions
+     *
+     * This should only be called after the contact was successfully removed from the database.
+     */
+    private fun cleanContactLeftovers(identity: String) {
+        deleteContactServices.contactService.removeFromCache(identity)
+        deleteContactServices.conversationService.delete(identity)
+
+        val uniqueIdString = ContactUtil.getUniqueIdString(identity)
+
+        deleteContactServices.ringtoneService.removeCustomRingtone(uniqueIdString)
+        deleteContactServices.mutedChatsListService.remove(uniqueIdString)
+        deleteContactServices.hiddenChatsListService.remove(uniqueIdString)
+        deleteContactServices.profilePicRecipientsService.remove(identity)
+        deleteContactServices.wallpaperService.removeWallpaper(uniqueIdString)
+        deleteContactServices.fileService.removeAndroidDefinedProfilePicture(identity)
+        deleteContactServices.fileService.removeUserDefinedProfilePicture(identity)
+        deleteContactServices.fileService.removeContactDefinedProfilePicture(identity)
+        deleteContactServices.notificationService.cancel(identity)
+        ShortcutUtil.deleteShareTargetShortcut(uniqueIdString)
+        ShortcutUtil.deletePinnedShortcut(uniqueIdString)
+
+        val myIdentity = deleteContactServices.userService.identity
+        try {
+            deleteContactServices.dhSessionStore.deleteAllDHSessions(myIdentity, identity)
+        } catch (e: DHSessionStoreException) {
+            logger.error("Could not delete all DH sessions with {}", identity, e)
+        }
+    }
+}
+
+open class DialogMarkContactAsDeletedBackgroundTask(
+    private val fragmentManager: FragmentManager,
+    private val contextRef: WeakReference<Context>,
+    contacts: Set<String>,
+    contactModelRepository: ContactModelRepository,
+    deleteContactServices: DeleteContactServices,
+    syncPolicy: ContactSyncPolicy,
+    linkPolicy: AndroidContactLinkPolicy,
+) : MarkContactAsDeletedBackgroundTask(
+    contacts,
+    contactModelRepository,
+    deleteContactServices,
+    syncPolicy,
+    linkPolicy,
+) {
+    override fun runBefore() {
+        val dialog: CancelableHorizontalProgressDialog =
+            CancelableHorizontalProgressDialog.newInstance(
+                R.string.deleting_contact,
+                contacts.size
+            )
+        dialog.show(fragmentManager, DIALOG_TAG_DELETE_CONTACT)
+
+        super.runBefore()
+    }
+
+    override fun runAfter(result: Set<String>) {
+        super.runAfter(result)
+
+        DialogUtil.dismissDialog(
+            fragmentManager,
+            DIALOG_TAG_DELETE_CONTACT,
+            true
+        )
+
+        val context = contextRef.get() ?: return
+
+        val failed = contacts.size - result.size
+        if (failed > 0) {
+            Toast.makeText(
+                context,
+                ConfigUtils.getSafeQuantityString(
+                    ThreemaApplication.getAppContext(),
+                    R.plurals.some_contacts_not_deleted,
+                    failed,
+                    failed
+                ),
+                Toast.LENGTH_LONG
+            ).show()
+        } else {
+            if (result.size > 1) {
+                Toast.makeText(context, R.string.contacts_deleted, Toast.LENGTH_LONG).show()
+            } else {
+                Toast.makeText(context, R.string.contact_deleted, Toast.LENGTH_LONG).show()
+            }
+        }
+    }
+
+    override fun updateProgress(progress: Int) {
+        RuntimeUtil.runOnUiThread {
+            DialogUtil.updateProgress(fragmentManager, DIALOG_TAG_DELETE_CONTACT, progress)
+        }
+    }
+}

+ 71 - 0
app/src/main/java/ch/threema/app/asynctasks/SendToSupportBackgroundTask.kt

@@ -0,0 +1,71 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.asynctasks
+
+import android.content.Context
+import ch.threema.app.preference.SettingsAdvancedOptionsFragment.THREEMA_SUPPORT_IDENTITY
+import ch.threema.app.services.ContactServiceImpl
+import ch.threema.data.models.ContactModel
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.protocol.api.APIConnector
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+
+/**
+ * The result of sending some messages to the support.
+ */
+enum class SendToSupportResult {
+    SUCCESS,
+    FAILED,
+}
+
+/**
+ * This class can be used to send messages to the support. It creates the support contact if not
+ * already available.
+ */
+abstract class SendToSupportBackgroundTask(
+    myIdentity: String,
+    apiConnector: APIConnector,
+    contactModelRepository: ContactModelRepository,
+    context: Context,
+) : AddOrUpdateContactBackgroundTask<SendToSupportResult>(
+    THREEMA_SUPPORT_IDENTITY,
+    AcquaintanceLevel.DIRECT,
+    myIdentity,
+    apiConnector,
+    contactModelRepository,
+    AddContactRestrictionPolicy.IGNORE,
+    context,
+    ContactServiceImpl.SUPPORT_PUBLIC_KEY,
+) {
+    final override fun onContactAdded(result: ContactResult): SendToSupportResult {
+        return when (result) {
+            is ContactAvailable -> onSupportAvailable(result.contactModel)
+            else -> SendToSupportResult.FAILED
+        }
+    }
+
+    /**
+     * This method is called when the support contact is available. It is run on a background
+     * thread.
+     */
+    abstract fun onSupportAvailable(contactModel: ContactModel): SendToSupportResult
+}

+ 98 - 28
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -97,10 +97,12 @@ import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ZipUtil;
 import ch.threema.base.ThreemaException;
+import ch.threema.base.crypto.HashedNonce;
+import ch.threema.base.crypto.NonceFactory;
+import ch.threema.base.crypto.NonceScope;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.identitybackup.IdentityBackupGenerator;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -133,6 +135,7 @@ public class BackupService extends Service {
 	private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
 	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
 	private static final int NONCES_PER_STEP = 50;
+	private static final int NONCES_CHUNK_SIZE = 2500;
 
 	private static final String EXTRA_ID_CANCEL = "cnc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
@@ -163,7 +166,7 @@ public class BackupService extends Service {
 	private PreferenceService preferenceService;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManagerCompat notificationManagerCompat;
-	private DatabaseNonceStore databaseNonceStore;
+	private NonceFactory nonceFactory;
 
 	private NotificationCompat.Builder notificationBuilder;
 
@@ -310,6 +313,7 @@ public class BackupService extends Service {
 			userService = serviceManager.getUserService();
 			ballotService = serviceManager.getBallotService();
 			preferenceService = serviceManager.getPreferenceService();
+			nonceFactory = serviceManager.getNonceFactory();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 			safeStopSelf();
@@ -317,7 +321,6 @@ public class BackupService extends Service {
 		}
 
 		notificationManagerCompat = NotificationManagerCompat.from(this);
-		databaseNonceStore = new DatabaseNonceStore(this, serviceManager.getIdentityStore());
 	}
 
 	@Override
@@ -411,7 +414,7 @@ public class BackupService extends Service {
 
 			if (this.config.backupNonces()) {
 				progress += 1;
-				long nonceCount = this.databaseNonceStore.getCount();
+				long nonceCount = nonceFactory.getCount(NonceScope.CSP) + nonceFactory.getCount(NonceScope.D2D);
 				long nonceProgress = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
 				progress += nonceProgress;
 			}
@@ -484,8 +487,9 @@ public class BackupService extends Service {
 		return this.next(subject, 1);
 	}
 
-	private boolean next(String subject, int factor) {
-		this.currentProgressStep += (this.currentProgressStep < this.processSteps ? factor : 0);
+	private boolean next(String subject, int increment) {
+		logger.debug("step [{}]", subject);
+		this.currentProgressStep += (this.currentProgressStep < this.processSteps ? increment : 0);
 		this.handleProgress();
 		return !isCanceled;
 	}
@@ -545,7 +549,7 @@ public class BackupService extends Service {
 			try {
 				ZipUtil.addZipStream(
 					zipOutputStream,
-					this.fileService.getContactAvatarStream(contactService.getMe().getIdentity()),
+					this.fileService.getUserDefinedProfilePictureStream(contactService.getMe().getIdentity()),
 					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
 					false
 				);
@@ -621,7 +625,7 @@ public class BackupService extends Service {
 							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
 								ZipUtil.addZipStream(
 									zipOutputStream,
-									this.fileService.getContactAvatarStream(contactModel.getIdentity()),
+									this.fileService.getUserDefinedProfilePictureStream(contactModel.getIdentity()),
 									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
 									false
 								);
@@ -634,7 +638,7 @@ public class BackupService extends Service {
 						try {
 							ZipUtil.addZipStream(
 								zipOutputStream,
-								this.fileService.getContactPhotoStream(contactModel.getIdentity()),
+								this.fileService.getContactDefinedProfilePictureStream(contactModel.getIdentity()),
 								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
 								false
 							);
@@ -732,6 +736,7 @@ public class BackupService extends Service {
 			Tags.TAG_GROUP_DESC,
 			Tags.TAG_GROUP_DESC_TIMESTAMP,
 			Tags.TAG_GROUP_UID,
+			Tags.TAG_GROUP_USER_STATE,
 		};
 		final String[] groupMessageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
@@ -807,6 +812,7 @@ public class BackupService extends Service {
 						.write(Tags.TAG_GROUP_DESC, groupModel.getGroupDesc())
 						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
 						.write(Tags.TAG_GROUP_UID, groupUid)
+						.write(Tags.TAG_GROUP_USER_STATE, groupModel.getUserState() != null ? groupModel.getUserState().value : 0)
 						.write();
 
 					//check if the group have a photo
@@ -1091,15 +1097,24 @@ public class BackupService extends Service {
 			return false;
 		}
 
-		try (ByteArrayOutputStream outputStreamBuffer = new ByteArrayOutputStream()) {
-			writeNonces(outputStreamBuffer);
-			// Write nonces to zip *after* the CSVWriter has been closed (and therefore flushed)
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(outputStreamBuffer.toByteArray()),
-				Tags.NONCE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
+		try {
+			int nonceCountCsp = writeNoncesToBackup(
+				NonceScope.CSP,
+				Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
+				zipOutputStream
+			);
+
+			int nonceCountD2d = writeNoncesToBackup(
+				NonceScope.D2D,
+				Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
+				zipOutputStream
 			);
+
+			writeNonceCounts(nonceCountCsp, nonceCountD2d, zipOutputStream);
+
+			int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
+			int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
+			next("Backup nonce", (int) Math.ceil(((double) remainingCsp + remainingD2d) / NONCES_PER_STEP));
 			logger.info("Nonce backup completed");
 		} catch (IOException | ThreemaException e) {
 			logger.error("Error with byte array output stream", e);
@@ -1109,36 +1124,91 @@ public class BackupService extends Service {
 		return true;
 	}
 
-	private void writeNonces(
+	private void writeNonceCounts(
+		int nonceCountCsp,
+		int nonceCountD2d,
+		@NonNull ZipOutputStream zipOutputStream
+	) throws IOException, ThreemaException {
+		logger.info("Write nonce counts to backup (CSP: {}, D2D: {})", nonceCountCsp, nonceCountD2d);
+		final String[] nonceCountHeader = new String[]{ Tags.TAG_NONCE_COUNT_CSP, Tags.TAG_NONCE_COUNT_D2D };
+		try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+			try (
+				OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
+				CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceCountHeader)
+			) {
+				csvWriter.createRow()
+					.write(Tags.TAG_NONCE_COUNT_CSP, nonceCountCsp)
+					.write(Tags.TAG_NONCE_COUNT_D2D, nonceCountD2d)
+					.write();
+			}
+			ZipUtil.addZipStream(
+				zipOutputStream,
+				new ByteArrayInputStream(outputStream.toByteArray()),
+				Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX,
+				false
+			);
+		}
+	}
+
+	private int writeNoncesToBackup(
+		@NonNull NonceScope scope,
+		@NonNull String fileName,
+		@NonNull ZipOutputStream zipOutputStream
+	) throws ThreemaException, IOException {
+		try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+			int count = writeNonces(scope, outputStream);
+			// Write nonces to zip *after* the CSVWriter has been closed (and therefore flushed)
+			ZipUtil.addZipStream(
+				zipOutputStream,
+				new ByteArrayInputStream(outputStream.toByteArray()),
+				fileName,
+				true
+			);
+			return count;
+		}
+	}
+
+	private int writeNonces(
+		@NonNull NonceScope scope,
 		@NonNull ByteArrayOutputStream outputStream
 	) throws ThreemaException, IOException {
+		logger.info("Backup {} nonces", scope);
 		final String[] nonceHeader = new String[]{Tags.TAG_NONCES};
+		int backedUpNonceCount = 0;
 		try (
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
 		) {
 			long start = System.currentTimeMillis();
-			long nonceCount = databaseNonceStore.getCount();
-			long numChunks = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
-			List<byte[]> nonces = new ArrayList<>(NONCES_PER_STEP);
+			long nonceCount = nonceFactory.getCount(scope);
+			long numChunks = (long) Math.ceil((double) nonceCount / NONCES_CHUNK_SIZE);
+			List<HashedNonce> nonces = new ArrayList<>(NONCES_CHUNK_SIZE);
 			for (int i = 0; i < numChunks; i++) {
-				databaseNonceStore.addHashedNonceChunk(NONCES_PER_STEP, NONCES_PER_STEP * i, nonces);
-				for (byte[] nonceBytes : nonces) {
-					String nonce = Utils.byteArrayToHexString(nonceBytes);
+				nonceFactory.addHashedNoncesChunk(
+					scope,
+					NONCES_CHUNK_SIZE,
+					NONCES_CHUNK_SIZE * i,
+					nonces
+				);
+				for (HashedNonce hashedNonce : nonces) {
+					String nonce = Utils.byteArrayToHexString(hashedNonce.getBytes());
 					csvWriter.createRow().write(Tags.TAG_NONCES, nonce).write();
 				}
+				int increment = nonces.size() / NONCES_PER_STEP;
+				backedUpNonceCount += nonces.size();
 				nonces.clear();
-				if (!next("Backup nonce")) {
-					return;
+				if (!next("Backup nonce", increment)) {
+					return backedUpNonceCount;
 				}
 				// Periodically log nonce backup progress for debugging purposes
-				if ((i & 2047) == 0) {
+				if ((i % 10) == 0 || i == numChunks) {
 					logger.info("Nonce backup progress: {} of {} chunks backed up", i, numChunks);
 				}
 			}
 			long end = System.currentTimeMillis();
-			logger.info("Created row for all nonces in {} ms", end - start);
+			logger.info("Created backup for all {} nonces in {} ms", scope, end - start);
 		}
+		return backedUpNonceCount;
 	}
 
 	/**

+ 147 - 29
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -87,13 +87,14 @@ import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
+import ch.threema.base.crypto.NonceFactory;
+import ch.threema.base.crypto.NonceScope;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.models.GroupId;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -105,6 +106,7 @@ import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupMemberModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.GroupModel.UserState;
 import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
@@ -120,6 +122,8 @@ import ch.threema.storage.models.data.media.FileDataModel;
 
 import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
 import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
+import static ch.threema.storage.models.GroupModel.UserState.LEFT;
+import static ch.threema.storage.models.GroupModel.UserState.MEMBER;
 
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
@@ -145,7 +149,7 @@ public class RestoreService extends Service {
 	private PreferenceService preferenceService;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManagerCompat notificationManagerCompat;
-	private DatabaseNonceStore databaseNonceStore;
+	private NonceFactory nonceFactory;
 
 	private NotificationCompat.Builder notificationBuilder;
 
@@ -174,6 +178,7 @@ public class RestoreService extends Service {
 	private static final int STEP_SIZE_GROUP_AVATARS = 50;
 	private static final int STEP_SIZE_MEDIA = 25; // per media file
 	private static final int NONCES_PER_STEP = 50;
+	private static final int NONCES_CHUNK_SIZE = 10_000;
 
 	private long stepSizeTotal = (long) STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GROUP_AVATARS;
 
@@ -295,7 +300,7 @@ public class RestoreService extends Service {
 			conversationService = serviceManager.getConversationService();
 			userService = serviceManager.getUserService();
 			preferenceService = serviceManager.getPreferenceService();
-			databaseNonceStore = new DatabaseNonceStore(this, serviceManager.getIdentityStore());
+			nonceFactory = serviceManager.getNonceFactory();
 		} catch (Exception e) {
 			logger.error("Could not instantiate all required services", e);
 			stopSelf();
@@ -504,10 +509,6 @@ public class RestoreService extends Service {
 				// Restore nonces
 				logger.info("Restoring nonces");
 				int nonceCount = restoreNonces(fileHeaders);
-				if (nonceCount < 0) {
-					logger.error("Restoring nonces failed ({})", nonceCount);
-					//continue anyway!
-				}
 
 				//contacts, groups and distribution lists
 				logger.info("Restoring main files (contacts, groups, distribution lists)");
@@ -602,7 +603,7 @@ public class RestoreService extends Service {
 		try (
 			InputStream is = zipFile.getInputStream(settingsHeader);
 			InputStreamReader inputStreamReader = new InputStreamReader(is);
-			CSVReader csvReader = new CSVReader(inputStreamReader)
+			CSVReader csvReader = new CSVReader(inputStreamReader, false)
 		) {
 			RestoreSettings settings = new RestoreSettings();
 			settings.parse(csvReader.readAll());
@@ -659,28 +660,112 @@ public class RestoreService extends Service {
 		return true;
 	}
 
+	/**
+	 * Attempt to restore the nonces. If restoring of nonces fails for some reason 0 is returned.
+	 * Since we continue anyway, there is no need to distinguish between zero restored nonces and
+	 * a failure.
+	 */
 	private int restoreNonces(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
-		FileHeader nonceFileHeader = null;
+		if (!writeToDb) {
+			// If not writing to the database only the count of nonces is required.
+			// Try to read optional nonces count file if present in backup.
+			logger.info("Get nonce counts");
+			int nonceCount = readNonceCounts(fileHeaders);
+			if (nonceCount >= 0) {
+				// If the nonce count is available return it and skip reading the whole nonces file.
+				logger.info("{} nonces in backup", nonceCount);
+				return nonceCount;
+			} else {
+				logger.info("Count nonces in backup.");
+			}
+		}
+
+		int nonceCountCsp = restoreNonces(
+			NonceScope.CSP,
+			Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
+			fileHeaders
+		);
+
+		int nonceCountD2d = restoreNonces(
+			NonceScope.D2D,
+			Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
+			fileHeaders
+		);
+
+		int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
+		int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
+		int remainingNonceProgress = remainingCsp + remainingD2d;
+		logger.debug("Remaining nonce progress: {}", remainingNonceProgress);
+		updateProgress((long) Math.ceil((double) remainingNonceProgress / NONCES_PER_STEP));
+
+		return nonceCountCsp + nonceCountD2d;
+	}
+
+	/**
+	 * Read the counts from the nonce counts file if available.
+	 *
+	 * @return the count, or -1 if the count could not be read from some reason.
+	 */
+	private int readNonceCounts(List<FileHeader> fileHeaders) throws IOException {
+		FileHeader nonceCountFileHeader = getFileHeader(Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX, fileHeaders);
+		if (nonceCountFileHeader == null) {
+			logger.info("No nonce count file available in backup");
+			return -1;
+		}
+		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceCountFileHeader);
+		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+		     CSVReader csvReader = new CSVReader(inputStreamReader, true)
+		) {
+			CSVRow row = csvReader.readNextRow();
+			if (row == null) {
+				logger.warn("Could not read nonce count. File is empty.");
+				return -1;
+			}
+			return row.getInteger(Tags.TAG_NONCE_COUNT_CSP) + row.getInteger(Tags.TAG_NONCE_COUNT_D2D);
+		} catch (ThreemaException | NumberFormatException e) {
+			logger.warn("Could not read nonce count", e);
+			return -1;
+		}
+	}
+
+	/**
+	 * Get the file header where the file name matches the provided exactFileName.
+	 *
+	 * @param exactFileName The file name that is matched against
+	 * @param fileHeaders The file headers that are scanned
+	 * @return The first matching file header or null if none matches
+	 */
+	@Nullable
+	private FileHeader getFileHeader(@NonNull String exactFileName, List<FileHeader> fileHeaders) {
 		for (FileHeader fileHeader : fileHeaders) {
-			String fileName = fileHeader.getFileName();
-			if (fileName != null && fileName.startsWith(Tags.NONCE_FILE_NAME)) {
-				nonceFileHeader = fileHeader;
-				break;
+			if (exactFileName.equals(fileHeader.getFileName())) {
+				return fileHeader;
 			}
 		}
+		logger.info("No file header for '{}' found", exactFileName);
+		return null;
+	}
+
+	private int restoreNonces(
+		@NonNull NonceScope scope,
+		@NonNull String nonceBackupFile,
+		@NonNull List<FileHeader> fileHeaders
+	) throws IOException, RestoreCanceledException {
+		logger.info("Restore {} nonces", scope);
+		final FileHeader nonceFileHeader = getFileHeader(nonceBackupFile, fileHeaders);
 		if (nonceFileHeader == null) {
 			logger.info("Nonce file header is null");
-			return -1;
+			return 0;
 		}
 
 		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
 		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-		     CSVReader csvReader = new CSVReader(inputStreamReader, false)
+		     CSVReader csvReader = new CSVReader(inputStreamReader, true)
 		) {
-			int nonceProgressCount = 0;
 			int nonceCount = 0;
 			boolean success = true;
 			CSVRow row;
+			List<byte[]> nonceBytes = new ArrayList<>(NONCES_CHUNK_SIZE);
 			while ((row = csvReader.readNextRow()) != null) {
 				try {
 					// Note that currently there is only one nonce per row, and therefore we do
@@ -689,27 +774,42 @@ public class RestoreService extends Service {
 					String[] nonces = row.getStrings(Tags.TAG_NONCES);
 					nonceCount += nonces.length;
 					if (writeToDb) {
-						success &= databaseNonceStore.insertHashedNonces(nonces);
-						nonceProgressCount += nonces.length;
-						if (nonceProgressCount >= NONCES_PER_STEP) {
-							long increment = nonceProgressCount / NONCES_PER_STEP;
-							updateProgress(increment);
-							nonceProgressCount -= increment * NONCES_PER_STEP;
+						for (String nonce : nonces) {
+							nonceBytes.add(Utils.hexStringToByteArray(nonce));
+							if (nonceBytes.size() >= NONCES_CHUNK_SIZE) {
+								success &= insertNonces(scope, nonceBytes);
+								nonceBytes.clear();
+							}
 						}
 					}
 				} catch (ThreemaException e) {
-					logger.error("Could not insert nonces");
-					return -1;
+					logger.error("Could not insert nonces", e);
+					return 0;
 				}
 			}
+			if (!nonceBytes.isEmpty()) {
+				success &= insertNonces(scope, nonceBytes);
+			}
 			if (success) {
+				logger.info("Restored {} {} nonces", nonceCount, scope);
 				return nonceCount;
 			} else {
-				return -1;
+				logger.warn("Restoring {} nonces was not successfull", scope);
+				return 0;
 			}
 		}
 	}
 
+	private boolean insertNonces(
+		@NonNull NonceScope scope,
+		@NonNull List<byte[]> nonces
+	) throws RestoreCanceledException {
+		logger.debug("Write {} nonces to database", nonces.size());
+		boolean success = nonceFactory.insertHashedNoncesJava(scope, nonces);
+		updateProgress(nonces.size() / NONCES_PER_STEP);
+		return success;
+	}
+
 	/**
 	 * restore all avatars and profile pics
 	 */
@@ -972,7 +1072,7 @@ public class RestoreService extends Service {
 
 		// Set contact avatar
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeContactAvatar(
+			return fileService.writeUserDefinedProfilePicture(
 				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream)
 			);
@@ -1001,7 +1101,7 @@ public class RestoreService extends Service {
 
 		// Set contact profile picture
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeContactPhoto(
+			return fileService.writeContactDefinedProfilePicture(
 				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream));
 		} catch (Exception e) {
@@ -1029,10 +1129,24 @@ public class RestoreService extends Service {
 					restoreResult.incContactSuccess();
 				}
 
-				List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
 				if (writeToDb) {
+					String myIdentity = userService.getIdentity();
+					boolean isInMemberList = false;
+
+					List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
+
 					for (GroupMemberModel groupMemberModel : groupMemberModels) {
-						databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
+						if (!myIdentity.equals(groupMemberModel.getIdentity())) {
+							databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
+						} else {
+							isInMemberList = true;
+						}
+					}
+					if (restoreSettings.getVersion() < 25) {
+						// In this case the group user state is not included in the backup and we
+						// need to determine the state based on the group member list.
+						groupModel.setUserState(isInMemberList ? MEMBER : LEFT);
+						databaseServiceNew.getGroupModelFactory().update(groupModel);
 					}
 				}
 			} catch (Exception x) {
@@ -1179,6 +1293,10 @@ public class RestoreService extends Service {
 			groupModel.setLastUpdate(row.getDate(Tags.TAG_GROUP_LAST_UPDATE));
 		}
 
+		if (restoreSettings.getVersion() >= 25) {
+			groupModel.setUserState(UserState.valueOf(row.getInteger(Tags.TAG_GROUP_USER_STATE)));
+		}
+
 		return groupModel;
 	}
 

+ 2 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java

@@ -44,8 +44,9 @@ public class RestoreSettings {
 	 * 22: add lastUpdate and remove isQueued flag
 	 * 23: add editedAt
 	 * 24: add deletedAt
+	 * 25: add group user state
 	 */
-	public static final int CURRENT_VERSION = 24;
+	public static final int CURRENT_VERSION = 25;
 	private int version;
 
 	public RestoreSettings(int version) {

+ 7 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

@@ -36,7 +36,10 @@ public abstract class Tags {
 	public static final String CONTACT_AVATAR_FILE_PREFIX = "contact_avatar_";
 	public static final String CONTACT_AVATAR_FILE_SUFFIX_ME = "me";
 	public static final String CONTACT_PROFILE_PIC_FILE_PREFIX = "contact_profile_pic_";
-	public static final String NONCE_FILE_NAME = "nonces";
+	// do not rename csp nonces file to preserve backwards compatibility
+	public static final String NONCE_FILE_NAME_CSP = "nonces";
+	public static final String NONCE_FILE_NAME_D2D = "nonces_d2d";
+	public static final String NONCE_COUNTS_FILE = "nonce_counts";
 
 	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX = "distribution_list_message_media_";
 	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "distribution_list_thumbnail_";
@@ -51,6 +54,8 @@ public abstract class Tags {
 	public static final String TAG_INFO_VERSION = "version";
 
 	public static final String TAG_NONCES = "nonces";
+	public static final String TAG_NONCE_COUNT_CSP = "csp";
+	public static final String TAG_NONCE_COUNT_D2D = "d2d";
 
 	public static final String TAG_CONTACT_IDENTITY = "identity";
 	public static final String TAG_CONTACT_FIRST_NAME = "firstname";
@@ -75,6 +80,7 @@ public abstract class Tags {
 	public static final String TAG_GROUP_DESC = "groupDesc";
 	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
 	public static final String TAG_GROUP_UID = "group_uid";
+	public static final String TAG_GROUP_USER_STATE = "user_state";
 
 	public static final String TAG_MESSAGE_UID = "uid";
 	public static final String TAG_MESSAGE_IDENTITY = "identity";

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

@@ -80,7 +80,7 @@ class QRCodeAnalyzer(private val onDecodeQRCode: (decodeQRCodeState: DecodeQRCod
                     try {
                         decode(imageProxy, data)
                     } catch (e: Exception) {
-                        logger.info("Decode error for inverted QR Code")
+                        logger.debug("Decode error for inverted QR Code")
                     }
                 } catch (e: Exception) {
                     logger.error("Scanning error", e)

+ 19 - 7
app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt

@@ -33,7 +33,12 @@ import android.view.WindowManager
 import android.widget.ImageView
 import android.widget.TextView
 import android.widget.Toast
-import androidx.camera.core.*
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
 import androidx.camera.lifecycle.ProcessCameraProvider
 import androidx.camera.view.PreviewView
 import androidx.core.content.ContextCompat
@@ -42,7 +47,6 @@ import ch.threema.app.ThreemaApplication
 import ch.threema.app.activities.ThreemaActivity
 import ch.threema.app.services.QRCodeServiceImpl.QRCodeColor
 import ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ANY
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.SoundUtil
 import ch.threema.base.utils.LoggingUtil
 import java.util.concurrent.ExecutorService
@@ -98,11 +102,19 @@ class QRScannerActivity : ThreemaActivity() {
                 getString(R.string.msg_default_status)
             }
         }
-        findViewById<TextView>(R.id.hint_view).text = hint
-        if (qrColor == QR_TYPE_ANY) {
-            findViewById<ImageView>(R.id.camera_viewfinder).visibility = View.GONE
-        } else {
-            findViewById<ImageView>(R.id.camera_viewfinder).setColorFilter(qrColor)
+
+        // set hint text
+        findViewById<TextView>(R.id.hint_view)?.let {
+            it.text = hint
+        }
+
+        // set viewfinder color
+        findViewById<ImageView>(R.id.camera_viewfinder)?.let {
+            if (qrColor == QR_TYPE_ANY) {
+                it.visibility = View.GONE
+            } else {
+                it.setColorFilter(qrColor)
+            }
         }
 
         // Wait for the views to be properly laid out

+ 2 - 9
app/src/main/java/ch/threema/app/compose/common/interop/ComposeJavaBridge.kt

@@ -22,8 +22,6 @@
 package ch.threema.app.compose.common.interop
 
 import androidx.compose.ui.platform.ComposeView
-import androidx.preference.PreferenceManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.activities.MessageDetailsUiModel
 import ch.threema.app.activities.MessageTimestampsUiModel
 import ch.threema.app.activities.toUiModel
@@ -40,7 +38,7 @@ object ComposeJavaBridge {
         messageDetailsUiModel: MessageDetailsUiModel,
     ) {
         composeView.setContent {
-            ThreemaTheme(dynamicColor = shouldUseDynamicColors()) {
+            ThreemaTheme {
                 CombinedMessageDetailsList(
                     messageTimestampsUiModel,
                     messageDetailsUiModel
@@ -56,7 +54,7 @@ object ComposeJavaBridge {
     ) {
         val messageBubbleUiState = model.toUiModel(myIdentity)
         composeView.setContent {
-            ThreemaTheme(dynamicColor = shouldUseDynamicColors()) {
+            ThreemaTheme {
                 MessageBubble(
                     text = messageBubbleUiState.text,
                     isOutbox = messageBubbleUiState.isOutbox,
@@ -64,9 +62,4 @@ object ComposeJavaBridge {
             }
         }
     }
-
-    private fun shouldUseDynamicColors(): Boolean {
-        val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext())
-        return sharedPreferences.getBoolean("pref_dynamic_color", false)
-    }
 }

+ 10 - 5
app/src/main/java/ch/threema/app/compose/theme/ThreemaTheme.kt

@@ -33,10 +33,13 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalView
 import androidx.core.view.WindowCompat
+import androidx.preference.PreferenceManager
+import ch.threema.app.ThreemaApplication
 import ch.threema.app.compose.theme.color.ColorsDark
 import ch.threema.app.compose.theme.color.ColorsLight
 import ch.threema.app.compose.theme.color.CustomColor
@@ -46,17 +49,19 @@ import ch.threema.app.compose.theme.color.LocalCustomColor
 
 val AppTypography = Typography() // system default
 
-/**
- *  @param dynamicColor available on Android 12+
- */
 @Composable
 fun ThreemaTheme(
     isDarkTheme: Boolean = isSystemInDarkTheme(),
-    dynamicColor: Boolean = true,
     content: @Composable () -> Unit
 ) {
+
+    val shouldUseDynamicColors: Boolean = remember {
+        val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext())
+        sharedPreferences.getBoolean("pref_dynamic_color", false)
+    }
+
     val materialColorScheme: ColorScheme = when {
-        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+        shouldUseDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
             val context = LocalContext.current
             if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
         }

+ 375 - 0
app/src/main/java/ch/threema/app/debug/PatternLibraryActivity.kt

@@ -0,0 +1,375 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 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.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.toUpperCase
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import ch.threema.app.R
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.app.compose.theme.customColorScheme
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+class PatternLibraryActivity : AppCompatActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        enableEdgeToEdge()
+        super.onCreate(savedInstanceState)
+        setContent {
+            ThreemaTheme {
+                Scaffold(
+                    topBar = {
+                        TopAppBar(
+                            modifier = Modifier.shadow(elevation = 8.dp),
+                            title = {
+                                Text("Pattern Library")
+                            },
+                            navigationIcon = {
+                                IconButton(
+                                    onClick = { finish() }
+                                ) {
+                                    Icon(
+                                        painter = painterResource(R.drawable.ic_arrow_back_24),
+                                        contentDescription = null
+                                    )
+                                }
+                            },
+                        )
+                    }
+                ) { padding ->
+
+                    val colorCategories: List<ColorSection> = listOf(
+                        ColorSection(
+                            "Brand",
+                            listOf(
+                                MaterialTheme.colorScheme.primary to "primary",
+                                MaterialTheme.colorScheme.onPrimary to "onPrimary",
+                                MaterialTheme.colorScheme.secondary to "secondary",
+                                MaterialTheme.colorScheme.onSecondary to "onSecondary",
+                                MaterialTheme.colorScheme.tertiary to "tertiary",
+                                MaterialTheme.colorScheme.onTertiary to "onTertiary",
+                                MaterialTheme.colorScheme.inversePrimary to "inversePrimary",
+                            )
+                        ),
+                        ColorSection(
+                            "Background",
+                            listOf(
+                                MaterialTheme.colorScheme.background to "background",
+                                MaterialTheme.colorScheme.onBackground to "onBackground",
+                            )
+                        ),
+                        ColorSection(
+                            "Container",
+                            listOf(
+                                MaterialTheme.colorScheme.primaryContainer to "primaryContainer",
+                                MaterialTheme.colorScheme.onPrimaryContainer to "onPrimaryContainer",
+                                MaterialTheme.colorScheme.secondaryContainer to "secondaryContainer",
+                                MaterialTheme.colorScheme.onSecondaryContainer to "onSecondaryContainer",
+                                MaterialTheme.colorScheme.tertiaryContainer to "tertiaryContainer",
+                                MaterialTheme.colorScheme.onTertiaryContainer to "onTertiaryContainer",
+                            )
+                        ),
+                        ColorSection(
+                            "Surface",
+                            listOf(
+                                MaterialTheme.colorScheme.surface to "surface",
+                                MaterialTheme.colorScheme.surfaceDim to "surfaceDim",
+                                MaterialTheme.colorScheme.surfaceBright to "surfaceBright",
+                                MaterialTheme.colorScheme.onSurface to "onSurface",
+                            )
+                        ),
+                        ColorSection(
+                            "Surface Container",
+                            listOf(
+                                MaterialTheme.colorScheme.surfaceContainerLowest to "surfaceContainerLowest",
+                                MaterialTheme.colorScheme.surfaceContainerLow to "surfaceContainerLow",
+                                MaterialTheme.colorScheme.surfaceContainer to "surfaceContainer",
+                                MaterialTheme.colorScheme.surfaceContainerHigh to "surfaceContainerHigh",
+                                MaterialTheme.colorScheme.surfaceContainerHighest to "surfaceContainerHighest",
+                                MaterialTheme.colorScheme.onSurfaceVariant to "onSurfaceVariant",
+                            )
+                        ),
+                        ColorSection(
+                            "Inverse",
+                            listOf(
+                                MaterialTheme.colorScheme.inverseSurface to "inverseSurface",
+                                MaterialTheme.colorScheme.inverseOnSurface to "inverseOnSurface",
+                            )
+                        ),
+                        ColorSection(
+                            "Outline",
+                            listOf(
+                                MaterialTheme.colorScheme.outline to "outline",
+                                MaterialTheme.colorScheme.outlineVariant to "outlineVariant",
+                            )
+                        ),
+                        ColorSection(
+                            "Error",
+                            listOf(
+                                MaterialTheme.colorScheme.error to "error",
+                                MaterialTheme.colorScheme.onError to "onError",
+                                MaterialTheme.colorScheme.errorContainer to "errorContainer",
+                                MaterialTheme.colorScheme.onErrorContainer to "onErrorContainer"
+                            )
+                        ),
+                        ColorSection(
+                            "Custom",
+                            listOf(
+                                MaterialTheme.customColorScheme.messageBubbleContainerReceive to "messageBubbleContainerReceive",
+                                MaterialTheme.customColorScheme.ackTint to "ackTint",
+                                MaterialTheme.customColorScheme.decTint to "decTint"
+                            )
+                        ),
+                    )
+
+                    LazyColumn(
+                        modifier = Modifier.padding(padding),
+                    ) {
+                        item {
+                            TopLevelSectionHeader(name = "Color Scheme")
+                        }
+
+                        colorCategories.forEach { colorSection ->
+
+                            stickyHeader {
+                                Box(
+                                    modifier = Modifier
+                                        .fillMaxWidth()
+                                        .background(MaterialTheme.colorScheme.surfaceContainer)
+                                ) {
+                                    Text(
+                                        modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
+                                        color = MaterialTheme.colorScheme.onSurface,
+                                        text = colorSection.name,
+                                        style = MaterialTheme.typography.bodyLarge
+                                    )
+                                }
+                            }
+                            items(colorSection.colors.size) { index ->
+                                ColorSpot(
+                                    modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
+                                    color = colorSection.colors[index].first,
+                                    colorName = colorSection.colors[index].second
+                                )
+                            }
+                        }
+
+                        item {
+                            Column {
+                                Spacer(modifier = Modifier.height(24.dp))
+                                TopLevelSectionHeader(name = "Typography")
+                            }
+                        }
+
+                        item {
+                            Text(
+                                modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),
+                                text = "Not yet implemented",
+                                style = MaterialTheme.typography.bodyMedium.copy(
+                                    fontStyle = FontStyle.Italic
+                                )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    @Composable
+    private fun TopLevelSectionHeader(
+        modifier: Modifier = Modifier,
+        name: String
+    ) {
+        Text(
+            modifier = modifier
+                .fillMaxWidth()
+                .background(MaterialTheme.colorScheme.surfaceContainer)
+                .padding(vertical = 24.dp, horizontal = 16.dp),
+            text = name,
+            style = MaterialTheme.typography.headlineSmall
+        )
+    }
+
+
+    private data class ColorSection(
+        val name: String,
+        val colors: List<Pair<Color, String>>
+    )
+}
+
+private fun Color.toHexCode(): String {
+    val red = this.red * 255
+    val green = this.green * 255
+    val blue = this.blue * 255
+    return String.format("#%02x%02x%02x", red.toInt(), green.toInt(), blue.toInt())
+}
+
+@Composable
+private fun ColorSpot(
+    modifier: Modifier = Modifier,
+    color: Color,
+    colorName: String
+) {
+    Row(
+        modifier = modifier,
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+
+        val tearShape = RoundedCornerShape(topStartPercent = 5, topEndPercent = 50, bottomEndPercent = 50, bottomStartPercent = 50)
+        val contentColor = MaterialTheme.colorScheme.onSurface
+
+        Box(
+            modifier = Modifier
+                .padding(all = 8.dp)
+                .size(size = 100.dp)
+                .shadow(
+                    elevation = 4.dp,
+                    shape = tearShape
+                )
+                .clip(tearShape)
+                .border(
+                    border = BorderStroke(
+                        width = 1.dp,
+                        color = contentColor
+                    ),
+                    shape = tearShape
+                )
+                .background(color)
+        ) { }
+
+        Spacer(modifier = Modifier.width(24.dp))
+
+        Column(
+            modifier = Modifier.fillMaxWidth()
+        ) {
+            Text(
+                text = colorName,
+                color = contentColor
+            )
+            Spacer(modifier = Modifier.height(2.dp))
+            SelectionContainer {
+                Text(
+                    text = color.toHexCode().toUpperCase(Locale.current),
+                    color = contentColor
+                )
+            }
+        }
+    }
+}
+
+@Preview(
+    showBackground = true,
+    uiMode = UI_MODE_NIGHT_NO,
+    group = "ColorSpot",
+)
+@Composable
+private fun ColorSpot_Preview() {
+    ThreemaTheme {
+        ColorSpot(
+            color = MaterialTheme.colorScheme.primary,
+            colorName = "primary"
+        )
+    }
+}
+
+@Preview(
+    showBackground = true,
+    uiMode = UI_MODE_NIGHT_NO,
+    group = "ColorSpot",
+)
+@Composable
+private fun ColorSpot_Preview_Surface() {
+    ThreemaTheme {
+        ColorSpot(
+            color = MaterialTheme.colorScheme.surface,
+            colorName = "surface"
+        )
+    }
+}
+
+@Preview(
+    showBackground = true,
+    uiMode = UI_MODE_NIGHT_YES,
+    group = "ColorSpot"
+)
+@Composable
+private fun ColorSpot_Preview_Night() {
+    ThreemaTheme {
+        ColorSpot(
+            color = MaterialTheme.colorScheme.primary,
+            colorName = "primary"
+        )
+    }
+}
+
+@Preview(
+    showBackground = true,
+    uiMode = UI_MODE_NIGHT_YES,
+    group = "ColorSpot"
+)
+@Composable
+private fun ColorSpot_Preview_Night_Surface() {
+    ThreemaTheme {
+        ColorSpot(
+            color = MaterialTheme.colorScheme.surface,
+            colorName = "surface"
+        )
+    }
+}

+ 21 - 2
app/src/main/java/ch/threema/app/dialogs/CancelableHorizontalProgressDialog.java

@@ -57,7 +57,7 @@ public class CancelableHorizontalProgressDialog extends ThreemaDialogFragment {
 	 * @param title title of dialog
 	 * @param button label of cancel button
 	 * @param total maximum allowed progress value.
-	 * @return nothing
+	 * @return the dialog
 	 */
 	public static CancelableHorizontalProgressDialog newInstance(@StringRes int title, @StringRes int button, int total) {
 		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();
@@ -70,12 +70,31 @@ public class CancelableHorizontalProgressDialog extends ThreemaDialogFragment {
 		return dialog;
 	}
 
+	/**
+	 * Creates a DialogFragment with a horizontal progress bar and a percentage display below.
+	 * Mimics deprecated system ProgressDialog behavior.
+	 * Note that when using this constructor, no cancel button is shown.
+	 *
+	 * @param title title of dialog
+	 * @param total maximum allowed progress value.
+	 * @return the dialog
+	 */
+	public static CancelableHorizontalProgressDialog newInstance(@StringRes int title, int total) {
+		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();
+		Bundle args = new Bundle();
+		args.putInt("title", title);
+		args.putInt("total", total);
+
+		dialog.setArguments(args);
+		return dialog;
+	}
+
 	/**
 	 * Creates a DialogFragment with a horizontal progress bar and a percentage display below. Mimics deprecated system ProgressDialog behavior
 	 * @param title title of dialog
 	 * @param button label of cancel button
 	 * @param total maximum allowed progress value.
-	 * @return nothing
+	 * @return the dialog
 	 */
 	public static CancelableHorizontalProgressDialog newInstance(@NonNull String title, @NonNull String button, int total) {
 		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();

+ 11 - 8
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -762,17 +762,17 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			updateToolBarTitleInUIThread();
 		}
 
 		@Override
-		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String identity) {
 			updateToolBarTitleInUIThread();
 		}
 
 		@Override
-		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String identity) {
 			updateToolBarTitleInUIThread();
 
 			if (userService.isMe(identity)) {
@@ -821,7 +821,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
+		public void onAvatarChanged(final @NonNull String identity) {
 			updateToolBarTitleInUIThread();
 		}
 
@@ -3112,7 +3112,7 @@ public class ComposeMessageFragment extends Fragment implements
 								// If there is no rejected recipient, we can just update the message
 								// state as the rejected recipient is not longer a group member.
 								// Note that this should never happen.
-								messageService.updateMessageState(messageModel, MessageState.SENT, null);
+								messageService.updateOutgoingMessageState(messageModel, MessageState.SENT, new Date());
 								logger.warn("Resend for group members requested, although no member rejected it");
 								return;
 							}
@@ -4086,13 +4086,16 @@ public class ComposeMessageFragment extends Fragment implements
 			setAvatarContentDescription(R.string.distribution_list);
 		} else {
 			if (contactModel != null) {
-				this.actionBarSubtitleImageView.setContactModel(contactModel);
+				this.actionBarSubtitleImageView.setVerificationLevel(
+					contactModel.verificationLevel,
+					contactModel.getWorkVerificationLevel()
+				);
 				this.actionBarSubtitleImageView.setVisibility(View.VISIBLE);
 				if (actionBarAvatarView.getAvatarView().isAttachedToWindow()) {
 					contactService.loadAvatarIntoImage(
 						contactModel,
 						this.actionBarAvatarView.getAvatarView(),
-						AvatarOptions.PRESET_RESPECT_SETTINGS,
+						AvatarOptions.PRESET_DEFAULT_FALLBACK,
 						Glide.with(requireActivity())
 					);
 				}
@@ -5594,7 +5597,7 @@ public class ComposeMessageFragment extends Fragment implements
 	@Override
 	public void onReportSpamClicked(@NonNull final ContactModel spammerContactModel, boolean block) {
 		contactService.reportSpam(
-			spammerContactModel,
+			spammerContactModel.getIdentity(),
 			unused -> {
 				if (isAdded()) {
 					LongToast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();

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

@@ -24,7 +24,8 @@ package ch.threema.app.fragments;
 import static android.view.MenuItem.SHOW_AS_ACTION_ALWAYS;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_NEVER;
-import static ch.threema.app.ThreemaApplication.WORKER_WORK_SYNC;
+import static ch.threema.app.asynctasks.ContactSyncPolicy.EXCLUDE;
+import static ch.threema.app.asynctasks.ContactSyncPolicy.INCLUDE;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
@@ -61,9 +62,6 @@ import androidx.core.util.Pair;
 import androidx.core.view.MenuItemCompat;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-import androidx.work.ExistingWorkPolicy;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
 
 import com.bumptech.glide.Glide;
 import com.google.android.material.button.MaterialButton;
@@ -72,12 +70,13 @@ import com.google.android.material.tabs.TabLayout;
 
 import org.slf4j.Logger;
 
+import java.lang.ref.WeakReference;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -86,7 +85,10 @@ import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ContactDetailActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.adapters.ContactListAdapter;
-import ch.threema.app.asynctasks.DeleteContactAsyncTask;
+import ch.threema.app.asynctasks.AndroidContactLinkPolicy;
+import ch.threema.app.asynctasks.ContactSyncPolicy;
+import ch.threema.app.asynctasks.DeleteContactServices;
+import ch.threema.app.asynctasks.DialogMarkContactAsDeletedBackgroundTask;
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetGridDialog;
@@ -107,7 +109,6 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.SynchronizeContactsService;
@@ -126,10 +127,12 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.workers.ContactUpdateWorker;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
@@ -192,6 +195,8 @@ public class ContactsSectionFragment
 	private PreferenceService preferenceService;
 	private LockAppService lockAppService;
 
+	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
+
 	private String filterQuery;
 	@SuppressLint("StaticFieldLeak")
 	private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@@ -258,11 +263,8 @@ public class ContactsSectionFragment
 		}
 	}
 
-	private final ResumePauseHandler.RunIfActive runIfActiveShowLoading = new ResumePauseHandler.RunIfActive() {
-		@Override
-		public void runOnUiThread() {
-			// do nothing
-		}
+	private final ResumePauseHandler.RunIfActive runIfActiveShowLoading = () -> {
+		// do nothing
 	};
 
 	private final ResumePauseHandler.RunIfActive runIfActiveClearCacheAndRefresh = new ResumePauseHandler.RunIfActive() {
@@ -275,10 +277,8 @@ public class ContactsSectionFragment
 				if (serviceManager != null) {
 					try {
 						AvatarCacheService avatarCacheService = serviceManager.getAvatarCacheService();
-						if (avatarCacheService != null) {
-							//clear the cache
-							avatarCacheService.clear();
-						}
+						//clear the cache
+						avatarCacheService.clear();
 					} catch (FileSystemNotPresentException e) {
 						logger.error("Exception", e);
 					}
@@ -306,12 +306,7 @@ public class ContactsSectionFragment
 		}
 	};
 
-	private final ResumePauseHandler.RunIfActive runIfActiveCreateList = new ResumePauseHandler.RunIfActive() {
-		@Override
-		public void runOnUiThread() {
-			createListAdapter(null);
-		}
-	};
+	private final ResumePauseHandler.RunIfActive runIfActiveCreateList = () -> createListAdapter(null);
 
 	private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
 		@Override
@@ -381,8 +376,8 @@ public class ContactsSectionFragment
 		}
 
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
-			this.onModified(contactModel.getIdentity());
+		public void onAvatarChanged(final @NonNull String identity) {
+			this.onModified(identity);
 		}
 
 		@Override
@@ -513,9 +508,7 @@ public class ContactsSectionFragment
 
 		this.resumePauseHandler = ResumePauseHandler.getByActivity(this, this.getActivity());
 
-		if (this.resumePauseHandler != null) {
-			this.resumePauseHandler.runOnActive(RUN_ON_ACTIVE_REFRESH_PULL_TO_REFRESH, runIfActiveUpdatePullToRefresh);
-		}
+		this.resumePauseHandler.runOnActive(RUN_ON_ACTIVE_REFRESH_PULL_TO_REFRESH, runIfActiveUpdatePullToRefresh);
 	}
 
 	@Override
@@ -573,7 +566,7 @@ public class ContactsSectionFragment
 	}
 
 	@Override
-	public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+	public void onCreateOptionsMenu(Menu menu, @NonNull MenuInflater inflater) {
 		logger.debug("onCreateOptionsMenu");
 		searchMenuItem = menu.findItem(R.id.menu_search_contacts);
 
@@ -588,12 +581,9 @@ public class ContactsSectionFragment
 					if (!TestUtil.isEmptyOrNull(filterQuery)) {
 						// restore filter
 						MenuItemCompat.expandActionView(searchMenuItem);
-						this.searchView.post(new Runnable() {
-							@Override
-							public void run() {
-								searchView.setQuery(filterQuery, true);
-								searchView.clearFocus();
-							}
+						this.searchView.post(() -> {
+							searchView.setQuery(filterQuery, true);
+							searchView.clearFocus();
 						});
 					}
 					this.searchView.setQueryHint(getString(R.string.hint_filter_list));
@@ -607,7 +597,7 @@ public class ContactsSectionFragment
 	final SearchView.OnQueryTextListener queryTextListener = new SearchView.OnQueryTextListener() {
 		@Override
 		public boolean onQueryTextChange(String query) {
-			if (contactListAdapter != null && contactListAdapter.getFilter() != null) {
+			if (contactListAdapter != null) {
 				filterQuery = query;
 				contactListAdapter.getFilter().filter(query);
 			}
@@ -812,7 +802,7 @@ public class ContactsSectionFragment
 	}
 
 	@Override
-	public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+	public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
 		View headerView, fragmentView = getView();
 
 		logger.debug("onCreateView");
@@ -893,12 +883,7 @@ public class ContactsSectionFragment
 				this.contactsCounterButton = footerView.findViewById(R.id.contact_counter_text);
 				listView.addFooterView(footerView, null, false);
 
-				headerView.findViewById(R.id.share_container).setOnClickListener(new View.OnClickListener() {
-					@Override
-					public void onClick(View v) {
-						shareInvite();
-					}
-				});
+				headerView.findViewById(R.id.share_container).setOnClickListener(v -> shareInvite());
 			} else {
 				workTabLayout = fragmentView.findViewById(R.id.work_contacts_tab_layout);
 				workTabLayout.addOnTabSelectedListener(onTabSelectedListener);
@@ -912,12 +897,7 @@ public class ContactsSectionFragment
 			this.swipeRefreshLayout.setSize(SwipeRefreshLayout.LARGE);
 
 			this.floatingButtonView = fragmentView.findViewById(R.id.floating);
-			this.floatingButtonView.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					onFABClicked(v);
-				}
-			});
+			this.floatingButtonView.setOnClickListener(this::onFABClicked);
 		}
 		return fragmentView;
 	}
@@ -1064,6 +1044,10 @@ public class ContactsSectionFragment
 
 		new Handler(Looper.getMainLooper()).postDelayed(this::stopSwipeRefresh, 2000);
 
+		try {
+			ContactUpdateWorker.performOneTimeSync(requireContext());
+		} catch (IllegalStateException ignored) {}
+
 		if (this.preferenceService.isSyncContacts() && ConfigUtils.requestContactPermissions(getActivity(), this, PERMISSION_REQUEST_REFRESH_CONTACTS)) {
 			if (this.synchronizeContactsService != null) {
 				// we force a contact sync even if the grace time has not yet been reached
@@ -1074,16 +1058,16 @@ public class ContactsSectionFragment
 
 		if (ConfigUtils.isWorkBuild()) {
 			try {
-				OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(false, true, "WorkContactSync");
-				WorkManager.getInstance(ThreemaApplication.getAppContext()).enqueueUniqueWork(WORKER_WORK_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+				WorkSyncWorker.Companion.performOneTimeWorkSync(
+					ThreemaApplication.getAppContext(),
+					false,
+					true,
+					"WorkContactSync"
+				);
 			} catch (IllegalStateException e) {
 				logger.error("Unable to schedule work sync one time work", e);
 			}
 		}
-		try {
-			OneTimeWorkRequest contactUpdateRequest = new OneTimeWorkRequest.Builder(ContactUpdateWorker.class).build();
-			WorkManager.getInstance(requireContext()).enqueue(contactUpdateRequest);
-		} catch (IllegalStateException ignored) {}
 	}
 
 	private void openConversationForIdentity(@Nullable View v, String identity) {
@@ -1189,7 +1173,7 @@ public class ContactsSectionFragment
 					contactModel.verificationLevel == VerificationLevel.UNVERIFIED
 				) {
 					MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
-					if (messageReceiver != null && messageReceiver.getMessagesCount() > 0) {
+					if (messageReceiver.getMessagesCount() > 0) {
 						items.add(new SelectorDialogItem(getString(R.string.spam_report), R.drawable.ic_outline_report_24));
 						tags.add(SELECTOR_TAG_REPORT_SPAM);
 					}
@@ -1285,43 +1269,57 @@ public class ContactsSectionFragment
 		dialog.show(getFragmentManager(), DIALOG_TAG_REALLY_DELETE_CONTACTS);
 	}
 
-	@SuppressLint("StaticFieldLeak")
-	private void reallyDeleteContacts(@NonNull Set<ContactModel> contactModels, boolean excludeFromSync) {
-		new DeleteContactAsyncTask(getParentFragmentManager(), contactModels, contactService, new DeleteContactAsyncTask.DeleteContactsPostRunnable() {
-			@Override
-			public void run() {
-				if (isAdded()) {
-					if (failed > 0) {
-						Toast.makeText(getActivity(), ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.some_contacts_not_deleted, failed, failed), Toast.LENGTH_LONG).show();
-					} else {
-						if (contactModels.size() > 1) {
-							Toast.makeText(getActivity(), R.string.contacts_deleted, Toast.LENGTH_LONG).show();
-						} else {
-							Toast.makeText(getActivity(), R.string.contact_deleted, Toast.LENGTH_LONG).show();
-						}
+	private void reallyDeleteContacts(@NonNull Set<ContactModel> contactModels, boolean excludeFromSync) throws ThreemaException {
+		Set<String> identities = contactModels.stream()
+			.map(Contact::getIdentity)
+			.collect(Collectors.toSet());
 
-						if (excludeFromSync) {
-							excludeContactsFromSync(contactModels);
-						}
-					}
-				}
+		ContactSyncPolicy syncPolicy = excludeFromSync ? EXCLUDE : INCLUDE;
 
-				if (actionMode != null) {
-					actionMode.finish();
-				}
-			}
-		}).execute();
+		DialogMarkContactAsDeletedBackgroundTask task = getDialogDeleteContactBackgroundTask(
+			identities, syncPolicy
+		);
+
+		backgroundExecutor.execute(task);
 	}
 
-	private void excludeContactsFromSync(@NonNull Collection<ContactModel> contactModels) {
-		IdListService excludedService = serviceManager.getExcludedSyncIdentitiesService();
-		if (excludedService != null) {
-			for (ContactModel contactModel : contactModels) {
-				if (contactModel.isLinkedToAndroidContact()) {
-					excludedService.add(contactModel.getIdentity());
+	@NonNull
+	private DialogMarkContactAsDeletedBackgroundTask getDialogDeleteContactBackgroundTask(
+		@NonNull Set<String> identities,
+		@NonNull ContactSyncPolicy syncPolicy
+	) throws ThreemaException {
+		DeleteContactServices deleteServices = new DeleteContactServices(
+			serviceManager.getUserService(),
+			contactService,
+			serviceManager.getConversationService(),
+			serviceManager.getRingtoneService(),
+			serviceManager.getMutedChatsListService(),
+			serviceManager.getHiddenChatsListService(),
+			serviceManager.getProfilePicRecipientsService(),
+			serviceManager.getWallpaperService(),
+			serviceManager.getFileService(),
+			serviceManager.getExcludedSyncIdentitiesService(),
+			serviceManager.getDHSessionStore(),
+			serviceManager.getNotificationService(),
+			serviceManager.getDatabaseServiceNew()
+		);
+
+		return new DialogMarkContactAsDeletedBackgroundTask(
+			getParentFragmentManager(),
+			new WeakReference<>(getContext()),
+			identities,
+			serviceManager.getModelRepositories().getContacts(),
+			deleteServices,
+			syncPolicy,
+			AndroidContactLinkPolicy.REMOVE_LINK
+		) {
+			@Override
+			protected void onFinished() {
+				if (actionMode != null) {
+					actionMode.finish();
 				}
 			}
-		}
+		};
 	}
 
 	@Override
@@ -1448,12 +1446,16 @@ public class ContactsSectionFragment
 	public void onYes(String tag, Object data, boolean checked) {
 		switch(tag) {
 			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
-				reallyDeleteContacts((Set<ContactModel>) data, checked);
+				try {
+					reallyDeleteContacts((Set<ContactModel>) data, checked);
+				} catch (ThreemaException e) {
+					logger.error("Could not delete contacts", e);
+				}
 				break;
 			case DIALOG_TAG_REPORT_SPAM:
 				ContactModel contactModel = (ContactModel) data;
 
-				contactService.reportSpam(contactModel,
+				contactService.reportSpam(contactModel.getIdentity(),
 					unused -> {
 						if (isAdded()) {
 							Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
@@ -1503,7 +1505,11 @@ public class ContactsSectionFragment
 	public void onYes(String tag, Object data) {
 		switch(tag) {
 			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
-				reallyDeleteContacts((Set<ContactModel>) data, false);
+				try {
+					reallyDeleteContacts((Set<ContactModel>) data, false);
+				} catch (ThreemaException e) {
+					logger.error("Could not delete contacts", e);
+				}
 				break;
 			default:
 				break;

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

@@ -149,7 +149,6 @@ import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
@@ -221,6 +220,7 @@ public class MessageSectionFragment extends MainFragment
 
 	private Activity activity;
 	private File tempMessagesFile;
+    @Nullable
 	private MessageListAdapter messageListAdapter;
 	private EmptyRecyclerView recyclerView;
 	private View loadingView;
@@ -333,7 +333,7 @@ public class MessageSectionFragment extends MainFragment
 
 	private final GroupListener groupListener = new GroupListener() {
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			// If this user is added to an existing group
 			if (groupService != null && myIdentity != null && myIdentity.equals(newIdentity)) {
 				fireReceiverUpdate(groupService.createReceiver(group));
@@ -390,7 +390,7 @@ public class MessageSectionFragment extends MainFragment
 		}
 
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
+		public void onAvatarChanged(final @NonNull String identity) {
 			this.handleChange();
 		}
 
@@ -599,8 +599,10 @@ public class MessageSectionFragment extends MainFragment
 		@Override
 		public boolean onQueryTextChange(String query) {
 			filterQuery = query;
-			messageListAdapter.setFilterQuery(query);
-			updateList(0, null, null);
+            if (messageListAdapter != null) {
+                messageListAdapter.setFilterQuery(query);
+                updateList(0, null, null);
+            }
 			return true;
 		}
 
@@ -691,14 +693,16 @@ public class MessageSectionFragment extends MainFragment
 	private void doUnhideChat(@NonNull ConversationModel conversationModel) {
 		MessageReceiver<?> receiver = conversationModel.getReceiver();
 		if (receiver != null && hiddenChatsListService.has(receiver.getUniqueIdString())) {
-			hiddenChatsListService.remove(receiver.getUniqueIdString());
+            hiddenChatsListService.remove(receiver.getUniqueIdString());
 
-			if (getView() != null) {
-				Snackbar.make(getView(), R.string.chat_visible, Snackbar.LENGTH_SHORT).show();
-			}
+            if (getView() != null) {
+                Snackbar.make(getView(), R.string.chat_visible, Snackbar.LENGTH_SHORT).show();
+            }
 
-			this.fireReceiverUpdate(receiver);
-			messageListAdapter.clearSelections();
+            this.fireReceiverUpdate(receiver);
+            if (messageListAdapter != null) {
+                messageListAdapter.clearSelections();
+            }
 		}
 	}
 
@@ -759,6 +763,9 @@ public class MessageSectionFragment extends MainFragment
 			@Override
 			protected void onPostExecute(Boolean success) {
 				if (success) {
+                    if (messageListAdapter == null) {
+                        return;
+                    }
 					messageListAdapter.clearSelections();
 					if (getView() != null) {
 						Snackbar.make(getView(), R.string.chat_hidden, Snackbar.LENGTH_SHORT).show();
@@ -894,6 +901,10 @@ public class MessageSectionFragment extends MainFragment
 
 				@Override
 				public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+                    if (messageListAdapter == null) {
+                        return;
+                    }
+
 					// swipe has ended successfully
 
 					// required to clear swipe layout
@@ -1153,7 +1164,7 @@ public class MessageSectionFragment extends MainFragment
 
 	@Override
 	public boolean onItemLongClick(View view, int position, ConversationModel conversationModel) {
-		if (!isMultiPaneEnabled(activity)) {
+		if (!isMultiPaneEnabled(activity) && messageListAdapter != null) {
 			messageListAdapter.toggleItemChecked(conversationModel, position);
 			showSelector();
 			return true;
@@ -1220,7 +1231,9 @@ public class MessageSectionFragment extends MainFragment
 		}
 		updateHiddenMenuVisibility();
 
-		messageListAdapter.updateDateView();
+        if (messageListAdapter != null) {
+            messageListAdapter.updateDateView();
+        }
 
 		super.onResume();
 	}
@@ -1245,7 +1258,7 @@ public class MessageSectionFragment extends MainFragment
 		ArrayList<SelectorDialogItem> labels = new ArrayList<>();
 		ArrayList<Integer> tags = new ArrayList<>();
 
-		if (messageListAdapter.getCheckedItemCount() != 1) {
+		if (messageListAdapter == null || messageListAdapter.getCheckedItemCount() != 1) {
 			return;
 		}
 
@@ -1311,7 +1324,7 @@ public class MessageSectionFragment extends MainFragment
 			}
 			boolean isCreator = groupService.isGroupCreator(group);
 			boolean isMember = groupService.isGroupMember(group);
-			boolean hasOtherMembers = groupService.getOtherMemberCount(group) > 0;
+			boolean hasOtherMembers = groupService.countMembersWithoutUser(group) > 0;
 			// Check also if the user is a group member, because orphaned groups should not be
 			// editable.
 			if (isCreator && isMember) {
@@ -1350,7 +1363,9 @@ public class MessageSectionFragment extends MainFragment
 	public void onClick(String tag, int which, Object data) {
 		GenericAlertDialog dialog;
 
-		messageListAdapter.clearSelections();
+        if (messageListAdapter != null) {
+            messageListAdapter.clearSelections();
+        }
 
 		final ConversationModel conversationModel = (ConversationModel) data;
 
@@ -1468,12 +1483,14 @@ public class MessageSectionFragment extends MainFragment
 
 	@Override
 	public void onCancel(String tag) {
+        if (messageListAdapter != null) {
 		messageListAdapter.clearSelections();
+        }
 	}
 
 	@Override
 	public void onNo(String tag) {
-		if (DIALOG_TAG_SELECT_DELETE_ACTION.equals(tag)) {
+		if (messageListAdapter != null && DIALOG_TAG_SELECT_DELETE_ACTION.equals(tag)) {
 			messageListAdapter.clearSelections();
 		}
 	}

+ 59 - 11
app/src/main/java/ch/threema/app/fragments/MyIDFragment.java

@@ -51,6 +51,7 @@ import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 
 import org.slf4j.Logger;
 
+import java.util.Arrays;
 import java.util.Date;
 
 import ch.threema.app.R;
@@ -73,11 +74,15 @@ import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.CheckIdentityRoutine;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.ContactService.ProfilePictureSharePolicy;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.IdListService;
 import ch.threema.app.services.LocaleService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.QRCodeServiceImpl;
 import ch.threema.app.services.UserService;
+import ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask;
+import ch.threema.app.tasks.ReflectUserProfileShareWithPolicySyncTask;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.QRCodePopup;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -93,6 +98,8 @@ import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
+import ch.threema.domain.taskmanager.TaskManager;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
 
 /**
@@ -117,6 +124,9 @@ public class MyIDFragment extends MainFragment
 	private LocaleService localeService;
 	private ContactService contactService;
 	private FileService fileService;
+    private IdListService profilePicRecipientsService;
+	private TaskManager taskManager;
+
 	private AvatarEditView avatarView;
 	private EmojiTextView nicknameTextView;
 	private boolean hidden = false;
@@ -237,7 +247,7 @@ public class MyIDFragment extends MainFragment
 
 			final MaterialButton picReleaseConfImageView = fragmentView.findViewById(R.id.picrelease_config);
 			picReleaseConfImageView.setOnClickListener(this);
-			picReleaseConfImageView.setVisibility(preferenceService.getProfilePicRelease() == PreferenceService.PROFILEPIC_RELEASE_SOME ? View.VISIBLE : View.GONE);
+			picReleaseConfImageView.setVisibility(preferenceService.getProfilePicRelease() == PreferenceService.PROFILEPIC_RELEASE_ALLOW_LIST ? View.VISIBLE : View.GONE);
 
 			configureEditWithButton(fragmentView.findViewById(R.id.linked_email_layout), fragmentView.findViewById(R.id.change_email), isReadonlyProfile);
 			configureEditWithButton(fragmentView.findViewById(R.id.linked_mobile_layout), fragmentView.findViewById(R.id.change_mobile), isReadonlyProfile);
@@ -300,21 +310,57 @@ public class MyIDFragment extends MainFragment
 		if (fragmentView != null && preferenceService != null) {
 			MaterialAutoCompleteTextView spinner = fragmentView.findViewById(R.id.picrelease_spinner);
 			if (spinner != null) {
-				ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(getContext(), R.array.picrelease_choices, android.R.layout.simple_spinner_dropdown_item);
+				ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(
+                    requireContext(),
+                    R.array.picrelease_choices,
+                    android.R.layout.simple_spinner_dropdown_item
+                );
 				spinner.setAdapter(adapter);
 				spinner.setText(adapter.getItem(preferenceService.getProfilePicRelease()), false);
-				spinner.setOnItemClickListener((parent, view, position, id) -> {
-					int oldPosition = preferenceService.getProfilePicRelease();
-					preferenceService.setProfilePicRelease(position);
-					fragmentView.findViewById(R.id.picrelease_config).setVisibility(position == PreferenceService.PROFILEPIC_RELEASE_SOME ? View.VISIBLE : View.GONE);
-					if (position == PreferenceService.PROFILEPIC_RELEASE_SOME && position != oldPosition) {
-						launchProfilePictureRecipientsSelector(view);
-					}
-				});
+				spinner.setOnItemClickListener((parent, view, position, id) -> onPicReleaseSpinnerItemClicked(view, position));
 			}
 		}
 	}
 
+    private void onPicReleaseSpinnerItemClicked(View view, int position){
+
+        final @Nullable ProfilePictureSharePolicy.Policy sharePolicy = ProfilePictureSharePolicy.Policy.fromIntOrNull(position);
+        if (sharePolicy == null) {
+            logger.error("Failed to get concrete enum value of type ProfilePictureSharePolicy.Policy for ordinal value {}", position);
+            return;
+        }
+
+        final int oldPosition = preferenceService.getProfilePicRelease();
+        preferenceService.setProfilePicRelease(position);
+
+        fragmentView.findViewById(R.id.picrelease_config)
+            .setVisibility(position == PreferenceService.PROFILEPIC_RELEASE_ALLOW_LIST ? View.VISIBLE : View.GONE);
+
+        // Only continue of the value actually changes from before
+        if (position == oldPosition) {
+            return;
+        }
+
+        if (sharePolicy == ProfilePictureSharePolicy.Policy.ALLOW_LIST) {
+            launchProfilePictureRecipientsSelector(view);
+            // sync new policy setting with currently set allow list values into device group (if md is active)
+            taskManager.schedule(
+                new ReflectUserProfileShareWithAllowListSyncTask(
+                    Arrays.asList(profilePicRecipientsService.getAll()),
+                    this.serviceManager
+                )
+            );
+        } else {
+            // sync new policy setting to device group (if md is active)
+            taskManager.schedule(
+                new ReflectUserProfileShareWithPolicySyncTask(
+                    sharePolicy,
+                    this.serviceManager
+                )
+            );
+        }
+    }
+
 	@Override
 	public void onStart() {
 		super.onStart();
@@ -737,7 +783,7 @@ public class MyIDFragment extends MainFragment
 				// Update public nickname
 				String newNickname = text.trim();
 				if (!newNickname.equals(userService.getPublicNickname())) {
-					userService.setPublicNickname(newNickname);
+					userService.setPublicNickname(newNickname, TriggerSource.LOCAL);
 				}
 				reloadNickname();
 				break;
@@ -805,6 +851,8 @@ public class MyIDFragment extends MainFragment
 				this.fileService = this.serviceManager.getFileService();
 				this.preferenceService = this.serviceManager.getPreferenceService();
 				this.localeService = this.serviceManager.getLocaleService();
+				this.taskManager = this.serviceManager.getTaskManager();
+				this.profilePicRecipientsService = this.serviceManager.getProfilePicRecipientsService();
 			} catch (MasterKeyLockedException e) {
 				logger.debug("Master Key locked!");
 			} catch (ThreemaException e) {

+ 8 - 7
app/src/main/java/ch/threema/app/fragments/UserMemberListFragment.java

@@ -37,6 +37,7 @@ import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.storage.models.ContactModel;
 
@@ -61,21 +62,21 @@ public class UserMemberListFragment extends MemberListFragment {
 				List<ContactModel> contactModels;
 
 				if (groups) {
-					final ContactModel.State[] contactStates;
+					final IdentityState[] contactStates;
 					if (preferenceService.showInactiveContacts()) {
-						contactStates = new ContactModel.State[]{
-							ContactModel.State.ACTIVE,
-							ContactModel.State.INACTIVE
+						contactStates = new IdentityState[]{
+							IdentityState.ACTIVE,
+							IdentityState.INACTIVE
 						};
 					} else {
-						contactStates = new ContactModel.State[]{
-							ContactModel.State.ACTIVE
+						contactStates = new IdentityState[]{
+							IdentityState.ACTIVE
 						};
 					}
 
 					contactModels = contactService.find(new ContactService.Filter() {
 						@Override
-						public ContactModel.State[] states() {
+						public IdentityState[] states() {
 							return contactStates;
 						}
 

+ 8 - 7
app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.java

@@ -48,6 +48,7 @@ import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.storage.models.ContactModel;
 
 public class WorkUserListFragment extends RecipientListFragment {
@@ -115,21 +116,21 @@ public class WorkUserListFragment extends RecipientListFragment {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 			@Override
 			protected List<ContactModel> doInBackground(Void... voids) {
-				final ContactModel.State[] contactStates;
+				final IdentityState[] contactStates;
 				if (preferenceService.showInactiveContacts()) {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE,
-						ContactModel.State.INACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE,
+						IdentityState.INACTIVE
 					};
 				} else {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE
 					};
 				}
 
 				return Functional.filter(contactService.find(new ContactService.Filter() {
 					@Override
-					public ContactModel.State[] states() {
+					public IdentityState[] states() {
 						return contactStates;
 					}
 

+ 8 - 7
app/src/main/java/ch/threema/app/fragments/WorkUserMemberListFragment.java

@@ -39,6 +39,7 @@ import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.ContactUtil;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.storage.models.ContactModel;
 
@@ -60,21 +61,21 @@ public class WorkUserMemberListFragment extends MemberListFragment {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 			@Override
 			protected List<ContactModel> doInBackground(Void... voids) {
-				final ContactModel.State[] contactStates;
+				final IdentityState[] contactStates;
 				if (preferenceService.showInactiveContacts()) {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE,
-						ContactModel.State.INACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE,
+						IdentityState.INACTIVE
 					};
 				} else {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE
 					};
 				}
 
 				List<ContactModel> contactModels = Functional.filter(contactService.find(new ContactService.Filter() {
 					@Override
-					public ContactModel.State[] states() {
+					public IdentityState[] states() {
 						return contactStates;
 					}
 

+ 7 - 16
app/src/main/java/ch/threema/app/glide/AvatarOptions.java

@@ -34,38 +34,29 @@ public class AvatarOptions {
 	 */
 	public enum DefaultAvatarPolicy {
 		/**
-		 * Try to load the custom avatar. If no custom avatar available, then return the default avatar instead of null.
+		 * Try to load the custom avatar. If no custom avatar available, then return the default
+         * avatar instead of null.
 		 */
 		DEFAULT_FALLBACK,
 		/**
-		 * Load the custom avatar. If no custom avatar is set, then return null.
+		 * Load the custom avatar. If no custom avatar is set, then return null. Note that a custom
+         * avatar can either be a contact or user defined profile picture.
 		 */
 		CUSTOM_AVATAR,
 		/**
 		 * Load the default avatar even if a custom avatar would be available.
 		 */
 		DEFAULT_AVATAR,
-		/**
-		 * Load the custom avatar if not prevented by settings. Otherwise returns the default avatar.
-		 */
-		RESPECT_SETTINGS
 	}
 
 	/**
-	 * Load the avatar in low resolution. If no avatar is found, load the default avatar.
+	 * Load the avatar in low resolution. If no avatar is found, load the default avatar. This
+     * respects the setting where the user defined profile picture should not be shown.
 	 */
 	public static final AvatarOptions PRESET_DEFAULT_FALLBACK = new Builder()
 		.setReturnPolicy(DefaultAvatarPolicy.DEFAULT_FALLBACK)
 		.toOptions();
 
-	/**
-	 * Load the avatar in low resolution. If no avatar is found, or custom avatars are disabled in settings,
-	 * load the default avatar.
-	 */
-	public static final AvatarOptions PRESET_RESPECT_SETTINGS = new Builder()
-		.setReturnPolicy(DefaultAvatarPolicy.RESPECT_SETTINGS)
-		.toOptions();
-
 	/**
 	 * Load the avatar with default fallback and do not cache it.
 	 */
@@ -146,7 +137,7 @@ public class AvatarOptions {
 	 */
 	public static class Builder {
 		private boolean highRes = false;
-		private @NonNull DefaultAvatarPolicy defaultAvatarPolicy = DefaultAvatarPolicy.RESPECT_SETTINGS;
+		private @NonNull DefaultAvatarPolicy defaultAvatarPolicy = DefaultAvatarPolicy.DEFAULT_AVATAR;
 		private boolean disableCache = false;
 		private boolean darkerBackground = false;
 

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

@@ -60,7 +60,7 @@ class ContactAvatarFetcher(
         val returnDefaultIfNone: Boolean
         when (contactAvatarConfig.options.defaultAvatarPolicy) {
             AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK -> {
-                profilePicReceive = true
+                profilePicReceive = preferenceService?.profilePicReceive == true
                 defaultAvatar = false
                 returnDefaultIfNone = true
             }
@@ -74,11 +74,6 @@ class ContactAvatarFetcher(
                 defaultAvatar = true
                 returnDefaultIfNone = true
             }
-            AvatarOptions.DefaultAvatarPolicy.RESPECT_SETTINGS -> {
-                profilePicReceive = preferenceService?.profilePicReceive == true
-                defaultAvatar = false
-                returnDefaultIfNone = true
-            }
         }
         val backgroundColor = getBackgroundColor(contactAvatarConfig.options)
 
@@ -96,29 +91,29 @@ class ContactAvatarFetcher(
             return buildDefaultAvatar(null, highRes, backgroundColor)
         }
 
-        // try profile picture
+        // Try the contact defined profile picture
         if (profilePicReceive) {
-            getProfilePicture(contactModel, highRes)?.let {
+            getContactDefinedProfilePicture(contactModel, highRes)?.let {
                 return it
             }
         }
 
-        // try local saved avatar
-        getLocallySavedAvatar(contactModel, highRes)?.let {
+        // Try the user defined profile picture
+        getUserDefinedProfilePicture(contactModel, highRes)?.let {
             return it
         }
 
-        // try android contact picture
-        getAndroidContactAvatar(contactModel, highRes)?.let {
+        // Try the android defined profile picture
+        getAndroidDefinedProfilePicture(contactModel, highRes)?.let {
             return it
         }
 
         return if (returnDefaultIfNone) buildDefaultAvatar(contactModel, highRes, backgroundColor) else null
     }
 
-    private fun getProfilePicture(contactModel: ContactModel, highRes: Boolean): Bitmap? {
+    private fun getContactDefinedProfilePicture(contactModel: ContactModel, highRes: Boolean): Bitmap? {
         try {
-            val result = fileService?.getContactPhoto(contactModel.identity)
+            val result = fileService?.getContactDefinedProfilePicture(contactModel.identity)
             if (result != null && !highRes) {
                 return AvatarConverterUtil.convert(this.context.resources, result)
             }
@@ -128,9 +123,9 @@ class ContactAvatarFetcher(
         }
     }
 
-    private fun getLocallySavedAvatar(contactModel: ContactModel, highRes: Boolean): Bitmap? {
+    private fun getUserDefinedProfilePicture(contactModel: ContactModel, highRes: Boolean): Bitmap? {
         return try {
-            var result = fileService?.getContactAvatar(contactModel.identity)
+            var result = fileService?.getUserDefinedProfilePicture(contactModel.identity)
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
             }
@@ -140,13 +135,13 @@ class ContactAvatarFetcher(
         }
     }
 
-    private fun getAndroidContactAvatar(contactModel: ContactModel, highRes: Boolean): Bitmap? {
+    private fun getAndroidDefinedProfilePicture(contactModel: ContactModel, highRes: Boolean): Bitmap? {
         if (ContactUtil.isGatewayContact(contactModel) || AndroidContactUtil.getInstance().getAndroidContactUri(contactModel) == null) {
             return null
         }
         // regular contacts
         return try {
-            var result = fileService?.getAndroidContactAvatar(contactModel)
+            var result = fileService?.getAndroidDefinedProfilePicture(contactModel)
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
             }

+ 0 - 4
app/src/main/java/ch/threema/app/glide/GroupAvatarFetcher.kt

@@ -64,10 +64,6 @@ class GroupAvatarFetcher(
                 defaultAvatar = true
                 defaultAvatarIfNone = true
             }
-            AvatarOptions.DefaultAvatarPolicy.RESPECT_SETTINGS -> {
-                defaultAvatar = preferenceService?.profilePicReceive == false
-                defaultAvatarIfNone = true
-            }
         }
         val backgroundColor = getBackgroundColor(config.options)
 

+ 2 - 1
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.kt

@@ -53,6 +53,7 @@ import ch.threema.app.ui.EmptyRecyclerView
 import ch.threema.app.ui.EmptyView
 import ch.threema.app.ui.ThreemaSearchView
 import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.AbstractMessageModel
@@ -196,7 +197,7 @@ class GlobalSearchActivity : ThreemaToolbarActivity(), SearchView.OnQueryTextLis
                     val deadlineListIdentifier: String = if (messageModel is GroupMessageModel) {
                         groupService.getUniqueIdString(messageModel.groupId)
                     } else {
-                        contactService.getUniqueIdString(messageModel.identity)
+                        ContactUtil.getUniqueIdString(messageModel.identity)
                     }
                     hiddenChatsListService.has(deadlineListIdentifier)
                 }

+ 2 - 1
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java

@@ -69,6 +69,7 @@ import ch.threema.app.ui.CheckableRelativeLayout;
 import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.MimeUtil;
@@ -197,7 +198,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 
             final @NonNull String uid = messageModel instanceof GroupMessageModel
                 ? groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId())
-                : contactService.getUniqueIdString(messageModel.getIdentity());
+                : ContactUtil.getUniqueIdString(messageModel.getIdentity());
             if (hiddenChatsListService.has(uid)) {
                 viewHolder.dateView.setText("");
                 viewHolder.thumbnailView.setVisibility(View.GONE);

+ 46 - 36
app/src/main/java/ch/threema/app/grouplinks/OutgoingGroupRequestActivity.java

@@ -23,13 +23,18 @@ package ch.threema.app.grouplinks;
 
 import android.content.Intent;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 
+import org.slf4j.Logger;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.ActionMode;
@@ -38,17 +43,14 @@ import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
-
-import org.slf4j.Logger;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ThreemaToolbarActivity;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.ContactResult;
+import ch.threema.app.asynctasks.ContactAvailable;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
@@ -62,16 +64,21 @@ import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteData;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.storage.DatabaseServiceNew;
+import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 import ch.threema.storage.models.group.OutgoingGroupJoinRequestModel;
@@ -98,6 +105,11 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 	private GroupService groupService;
 	private ContactService contactService;
 	private DatabaseServiceNew databaseService;
+	private APIConnector apiConnector;
+	private ContactModelRepository contactModelRepository;
+
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
 	private OutgoingGroupRequestViewModel viewModel;
 	private GroupInviteData groupInvite;
@@ -169,6 +181,8 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 			this.userService = serviceManager.getUserService();
 			this.groupService = serviceManager.getGroupService();
 			this.databaseService = serviceManager.getDatabaseServiceNew();
+			this.apiConnector = serviceManager.getAPIConnector();
+			this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
 		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 			logger.error("Exception, services not available... finishing");
 			finish();
@@ -412,36 +426,32 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 			if (this.resendRequestReference == null) {
 				// first add contact and fetch public key to be able to send a request
 				if (contactService.getByIdentity(groupInvite.getAdminIdentity()) == null) {
-					new AsyncTask<Void, Void, Exception>() {
-						@Override
-						protected void onPreExecute() {
-							// no preparation steps needed
-						}
-
-						@Override
-						protected Exception doInBackground(Void... params) {
-							try {
-								contactService.createContactByIdentity(groupInvite.getAdminIdentity(), true);
-								return null;
-							} catch (Exception e) {
-								return e;
-							}
-						}
-
-						@Override
-						protected void onPostExecute(Exception exception) {
-							if (isDestroyed()) {
-								return;
-							}
-							try {
-								outgoingGroupJoinRequestService.send(
-									groupInvite,
-									message);
-							} catch (Exception e) {
-								LogUtil.error("Exception, sending request after adding contact failed" + e, OutgoingGroupRequestActivity.this);
+					backgroundExecutor.get().execute(
+						new BasicAddOrUpdateContactBackgroundTask(
+							groupInvite.getAdminIdentity(),
+							ContactModel.AcquaintanceLevel.DIRECT,
+							userService.getIdentity(),
+							apiConnector,
+							contactModelRepository,
+							AddContactRestrictionPolicy.CHECK,
+							this,
+							null
+						) {
+							@Override
+							public void onFinished(ContactResult result) {
+								if (result instanceof ContactAvailable) {
+									if (isDestroyed()) {
+										return;
+									}
+									try {
+										outgoingGroupJoinRequestService.send(groupInvite, message);
+									} catch (Exception e) {
+										logger.error("Sending request after adding contact failed", e);
+									}
+								}
 							}
 						}
-					}.execute();
+					);
 				} else {
 					outgoingGroupJoinRequestService.send(
 						groupInvite,

+ 1 - 1
app/src/main/java/ch/threema/app/listeners/ContactListener.java

@@ -39,7 +39,7 @@ public interface ContactListener {
 	/**
 	 * Called when the contact avatar was changed.
 	 */
-	@AnyThread default void onAvatarChanged(final ContactModel contactModel) { }
+	@AnyThread default void onAvatarChanged(final @NonNull String identity) { }
 
 	/**
 	 * The contact was removed.

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

@@ -31,9 +31,9 @@ public interface GroupListener {
 	@AnyThread default void onUpdatePhoto(GroupModel groupModel) { }
 	@AnyThread default void onRemove(GroupModel groupModel) { }
 
-	@AnyThread default void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) { }
-	@AnyThread default void onMemberLeave(GroupModel group, String identity, int previousMemberCount) { }
-	@AnyThread default void onMemberKicked(GroupModel group, String identity, int previousMemberCount) { }
+	@AnyThread default void onNewMember(GroupModel group, String newIdentity) { }
+	@AnyThread default void onMemberLeave(GroupModel group, String identity) { }
+	@AnyThread default void onMemberKicked(GroupModel group, String identity) { }
 
 	/**
 	 * Group was updated.

+ 1 - 1
app/src/main/java/ch/threema/app/listeners/NewSyncedContactsListener.java

@@ -24,7 +24,7 @@ package ch.threema.app.listeners;
 import java.util.List;
 
 import androidx.annotation.AnyThread;
-import ch.threema.storage.models.ContactModel;
+import ch.threema.data.models.ContactModel;
 
 /**
  * Listen for new contacts added via sync.

+ 18 - 6
app/src/main/java/ch/threema/app/managers/CoreServiceManager.kt

@@ -21,11 +21,13 @@
 
 package ch.threema.app.managers
 
-import ch.threema.app.multidevice.MultiDeviceManagerImpl
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.stores.IdentityStore
 import ch.threema.app.stores.PreferenceStoreInterface
-import ch.threema.app.tasks.TaskArchiverImpl
-import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.base.crypto.NonceFactory
 import ch.threema.domain.models.AppVersion
+import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
+import ch.threema.domain.taskmanager.TaskArchiver
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
 
@@ -55,13 +57,13 @@ interface CoreServiceManager {
      * The task archiver. Note that this must only be used to load the persisted tasks when the
      * service manager has been set.
      */
-    val taskArchiver: TaskArchiverImpl
+    val taskArchiver: TaskArchiver
 
     /**
      * The device cookie manager. Note that this must only be used when the notification service is
      * passed to it.
      */
-    val deviceCookieManager: DeviceCookieManagerImpl
+    val deviceCookieManager: DeviceCookieManager
 
     /**
      * The task manager. Note that this must only be used to schedule tasks when the task archiver
@@ -72,6 +74,16 @@ interface CoreServiceManager {
     /**
      * The multi device manager.
      */
-    val multiDeviceManager: MultiDeviceManagerImpl
+    val multiDeviceManager: MultiDeviceManager
+
+    /**
+     * The identity store.
+     */
+    val identityStore: IdentityStore
+
+    /**
+     * The nonce factory.
+     */
+    val nonceFactory: NonceFactory
 
 }

+ 9 - 0
app/src/main/java/ch/threema/app/managers/CoreServiceManagerImpl.kt

@@ -24,14 +24,17 @@ package ch.threema.app.managers
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.ServerMessageServiceImpl
+import ch.threema.app.stores.IdentityStore
 import ch.threema.app.stores.PreferenceStoreInterface
 import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.base.crypto.NonceFactory
 import ch.threema.domain.models.AppVersion
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.TaskManagerConfiguration
 import ch.threema.domain.taskmanager.TaskManagerProvider
+import ch.threema.storage.DatabaseNonceStore
 import ch.threema.storage.DatabaseServiceNew
 
 /**
@@ -43,6 +46,8 @@ class CoreServiceManagerImpl(
     override val version: AppVersion,
     override val databaseService: DatabaseServiceNew,
     override val preferenceStore: PreferenceStoreInterface,
+    override val identityStore: IdentityStore,
+    private val nonceDatabaseStoreProvider: () -> DatabaseNonceStore,
 ) : CoreServiceManager {
 
     /**
@@ -94,4 +99,8 @@ class CoreServiceManagerImpl(
         )
     }
 
+    /**
+     * The nonce factory.
+     */
+    override val nonceFactory: NonceFactory by lazy { NonceFactory(nonceDatabaseStoreProvider()) }
 }

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

@@ -22,6 +22,9 @@
 package ch.threema.app.managers;
 
 import android.content.Context;
+import android.os.Build;
+
+import com.datatheorem.android.trustkit.pinning.OkHttp3Helper;
 
 import org.slf4j.Logger;
 
@@ -45,7 +48,6 @@ import ch.threema.app.emojis.search.EmojiSearchIndex;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.NoIdentityException;
 import ch.threema.app.multidevice.MultiDeviceManager;
-import ch.threema.app.multidevice.linking.DeviceJoinDataCollector;
 import ch.threema.app.processors.IncomingMessageProcessorImpl;
 import ch.threema.app.services.ActivityService;
 import ch.threema.app.services.ApiService;
@@ -150,1101 +152,1078 @@ import ch.threema.domain.protocol.connection.csp.DeviceCookieManager;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.stores.DHSessionStoreInterface;
-import ch.threema.domain.taskmanager.ActiveTaskCodec;
 import ch.threema.domain.taskmanager.IncomingMessageProcessor;
 import ch.threema.domain.taskmanager.TaskManager;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import java8.util.function.Supplier;
 import okhttp3.OkHttpClient;
 
 public class ServiceManager {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("ServiceManager");
-
-	@NonNull
-	private final CoreServiceManager coreServiceManager;
-	@NonNull
-	private final Supplier<Boolean> isIpv6Preferred;
-	@NonNull
-	private final IdentityStore identityStore;
-	@NonNull
-	private final MasterKey masterKey;
-	@NonNull
-	private final UpdateSystemService updateSystemService;
-	@NonNull
-	private final CacheService cacheService;
-	@Nullable
-	private DatabaseContactStore contactStore;
-	@Nullable
-	private APIConnector apiConnector;
-	@Nullable
-	private ContactService contactService;
-	@Nullable
-	private UserService userService;
-	@Nullable
-	private MessageService messageService;
-	@Nullable
-	private QRCodeService qrCodeService;
-	@Nullable
-	private FileService fileService;
-	@Nullable
-	private PreferenceService preferencesService;
-	@Nullable
-	private LocaleService localeService;
-	@Nullable
-	private DeviceService deviceService;
-	@Nullable
-	private LifetimeService lifetimeService;
-	@Nullable
-	private AvatarCacheService avatarCacheService;
-	@Nullable
-	private LicenseService licenseService;
-	@Nullable
-	private BackupRestoreDataService backupRestoreDataService;
-	@Nullable
-	private GroupService groupService;
-	@Nullable
-	private GroupInviteService groupInviteService;
-	@Nullable
-	private GroupJoinResponseService groupJoinResponseService;
-	@Nullable
-	private IncomingGroupJoinRequestService incomingGroupJoinRequestService;
-	@Nullable
-	private OutgoingGroupJoinRequestService outgoingGroupJoinRequestService;
-	@Nullable
-	private LockAppService lockAppService;
-	@Nullable
-	private ActivityService activityService;
-	@Nullable
-	private ApiService apiService;
-	@Nullable
-	private ConversationService conversationService;
-	@Nullable
-	private NotificationService notificationService;
-	@Nullable
-	private SynchronizeContactsService synchronizeContactsService;
-	@Nullable
-	private SystemScreenLockService systemScreenLockService;
-
-	@Nullable
-	private IdListService blockedContactsService, excludedSyncIdentitiesService, profilePicRecipientsService;
-	@Nullable
-	private DeadlineListService mutedChatsListService, hiddenChatListService, mentionOnlyChatsListService;
-	@Nullable
-	private DistributionListService distributionListService;
-	@Nullable
-	private IncomingMessageProcessor incomingMessageProcessor;
-	@Nullable
-	private MessagePlayerService messagePlayerService = null;
-	@Nullable
-	private DownloadServiceImpl downloadService;
-	@Nullable
-	private BallotService ballotService;
-	@Nullable
-	private WallpaperService wallpaperService;
-	@Nullable
-	private ThreemaSafeService threemaSafeService;
-	@Nullable
-	private RingtoneService ringtoneService;
-	@Nullable
-	private BackupChatService backupChatService;
-	@NonNull
-	private final DatabaseServiceNew databaseServiceNew;
-	@NonNull
-	private final ModelRepositories modelRepositories;
-	@Nullable
-	private SensorService sensorService;
-	@Nullable
-	private VoipStateService voipStateService;
-	@Nullable
-	private GroupCallManager groupCallManager;
-	@Nullable
-	private SfuConnection sfuConnection;
-	@Nullable
-	private BrowserDetectionService browserDetectionService;
-	@Nullable
-	private ConversationTagServiceImpl conversationTagService;
-	@Nullable
-	private ServerAddressProviderService serverAddressProviderService;
-	@Nullable
-	private WebClientServiceManager webClientServiceManager;
-
-	@NonNull
-	private final DHSessionStoreInterface dhSessionStore;
-
-	@Nullable
-	private ForwardSecurityMessageProcessor forwardSecurityMessageProcessor;
-
-	@Nullable
-	private SymmetricEncryptionService symmetricEncryptionService;
-
-	@Nullable
-	private EmojiService emojiService;
-
-	@Nullable
-	private NonceFactory nonceFactory;
-
-	@Nullable
-	private TaskCreator taskCreator;
-
-	@Nullable
-	private DeviceJoinDataCollector deviceJoinDataCollector;
-
-	@NonNull
-	private final ConvertibleServerConnection connection;
-	@NonNull
-	private final LazyProperty<OkHttpClient> okHttpClient = new LazyProperty<>(this::createOkHttpClient);
-
-	public ServiceManager(
-		@NonNull ModelRepositories modelRepositories,
-		@NonNull DHSessionStoreInterface dhSessionStore,
-		@NonNull IdentityStore identityStore,
-		@NonNull MasterKey masterKey,
-		@NonNull CoreServiceManager coreServiceManager,
-		@NonNull UpdateSystemService updateSystemService
-	) throws ThreemaException {
-		this.cacheService = new CacheService();
-		this.coreServiceManager = coreServiceManager;
-		this.isIpv6Preferred = new LazyProperty<>(() -> getPreferenceService().isIpv6Preferred());
-		this.identityStore = identityStore;
-		this.masterKey = masterKey;
-		this.databaseServiceNew = coreServiceManager.getDatabaseService();
-		this.modelRepositories = modelRepositories;
-		this.dhSessionStore = dhSessionStore;
-		this.updateSystemService = updateSystemService;
-		// Finalize initialization of task archiver and device cookie manager before the connection
-		// is created.
-		coreServiceManager.getTaskArchiver().setServiceManager(this);
-		coreServiceManager.getDeviceCookieManager().setNotificationService(getNotificationService());
-		this.connection = createServerConnection();
-		coreServiceManager.getMultiDeviceManager().setReconnectHandle(connection);
-	}
-
-	@NonNull
-	public DatabaseContactStore getContactStore() {
-		if (this.contactStore == null) {
-			this.contactStore = new DatabaseContactStore(
-					this.getIdentityStore(),
-					this.getDHSessionStore(),
-					this.databaseServiceNew,
-					this.getServerAddressProviderService().getServerAddressProvider()
-			);
-		}
-
-		return this.contactStore;
-	}
-
-	@NonNull
-	public APIConnector getAPIConnector() {
-		if (this.apiConnector == null) {
-			try {
-				this.apiConnector = new APIConnector(
-					isIpv6Preferred.get(),
-					this.getServerAddressProviderService().getServerAddressProvider(),
-					ConfigUtils.isWorkBuild(),
-					ConfigUtils::getSSLSocketFactory
-				);
-				this.apiConnector.setVersion(ThreemaApplication.getAppVersion());
-				this.apiConnector.setLanguage(Locale.getDefault().getLanguage());
-
-				if (BuildFlavor.getCurrent().getLicenseType() == BuildFlavor.LicenseType.ONPREM) {
-					// On Premise always requires Basic authentication
-					PreferenceService preferenceService = this.getPreferenceService();
-					this.apiConnector.setAuthenticator(urlConnection -> {
-						if (preferenceService.getLicenseUsername() != null) {
-							String auth = preferenceService.getLicenseUsername() + ":" + preferenceService.getLicensePassword();
-							urlConnection.setRequestProperty("Authorization", "Basic " + Base64.encodeBytes(auth.getBytes(StandardCharsets.UTF_8)));
-						}
-					});
-				}
-			} catch (Exception e) {
-				logger.error("Exception", e);
-			}
-		}
-
-		return this.apiConnector;
-	}
-
-	/**
-	 * Start the server connection. Do not call this directly; use the LifetimeService!
-	 */
-	public void startConnection() throws ThreemaException {
-		logger.trace("startConnection");
-
-		String currentIdentity = this.identityStore.getIdentity();
-		if (currentIdentity == null || currentIdentity.isEmpty()) {
-			throw new NoIdentityException();
-		}
-
-		if(this.masterKey.isLocked()) {
-			throw new MasterKeyLockedException("master key is locked");
-		}
-
-		logger.info("Starting connection");
-		this.connection.start();
-	}
-
-	@NonNull
-	public PreferenceStoreInterface getPreferenceStore() {
-		return coreServiceManager.getPreferenceStore();
-	}
-
-	/**
-	 * Stop the connection. Do not call this directly; use the LifetimeService!
-	 */
-	public void stopConnection() throws InterruptedException {
-		logger.info("Stopping connection");
-		InterruptedException interrupted = null;
-		try {
-			this.connection.stop();
-		} catch (InterruptedException e) {
-			logger.error("Interrupted while stopping connection");
-			interrupted = e;
-		}
-
-		// Re-set interrupted flag
-		if (interrupted != null) {
-			Thread.currentThread().interrupt();
-			throw interrupted;
-		}
-	}
-
-	@WorkerThread
-	private void reconnectConnection() throws InterruptedException {
-		connection.reconnect();
-	}
-
-	@NonNull
-	public UserService getUserService() {
-		if (this.userService == null) {
-			try {
-				this.userService = new UserServiceImpl(
-						this.getContext(),
-						this.coreServiceManager.getPreferenceStore(),
-						this.getLocaleService(),
-						this.getAPIConnector(),
-						this.getIdentityStore(),
-						this.getPreferenceService());
-				// TODO(ANDR-2519): Remove when md allows fs
-				this.userService.setForwardSecurityEnabled(getMultiDeviceManager().isMdDisabledOrSupportsFs());
-			} catch (Exception e) {
-				logger.error("Exception", e);
-			}
-		}
-
-		return this.userService;
-	}
-
-	public @NonNull ContactService getContactService() throws MasterKeyLockedException, FileSystemNotPresentException {
-		if (this.contactService == null) {
-			if(this.masterKey.isLocked()) {
-				throw new MasterKeyLockedException("master key is locked");
-			}
-			this.contactService = new ContactServiceImpl(
-				this.getContext(),
-				this.getContactStore(),
-				this.getAvatarCacheService(),
-				this.databaseServiceNew,
-				this.getDeviceService(),
-				this.getUserService(),
-				this.getIdentityStore(),
-				this.getPreferenceService(),
-				this.getBlockedContactsService(),
-				this.getProfilePicRecipientsService(),
-				this.getRingtoneService(),
-				this.getMutedChatsListService(),
-				this.getHiddenChatsListService(),
-				this.getFileService(),
-				this.cacheService,
-				this.getApiService(),
-				this.getWallpaperService(),
-				this.getLicenseService(),
-				this.getAPIConnector(),
-				this.getModelRepositories().getContacts()
-			);
-		}
-
-		return this.contactService;
-	}
-
-	@NonNull
-	public MessageService getMessageService() throws ThreemaException {
-		if (this.messageService == null) {
-			this.messageService = new MessageServiceImpl(
-					this.getContext(),
-					this.cacheService,
-					this.databaseServiceNew,
-					this.getContactService(),
-					this.getFileService(),
-					this.getIdentityStore(),
-					this.getSymmetricEncryptionService(),
-					this.getPreferenceService(),
-					this.getLockAppService(),
-					this.getBallotService(),
-					this.getGroupService(),
-					this.getApiService(),
-					this.getDownloadService(),
-					this.getHiddenChatsListService(),
-					this.getBlockedContactsService(),
-                    this.getModelRepositories().getEditHistory()
-			);
-		}
-
-		return this.messageService;
-	}
-
-	@NonNull
-	public PreferenceService getPreferenceService() {
-		if (this.preferencesService == null) {
-			this.preferencesService = new PreferenceServiceImpl(
-					this.getContext(),
-					this.coreServiceManager.getPreferenceStore()
-			);
-		}
-		return this.preferencesService;
-	}
-
-	@NonNull
-	public QRCodeService getQRCodeService() {
-		if (this.qrCodeService == null) {
-			this.qrCodeService = new QRCodeServiceImpl(this.getUserService());
-		}
-
-		return this.qrCodeService;
-	}
-
-	@NonNull
-	public FileService getFileService() throws FileSystemNotPresentException {
-		if (this.fileService == null) {
-			this.fileService = new FileServiceImpl(
-					this.getContext(),
-					this.masterKey,
-					this.getPreferenceService()
-			);
-		}
-
-		return this.fileService;
-	}
-
-	@NonNull
-	public LocaleService getLocaleService() {
-		if (this.localeService == null) {
-			this.localeService = new LocaleServiceImpl(this.getContext());
-		}
-
-		return this.localeService;
-	}
-
-	@NonNull
-	public ServerConnection getConnection() {
-		return this.connection;
-	}
-
-	@NonNull
-	public DeviceService getDeviceService() {
-		if(this.deviceService == null) {
-			this.deviceService = new DeviceServiceImpl(this.getContext());
-		}
-
-		return this.deviceService;
-	}
-
-	@NonNull
-	public LifetimeService getLifetimeService() {
-		if(this.lifetimeService == null) {
-			this.lifetimeService = new LifetimeServiceImpl(this.getContext());
-		}
-
-		return this.lifetimeService;
-	}
-
-	@NonNull
-	public AvatarCacheService getAvatarCacheService() throws FileSystemNotPresentException {
-		if(this.avatarCacheService == null) {
-			this.avatarCacheService = new AvatarCacheServiceImpl(this.getContext());
-		}
-
-		return this.avatarCacheService;
-	}
-
-	/**
-	 * @return service to backup or restore data (conversations and contacts)
-	 */
-	public @NonNull BackupRestoreDataService getBackupRestoreDataService() throws FileSystemNotPresentException {
-		if(this.backupRestoreDataService == null) {
-			this.backupRestoreDataService = new BackupRestoreDataServiceImpl(this.getFileService());
-		}
-
-		return this.backupRestoreDataService;
-	}
-
-	@NonNull
-	public LicenseService getLicenseService() throws FileSystemNotPresentException {
-		if(this.licenseService == null) {
-			switch(BuildFlavor.getCurrent().getLicenseType()) {
-				case SERIAL:
-					this.licenseService = new LicenseServiceSerial(
-							this.getAPIConnector(),
-							this.getPreferenceService(),
-							DeviceIdUtil.getDeviceId(getContext()));
-					break;
-				case GOOGLE_WORK:
-				case HMS_WORK:
-				case ONPREM:
-					this.licenseService = new LicenseServiceUser(
-						this.getAPIConnector(),
-						this.getPreferenceService(),
-						DeviceIdUtil.getDeviceId(getContext()));
-					break;
-				default:
-					this.licenseService = new LicenseService() {
-						@Override
-						public String validate(Credentials credentials) {
-							return null;
-						}
-
-						@Override
-						public String validate(boolean allowException) {
-							return null;
-						}
-						@Override
-						public boolean hasCredentials() {
-							return false;
-						}
-
-						@Override
-						public boolean isLicensed() {
-							return true;
-						}
-
-						@Override
-						public Credentials loadCredentials() {
-							return null;
-						}
-					};
-			}
-
-		}
-
-		return this.licenseService;
-	}
-
-	@NonNull
-	public LockAppService getLockAppService() {
-		if(null == this.lockAppService) {
-			this.lockAppService = new PinLockService(
-					this.getContext(),
-					this.getPreferenceService(),
-					this.getUserService()
-			);
-		}
-
-		return this.lockAppService;
-	}
-
-	@NonNull
-	public ActivityService getActivityService() {
-		if(null == this.activityService) {
-			this.activityService = new ActivityService(
-					this.getContext(),
-					this.getLockAppService(),
-					this.getPreferenceService());
-		}
-		return this.activityService;
-	}
-
-	@NonNull
-	public GroupService getGroupService() throws MasterKeyLockedException, FileSystemNotPresentException {
-		if (null == this.groupService) {
-			this.groupService = new GroupServiceImpl(
-				this.getContext(),
-				this.cacheService,
-				this.getUserService(),
-				this.getContactService(),
-				this.databaseServiceNew,
-				this.getAvatarCacheService(),
-				this.getFileService(),
-				this.getWallpaperService(),
-				this.getMutedChatsListService(),
-				this.getHiddenChatsListService(),
-				this.getRingtoneService(),
-				this.getConversationTagService(),
-				this
-			);
-		}
-		return this.groupService;
-	}
-
-	@NonNull
-	public GroupInviteService getGroupInviteService() throws FileSystemNotPresentException, MasterKeyLockedException {
-		if (this.groupInviteService == null) {
-			this.groupInviteService = new GroupInviteServiceImpl(
-				this.getUserService(),
-				this.getGroupService(),
-				this.getDatabaseServiceNew()
-			);
-		}
-		return this.groupInviteService;
-	}
-
-	@NonNull
-	public GroupJoinResponseService getGroupJoinResponseService() {
-		if (this.groupJoinResponseService == null) {
-			this.groupJoinResponseService = new GroupJoinResponseServiceImpl(
-				this.getDatabaseServiceNew()
-			);
-		}
-		return this.groupJoinResponseService;
-	}
-
-	@NonNull
-	public IncomingGroupJoinRequestService getIncomingGroupJoinRequestService() throws FileSystemNotPresentException, MasterKeyLockedException {
-		if (this.incomingGroupJoinRequestService == null) {
-			this.incomingGroupJoinRequestService = new IncomingGroupJoinRequestServiceImpl(
-				this.getGroupJoinResponseService(),
-				this.getGroupService(),
-				this.getUserService(),
-				this.databaseServiceNew
-			);
-		}
-		return this.incomingGroupJoinRequestService;
-	}
-
-	@NonNull
-	public OutgoingGroupJoinRequestService getOutgoingGroupJoinRequestService() {
-		if (this.outgoingGroupJoinRequestService == null) {
-			this.outgoingGroupJoinRequestService = new OutgoingGroupJoinRequestServiceImpl(
-				this.getDatabaseServiceNew()
-			);
-		}
-		return this.outgoingGroupJoinRequestService;
-	}
-
-	@NonNull
-	public ApiService getApiService() {
-		if(null == this.apiService) {
-			this.apiService = new ApiServiceImpl(
-				ThreemaApplication.getAppVersion(),
-				isIpv6Preferred.get(),
-				this.getAPIConnector(),
-				new AuthTokenStore(),
-				this.getServerAddressProviderService().getServerAddressProvider()
-			);
-		}
-		return this.apiService;
-	}
-
-	@NonNull
-	public DistributionListService getDistributionListService() throws MasterKeyLockedException, NoIdentityException, FileSystemNotPresentException {
-		if(null == this.distributionListService) {
-			this.distributionListService = new DistributionListServiceImpl(
-				this.getContext(),
-				this.getAvatarCacheService(),
-				this.databaseServiceNew,
-				this.getContactService(),
-				this.getConversationTagService()
-			);
-		}
-
-		return this.distributionListService;
-	}
-
-	@NonNull
-	public ConversationTagService getConversationTagService() {
-		if (this.conversationTagService == null) {
-			this.conversationTagService = new ConversationTagServiceImpl(this.databaseServiceNew);
-		}
-
-		return this.conversationTagService;
-	}
-
-	@NonNull
-	public ConversationService getConversationService() throws ThreemaException {
-		if(null == this.conversationService) {
-			this.conversationService = new ConversationServiceImpl(
-				this.getContext(),
-				this.cacheService,
-				this.databaseServiceNew,
-				this.getContactService(),
-				this.getGroupService(),
-				this.getDistributionListService(),
-				this.getMessageService(),
-				this.getHiddenChatsListService(),
-				this.getBlockedContactsService(),
-				this.getConversationTagService()
-			);
-		}
-
-		return this.conversationService;
-	}
-
-	@NonNull
-	public ServerAddressProviderService getServerAddressProviderService() {
-		if(null == this.serverAddressProviderService) {
-			this.serverAddressProviderService = new ServerAddressProviderServiceImpl(this.getPreferenceService());
-		}
-
-		return this.serverAddressProviderService;
-	}
-
-	@NonNull
-	public NotificationService getNotificationService() {
-		if(this.notificationService == null) {
-			this.notificationService = new NotificationServiceImpl(
-					this.getContext(),
-					this.getLockAppService(),
-					this.getHiddenChatsListService(),
-					this.getPreferenceService(),
-					this.getRingtoneService()
-			);
-		}
-		return this.notificationService;
-	}
-
-	@NonNull
-	public SynchronizeContactsService getSynchronizeContactsService() throws MasterKeyLockedException, FileSystemNotPresentException {
-		if(this.synchronizeContactsService == null) {
-			this.synchronizeContactsService = new SynchronizeContactsServiceImpl(
-				this.getContext(),
-				this.getAPIConnector(),
-				this.getContactService(),
-				this.getModelRepositories().getContacts(),
-				this.getUserService(),
-				this.getLocaleService(),
-				this.getExcludedSyncIdentitiesService(),
-				this.getPreferenceService(),
-				this.getDeviceService(),
-				this.getFileService(),
-				this.getIdentityStore(),
-				this.getBlockedContactsService(),
-				this.getApiService()
-			);
-		}
-
-		return this.synchronizeContactsService;
-	}
-
-	@NonNull
-	public IdListService getBlockedContactsService() {
-		if(this.blockedContactsService == null) {
+    private static final Logger logger = LoggingUtil.getThreemaLogger("ServiceManager");
+
+    @NonNull
+    private final CoreServiceManager coreServiceManager;
+    @NonNull
+    private final Supplier<Boolean> isIpv6Preferred;
+    @NonNull
+    private final MasterKey masterKey;
+    @NonNull
+    private final UpdateSystemService updateSystemService;
+    @NonNull
+    private final CacheService cacheService;
+    @Nullable
+    private DatabaseContactStore contactStore;
+    @Nullable
+    private APIConnector apiConnector;
+    @Nullable
+    private ContactService contactService;
+    @Nullable
+    private UserService userService;
+    @Nullable
+    private MessageService messageService;
+    @Nullable
+    private QRCodeService qrCodeService;
+    @Nullable
+    private FileService fileService;
+    @Nullable
+    private PreferenceService preferencesService;
+    @Nullable
+    private LocaleService localeService;
+    @Nullable
+    private DeviceService deviceService;
+    @Nullable
+    private LifetimeService lifetimeService;
+    @Nullable
+    private AvatarCacheService avatarCacheService;
+    @Nullable
+    private LicenseService licenseService;
+    @Nullable
+    private BackupRestoreDataService backupRestoreDataService;
+    @Nullable
+    private GroupService groupService;
+    @Nullable
+    private GroupInviteService groupInviteService;
+    @Nullable
+    private GroupJoinResponseService groupJoinResponseService;
+    @Nullable
+    private IncomingGroupJoinRequestService incomingGroupJoinRequestService;
+    @Nullable
+    private OutgoingGroupJoinRequestService outgoingGroupJoinRequestService;
+    @Nullable
+    private LockAppService lockAppService;
+    @Nullable
+    private ActivityService activityService;
+    @Nullable
+    private ApiService apiService;
+    @Nullable
+    private ConversationService conversationService;
+    @Nullable
+    private NotificationService notificationService;
+    @Nullable
+    private SynchronizeContactsService synchronizeContactsService;
+    @Nullable
+    private SystemScreenLockService systemScreenLockService;
+
+    @Nullable
+    private IdListService blockedContactsService, excludedSyncIdentitiesService, profilePicRecipientsService;
+    @Nullable
+    private DeadlineListService mutedChatsListService, hiddenChatListService, mentionOnlyChatsListService;
+    @Nullable
+    private DistributionListService distributionListService;
+    @Nullable
+    private IncomingMessageProcessor incomingMessageProcessor;
+    @Nullable
+    private MessagePlayerService messagePlayerService = null;
+    @Nullable
+    private DownloadServiceImpl downloadService;
+    @Nullable
+    private BallotService ballotService;
+    @Nullable
+    private WallpaperService wallpaperService;
+    @Nullable
+    private ThreemaSafeService threemaSafeService;
+    @Nullable
+    private RingtoneService ringtoneService;
+    @Nullable
+    private BackupChatService backupChatService;
+    @NonNull
+    private final DatabaseServiceNew databaseServiceNew;
+    @NonNull
+    private final ModelRepositories modelRepositories;
+    @Nullable
+    private SensorService sensorService;
+    @Nullable
+    private VoipStateService voipStateService;
+    @Nullable
+    private GroupCallManager groupCallManager;
+    @Nullable
+    private SfuConnection sfuConnection;
+    @Nullable
+    private BrowserDetectionService browserDetectionService;
+    @Nullable
+    private ConversationTagServiceImpl conversationTagService;
+    @Nullable
+    private ServerAddressProviderService serverAddressProviderService;
+    @Nullable
+    private WebClientServiceManager webClientServiceManager;
+
+    @NonNull
+    private final DHSessionStoreInterface dhSessionStore;
+
+    @Nullable
+    private ForwardSecurityMessageProcessor forwardSecurityMessageProcessor;
+
+    @Nullable
+    private SymmetricEncryptionService symmetricEncryptionService;
+
+    @Nullable
+    private EmojiService emojiService;
+
+    @Nullable
+    private TaskCreator taskCreator;
+
+    @NonNull
+    private final ConvertibleServerConnection connection;
+
+    @NonNull
+    private final LazyProperty<OkHttpClient> okHttpClient = new LazyProperty<>(this::createOkHttpClient);
+
+    public ServiceManager(
+        @NonNull ModelRepositories modelRepositories,
+        @NonNull DHSessionStoreInterface dhSessionStore,
+        @NonNull MasterKey masterKey,
+        @NonNull CoreServiceManagerImpl coreServiceManager,
+        @NonNull UpdateSystemService updateSystemService
+    ) throws ThreemaException {
+        this.cacheService = new CacheService();
+        this.coreServiceManager = coreServiceManager;
+        this.isIpv6Preferred = new LazyProperty<>(() -> getPreferenceService().isIpv6Preferred());
+        this.masterKey = masterKey;
+        this.databaseServiceNew = coreServiceManager.getDatabaseService();
+        this.modelRepositories = modelRepositories;
+        this.dhSessionStore = dhSessionStore;
+        this.updateSystemService = updateSystemService;
+        // Finalize initialization of task archiver and device cookie manager before the connection
+        // is created.
+        coreServiceManager.getTaskArchiver().setServiceManager(this);
+        coreServiceManager.getDeviceCookieManager().setNotificationService(getNotificationService());
+        this.connection = createServerConnection();
+        coreServiceManager.getMultiDeviceManager().setReconnectHandle(connection);
+    }
+
+    @NonNull
+    public DatabaseContactStore getContactStore() {
+        if (this.contactStore == null) {
+            this.contactStore = new DatabaseContactStore(
+                this.databaseServiceNew,
+                this.getServerAddressProviderService().getServerAddressProvider()
+            );
+        }
+
+        return this.contactStore;
+    }
+
+    @NonNull
+    public APIConnector getAPIConnector() {
+        if (this.apiConnector == null) {
+            try {
+                this.apiConnector = new APIConnector(
+                    isIpv6Preferred.get(),
+                    this.getServerAddressProviderService().getServerAddressProvider(),
+                    ConfigUtils.isWorkBuild(),
+                    ConfigUtils::getSSLSocketFactory
+                );
+                this.apiConnector.setVersion(ThreemaApplication.getAppVersion());
+                this.apiConnector.setLanguage(Locale.getDefault().getLanguage());
+
+                if (BuildFlavor.getCurrent().getLicenseType() == BuildFlavor.LicenseType.ONPREM) {
+                    // On Premise always requires Basic authentication
+                    PreferenceService preferenceService = this.getPreferenceService();
+                    this.apiConnector.setAuthenticator(urlConnection -> {
+                        if (preferenceService.getLicenseUsername() != null) {
+                            String auth = preferenceService.getLicenseUsername() + ":" + preferenceService.getLicensePassword();
+                            urlConnection.setRequestProperty("Authorization", "Basic " + Base64.encodeBytes(auth.getBytes(StandardCharsets.UTF_8)));
+                        }
+                    });
+                }
+            } catch (Exception e) {
+                logger.error("Exception", e);
+            }
+        }
+
+        return this.apiConnector;
+    }
+
+    /**
+     * Start the server connection. Do not call this directly; use the LifetimeService!
+     */
+    public void startConnection() throws ThreemaException {
+        logger.trace("startConnection");
+
+        String currentIdentity = this.coreServiceManager.getIdentityStore().getIdentity();
+        if (currentIdentity == null || currentIdentity.isEmpty()) {
+            throw new NoIdentityException();
+        }
+
+        if (this.masterKey.isLocked()) {
+            throw new MasterKeyLockedException("master key is locked");
+        }
+
+        logger.info("Starting connection");
+        this.connection.start();
+    }
+
+    @NonNull
+    public PreferenceStoreInterface getPreferenceStore() {
+        return coreServiceManager.getPreferenceStore();
+    }
+
+    /**
+     * Stop the connection. Do not call this directly; use the LifetimeService!
+     */
+    public void stopConnection() throws InterruptedException {
+        logger.info("Stopping connection");
+        InterruptedException interrupted = null;
+        try {
+            this.connection.stop();
+        } catch (InterruptedException e) {
+            logger.error("Interrupted while stopping connection");
+            interrupted = e;
+        }
+
+        // Re-set interrupted flag
+        if (interrupted != null) {
+            Thread.currentThread().interrupt();
+            throw interrupted;
+        }
+    }
+
+    @WorkerThread
+    private void reconnectConnection() throws InterruptedException {
+        connection.reconnect();
+    }
+
+    @NonNull
+    public UserService getUserService() {
+        if (this.userService == null) {
+            try {
+                this.userService = new UserServiceImpl(
+                    this.getContext(),
+                    this.coreServiceManager.getPreferenceStore(),
+                    this.getLocaleService(),
+                    this.getAPIConnector(),
+                    this.getApiService(),
+                    this.getFileService(),
+                    this.getIdentityStore(),
+                    this.getPreferenceService(),
+                    this.getTaskManager(),
+                    this.getTaskCreator(),
+                    this.getMultiDeviceManager()
+                );
+                // TODO(ANDR-2519): Remove when md allows fs
+                this.userService.setForwardSecurityEnabled(getMultiDeviceManager().isMdDisabledOrSupportsFs());
+            } catch (Exception e) {
+                logger.error("Exception", e);
+            }
+        }
+
+        return this.userService;
+    }
+
+    public @NonNull ContactService getContactService() throws MasterKeyLockedException, FileSystemNotPresentException {
+        if (this.contactService == null) {
+            if (this.masterKey.isLocked()) {
+                throw new MasterKeyLockedException("master key is locked");
+            }
+            this.contactService = new ContactServiceImpl(
+                this.getContext(),
+                this.getContactStore(),
+                this.getAvatarCacheService(),
+                this.databaseServiceNew,
+                this.getUserService(),
+                this.getIdentityStore(),
+                this.getPreferenceService(),
+                this.getBlockedContactsService(),
+                this.getProfilePicRecipientsService(),
+                this.getFileService(),
+                this.cacheService,
+                this.getApiService(),
+                this.getLicenseService(),
+                this.getAPIConnector(),
+                this.getModelRepositories().getContacts(),
+                this.getTaskCreator(),
+                this.getMultiDeviceManager()
+            );
+        }
+
+        return this.contactService;
+    }
+
+    @NonNull
+    public MessageService getMessageService() throws ThreemaException {
+        if (this.messageService == null) {
+            this.messageService = new MessageServiceImpl(
+                this.getContext(),
+                this.cacheService,
+                this.databaseServiceNew,
+                this.getContactService(),
+                this.getFileService(),
+                this.getIdentityStore(),
+                this.getSymmetricEncryptionService(),
+                this.getPreferenceService(),
+                this.getLockAppService(),
+                this.getBallotService(),
+                this.getGroupService(),
+                this.getApiService(),
+                this.getDownloadService(),
+                this.getHiddenChatsListService(),
+                this.getBlockedContactsService(),
+                this.getModelRepositories().getEditHistory(),
+                this.getMultiDeviceManager()
+            );
+        }
+
+        return this.messageService;
+    }
+
+    @NonNull
+    public PreferenceService getPreferenceService() {
+        if (this.preferencesService == null) {
+            this.preferencesService = new PreferenceServiceImpl(
+                this.getContext(),
+                this.coreServiceManager.getPreferenceStore()
+            );
+        }
+        return this.preferencesService;
+    }
+
+    @NonNull
+    public QRCodeService getQRCodeService() {
+        if (this.qrCodeService == null) {
+            this.qrCodeService = new QRCodeServiceImpl(this.getUserService());
+        }
+
+        return this.qrCodeService;
+    }
+
+    @NonNull
+    public FileService getFileService() throws FileSystemNotPresentException {
+        if (this.fileService == null) {
+            this.fileService = new FileServiceImpl(
+                this.getContext(),
+                this.masterKey,
+                this.getPreferenceService(),
+                this.getAvatarCacheService()
+            );
+        }
+
+        return this.fileService;
+    }
+
+    @NonNull
+    public LocaleService getLocaleService() {
+        if (this.localeService == null) {
+            this.localeService = new LocaleServiceImpl(this.getContext());
+        }
+
+        return this.localeService;
+    }
+
+    @NonNull
+    public ServerConnection getConnection() {
+        return this.connection;
+    }
+
+    @NonNull
+    public DeviceService getDeviceService() {
+        if (this.deviceService == null) {
+            this.deviceService = new DeviceServiceImpl(this.getContext());
+        }
+
+        return this.deviceService;
+    }
+
+    @NonNull
+    public LifetimeService getLifetimeService() {
+        if (this.lifetimeService == null) {
+            this.lifetimeService = new LifetimeServiceImpl(this.getContext());
+        }
+
+        return this.lifetimeService;
+    }
+
+    @NonNull
+    public AvatarCacheService getAvatarCacheService() throws FileSystemNotPresentException {
+        if (this.avatarCacheService == null) {
+            this.avatarCacheService = new AvatarCacheServiceImpl(this.getContext());
+        }
+
+        return this.avatarCacheService;
+    }
+
+    /**
+     * @return service to backup or restore data (conversations and contacts)
+     */
+    public @NonNull BackupRestoreDataService getBackupRestoreDataService() throws FileSystemNotPresentException {
+        if (this.backupRestoreDataService == null) {
+            this.backupRestoreDataService = new BackupRestoreDataServiceImpl(this.getFileService());
+        }
+
+        return this.backupRestoreDataService;
+    }
+
+    @NonNull
+    public LicenseService getLicenseService() throws FileSystemNotPresentException {
+        if (this.licenseService == null) {
+            switch (BuildFlavor.getCurrent().getLicenseType()) {
+                case SERIAL:
+                    this.licenseService = new LicenseServiceSerial(
+                        this.getAPIConnector(),
+                        this.getPreferenceService(),
+                        DeviceIdUtil.getDeviceId(getContext()));
+                    break;
+                case GOOGLE_WORK:
+                case HMS_WORK:
+                case ONPREM:
+                    this.licenseService = new LicenseServiceUser(
+                        this.getAPIConnector(),
+                        this.getPreferenceService(),
+                        DeviceIdUtil.getDeviceId(getContext()));
+                    break;
+                default:
+                    this.licenseService = new LicenseService() {
+                        @Override
+                        public String validate(Credentials credentials) {
+                            return null;
+                        }
+
+                        @Override
+                        public String validate(boolean allowException) {
+                            return null;
+                        }
+
+                        @Override
+                        public boolean hasCredentials() {
+                            return false;
+                        }
+
+                        @Override
+                        public boolean isLicensed() {
+                            return true;
+                        }
+
+                        @Override
+                        public Credentials loadCredentials() {
+                            return null;
+                        }
+                    };
+            }
+
+        }
+
+        return this.licenseService;
+    }
+
+    @NonNull
+    public LockAppService getLockAppService() {
+        if (null == this.lockAppService) {
+            this.lockAppService = new PinLockService(
+                this.getContext(),
+                this.getPreferenceService(),
+                this.getUserService()
+            );
+        }
+
+        return this.lockAppService;
+    }
+
+    @NonNull
+    public ActivityService getActivityService() {
+        if (null == this.activityService) {
+            this.activityService = new ActivityService(
+                this.getContext(),
+                this.getLockAppService(),
+                this.getPreferenceService());
+        }
+        return this.activityService;
+    }
+
+    @NonNull
+    public GroupService getGroupService() throws MasterKeyLockedException, FileSystemNotPresentException {
+        if (null == this.groupService) {
+            this.groupService = new GroupServiceImpl(
+                this.getContext(),
+                this.cacheService,
+                this.getUserService(),
+                this.getContactService(),
+                this.databaseServiceNew,
+                this.getAvatarCacheService(),
+                this.getFileService(),
+                this.getWallpaperService(),
+                this.getMutedChatsListService(),
+                this.getHiddenChatsListService(),
+                this.getRingtoneService(),
+                this.getConversationTagService(),
+                this
+            );
+        }
+        return this.groupService;
+    }
+
+    @NonNull
+    public GroupInviteService getGroupInviteService() throws FileSystemNotPresentException, MasterKeyLockedException {
+        if (this.groupInviteService == null) {
+            this.groupInviteService = new GroupInviteServiceImpl(
+                this.getUserService(),
+                this.getGroupService(),
+                this.getDatabaseServiceNew()
+            );
+        }
+        return this.groupInviteService;
+    }
+
+    @NonNull
+    public GroupJoinResponseService getGroupJoinResponseService() {
+        if (this.groupJoinResponseService == null) {
+            this.groupJoinResponseService = new GroupJoinResponseServiceImpl(
+                this.getDatabaseServiceNew()
+            );
+        }
+        return this.groupJoinResponseService;
+    }
+
+    @NonNull
+    public IncomingGroupJoinRequestService getIncomingGroupJoinRequestService() throws FileSystemNotPresentException, MasterKeyLockedException {
+        if (this.incomingGroupJoinRequestService == null) {
+            this.incomingGroupJoinRequestService = new IncomingGroupJoinRequestServiceImpl(
+                this.getGroupJoinResponseService(),
+                this.getGroupService(),
+                this.getUserService(),
+                this.databaseServiceNew
+            );
+        }
+        return this.incomingGroupJoinRequestService;
+    }
+
+    @NonNull
+    public OutgoingGroupJoinRequestService getOutgoingGroupJoinRequestService() {
+        if (this.outgoingGroupJoinRequestService == null) {
+            this.outgoingGroupJoinRequestService = new OutgoingGroupJoinRequestServiceImpl(
+                this.getDatabaseServiceNew()
+            );
+        }
+        return this.outgoingGroupJoinRequestService;
+    }
+
+    @NonNull
+    public ApiService getApiService() {
+        if (null == this.apiService) {
+            this.apiService = new ApiServiceImpl(
+                ThreemaApplication.getAppVersion(),
+                isIpv6Preferred.get(),
+                this.getAPIConnector(),
+                new AuthTokenStore(),
+                this.getServerAddressProviderService().getServerAddressProvider(),
+                this.getMultiDeviceManager(),
+                this.getOkHttpClient()
+            );
+        }
+        return this.apiService;
+    }
+
+    @NonNull
+    public DistributionListService getDistributionListService() throws MasterKeyLockedException, NoIdentityException, FileSystemNotPresentException {
+        if (null == this.distributionListService) {
+            this.distributionListService = new DistributionListServiceImpl(
+                this.getContext(),
+                this.getAvatarCacheService(),
+                this.databaseServiceNew,
+                this.getContactService(),
+                this.getConversationTagService()
+            );
+        }
+
+        return this.distributionListService;
+    }
+
+    @NonNull
+    public ConversationTagService getConversationTagService() {
+        if (this.conversationTagService == null) {
+            this.conversationTagService = new ConversationTagServiceImpl(this.databaseServiceNew);
+        }
+
+        return this.conversationTagService;
+    }
+
+    @NonNull
+    public ConversationService getConversationService() throws ThreemaException {
+        if (null == this.conversationService) {
+            this.conversationService = new ConversationServiceImpl(
+                this.getContext(),
+                this.cacheService,
+                this.databaseServiceNew,
+                this.getContactService(),
+                this.getGroupService(),
+                this.getDistributionListService(),
+                this.getMessageService(),
+                this.getHiddenChatsListService(),
+                this.getBlockedContactsService(),
+                this.getConversationTagService()
+            );
+        }
+
+        return this.conversationService;
+    }
+
+    @NonNull
+    public ServerAddressProviderService getServerAddressProviderService() {
+        if (null == this.serverAddressProviderService) {
+            this.serverAddressProviderService = new ServerAddressProviderServiceImpl(this.getPreferenceService());
+        }
+
+        return this.serverAddressProviderService;
+    }
+
+    @NonNull
+    public NotificationService getNotificationService() {
+        if (this.notificationService == null) {
+            this.notificationService = new NotificationServiceImpl(
+                this.getContext(),
+                this.getLockAppService(),
+                this.getHiddenChatsListService(),
+                this.getPreferenceService(),
+                this.getRingtoneService()
+            );
+        }
+        return this.notificationService;
+    }
+
+    @NonNull
+    public SynchronizeContactsService getSynchronizeContactsService() throws MasterKeyLockedException, FileSystemNotPresentException {
+        if (this.synchronizeContactsService == null) {
+            this.synchronizeContactsService = new SynchronizeContactsServiceImpl(
+                this.getContext(),
+                this.getAPIConnector(),
+                this.getContactService(),
+                this.getModelRepositories().getContacts(),
+                this.getUserService(),
+                this.getLocaleService(),
+                this.getExcludedSyncIdentitiesService(),
+                this.getPreferenceService(),
+                this.getDeviceService(),
+                this.getFileService(),
+                this.getIdentityStore(),
+                this.getBlockedContactsService(),
+                this.getApiService()
+            );
+        }
+
+        return this.synchronizeContactsService;
+    }
+
+    @NonNull
+    public IdListService getBlockedContactsService() {
+        if (this.blockedContactsService == null) {
             // Keep the uniqueListName `identity_list_blacklist` to avoid a migration of the key in the preferences
-			this.blockedContactsService = new IdListServiceImpl("identity_list_blacklist", this.getPreferenceService());
-		}
-		return this.blockedContactsService;
-	}
-
-	@NonNull
-	public DeadlineListService getMutedChatsListService() {
-		if(this.mutedChatsListService == null) {
-			this.mutedChatsListService = new DeadlineListServiceImpl("list_muted_chats", this.getPreferenceService());
-		}
-		return this.mutedChatsListService;
-	}
-
-	@NonNull
-	public DeadlineListService getHiddenChatsListService() {
-		if(this.hiddenChatListService == null) {
-			this.hiddenChatListService = new DeadlineListServiceImpl("list_hidden_chats", this.getPreferenceService());
-		}
-		return this.hiddenChatListService;
-	}
-
-	@NonNull
-	public DeadlineListService getMentionOnlyChatsListService() {
-		if(this.mentionOnlyChatsListService == null) {
-			this.mentionOnlyChatsListService = new DeadlineListServiceImpl("list_mention_only", this.getPreferenceService());
-		}
-		return this.mentionOnlyChatsListService;
-	}
-
-	@NonNull
-	public IdListService getExcludedSyncIdentitiesService() {
-		if(this.excludedSyncIdentitiesService == null) {
-			this.excludedSyncIdentitiesService = new IdListServiceImpl("identity_list_sync_excluded", this.getPreferenceService());
-		}
-		return this.excludedSyncIdentitiesService;
-	}
-
-	@NonNull
-	public UpdateSystemService getUpdateSystemService() {
-		return this.updateSystemService;
-	}
-
-	@NonNull
-	public MessagePlayerService getMessagePlayerService() throws ThreemaException {
-		if(this.messagePlayerService == null) {
-			this.messagePlayerService = new MessagePlayerServiceImpl(
-					getContext(),
-					this.getMessageService(),
-					this.getFileService(),
-					this.getPreferenceService(),
-					this.getHiddenChatsListService()
-			);
-		}
-		return this.messagePlayerService;
-	}
-
-	@NonNull
-	public DownloadService getDownloadService() throws FileSystemNotPresentException {
-		if (this.downloadService == null) {
-			this.downloadService = new DownloadServiceImpl(
-					this.getContext(),
-					this.getFileService(),
-					this.getApiService()
-			);
-		}
-		return this.downloadService;
-	}
-
-	@NonNull
-	public BallotService getBallotService() throws NoIdentityException, MasterKeyLockedException, FileSystemNotPresentException {
-		if(this.ballotService == null) {
-			this.ballotService = new BallotServiceImpl(
-					this.cacheService.getBallotModelCache(),
-					this.cacheService.getLinkBallotModelCache(),
-					this.databaseServiceNew,
-					this.getUserService(),
-					this.getGroupService(),
-					this.getContactService(),
-					this);
-		}
-		return this.ballotService;
-	}
-
-	@NonNull
-	public WallpaperService getWallpaperService() throws FileSystemNotPresentException {
-		if(this.wallpaperService == null) {
-			this.wallpaperService = new WallpaperServiceImpl(this.getContext(),
-					this.getFileService(),
-					this.getPreferenceService(),
-					this.masterKey
-			);
-		}
-
-		return this.wallpaperService;
-	}
-
-	public @NonNull ThreemaSafeService getThreemaSafeService() throws FileSystemNotPresentException, MasterKeyLockedException, NoIdentityException {
-		if (this.threemaSafeService == null) {
-			this.threemaSafeService = new ThreemaSafeServiceImpl(
-				this.getContext(),
-				this.getPreferenceService(),
-				this.getUserService(),
-				this.getContactService(),
-				this.getGroupService(),
-				this.getDistributionListService(),
-				this.getLocaleService(),
-				this.getFileService(),
-				this.getBlockedContactsService(),
-				this.getExcludedSyncIdentitiesService(),
-				this.getProfilePicRecipientsService(),
-				this.getDatabaseServiceNew(),
-				this.getIdentityStore(),
-				this.getApiService(),
-				this.getAPIConnector(),
-				this.getHiddenChatsListService(),
-				this.getServerAddressProviderService().getServerAddressProvider(),
-				this.getPreferenceStore()
-			);
-		}
-		return this.threemaSafeService;
-	}
-
-	@NonNull
-	public Context getContext() {
-		return ThreemaApplication.getAppContext();
-	}
-
-	@NonNull
-	public IdentityStore getIdentityStore() {
-		return this.identityStore;
-	}
-
-	@NonNull
-	public RingtoneService getRingtoneService() {
-		if(this.ringtoneService == null) {
-			this.ringtoneService = new RingtoneServiceImpl(this.getPreferenceService());
-		}
-
-		return this.ringtoneService;
-	}
-
-	@NonNull
-	public BackupChatService getBackupChatService() throws ThreemaException {
-		if (this.backupChatService == null) {
-			this.backupChatService = new BackupChatServiceImpl(
-					this.getContext(),
-					this.getFileService(),
-					this.getMessageService(),
-					this.getContactService()
-			);
-		}
-
-		return this.backupChatService;
-	}
-
-	@NonNull
-	public SystemScreenLockService getScreenLockService() {
-		if(this.systemScreenLockService == null) {
-			this.systemScreenLockService = new SystemScreenLockServiceImpl(
-					this.getContext(),
-					this.getLockAppService(),
-					this.getPreferenceService()
-			);
-		}
-		return this.systemScreenLockService;
-	}
-
-	@NonNull
-	public SensorService getSensorService() {
-		if (this.sensorService == null) {
-			this.sensorService = new SensorServiceImpl(this.getContext());
-		}
-		return this.sensorService;
-	}
-
-	@NonNull
-	public WebClientServiceManager getWebClientServiceManager() throws ThreemaException {
-		if (this.webClientServiceManager == null) {
-			this.webClientServiceManager = new WebClientServiceManager(new ServicesContainer(
-				this.getContext().getApplicationContext(),
-				this.getLifetimeService(),
-				this.getContactService(),
-				this.getGroupService(),
-				this.getDistributionListService(),
-				this.getConversationService(),
-				this.getConversationTagService(),
-				this.getMessageService(),
-				this.getNotificationService(),
-				this.databaseServiceNew,
-				this.getBlockedContactsService(),
-				this.getPreferenceService(),
-				this.getUserService(),
-				this.getHiddenChatsListService(),
-				this.getFileService(),
-				this.getSynchronizeContactsService(),
-				this.getLicenseService()
-			));
-		}
-		return this.webClientServiceManager;
-	}
-
-	@NonNull
-	public BrowserDetectionService getBrowserDetectionService() {
-		if (this.browserDetectionService == null) {
-			this.browserDetectionService = new BrowserDetectionServiceImpl();
-		}
-		return this.browserDetectionService;
-	}
-
-	@NonNull
-	public IdListService getProfilePicRecipientsService() {
-		if(this.profilePicRecipientsService == null) {
-			this.profilePicRecipientsService = new IdListServiceImpl("identity_list_profilepics", this.getPreferenceService());
-		}
-		return this.profilePicRecipientsService;
-	}
-
-	@NonNull
-	public VoipStateService getVoipStateService() throws ThreemaException {
-		if (this.voipStateService == null) {
-			this.voipStateService = new VoipStateService(
-					this.getContactService(),
-					this.getRingtoneService(),
-					this.getPreferenceService(),
-					this.getLifetimeService(),
-					this.getContext()
-			);
-		}
-		return this.voipStateService;
-	}
-
-	@NonNull
-	public DatabaseServiceNew getDatabaseServiceNew() {
-		return this.databaseServiceNew;
-	}
-
-	@NonNull
-	public ModelRepositories getModelRepositories() {
-		return this.modelRepositories;
-	}
-
-	@NonNull
-	public DHSessionStoreInterface getDHSessionStore() {
-		return this.dhSessionStore;
-	}
-
-	@NonNull
-	public ForwardSecurityMessageProcessor getForwardSecurityMessageProcessor() throws ThreemaException {
-		if (this.forwardSecurityMessageProcessor == null) {
-			this.forwardSecurityMessageProcessor = new ForwardSecurityMessageProcessor(
-				this.getDHSessionStore(),
-				this.getContactStore(),
-				this.getIdentityStore(),
-				this.getNonceFactory(),
-				new ForwardSecurityStatusSender(
-					this.getContactService(),
-					this.getMessageService(),
-					this.getAPIConnector()
-				)
-			);
-			// TODO(ANDR-2519): Remove when md allows fs
-			forwardSecurityMessageProcessor.setForwardSecurityEnabled(getMultiDeviceManager().isMdDisabledOrSupportsFs());
-		}
-		return this.forwardSecurityMessageProcessor;
-	}
-
-	@NonNull
-	public SymmetricEncryptionService getSymmetricEncryptionService() {
-		if (symmetricEncryptionService == null) {
-			symmetricEncryptionService = new SymmetricEncryptionService();
-		}
-		return symmetricEncryptionService;
-	}
-
-	@NonNull
-	public EmojiService getEmojiService() {
-		if (emojiService == null) {
-			EmojiSearchIndex searchIndex = new EmojiSearchIndex(
-				getContext().getApplicationContext(),
-				getPreferenceService()
-			);
-			emojiService = new EmojiService(
-				getPreferenceService(),
-				searchIndex,
-				new EmojiRecent(getPreferenceService())
-			);
-		}
-		return emojiService;
-	}
-
-	@NonNull
-	public GroupCallManager getGroupCallManager() throws ThreemaException {
-		if (groupCallManager == null) {
-			groupCallManager = new GroupCallManagerImpl(
-				getContext().getApplicationContext(),
-				this,
-				getDatabaseServiceNew(),
-				getGroupService(),
-				getContactService(),
-				getPreferenceService(),
-				getMessageService(),
-				getNotificationService(),
-				getSfuConnection()
-			);
-		}
-		return groupCallManager;
-	}
-
-	@NonNull
-	public SfuConnection getSfuConnection() {
-		if (sfuConnection == null) {
-			sfuConnection = new SfuConnectionImpl(
-				getAPIConnector(),
-				getIdentityStore(),
-				ThreemaApplication.getAppVersion()
-			);
-		}
-		return sfuConnection;
-	}
-
-	public @NonNull NonceFactory getNonceFactory() {
-		if (nonceFactory == null) {
-			DatabaseNonceStore databaseNonceStore = new DatabaseNonceStore(getContext(), identityStore);
-			databaseNonceStore.executeNull();
-			logger.info("Nonce count: " + databaseNonceStore.getCount());
-			nonceFactory = new NonceFactory(databaseNonceStore);
-		}
-		return nonceFactory;
-	}
-
-	private @NonNull IncomingMessageProcessor getIncomingMessageProcessor() throws ThreemaException {
-		if (this.incomingMessageProcessor == null) {
-			this.incomingMessageProcessor = new IncomingMessageProcessorImpl(
-				getMessageService(),
-				getNonceFactory(),
-				getForwardSecurityMessageProcessor(),
-				getContactService(),
-				getContactStore(),
-				getIdentityStore(),
-				getBlockedContactsService(),
-				getPreferenceService(),
-				this
-			);
-		}
-		return this.incomingMessageProcessor;
-	}
-
-	public @NonNull TaskManager getTaskManager() {
-		return this.coreServiceManager.getTaskManager();
-	}
-
-	public @NonNull TaskCreator getTaskCreator() {
-		if (this.taskCreator == null) {
-			this.taskCreator = new TaskCreator(this);
-		}
-		return this.taskCreator;
-	}
-
-	@NonNull
-	public MultiDeviceManager getMultiDeviceManager() {
-		return this.coreServiceManager.getMultiDeviceManager();
-	}
-
-	@NonNull
-	public DeviceJoinDataCollector getDeviceJoinDataCollector() {
-		if (deviceJoinDataCollector == null) {
-			deviceJoinDataCollector = new DeviceJoinDataCollector(this);
-			return deviceJoinDataCollector;
-		}
-		return deviceJoinDataCollector;
-	}
-
-	/**
-	 * Get a task handle. This task handle can be used to send messages.
-	 *
-	 * @deprecated Note that we should only be able to send messages inside a task (where we have
-	 * the task handle anyway). This task handle is only available in the migration phase until we
-	 * have switched completely to tasks.
-	 *
-	 * @return the task handle during the migration phase
-	 */
-	@Deprecated
-	public @NonNull ActiveTaskCodec getMigrationTaskHandle() {
-		return getTaskManager().getMigrationTaskHandle();
-	}
-
-	@NonNull
-	private ConvertibleServerConnection createServerConnection() throws ThreemaException {
-		Supplier<ServerConnection> connectionSupplier = new CspD2mDualConnectionSupplier(
-			getMultiDeviceManager(),
-			getIncomingMessageProcessor(),
-			getTaskManager(),
-			getDeviceCookieManager(),
-			getServerAddressProviderService(),
-			getIdentityStore(),
-			coreServiceManager.getVersion(),
-			isIpv6Preferred.get(),
-			okHttpClient,
-			ConfigUtils.isDevBuild()
-		);
-		return new ConvertibleServerConnection(connectionSupplier);
-	}
-
-	@NonNull
-	public DeviceCookieManager getDeviceCookieManager() {
-		return coreServiceManager.getDeviceCookieManager();
-	}
-
-	@NonNull
-	private OkHttpClient createOkHttpClient() {
-		logger.debug("Create OkHttpClient");
-		return new OkHttpClient.Builder()
-			.connectTimeout(ProtocolDefines.CONNECT_TIMEOUT, TimeUnit.SECONDS)
-			.writeTimeout(ProtocolDefines.WRITE_TIMEOUT, TimeUnit.SECONDS)
-			.readTimeout(ProtocolDefines.READ_TIMEOUT, TimeUnit.SECONDS)
-			.build();
-	}
+            this.blockedContactsService = new IdListServiceImpl("identity_list_blacklist", this.getPreferenceService());
+        }
+        return this.blockedContactsService;
+    }
+
+    @NonNull
+    public DeadlineListService getMutedChatsListService() {
+        if (this.mutedChatsListService == null) {
+            this.mutedChatsListService = new DeadlineListServiceImpl("list_muted_chats", this.getPreferenceService());
+        }
+        return this.mutedChatsListService;
+    }
+
+    @NonNull
+    public DeadlineListService getHiddenChatsListService() {
+        if (this.hiddenChatListService == null) {
+            this.hiddenChatListService = new DeadlineListServiceImpl("list_hidden_chats", this.getPreferenceService());
+        }
+        return this.hiddenChatListService;
+    }
+
+    @NonNull
+    public DeadlineListService getMentionOnlyChatsListService() {
+        if (this.mentionOnlyChatsListService == null) {
+            this.mentionOnlyChatsListService = new DeadlineListServiceImpl("list_mention_only", this.getPreferenceService());
+        }
+        return this.mentionOnlyChatsListService;
+    }
+
+    @NonNull
+    public IdListService getExcludedSyncIdentitiesService() {
+        if (this.excludedSyncIdentitiesService == null) {
+            this.excludedSyncIdentitiesService = new IdListServiceImpl("identity_list_sync_excluded", this.getPreferenceService());
+        }
+        return this.excludedSyncIdentitiesService;
+    }
+
+    @NonNull
+    public UpdateSystemService getUpdateSystemService() {
+        return this.updateSystemService;
+    }
+
+    @NonNull
+    public MessagePlayerService getMessagePlayerService() throws ThreemaException {
+        if (this.messagePlayerService == null) {
+            this.messagePlayerService = new MessagePlayerServiceImpl(
+                getContext(),
+                this.getMessageService(),
+                this.getFileService(),
+                this.getPreferenceService(),
+                this.getHiddenChatsListService()
+            );
+        }
+        return this.messagePlayerService;
+    }
+
+    @NonNull
+    public DownloadService getDownloadService() throws FileSystemNotPresentException {
+        if (this.downloadService == null) {
+            this.downloadService = new DownloadServiceImpl(
+                this.getContext(),
+                this.getFileService(),
+                this.getApiService()
+            );
+        }
+        return this.downloadService;
+    }
+
+    @NonNull
+    public BallotService getBallotService() throws NoIdentityException, MasterKeyLockedException, FileSystemNotPresentException {
+        if (this.ballotService == null) {
+            this.ballotService = new BallotServiceImpl(
+                this.cacheService.getBallotModelCache(),
+                this.cacheService.getLinkBallotModelCache(),
+                this.databaseServiceNew,
+                this.getUserService(),
+                this.getGroupService(),
+                this.getContactService(),
+                this);
+        }
+        return this.ballotService;
+    }
+
+    @NonNull
+    public WallpaperService getWallpaperService() throws FileSystemNotPresentException {
+        if (this.wallpaperService == null) {
+            this.wallpaperService = new WallpaperServiceImpl(this.getContext(),
+                this.getFileService(),
+                this.getPreferenceService(),
+                this.masterKey
+            );
+        }
+
+        return this.wallpaperService;
+    }
+
+    public @NonNull ThreemaSafeService getThreemaSafeService() throws FileSystemNotPresentException, MasterKeyLockedException, NoIdentityException {
+        if (this.threemaSafeService == null) {
+            this.threemaSafeService = new ThreemaSafeServiceImpl(
+                this.getContext(),
+                this.getPreferenceService(),
+                this.getUserService(),
+                this.getContactService(),
+                this.getGroupService(),
+                this.getDistributionListService(),
+                this.getLocaleService(),
+                this.getFileService(),
+                this.getBlockedContactsService(),
+                this.getExcludedSyncIdentitiesService(),
+                this.getProfilePicRecipientsService(),
+                this.getDatabaseServiceNew(),
+                this.getIdentityStore(),
+                this.getApiService(),
+                this.getAPIConnector(),
+                this.getHiddenChatsListService(),
+                this.getServerAddressProviderService().getServerAddressProvider(),
+                this.getPreferenceStore(),
+                this.getModelRepositories().getContacts()
+            );
+        }
+        return this.threemaSafeService;
+    }
+
+    @NonNull
+    public Context getContext() {
+        return ThreemaApplication.getAppContext();
+    }
+
+    @NonNull
+    public IdentityStore getIdentityStore() {
+        return this.coreServiceManager.getIdentityStore();
+    }
+
+    @NonNull
+    public RingtoneService getRingtoneService() {
+        if (this.ringtoneService == null) {
+            this.ringtoneService = new RingtoneServiceImpl(this.getPreferenceService());
+        }
+
+        return this.ringtoneService;
+    }
+
+    @NonNull
+    public BackupChatService getBackupChatService() throws ThreemaException {
+        if (this.backupChatService == null) {
+            this.backupChatService = new BackupChatServiceImpl(
+                this.getContext(),
+                this.getFileService(),
+                this.getMessageService(),
+                this.getContactService()
+            );
+        }
+
+        return this.backupChatService;
+    }
+
+    @NonNull
+    public SystemScreenLockService getScreenLockService() {
+        if (this.systemScreenLockService == null) {
+            this.systemScreenLockService = new SystemScreenLockServiceImpl(
+                this.getContext(),
+                this.getLockAppService(),
+                this.getPreferenceService()
+            );
+        }
+        return this.systemScreenLockService;
+    }
+
+    @NonNull
+    public SensorService getSensorService() {
+        if (this.sensorService == null) {
+            this.sensorService = new SensorServiceImpl(this.getContext());
+        }
+        return this.sensorService;
+    }
+
+    @NonNull
+    public WebClientServiceManager getWebClientServiceManager() throws ThreemaException {
+        if (this.webClientServiceManager == null) {
+            this.webClientServiceManager = new WebClientServiceManager(new ServicesContainer(
+                this.getContext().getApplicationContext(),
+                this.getLifetimeService(),
+                this.getContactService(),
+                this.getGroupService(),
+                this.getDistributionListService(),
+                this.getConversationService(),
+                this.getConversationTagService(),
+                this.getMessageService(),
+                this.getNotificationService(),
+                this.databaseServiceNew,
+                this.getBlockedContactsService(),
+                this.getPreferenceService(),
+                this.getUserService(),
+                this.getHiddenChatsListService(),
+                this.getFileService(),
+                this.getSynchronizeContactsService(),
+                this.getLicenseService(),
+                this.getAPIConnector(),
+                this.getModelRepositories().getContacts()
+            ));
+        }
+        return this.webClientServiceManager;
+    }
+
+    @NonNull
+    public BrowserDetectionService getBrowserDetectionService() {
+        if (this.browserDetectionService == null) {
+            this.browserDetectionService = new BrowserDetectionServiceImpl();
+        }
+        return this.browserDetectionService;
+    }
+
+    @NonNull
+    public IdListService getProfilePicRecipientsService() {
+        if (this.profilePicRecipientsService == null) {
+            this.profilePicRecipientsService = new IdListServiceImpl("identity_list_profilepics", this.getPreferenceService());
+        }
+        return this.profilePicRecipientsService;
+    }
+
+    @NonNull
+    public VoipStateService getVoipStateService() throws ThreemaException {
+        if (this.voipStateService == null) {
+            this.voipStateService = new VoipStateService(
+                this.getContactService(),
+                this.getRingtoneService(),
+                this.getPreferenceService(),
+                this.getLifetimeService(),
+                this.getContext()
+            );
+        }
+        return this.voipStateService;
+    }
+
+    @NonNull
+    public DatabaseServiceNew getDatabaseServiceNew() {
+        return this.databaseServiceNew;
+    }
+
+    @NonNull
+    public ModelRepositories getModelRepositories() {
+        return this.modelRepositories;
+    }
+
+    @NonNull
+    public DHSessionStoreInterface getDHSessionStore() {
+        return this.dhSessionStore;
+    }
+
+    @NonNull
+    public ForwardSecurityMessageProcessor getForwardSecurityMessageProcessor() throws ThreemaException {
+        if (this.forwardSecurityMessageProcessor == null) {
+            this.forwardSecurityMessageProcessor = new ForwardSecurityMessageProcessor(
+                this.getDHSessionStore(),
+                this.getContactStore(),
+                this.getIdentityStore(),
+                this.getNonceFactory(),
+                new ForwardSecurityStatusSender(
+                    this.getContactService(),
+                    this.getMessageService(),
+                    this.getAPIConnector(),
+                    this.getUserService(),
+                    this.getModelRepositories().getContacts()
+                )
+            );
+            // TODO(ANDR-2519): Remove when md allows fs
+            forwardSecurityMessageProcessor.setForwardSecurityEnabled(getMultiDeviceManager().isMdDisabledOrSupportsFs());
+        }
+        return this.forwardSecurityMessageProcessor;
+    }
+
+    @NonNull
+    public SymmetricEncryptionService getSymmetricEncryptionService() {
+        if (symmetricEncryptionService == null) {
+            symmetricEncryptionService = new SymmetricEncryptionService();
+        }
+        return symmetricEncryptionService;
+    }
+
+    @NonNull
+    public EmojiService getEmojiService() {
+        if (emojiService == null) {
+            EmojiSearchIndex searchIndex = new EmojiSearchIndex(
+                getContext().getApplicationContext(),
+                getPreferenceService()
+            );
+            emojiService = new EmojiService(
+                getPreferenceService(),
+                searchIndex,
+                new EmojiRecent(getPreferenceService())
+            );
+        }
+        return emojiService;
+    }
+
+    @NonNull
+    public GroupCallManager getGroupCallManager() throws ThreemaException {
+        if (groupCallManager == null) {
+            groupCallManager = new GroupCallManagerImpl(
+                getContext().getApplicationContext(),
+                this,
+                getDatabaseServiceNew(),
+                getGroupService(),
+                getContactService(),
+                getPreferenceService(),
+                getMessageService(),
+                getNotificationService(),
+                getSfuConnection()
+            );
+        }
+        return groupCallManager;
+    }
+
+    @NonNull
+    public SfuConnection getSfuConnection() {
+        if (sfuConnection == null) {
+            sfuConnection = new SfuConnectionImpl(
+                getAPIConnector(),
+                getIdentityStore(),
+                ThreemaApplication.getAppVersion()
+            );
+        }
+        return sfuConnection;
+    }
+
+    public @NonNull NonceFactory getNonceFactory() {
+        return coreServiceManager.getNonceFactory();
+    }
+
+    private @NonNull IncomingMessageProcessor getIncomingMessageProcessor() {
+        if (this.incomingMessageProcessor == null) {
+            this.incomingMessageProcessor = new IncomingMessageProcessorImpl(this);
+        }
+        return this.incomingMessageProcessor;
+    }
+
+    public @NonNull TaskManager getTaskManager() {
+        return this.coreServiceManager.getTaskManager();
+    }
+
+    public @NonNull TaskCreator getTaskCreator() {
+        if (this.taskCreator == null) {
+            this.taskCreator = new TaskCreator(this);
+        }
+        return this.taskCreator;
+    }
+
+    @NonNull
+    public MultiDeviceManager getMultiDeviceManager() {
+        return this.coreServiceManager.getMultiDeviceManager();
+    }
+
+    @NonNull
+    public OkHttpClient getOkHttpClient() {
+        return okHttpClient.get();
+    }
+
+    @NonNull
+    private ConvertibleServerConnection createServerConnection() throws ThreemaException {
+        Supplier<ServerConnection> connectionSupplier = new CspD2mDualConnectionSupplier(
+            getMultiDeviceManager(),
+            getIncomingMessageProcessor(),
+            getTaskManager(),
+            getDeviceCookieManager(),
+            getServerAddressProviderService(),
+            getIdentityStore(),
+            coreServiceManager.getVersion(),
+            isIpv6Preferred.get(),
+            okHttpClient,
+            ConfigUtils.isDevBuild()
+        );
+        return new ConvertibleServerConnection(connectionSupplier);
+    }
+
+    @NonNull
+    public DeviceCookieManager getDeviceCookieManager() {
+        return coreServiceManager.getDeviceCookieManager();
+    }
+
+    @NonNull
+    private OkHttpClient createOkHttpClient() {
+        logger.debug("Create OkHttpClient");
+        OkHttpClient.Builder builder = new OkHttpClient.Builder()
+            .connectTimeout(ProtocolDefines.CONNECT_TIMEOUT, TimeUnit.SECONDS)
+            .writeTimeout(ProtocolDefines.WRITE_TIMEOUT, TimeUnit.SECONDS)
+            .readTimeout(ProtocolDefines.READ_TIMEOUT, TimeUnit.SECONDS);
+
+        /*
+         * For android versions < 7.0 we have to explicitly configure the okhttp client
+         * to use certificate pinning via TrustKit.
+         * In on-prem builds we never pin.
+         */
+        if (!ConfigUtils.isOnPremBuild() && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+            builder.sslSocketFactory(OkHttp3Helper.getSSLSocketFactory(), OkHttp3Helper.getTrustManager());
+            builder.addInterceptor(OkHttp3Helper.getPinningInterceptor());
+        }
+
+        return builder.build();
+    }
 }

+ 47 - 26
app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java

@@ -36,17 +36,19 @@ import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.stores.IdentityStore;
+import ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask;
+import ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask;
 import ch.threema.app.tasks.OutgoingContactDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingContactEditMessageTask;
-import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
-import ch.threema.app.tasks.OutgoingPollVoteContactMessageTask;
-import ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingLocationMessageTask;
+import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
+import ch.threema.app.tasks.OutgoingPollVoteContactMessageTask;
 import ch.threema.app.tasks.OutgoingTextMessageTask;
 import ch.threema.app.tasks.OutgoingTypingIndicatorMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallAnswerMessageTask;
@@ -54,6 +56,7 @@ import ch.threema.app.tasks.OutgoingVoipCallHangupMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallOfferMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallRingingMessageTask;
 import ch.threema.app.tasks.OutgoingVoipICECandidateMessageTask;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
@@ -83,20 +86,21 @@ import ch.threema.storage.models.data.media.FileDataModel;
 public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	private final ContactModel contactModel;
 	private final ContactService contactService;
-	private Bitmap avatar = null;
 	@NonNull
 	private final ServiceManager serviceManager;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final IdentityStore identityStore;
 	private final IdListService blockedContactsService;
 	private final @NonNull TaskManager taskManager;
+	private final @NonNull MultiDeviceManager multiDeviceManager;
 
 	public ContactMessageReceiver(ContactModel contactModel,
 	                              ContactService contactService,
 	                              @NonNull ServiceManager serviceManager,
 	                              DatabaseServiceNew databaseServiceNew,
 	                              IdentityStore identityStore,
-	                              IdListService blockedContactsService) {
+	                              IdListService blockedContactsService
+	) {
 		this.contactModel = contactModel;
 		this.contactService = contactService;
 		this.serviceManager = serviceManager;
@@ -104,6 +108,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		this.identityStore = identityStore;
 		this.blockedContactsService = blockedContactsService;
 		this.taskManager = serviceManager.getTaskManager();
+		this.multiDeviceManager = serviceManager.getMultiDeviceManager();
 	}
 
 	protected ContactMessageReceiver(ContactMessageReceiver contactMessageReceiver) {
@@ -115,7 +120,6 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 			contactMessageReceiver.identityStore,
 			contactMessageReceiver.blockedContactsService
 		);
-		avatar = contactMessageReceiver.avatar;
 	}
 
 	@Override
@@ -163,7 +167,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		saveLocalModel(messageModel);
 
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		bumpLastUpdate();
@@ -178,7 +182,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	}
 
 	public void resendTextMessage(@NonNull MessageModel messageModel) {
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		scheduleTask(new OutgoingTextMessageTask(
@@ -196,7 +200,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		saveLocalModel(messageModel);
 
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		bumpLastUpdate();
@@ -212,7 +216,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 	public void resendLocationMessage(@NonNull MessageModel messageModel) {
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		// Schedule outgoing text message task
@@ -242,14 +246,14 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 		// Set file data model again explicitly to enforce that the body of the message is rewritten
 		// and therefore updated.
-		messageModel.setFileData(modelFileData);
+		messageModel.setFileDataModel(modelFileData);
 
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		saveLocalModel(messageModel);
 
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		// Note that lastUpdate lastUpdate was bumped when the file message was created
@@ -279,7 +283,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
 
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		bumpLastUpdate();
@@ -310,7 +314,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		}
 
 		// Mark the contact as non-hidden and unarchived
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 		// Schedule outgoing text message task
@@ -340,14 +344,33 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	 * @param receiptType the type of the delivery receipt
 	 * @param messageIds  the message ids
 	 */
-	public void sendDeliveryReceipt(int receiptType, @NonNull MessageId[] messageIds) {
+	public void sendDeliveryReceipt(int receiptType, @NonNull MessageId[] messageIds, long time) {
 		scheduleTask(
 			new OutgoingContactDeliveryReceiptMessageTask(
-				receiptType, messageIds, new Date().getTime(), contactModel.getIdentity(), serviceManager
+				receiptType, messageIds, time, contactModel.getIdentity(), serviceManager
 			)
 		);
 	}
 
+	/**
+	 * Send an incoming message update to mark the message as read. Note that this is the
+	 * alternative of {@link ContactMessageReceiver#sendDeliveryReceipt(int, MessageId[], long)}
+	 * when no delivery receipt should be sent. This method only schedules the outgoing message
+	 * update if multi device is activated.
+	 */
+	public void sendIncomingMessageUpdateRead(@NonNull Set<MessageId> messageIds, long timestamp) {
+		if (multiDeviceManager.isMultiDeviceActive()) {
+			scheduleTask(
+				new OutboundIncomingContactMessageUpdateReadTask(
+					messageIds,
+					timestamp,
+					contactModel.getIdentity(),
+					serviceManager
+				)
+			);
+		}
+	}
+
 	/**
 	 * Send a voip call offer message to the receiver.
 	 *
@@ -506,30 +529,28 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	@Override
 	@Nullable
 	public Bitmap getNotificationAvatar() {
-		if (avatar == null && contactService != null) {
-			avatar = contactService.getAvatar(contactModel, false);
-		}
-		return avatar;
+		return contactService.getAvatar(contactModel, false);
 	}
 
 	@Override
 	@Nullable
 	public Bitmap getAvatar() {
-		if (avatar == null && contactService != null) {
-			avatar = contactService.getAvatar(contactModel, true, true);
-		}
-		return avatar;
+		return contactService.getAvatar(contactModel, true, true);
 	}
 
 	@Deprecated
 	@Override
 	public int getUniqueId() {
-		return contactService.getUniqueId(contactModel);
+		return contactModel != null
+			? ContactUtil.getUniqueId(contactModel.getIdentity())
+			: 0;
 	}
 
 	@Override
 	public String getUniqueIdString() {
-		return contactService.getUniqueIdString(contactModel);
+		return contactModel != null
+			? ContactUtil.getUniqueIdString(contactModel.getIdentity())
+		    : "";
 	}
 
 	@Override

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

@@ -36,8 +36,10 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
@@ -71,10 +73,10 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	private final GroupModel group;
 	private final GroupService groupService;
-	private Bitmap avatar = null;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final @NonNull ServiceManager serviceManager;
 	private final TaskManager taskManager;
+	private final MultiDeviceManager multiDeviceManager;
 
 	public GroupMessageReceiver(
 		GroupModel group,
@@ -87,6 +89,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		this.databaseServiceNew = databaseServiceNew;
 		this.serviceManager = serviceManager;
 		this.taskManager = serviceManager.getTaskManager();
+		this.multiDeviceManager = serviceManager.getMultiDeviceManager();
 	}
 
 	@Override
@@ -126,7 +129,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	@Override
 	public void createAndSendTextMessage(@NonNull GroupMessageModel messageModel) {
-		Set<String> otherMembers = groupService.getOtherMembers(group);
+		Set<String> otherMembers = groupService.getMembersWithoutUser(group);
 
 		if (otherMembers.isEmpty()) {
 			// In case the recipients set is empty, we are sending the message in a notes group. In
@@ -161,7 +164,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	@Override
 	public void createAndSendLocationMessage(@NonNull GroupMessageModel messageModel) {
-		Set<String> otherMembers = groupService.getOtherMembers(group);
+		Set<String> otherMembers = groupService.getMembersWithoutUser(group);
 
 		if (otherMembers.isEmpty()) {
 			// In case the recipients set is empty, we are sending the message in a notes group. In
@@ -216,7 +219,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 		// Set file data model again explicitly to enforce that the body of the message is rewritten
 		// and therefore updated.
-		messageModel.setFileData(modelFileData);
+		messageModel.setFileDataModel(modelFileData);
 
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
@@ -282,6 +285,24 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		));
 	}
 
+	/**
+	 * Send an incoming message update to mark the message as read. This method only schedules the
+	 * outgoing group message update if multi device is activated.
+	 */
+	public void sendIncomingMessageUpdateRead(@NonNull Set<MessageId> messageIds, long timestamp) {
+		if (multiDeviceManager.isMultiDeviceActive()) {
+			taskManager.schedule(
+				new OutboundIncomingGroupMessageUpdateReadTask(
+					messageIds,
+					timestamp,
+					group.getApiGroupId(),
+					group.getCreatorIdentity(),
+					serviceManager
+				)
+			);
+		}
+	}
+
 	public void sendEditMessage(int messageModelId, @NonNull String body, @NonNull Date editedAt) {
 		taskManager.schedule(
 			new OutgoingGroupEditMessageTask(
@@ -362,18 +383,12 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	@Override
 	public Bitmap getNotificationAvatar() {
-		if (avatar == null && groupService != null) {
-			avatar = groupService.getAvatar(group, false);
-		}
-		return avatar;
+		return groupService.getAvatar(group, false);
 	}
 
 	@Override
 	public Bitmap getAvatar() {
-		if (avatar == null && groupService != null) {
-			avatar = groupService.getAvatar(group, true, true);
-		}
-		return avatar;
+		return groupService.getAvatar(group, true, true);
 	}
 
 	@Override
@@ -401,6 +416,12 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	@Override
 	public boolean sendMediaData() {
+        if (multiDeviceManager.isMultiDeviceActive()) {
+            // We need to upload the media in any case (also for notes groups) if multi device is
+            // active. In this case the upload is needed as the message is reflected.
+            return true;
+        }
+
 		// don't really send off group media if user is the only group member left - keep it local
 		String[] groupIdentities = groupService.getGroupIdentities(group);
 		return groupIdentities.length != 1 || !groupService.isGroupMember(group);

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

@@ -24,10 +24,6 @@ package ch.threema.app.messagereceiver;
 import android.content.Intent;
 import android.graphics.Bitmap;
 
-import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.sql.SQLException;
@@ -35,6 +31,9 @@ import java.util.Collection;
 import java.util.Date;
 import java.util.List;
 
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.services.MessageService;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.SymmetricEncryptionResult;

+ 28 - 17
app/src/main/java/ch/threema/app/multidevice/LinkedDevicesActivity.kt

@@ -23,11 +23,14 @@ package ch.threema.app.multidevice
 
 import android.Manifest
 import android.annotation.TargetApi
+import android.app.Activity
+import android.content.Intent
 import android.content.pm.PackageManager
 import android.os.Build
 import android.os.Bundle
 import android.view.View
 import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.viewModels
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
@@ -37,11 +40,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import ch.threema.app.R
 import ch.threema.app.activities.ThreemaToolbarActivity
-import ch.threema.app.services.QRCodeServiceImpl
+import ch.threema.app.multidevice.wizard.LinkNewDeviceWizardActivity
 import ch.threema.app.ui.EmptyRecyclerView
 import ch.threema.app.ui.SilentSwitchCompat
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.QRScannerUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
@@ -56,19 +58,26 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
 
     private val viewModel: LinkedDevicesViewModel by viewModels()
 
-    private val qrScanner = QRScannerUtil.prepareScanner(this) {
-        if (it?.isNotEmpty() == true) {
-            logger.debug("Got device link data: {}", it)
-            viewModel.linkDevice(it, serviceManager.deviceJoinDataCollector)
-        }
-    }
-
     private lateinit var devicesList: EmptyRecyclerView
     private lateinit var devicesAdapter: LinkedDevicesAdapter
 
     private lateinit var onOffButton: SilentSwitchCompat
     private lateinit var linkDeviceButton: ExtendedFloatingActionButton
 
+    private var wizardLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+        if (result.resultCode == Activity.RESULT_OK) {
+            logger.debug("Device linking success")
+            viewModel.refreshLinkedDevices()
+        } else {
+            // TODO(ANDR-2758): proper error handling
+            if (result.data?.getStringExtra(LinkNewDeviceWizardActivity.ACTIVITY_RESULT_EXTRA_FAILURE_REASON) != null) {
+                logger.debug("Device linking failed")
+            } else {
+                logger.debug("Device linking cancelled (not started)")
+            }
+        }
+    }
+
     override fun getLayoutResource(): Int = R.layout.activity_linked_devices
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -93,6 +102,11 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
 
         linkDeviceButton = findViewById(R.id.link_device_button)
         linkDeviceButton.setOnClickListener { initiateLinking() }
+        // TODO(ANDR-2717): Remove
+        linkDeviceButton.setOnLongClickListener {
+            viewModel.dropOtherDevices()
+            true
+        }
 
         initDevicesList()
 
@@ -104,7 +118,7 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         if (requestCode == PERMISSION_REQUEST_CAMERA) {
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-                scanQr()
+                startLinkingWizard()
             } else if (!this.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
                 ConfigUtils.showPermissionRationale(this, findViewById(R.id.parent_layout), R.string.permission_camera_qr_required)
             }
@@ -139,16 +153,13 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
     private fun initiateLinking() {
         logger.debug("Initiate linking")
         if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
-            scanQr()
+            startLinkingWizard()
         }
     }
 
-    private fun scanQr() {
-        logger.info("Scan Qr Code")
-        qrScanner.scan(
-            getString(R.string.md_link_device_qr_scan_message),
-            QRCodeServiceImpl.QR_TYPE_ANY
-        )
+    private fun startLinkingWizard() {
+        logger.info("Start linking wizard")
+        wizardLauncher.launch(Intent(this, LinkNewDeviceWizardActivity::class.java))
     }
 
     private fun startObservers() {

+ 23 - 10
app/src/main/java/ch/threema/app/multidevice/LinkedDevicesViewModel.kt

@@ -25,10 +25,12 @@ import androidx.annotation.AnyThread
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import ch.threema.app.ThreemaApplication.requireServiceManager
-import ch.threema.app.multidevice.linking.DeviceJoinDataCollector
+import ch.threema.app.tasks.TaskCreator
+import ch.threema.base.utils.LoggingUtil
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,6 +39,8 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
+private val logger = LoggingUtil.getThreemaLogger("LinkedDevicesViewModel")
+
 class LinkedDevicesViewModel : ViewModel() {
 
     private val _isMdActive = MutableStateFlow(false)
@@ -51,15 +55,24 @@ class LinkedDevicesViewModel : ViewModel() {
 
     private val mdManager: MultiDeviceManager by lazy { requireServiceManager().multiDeviceManager }
 
+    private val taskCreator: TaskCreator by lazy { requireServiceManager().taskCreator }
+
     init {
         emitStates()
         collectLatestCloseReason()
     }
 
-    @AnyThread
-    fun linkDevice(deviceJoinOfferUri: String, deviceJoinDataCollector: DeviceJoinDataCollector) {
+    fun refreshLinkedDevices() {
+        logger.info("Refresh linked devices")
+        emitLinkedDevices()
+    }
+
+    // TODO(ANDR-2717): Remove, as this is only used for development purposes
+    fun dropOtherDevices() {
+        logger.warn("Drop all other devices")
         CoroutineScope(Dispatchers.Default).launch {
-            mdManager.linkDevice(deviceJoinOfferUri, deviceJoinDataCollector)
+            mdManager.purge(taskCreator)
+            delay(500)
             emitStates()
         }
     }
@@ -75,14 +88,15 @@ class LinkedDevicesViewModel : ViewModel() {
 
     @AnyThread
     private fun activateMultiDevice() {
+        logger.info("Activate multi device")
         CoroutineScope(Dispatchers.Default).launch {
             val serviceManager = requireServiceManager()
             mdManager.activate(
                 "Android Client", // TODO(ANDR-2487): Should be userselectable (and updateable)
-                serviceManager.taskManager,
                 serviceManager.contactService,
                 serviceManager.userService,
-                serviceManager.forwardSecurityMessageProcessor
+                serviceManager.forwardSecurityMessageProcessor,
+                taskCreator,
             )
             emitStates()
         }
@@ -92,11 +106,10 @@ class LinkedDevicesViewModel : ViewModel() {
     private fun deactivateMultiDevice() {
         CoroutineScope(Dispatchers.Default).launch {
             val serviceManager = requireServiceManager()
-            // TODO(ANDR-2603): Maybe show a spinner while we are waiting for deactivation to complete
             mdManager.deactivate(
-                serviceManager.taskManager,
                 serviceManager.userService,
-                serviceManager.forwardSecurityMessageProcessor
+                serviceManager.forwardSecurityMessageProcessor,
+                taskCreator,
             )
             emitStates()
         }
@@ -118,7 +131,7 @@ class LinkedDevicesViewModel : ViewModel() {
     @AnyThread
     private fun emitLinkedDevices() {
         viewModelScope.launch {
-            _linkedDevices.emit(withContext(Dispatchers.Default) { mdManager.linkedDevices })
+            _linkedDevices.emit(mdManager.loadLinkedDevicesInfo(taskCreator))
         }
     }
 

+ 34 - 10
app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt

@@ -24,14 +24,15 @@ package ch.threema.app.multidevice
 
 import androidx.annotation.AnyThread
 import androidx.annotation.WorkerThread
-import ch.threema.app.multidevice.linking.DeviceJoinDataCollector
+import ch.threema.app.multidevice.linking.DeviceLinkingDataCollector
+import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
+import ch.threema.app.tasks.TaskCreator
 import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
-import ch.threema.domain.taskmanager.TaskManager
 import kotlinx.coroutines.flow.Flow
 
 interface MultiDeviceManager {
@@ -40,8 +41,6 @@ interface MultiDeviceManager {
 
     val isMultiDeviceActive: Boolean
 
-    val linkedDevices: List<String>
-
     val propertiesProvider: MultiDevicePropertyProvider
 
     val socketCloseListener: D2mSocketCloseListener
@@ -52,25 +51,50 @@ interface MultiDeviceManager {
     @WorkerThread
     suspend fun activate(
         deviceLabel: String,
-        taskManager: TaskManager, // TODO(ANDR-2519): Remove
         contactService: ContactService, // TODO(ANDR-2519): remove
         userService: UserService, // TODO(ANDR-2519): remove
         fsMessageProcessor: ForwardSecurityMessageProcessor, // TODO(ANDR-2519): remove
+        taskCreator: TaskCreator,
     )
 
+    /**
+     * Deactivate multi device:
+     * - drop all (including own) devices from device group
+     * - delete dgk
+     * - reconnect to chat server
+     * - reactivate fs TODO(ANDR-2519): Remove fs part
+     *
+     * NOTE: This method should not be invoked from within a task as the mediator will close the
+     * connection when the own device is dropped. This might lead to unexpected behaviour.
+     */
     @WorkerThread
     suspend fun deactivate(
-        taskManager: TaskManager,
         userService: UserService, // TODO(ANDR-2519): remove
-        fsMessageProcessor: ForwardSecurityMessageProcessor // TODO(ANDR-2519): remove
+        fsMessageProcessor: ForwardSecurityMessageProcessor, // TODO(ANDR-2519): remove
+        taskCreator: TaskCreator,
     )
 
     @WorkerThread
     suspend fun setDeviceLabel(deviceLabel: String)
 
-    @AnyThread
+    /**
+     * Start linking of a new device with a device join offer uri.
+     * The returned flow emits the current status of the linking process.
+     * To abort the linking process, the coroutine performing the linking
+     * should be cancelled.
+     */
+    @WorkerThread
     suspend fun linkDevice(
         deviceJoinOfferUri: String,
-        deviceJoinDataCollector: DeviceJoinDataCollector,
-    )
+        taskCreator: TaskCreator,
+    ): Flow<DeviceLinkingStatus>
+
+    /**
+     * Remove all _other_ devices from the device group
+     * TODO(ANDR-2717): Remove, as it is only used for development
+     */
+    suspend fun purge(taskCreator: TaskCreator)
+
+    @AnyThread
+    suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String>
 }

+ 112 - 113
app/src/main/java/ch/threema/app/multidevice/MultiDeviceManagerImpl.kt

@@ -25,15 +25,15 @@ import android.os.Build
 import androidx.annotation.AnyThread
 import androidx.annotation.WorkerThread
 import ch.threema.app.BuildConfig
-import ch.threema.app.multidevice.linking.DeviceJoinData
-import ch.threema.app.multidevice.linking.DeviceJoinDataCollector
+import ch.threema.app.multidevice.linking.DeviceLinkingCancelledException
+import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.multidevice.linking.Failed
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.UserService
 import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.stores.PreferenceStoreInterface
-import ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask
-import ch.threema.app.tasks.OutgoingDropDeviceTask
+import ch.threema.app.tasks.TaskCreator
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.SecureRandomUtil.generateRandomBytes
 import ch.threema.base.utils.SecureRandomUtil.generateRandomU64
@@ -53,19 +53,22 @@ import ch.threema.domain.protocol.connection.socket.ServerSocketCloseReason
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
 import ch.threema.domain.protocol.multidevice.MultiDeviceKeys
 import ch.threema.domain.protocol.multidevice.MultiDeviceProperties
-import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.protobuf.csp.e2e.fs.Terminate
 import ch.threema.storage.models.ServerMessageModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
-import org.saltyrtc.client.exceptions.InvalidStateException
 import java.util.Date
 
 private val logger = LoggingUtil.getThreemaLogger("MultiDeviceManagerImpl")
@@ -74,13 +77,13 @@ private val logger = LoggingUtil.getThreemaLogger("MultiDeviceManagerImpl")
  * NOTE: If set to `false` the backup version should be incremented, as
  * `ForwardSecurityStatusType.FORWARD_SECURITY_DISABLED` cannot be restored on older versions.
  */
-private const val IS_FS_SUPPORTED_WITH_MD = true // TODO(ANDR-2519): Remove when md supports fs
+private const val IS_FS_SUPPORTED_WITH_MD = false // TODO(ANDR-2519): Remove when md supports fs
 
 class MultiDeviceManagerImpl(
     private val preferenceStore: PreferenceStoreInterface,
     private val serverMessageService: ServerMessageService,
     private val version: Version,
-) : MultiDeviceManager {
+    ) : MultiDeviceManager {
 
     private var reconnectHandle: ReconnectableServerConnection? = null
 
@@ -129,23 +132,17 @@ class MultiDeviceManagerImpl(
     override val isMultiDeviceActive: Boolean
         get() = properties != null
 
-    private val _linkedDevices = mutableListOf<String>()
-    override val linkedDevices: List<String>
-        get() = _linkedDevices // TODO(ANDR-2484): persist linked devices
-
     override val latestSocketCloseReason = MutableSharedFlow<D2mSocketCloseReason?>(1, 0, BufferOverflow.DROP_OLDEST)
 
     private var serverInfo: InboundD2mMessage.ServerInfo? = null
 
-    private var deactivationOngoing = false
-
     @AnyThread
     override suspend fun activate(
         deviceLabel: String,
-        taskManager: TaskManager,
         contactService: ContactService,
         userService: UserService,
         fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
     ) {
         logger.info("Activate multi device")
         if (!BuildConfig.MD_ENABLED) {
@@ -162,7 +159,7 @@ class MultiDeviceManagerImpl(
 
         // TODO(ANDR-2519): Remove when md allows fs by default `activate` could then be non-suspending
         if (!IS_FS_SUPPORTED_WITH_MD) {
-            disableForwardSecurity(taskManager, contactService, userService, fsMessageProcessor)
+            disableForwardSecurity(contactService, userService, fsMessageProcessor, taskCreator)
         }
         latestSocketCloseReason.tryEmit(null)
         reconnect()
@@ -170,82 +167,118 @@ class MultiDeviceManagerImpl(
 
     @AnyThread
     override suspend fun deactivate(
-        taskManager: TaskManager,
         userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
     ) {
-        logger.debug("Deactivate multi device")
+        logger.info("Deactivate multi device")
+
+        // 1. Delete device group
+        logger.info("Delete device group")
+        taskCreator.scheduleDeleteDeviceGroupTask().await()
 
-        val mdProperties = properties ?: throw MultiDeviceException("Multi device properties are missing")
+        // 2. Delete dgk
+        logger.info("Delete multi device properties")
+        persistedProperties = null
 
         // TODO(ANDR-2519): Remove when md allows fs by default
+        // 3. Enable FS
         if (!IS_FS_SUPPORTED_WITH_MD) {
             enableForwardSecurity(userService, fsMessageProcessor)
         }
 
+        // 4. Cleanup
         serverInfo = null
-        _linkedDevices.clear()
-
-        deactivationOngoing = true
-
-        taskManager.schedule(OutgoingDropDeviceTask(mdProperties.mediatorDeviceId)).await()
-
-        // TODO(ANDR-2603): Unlink all linked devices (including own device id):
-        //  Ensure all linked devices are removed, then kick own device. When the connection is closed by the mediator with
-        //  a close code "kicked from group" the dgk can be deleted an md deactivated. It
-        //  would be even nicer if we can wait for the drop device ack of the own device
-        //  and then complete the task without being cancelled. This should be possible if the code
-        //  executed after the drop device ack is not cancellable.
-        //  Will it be possible to trigger a reconnect from the task?
-        //  There should probably be a dedicated task, that ensures that _all_ other devices are dropped and only then
-        //  drops the own device. If we are sure every other device is dropped and no device could be linked in the meantime
-        //  the task could still trigger deletion of the properties if connection to the mediator is not possible anymore because
-        //  the own device has already been dropped.
-        //   oh no -> if no connection is possible, no tasks will be executed...
+
+        // 5. Reconnect
+        reconnect()
     }
 
     override suspend fun setDeviceLabel(deviceLabel: String) {
         persistedProperties = persistedProperties!!.withDeviceLabel(deviceLabel)
     }
 
-    @AnyThread
+    @WorkerThread
     override suspend fun linkDevice(
         deviceJoinOfferUri: String,
-        deviceJoinDataCollector: DeviceJoinDataCollector,
-    ) {
+        taskCreator: TaskCreator,
+    ): Flow<DeviceLinkingStatus> {
         logger.debug("Link device: {}", deviceJoinOfferUri)
 
-        _linkedDevices.add(deviceJoinOfferUri)
-        // TODO(ANDR-2484): Actual device linking
+        return channelFlow {
+            try {
+                val linkingCancelledSignal = CompletableDeferred<Unit>()
 
-        return try {
-            val deviceJoinData = withContext(Dispatchers.Default) {
-                collectDeviceJoinData(deviceJoinDataCollector)
-            }
-            deviceJoinData.essentialData.toString().lines().forEach {
-                logger.debug("Essential data: {}", it)
+                val (controller, linkingCompleted) = taskCreator.scheduleDeviceLinkingTask(deviceJoinOfferUri, linkingCancelledSignal)
+
+                launch {
+                    controller.linkingStatus.collect { send(it) }
+                }
+
+                val result = try {
+                    linkingCompleted.await()
+                } catch (e: CancellationException) {
+                    linkingCancelledSignal.complete(Unit)
+                    Result.failure(DeviceLinkingCancelledException())
+                }
+                if (result.isFailure) {
+                    // Cause could for example be a MasterKeyLockedException since the data collector
+                    // initialises some dependencies when data is collected (e.g. ContactService)
+                    // or any other exception that can occur during device join 😉
+                    logger.error("Linking failed due to an exception")
+                    send(Failed(result.exceptionOrNull()))
+                }
+            } catch (e: Exception) {
+                send(Failed(e))
             }
-        } catch (e: Exception) {
-            // This could for example be a MasterKeyLockedException since the data collector
-            // initialises some dependencies when data is collected (e.g. ContactService)
-            logger.error("Linking failed due to an exception", e)
-            // TODO(ANDR-2484): rethrow (dedicated type?) and abort linking
-            // TODO(ANDR-2487): show a message to users that linking failed
         }
     }
 
-    @WorkerThread
-    private fun collectDeviceJoinData(deviceJoinDataCollector: DeviceJoinDataCollector): DeviceJoinData {
-        // TODO(ANDR-2484): Make sure the state of the data cannot change during collection:
-        //  - disconnect from server
-        //  - do not perform any api calls?
-        //  - disconnect web clients
-        //  - stop workers..?
-        //  --> how is this done during a backup?
+    // TODO(ANDR-2717): Remove
+    override suspend fun purge(taskCreator: TaskCreator) {
+        val myDeviceId = (properties ?: throw MultiDeviceException("Multi device properties are missing")).mediatorDeviceId
+        loadLinkedDevicesMediatorIds(taskCreator)
+            .filter { it != myDeviceId }
+            .forEach {
+                taskCreator.scheduleDropDeviceTask(it).await()
+            }
+    }
 
-        val dgk = properties?.keys?.dgk ?: throw InvalidStateException("Multi device is not active")
+    // TODO(ANDR-2717): Use a Proper model (probably `List<DeviceInfo>`) `List<String>` is only used
+    //  for the sake of simplicity during development
+    @AnyThread
+    override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
+        if (!isMultiDeviceActive) {
+            return listOf()
+        }
+        val keys = _properties.filterNotNull().first().keys
+        return withContext(Dispatchers.Default) {
+            val devicesInfo = taskCreator.scheduleGetDevicesInfoTask().await()
+            devicesInfo.augmentedDeviceInfo.values.map { augmentedDeviceInfo ->
+                val deviceInfo = try {
+                    keys.decryptDeviceInfo(augmentedDeviceInfo.encryptedDeviceInfo)
+                } catch (e: Exception) {
+                    logger.error("Could not decrypt device info", e)
+                    // TODO(ANDR-2717): Display as invalid device in devices list
+                    D2dMessage.DeviceInfo.INVALID_DEVICE_INFO
+                }
+                val activityInfo = augmentedDeviceInfo.connectedSince?.let { "Connected since ${Date(it.toLong())}" }
+                    ?: augmentedDeviceInfo.lastDisconnectAt?.let { "Last disconnect: ${Date(it.toLong())}" }
+                listOfNotNull(
+                    deviceInfo.label,
+                    deviceInfo.platform,
+                    "${deviceInfo.platformDetails} (${deviceInfo.appVersion})",
+                    activityInfo
+                ).joinToString("\n")
+            }
+        }
+    }
 
-        return deviceJoinDataCollector.collectData(dgk)
+    @AnyThread
+    private suspend fun loadLinkedDevicesMediatorIds(taskCreator: TaskCreator): Set<DeviceId> {
+        return withContext(Dispatchers.Default) {
+            taskCreator.scheduleGetDevicesInfoTask().await().augmentedDeviceInfo.keys
+        }
     }
 
     private fun onSocketClosed(reason: ServerSocketCloseReason) {
@@ -281,43 +314,15 @@ class MultiDeviceManagerImpl(
     }
 
     private fun handleDeviceDropped() {
-        if (deactivationOngoing) {
-            logger.debug("Device dropped during ongoing md deactivation. Delete properties.")
-            // complete deactivation: delete dgk etc.
-            persistedProperties = null
-            deactivationOngoing = false
-            reconnect()
-        } else {
-            displayConnectionError("Device was dropped")
-        }
+        // TODO(ANDR-2604): The dialog should offer the possibility to use threema without server connection
+        //  (no messages can be sent or received) or to reset the App (see SE-137)
+        displayConnectionError("Device was dropped")
     }
 
     private fun handleDeviceSlotMismatch() {
+        // TODO(ANDR-2604): The dialog should offer the possibility to use threema without server connection
+        //  (no messages can be sent or received) or to reset the App (see SE-137)
         displayConnectionError("Device slot mismatch")
-
-        // TODO(ANDR-2603): Remove
-        deleteMdPropertiesAfterSlotMismatch()
-    }
-
-    private fun deleteMdPropertiesAfterSlotMismatch() {
-        // TODO(ANDR-2603): Remove this, as it is just a temporary workaround for an unsuccessful
-        //  md deactivation.
-        //  If deactivation of md has not been properly completed the client might already be dropped,
-        //  but the properties are not yet deleted.
-        //  In that state it is not possible to login on the server (expected slot mismatch) and therefore
-        //  a drop device cannot be sent (and actually does not have to, since the device has already
-        //  been dropped).
-        //  How to handle that case? How does iOS handle this situation?
-        //  Only if we are sure there are no other remaining devices in the device group md should be deactivated.
-        //  --> could lead to many "Another connection ..." server errors
-        logger.warn("Delete md properties after device slot mismatch")
-        deactivationOngoing = false
-        persistedProperties = null
-
-        // We do not reconnect automatically. After a restart of the app the csp connection will be used
-        // which should work. This way we could display an error to the user which will allow to react somehow
-        // before the connection is changed. Or there might even be a button "reconnect without md" in the
-        // shown dialog
     }
 
     /**
@@ -326,7 +331,7 @@ class MultiDeviceManagerImpl(
     private fun displayConnectionError(msg: String) {
         // TODO(ANDR-2604): Show actual dialog to user
         // TODO(ANDR-2604): Use string resources instead of string
-        // TODO(ANDR-2604): Only show error if a reconnect ist not allowed (see `D2mCloseCode#isReconnectAllowed()`)
+        // TODO(ANDR-2604): Only show error if a reconnect is not allowed (see `D2mCloseCode#isReconnectAllowed()`)
         logger.warn("Reconnect is not allowed: {}", msg)
 
         val message = ServerMessageModel(msg, ServerMessageModel.TYPE_ERROR)
@@ -335,6 +340,7 @@ class MultiDeviceManagerImpl(
 
     private fun reconnect() {
         CoroutineScope(Dispatchers.Default).launch {
+            logger.info("Reconnect server connection")
             reconnectHandle?.reconnect() ?: logger.error("Reconnect handle is null")
         }
     }
@@ -342,18 +348,14 @@ class MultiDeviceManagerImpl(
     // TODO(ANDR-2519): Remove when md allows fs
     @AnyThread
     private suspend fun disableForwardSecurity(
-        taskManager: TaskManager,
         contactService: ContactService,
         userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
     ) {
         withContext(Dispatchers.IO) {
             updateFeatureMask(userService, false)
-            terminateAllForwardSecuritySessions(
-                taskManager,
-                contactService,
-                fsMessageProcessor
-            )
+            terminateAllForwardSecuritySessions(contactService, taskCreator)
             fsMessageProcessor.setForwardSecurityEnabled(false)
         }
     }
@@ -377,15 +379,12 @@ class MultiDeviceManagerImpl(
     // TODO(ANDR-2519): Remove when md allows fs
     @WorkerThread
     private suspend fun terminateAllForwardSecuritySessions(
-        taskManager: TaskManager,
         contactService: ContactService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        taskCreator: TaskCreator,
     ) {
         contactService.all.map {
-            taskManager.schedule(
-                DeleteAndTerminateFSSessionsTask(
-                    fsMessageProcessor, it, Terminate.Cause.DISABLED_BY_LOCAL
-                )
+            taskCreator.scheduleDeleteAndTerminateFSSessionsTaskAsync(
+                it, Terminate.Cause.DISABLED_BY_LOCAL
             )
         }.awaitAll()
     }
@@ -433,7 +432,7 @@ class MultiDeviceManagerImpl(
         return D2dMessage.DeviceInfo(
             D2dMessage.DeviceInfo.Platform.ANDROID,
             platformDetails,
-            version.version,
+            version.versionNumber,
             deviceLabel
         ).also { logger.trace("Device info created: {}", it) }
     }

+ 78 - 57
app/src/main/java/ch/threema/app/multidevice/linking/DeviceJoinDataCollector.kt → app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt

@@ -23,14 +23,18 @@ package ch.threema.app.multidevice.linking
 
 import android.graphics.Bitmap
 import androidx.annotation.WorkerThread
+import ch.threema.app.BuildConfig
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.DeadlineListService
 import ch.threema.app.services.license.LicenseServiceUser
 import ch.threema.app.utils.BitmapUtil
 import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.ConversationUtil.getConversationUid
+import ch.threema.base.crypto.NonceScope
 import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.protobuf.Common.BlobData
@@ -85,9 +89,9 @@ import ch.threema.storage.models.GroupModel
 import com.google.protobuf.ByteString
 import java.nio.ByteBuffer
 
-private val logger = LoggingUtil.getThreemaLogger("DeviceJoinDataCollector")
+private val logger = LoggingUtil.getThreemaLogger("DeviceLinkingDataCollector")
 
-data class DeviceJoinData(val blobs: Sequence<BlobData>, val essentialData: EssentialData)
+data class DeviceLinkingData(val blobs: Sequence<BlobData>, val essentialData: EssentialData)
 
 class BlobDataProvider (private val blobId: ByteArray?, private val dataProvider: () -> ByteArray?) {
     /**
@@ -110,7 +114,7 @@ class BlobDataProvider (private val blobId: ByteArray?, private val dataProvider
     }
 }
 
-class DeviceJoinDataCollector(
+class DeviceLinkingDataCollector(
     serviceManager: ServiceManager
 ) {
     private val identityStore by lazy { serviceManager.identityStore }
@@ -132,7 +136,7 @@ class DeviceJoinDataCollector(
     private val licenseService by lazy { serviceManager.licenseService }
 
     @WorkerThread
-    fun collectData(dgk: ByteArray): DeviceJoinData {
+    fun collectData(dgk: ByteArray): DeviceLinkingData {
         val blobDataProviders = mutableListOf<BlobDataProvider>()
 
         val data = essentialData {
@@ -146,7 +150,9 @@ class DeviceJoinDataCollector(
 
             logger.trace("Collect user profile")
             val (userProfileBlobProvider, userProfileData) = collectUserProfile()
-            blobDataProviders.add(userProfileBlobProvider)
+            userProfileBlobProvider?.let {
+                blobDataProviders.add(it)
+            }
             this.userProfile = userProfileData
 
             logger.trace("Collect settings")
@@ -166,15 +172,19 @@ class DeviceJoinDataCollector(
                 this.groups += group
             }
 
-            logger.trace("Collect distribution lists")
-            this.distributionLists += collectDistributionLists(conversationsStats)
+            if (BuildConfig.MD_SYNC_DISTRIBUTION_LISTS) {
+                logger.trace("Collect distribution lists")
+                this.distributionLists += collectDistributionLists(conversationsStats)
+            } else {
+                logger.trace("Skip collection of distribution lists")
+                this.distributionLists.clear()
+            }
 
             logger.trace("Collect csp nonce hashes")
             this.cspHashedNonces += collectCspNonceHashes()
 
-
-            // TODO(ANDR-2632): At the moment we do not store any d2d nonces
-            this.d2DHashedNonces.clear()
+            logger.trace("Collect d2d nonce hashes")
+            this.d2DHashedNonces += collectD2dNonceHashes()
 
             // work
             if (ConfigUtils.isWorkBuild()) {
@@ -192,7 +202,7 @@ class DeviceJoinDataCollector(
             .asSequence()
             .mapNotNull { it.get() }
 
-        return DeviceJoinData(blobsSequence, data)
+        return DeviceLinkingData(blobsSequence, data)
     }
 
     private fun collectIdentityData(): IdentityData {
@@ -204,24 +214,26 @@ class DeviceJoinDataCollector(
         }
     }
 
-    private fun collectUserProfile(): Pair<BlobDataProvider, MdD2DSync.UserProfile> {
-        return collectUserProfilePicture().let { (blobDataProvider, avatar) ->
-            blobDataProvider to userProfile {
+    private fun collectUserProfile(): Pair<BlobDataProvider?, MdD2DSync.UserProfile> {
+        return collectUserProfilePicture().let { profilePictureData ->
+            profilePictureData?.first to userProfile {
                 nickname = identityStore.publicNickname
-                profilePicture = avatar
+                profilePictureData?.second?.let {
+                    profilePicture = it
+                }
                 profilePictureShareWith = collectProfilePictureShareWith()
                 identityLinks = collectIdentityLinks()
             }
         }
     }
 
-    private fun collectUserProfilePicture(): Pair<BlobDataProvider, DeltaImage> {
-        val profilePictureData = contactService.updatedProfilePictureUploadData
+    private fun collectUserProfilePicture(): Pair<BlobDataProvider, DeltaImage>? {
+        val profilePictureData = userService.uploadUserProfilePictureOrGetPreviousUploadData()
 
         val hasProfilePicture = profilePictureData.blobId != null
             && !profilePictureData.blobId.contentEquals(ContactModel.NO_PROFILE_PICTURE_BLOB_ID)
 
-        val profilePicture = if (hasProfilePicture) {
+        return if (hasProfilePicture) {
             val blobMeta = blob {
                 id = profilePictureData.blobId.toByteString()
                 nonce = ProtocolDefines.CONTACT_PHOTO_NONCE.toByteString()
@@ -229,19 +241,19 @@ class DeviceJoinDataCollector(
                 uploadedAt = profilePictureData.uploadedAt
             }
 
-            deltaImage { updated = image {
+            val profilePicture = deltaImage { updated = image {
                 type = Image.Type.JPEG
                 blob = blobMeta
             } }
-        } else {
-            deltaImage { removed = unit {} }
-        }
 
-        val blobDataProvider = BlobDataProvider(profilePictureData.blobId) {
-            profilePictureData.bitmapArray
-        }
+            val blobDataProvider = BlobDataProvider(profilePictureData.blobId) {
+                profilePictureData.bitmapArray
+            }
 
-        return blobDataProvider to profilePicture
+            blobDataProvider to profilePicture
+        } else {
+            null
+        }
     }
 
     private fun collectProfilePictureShareWith(): ProfilePictureShareWith {
@@ -251,7 +263,7 @@ class DeviceJoinDataCollector(
             when (policy.policy) {
                 ContactService.ProfilePictureSharePolicy.Policy.NOBODY -> nobody = unit {}
                 ContactService.ProfilePictureSharePolicy.Policy.EVERYONE -> everyone = unit {}
-                ContactService.ProfilePictureSharePolicy.Policy.SOME -> {
+                ContactService.ProfilePictureSharePolicy.Policy.ALLOW_LIST -> {
                     allowList = identities { identities += policy.allowedIdentities }
                 }
             }
@@ -341,18 +353,16 @@ class DeviceJoinDataCollector(
     private data class ConversationStats(
         val isArchived: Boolean,
         val isPinned: Boolean,
-        val lastMessageCreatedAt: Long?
     )
 
     private fun collectConversationsStats(): Map<String, ConversationStats> {
         val notArchived = conversationService.getAll(true).associate {
-            it.uid to ConversationStats(false, it.isPinTagged, it.latestMessage?.createdAt?.time)
+            it.uid to ConversationStats(false, it.isPinTagged)
         }
         val archived = conversationService.getArchived(null).associate {
             it.uid to ConversationStats(
                 isArchived = true,
-                isPinned = false,
-                lastMessageCreatedAt = it.latestMessage?.createdAt?.time)
+                isPinned = false)
         }
         return notArchived + archived
     }
@@ -390,9 +400,9 @@ class DeviceJoinDataCollector(
                 ContactModel.AcquaintanceLevel.DIRECT -> Contact.AcquaintanceLevel.DIRECT
             }
             activityState = when (contactModel.state) {
-                ContactModel.State.ACTIVE -> Contact.ActivityState.ACTIVE
-                ContactModel.State.INACTIVE -> Contact.ActivityState.INACTIVE
-                ContactModel.State.INVALID -> Contact.ActivityState.INVALID
+                IdentityState.ACTIVE -> Contact.ActivityState.ACTIVE
+                IdentityState.INACTIVE -> Contact.ActivityState.INACTIVE
+                IdentityState.INVALID -> Contact.ActivityState.INVALID
                 else -> throw IllegalStateException("Contact ${contactModel.identity} has missing state")
             }
             featureMask = contactModel.featureMask
@@ -449,9 +459,7 @@ class DeviceJoinDataCollector(
 
         val augmentedContact = augmentedContact {
             this.contact = contact
-            conversationStats?.lastMessageCreatedAt?.let {
-                this.lastUpdateAt = it
-            }
+            contactModel.lastUpdate?.let { this.lastUpdateAt = it.time }
         }
 
         return blobDataProviders to augmentedContact
@@ -497,20 +505,20 @@ class DeviceJoinDataCollector(
     }
 
     private fun ContactModel.getUniqueId(): String {
-        return contactService.getUniqueIdString(this)
+        return ContactUtil.getUniqueIdString(identity)
     }
 
     private fun collectContactDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
-        return if (fileService.hasContactPhotoFile(contactModel.identity)) {
-            createJpegBlobAssets { fileService.getContactPhoto(contactModel.identity) }
+        return if (fileService.hasContactDefinedProfilePicture(contactModel.identity)) {
+            createJpegBlobAssets { fileService.getContactDefinedProfilePicture(contactModel.identity) }
         } else {
             null
         }
     }
 
     private fun collectUserDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
-        return if (fileService.hasContactAvatarFile(contactModel.identity)) {
-            createJpegBlobAssets { fileService.getContactAvatar(contactModel.identity) }
+        return if (fileService.hasUserDefinedProfilePicture(contactModel.identity)) {
+            createJpegBlobAssets { fileService.getUserDefinedProfilePicture(contactModel.identity) }
         } else {
             null
         }
@@ -541,9 +549,10 @@ class DeviceJoinDataCollector(
     }
 
     private fun collectGroups(conversationsStats: Map<String, ConversationStats>): List<Pair<List<BlobDataProvider>, AugmentedGroup>> {
-        return groupService.all.map {
-            mapToAugmentedGroup(it, conversationsStats)
-        }.also { logger.trace("{} groups", it.size) }
+        return groupService.all
+            .filter { !it.isDeleted }
+            .map { mapToAugmentedGroup(it, conversationsStats) }
+            .also { logger.trace("{} groups", it.size) }
     }
 
     private fun mapToAugmentedGroup(groupModel: GroupModel, conversationsStats: Map<String, ConversationStats>): Pair<List<BlobDataProvider>, AugmentedGroup> {
@@ -558,13 +567,7 @@ class DeviceJoinDataCollector(
             }
             name = groupModel.name ?: ""
             createdAt = groupModel.createdAt.time
-            userState = if (groupService.isGroupMember(groupModel)) {
-                UserState.MEMBER
-            } else {
-                // TODO(ANDR-2676)
-                //  at the moment we cannot distinguish between KICKED and LEFT
-                UserState.KICKED
-            }
+            userState = collectUserState(groupModel)
             notificationTriggerPolicyOverride = collectGroupNotificationTriggerPolicyOverride(groupModel)
             notificationSoundPolicyOverride = collectGroupNotificationSoundPolicyOverride(groupModel)
             collectGroupAvatar(groupModel)?.let { (groupAvatarBlobDataProvider, image) ->
@@ -588,8 +591,9 @@ class DeviceJoinDataCollector(
 
         val augmentedGroup = augmentedGroup {
             this.group = group
-            this.lastUpdateAt = conversationStats?.lastMessageCreatedAt
-                ?: groupModel.createdAt.time
+            groupModel.lastUpdate?.let {
+                this.lastUpdateAt = it.time
+            }
         }
         return blobDataProviders to augmentedGroup
     }
@@ -602,6 +606,16 @@ class DeviceJoinDataCollector(
         }
     }
 
+    private fun collectUserState(groupModel: GroupModel) = when (groupModel.userState) {
+        GroupModel.UserState.MEMBER -> UserState.MEMBER
+        GroupModel.UserState.KICKED -> UserState.KICKED
+        GroupModel.UserState.LEFT -> UserState.LEFT
+        null -> {
+            logger.warn("User state of group model is null; using member as default")
+            UserState.MEMBER
+        }
+    }
+
     /**
      * @return The provided group's member identities NOT including the user itself
      */
@@ -695,8 +709,9 @@ class DeviceJoinDataCollector(
         }?.let {
             augmentedDistributionList {
                 this.distributionList = it
-                this.lastUpdateAt = conversationStats?.lastMessageCreatedAt
-                    ?: distributionListModel.createdAt.time
+                distributionListModel.lastUpdate?.let {
+                    this.lastUpdateAt = it.time
+                }
             }
         }
     }
@@ -718,11 +733,17 @@ class DeviceJoinDataCollector(
     }
 
     private fun collectCspNonceHashes(): Set<ByteString> {
-        return nonceFactory.allHashedNonces.map { it.toByteString() }
+        return nonceFactory.getAllHashedNonces(NonceScope.CSP).map { it.bytes.toByteString() }
             .toSet()
             .also { logger.trace("{} csp nonce hashes", it.size) }
     }
 
+    private fun collectD2dNonceHashes(): Set<ByteString> {
+        return nonceFactory.getAllHashedNonces(NonceScope.D2D).map { it.bytes.toByteString() }
+            .toSet()
+            .also { logger.trace("{} d2d nonce hashes", it.size) }
+    }
+
     private fun collectWorkCredentials(): MdD2DSync.ThreemaWorkCredentials? {
         val credentials = licenseService.let {
             if (it is LicenseServiceUser) {

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