Browse Source

Version 5.7.0

Threema 10 months ago
parent
commit
ca14fac988
100 changed files with 8875 additions and 4683 deletions
  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 SDK
 - Android NDK
 - Android NDK
 - bash shell
 - 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:
 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,
     of the authors and should not be interpreted as representing official policies,
     either expressed or implied, of the FreeBSD Project.</p>
     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>
 <h2>Gesture Views</h2>
 
 
 <p>Copyright (c) 2022 Alex Vasilkov</p>
 <p>Copyright (c) 2022 Alex Vasilkov</p>

+ 97 - 47
app/build.gradle

@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 plugins {
 plugins {
     id 'org.sonarqube'
     id 'org.sonarqube'
     id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
     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'
 apply plugin: 'com.android.application'
@@ -18,14 +19,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 // version codes
 
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
 // 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`)
 // 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.
 // 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"
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 def beta_suffix = ""
 
 
-def defaultVersionCode = 1014
+def defaultVersionCode = 1033
 
 
 /**
 /**
  * Return the git hash, if git is installed.
  * Return the git hash, if git is installed.
@@ -40,7 +41,8 @@ def getGitHash = { ->
             errorOutput = stderr
             errorOutput = stderr
             ignoreExitValue true
             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()
     def hash = stdout.toString().trim()
     return (hash.isEmpty()) ? "?" : hash
     return (hash.isEmpty()) ? "?" : hash
 }
 }
@@ -61,17 +63,17 @@ def findKeystore = { name ->
             Properties props = new Properties()
             Properties props = new Properties()
             propertiesFile.withInputStream { props.load(it) }
             propertiesFile.withInputStream { props.load(it) }
             return [
             return [
-                storeFile: storePath,
+                storeFile    : storePath,
                 storePassword: props.storePassword,
                 storePassword: props.storePassword,
-                keyAlias: props.keyAlias,
-                keyPassword: props.keyPassword,
+                keyAlias     : props.keyAlias,
+                keyPassword  : props.keyPassword,
             ]
             ]
         } else {
         } else {
             return [
             return [
-                storeFile: storePath,
+                storeFile    : storePath,
                 storePassword: null,
                 storePassword: null,
-                keyAlias: null,
-                keyPassword: null,
+                keyAlias     : null,
+                keyPassword  : null,
             ]
             ]
         }
         }
     }
     }
@@ -81,11 +83,11 @@ def findKeystore = { name ->
  * Map with keystore paths (if found).
  * Map with keystore paths (if found).
  */
  */
 def keystores = [
 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"),
     onprem_release: findKeystore("onprem"),
-    blue_release: findKeystore("threema_blue"),
+    blue_release  : findKeystore("threema_blue"),
 ]
 ]
 
 
 android {
 android {
@@ -121,6 +123,7 @@ android {
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
         buildConfigField "boolean", "VIDEO_CALLS_ENABLED", "true"
         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", "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 "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()}\""
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
@@ -129,12 +132,18 @@ android {
         buildConfigField "String", "WORK_SERVER_URL", "null"
         buildConfigField "String", "WORK_SERVER_URL", "null"
         buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
         buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
         buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
         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", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-{backupIdPrefix8}.threema.ch/\""
         buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-{backupIdPrefix8}.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.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 "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "MD_ENABLED", "false"
         buildConfigField "boolean", "MD_ENABLED", "false"
+        buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "SHOW_TIMESTAMPS_AND_TECHNICAL_INFO_IN_MESSAGE_DETAILS", "false"
         buildConfigField "boolean", "SHOW_TIMESTAMPS_AND_TECHNICAL_INFO_IN_MESSAGE_DETAILS", "false"
@@ -158,11 +168,11 @@ android {
 
 
         // duplicated for manifest
         // duplicated for manifest
         manifestPlaceholders = [
         manifestPlaceholders = [
-            uriScheme: "threema",
-            contactActionUrl: "threema.id",
+            uriScheme         : "threema",
+            contactActionUrl  : "threema.id",
             groupLinkActionUrl: "threema.group",
             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'
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
@@ -210,15 +220,15 @@ android {
         variant.outputs.each { output ->
         variant.outputs.each { output ->
             def abi = output.getFilter("ABI")
             def abi = output.getFilter("ABI")
             output.versionCodeOverride =
             output.versionCodeOverride =
-                    abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
+                abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
         }
         }
     }
     }
 
 
     namespace 'ch.threema.app'
     namespace 'ch.threema.app'
     flavorDimensions = ["default"]
     flavorDimensions = ["default"]
     productFlavors {
     productFlavors {
-        none { }
-        store_google { }
+        none {}
+        store_google {}
         store_threema {
         store_threema {
             resValue "string", "shop_download_filename", "Threema-update.apk"
             resValue "string", "shop_download_filename", "Threema-update.apk"
         }
         }
@@ -244,8 +254,8 @@ android {
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
+                uriScheme   : "threemawork",
+                actionUrl   : "work.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
                 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"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.call"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             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", "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 "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/\""
             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", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
             buildConfigField "boolean", "MD_ENABLED", "true"
             buildConfigField "boolean", "MD_ENABLED", "true"
+
+            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
         }
         }
         sandbox_work {
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
             versionName "${app_version}k${beta_suffix}"
@@ -280,6 +293,7 @@ android {
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWorkSandbox\""
             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", "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 "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", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
             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
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "uriScheme", "\"threemawork\""
@@ -300,10 +315,9 @@ android {
 
 
             buildConfigField "boolean", "MD_ENABLED", "true"
             buildConfigField "boolean", "MD_ENABLED", "true"
 
 
-
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme       : "threemawork",
-                actionUrl       : "work.test.threema.ch",
+                uriScheme: "threemawork",
+                actionUrl: "work.test.threema.ch",
             ]
             ]
         }
         }
         onprem {
         onprem {
@@ -325,12 +339,13 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "null"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "null"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "null"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "null"
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_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[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "new String[] {\"ek1qBp4DyRmLL9J5sCmsKSfwbsiGNB4veDAODjkwe/k=\", \"Hrk8aCjwKkXySubI7CZ3y9Sx+oToEHjNkGw98WSRneU=\", \"5pEn1T/5bhecNWrp9NgUQweRfgVtu/I8gRb3VxGP7k4=\"}"
             buildConfigField "String", "LOG_TAG", "\"3maop\""
             buildConfigField "String", "LOG_TAG", "\"3maop\""
 
 
@@ -339,12 +354,13 @@ android {
             buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
             buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme: "threemaonprem",
-                actionUrl: "onprem.threema.ch",
+                uriScheme   : "threemaonprem",
+                actionUrl   : "onprem.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
                 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}"
             versionName "${app_version}b${beta_suffix}"
             // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
             // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
             applicationId "ch.threema.app.red"
             applicationId "ch.threema.app.red"
@@ -358,6 +374,7 @@ android {
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaBlue\""
             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", "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 "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/\""
             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", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mablue\""
             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
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme: "threemablue",
-                actionUrl: "blue.threema.ch",
+                uriScheme   : "threemablue",
+                actionUrl   : "blue.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.blue.call",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.blue.call",
             ]
             ]
         }
         }
@@ -404,8 +423,8 @@ android {
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
+                uriScheme   : "threemawork",
+                actionUrl   : "work.threema.ch",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
             ]
         }
         }
@@ -413,7 +432,7 @@ android {
             versionName "${app_version}l${beta_suffix}"
             versionName "${app_version}l${beta_suffix}"
             applicationId "ch.threema.app.libre"
             applicationId "ch.threema.app.libre"
             testApplicationId 'ch.threema.app.libre.test'
             testApplicationId 'ch.threema.app.libre.test'
-            resValue "string", "package_name",  applicationId
+            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Libre"
             resValue "string", "app_name", "Threema Libre"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
         }
         }
@@ -484,6 +503,7 @@ android {
         main {
         main {
             assets.srcDirs = ['assets']
             assets.srcDirs = ['assets']
             jniLibs.srcDirs = ['libs']
             jniLibs.srcDirs = ['libs']
+            res.srcDir 'src/main/res-rendezvous'
         }
         }
 
 
         // Based on Google services
         // 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=ALL-UNNAMED']
                 jvmArgs = jvmArgs + ['--add-opens=java.base/java.util.stream=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 + ['--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
             // 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
             // 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.android.material:material:1.12.0'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     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
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
@@ -893,7 +915,7 @@ dependencies {
         'com.google.android.gms:play-services-base:18.0.1': [],
         'com.google.android.gms:play-services-base:18.0.1': [],
 
 
         // Firebase push
         // 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-core'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-analytics'],
             [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
             [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 {
 sonarqube {
     properties {
     properties {
         property "sonar.sources", "src/main/, ../scripts/, ../scripts-internal/"
         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.commons.codec.**
 -dontnote org.apache.http.**
 -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
 # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native
 -keepclasseswithmembernames class * {
 -keepclasseswithmembernames class * {
     native <methods>;
     native <methods>;

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

@@ -22,20 +22,219 @@
 package ch.threema.app
 package ch.threema.app
 
 
 import ch.threema.app.managers.CoreServiceManager
 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.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.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.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
 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(
 class TestCoreServiceManager(
     override val version: AppVersion,
     override val version: AppVersion,
     override val databaseService: DatabaseServiceNew,
     override val databaseService: DatabaseServiceNew,
     override val preferenceStore: PreferenceStoreInterface,
     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 java.util.Objects;
 
 
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.GrantPermissionRule;
 import androidx.test.rule.GrantPermissionRule;
 import ch.threema.app.DangerousTest;
 import ch.threema.app.DangerousTest;
-import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.ThreemaApplication;
 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.backuprestore.BackupRestoreDataConfig;
-import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 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.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.ballot.BallotService;
+import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVRow;
 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.identitybackup.IdentityBackupDecoder;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import java8.util.stream.StreamSupport;
 import java8.util.stream.StreamSupport;
@@ -86,6 +94,7 @@ public class BackupServiceTest {
 	private static @NonNull String TEST_IDENTITY;
 	private static @NonNull String TEST_IDENTITY;
 
 
 	// Services
 	// Services
+	private @NonNull ServiceManager serviceManager;
 	private @NonNull FileService fileService;
 	private @NonNull FileService fileService;
     private @NonNull MessageService messageService;
     private @NonNull MessageService messageService;
     private @NonNull ConversationService conversationService;
     private @NonNull ConversationService conversationService;
@@ -93,6 +102,10 @@ public class BackupServiceTest {
     private @NonNull ContactService contactService;
     private @NonNull ContactService contactService;
     private @NonNull DistributionListService distributionListService;
     private @NonNull DistributionListService distributionListService;
     private @NonNull BallotService ballotService;
     private @NonNull BallotService ballotService;
+	private @NonNull APIConnector apiConnector;
+	private @NonNull ContactModelRepository contactModelRepository;
+
+	private final @NonNull BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
 
 
 	@Rule
 	@Rule
 	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
 	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
@@ -112,7 +125,7 @@ public class BackupServiceTest {
 	 */
 	 */
 	@Before
 	@Before
 	public void loadServices() throws Exception {
 	public void loadServices() throws Exception {
-		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
+		this.serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
 		this.fileService = serviceManager.getFileService();
 		this.fileService = serviceManager.getFileService();
 		this.messageService = serviceManager.getMessageService();
 		this.messageService = serviceManager.getMessageService();
 		this.conversationService = serviceManager.getConversationService();
 		this.conversationService = serviceManager.getConversationService();
@@ -120,12 +133,14 @@ public class BackupServiceTest {
 		this.contactService = serviceManager.getContactService();
 		this.contactService = serviceManager.getContactService();
 		this.distributionListService = serviceManager.getDistributionListService();
 		this.distributionListService = serviceManager.getDistributionListService();
 		this.ballotService = serviceManager.getBallotService();
 		this.ballotService = serviceManager.getBallotService();
+		this.apiConnector = serviceManager.getAPIConnector();
+		this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
 	}
 	}
 
 
 	/**
 	/**
 	 * Return the list of backups for the TEST_IDENTITY identity.
 	 * 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()) {
 		if (backupPath.exists() && backupPath.isDirectory()) {
 			final File[] files = backupPath.listFiles(
 			final File[] files = backupPath.listFiles(
 				(dir, name) -> name.startsWith("threema-backup_" + TEST_IDENTITY)
 				(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.
 	 * 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
 		// List old backups
 		final File backupPath = this.fileService.getBackupPath();
 		final File backupPath = this.fileService.getBackupPath();
 		final List<File> initialBackupFiles = this.getUserBackups(backupPath);
 		final List<File> initialBackupFiles = this.getUserBackups(backupPath);
@@ -250,18 +265,18 @@ public class BackupServiceTest {
         this.messageService.removeAll();
         this.messageService.removeAll();
         this.conversationService.reset();
         this.conversationService.reset();
         this.groupService.removeAll();
         this.groupService.removeAll();
-        this.contactService.removeAll();
+        this.backgroundExecutor.execute(getContactDeleteTask());
         this.distributionListService.removeAll();
         this.distributionListService.removeAll();
         this.ballotService.removeAll();
         this.ballotService.removeAll();
 
 
         // Insert test data:
         // Insert test data:
 	    // Contacts
 	    // Contacts
-	    final ContactModel contact1 = this.contactService.createContactByIdentity("CDXVZ5E4", true);
+	    final ContactModel contact1 = createContact("CDXVZ5E4");
 	    contact1.setFirstName("Fritzli");
 	    contact1.setFirstName("Fritzli");
 	    contact1.setLastName("Bühler");
 	    contact1.setLastName("Bühler");
 	    this.contactService.save(contact1);
 	    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
 	    // Messages contact 1
 	    this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
 	    this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
 	    this.messageService.sendText("Phở?", 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
 package ch.threema.app.contacts
 
 
+import android.os.Looper
+import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy
 import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
 import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
 import ch.threema.app.asynctasks.AlreadyVerified
 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.ContactExists
-import ch.threema.app.asynctasks.Failed
-import ch.threema.app.asynctasks.ContactAddResult
 import ch.threema.app.asynctasks.ContactModified
 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.ConfigUtils
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.data.TestDatabaseService
 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
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
-import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
@@ -51,20 +56,28 @@ import org.junit.Before
 import java.net.HttpURLConnection
 import java.net.HttpURLConnection
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
-import kotlin.test.assertTrue
 import kotlin.test.assertFalse
 import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlin.test.fail
 
 
 class AddOrUpdateContactBackgroundTaskTest {
 class AddOrUpdateContactBackgroundTaskTest {
 
 
     private val backgroundExecutor = BackgroundExecutor()
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
+    private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactModelRepository: ContactModelRepository
 
 
     @Before
     @Before
     fun before() {
     fun before() {
         databaseService = TestDatabaseService()
         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
     @Test
@@ -78,19 +91,50 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 12
                     it.featureMask = 12
                     it.type = 0
                     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)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertEquals(newIdentity, data.identity)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityType.NORMAL, data.identityType)
-                assertEquals(ContactModel.State.ACTIVE, data.activityState)
+                assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
+                assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
             }
             }
         )
         )
     }
     }
@@ -106,19 +150,20 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 127
                     it.featureMask = 127
                     it.type = 1
                     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)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertEquals(newIdentity, data.identity)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(127u, data.featureMask)
                 assertEquals(127u, data.featureMask)
                 assertEquals(IdentityType.WORK, data.identityType)
                 assertEquals(IdentityType.WORK, data.identityType)
-                assertEquals(ContactModel.State.INACTIVE, data.activityState)
+                assertEquals(IdentityState.INACTIVE, data.activityState)
                 assertEquals(VerificationLevel.FULLY_VERIFIED, data.verificationLevel)
                 assertEquals(VerificationLevel.FULLY_VERIFIED, data.verificationLevel)
+                assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
             },
             },
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
         )
@@ -134,11 +179,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 127
                     it.featureMask = 127
                     it.type = 1
                     it.type = 1
-                    it.state = IdentityState.INACTIVE
+                    it.state = IdentityState.INACTIVE.value
                 }
                 }
             },
             },
             {
             {
-                assertTrue(it is Failed)
+                assertTrue(it is UserIdentity)
             },
             },
             newIdentity = myIdentity,
             newIdentity = myIdentity,
             myIdentity = myIdentity,
             myIdentity = myIdentity,
@@ -154,11 +199,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                     it.featureMask = 12
                     it.featureMask = 12
                     it.type = 0
                     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) }
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES).also { it.fill(1) }
         )
         )
@@ -171,7 +216,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 throw HttpConnectionException(HttpURLConnection.HTTP_NOT_FOUND, Exception())
                 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.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.featureMask = 12
                 it.type = 0
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
             }
         }
         }
 
 
@@ -192,7 +237,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             }
             }
         )
         )
 
 
@@ -215,7 +260,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = publicKey
                 it.publicKey = publicKey
                 it.featureMask = 12
                 it.featureMask = 12
                 it.type = 0
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
             }
         }
         }
 
 
@@ -223,7 +268,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             },
             publicKey = publicKey,
             publicKey = publicKey,
         )
         )
@@ -239,7 +284,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     }
     }
 
 
     @Test
     @Test
-    fun testAddGroupContact() {
+    fun testUpgradeGroupContact() {
         val newIdentity = "01234567"
         val newIdentity = "01234567"
 
 
         val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
         val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
@@ -248,7 +293,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.featureMask = 12
                 it.type = 0
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
             }
         }
         }
 
 
@@ -256,7 +301,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             },
             newIdentity = newIdentity
             newIdentity = newIdentity
         )
         )
@@ -292,7 +337,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.featureMask = 12
                 it.type = 0
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
             }
         }
         }
 
 
@@ -300,7 +345,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             },
             newIdentity = newIdentity
             newIdentity = newIdentity
         )
         )
@@ -310,14 +355,17 @@ class AddOrUpdateContactBackgroundTaskTest {
         // Assert that the verification level is unverified
         // Assert that the verification level is unverified
         assertEquals(VerificationLevel.UNVERIFIED, contactModel.data.value!!.verificationLevel)
         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(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
                 assertTrue(it is ContactModified)
                 assertTrue(it is ContactModified)
                 assertTrue(it.verificationLevelChanged)
                 assertTrue(it.verificationLevelChanged)
                 assertFalse(it.acquaintanceLevelChanged)
                 assertFalse(it.acquaintanceLevelChanged)
-                assertEquals(VerificationLevel.FULLY_VERIFIED, contactModel.data.value!!.verificationLevel)
+                assertEquals(
+                    VerificationLevel.FULLY_VERIFIED,
+                    contactModel.data.value!!.verificationLevel
+                )
             },
             },
             newIdentity = newIdentity,
             newIdentity = newIdentity,
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
@@ -334,7 +382,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
                 it.featureMask = 12
                 it.featureMask = 12
                 it.type = 0
                 it.type = 0
-                it.state = IdentityState.ACTIVE
+                it.state = IdentityState.ACTIVE.value
             }
             }
         }
         }
 
 
@@ -342,7 +390,7 @@ class AddOrUpdateContactBackgroundTaskTest {
         testAddingContact(
         testAddingContact(
             apiConnectorResult,
             apiConnectorResult,
             {
             {
-                assertTrue(it is Success)
+                assertTrue(it is ContactCreated)
             },
             },
             newIdentity = newIdentity
             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(
     private fun testAddingContact(
         fetchIdentity: (identity: String) -> FetchIdentityResult,
         fetchIdentity: (identity: String) -> FetchIdentityResult,
-        runOnFinished: (result: ContactAddResult) -> Unit,
+        runOnFinished: (result: ContactResult) -> Unit,
         newIdentity: String = "01234567",
         newIdentity: String = "01234567",
+        acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
         myIdentity: String = "00000000",
         myIdentity: String = "00000000",
         publicKey: ByteArray? = null,
         publicKey: ByteArray? = null,
     ) {
     ) {
@@ -386,19 +494,21 @@ class AddOrUpdateContactBackgroundTaskTest {
             fetchIdentity(it)
             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
         // Assert that the test is not stopped before running the background task completely
         runBlocking {
         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 androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.activities.HomeActivity
 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.EmptyOrDeleteConversationsAsyncTask
+import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
 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.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.domain.models.MessageId
 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.GroupEditMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
+import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.factories.GroupMessageModelFactory
 import ch.threema.storage.factories.GroupMessageModelFactory
 import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
@@ -62,9 +69,28 @@ class EditHistoryTest : MessageProcessorProvider() {
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val groupService: GroupService by lazy { serviceManager.groupService }
     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
     @Test
     fun testHistoryDeletedOnContactMessageDelete() = runTest {
     fun testHistoryDeletedOnContactMessageDelete() = runTest {
@@ -248,7 +274,15 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
         messageModel.assertHistorySize(1)
         messageModel.assertHistorySize(1)
 
 
-        contactService.remove(contactA.contactModel)
+        BackgroundExecutor().executeDeferred(
+            MarkContactAsDeletedBackgroundTask(
+                setOf(messageModel.identity),
+                contactModelRepository,
+                deleteContactServices,
+                ContactSyncPolicy.INCLUDE,
+                AndroidContactLinkPolicy.KEEP,
+            )
+        ).await()
 
 
         messageModel.assertHistorySize(0)
         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
         // 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>()
         launchActivity<HomeActivity>()
 
 
         serviceManager.groupService.resetCache(group.groupModel.id)
         serviceManager.groupService.resetCache(group.groupModel.id)
 
 
         assertEquals(
         assertEquals(
             group.members.map { it.identity },
             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)
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
             .apply { start() }
             .apply { start() }
@@ -182,7 +187,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         )
         )
         assertEquals(
         assertEquals(
             group.members.map { it.identity }.filter { it != contact.identity },
             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
         // Assert that no message has been sent as a response to a group leave
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -200,9 +206,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
         serviceManager.groupService.resetCache(group.groupModel.id)
         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() }
         val leaveTracker = GroupLeaveTracker(group, contact.identity).apply { start() }
 
 
@@ -215,13 +220,8 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
         serviceManager.groupService.resetCache(group.groupModel.id)
         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) {
         if (shouldSendSyncRequest) {
             // Should send sync request to the group creator
             // Should send sync request to the group creator
@@ -245,6 +245,37 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             toIdentity = myContact.identity
             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 class GroupLeaveTracker(
         private val group: TestGroup?,
         private val group: TestGroup?,
         private val leavingIdentity: String?,
         private val leavingIdentity: String?,
@@ -264,13 +295,11 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onNewMember(
             override fun onNewMember(
                 group: GroupModel?,
                 group: GroupModel?,
                 newIdentity: String?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) = fail()
             ) = fail()
 
 
             override fun onMemberLeave(
             override fun onMemberLeave(
                 groupModel: GroupModel?,
                 groupModel: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 assertFalse(memberHasLeft)
                 assertFalse(memberHasLeft)
                 group?.let {
                 group?.let {
@@ -284,7 +313,6 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onMemberKicked(
             override fun onMemberKicked(
                 group: GroupModel?,
                 group: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) = fail()
             ) = fail()
 
 
             override fun onUpdate(groupModel: GroupModel?) = fail()
             override fun onUpdate(groupModel: GroupModel?) = fail()
@@ -294,7 +322,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             override fun onGroupStateChanged(
             override fun onGroupStateChanged(
                 groupModel: GroupModel?,
                 groupModel: GroupModel?,
                 oldState: Int,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
             ) {
                 if (!expectStateChange) {
                 if (!expectStateChange) {
                     fail()
                     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
         // Create group rename message
         val groupARenamed =
         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() }
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
 
 
@@ -103,7 +103,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
 
         // Create group rename message (from wrong sender)
         // Create group rename message (from wrong sender)
         val groupARenamed =
         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() }
         val renameTracker = GroupRenameTracker(null).apply { start() }
 
 
@@ -215,7 +215,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onNewMember(
             override fun onNewMember(
                 group: GroupModel?,
                 group: GroupModel?,
                 newIdentity: String?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 fail()
                 fail()
             }
             }
@@ -223,7 +222,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onMemberLeave(
             override fun onMemberLeave(
                 group: GroupModel?,
                 group: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 fail()
                 fail()
             }
             }
@@ -231,7 +229,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             override fun onMemberKicked(
             override fun onMemberKicked(
                 group: GroupModel?,
                 group: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 fail()
                 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
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
 import org.junit.Assert.fail
 import org.junit.Test
 import org.junit.Test
@@ -200,6 +201,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert initial group conversations
         // Assert initial group conversations
         assertGroupConversations(scenario, initialGroups)
         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(
         val setupTracker = GroupSetupTracker(
             groupAB,
             groupAB,
             myContact.identity,
             myContact.identity,
@@ -217,6 +224,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Create message box from contact A (group creator)
         // Create message box from contact A (group creator)
         processMessage(message, groupAB.groupCreator.identityStore)
         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
         // Assert that group conversations did not appear, disappear, or change their name
         assertGroupConversations(scenario, initialGroups)
         assertGroupConversations(scenario, initialGroups)
 
 
@@ -287,7 +300,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             newAGroup.members,
             newAGroup.members,
             // Note that this will be the group name because we only test the group setup message
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
             // that is not followed by a group rename
-            "12345678, Me, ABCDEFGH",
+            "Me, 12345678, ABCDEFGH",
+            myContact.identity,
         )
         )
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
@@ -316,6 +330,22 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         setupTracker.assertAllKickedMembersRemoved()
         setupTracker.assertAllKickedMembersRemoved()
         setupTracker.assertCreateLeave()
         setupTracker.assertCreateLeave()
         setupTracker.stop()
         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
             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
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
             // that is not followed by a group rename
-            "12345678, Me, ABCDEFGH",
+            "Me, 12345678, ABCDEFGH",
+            myContact.identity,
         )
         )
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
@@ -459,7 +490,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             override fun onNewMember(
             override fun onNewMember(
                 group: GroupModel?,
                 group: GroupModel?,
                 newIdentity: String?,
                 newIdentity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 assertTrue("Did not expect member $newIdentity", newMembers.contains(newIdentity))
                 assertTrue("Did not expect member $newIdentity", newMembers.contains(newIdentity))
                 newMembersAdded.add(newIdentity!!)
                 newMembersAdded.add(newIdentity!!)
@@ -468,13 +498,11 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             override fun onMemberLeave(
             override fun onMemberLeave(
                 group: GroupModel?,
                 group: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) = fail()
             ) = fail()
 
 
             override fun onMemberKicked(
             override fun onMemberKicked(
                 group: GroupModel?,
                 group: GroupModel?,
                 identity: String?,
                 identity: String?,
-                previousMemberCount: Int
             ) {
             ) {
                 assertTrue(kickedMembers.contains(identity))
                 assertTrue(kickedMembers.contains(identity))
                 kickedMembersRemoved.add(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 ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.assertTrue
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.fail
 import org.junit.Assert.fail
 import org.junit.Test
 import org.junit.Test
 import java.util.Date
 import java.util.Date
 
 
-@OptIn(ExperimentalCoroutinesApi::class)
 @DangerousTest
 @DangerousTest
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
 
@@ -93,9 +91,9 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         }
         }
 
 
         val pollSetupMessage = PollSetupMessage().also {
         val pollSetupMessage = PollSetupMessage().also {
-            it.ballotCreator = ballotCreator
+            it.ballotCreatorIdentity = ballotCreator
             it.ballotId = ballotId
             it.ballotId = ballotId
-            it.data = ballotData
+            it.ballotData = ballotData
         }.enrich()
         }.enrich()
 
 
         // Test a valid ballot setup message that opens a poll
         // Test a valid ballot setup message that opens a poll
@@ -103,12 +101,9 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
 
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
             voteMessage.ballotId = ballotId
             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()
         }.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 androidx.test.rule.GrantPermissionRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.FileService
 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
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 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.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.NonceFactory
+import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryDHSessionStore
 import ch.threema.domain.helpers.InMemoryDHSessionStore
 import ch.threema.domain.helpers.InMemoryNonceStore
 import ch.threema.domain.helpers.InMemoryNonceStore
+import ch.threema.domain.models.BasicContact
 import ch.threema.domain.models.Contact
 import ch.threema.domain.models.Contact
 import ch.threema.domain.models.GroupId
 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.ThreemaFeature
+import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.coders.MessageBox
 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.protocol.csp.messages.fs.ForwardSecurityEnvelopeMessage
 import ch.threema.domain.stores.ContactStore
 import ch.threema.domain.stores.ContactStore
 import ch.threema.domain.stores.IdentityStoreInterface
 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.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskCodec
@@ -87,31 +95,32 @@ open class MessageProcessorProvider {
     protected val contactB = TestContact("ABCDEFGH")
     protected val contactB = TestContact("ABCDEFGH")
     protected val contactC = TestContact("TESTTEST")
     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 =
     protected val myGroupWithProfilePicture =
         TestGroup(
         TestGroup(
             GroupId(1),
             GroupId(1),
             myContact,
             myContact,
             listOf(myContact, contactA),
             listOf(myContact, contactA),
             "MyGroupWithPicture",
             "MyGroupWithPicture",
-            byteArrayOf(0, 1, 2, 3)
+            byteArrayOf(0, 1, 2, 3),
+            myContact.identity
         )
         )
     protected val groupA =
     protected val groupA =
-        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA")
+        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
     protected val groupB =
     protected val groupB =
-        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB")
+        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB", myContact.identity)
     protected val groupAB =
     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 =
     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 =
     protected val groupALeft =
-        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft")
+        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft", myContact.identity)
     protected val myUnknownGroup =
     protected val myUnknownGroup =
-        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup")
+        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup", myContact.identity)
     protected val myLeftGroup =
     protected val myLeftGroup =
-        TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup")
+        TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
     protected val newAGroup =
     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()
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
     private val contactStore: ContactStore = InMemoryContactStore().apply {
     private val contactStore: ContactStore = InMemoryContactStore().apply {
@@ -128,11 +137,21 @@ open class MessageProcessorProvider {
         contactC.identity to contactC.identityStore,
         contactC.identity to contactC.identityStore,
     ).toMap()
     ).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(
         override fun messageWithoutFSReceived(
             contact: Contact,
             contact: Contact,
             session: DHSession,
             session: DHSession,
-            message: AbstractMessage
+            message: AbstractMessage,
         ) {
         ) {
             throw AssertionError("We do not accept messages without forward security")
             throw AssertionError("We do not accept messages without forward security")
         }
         }
@@ -244,12 +263,6 @@ open class MessageProcessorProvider {
 
 
             override fun hasPendingTasks(): Boolean = false
             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) {
             override fun addQueueSendCompleteListener(listener: QueueSendCompleteListener) {
                 // Nothing to do
                 // 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
             // 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.
             // need to send the first message, which is the init.
             val result =
             val result =
-                myForwardSecurityMessageProcessor.makeMessage(it.contact, textMessage, globalTaskCodec)
+                myForwardSecurityMessageProcessor.runFsEncapsulationSteps(
+                    it.toBasicContact(),
+                    textMessage,
+                    nonceFactory.next(NonceScope.CSP),
+                    nonceFactory,
+                    globalTaskCodec,
+                )
 
 
             // Commit the dh session state
             // Commit the dh session state
             myForwardSecurityMessageProcessor.commitSessionState(result)
             myForwardSecurityMessageProcessor.commitSessionState(result)
 
 
             // Process the init message
             // 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 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 {
             runBlocking {
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                     myContact.contact,
                     myContact.contact,
@@ -390,9 +412,11 @@ open class MessageProcessorProvider {
             serviceManager.databaseServiceNew,
             serviceManager.databaseServiceNew,
             serviceManager.preferenceStore,
             serviceManager.preferenceStore,
             TaskArchiverImpl(serviceManager.databaseServiceNew.taskArchiveFactory),
             TaskArchiverImpl(serviceManager.databaseServiceNew.taskArchiveFactory),
-            serviceManager.deviceCookieManager as DeviceCookieManagerImpl,
+            serviceManager.deviceCookieManager,
             taskManager,
             taskManager,
-            serviceManager.multiDeviceManager as MultiDeviceManagerImpl
+            serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
+            serviceManager.identityStore,
+            serviceManager.nonceFactory,
         )
         )
 
 
         val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
         val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
@@ -427,7 +451,14 @@ open class MessageProcessorProvider {
         val contactStore = serviceManager.contactStore
         val contactStore = serviceManager.contactStore
         val fileService = serviceManager.fileService
         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) }
         initialGroups.forEach { addGroupToDatabase(it, databaseService, fileService) }
     }
     }
@@ -443,7 +474,10 @@ open class MessageProcessorProvider {
                 .setFeatureMask(ThreemaFeature.FORWARD_SECURITY)
                 .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(
     private fun addGroupToDatabase(
@@ -454,7 +488,7 @@ open class MessageProcessorProvider {
         val groupModel = testGroup.groupModel
         val groupModel = testGroup.groupModel
         databaseService.groupModelFactory.createOrUpdate(groupModel)
         databaseService.groupModelFactory.createOrUpdate(groupModel)
         testGroup.setLocalGroupId(groupModel.id)
         testGroup.setLocalGroupId(groupModel.id)
-        testGroup.members.forEach { member ->
+        testGroup.members.filter { it.identity != myContact.identity }.forEach { member ->
             val memberModel = GroupMemberModel()
             val memberModel = GroupMemberModel()
                 .setGroupId(groupModel.id)
                 .setGroupId(groupModel.id)
                 .setIdentity(member.identity)
                 .setIdentity(member.identity)
@@ -479,20 +513,9 @@ open class MessageProcessorProvider {
         )
         )
 
 
         // Process the group message
         // 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
         // Assert that this message has been acked towards the server
         assertEquals(
         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.
      * Create a message box from a user with the given identity store.
      */
      */
@@ -514,19 +544,45 @@ open class MessageProcessorProvider {
         forwardSecurityMessageProcessor: ForwardSecurityMessageProcessor,
         forwardSecurityMessageProcessor: ForwardSecurityMessageProcessor,
     ): MessageBox {
     ): MessageBox {
         val nonceFactory = NonceFactory(object : NonceStore {
         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(
             contactStore.getContactForIdentityIncludingCache(
                 msg.toIdentity
                 msg.toIdentity
-            )!!, msg, globalTaskCodec
-        ).outgoingMessages.last()
+            )!!.enhanceToBasicContact(),
+            msg,
+            nonceFactory.next(NonceScope.CSP),
+            nonceFactory,
+            globalTaskCodec
+        ).outgoingMessages.last().first
 
 
         val messageCoder = MessageCoder(contactStore, identityStore)
         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.Before;
 import org.junit.Test;
 import org.junit.Test;
 
 
+import java.io.File;
 import java.io.IOException;
 import java.io.IOException;
 import java.util.Date;
 import java.util.Date;
 
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.group.GroupInviteService;
 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.app.services.license.LicenseService;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteData;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteData;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.storage.DatabaseServiceNew;
 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() {
 			public Account getAccount() {
 				return null;
 				return null;
 			}
 			}
@@ -228,7 +259,7 @@ public class GroupInviteServiceTest {
 
 
 			@Nullable
 			@Nullable
 			@Override
 			@Override
-			public String setPublicNickname(String publicNickname) {
+			public String setPublicNickname(String publicNickname, @NonNull TriggerSource triggerSource) {
 				return null;
 				return null;
 			}
 			}
 
 

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

@@ -22,15 +22,25 @@
 package ch.threema.app.tasks
 package ch.threema.app.tasks
 
 
 import ch.threema.app.ThreemaApplication
 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.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.fail
 import junit.framework.TestCase.fail
+import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.Json
 import org.junit.Test
 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
  * These tests are useful to detect when a task cannot be created out of a persisted representation
@@ -187,8 +197,8 @@ class PersistableTasksTest {
 
 
     @Test
     @Test
     fun testDeleteAndTerminateFSSessionsTask() {
     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(
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
             DeleteAndTerminateFSSessionsTask::class.java,
@@ -225,10 +235,18 @@ class PersistableTasksTest {
     }
     }
 
 
     @Test
     @Test
-    fun testOutgoingDropDeviceTask() {
+    fun testOutboundIncomingContactMessageUpdateReadTask() {
         assertValidEncoding(
         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) {
     private fun <T> assertValidEncoding(expectedTaskClass: Class<T>, encodedTask: String) {
         val decodedTask = encodedTask.decodeToTask()
         val decodedTask = encodedTask.decodeToTask()
         assertNotNull(decodedTask)
         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.base.utils.Utils;
 import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.Contact;
+import ch.threema.domain.models.BasicContact;
 import ch.threema.domain.models.GroupId;
 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.models.VerificationLevel;
+import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.stores.IdentityStoreInterface;
 import ch.threema.domain.stores.IdentityStoreInterface;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
@@ -103,6 +107,28 @@ public class TestHelpers {
 				null
 				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 {
 	public static final class TestGroup {
@@ -123,13 +149,20 @@ public class TestHelpers {
 		@Nullable
 		@Nullable
 		public final byte[] profilePicture;
 		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(
 		public TestGroup(
 			@NonNull GroupId apiGroupId,
 			@NonNull GroupId apiGroupId,
 			@NonNull TestContact groupCreator,
 			@NonNull TestContact groupCreator,
 			@NonNull List<TestContact> members,
 			@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(
 		public TestGroup(
@@ -137,23 +170,38 @@ public class TestHelpers {
 			@NonNull TestContact groupCreator,
 			@NonNull TestContact groupCreator,
 			@NonNull List<TestContact> members,
 			@NonNull List<TestContact> members,
 			@NonNull String groupName,
 			@NonNull String groupName,
-			@Nullable byte[] profilePicture
+			@Nullable byte[] profilePicture,
+			@NonNull String userIdentity
 		) {
 		) {
 			this.apiGroupId = apiGroupId;
 			this.apiGroupId = apiGroupId;
 			this.groupCreator = groupCreator;
 			this.groupCreator = groupCreator;
 			this.members = members;
 			this.members = members;
 			this.groupName = groupName;
 			this.groupName = groupName;
 			this.profilePicture = profilePicture;
 			this.profilePicture = profilePicture;
+			this.userIdentity = userIdentity;
 		}
 		}
 
 
 		@NonNull
 		@NonNull
 		public GroupModel getGroupModel() {
 		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()
 			return new GroupModel()
 				.setApiGroupId(apiGroupId)
 				.setApiGroupId(apiGroupId)
 				.setCreatedAt(new Date())
 				.setCreatedAt(new Date())
 				.setName(this.groupName)
 				.setName(this.groupName)
 				.setCreatorIdentity(this.groupCreator.identity)
 				.setCreatorIdentity(this.groupCreator.identity)
-				.setId(localGroupId);
+				.setId(localGroupId)
+				.setUserState(userState);
 		}
 		}
 
 
 		public void setLocalGroupId(int localGroupId) {
 		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<>()
 			new HashMap<>()
 		);
 		);
 		final AbstractMessageModel messageModel = new MessageModel();
 		final AbstractMessageModel messageModel = new MessageModel();
-		messageModel.setFileData(fileDataModel);
+		messageModel.setFileDataModel(fileDataModel);
 		messageModel.setCreatedAt(createdAt);
 		messageModel.setCreatedAt(createdAt);
 		messageModel.setApiMessageId(messageId);
 		messageModel.setApiMessageId(messageId);
 		Message.maybePutFile(builder, "file", messageModel, fileDataModel);
 		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
 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.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.ContactSyncState
+import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
 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.domain.models.WorkVerificationLevel
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import ch.threema.storage.models.ContactModel.State
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
 import ch.threema.testhelpers.randomIdentity
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
-import junit.framework.TestCase.assertNotNull
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertThrows
 import org.junit.Assert.assertThrows
 import org.junit.Before
 import org.junit.Before
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 import java.util.Date
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-import kotlin.test.assertFalse
 import kotlin.test.assertNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 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 databaseService: TestDatabaseService
+    private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     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 {
     private enum class TestTriggerSource {
         FROM_LOCAL,
         FROM_LOCAL,
         FROM_REMOTE,
         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
     @Before
     fun before() {
     fun before() {
+        // Instantiate services where MD is disabled
         this.databaseService = TestDatabaseService()
         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
     @Test
     fun createFromLocal() {
     fun createFromLocal() {
-        initialValuesSet.forEach { testCreateFromLocalOrRemote(it, TestTriggerSource.FROM_LOCAL) }
+        testCreateFromLocalOrRemote(contactModelData, TestTriggerSource.FROM_LOCAL)
     }
     }
 
 
+    /**
+     * Test creation of a new contact from remote.
+     */
     @Test
     @Test
     fun createFromRemote() {
     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
     @Test
     fun createFromLocalTwice() {
     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
     @Test
     fun createFromRemoteTwice() {
     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
     @Test
-    fun createFromSync() {
-        // TODO(ANDR-2835): Create contact from sync
+    fun createFromSyncTwice() {
+        testCreateFromSyncTwice(contactModelData)
     }
     }
 
 
     @Test
     @Test
     fun getByIdentityNotFound() {
     fun getByIdentityNotFound() {
         val model = contactModelRepository.getByIdentity("ABCDEFGH")
         val model = contactModelRepository.getByIdentity("ABCDEFGH")
         assertNull(model)
         assertNull(model)
+        val modelMd = contactModelRepositoryMd.getByIdentity("ABCDEFGH")
+        assertNull(modelMd)
     }
     }
 
 
     @Test
     @Test
@@ -119,219 +228,219 @@ class ContactModelRepositoryTest {
 
 
         // Create contact using "old model"
         // Create contact using "old model"
         databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
         databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
+        databaseServiceMd.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
 
 
         // Fetch contact using "new model"
         // 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.identity == identity }
         assertTrue { model.data.value?.identity == identity }
         assertTrue { model.data.value?.identity == identity }
         assertContentEquals(publicKey, model.data.value?.publicKey)
         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(
     private fun testCreateFromLocalOrRemote(
-        initialValues: InitialValues,
+        contactModelData: ContactModelData,
         triggerSource: TestTriggerSource,
         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) {
             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)
         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(
     private fun testCreateFromLocalOrRemoteTwice(
-        initialValues: InitialValues,
+        contactModelData: ContactModelData,
         triggerSource: TestTriggerSource,
         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
         // 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)
         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
         // 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
 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.TestDatabaseService
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.data.storage.EditHistoryDaoImpl
+import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageModel
@@ -41,7 +45,13 @@ class EditHistoryRepositoryTest {
     @Before
     @Before
     fun before() {
     fun before() {
         databaseService = TestDatabaseService()
         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)
         editHistoryDao = EditHistoryDaoImpl(databaseService)
     }
     }
 
 

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

@@ -22,11 +22,15 @@
 package ch.threema.data.repositories
 package ch.threema.data.repositories
 
 
 import ch.threema.data.TestDatabaseService
 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.GroupIdentity
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
 import ch.threema.data.storage.DatabaseBackend
 import ch.threema.data.storage.DbGroup
 import ch.threema.data.storage.DbGroup
 import ch.threema.data.storage.SqliteDatabaseBackend
 import ch.threema.data.storage.SqliteDatabaseBackend
+import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.GroupId
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
 import org.junit.Assert
 import org.junit.Assert
@@ -40,6 +44,7 @@ import kotlin.test.assertTrue
 class GroupModelRepositoryTest {
 class GroupModelRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseBackend: DatabaseBackend
     private lateinit var databaseBackend: DatabaseBackend
+    private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var groupModelRepository: GroupModelRepository
     private lateinit var groupModelRepository: GroupModelRepository
 
 
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
@@ -56,6 +61,7 @@ class GroupModelRepositoryTest {
             "Description",
             "Description",
             Date(),
             Date(),
             setOf("AAAAAAAA", "BBBBBBBB"),
             setOf("AAAAAAAA", "BBBBBBBB"),
+            GroupModel.UserState.MEMBER,
         )
         )
     }
     }
 
 
@@ -63,7 +69,13 @@ class GroupModelRepositoryTest {
     fun before() {
     fun before() {
         this.databaseService = TestDatabaseService()
         this.databaseService = TestDatabaseService()
         this.databaseBackend = SqliteDatabaseBackend(databaseService)
         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
     @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>
 <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>
 <h2>Gesture Views</h2>
 
 
 <p>Copyright (c) 2022 Alex Vasilkov</p>
 <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:launchMode="singleTop"
 			android:parentActivityName=".activities.HomeActivity"
 			android:parentActivityName=".activities.HomeActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			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 -->
 		<!-- VoIP activities -->
 		<activity
 		<activity
@@ -885,6 +890,9 @@
 			android:name=".activities.ProblemSolverActivity"
 			android:name=".activities.ProblemSolverActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:launchMode="singleTop"/>
 			android:launchMode="singleTop"/>
+		<activity
+			android:name=".debug.PatternLibraryActivity"
+			android:theme="@style/Theme.Threema.Translucent" />
 
 
 		<!-- services -->
 		<!-- services -->
 		<service
 		<service

File diff suppressed because it is too large
+ 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
-import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask;
 import ch.threema.app.asynctasks.AlreadyVerified;
 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.ContactExists;
 import ch.threema.app.asynctasks.ContactModified;
 import ch.threema.app.asynctasks.ContactModified;
 import ch.threema.app.asynctasks.Failed;
 import ch.threema.app.asynctasks.Failed;
 import ch.threema.app.asynctasks.PolicyViolation;
 import ch.threema.app.asynctasks.PolicyViolation;
-import ch.threema.app.asynctasks.Success;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.NewContactDialog;
 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.base.utils.LoggingUtil;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.protocol.api.APIConnector;
 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.app.services.QRCodeServiceImpl.QR_TYPE_ID;
 import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
 import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
@@ -237,8 +238,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			return;
 			return;
 		}
 		}
 
 
-		backgroundExecutor.execute(new AddOrUpdateContactBackgroundTask(
+		backgroundExecutor.execute(new BasicAddOrUpdateContactBackgroundTask(
 			identity,
 			identity,
+			ContactModel.AcquaintanceLevel.DIRECT,
 			getMyIdentity(),
 			getMyIdentity(),
 			apiConnector,
 			apiConnector,
 			contactModelRepository,
 			contactModelRepository,
@@ -252,14 +254,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			}
 			}
 
 
 			@Override
 			@Override
-			public void onFinished(@NonNull ContactAddResult result) {
+			public void onFinished(@NonNull ContactResult result) {
 				if (isDestroyed()) {
 				if (isDestroyed()) {
 					return;
 					return;
 				}
 				}
 
 
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
 
 
-				if (result instanceof Success) {
+				if (result instanceof ContactCreated) {
 					showContactAndFinish(identity, R.string.creating_contact_successful);
 					showContactAndFinish(identity, R.string.creating_contact_successful);
 				} else if (result instanceof ContactModified) {
 				} else if (result instanceof ContactModified) {
 					if (((ContactModified) result).getAcquaintanceLevelChanged()) {
 					if (((ContactModified) result).getAcquaintanceLevelChanged()) {
@@ -271,6 +273,9 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 					showContactAndFinish(identity, R.string.scan_duplicate);
 					showContactAndFinish(identity, R.string.scan_duplicate);
 				} else if (result instanceof ContactExists) {
 				} else if (result instanceof ContactExists) {
 					showContactAndFinish(identity, R.string.identity_already_exists);
 					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) {
 				} else if (result instanceof Failed) {
 					GenericAlertDialog.newInstance(
 					GenericAlertDialog.newInstance(
 						ConfigUtils.isOnPremBuild() ?
 						ConfigUtils.isOnPremBuild() ?
@@ -280,9 +285,6 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 						R.string.close,
 						R.string.close,
 						0
 						0
 					).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
 					).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 org.slf4j.Logger;
 
 
+import androidx.annotation.NonNull;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 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.grouplinks.OutgoingGroupRequestActivity;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.LockAppService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.HiddenChatUtil;
 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.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.domain.protocol.csp.ProtocolDefines;
+import ch.threema.storage.models.ContactModel;
 
 
 public class AppLinksActivity extends ThreemaToolbarActivity {
 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
     @Override
     public void onCreate(Bundle savedInstanceState) {
     public void onCreate(Bundle savedInstanceState) {
@@ -91,40 +103,23 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         finish();
         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) {
     private void handleGroupLinkUrl(Uri appLinkData) {
         logger.info("Handle group link url");
         logger.info("Handle group link url");
@@ -139,27 +134,70 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         overridePendingTransition(0, 0);
         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.content.pm.PackageManager;
 import android.graphics.Color;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuff;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Handler;
@@ -46,9 +45,11 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 
 
 import java.io.File;
 import java.io.File;
+import java.lang.ref.WeakReference;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.Objects;
 import java.util.Objects;
+import java.util.Set;
 
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
@@ -64,9 +65,19 @@ import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.ContactDetailAdapter;
 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.ContactEditDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
-import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 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.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 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.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QRScannerUtil;
 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.ShareUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
 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.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.models.ContactModelData;
 import ch.threema.data.models.ContactModelData;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
 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_EDIT = "cedit";
 	private static final String DIALOG_TAG_DELETE_CONTACT = "deleteContact";
 	private static final String DIALOG_TAG_DELETE_CONTACT = "deleteContact";
 	private static final String DIALOG_TAG_EXCLUDE_CONTACT = "excludeContact";
 	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_ADD_CONTACT = "dac";
 	private static final String DIALOG_TAG_CONFIRM_BLOCK = "block";
 	private static final String DIALOG_TAG_CONFIRM_BLOCK = "block";
 
 
@@ -129,15 +141,20 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 
 	// Services
 	// Services
 	private ContactService contactService;
 	private ContactService contactService;
+	private ContactModelRepository contactModelRepository;
 	private GroupService groupService;
 	private GroupService groupService;
 	private IdListService blockedContactsService, profilePicRecipientsService;
 	private IdListService blockedContactsService, profilePicRecipientsService;
 	private DeadlineListService hiddenChatsListService;
 	private DeadlineListService hiddenChatsListService;
 	private VoipStateService voipStateService;
 	private VoipStateService voipStateService;
+	private DeleteContactServices deleteContactServices;
+
+	private final @NonNull LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
 
 	// Data and state holders
 	// Data and state holders
 	private String identity;
 	private String identity;
 	@Deprecated
 	@Deprecated
 	private ContactModel contact;
 	private ContactModel contact;
+	private ch.threema.data.models.ContactModel contactModel;
 	private @Nullable ContactDetailViewModel viewModel; // Initially null, until initialized
 	private @Nullable ContactDetailViewModel viewModel; // Initially null, until initialized
 	private List<GroupModel> groupList;
 	private List<GroupModel> groupList;
 	private boolean isReadonly;
 	private boolean isReadonly;
@@ -153,7 +170,15 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private View workIcon;
 	private View workIcon;
 
 
 	private void refreshAdapter() {
 	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() {
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
@@ -201,8 +226,8 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
-			if (!this.shouldHandleChange(contactModel.getIdentity())) {
+		public void onAvatarChanged(final @NonNull String identity) {
+			if (!this.shouldHandleChange(identity)) {
 				return;
 				return;
 			}
 			}
 			RuntimeUtil.runOnUiThread(() -> updateProfilepicMenu());
 			RuntimeUtil.runOnUiThread(() -> updateProfilepicMenu());
@@ -237,21 +262,21 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			if (newIdentity.equals(identity)) {
 			if (newIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 			}
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberLeave(GroupModel group, String leftIdentity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String leftIdentity) {
 			if (leftIdentity.equals(identity)) {
 			if (leftIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 			}
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberKicked(GroupModel group, String kickedIdentity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String kickedIdentity) {
 			if (kickedIdentity.equals(identity)) {
 			if (kickedIdentity.equals(identity)) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD_GROUP, runIfActiveGroupUpdate);
 			}
 			}
@@ -302,11 +327,27 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		try {
 		try {
 			this.contactService = serviceManager.getContactService();
 			this.contactService = serviceManager.getContactService();
 			modelRepositories = serviceManager.getModelRepositories();
 			modelRepositories = serviceManager.getModelRepositories();
+			contactModelRepository = modelRepositories.getContacts();
 			this.blockedContactsService = serviceManager.getBlockedContactsService();
 			this.blockedContactsService = serviceManager.getBlockedContactsService();
 			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
 			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
 			this.groupService = serviceManager.getGroupService();
 			this.groupService = serviceManager.getGroupService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
 			this.voipStateService = serviceManager.getVoipStateService();
 			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) {
 		} catch (Exception e) {
 			LogUtil.exception(e, this);
 			LogUtil.exception(e, this);
 			this.finish();
 			this.finish();
@@ -315,7 +356,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 
 		// Look up contact data
 		// Look up contact data
 		this.contact = this.contactService.getByIdentity(this.identity);
 		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) {
 		if (this.contact == null || contactModel == null) {
 			Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
 			Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
 			this.finish();
 			this.finish();
@@ -371,7 +412,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 
 		// Set up contact detail recycler view
 		// Set up contact detail recycler view
 		this.contactDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 		this.contactDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
-		this.contactDetailRecyclerView.setAdapter(setupAdapter());
 
 
 		// Set description for badge
 		// Set description for badge
 		this.workIcon.setContentDescription(
 		this.workIcon.setContentDescription(
@@ -480,16 +520,17 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 	}
 
 
 	@UiThread
 	@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(
 		final ContactDetailAdapter contactDetailAdapter = new ContactDetailAdapter(
 			this,
 			this,
 			this.groupList,
 			this.groupList,
-			contact,
+			viewModel.getContactModel(),
 			contactModelData,
 			contactModelData,
 			Glide.with(this)
 			Glide.with(this)
 		);
 		);
@@ -565,37 +606,25 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_CONTACT);
 		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();
 					finishAndGoHome();
 				}
 				}
 			}
 			}
-		}.execute();
+		);
 	}
 	}
 
 
 	private void editName() {
 	private void editName() {
@@ -658,7 +687,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		updateVoipCallMenuItem(null);
 		updateVoipCallMenuItem(null);
 
 
 		MenuItem galleryMenuItem = menu.findItem(R.id.menu_gallery);
 		MenuItem galleryMenuItem = menu.findItem(R.id.menu_gallery);
-		if (hiddenChatsListService.has(contactService.getUniqueIdString(contact))) {
+		if (hiddenChatsListService.has(ContactUtil.getUniqueIdString(identity))) {
 			galleryMenuItem.setVisible(false);
 			galleryMenuItem.setVisible(false);
 		}
 		}
 
 
@@ -709,7 +738,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		} else if (id == R.id.action_share_contact) {
 		} else if (id == R.id.action_share_contact) {
 			ShareUtil.shareContact(this, contact);
 			ShareUtil.shareContact(this, contact);
 		} else if (id == R.id.menu_gallery) {
 		} 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);
 				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
 				startActivity(mediaGalleryIntent);
 				startActivity(mediaGalleryIntent);
@@ -763,7 +792,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 					this.profilePicItem.setVisible(false);
 					this.profilePicItem.setVisible(false);
 					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrGatewayContact(contact));
 					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrGatewayContact(contact));
 					break;
 					break;
-				case PreferenceService.PROFILEPIC_RELEASE_SOME:
+				case PreferenceService.PROFILEPIC_RELEASE_ALLOW_LIST:
 					if (!ContactUtil.isEchoEchoOrGatewayContact(contact)) {
 					if (!ContactUtil.isEchoEchoOrGatewayContact(contact)) {
 						if (profilePicRecipientsService != null && profilePicRecipientsService.has(this.identity)) {
 						if (profilePicRecipientsService != null && profilePicRecipientsService.has(this.identity)) {
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
@@ -819,42 +848,15 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 								this.serviceManager.getQRCodeService());
 								this.serviceManager.getQRCodeService());
 
 
 				if (qrRes != null) {
 				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;
 				break;
 			case REQUEST_CODE_CONTACT_EDITOR:
 			case REQUEST_CODE_CONTACT_EDITOR:
 				try {
 				try {
-					AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
-					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
+					AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel);
+					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel);
 					this.avatarEditView.setContactModel(contact);
 					this.avatarEditView.setContactModel(contact);
-				} catch (ThreemaException e) {
+				} catch (ThreemaException|SecurityException e) {
 					logger.info("Unable to update contact name or avatar after returning from ContactEditor");
 					logger.info("Unable to update contact name or avatar after returning from ContactEditor");
 				}
 				}
 				break;
 				break;
@@ -881,15 +883,67 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			dialogFragment.setData(contact);
 			dialogFragment.setData(contact);
 			dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_EXCLUDE_CONTACT);
 			dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_EXCLUDE_CONTACT);
 		} else {
 		} else {
-			removeContactConfirmed(false, contactModel);
+			removeContactConfirmed(false);
 		}
 		}
 	}
 	}
 
 
 	void unhideContact(ContactModel contactModel) {
 	void unhideContact(ContactModel contactModel) {
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		onCreateLocal();
 		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
 	@Override
 	public void onYes(String tag, Object data) {
 	public void onYes(String tag, Object data) {
 		switch (tag) {
 		switch (tag) {
@@ -898,7 +952,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				deleteContact(contactModel);
 				deleteContact(contactModel);
 				break;
 				break;
 			case DIALOG_TAG_EXCLUDE_CONTACT:
 			case DIALOG_TAG_EXCLUDE_CONTACT:
-				removeContactConfirmed(true, (ContactModel) data);
+				removeContactConfirmed(true);
 				break;
 				break;
 			case DIALOG_TAG_ADD_CONTACT:
 			case DIALOG_TAG_ADD_CONTACT:
 				unhideContact(this.contact);
 				unhideContact(this.contact);
@@ -915,7 +969,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	public void onNo(String tag, Object data) {
 	public void onNo(String tag, Object data) {
 		switch (tag) {
 		switch (tag) {
 			case DIALOG_TAG_EXCLUDE_CONTACT:
 			case DIALOG_TAG_EXCLUDE_CONTACT:
-				removeContactConfirmed(false, (ContactModel) data);
+				removeContactConfirmed(false);
 				break;
 				break;
 			case DIALOG_TAG_ADD_CONTACT:
 			case DIALOG_TAG_ADD_CONTACT:
 				finish();
 				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.app.ThreemaApplication
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModel
 
 
-class ContactDetailViewModel(private val contactModel: ContactModel) : ViewModel() {
+class ContactDetailViewModel(val contactModel: ContactModel) : ViewModel() {
     val contact = contactModel.liveData()
     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 android.view.View;
 
 
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 
 
 public class ContactNotificationsActivity extends NotificationsActivity {
 public class ContactNotificationsActivity extends NotificationsActivity {
-	private String identity;
 	private ContactModel contactModel;
 	private ContactModel contactModel;
 
 
 	@Override
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(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();
 			finish();
 			return;
 			return;
 		}
 		}
 
 
 		this.contactModel = contactService.getByIdentity(identity);
 		this.contactModel = contactService.getByIdentity(identity);
-		this.uid = contactService.getUniqueIdString(contactModel);
+		this.uid = ContactUtil.getUniqueIdString(identity);
 
 
 		refreshSettings();
 		refreshSettings();
 	}
 	}

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

@@ -21,8 +21,6 @@
 
 
 package ch.threema.app.activities;
 package ch.threema.app.activities;
 
 
-import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
-
 import android.animation.LayoutTransition;
 import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
 import android.content.Intent;
 import android.content.Intent;
@@ -38,18 +36,6 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.TextView;
 import android.widget.Toast;
 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.Chip;
 import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.progressindicator.LinearProgressIndicator;
 import com.google.android.material.progressindicator.LinearProgressIndicator;
@@ -62,24 +48,41 @@ import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.List;
 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.R;
 import ch.threema.app.adapters.DirectoryAdapter;
 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.dialogs.MultiChoiceSelectorDialog;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.ui.DirectoryDataSourceFactory;
 import ch.threema.app.ui.DirectoryDataSourceFactory;
 import ch.threema.app.ui.DirectoryHeaderItemDecoration;
 import ch.threema.app.ui.DirectoryHeaderItemDecoration;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 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.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.utils.LoggingUtil;
 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.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
 import ch.threema.domain.protocol.api.work.WorkOrganization;
 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 {
 public class DirectoryActivity extends ThreemaToolbarActivity implements ThreemaSearchView.OnQueryTextListener, MultiChoiceSelectorDialog.SelectorDialogClickListener {
     private static final Logger logger = LoggingUtil.getThreemaLogger("DirectoryActivity");
     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_SEARCHING = 1;
     private static final int EMPTY_STATE_RESULTS = 2;
     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 DirectoryAdapter directoryAdapter;
     private DirectoryDataSourceFactory directoryDataSourceFactory;
     private DirectoryDataSourceFactory directoryDataSourceFactory;
@@ -176,12 +184,14 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
             updateToolbarTitle(getString(R.string.directory_title));
             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) {
         if (preferenceService == null) {
             return false;
             return false;
@@ -229,11 +239,11 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
                 launchContact(workDirectoryContact, position);
                 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
         // initial page size
         PagedList.Config config = new PagedList.Config.Builder().setPageSize(API_DIRECTORY_PAGE_SIZE).build();
         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);
         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");
         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() {
     private DirectoryHeaderItemDecoration.HeaderCallback getSectionCallback() {
         return new DirectoryHeaderItemDecoration.HeaderCallback() {
         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
 		@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
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String identity) {
 			if (identity.equals(myIdentity)) {
 			if (identity.equals(myIdentity)) {
 				finish();
 				finish();
 			} else {
 			} else {
@@ -277,7 +277,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String identity) {
 			if (identity.equals(myIdentity)) {
 			if (identity.equals(myIdentity)) {
 				finish();
 				finish();
 			} else {
 			} else {
@@ -482,7 +482,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 
 	private void setupAdapter() throws MasterKeyLockedException, FileSystemNotPresentException {
 	private void setupAdapter() throws MasterKeyLockedException, FileSystemNotPresentException {
 		Runnable onCloneGroupRunnable = null;
 		Runnable onCloneGroupRunnable = null;
-		if (groupService.isOrphanedGroup(groupModel) && groupService.getOtherMemberCount(groupModel) > 0) {
+		if (groupService.isOrphanedGroup(groupModel) && groupService.countMembersWithoutUser(groupModel) > 0) {
 			onCloneGroupRunnable = this::showCloneDialog;
 			onCloneGroupRunnable = this::showCloneDialog;
 		}
 		}
 
 
@@ -597,7 +597,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 
 			boolean isMember = groupService.isGroupMember(groupModel);
 			boolean isMember = groupService.isGroupMember(groupModel);
 			boolean isCreator = groupService.isGroupCreator(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
 			// The clone menu only makes sense if at least one other member is present
 			cloneMenu.setVisible(hasOtherMembers);
 			cloneMenu.setVisible(hasOtherMembers);

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

@@ -21,8 +21,6 @@
 
 
 package ch.threema.app.activities;
 package ch.threema.app.activities;
 
 
-import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
-
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.app.Activity;
 import android.content.BroadcastReceiver;
 import android.content.BroadcastReceiver;
@@ -52,21 +50,6 @@ import android.view.Window;
 import android.widget.ImageView;
 import android.widget.ImageView;
 import android.widget.Toast;
 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.appbar.MaterialToolbar;
 import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.badge.ExperimentalBadgeUtils;
 import com.google.android.material.badge.ExperimentalBadgeUtils;
@@ -87,12 +70,31 @@ import java.util.Set;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.stream.Collectors;
 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.BuildFlavor;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.wizard.WizardBaseActivity;
 import ch.threema.app.activities.wizard.WizardBaseActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.activities.wizard.WizardStartActivity;
 import ch.threema.app.archive.ArchiveActivity;
 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.BackupService;
 import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.dialogs.GenericAlertDialog;
 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.SMSVerificationDialog;
 import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.ContactsSectionFragment;
 import ch.threema.app.fragments.ContactsSectionFragment;
 import ch.threema.app.fragments.MessageSectionFragment;
 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.qrscanner.activity.BaseQrScannerActivity;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.ContactServiceImpl;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationTagService;
 import ch.threema.app.services.ConversationTagService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ThreemaPushService;
 import ch.threema.app.services.ThreemaPushService;
 import ch.threema.app.services.UpdateSystemService;
 import ch.threema.app.services.UpdateSystemService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.license.LicenseService;
+import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.tasks.ApplicationUpdateStepsTask;
 import ch.threema.app.tasks.ApplicationUpdateStepsTask;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeService;
 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.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.PowermanagerUtil;
 import ch.threema.app.utils.PowermanagerUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 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.GroupCallDescription;
 import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.groupcall.GroupCallObserver;
 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.voip.services.VoipCallService;
 import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.base.utils.LoggingUtil;
 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.api.LinkMobileNoException;
-import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.connection.ConnectionState;
 import ch.threema.domain.protocol.connection.ConnectionState;
 import ch.threema.domain.protocol.connection.ConnectionStateListener;
 import ch.threema.domain.protocol.connection.ConnectionStateListener;
+import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 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.MessageState;
 import ch.threema.storage.models.TagModel;
 import ch.threema.storage.models.TagModel;
 
 
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
+
 public class HomeActivity extends ThreemaAppCompatActivity implements
 public class HomeActivity extends ThreemaAppCompatActivity implements
 	SMSVerificationDialog.SMSVerificationDialogCallback,
 	SMSVerificationDialog.SMSVerificationDialogCallback,
 	GenericAlertDialog.DialogClickListener,
 	GenericAlertDialog.DialogClickListener,
@@ -218,6 +226,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private NotificationService notificationService;
 	private NotificationService notificationService;
 	private UserService userService;
 	private UserService userService;
 	private ContactService contactService;
 	private ContactService contactService;
+	private ContactModelRepository contactModelRepository;
+	private APIConnector apiConnector;
 	private LockAppService lockAppService;
 	private LockAppService lockAppService;
 	private PreferenceService preferenceService;
 	private PreferenceService preferenceService;
 	private ConversationService conversationService;
 	private ConversationService conversationService;
@@ -225,6 +235,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 
     private @Nullable IdentityPopup identityPopup = null;
     private @Nullable IdentityPopup identityPopup = null;
 
 
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+
 	private enum UnsentMessageAction {
 	private enum UnsentMessageAction {
 		ADD,
 		ADD,
 		REMOVE,
 		REMOVE,
@@ -778,7 +791,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 	}
 
 
 	private void showWhatsNew() {
 	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 != null) {
 			if (!preferenceService.isLatestVersion(this)) {
 			if (!preferenceService.isLatestVersion(this)) {
@@ -1058,6 +1071,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			} catch (Exception e) {
 			} catch (Exception e) {
 				//
 				//
 			}
 			}
+			this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
+			this.apiConnector = serviceManager.getAPIConnector();
 
 
 			if (preferenceService == null || notificationService == null || userService == null) {
 			if (preferenceService == null || notificationService == null || userService == null) {
 				finish();
 				finish();
@@ -1949,56 +1964,66 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			return;
 			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);
 									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() {
 	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
 			@Override
 			protected void onPostExecute(List<FaceItem> faceItemList) {
 			protected void onPostExecute(List<FaceItem> faceItemList) {
-				if (faceItemList != null && faceItemList.size() > 0) {
+				if (faceItemList != null && !faceItemList.isEmpty()) {
 					motionView.post(() -> {
 					motionView.post(() -> {
 						for (FaceItem faceItem : faceItemList) {
 						for (FaceItem faceItem : faceItemList) {
 							Layer layer = new Layer();
 							Layer layer = new Layer();
@@ -1703,9 +1703,6 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		finishWithoutChanges();
 		finishWithoutChanges();
 	}
 	}
 
 
-	@Override
-	public void onNo(String tag, Object data) {}
-
 	/**
 	/**
 	 * Finish activity with changes (result ok)
 	 * 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.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.viewmodel.compose.viewModel
 import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.preference.PreferenceManager
 import ch.threema.app.BuildConfig
 import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
@@ -171,16 +170,12 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
     }
     }
 
 
     private fun initScreenContent() {
     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)
         val editHistoryComposeView = findViewById<ComposeView>(R.id.message_details_compose_view)
         editHistoryComposeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
         editHistoryComposeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
 
 
         editHistoryComposeView.setContent {
         editHistoryComposeView.setContent {
-            ThreemaTheme(
-                dynamicColor = shouldUseDynamicColors
-            ) {
+            ThreemaTheme {
                 val uiState: ChatMessageDetailsUiState by viewModel.uiState.collectAsStateWithLifecycle()
                 val uiState: ChatMessageDetailsUiState by viewModel.uiState.collectAsStateWithLifecycle()
                 val messageModel: MessageUiModel = uiState.message
                 val messageModel: MessageUiModel = uiState.message
                 val editHistoryViewModel: EditHistoryViewModel = viewModel(
                 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;
 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.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
 import android.content.ClipData;
 import android.content.ClipData;
@@ -55,22 +49,6 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewGroup;
 import android.widget.Toast;
 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.progressindicator.CircularProgressIndicator;
 import com.google.android.material.search.SearchBar;
 import com.google.android.material.search.SearchBar;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.snackbar.Snackbar;
@@ -87,6 +65,21 @@ import java.util.List;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Executors;
 import java.util.stream.Collectors;
 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.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 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.SendAction;
 import ch.threema.app.actions.TextMessageSendAction;
 import ch.threema.app.actions.TextMessageSendAction;
 import ch.threema.app.adapters.FilterableListAdapter;
 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.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 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.UserListFragment;
 import ch.threema.app.fragments.WorkUserListFragment;
 import ch.threema.app.fragments.WorkUserListFragment;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DistributionListService;
 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.FileUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.utils.LoggingUtil;
 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.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 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 ch.threema.storage.models.data.MessageContentsType;
 import java8.util.concurrent.CompletableFuture;
 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
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     CancelableHorizontalProgressDialog.ProgressDialogClickListener,
     CancelableHorizontalProgressDialog.ProgressDialogClickListener,
     ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
     ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
@@ -181,12 +187,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     private final List<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
     private final List<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
     private boolean isInternallyForwardingMediaFiles = false;
     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() {
     private final Runnable copyExternalFilesRunnable = new Runnable() {
         @Override
         @Override
@@ -276,6 +288,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             this.distributionListService = serviceManager.getDistributionListService();
             this.distributionListService = serviceManager.getDistributionListService();
             this.messageService = serviceManager.getMessageService();
             this.messageService = serviceManager.getMessageService();
             this.fileService = serviceManager.getFileService();
             this.fileService = serviceManager.getFileService();
+            this.apiConnector = serviceManager.getAPIConnector();
+            this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
             userService = serviceManager.getUserService();
             userService = serviceManager.getUserService();
         } catch (Exception e) {
         } catch (Exception e) {
             logger.error("Exception", e);
             logger.error("Exception", e);
@@ -838,36 +852,35 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         if (contactModel == null) {
         if (contactModel == null) {
             GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), "pro");
             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
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
     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;
 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.Manifest;
 import android.accounts.Account;
 import android.accounts.Account;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
@@ -39,13 +36,6 @@ import android.view.View;
 import android.widget.Button;
 import android.widget.Button;
 import android.widget.Toast;
 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.android.material.button.MaterialButton;
 import com.google.i18n.phonenumbers.NumberParseException;
 import com.google.i18n.phonenumbers.NumberParseException;
 import com.google.i18n.phonenumbers.PhoneNumberUtil;
 import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -53,17 +43,23 @@ import com.google.i18n.phonenumbers.Phonenumber;
 
 
 import org.slf4j.Logger;
 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
 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.GenericProgressDialog;
 import ch.threema.app.dialogs.WizardDialog;
 import ch.threema.app.dialogs.WizardDialog;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 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.WizardFragment0;
 import ch.threema.app.fragments.wizard.WizardFragment1;
 import ch.threema.app.fragments.wizard.WizardFragment1;
 import ch.threema.app.fragments.wizard.WizardFragment2;
 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.utils.executor.BackgroundTask;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.utils.LoggingUtil;
 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.LinkEmailException;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
 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
 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
 			@Override
 			public Boolean runInBackground() {
 			public Boolean runInBackground() {
-				return runApplicationSetupSteps(serviceManager, WizardSafeRestoreActivity.this);
+				return runApplicationSetupSteps(serviceManager);
 			}
 			}
 
 
 			@Override
 			@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 {
 public class WizardStartActivity extends WizardBackgroundActivity {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardStartActivity");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("WizardStartActivity");
-	boolean doFinish = false;
+	boolean nextActivityLaunched = false;
 
 
 	@Override
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 	protected void onCreate(Bundle savedInstanceState) {
@@ -98,7 +98,12 @@ public class WizardStartActivity extends WizardBackgroundActivity {
 		return frameAnimation;
 		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;
 		Intent intent;
 
 
 		if (userService != null && userService.hasIdentity()) {
 		if (userService != null && userService.hasIdentity()) {
@@ -122,13 +127,14 @@ public class WizardStartActivity extends WizardBackgroundActivity {
 			startActivity(intent);
 			startActivity(intent);
 			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 		}
 		}
-		doFinish = true;
+		nextActivityLaunched = true;
 	}
 	}
 
 
 	@Override
 	@Override
 	public void onStop() {
 	public void onStop() {
 		super.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.app.utils.ViewUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.models.ContactModelData;
 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.protobuf.csp.e2e.fs.Terminate;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 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_HEADER = 0;
     private static final int TYPE_ITEM = 1;
     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 static class ItemHolder extends RecyclerView.ViewHolder {
         public final @NonNull View view;
         public final @NonNull View view;
@@ -163,28 +165,34 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 return true;
                 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 -> {
             publicNickNameView.setOnLongClickListener(ignored -> {
                 copyTextToClipboard(contactModelData.nickname, R.string.contact_details_nickname_copied);
                 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 {
         try {
             ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
             ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
@@ -280,40 +288,45 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         } else {
         } else {
             HeaderHolder headerHolder = (HeaderHolder) holder;
             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.jobTitleHeaderView, shouldShowJobTitle);
             ViewUtil.show(headerHolder.jobTitleTextView, shouldShowJobTitle);
             ViewUtil.show(headerHolder.jobTitleTextView, shouldShowJobTitle);
             if (shouldShowJobTitle) {
             if (shouldShowJobTitle) {
                 headerHolder.jobTitleTextView.setText(contactModelData.jobTitle);
                 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.departmentHeaderView, shouldShowDepartment);
             ViewUtil.show(headerHolder.departmentTextView, shouldShowDepartment);
             ViewUtil.show(headerHolder.departmentTextView, shouldShowDepartment);
             if (shouldShowDepartment) {
             if (shouldShowDepartment) {
                 headerHolder.departmentTextView.setText(contactModelData.department);
                 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);
             boolean isSyncExcluded = excludeFromSyncListService.has(contactModelData.identity);
 
 
@@ -323,18 +336,18 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
             ) {
             ) {
                 headerHolder.synchronizeContainer.setVisibility(View.VISIBLE);
                 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.setChecked(isSyncExcluded);
                 headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
                 headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
@@ -361,31 +374,10 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 headerHolder.groupMembershipTitle.setVisibility(View.GONE);
                 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
     @Override
     public int getItemCount() {
     public int getItemCount() {
@@ -418,4 +410,83 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         void onVerificationInfoClick(View v);
         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);
         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(
         ViewUtil.show(
             holder.blockedContactView,
             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 org.slf4j.Logger;
 
 
-import java.util.List;
-
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
-import ch.threema.app.listeners.NewSyncedContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 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.app.utils.IntentDataUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
 
 
 public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSyncAdapter");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSyncAdapter");
 
 
+	private boolean isSyncEnabled = true;
+
 	public ContactsSyncAdapter(Context context, boolean autoInitialize) {
 	public ContactsSyncAdapter(Context context, boolean autoInitialize) {
 		super(context, autoInitialize);
 		super(context, autoInitialize);
 	}
 	}
 
 
+	public void setSyncEnabled(boolean enabled) {
+		isSyncEnabled = enabled;
+	}
+
 	@Override
 	@Override
 	public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
 	public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
 		logger.info("onPerformSync");
 		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 {
 		try {
 			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
 
@@ -66,10 +78,7 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 			if (serviceManager.getPreferenceService().isSyncContacts()) {
 			if (serviceManager.getPreferenceService().isSyncContacts()) {
 				logger.info("Start sync adapter run");
 				logger.info("Start sync adapter run");
 				SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
 				SynchronizeContactsService synchronizeContactsService = serviceManager.getSynchronizeContactsService();
-				if (synchronizeContactsService == null) {
-					return;
 
 
-				}
 				if (synchronizeContactsService.isFullSyncInProgress()) {
 				if (synchronizeContactsService.isFullSyncInProgress()) {
 					logger.info("A full sync is already running");
 					logger.info("A full sync is already running");
 					syncResult.stats.numUpdates = 0;
 					syncResult.stats.numUpdates = 0;
@@ -82,29 +91,21 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 				SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
 				SynchronizeContactsRoutine routine = synchronizeContactsService.instantiateSynchronization(account);
 				//update stats on finished to resolve the "every minute sync" bug
 				//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();
 				routine.run();
 			}
 			}
 		}
 		}
@@ -115,10 +116,13 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
 			logger.debug("MasterKeyLockedException [" + e.getMessage() + "]");
 			logger.debug("MasterKeyLockedException [" + e.getMessage() + "]");
 
 
 		}finally{
 		}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 isOrphanedGroup = groupService.isOrphanedGroup(groupModel);
 			boolean isCreator = groupService.isGroupCreator(groupModel);
 			boolean isCreator = groupService.isGroupCreator(groupModel);
 			boolean isMember = groupService.isGroupMember(groupModel);
 			boolean isMember = groupService.isGroupMember(groupModel);
-			boolean hasOtherMembers = groupService.getOtherMemberCount(groupModel) > 0;
+			boolean hasOtherMembers = groupService.countMembersWithoutUser(groupModel) > 0;
 
 
 			if (isOrphanedGroup) {
 			if (isOrphanedGroup) {
 				// Show orphaned group notice
 				// 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())
 				blockedContactsService != null && blockedContactsService.has(contactModel.getIdentity())
 		);
 		);
 
 
-		holder.verificationLevelView.setContactModel(contactModel);
+		holder.verificationLevelView.setVerificationLevel(
+			contactModel.verificationLevel,
+			contactModel.getWorkVerificationLevel()
+		);
 
 
 		String lastMessageDateString = null;
 		String lastMessageDateString = null;
 		MessageReceiver messageReceiver = this.contactService.createReceiver(contactModel);
 		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
 package ch.threema.app.asynctasks
 
 
 import android.content.Context
 import android.content.Context
+import androidx.annotation.StringRes
+import androidx.annotation.WorkerThread
 import ch.threema.app.R
 import ch.threema.app.R
 import ch.threema.app.utils.AppRestrictionUtil
 import ch.threema.app.utils.AppRestrictionUtil
 import ch.threema.app.utils.executor.BackgroundTask
 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.base.utils.LoggingUtil
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
 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.ContactCreateException
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 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.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
@@ -43,7 +50,7 @@ import kotlinx.coroutines.runBlocking
 import java.net.HttpURLConnection
 import java.net.HttpURLConnection
 import java.util.Date
 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
  * 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.
  * returns [Failed] if it doesn't match.
  *
  *
  * This task also updates the contact if it already exists. This includes changing the acquaintance
  * 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 identity: String,
+    protected val acquaintanceLevel: AcquaintanceLevel,
     private val myIdentity: String,
     private val myIdentity: String,
     private val apiConnector: APIConnector,
     private val apiConnector: APIConnector,
     private val contactModelRepository: ContactModelRepository,
     private val contactModelRepository: ContactModelRepository,
     private val addContactRestrictionPolicy: AddContactRestrictionPolicy,
     private val addContactRestrictionPolicy: AddContactRestrictionPolicy,
     private val context: Context,
     private val context: Context,
     private val expectedPublicKey: ByteArray? = null,
     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() {
     final override fun runBefore() {
         onBefore()
         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) {
         if (identity == myIdentity) {
-            return failed(R.string.identity_already_exists)
+            return UserIdentity(context)
         }
         }
 
 
         // Update contact if it exists
         // Update contact if it exists
@@ -91,7 +154,7 @@ open class AddOrUpdateContactBackgroundTask(
         if (addContactRestrictionPolicy == AddContactRestrictionPolicy.CHECK
         if (addContactRestrictionPolicy == AddContactRestrictionPolicy.CHECK
             && AppRestrictionUtil.isAddContactDisabled(context)
             && AppRestrictionUtil.isAddContactDisabled(context)
         ) {
         ) {
-            return PolicyViolation
+            return PolicyViolation(context)
         }
         }
 
 
         // Fetch the identity
         // Fetch the identity
@@ -102,15 +165,15 @@ open class AddOrUpdateContactBackgroundTask(
 
 
             when (e) {
             when (e) {
                 is HttpConnectionException -> {
                 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 {
                     } else {
-                        return failed(R.string.connection_error)
+                        ConnectionError(context)
                     }
                     }
                 }
                 }
 
 
                 is NetworkException, is ThreemaException -> {
                 is NetworkException, is ThreemaException -> {
-                    return failed(R.string.connection_error)
+                    return ConnectionError(context)
                 }
                 }
 
 
                 else -> {
                 else -> {
@@ -123,94 +186,86 @@ open class AddOrUpdateContactBackgroundTask(
         return addNewContact(result, expectedPublicKey)
         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(
     private fun addNewContact(
         result: FetchIdentityResult,
         result: FetchIdentityResult,
         expectedPublicKey: ByteArray?,
         expectedPublicKey: ByteArray?,
-    ): ContactAddResult {
+    ): ContactResult {
         val verificationLevel = if (expectedPublicKey != null) {
         val verificationLevel = if (expectedPublicKey != null) {
             if (expectedPublicKey.contentEquals(result.publicKey)) {
             if (expectedPublicKey.contentEquals(result.publicKey)) {
                 VerificationLevel.FULLY_VERIFIED
                 VerificationLevel.FULLY_VERIFIED
             } else {
             } else {
-                return failed(R.string.id_mismatch)
+                return RemotePublicKeyMismatch(context)
             }
             }
         } else {
         } else {
             VerificationLevel.UNVERIFIED
             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 {
         return runBlocking {
             try {
             try {
                 val contactModel = contactModelRepository.createFromLocal(
                 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) {
             } catch (e: ContactCreateException) {
                 logger.error("Could not insert new contact", e)
                 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 verificationLevelChanged = false
         var contactVerifiedAgain = false
         var contactVerifiedAgain = false
         var acquaintanceLevelChanged = false
         var acquaintanceLevelChanged = false
 
 
         if (expectedPublicKey != null) {
         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)
                     contactModel.setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED)
                     verificationLevelChanged = true
                     verificationLevelChanged = true
                 } else {
                 } else {
                     contactVerifiedAgain = true
                     contactVerifiedAgain = true
                 }
                 }
             } else {
             } 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
             acquaintanceLevelChanged = true
         }
         }
 
 
@@ -223,8 +278,52 @@ open class AddOrUpdateContactBackgroundTask(
             else -> ContactExists(contactModel)
             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.
  * The contact already existed and has now been updated.
@@ -262,42 +381,65 @@ data class ContactModified(
     /**
     /**
      * The updated contact model.
      * 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,
     val acquaintanceLevelChanged: Boolean,
     /**
     /**
      * If true, the verification level has changed to [VerificationLevel.FULLY_VERIFIED].
      * If true, the verification level has changed to [VerificationLevel.FULLY_VERIFIED].
      */
      */
     val verificationLevelChanged: Boolean,
     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
  * 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
  * contact already exists. This means, that neither the verification level nor the acquaintance
  * level did change.
  * 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.
  * 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.
  * 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.File;
 import java.io.IOException;
 import java.io.IOException;
 
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentManager;
 import ch.threema.app.R;
 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.DialogUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
 import ch.threema.app.utils.ShortcutUtil;
 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.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
+import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseNonceStore;
+import ch.threema.storage.DatabaseServiceNew;
 
 
 public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("DeleteIdentityAsyncTask");
 	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 ServiceManager serviceManager;
 	private final FragmentManager fragmentManager;
 	private final FragmentManager fragmentManager;
 	private final Runnable runOnCompletion;
 	private final Runnable runOnCompletion;
+	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
 
 
 	public DeleteIdentityAsyncTask(@Nullable FragmentManager fragmentManager,
 	public DeleteIdentityAsyncTask(@Nullable FragmentManager fragmentManager,
 	                               @Nullable Runnable runOnCompletion) {
 	                               @Nullable Runnable runOnCompletion) {
@@ -80,7 +84,7 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			serviceManager.getMessageService().removeAll();
 			serviceManager.getMessageService().removeAll();
 			serviceManager.getConversationService().reset();
 			serviceManager.getConversationService().reset();
 			serviceManager.getGroupService().removeAll();
 			serviceManager.getGroupService().removeAll();
-			serviceManager.getContactService().removeAll();
+			backgroundExecutor.execute(getDeleteAllContactsTask());
 			try {
 			try {
 				serviceManager.getUserService().removeIdentity();
 				serviceManager.getUserService().removeIdentity();
 			} catch (Exception ignored) {}
 			} 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) {
 	private void secureDelete(File file) {
 		try {
 		try {
 			SecureDeleteUtil.secureDelete(file);
 			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.TestUtil;
 import ch.threema.app.utils.ZipUtil;
 import ch.threema.app.utils.ZipUtil;
 import ch.threema.base.ThreemaException;
 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.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.identitybackup.IdentityBackupGenerator;
 import ch.threema.domain.identitybackup.IdentityBackupGenerator;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 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_VIDEOS_AND_FILES = 12;
 	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
 	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
 	private static final int NONCES_PER_STEP = 50;
 	private static final int NONCES_PER_STEP = 50;
+	private static final int NONCES_CHUNK_SIZE = 2500;
 
 
 	private static final String EXTRA_ID_CANCEL = "cnc";
 	private static final String EXTRA_ID_CANCEL = "cnc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
 	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
@@ -163,7 +166,7 @@ public class BackupService extends Service {
 	private PreferenceService preferenceService;
 	private PreferenceService preferenceService;
 	private PowerManager.WakeLock wakeLock;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManagerCompat notificationManagerCompat;
 	private NotificationManagerCompat notificationManagerCompat;
-	private DatabaseNonceStore databaseNonceStore;
+	private NonceFactory nonceFactory;
 
 
 	private NotificationCompat.Builder notificationBuilder;
 	private NotificationCompat.Builder notificationBuilder;
 
 
@@ -310,6 +313,7 @@ public class BackupService extends Service {
 			userService = serviceManager.getUserService();
 			userService = serviceManager.getUserService();
 			ballotService = serviceManager.getBallotService();
 			ballotService = serviceManager.getBallotService();
 			preferenceService = serviceManager.getPreferenceService();
 			preferenceService = serviceManager.getPreferenceService();
+			nonceFactory = serviceManager.getNonceFactory();
 		} catch (Exception e) {
 		} catch (Exception e) {
 			logger.error("Exception", e);
 			logger.error("Exception", e);
 			safeStopSelf();
 			safeStopSelf();
@@ -317,7 +321,6 @@ public class BackupService extends Service {
 		}
 		}
 
 
 		notificationManagerCompat = NotificationManagerCompat.from(this);
 		notificationManagerCompat = NotificationManagerCompat.from(this);
-		databaseNonceStore = new DatabaseNonceStore(this, serviceManager.getIdentityStore());
 	}
 	}
 
 
 	@Override
 	@Override
@@ -411,7 +414,7 @@ public class BackupService extends Service {
 
 
 			if (this.config.backupNonces()) {
 			if (this.config.backupNonces()) {
 				progress += 1;
 				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);
 				long nonceProgress = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
 				progress += nonceProgress;
 				progress += nonceProgress;
 			}
 			}
@@ -484,8 +487,9 @@ public class BackupService extends Service {
 		return this.next(subject, 1);
 		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();
 		this.handleProgress();
 		return !isCanceled;
 		return !isCanceled;
 	}
 	}
@@ -545,7 +549,7 @@ public class BackupService extends Service {
 			try {
 			try {
 				ZipUtil.addZipStream(
 				ZipUtil.addZipStream(
 					zipOutputStream,
 					zipOutputStream,
-					this.fileService.getContactAvatarStream(contactService.getMe().getIdentity()),
+					this.fileService.getUserDefinedProfilePictureStream(contactService.getMe().getIdentity()),
 					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
 					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
 					false
 					false
 				);
 				);
@@ -621,7 +625,7 @@ public class BackupService extends Service {
 							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
 							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
 								ZipUtil.addZipStream(
 								ZipUtil.addZipStream(
 									zipOutputStream,
 									zipOutputStream,
-									this.fileService.getContactAvatarStream(contactModel.getIdentity()),
+									this.fileService.getUserDefinedProfilePictureStream(contactModel.getIdentity()),
 									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
 									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
 									false
 									false
 								);
 								);
@@ -634,7 +638,7 @@ public class BackupService extends Service {
 						try {
 						try {
 							ZipUtil.addZipStream(
 							ZipUtil.addZipStream(
 								zipOutputStream,
 								zipOutputStream,
-								this.fileService.getContactPhotoStream(contactModel.getIdentity()),
+								this.fileService.getContactDefinedProfilePictureStream(contactModel.getIdentity()),
 								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
 								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
 								false
 								false
 							);
 							);
@@ -732,6 +736,7 @@ public class BackupService extends Service {
 			Tags.TAG_GROUP_DESC,
 			Tags.TAG_GROUP_DESC,
 			Tags.TAG_GROUP_DESC_TIMESTAMP,
 			Tags.TAG_GROUP_DESC_TIMESTAMP,
 			Tags.TAG_GROUP_UID,
 			Tags.TAG_GROUP_UID,
+			Tags.TAG_GROUP_USER_STATE,
 		};
 		};
 		final String[] groupMessageCsvHeader = {
 		final String[] groupMessageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
 			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, groupModel.getGroupDesc())
 						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
 						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
 						.write(Tags.TAG_GROUP_UID, groupUid)
 						.write(Tags.TAG_GROUP_UID, groupUid)
+						.write(Tags.TAG_GROUP_USER_STATE, groupModel.getUserState() != null ? groupModel.getUserState().value : 0)
 						.write();
 						.write();
 
 
 					//check if the group have a photo
 					//check if the group have a photo
@@ -1091,15 +1097,24 @@ public class BackupService extends Service {
 			return false;
 			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");
 			logger.info("Nonce backup completed");
 		} catch (IOException | ThreemaException e) {
 		} catch (IOException | ThreemaException e) {
 			logger.error("Error with byte array output stream", e);
 			logger.error("Error with byte array output stream", e);
@@ -1109,36 +1124,91 @@ public class BackupService extends Service {
 		return true;
 		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
 		@NonNull ByteArrayOutputStream outputStream
 	) throws ThreemaException, IOException {
 	) throws ThreemaException, IOException {
+		logger.info("Backup {} nonces", scope);
 		final String[] nonceHeader = new String[]{Tags.TAG_NONCES};
 		final String[] nonceHeader = new String[]{Tags.TAG_NONCES};
+		int backedUpNonceCount = 0;
 		try (
 		try (
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
 			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
 			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
 		) {
 		) {
 			long start = System.currentTimeMillis();
 			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++) {
 			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();
 					csvWriter.createRow().write(Tags.TAG_NONCES, nonce).write();
 				}
 				}
+				int increment = nonces.size() / NONCES_PER_STEP;
+				backedUpNonceCount += nonces.size();
 				nonces.clear();
 				nonces.clear();
-				if (!next("Backup nonce")) {
-					return;
+				if (!next("Backup nonce", increment)) {
+					return backedUpNonceCount;
 				}
 				}
 				// Periodically log nonce backup progress for debugging purposes
 				// 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);
 					logger.info("Nonce backup progress: {} of {} chunks backed up", i, numChunks);
 				}
 				}
 			}
 			}
 			long end = System.currentTimeMillis();
 			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.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 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.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.models.GroupId;
 import ch.threema.domain.models.GroupId;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.AbstractMessageModel;
 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.GroupMemberModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.GroupModel.UserState;
 import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 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 android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
 import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
 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 {
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
@@ -145,7 +149,7 @@ public class RestoreService extends Service {
 	private PreferenceService preferenceService;
 	private PreferenceService preferenceService;
 	private PowerManager.WakeLock wakeLock;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManagerCompat notificationManagerCompat;
 	private NotificationManagerCompat notificationManagerCompat;
-	private DatabaseNonceStore databaseNonceStore;
+	private NonceFactory nonceFactory;
 
 
 	private NotificationCompat.Builder notificationBuilder;
 	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_GROUP_AVATARS = 50;
 	private static final int STEP_SIZE_MEDIA = 25; // per media file
 	private static final int STEP_SIZE_MEDIA = 25; // per media file
 	private static final int NONCES_PER_STEP = 50;
 	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;
 	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();
 			conversationService = serviceManager.getConversationService();
 			userService = serviceManager.getUserService();
 			userService = serviceManager.getUserService();
 			preferenceService = serviceManager.getPreferenceService();
 			preferenceService = serviceManager.getPreferenceService();
-			databaseNonceStore = new DatabaseNonceStore(this, serviceManager.getIdentityStore());
+			nonceFactory = serviceManager.getNonceFactory();
 		} catch (Exception e) {
 		} catch (Exception e) {
 			logger.error("Could not instantiate all required services", e);
 			logger.error("Could not instantiate all required services", e);
 			stopSelf();
 			stopSelf();
@@ -504,10 +509,6 @@ public class RestoreService extends Service {
 				// Restore nonces
 				// Restore nonces
 				logger.info("Restoring nonces");
 				logger.info("Restoring nonces");
 				int nonceCount = restoreNonces(fileHeaders);
 				int nonceCount = restoreNonces(fileHeaders);
-				if (nonceCount < 0) {
-					logger.error("Restoring nonces failed ({})", nonceCount);
-					//continue anyway!
-				}
 
 
 				//contacts, groups and distribution lists
 				//contacts, groups and distribution lists
 				logger.info("Restoring main files (contacts, groups, distribution lists)");
 				logger.info("Restoring main files (contacts, groups, distribution lists)");
@@ -602,7 +603,7 @@ public class RestoreService extends Service {
 		try (
 		try (
 			InputStream is = zipFile.getInputStream(settingsHeader);
 			InputStream is = zipFile.getInputStream(settingsHeader);
 			InputStreamReader inputStreamReader = new InputStreamReader(is);
 			InputStreamReader inputStreamReader = new InputStreamReader(is);
-			CSVReader csvReader = new CSVReader(inputStreamReader)
+			CSVReader csvReader = new CSVReader(inputStreamReader, false)
 		) {
 		) {
 			RestoreSettings settings = new RestoreSettings();
 			RestoreSettings settings = new RestoreSettings();
 			settings.parse(csvReader.readAll());
 			settings.parse(csvReader.readAll());
@@ -659,28 +660,112 @@ public class RestoreService extends Service {
 		return true;
 		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 {
 	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) {
 		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) {
 		if (nonceFileHeader == null) {
 			logger.info("Nonce file header is null");
 			logger.info("Nonce file header is null");
-			return -1;
+			return 0;
 		}
 		}
 
 
 		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
 		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
 		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
 		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-		     CSVReader csvReader = new CSVReader(inputStreamReader, false)
+		     CSVReader csvReader = new CSVReader(inputStreamReader, true)
 		) {
 		) {
-			int nonceProgressCount = 0;
 			int nonceCount = 0;
 			int nonceCount = 0;
 			boolean success = true;
 			boolean success = true;
 			CSVRow row;
 			CSVRow row;
+			List<byte[]> nonceBytes = new ArrayList<>(NONCES_CHUNK_SIZE);
 			while ((row = csvReader.readNextRow()) != null) {
 			while ((row = csvReader.readNextRow()) != null) {
 				try {
 				try {
 					// Note that currently there is only one nonce per row, and therefore we do
 					// 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);
 					String[] nonces = row.getStrings(Tags.TAG_NONCES);
 					nonceCount += nonces.length;
 					nonceCount += nonces.length;
 					if (writeToDb) {
 					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) {
 				} 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) {
 			if (success) {
+				logger.info("Restored {} {} nonces", nonceCount, scope);
 				return nonceCount;
 				return nonceCount;
 			} else {
 			} 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
 	 * restore all avatars and profile pics
 	 */
 	 */
@@ -972,7 +1072,7 @@ public class RestoreService extends Service {
 
 
 		// Set contact avatar
 		// Set contact avatar
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeContactAvatar(
+			return fileService.writeUserDefinedProfilePicture(
 				contactModel.getIdentity(),
 				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream)
 				IOUtils.toByteArray(inputStream)
 			);
 			);
@@ -1001,7 +1101,7 @@ public class RestoreService extends Service {
 
 
 		// Set contact profile picture
 		// Set contact profile picture
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeContactPhoto(
+			return fileService.writeContactDefinedProfilePicture(
 				contactModel.getIdentity(),
 				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream));
 				IOUtils.toByteArray(inputStream));
 		} catch (Exception e) {
 		} catch (Exception e) {
@@ -1029,10 +1129,24 @@ public class RestoreService extends Service {
 					restoreResult.incContactSuccess();
 					restoreResult.incContactSuccess();
 				}
 				}
 
 
-				List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
 				if (writeToDb) {
 				if (writeToDb) {
+					String myIdentity = userService.getIdentity();
+					boolean isInMemberList = false;
+
+					List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
+
 					for (GroupMemberModel groupMemberModel : groupMemberModels) {
 					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) {
 			} catch (Exception x) {
@@ -1179,6 +1293,10 @@ public class RestoreService extends Service {
 			groupModel.setLastUpdate(row.getDate(Tags.TAG_GROUP_LAST_UPDATE));
 			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;
 		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
 	 * 22: add lastUpdate and remove isQueued flag
 	 * 23: add editedAt
 	 * 23: add editedAt
 	 * 24: add deletedAt
 	 * 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;
 	private int version;
 
 
 	public RestoreSettings(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_PREFIX = "contact_avatar_";
 	public static final String CONTACT_AVATAR_FILE_SUFFIX_ME = "me";
 	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 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_FILE_PREFIX = "distribution_list_message_media_";
 	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "distribution_list_thumbnail_";
 	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_INFO_VERSION = "version";
 
 
 	public static final String TAG_NONCES = "nonces";
 	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_IDENTITY = "identity";
 	public static final String TAG_CONTACT_FIRST_NAME = "firstname";
 	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 = "groupDesc";
 	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
 	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
 	public static final String TAG_GROUP_UID = "group_uid";
 	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_UID = "uid";
 	public static final String TAG_MESSAGE_IDENTITY = "identity";
 	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 {
                     try {
                         decode(imageProxy, data)
                         decode(imageProxy, data)
                     } catch (e: Exception) {
                     } catch (e: Exception) {
-                        logger.info("Decode error for inverted QR Code")
+                        logger.debug("Decode error for inverted QR Code")
                     }
                     }
                 } catch (e: Exception) {
                 } catch (e: Exception) {
                     logger.error("Scanning error", e)
                     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.ImageView
 import android.widget.TextView
 import android.widget.TextView
 import android.widget.Toast
 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.lifecycle.ProcessCameraProvider
 import androidx.camera.view.PreviewView
 import androidx.camera.view.PreviewView
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat
@@ -42,7 +47,6 @@ import ch.threema.app.ThreemaApplication
 import ch.threema.app.activities.ThreemaActivity
 import ch.threema.app.activities.ThreemaActivity
 import ch.threema.app.services.QRCodeServiceImpl.QRCodeColor
 import ch.threema.app.services.QRCodeServiceImpl.QRCodeColor
 import ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ANY
 import ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ANY
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.SoundUtil
 import ch.threema.app.utils.SoundUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.ExecutorService
@@ -98,11 +102,19 @@ class QRScannerActivity : ThreemaActivity() {
                 getString(R.string.msg_default_status)
                 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
         // 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
 package ch.threema.app.compose.common.interop
 
 
 import androidx.compose.ui.platform.ComposeView
 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.MessageDetailsUiModel
 import ch.threema.app.activities.MessageTimestampsUiModel
 import ch.threema.app.activities.MessageTimestampsUiModel
 import ch.threema.app.activities.toUiModel
 import ch.threema.app.activities.toUiModel
@@ -40,7 +38,7 @@ object ComposeJavaBridge {
         messageDetailsUiModel: MessageDetailsUiModel,
         messageDetailsUiModel: MessageDetailsUiModel,
     ) {
     ) {
         composeView.setContent {
         composeView.setContent {
-            ThreemaTheme(dynamicColor = shouldUseDynamicColors()) {
+            ThreemaTheme {
                 CombinedMessageDetailsList(
                 CombinedMessageDetailsList(
                     messageTimestampsUiModel,
                     messageTimestampsUiModel,
                     messageDetailsUiModel
                     messageDetailsUiModel
@@ -56,7 +54,7 @@ object ComposeJavaBridge {
     ) {
     ) {
         val messageBubbleUiState = model.toUiModel(myIdentity)
         val messageBubbleUiState = model.toUiModel(myIdentity)
         composeView.setContent {
         composeView.setContent {
-            ThreemaTheme(dynamicColor = shouldUseDynamicColors()) {
+            ThreemaTheme {
                 MessageBubble(
                 MessageBubble(
                     text = messageBubbleUiState.text,
                     text = messageBubbleUiState.text,
                     isOutbox = messageBubbleUiState.isOutbox,
                     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.CompositionLocalProvider
 import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.LocalView
 import androidx.core.view.WindowCompat
 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.ColorsDark
 import ch.threema.app.compose.theme.color.ColorsLight
 import ch.threema.app.compose.theme.color.ColorsLight
 import ch.threema.app.compose.theme.color.CustomColor
 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
 val AppTypography = Typography() // system default
 
 
-/**
- *  @param dynamicColor available on Android 12+
- */
 @Composable
 @Composable
 fun ThreemaTheme(
 fun ThreemaTheme(
     isDarkTheme: Boolean = isSystemInDarkTheme(),
     isDarkTheme: Boolean = isSystemInDarkTheme(),
-    dynamicColor: Boolean = true,
     content: @Composable () -> Unit
     content: @Composable () -> Unit
 ) {
 ) {
+
+    val shouldUseDynamicColors: Boolean = remember {
+        val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext())
+        sharedPreferences.getBoolean("pref_dynamic_color", false)
+    }
+
     val materialColorScheme: ColorScheme = when {
     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
             val context = LocalContext.current
             if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
             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 title title of dialog
 	 * @param button label of cancel button
 	 * @param button label of cancel button
 	 * @param total maximum allowed progress value.
 	 * @param total maximum allowed progress value.
-	 * @return nothing
+	 * @return the dialog
 	 */
 	 */
 	public static CancelableHorizontalProgressDialog newInstance(@StringRes int title, @StringRes int button, int total) {
 	public static CancelableHorizontalProgressDialog newInstance(@StringRes int title, @StringRes int button, int total) {
 		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();
 		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();
@@ -70,12 +70,31 @@ public class CancelableHorizontalProgressDialog extends ThreemaDialogFragment {
 		return dialog;
 		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
 	 * Creates a DialogFragment with a horizontal progress bar and a percentage display below. Mimics deprecated system ProgressDialog behavior
 	 * @param title title of dialog
 	 * @param title title of dialog
 	 * @param button label of cancel button
 	 * @param button label of cancel button
 	 * @param total maximum allowed progress value.
 	 * @param total maximum allowed progress value.
-	 * @return nothing
+	 * @return the dialog
 	 */
 	 */
 	public static CancelableHorizontalProgressDialog newInstance(@NonNull String title, @NonNull String button, int total) {
 	public static CancelableHorizontalProgressDialog newInstance(@NonNull String title, @NonNull String button, int total) {
 		CancelableHorizontalProgressDialog dialog = new CancelableHorizontalProgressDialog();
 		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
 		@Override
-		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+		public void onNewMember(GroupModel group, String newIdentity) {
 			updateToolBarTitleInUIThread();
 			updateToolBarTitleInUIThread();
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberLeave(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberLeave(GroupModel group, String identity) {
 			updateToolBarTitleInUIThread();
 			updateToolBarTitleInUIThread();
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onMemberKicked(GroupModel group, String identity, int previousMemberCount) {
+		public void onMemberKicked(GroupModel group, String identity) {
 			updateToolBarTitleInUIThread();
 			updateToolBarTitleInUIThread();
 
 
 			if (userService.isMe(identity)) {
 			if (userService.isMe(identity)) {
@@ -821,7 +821,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
+		public void onAvatarChanged(final @NonNull String identity) {
 			updateToolBarTitleInUIThread();
 			updateToolBarTitleInUIThread();
 		}
 		}
 
 
@@ -3112,7 +3112,7 @@ public class ComposeMessageFragment extends Fragment implements
 								// If there is no rejected recipient, we can just update the message
 								// If there is no rejected recipient, we can just update the message
 								// state as the rejected recipient is not longer a group member.
 								// state as the rejected recipient is not longer a group member.
 								// Note that this should never happen.
 								// 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");
 								logger.warn("Resend for group members requested, although no member rejected it");
 								return;
 								return;
 							}
 							}
@@ -4086,13 +4086,16 @@ public class ComposeMessageFragment extends Fragment implements
 			setAvatarContentDescription(R.string.distribution_list);
 			setAvatarContentDescription(R.string.distribution_list);
 		} else {
 		} else {
 			if (contactModel != null) {
 			if (contactModel != null) {
-				this.actionBarSubtitleImageView.setContactModel(contactModel);
+				this.actionBarSubtitleImageView.setVerificationLevel(
+					contactModel.verificationLevel,
+					contactModel.getWorkVerificationLevel()
+				);
 				this.actionBarSubtitleImageView.setVisibility(View.VISIBLE);
 				this.actionBarSubtitleImageView.setVisibility(View.VISIBLE);
 				if (actionBarAvatarView.getAvatarView().isAttachedToWindow()) {
 				if (actionBarAvatarView.getAvatarView().isAttachedToWindow()) {
 					contactService.loadAvatarIntoImage(
 					contactService.loadAvatarIntoImage(
 						contactModel,
 						contactModel,
 						this.actionBarAvatarView.getAvatarView(),
 						this.actionBarAvatarView.getAvatarView(),
-						AvatarOptions.PRESET_RESPECT_SETTINGS,
+						AvatarOptions.PRESET_DEFAULT_FALLBACK,
 						Glide.with(requireActivity())
 						Glide.with(requireActivity())
 					);
 					);
 				}
 				}
@@ -5594,7 +5597,7 @@ public class ComposeMessageFragment extends Fragment implements
 	@Override
 	@Override
 	public void onReportSpamClicked(@NonNull final ContactModel spammerContactModel, boolean block) {
 	public void onReportSpamClicked(@NonNull final ContactModel spammerContactModel, boolean block) {
 		contactService.reportSpam(
 		contactService.reportSpam(
-			spammerContactModel,
+			spammerContactModel.getIdentity(),
 			unused -> {
 			unused -> {
 				if (isAdded()) {
 				if (isAdded()) {
 					LongToast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
 					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_ALWAYS;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_NEVER;
 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.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.SuppressLint;
@@ -61,9 +62,6 @@ import androidx.core.util.Pair;
 import androidx.core.view.MenuItemCompat;
 import androidx.core.view.MenuItemCompat;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-import androidx.work.ExistingWorkPolicy;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
 
 
 import com.bumptech.glide.Glide;
 import com.bumptech.glide.Glide;
 import com.google.android.material.button.MaterialButton;
 import com.google.android.material.button.MaterialButton;
@@ -72,12 +70,13 @@ import com.google.android.material.tabs.TabLayout;
 
 
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 
 
+import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Set;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 
 import ch.threema.app.R;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 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.ContactDetailActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.adapters.ContactListAdapter;
 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.asynctasks.EmptyOrDeleteConversationsAsyncTask;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetGridDialog;
 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.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.SynchronizeContactsService;
 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.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.workers.ContactUpdateWorker;
 import ch.threema.app.workers.ContactUpdateWorker;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
@@ -192,6 +195,8 @@ public class ContactsSectionFragment
 	private PreferenceService preferenceService;
 	private PreferenceService preferenceService;
 	private LockAppService lockAppService;
 	private LockAppService lockAppService;
 
 
+	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
+
 	private String filterQuery;
 	private String filterQuery;
 	@SuppressLint("StaticFieldLeak")
 	@SuppressLint("StaticFieldLeak")
 	private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
 	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() {
 	private final ResumePauseHandler.RunIfActive runIfActiveClearCacheAndRefresh = new ResumePauseHandler.RunIfActive() {
@@ -275,10 +277,8 @@ public class ContactsSectionFragment
 				if (serviceManager != null) {
 				if (serviceManager != null) {
 					try {
 					try {
 						AvatarCacheService avatarCacheService = serviceManager.getAvatarCacheService();
 						AvatarCacheService avatarCacheService = serviceManager.getAvatarCacheService();
-						if (avatarCacheService != null) {
-							//clear the cache
-							avatarCacheService.clear();
-						}
+						//clear the cache
+						avatarCacheService.clear();
 					} catch (FileSystemNotPresentException e) {
 					} catch (FileSystemNotPresentException e) {
 						logger.error("Exception", 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() {
 	private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
 		@Override
 		@Override
@@ -381,8 +376,8 @@ public class ContactsSectionFragment
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
-			this.onModified(contactModel.getIdentity());
+		public void onAvatarChanged(final @NonNull String identity) {
+			this.onModified(identity);
 		}
 		}
 
 
 		@Override
 		@Override
@@ -513,9 +508,7 @@ public class ContactsSectionFragment
 
 
 		this.resumePauseHandler = ResumePauseHandler.getByActivity(this, this.getActivity());
 		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
 	@Override
@@ -573,7 +566,7 @@ public class ContactsSectionFragment
 	}
 	}
 
 
 	@Override
 	@Override
-	public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+	public void onCreateOptionsMenu(Menu menu, @NonNull MenuInflater inflater) {
 		logger.debug("onCreateOptionsMenu");
 		logger.debug("onCreateOptionsMenu");
 		searchMenuItem = menu.findItem(R.id.menu_search_contacts);
 		searchMenuItem = menu.findItem(R.id.menu_search_contacts);
 
 
@@ -588,12 +581,9 @@ public class ContactsSectionFragment
 					if (!TestUtil.isEmptyOrNull(filterQuery)) {
 					if (!TestUtil.isEmptyOrNull(filterQuery)) {
 						// restore filter
 						// restore filter
 						MenuItemCompat.expandActionView(searchMenuItem);
 						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));
 					this.searchView.setQueryHint(getString(R.string.hint_filter_list));
@@ -607,7 +597,7 @@ public class ContactsSectionFragment
 	final SearchView.OnQueryTextListener queryTextListener = new SearchView.OnQueryTextListener() {
 	final SearchView.OnQueryTextListener queryTextListener = new SearchView.OnQueryTextListener() {
 		@Override
 		@Override
 		public boolean onQueryTextChange(String query) {
 		public boolean onQueryTextChange(String query) {
-			if (contactListAdapter != null && contactListAdapter.getFilter() != null) {
+			if (contactListAdapter != null) {
 				filterQuery = query;
 				filterQuery = query;
 				contactListAdapter.getFilter().filter(query);
 				contactListAdapter.getFilter().filter(query);
 			}
 			}
@@ -812,7 +802,7 @@ public class ContactsSectionFragment
 	}
 	}
 
 
 	@Override
 	@Override
-	public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+	public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
 		View headerView, fragmentView = getView();
 		View headerView, fragmentView = getView();
 
 
 		logger.debug("onCreateView");
 		logger.debug("onCreateView");
@@ -893,12 +883,7 @@ public class ContactsSectionFragment
 				this.contactsCounterButton = footerView.findViewById(R.id.contact_counter_text);
 				this.contactsCounterButton = footerView.findViewById(R.id.contact_counter_text);
 				listView.addFooterView(footerView, null, false);
 				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 {
 			} else {
 				workTabLayout = fragmentView.findViewById(R.id.work_contacts_tab_layout);
 				workTabLayout = fragmentView.findViewById(R.id.work_contacts_tab_layout);
 				workTabLayout.addOnTabSelectedListener(onTabSelectedListener);
 				workTabLayout.addOnTabSelectedListener(onTabSelectedListener);
@@ -912,12 +897,7 @@ public class ContactsSectionFragment
 			this.swipeRefreshLayout.setSize(SwipeRefreshLayout.LARGE);
 			this.swipeRefreshLayout.setSize(SwipeRefreshLayout.LARGE);
 
 
 			this.floatingButtonView = fragmentView.findViewById(R.id.floating);
 			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;
 		return fragmentView;
 	}
 	}
@@ -1064,6 +1044,10 @@ public class ContactsSectionFragment
 
 
 		new Handler(Looper.getMainLooper()).postDelayed(this::stopSwipeRefresh, 2000);
 		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.preferenceService.isSyncContacts() && ConfigUtils.requestContactPermissions(getActivity(), this, PERMISSION_REQUEST_REFRESH_CONTACTS)) {
 			if (this.synchronizeContactsService != null) {
 			if (this.synchronizeContactsService != null) {
 				// we force a contact sync even if the grace time has not yet been reached
 				// 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()) {
 		if (ConfigUtils.isWorkBuild()) {
 			try {
 			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) {
 			} catch (IllegalStateException e) {
 				logger.error("Unable to schedule work sync one time work", 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) {
 	private void openConversationForIdentity(@Nullable View v, String identity) {
@@ -1189,7 +1173,7 @@ public class ContactsSectionFragment
 					contactModel.verificationLevel == VerificationLevel.UNVERIFIED
 					contactModel.verificationLevel == VerificationLevel.UNVERIFIED
 				) {
 				) {
 					MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
 					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));
 						items.add(new SelectorDialogItem(getString(R.string.spam_report), R.drawable.ic_outline_report_24));
 						tags.add(SELECTOR_TAG_REPORT_SPAM);
 						tags.add(SELECTOR_TAG_REPORT_SPAM);
 					}
 					}
@@ -1285,43 +1269,57 @@ public class ContactsSectionFragment
 		dialog.show(getFragmentManager(), DIALOG_TAG_REALLY_DELETE_CONTACTS);
 		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
 	@Override
@@ -1448,12 +1446,16 @@ public class ContactsSectionFragment
 	public void onYes(String tag, Object data, boolean checked) {
 	public void onYes(String tag, Object data, boolean checked) {
 		switch(tag) {
 		switch(tag) {
 			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
 			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;
 				break;
 			case DIALOG_TAG_REPORT_SPAM:
 			case DIALOG_TAG_REPORT_SPAM:
 				ContactModel contactModel = (ContactModel) data;
 				ContactModel contactModel = (ContactModel) data;
 
 
-				contactService.reportSpam(contactModel,
+				contactService.reportSpam(contactModel.getIdentity(),
 					unused -> {
 					unused -> {
 						if (isAdded()) {
 						if (isAdded()) {
 							Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
 							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) {
 	public void onYes(String tag, Object data) {
 		switch(tag) {
 		switch(tag) {
 			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
 			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;
 				break;
 			default:
 			default:
 				break;
 				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.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
@@ -221,6 +220,7 @@ public class MessageSectionFragment extends MainFragment
 
 
 	private Activity activity;
 	private Activity activity;
 	private File tempMessagesFile;
 	private File tempMessagesFile;
+    @Nullable
 	private MessageListAdapter messageListAdapter;
 	private MessageListAdapter messageListAdapter;
 	private EmptyRecyclerView recyclerView;
 	private EmptyRecyclerView recyclerView;
 	private View loadingView;
 	private View loadingView;
@@ -333,7 +333,7 @@ public class MessageSectionFragment extends MainFragment
 
 
 	private final GroupListener groupListener = new GroupListener() {
 	private final GroupListener groupListener = new GroupListener() {
 		@Override
 		@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 this user is added to an existing group
 			if (groupService != null && myIdentity != null && myIdentity.equals(newIdentity)) {
 			if (groupService != null && myIdentity != null && myIdentity.equals(newIdentity)) {
 				fireReceiverUpdate(groupService.createReceiver(group));
 				fireReceiverUpdate(groupService.createReceiver(group));
@@ -390,7 +390,7 @@ public class MessageSectionFragment extends MainFragment
 		}
 		}
 
 
 		@Override
 		@Override
-		public void onAvatarChanged(ContactModel contactModel) {
+		public void onAvatarChanged(final @NonNull String identity) {
 			this.handleChange();
 			this.handleChange();
 		}
 		}
 
 
@@ -599,8 +599,10 @@ public class MessageSectionFragment extends MainFragment
 		@Override
 		@Override
 		public boolean onQueryTextChange(String query) {
 		public boolean onQueryTextChange(String query) {
 			filterQuery = query;
 			filterQuery = query;
-			messageListAdapter.setFilterQuery(query);
-			updateList(0, null, null);
+            if (messageListAdapter != null) {
+                messageListAdapter.setFilterQuery(query);
+                updateList(0, null, null);
+            }
 			return true;
 			return true;
 		}
 		}
 
 
@@ -691,14 +693,16 @@ public class MessageSectionFragment extends MainFragment
 	private void doUnhideChat(@NonNull ConversationModel conversationModel) {
 	private void doUnhideChat(@NonNull ConversationModel conversationModel) {
 		MessageReceiver<?> receiver = conversationModel.getReceiver();
 		MessageReceiver<?> receiver = conversationModel.getReceiver();
 		if (receiver != null && hiddenChatsListService.has(receiver.getUniqueIdString())) {
 		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
 			@Override
 			protected void onPostExecute(Boolean success) {
 			protected void onPostExecute(Boolean success) {
 				if (success) {
 				if (success) {
+                    if (messageListAdapter == null) {
+                        return;
+                    }
 					messageListAdapter.clearSelections();
 					messageListAdapter.clearSelections();
 					if (getView() != null) {
 					if (getView() != null) {
 						Snackbar.make(getView(), R.string.chat_hidden, Snackbar.LENGTH_SHORT).show();
 						Snackbar.make(getView(), R.string.chat_hidden, Snackbar.LENGTH_SHORT).show();
@@ -894,6 +901,10 @@ public class MessageSectionFragment extends MainFragment
 
 
 				@Override
 				@Override
 				public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
 				public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
+                    if (messageListAdapter == null) {
+                        return;
+                    }
+
 					// swipe has ended successfully
 					// swipe has ended successfully
 
 
 					// required to clear swipe layout
 					// required to clear swipe layout
@@ -1153,7 +1164,7 @@ public class MessageSectionFragment extends MainFragment
 
 
 	@Override
 	@Override
 	public boolean onItemLongClick(View view, int position, ConversationModel conversationModel) {
 	public boolean onItemLongClick(View view, int position, ConversationModel conversationModel) {
-		if (!isMultiPaneEnabled(activity)) {
+		if (!isMultiPaneEnabled(activity) && messageListAdapter != null) {
 			messageListAdapter.toggleItemChecked(conversationModel, position);
 			messageListAdapter.toggleItemChecked(conversationModel, position);
 			showSelector();
 			showSelector();
 			return true;
 			return true;
@@ -1220,7 +1231,9 @@ public class MessageSectionFragment extends MainFragment
 		}
 		}
 		updateHiddenMenuVisibility();
 		updateHiddenMenuVisibility();
 
 
-		messageListAdapter.updateDateView();
+        if (messageListAdapter != null) {
+            messageListAdapter.updateDateView();
+        }
 
 
 		super.onResume();
 		super.onResume();
 	}
 	}
@@ -1245,7 +1258,7 @@ public class MessageSectionFragment extends MainFragment
 		ArrayList<SelectorDialogItem> labels = new ArrayList<>();
 		ArrayList<SelectorDialogItem> labels = new ArrayList<>();
 		ArrayList<Integer> tags = new ArrayList<>();
 		ArrayList<Integer> tags = new ArrayList<>();
 
 
-		if (messageListAdapter.getCheckedItemCount() != 1) {
+		if (messageListAdapter == null || messageListAdapter.getCheckedItemCount() != 1) {
 			return;
 			return;
 		}
 		}
 
 
@@ -1311,7 +1324,7 @@ public class MessageSectionFragment extends MainFragment
 			}
 			}
 			boolean isCreator = groupService.isGroupCreator(group);
 			boolean isCreator = groupService.isGroupCreator(group);
 			boolean isMember = groupService.isGroupMember(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
 			// Check also if the user is a group member, because orphaned groups should not be
 			// editable.
 			// editable.
 			if (isCreator && isMember) {
 			if (isCreator && isMember) {
@@ -1350,7 +1363,9 @@ public class MessageSectionFragment extends MainFragment
 	public void onClick(String tag, int which, Object data) {
 	public void onClick(String tag, int which, Object data) {
 		GenericAlertDialog dialog;
 		GenericAlertDialog dialog;
 
 
-		messageListAdapter.clearSelections();
+        if (messageListAdapter != null) {
+            messageListAdapter.clearSelections();
+        }
 
 
 		final ConversationModel conversationModel = (ConversationModel) data;
 		final ConversationModel conversationModel = (ConversationModel) data;
 
 
@@ -1468,12 +1483,14 @@ public class MessageSectionFragment extends MainFragment
 
 
 	@Override
 	@Override
 	public void onCancel(String tag) {
 	public void onCancel(String tag) {
+        if (messageListAdapter != null) {
 		messageListAdapter.clearSelections();
 		messageListAdapter.clearSelections();
+        }
 	}
 	}
 
 
 	@Override
 	@Override
 	public void onNo(String tag) {
 	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();
 			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 org.slf4j.Logger;
 
 
+import java.util.Arrays;
 import java.util.Date;
 import java.util.Date;
 
 
 import ch.threema.app.R;
 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.managers.ServiceManager;
 import ch.threema.app.routines.CheckIdentityRoutine;
 import ch.threema.app.routines.CheckIdentityRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.ContactService.ProfilePictureSharePolicy;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.IdListService;
 import ch.threema.app.services.LocaleService;
 import ch.threema.app.services.LocaleService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.QRCodeServiceImpl;
 import ch.threema.app.services.QRCodeServiceImpl;
 import ch.threema.app.services.UserService;
 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.AvatarEditView;
 import ch.threema.app.ui.QRCodePopup;
 import ch.threema.app.ui.QRCodePopup;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -93,6 +98,8 @@ import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
+import ch.threema.domain.taskmanager.TaskManager;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
 
 
 /**
 /**
@@ -117,6 +124,9 @@ public class MyIDFragment extends MainFragment
 	private LocaleService localeService;
 	private LocaleService localeService;
 	private ContactService contactService;
 	private ContactService contactService;
 	private FileService fileService;
 	private FileService fileService;
+    private IdListService profilePicRecipientsService;
+	private TaskManager taskManager;
+
 	private AvatarEditView avatarView;
 	private AvatarEditView avatarView;
 	private EmojiTextView nicknameTextView;
 	private EmojiTextView nicknameTextView;
 	private boolean hidden = false;
 	private boolean hidden = false;
@@ -237,7 +247,7 @@ public class MyIDFragment extends MainFragment
 
 
 			final MaterialButton picReleaseConfImageView = fragmentView.findViewById(R.id.picrelease_config);
 			final MaterialButton picReleaseConfImageView = fragmentView.findViewById(R.id.picrelease_config);
 			picReleaseConfImageView.setOnClickListener(this);
 			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_email_layout), fragmentView.findViewById(R.id.change_email), isReadonlyProfile);
 			configureEditWithButton(fragmentView.findViewById(R.id.linked_mobile_layout), fragmentView.findViewById(R.id.change_mobile), 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) {
 		if (fragmentView != null && preferenceService != null) {
 			MaterialAutoCompleteTextView spinner = fragmentView.findViewById(R.id.picrelease_spinner);
 			MaterialAutoCompleteTextView spinner = fragmentView.findViewById(R.id.picrelease_spinner);
 			if (spinner != null) {
 			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.setAdapter(adapter);
 				spinner.setText(adapter.getItem(preferenceService.getProfilePicRelease()), false);
 				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
 	@Override
 	public void onStart() {
 	public void onStart() {
 		super.onStart();
 		super.onStart();
@@ -737,7 +783,7 @@ public class MyIDFragment extends MainFragment
 				// Update public nickname
 				// Update public nickname
 				String newNickname = text.trim();
 				String newNickname = text.trim();
 				if (!newNickname.equals(userService.getPublicNickname())) {
 				if (!newNickname.equals(userService.getPublicNickname())) {
-					userService.setPublicNickname(newNickname);
+					userService.setPublicNickname(newNickname, TriggerSource.LOCAL);
 				}
 				}
 				reloadNickname();
 				reloadNickname();
 				break;
 				break;
@@ -805,6 +851,8 @@ public class MyIDFragment extends MainFragment
 				this.fileService = this.serviceManager.getFileService();
 				this.fileService = this.serviceManager.getFileService();
 				this.preferenceService = this.serviceManager.getPreferenceService();
 				this.preferenceService = this.serviceManager.getPreferenceService();
 				this.localeService = this.serviceManager.getLocaleService();
 				this.localeService = this.serviceManager.getLocaleService();
+				this.taskManager = this.serviceManager.getTaskManager();
+				this.profilePicRecipientsService = this.serviceManager.getProfilePicRecipientsService();
 			} catch (MasterKeyLockedException e) {
 			} catch (MasterKeyLockedException e) {
 				logger.debug("Master Key locked!");
 				logger.debug("Master Key locked!");
 			} catch (ThreemaException e) {
 			} 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.collections.IPredicateNonNull;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 
 
@@ -61,21 +62,21 @@ public class UserMemberListFragment extends MemberListFragment {
 				List<ContactModel> contactModels;
 				List<ContactModel> contactModels;
 
 
 				if (groups) {
 				if (groups) {
-					final ContactModel.State[] contactStates;
+					final IdentityState[] contactStates;
 					if (preferenceService.showInactiveContacts()) {
 					if (preferenceService.showInactiveContacts()) {
-						contactStates = new ContactModel.State[]{
-							ContactModel.State.ACTIVE,
-							ContactModel.State.INACTIVE
+						contactStates = new IdentityState[]{
+							IdentityState.ACTIVE,
+							IdentityState.INACTIVE
 						};
 						};
 					} else {
 					} else {
-						contactStates = new ContactModel.State[]{
-							ContactModel.State.ACTIVE
+						contactStates = new IdentityState[]{
+							IdentityState.ACTIVE
 						};
 						};
 					}
 					}
 
 
 					contactModels = contactService.find(new ContactService.Filter() {
 					contactModels = contactService.find(new ContactService.Filter() {
 						@Override
 						@Override
-						public ContactModel.State[] states() {
+						public IdentityState[] states() {
 							return contactStates;
 							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.services.ContactService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 
 
 public class WorkUserListFragment extends RecipientListFragment {
 public class WorkUserListFragment extends RecipientListFragment {
@@ -115,21 +116,21 @@ public class WorkUserListFragment extends RecipientListFragment {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 			@Override
 			@Override
 			protected List<ContactModel> doInBackground(Void... voids) {
 			protected List<ContactModel> doInBackground(Void... voids) {
-				final ContactModel.State[] contactStates;
+				final IdentityState[] contactStates;
 				if (preferenceService.showInactiveContacts()) {
 				if (preferenceService.showInactiveContacts()) {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE,
-						ContactModel.State.INACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE,
+						IdentityState.INACTIVE
 					};
 					};
 				} else {
 				} else {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE
 					};
 					};
 				}
 				}
 
 
 				return Functional.filter(contactService.find(new ContactService.Filter() {
 				return Functional.filter(contactService.find(new ContactService.Filter() {
 					@Override
 					@Override
-					public ContactModel.State[] states() {
+					public IdentityState[] states() {
 						return contactStates;
 						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.collections.IPredicateNonNull;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.ContactUtil;
+import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel;
 
 
@@ -60,21 +61,21 @@ public class WorkUserMemberListFragment extends MemberListFragment {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 		new AsyncTask<Void, Void, List<ContactModel>>() {
 			@Override
 			@Override
 			protected List<ContactModel> doInBackground(Void... voids) {
 			protected List<ContactModel> doInBackground(Void... voids) {
-				final ContactModel.State[] contactStates;
+				final IdentityState[] contactStates;
 				if (preferenceService.showInactiveContacts()) {
 				if (preferenceService.showInactiveContacts()) {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE,
-						ContactModel.State.INACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE,
+						IdentityState.INACTIVE
 					};
 					};
 				} else {
 				} else {
-					contactStates = new ContactModel.State[]{
-						ContactModel.State.ACTIVE
+					contactStates = new IdentityState[]{
+						IdentityState.ACTIVE
 					};
 					};
 				}
 				}
 
 
 				List<ContactModel> contactModels = Functional.filter(contactService.find(new ContactService.Filter() {
 				List<ContactModel> contactModels = Functional.filter(contactService.find(new ContactService.Filter() {
 					@Override
 					@Override
-					public ContactModel.State[] states() {
+					public IdentityState[] states() {
 						return contactStates;
 						return contactStates;
 					}
 					}
 
 

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

@@ -34,38 +34,29 @@ public class AvatarOptions {
 	 */
 	 */
 	public enum DefaultAvatarPolicy {
 	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,
 		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,
 		CUSTOM_AVATAR,
 		/**
 		/**
 		 * Load the default avatar even if a custom avatar would be available.
 		 * Load the default avatar even if a custom avatar would be available.
 		 */
 		 */
 		DEFAULT_AVATAR,
 		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()
 	public static final AvatarOptions PRESET_DEFAULT_FALLBACK = new Builder()
 		.setReturnPolicy(DefaultAvatarPolicy.DEFAULT_FALLBACK)
 		.setReturnPolicy(DefaultAvatarPolicy.DEFAULT_FALLBACK)
 		.toOptions();
 		.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.
 	 * Load the avatar with default fallback and do not cache it.
 	 */
 	 */
@@ -146,7 +137,7 @@ public class AvatarOptions {
 	 */
 	 */
 	public static class Builder {
 	public static class Builder {
 		private boolean highRes = false;
 		private boolean highRes = false;
-		private @NonNull DefaultAvatarPolicy defaultAvatarPolicy = DefaultAvatarPolicy.RESPECT_SETTINGS;
+		private @NonNull DefaultAvatarPolicy defaultAvatarPolicy = DefaultAvatarPolicy.DEFAULT_AVATAR;
 		private boolean disableCache = false;
 		private boolean disableCache = false;
 		private boolean darkerBackground = 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
         val returnDefaultIfNone: Boolean
         when (contactAvatarConfig.options.defaultAvatarPolicy) {
         when (contactAvatarConfig.options.defaultAvatarPolicy) {
             AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK -> {
             AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK -> {
-                profilePicReceive = true
+                profilePicReceive = preferenceService?.profilePicReceive == true
                 defaultAvatar = false
                 defaultAvatar = false
                 returnDefaultIfNone = true
                 returnDefaultIfNone = true
             }
             }
@@ -74,11 +74,6 @@ class ContactAvatarFetcher(
                 defaultAvatar = true
                 defaultAvatar = true
                 returnDefaultIfNone = true
                 returnDefaultIfNone = true
             }
             }
-            AvatarOptions.DefaultAvatarPolicy.RESPECT_SETTINGS -> {
-                profilePicReceive = preferenceService?.profilePicReceive == true
-                defaultAvatar = false
-                returnDefaultIfNone = true
-            }
         }
         }
         val backgroundColor = getBackgroundColor(contactAvatarConfig.options)
         val backgroundColor = getBackgroundColor(contactAvatarConfig.options)
 
 
@@ -96,29 +91,29 @@ class ContactAvatarFetcher(
             return buildDefaultAvatar(null, highRes, backgroundColor)
             return buildDefaultAvatar(null, highRes, backgroundColor)
         }
         }
 
 
-        // try profile picture
+        // Try the contact defined profile picture
         if (profilePicReceive) {
         if (profilePicReceive) {
-            getProfilePicture(contactModel, highRes)?.let {
+            getContactDefinedProfilePicture(contactModel, highRes)?.let {
                 return it
                 return it
             }
             }
         }
         }
 
 
-        // try local saved avatar
-        getLocallySavedAvatar(contactModel, highRes)?.let {
+        // Try the user defined profile picture
+        getUserDefinedProfilePicture(contactModel, highRes)?.let {
             return it
             return it
         }
         }
 
 
-        // try android contact picture
-        getAndroidContactAvatar(contactModel, highRes)?.let {
+        // Try the android defined profile picture
+        getAndroidDefinedProfilePicture(contactModel, highRes)?.let {
             return it
             return it
         }
         }
 
 
         return if (returnDefaultIfNone) buildDefaultAvatar(contactModel, highRes, backgroundColor) else null
         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 {
         try {
-            val result = fileService?.getContactPhoto(contactModel.identity)
+            val result = fileService?.getContactDefinedProfilePicture(contactModel.identity)
             if (result != null && !highRes) {
             if (result != null && !highRes) {
                 return AvatarConverterUtil.convert(this.context.resources, result)
                 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 {
         return try {
-            var result = fileService?.getContactAvatar(contactModel.identity)
+            var result = fileService?.getUserDefinedProfilePicture(contactModel.identity)
             if (result != null && !highRes) {
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
                 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) {
         if (ContactUtil.isGatewayContact(contactModel) || AndroidContactUtil.getInstance().getAndroidContactUri(contactModel) == null) {
             return null
             return null
         }
         }
         // regular contacts
         // regular contacts
         return try {
         return try {
-            var result = fileService?.getAndroidContactAvatar(contactModel)
+            var result = fileService?.getAndroidDefinedProfilePicture(contactModel)
             if (result != null && !highRes) {
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
                 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
                 defaultAvatar = true
                 defaultAvatarIfNone = true
                 defaultAvatarIfNone = true
             }
             }
-            AvatarOptions.DefaultAvatarPolicy.RESPECT_SETTINGS -> {
-                defaultAvatar = preferenceService?.profilePicReceive == false
-                defaultAvatarIfNone = true
-            }
         }
         }
         val backgroundColor = getBackgroundColor(config.options)
         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.EmptyView
 import ch.threema.app.ui.ThreemaSearchView
 import ch.threema.app.ui.ThreemaSearchView
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
@@ -196,7 +197,7 @@ class GlobalSearchActivity : ThreemaToolbarActivity(), SearchView.OnQueryTextLis
                     val deadlineListIdentifier: String = if (messageModel is GroupMessageModel) {
                     val deadlineListIdentifier: String = if (messageModel is GroupMessageModel) {
                         groupService.getUniqueIdString(messageModel.groupId)
                         groupService.getUniqueIdString(messageModel.groupId)
                     } else {
                     } else {
-                        contactService.getUniqueIdString(messageModel.identity)
+                        ContactUtil.getUniqueIdString(messageModel.identity)
                     }
                     }
                     hiddenChatsListService.has(deadlineListIdentifier)
                     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.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.MimeUtil;
 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
             final @NonNull String uid = messageModel instanceof GroupMessageModel
                 ? groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId())
                 ? groupService.getUniqueIdString(((GroupMessageModel) messageModel).getGroupId())
-                : contactService.getUniqueIdString(messageModel.getIdentity());
+                : ContactUtil.getUniqueIdString(messageModel.getIdentity());
             if (hiddenChatsListService.has(uid)) {
             if (hiddenChatsListService.has(uid)) {
                 viewHolder.dateView.setText("");
                 viewHolder.dateView.setText("");
                 viewHolder.thumbnailView.setVisibility(View.GONE);
                 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.content.Intent;
 import android.net.Uri;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Bundle;
 import android.view.Menu;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.View;
 import android.view.ViewGroup;
 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.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.ActionMode;
 import androidx.appcompat.view.ActionMode;
@@ -38,17 +43,14 @@ import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.ThreemaToolbarActivity;
 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.GenericAlertDialog;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 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.EmptyView;
 import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 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.GroupInviteData;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.protobuf.url_payloads.GroupInvite;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseServiceNew;
+import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 import ch.threema.storage.models.group.OutgoingGroupJoinRequestModel;
 import ch.threema.storage.models.group.OutgoingGroupJoinRequestModel;
@@ -98,6 +105,11 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 	private GroupService groupService;
 	private GroupService groupService;
 	private ContactService contactService;
 	private ContactService contactService;
 	private DatabaseServiceNew databaseService;
 	private DatabaseServiceNew databaseService;
+	private APIConnector apiConnector;
+	private ContactModelRepository contactModelRepository;
+
+	@NonNull
+	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
 
 	private OutgoingGroupRequestViewModel viewModel;
 	private OutgoingGroupRequestViewModel viewModel;
 	private GroupInviteData groupInvite;
 	private GroupInviteData groupInvite;
@@ -169,6 +181,8 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 			this.userService = serviceManager.getUserService();
 			this.userService = serviceManager.getUserService();
 			this.groupService = serviceManager.getGroupService();
 			this.groupService = serviceManager.getGroupService();
 			this.databaseService = serviceManager.getDatabaseServiceNew();
 			this.databaseService = serviceManager.getDatabaseServiceNew();
+			this.apiConnector = serviceManager.getAPIConnector();
+			this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
 		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 			logger.error("Exception, services not available... finishing");
 			logger.error("Exception, services not available... finishing");
 			finish();
 			finish();
@@ -412,36 +426,32 @@ public class OutgoingGroupRequestActivity extends ThreemaToolbarActivity impleme
 			if (this.resendRequestReference == null) {
 			if (this.resendRequestReference == null) {
 				// first add contact and fetch public key to be able to send a request
 				// first add contact and fetch public key to be able to send a request
 				if (contactService.getByIdentity(groupInvite.getAdminIdentity()) == null) {
 				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 {
 				} else {
 					outgoingGroupJoinRequestService.send(
 					outgoingGroupJoinRequestService.send(
 						groupInvite,
 						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.
 	 * 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.
 	 * 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 onUpdatePhoto(GroupModel groupModel) { }
 	@AnyThread default void onRemove(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.
 	 * 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 java.util.List;
 
 
 import androidx.annotation.AnyThread;
 import androidx.annotation.AnyThread;
-import ch.threema.storage.models.ContactModel;
+import ch.threema.data.models.ContactModel;
 
 
 /**
 /**
  * Listen for new contacts added via sync.
  * 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
 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.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.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.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
 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
      * The task archiver. Note that this must only be used to load the persisted tasks when the
      * service manager has been set.
      * 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
      * The device cookie manager. Note that this must only be used when the notification service is
      * passed to it.
      * 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
      * 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.
      * 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.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.ServerMessageServiceImpl
 import ch.threema.app.services.ServerMessageServiceImpl
+import ch.threema.app.stores.IdentityStore
 import ch.threema.app.stores.PreferenceStoreInterface
 import ch.threema.app.stores.PreferenceStoreInterface
 import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.DeviceCookieManagerImpl
 import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.base.crypto.NonceFactory
 import ch.threema.domain.models.AppVersion
 import ch.threema.domain.models.AppVersion
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.TaskManagerConfiguration
 import ch.threema.domain.taskmanager.TaskManagerConfiguration
 import ch.threema.domain.taskmanager.TaskManagerProvider
 import ch.threema.domain.taskmanager.TaskManagerProvider
+import ch.threema.storage.DatabaseNonceStore
 import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.DatabaseServiceNew
 
 
 /**
 /**
@@ -43,6 +46,8 @@ class CoreServiceManagerImpl(
     override val version: AppVersion,
     override val version: AppVersion,
     override val databaseService: DatabaseServiceNew,
     override val databaseService: DatabaseServiceNew,
     override val preferenceStore: PreferenceStoreInterface,
     override val preferenceStore: PreferenceStoreInterface,
+    override val identityStore: IdentityStore,
+    private val nonceDatabaseStoreProvider: () -> DatabaseNonceStore,
 ) : CoreServiceManager {
 ) : 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;
 package ch.threema.app.managers;
 
 
 import android.content.Context;
 import android.content.Context;
+import android.os.Build;
+
+import com.datatheorem.android.trustkit.pinning.OkHttp3Helper;
 
 
 import org.slf4j.Logger;
 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.FileSystemNotPresentException;
 import ch.threema.app.exceptions.NoIdentityException;
 import ch.threema.app.exceptions.NoIdentityException;
 import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.multidevice.MultiDeviceManager;
-import ch.threema.app.multidevice.linking.DeviceJoinDataCollector;
 import ch.threema.app.processors.IncomingMessageProcessorImpl;
 import ch.threema.app.processors.IncomingMessageProcessorImpl;
 import ch.threema.app.services.ActivityService;
 import ch.threema.app.services.ActivityService;
 import ch.threema.app.services.ApiService;
 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.ProtocolDefines;
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.stores.DHSessionStoreInterface;
 import ch.threema.domain.stores.DHSessionStoreInterface;
-import ch.threema.domain.taskmanager.ActiveTaskCodec;
 import ch.threema.domain.taskmanager.IncomingMessageProcessor;
 import ch.threema.domain.taskmanager.IncomingMessageProcessor;
 import ch.threema.domain.taskmanager.TaskManager;
 import ch.threema.domain.taskmanager.TaskManager;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.DatabaseNonceStore;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.DatabaseServiceNew;
 import java8.util.function.Supplier;
 import java8.util.function.Supplier;
 import okhttp3.OkHttpClient;
 import okhttp3.OkHttpClient;
 
 
 public class ServiceManager {
 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
             // 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.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.stores.IdentityStore;
 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.OutgoingContactDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingContactEditMessageTask;
 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.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingLocationMessageTask;
 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.OutgoingTextMessageTask;
 import ch.threema.app.tasks.OutgoingTypingIndicatorMessageTask;
 import ch.threema.app.tasks.OutgoingTypingIndicatorMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallAnswerMessageTask;
 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.OutgoingVoipCallOfferMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallRingingMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallRingingMessageTask;
 import ch.threema.app.tasks.OutgoingVoipICECandidateMessageTask;
 import ch.threema.app.tasks.OutgoingVoipICECandidateMessageTask;
+import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
@@ -83,20 +86,21 @@ import ch.threema.storage.models.data.media.FileDataModel;
 public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	private final ContactModel contactModel;
 	private final ContactModel contactModel;
 	private final ContactService contactService;
 	private final ContactService contactService;
-	private Bitmap avatar = null;
 	@NonNull
 	@NonNull
 	private final ServiceManager serviceManager;
 	private final ServiceManager serviceManager;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final IdentityStore identityStore;
 	private final IdentityStore identityStore;
 	private final IdListService blockedContactsService;
 	private final IdListService blockedContactsService;
 	private final @NonNull TaskManager taskManager;
 	private final @NonNull TaskManager taskManager;
+	private final @NonNull MultiDeviceManager multiDeviceManager;
 
 
 	public ContactMessageReceiver(ContactModel contactModel,
 	public ContactMessageReceiver(ContactModel contactModel,
 	                              ContactService contactService,
 	                              ContactService contactService,
 	                              @NonNull ServiceManager serviceManager,
 	                              @NonNull ServiceManager serviceManager,
 	                              DatabaseServiceNew databaseServiceNew,
 	                              DatabaseServiceNew databaseServiceNew,
 	                              IdentityStore identityStore,
 	                              IdentityStore identityStore,
-	                              IdListService blockedContactsService) {
+	                              IdListService blockedContactsService
+	) {
 		this.contactModel = contactModel;
 		this.contactModel = contactModel;
 		this.contactService = contactService;
 		this.contactService = contactService;
 		this.serviceManager = serviceManager;
 		this.serviceManager = serviceManager;
@@ -104,6 +108,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		this.identityStore = identityStore;
 		this.identityStore = identityStore;
 		this.blockedContactsService = blockedContactsService;
 		this.blockedContactsService = blockedContactsService;
 		this.taskManager = serviceManager.getTaskManager();
 		this.taskManager = serviceManager.getTaskManager();
+		this.multiDeviceManager = serviceManager.getMultiDeviceManager();
 	}
 	}
 
 
 	protected ContactMessageReceiver(ContactMessageReceiver contactMessageReceiver) {
 	protected ContactMessageReceiver(ContactMessageReceiver contactMessageReceiver) {
@@ -115,7 +120,6 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 			contactMessageReceiver.identityStore,
 			contactMessageReceiver.identityStore,
 			contactMessageReceiver.blockedContactsService
 			contactMessageReceiver.blockedContactsService
 		);
 		);
-		avatar = contactMessageReceiver.avatar;
 	}
 	}
 
 
 	@Override
 	@Override
@@ -163,7 +167,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		saveLocalModel(messageModel);
 		saveLocalModel(messageModel);
 
 
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		bumpLastUpdate();
 		bumpLastUpdate();
@@ -178,7 +182,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	}
 	}
 
 
 	public void resendTextMessage(@NonNull MessageModel messageModel) {
 	public void resendTextMessage(@NonNull MessageModel messageModel) {
-		contactService.setIsHidden(contactModel.getIdentity(), false);
+		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		scheduleTask(new OutgoingTextMessageTask(
 		scheduleTask(new OutgoingTextMessageTask(
@@ -196,7 +200,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		saveLocalModel(messageModel);
 		saveLocalModel(messageModel);
 
 
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		bumpLastUpdate();
 		bumpLastUpdate();
@@ -212,7 +216,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 
 	public void resendLocationMessage(@NonNull MessageModel messageModel) {
 	public void resendLocationMessage(@NonNull MessageModel messageModel) {
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		// Schedule outgoing text message task
 		// 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
 		// Set file data model again explicitly to enforce that the body of the message is rewritten
 		// and therefore updated.
 		// and therefore updated.
-		messageModel.setFileData(modelFileData);
+		messageModel.setFileDataModel(modelFileData);
 
 
 		// Create a new message id if the given message id is null
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		saveLocalModel(messageModel);
 		saveLocalModel(messageModel);
 
 
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		// Note that lastUpdate lastUpdate was bumped when the file message was created
 		// 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()));
 		final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
 
 
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		bumpLastUpdate();
 		bumpLastUpdate();
@@ -310,7 +314,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		}
 		}
 
 
 		// Mark the contact as non-hidden and unarchived
 		// 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);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
 
 		// Schedule outgoing text message task
 		// Schedule outgoing text message task
@@ -340,14 +344,33 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	 * @param receiptType the type of the delivery receipt
 	 * @param receiptType the type of the delivery receipt
 	 * @param messageIds  the message ids
 	 * @param messageIds  the message ids
 	 */
 	 */
-	public void sendDeliveryReceipt(int receiptType, @NonNull MessageId[] messageIds) {
+	public void sendDeliveryReceipt(int receiptType, @NonNull MessageId[] messageIds, long time) {
 		scheduleTask(
 		scheduleTask(
 			new OutgoingContactDeliveryReceiptMessageTask(
 			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.
 	 * Send a voip call offer message to the receiver.
 	 *
 	 *
@@ -506,30 +529,28 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	@Override
 	@Override
 	@Nullable
 	@Nullable
 	public Bitmap getNotificationAvatar() {
 	public Bitmap getNotificationAvatar() {
-		if (avatar == null && contactService != null) {
-			avatar = contactService.getAvatar(contactModel, false);
-		}
-		return avatar;
+		return contactService.getAvatar(contactModel, false);
 	}
 	}
 
 
 	@Override
 	@Override
 	@Nullable
 	@Nullable
 	public Bitmap getAvatar() {
 	public Bitmap getAvatar() {
-		if (avatar == null && contactService != null) {
-			avatar = contactService.getAvatar(contactModel, true, true);
-		}
-		return avatar;
+		return contactService.getAvatar(contactModel, true, true);
 	}
 	}
 
 
 	@Deprecated
 	@Deprecated
 	@Override
 	@Override
 	public int getUniqueId() {
 	public int getUniqueId() {
-		return contactService.getUniqueId(contactModel);
+		return contactModel != null
+			? ContactUtil.getUniqueId(contactModel.getIdentity())
+			: 0;
 	}
 	}
 
 
 	@Override
 	@Override
 	public String getUniqueIdString() {
 	public String getUniqueIdString() {
-		return contactService.getUniqueIdString(contactModel);
+		return contactModel != null
+			? ContactUtil.getUniqueIdString(contactModel.getIdentity())
+		    : "";
 	}
 	}
 
 
 	@Override
 	@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 androidx.annotation.Nullable;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
 import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
@@ -71,10 +73,10 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 
 	private final GroupModel group;
 	private final GroupModel group;
 	private final GroupService groupService;
 	private final GroupService groupService;
-	private Bitmap avatar = null;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final @NonNull ServiceManager serviceManager;
 	private final @NonNull ServiceManager serviceManager;
 	private final TaskManager taskManager;
 	private final TaskManager taskManager;
+	private final MultiDeviceManager multiDeviceManager;
 
 
 	public GroupMessageReceiver(
 	public GroupMessageReceiver(
 		GroupModel group,
 		GroupModel group,
@@ -87,6 +89,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		this.databaseServiceNew = databaseServiceNew;
 		this.databaseServiceNew = databaseServiceNew;
 		this.serviceManager = serviceManager;
 		this.serviceManager = serviceManager;
 		this.taskManager = serviceManager.getTaskManager();
 		this.taskManager = serviceManager.getTaskManager();
+		this.multiDeviceManager = serviceManager.getMultiDeviceManager();
 	}
 	}
 
 
 	@Override
 	@Override
@@ -126,7 +129,7 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 
 	@Override
 	@Override
 	public void createAndSendTextMessage(@NonNull GroupMessageModel messageModel) {
 	public void createAndSendTextMessage(@NonNull GroupMessageModel messageModel) {
-		Set<String> otherMembers = groupService.getOtherMembers(group);
+		Set<String> otherMembers = groupService.getMembersWithoutUser(group);
 
 
 		if (otherMembers.isEmpty()) {
 		if (otherMembers.isEmpty()) {
 			// In case the recipients set is empty, we are sending the message in a notes group. In
 			// 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
 	@Override
 	public void createAndSendLocationMessage(@NonNull GroupMessageModel messageModel) {
 	public void createAndSendLocationMessage(@NonNull GroupMessageModel messageModel) {
-		Set<String> otherMembers = groupService.getOtherMembers(group);
+		Set<String> otherMembers = groupService.getMembersWithoutUser(group);
 
 
 		if (otherMembers.isEmpty()) {
 		if (otherMembers.isEmpty()) {
 			// In case the recipients set is empty, we are sending the message in a notes group. In
 			// 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
 		// Set file data model again explicitly to enforce that the body of the message is rewritten
 		// and therefore updated.
 		// and therefore updated.
-		messageModel.setFileData(modelFileData);
+		messageModel.setFileDataModel(modelFileData);
 
 
 		// Create a new message id if the given message id is null
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		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) {
 	public void sendEditMessage(int messageModelId, @NonNull String body, @NonNull Date editedAt) {
 		taskManager.schedule(
 		taskManager.schedule(
 			new OutgoingGroupEditMessageTask(
 			new OutgoingGroupEditMessageTask(
@@ -362,18 +383,12 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 
 	@Override
 	@Override
 	public Bitmap getNotificationAvatar() {
 	public Bitmap getNotificationAvatar() {
-		if (avatar == null && groupService != null) {
-			avatar = groupService.getAvatar(group, false);
-		}
-		return avatar;
+		return groupService.getAvatar(group, false);
 	}
 	}
 
 
 	@Override
 	@Override
 	public Bitmap getAvatar() {
 	public Bitmap getAvatar() {
-		if (avatar == null && groupService != null) {
-			avatar = groupService.getAvatar(group, true, true);
-		}
-		return avatar;
+		return groupService.getAvatar(group, true, true);
 	}
 	}
 
 
 	@Override
 	@Override
@@ -401,6 +416,12 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 
 	@Override
 	@Override
 	public boolean sendMediaData() {
 	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
 		// don't really send off group media if user is the only group member left - keep it local
 		String[] groupIdentities = groupService.getGroupIdentities(group);
 		String[] groupIdentities = groupService.getGroupIdentities(group);
 		return groupIdentities.length != 1 || !groupService.isGroupMember(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.content.Intent;
 import android.graphics.Bitmap;
 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.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.RetentionPolicy;
 import java.sql.SQLException;
 import java.sql.SQLException;
@@ -35,6 +31,9 @@ import java.util.Collection;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 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.app.services.MessageService;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.SymmetricEncryptionResult;
 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.Manifest
 import android.annotation.TargetApi
 import android.annotation.TargetApi
+import android.app.Activity
+import android.content.Intent
 import android.content.pm.PackageManager
 import android.content.pm.PackageManager
 import android.os.Build
 import android.os.Build
 import android.os.Bundle
 import android.os.Bundle
 import android.view.View
 import android.view.View
 import android.widget.TextView
 import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.viewModels
 import androidx.activity.viewModels
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.lifecycleScope
@@ -37,11 +40,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView
 import ch.threema.app.R
 import ch.threema.app.R
 import ch.threema.app.activities.ThreemaToolbarActivity
 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.EmptyRecyclerView
 import ch.threema.app.ui.SilentSwitchCompat
 import ch.threema.app.ui.SilentSwitchCompat
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.QRScannerUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
@@ -56,19 +58,26 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
 
 
     private val viewModel: LinkedDevicesViewModel by viewModels()
     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 devicesList: EmptyRecyclerView
     private lateinit var devicesAdapter: LinkedDevicesAdapter
     private lateinit var devicesAdapter: LinkedDevicesAdapter
 
 
     private lateinit var onOffButton: SilentSwitchCompat
     private lateinit var onOffButton: SilentSwitchCompat
     private lateinit var linkDeviceButton: ExtendedFloatingActionButton
     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 getLayoutResource(): Int = R.layout.activity_linked_devices
 
 
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -93,6 +102,11 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
 
 
         linkDeviceButton = findViewById(R.id.link_device_button)
         linkDeviceButton = findViewById(R.id.link_device_button)
         linkDeviceButton.setOnClickListener { initiateLinking() }
         linkDeviceButton.setOnClickListener { initiateLinking() }
+        // TODO(ANDR-2717): Remove
+        linkDeviceButton.setOnLongClickListener {
+            viewModel.dropOtherDevices()
+            true
+        }
 
 
         initDevicesList()
         initDevicesList()
 
 
@@ -104,7 +118,7 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         if (requestCode == PERMISSION_REQUEST_CAMERA) {
         if (requestCode == PERMISSION_REQUEST_CAMERA) {
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-                scanQr()
+                startLinkingWizard()
             } else if (!this.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
             } else if (!this.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
                 ConfigUtils.showPermissionRationale(this, findViewById(R.id.parent_layout), R.string.permission_camera_qr_required)
                 ConfigUtils.showPermissionRationale(this, findViewById(R.id.parent_layout), R.string.permission_camera_qr_required)
             }
             }
@@ -139,16 +153,13 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
     private fun initiateLinking() {
     private fun initiateLinking() {
         logger.debug("Initiate linking")
         logger.debug("Initiate linking")
         if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
         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() {
     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.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import ch.threema.app.ThreemaApplication.requireServiceManager
 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 ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,6 +39,8 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 
 
+private val logger = LoggingUtil.getThreemaLogger("LinkedDevicesViewModel")
+
 class LinkedDevicesViewModel : ViewModel() {
 class LinkedDevicesViewModel : ViewModel() {
 
 
     private val _isMdActive = MutableStateFlow(false)
     private val _isMdActive = MutableStateFlow(false)
@@ -51,15 +55,24 @@ class LinkedDevicesViewModel : ViewModel() {
 
 
     private val mdManager: MultiDeviceManager by lazy { requireServiceManager().multiDeviceManager }
     private val mdManager: MultiDeviceManager by lazy { requireServiceManager().multiDeviceManager }
 
 
+    private val taskCreator: TaskCreator by lazy { requireServiceManager().taskCreator }
+
     init {
     init {
         emitStates()
         emitStates()
         collectLatestCloseReason()
         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 {
         CoroutineScope(Dispatchers.Default).launch {
-            mdManager.linkDevice(deviceJoinOfferUri, deviceJoinDataCollector)
+            mdManager.purge(taskCreator)
+            delay(500)
             emitStates()
             emitStates()
         }
         }
     }
     }
@@ -75,14 +88,15 @@ class LinkedDevicesViewModel : ViewModel() {
 
 
     @AnyThread
     @AnyThread
     private fun activateMultiDevice() {
     private fun activateMultiDevice() {
+        logger.info("Activate multi device")
         CoroutineScope(Dispatchers.Default).launch {
         CoroutineScope(Dispatchers.Default).launch {
             val serviceManager = requireServiceManager()
             val serviceManager = requireServiceManager()
             mdManager.activate(
             mdManager.activate(
                 "Android Client", // TODO(ANDR-2487): Should be userselectable (and updateable)
                 "Android Client", // TODO(ANDR-2487): Should be userselectable (and updateable)
-                serviceManager.taskManager,
                 serviceManager.contactService,
                 serviceManager.contactService,
                 serviceManager.userService,
                 serviceManager.userService,
-                serviceManager.forwardSecurityMessageProcessor
+                serviceManager.forwardSecurityMessageProcessor,
+                taskCreator,
             )
             )
             emitStates()
             emitStates()
         }
         }
@@ -92,11 +106,10 @@ class LinkedDevicesViewModel : ViewModel() {
     private fun deactivateMultiDevice() {
     private fun deactivateMultiDevice() {
         CoroutineScope(Dispatchers.Default).launch {
         CoroutineScope(Dispatchers.Default).launch {
             val serviceManager = requireServiceManager()
             val serviceManager = requireServiceManager()
-            // TODO(ANDR-2603): Maybe show a spinner while we are waiting for deactivation to complete
             mdManager.deactivate(
             mdManager.deactivate(
-                serviceManager.taskManager,
                 serviceManager.userService,
                 serviceManager.userService,
-                serviceManager.forwardSecurityMessageProcessor
+                serviceManager.forwardSecurityMessageProcessor,
+                taskCreator,
             )
             )
             emitStates()
             emitStates()
         }
         }
@@ -118,7 +131,7 @@ class LinkedDevicesViewModel : ViewModel() {
     @AnyThread
     @AnyThread
     private fun emitLinkedDevices() {
     private fun emitLinkedDevices() {
         viewModelScope.launch {
         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.AnyThread
 import androidx.annotation.WorkerThread
 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.ContactService
 import ch.threema.app.services.UserService
 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.MultiDevicePropertyProvider
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
-import ch.threema.domain.taskmanager.TaskManager
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 
 
 interface MultiDeviceManager {
 interface MultiDeviceManager {
@@ -40,8 +41,6 @@ interface MultiDeviceManager {
 
 
     val isMultiDeviceActive: Boolean
     val isMultiDeviceActive: Boolean
 
 
-    val linkedDevices: List<String>
-
     val propertiesProvider: MultiDevicePropertyProvider
     val propertiesProvider: MultiDevicePropertyProvider
 
 
     val socketCloseListener: D2mSocketCloseListener
     val socketCloseListener: D2mSocketCloseListener
@@ -52,25 +51,50 @@ interface MultiDeviceManager {
     @WorkerThread
     @WorkerThread
     suspend fun activate(
     suspend fun activate(
         deviceLabel: String,
         deviceLabel: String,
-        taskManager: TaskManager, // TODO(ANDR-2519): Remove
         contactService: ContactService, // TODO(ANDR-2519): remove
         contactService: ContactService, // TODO(ANDR-2519): remove
         userService: UserService, // TODO(ANDR-2519): remove
         userService: UserService, // TODO(ANDR-2519): remove
         fsMessageProcessor: ForwardSecurityMessageProcessor, // 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
     @WorkerThread
     suspend fun deactivate(
     suspend fun deactivate(
-        taskManager: TaskManager,
         userService: UserService, // TODO(ANDR-2519): remove
         userService: UserService, // TODO(ANDR-2519): remove
-        fsMessageProcessor: ForwardSecurityMessageProcessor // TODO(ANDR-2519): remove
+        fsMessageProcessor: ForwardSecurityMessageProcessor, // TODO(ANDR-2519): remove
+        taskCreator: TaskCreator,
     )
     )
 
 
     @WorkerThread
     @WorkerThread
     suspend fun setDeviceLabel(deviceLabel: String)
     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(
     suspend fun linkDevice(
         deviceJoinOfferUri: String,
         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.AnyThread
 import androidx.annotation.WorkerThread
 import androidx.annotation.WorkerThread
 import ch.threema.app.BuildConfig
 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.ContactService
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.ServerMessageService
 import ch.threema.app.services.UserService
 import ch.threema.app.services.UserService
 import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.stores.PreferenceStoreInterface
 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.LoggingUtil
 import ch.threema.base.utils.SecureRandomUtil.generateRandomBytes
 import ch.threema.base.utils.SecureRandomUtil.generateRandomBytes
 import ch.threema.base.utils.SecureRandomUtil.generateRandomU64
 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.csp.fs.ForwardSecurityMessageProcessor
 import ch.threema.domain.protocol.multidevice.MultiDeviceKeys
 import ch.threema.domain.protocol.multidevice.MultiDeviceKeys
 import ch.threema.domain.protocol.multidevice.MultiDeviceProperties
 import ch.threema.domain.protocol.multidevice.MultiDeviceProperties
-import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.protobuf.csp.e2e.fs.Terminate
 import ch.threema.protobuf.csp.e2e.fs.Terminate
 import ch.threema.storage.models.ServerMessageModel
 import ch.threema.storage.models.ServerMessageModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
-import org.saltyrtc.client.exceptions.InvalidStateException
 import java.util.Date
 import java.util.Date
 
 
 private val logger = LoggingUtil.getThreemaLogger("MultiDeviceManagerImpl")
 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
  * NOTE: If set to `false` the backup version should be incremented, as
  * `ForwardSecurityStatusType.FORWARD_SECURITY_DISABLED` cannot be restored on older versions.
  * `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(
 class MultiDeviceManagerImpl(
     private val preferenceStore: PreferenceStoreInterface,
     private val preferenceStore: PreferenceStoreInterface,
     private val serverMessageService: ServerMessageService,
     private val serverMessageService: ServerMessageService,
     private val version: Version,
     private val version: Version,
-) : MultiDeviceManager {
+    ) : MultiDeviceManager {
 
 
     private var reconnectHandle: ReconnectableServerConnection? = null
     private var reconnectHandle: ReconnectableServerConnection? = null
 
 
@@ -129,23 +132,17 @@ class MultiDeviceManagerImpl(
     override val isMultiDeviceActive: Boolean
     override val isMultiDeviceActive: Boolean
         get() = properties != null
         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)
     override val latestSocketCloseReason = MutableSharedFlow<D2mSocketCloseReason?>(1, 0, BufferOverflow.DROP_OLDEST)
 
 
     private var serverInfo: InboundD2mMessage.ServerInfo? = null
     private var serverInfo: InboundD2mMessage.ServerInfo? = null
 
 
-    private var deactivationOngoing = false
-
     @AnyThread
     @AnyThread
     override suspend fun activate(
     override suspend fun activate(
         deviceLabel: String,
         deviceLabel: String,
-        taskManager: TaskManager,
         contactService: ContactService,
         contactService: ContactService,
         userService: UserService,
         userService: UserService,
         fsMessageProcessor: ForwardSecurityMessageProcessor,
         fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
     ) {
     ) {
         logger.info("Activate multi device")
         logger.info("Activate multi device")
         if (!BuildConfig.MD_ENABLED) {
         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
         // TODO(ANDR-2519): Remove when md allows fs by default `activate` could then be non-suspending
         if (!IS_FS_SUPPORTED_WITH_MD) {
         if (!IS_FS_SUPPORTED_WITH_MD) {
-            disableForwardSecurity(taskManager, contactService, userService, fsMessageProcessor)
+            disableForwardSecurity(contactService, userService, fsMessageProcessor, taskCreator)
         }
         }
         latestSocketCloseReason.tryEmit(null)
         latestSocketCloseReason.tryEmit(null)
         reconnect()
         reconnect()
@@ -170,82 +167,118 @@ class MultiDeviceManagerImpl(
 
 
     @AnyThread
     @AnyThread
     override suspend fun deactivate(
     override suspend fun deactivate(
-        taskManager: TaskManager,
         userService: UserService,
         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
         // TODO(ANDR-2519): Remove when md allows fs by default
+        // 3. Enable FS
         if (!IS_FS_SUPPORTED_WITH_MD) {
         if (!IS_FS_SUPPORTED_WITH_MD) {
             enableForwardSecurity(userService, fsMessageProcessor)
             enableForwardSecurity(userService, fsMessageProcessor)
         }
         }
 
 
+        // 4. Cleanup
         serverInfo = null
         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) {
     override suspend fun setDeviceLabel(deviceLabel: String) {
         persistedProperties = persistedProperties!!.withDeviceLabel(deviceLabel)
         persistedProperties = persistedProperties!!.withDeviceLabel(deviceLabel)
     }
     }
 
 
-    @AnyThread
+    @WorkerThread
     override suspend fun linkDevice(
     override suspend fun linkDevice(
         deviceJoinOfferUri: String,
         deviceJoinOfferUri: String,
-        deviceJoinDataCollector: DeviceJoinDataCollector,
-    ) {
+        taskCreator: TaskCreator,
+    ): Flow<DeviceLinkingStatus> {
         logger.debug("Link device: {}", deviceJoinOfferUri)
         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) {
     private fun onSocketClosed(reason: ServerSocketCloseReason) {
@@ -281,43 +314,15 @@ class MultiDeviceManagerImpl(
     }
     }
 
 
     private fun handleDeviceDropped() {
     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() {
     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")
         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) {
     private fun displayConnectionError(msg: String) {
         // TODO(ANDR-2604): Show actual dialog to user
         // TODO(ANDR-2604): Show actual dialog to user
         // TODO(ANDR-2604): Use string resources instead of string
         // 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)
         logger.warn("Reconnect is not allowed: {}", msg)
 
 
         val message = ServerMessageModel(msg, ServerMessageModel.TYPE_ERROR)
         val message = ServerMessageModel(msg, ServerMessageModel.TYPE_ERROR)
@@ -335,6 +340,7 @@ class MultiDeviceManagerImpl(
 
 
     private fun reconnect() {
     private fun reconnect() {
         CoroutineScope(Dispatchers.Default).launch {
         CoroutineScope(Dispatchers.Default).launch {
+            logger.info("Reconnect server connection")
             reconnectHandle?.reconnect() ?: logger.error("Reconnect handle is null")
             reconnectHandle?.reconnect() ?: logger.error("Reconnect handle is null")
         }
         }
     }
     }
@@ -342,18 +348,14 @@ class MultiDeviceManagerImpl(
     // TODO(ANDR-2519): Remove when md allows fs
     // TODO(ANDR-2519): Remove when md allows fs
     @AnyThread
     @AnyThread
     private suspend fun disableForwardSecurity(
     private suspend fun disableForwardSecurity(
-        taskManager: TaskManager,
         contactService: ContactService,
         contactService: ContactService,
         userService: UserService,
         userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
     ) {
     ) {
         withContext(Dispatchers.IO) {
         withContext(Dispatchers.IO) {
             updateFeatureMask(userService, false)
             updateFeatureMask(userService, false)
-            terminateAllForwardSecuritySessions(
-                taskManager,
-                contactService,
-                fsMessageProcessor
-            )
+            terminateAllForwardSecuritySessions(contactService, taskCreator)
             fsMessageProcessor.setForwardSecurityEnabled(false)
             fsMessageProcessor.setForwardSecurityEnabled(false)
         }
         }
     }
     }
@@ -377,15 +379,12 @@ class MultiDeviceManagerImpl(
     // TODO(ANDR-2519): Remove when md allows fs
     // TODO(ANDR-2519): Remove when md allows fs
     @WorkerThread
     @WorkerThread
     private suspend fun terminateAllForwardSecuritySessions(
     private suspend fun terminateAllForwardSecuritySessions(
-        taskManager: TaskManager,
         contactService: ContactService,
         contactService: ContactService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        taskCreator: TaskCreator,
     ) {
     ) {
         contactService.all.map {
         contactService.all.map {
-            taskManager.schedule(
-                DeleteAndTerminateFSSessionsTask(
-                    fsMessageProcessor, it, Terminate.Cause.DISABLED_BY_LOCAL
-                )
+            taskCreator.scheduleDeleteAndTerminateFSSessionsTaskAsync(
+                it, Terminate.Cause.DISABLED_BY_LOCAL
             )
             )
         }.awaitAll()
         }.awaitAll()
     }
     }
@@ -433,7 +432,7 @@ class MultiDeviceManagerImpl(
         return D2dMessage.DeviceInfo(
         return D2dMessage.DeviceInfo(
             D2dMessage.DeviceInfo.Platform.ANDROID,
             D2dMessage.DeviceInfo.Platform.ANDROID,
             platformDetails,
             platformDetails,
-            version.version,
+            version.versionNumber,
             deviceLabel
             deviceLabel
         ).also { logger.trace("Device info created: {}", it) }
         ).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 android.graphics.Bitmap
 import androidx.annotation.WorkerThread
 import androidx.annotation.WorkerThread
+import ch.threema.app.BuildConfig
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.DeadlineListService
 import ch.threema.app.services.DeadlineListService
 import ch.threema.app.services.license.LicenseServiceUser
 import ch.threema.app.services.license.LicenseServiceUser
 import ch.threema.app.utils.BitmapUtil
 import ch.threema.app.utils.BitmapUtil
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.ConversationUtil.getConversationUid
 import ch.threema.app.utils.ConversationUtil.getConversationUid
+import ch.threema.base.crypto.NonceScope
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.protobuf.Common.BlobData
 import ch.threema.protobuf.Common.BlobData
@@ -85,9 +89,9 @@ import ch.threema.storage.models.GroupModel
 import com.google.protobuf.ByteString
 import com.google.protobuf.ByteString
 import java.nio.ByteBuffer
 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?) {
 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
     serviceManager: ServiceManager
 ) {
 ) {
     private val identityStore by lazy { serviceManager.identityStore }
     private val identityStore by lazy { serviceManager.identityStore }
@@ -132,7 +136,7 @@ class DeviceJoinDataCollector(
     private val licenseService by lazy { serviceManager.licenseService }
     private val licenseService by lazy { serviceManager.licenseService }
 
 
     @WorkerThread
     @WorkerThread
-    fun collectData(dgk: ByteArray): DeviceJoinData {
+    fun collectData(dgk: ByteArray): DeviceLinkingData {
         val blobDataProviders = mutableListOf<BlobDataProvider>()
         val blobDataProviders = mutableListOf<BlobDataProvider>()
 
 
         val data = essentialData {
         val data = essentialData {
@@ -146,7 +150,9 @@ class DeviceJoinDataCollector(
 
 
             logger.trace("Collect user profile")
             logger.trace("Collect user profile")
             val (userProfileBlobProvider, userProfileData) = collectUserProfile()
             val (userProfileBlobProvider, userProfileData) = collectUserProfile()
-            blobDataProviders.add(userProfileBlobProvider)
+            userProfileBlobProvider?.let {
+                blobDataProviders.add(it)
+            }
             this.userProfile = userProfileData
             this.userProfile = userProfileData
 
 
             logger.trace("Collect settings")
             logger.trace("Collect settings")
@@ -166,15 +172,19 @@ class DeviceJoinDataCollector(
                 this.groups += group
                 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")
             logger.trace("Collect csp nonce hashes")
             this.cspHashedNonces += collectCspNonceHashes()
             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
             // work
             if (ConfigUtils.isWorkBuild()) {
             if (ConfigUtils.isWorkBuild()) {
@@ -192,7 +202,7 @@ class DeviceJoinDataCollector(
             .asSequence()
             .asSequence()
             .mapNotNull { it.get() }
             .mapNotNull { it.get() }
 
 
-        return DeviceJoinData(blobsSequence, data)
+        return DeviceLinkingData(blobsSequence, data)
     }
     }
 
 
     private fun collectIdentityData(): IdentityData {
     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
                 nickname = identityStore.publicNickname
-                profilePicture = avatar
+                profilePictureData?.second?.let {
+                    profilePicture = it
+                }
                 profilePictureShareWith = collectProfilePictureShareWith()
                 profilePictureShareWith = collectProfilePictureShareWith()
                 identityLinks = collectIdentityLinks()
                 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
         val hasProfilePicture = profilePictureData.blobId != null
             && !profilePictureData.blobId.contentEquals(ContactModel.NO_PROFILE_PICTURE_BLOB_ID)
             && !profilePictureData.blobId.contentEquals(ContactModel.NO_PROFILE_PICTURE_BLOB_ID)
 
 
-        val profilePicture = if (hasProfilePicture) {
+        return if (hasProfilePicture) {
             val blobMeta = blob {
             val blobMeta = blob {
                 id = profilePictureData.blobId.toByteString()
                 id = profilePictureData.blobId.toByteString()
                 nonce = ProtocolDefines.CONTACT_PHOTO_NONCE.toByteString()
                 nonce = ProtocolDefines.CONTACT_PHOTO_NONCE.toByteString()
@@ -229,19 +241,19 @@ class DeviceJoinDataCollector(
                 uploadedAt = profilePictureData.uploadedAt
                 uploadedAt = profilePictureData.uploadedAt
             }
             }
 
 
-            deltaImage { updated = image {
+            val profilePicture = deltaImage { updated = image {
                 type = Image.Type.JPEG
                 type = Image.Type.JPEG
                 blob = blobMeta
                 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 {
     private fun collectProfilePictureShareWith(): ProfilePictureShareWith {
@@ -251,7 +263,7 @@ class DeviceJoinDataCollector(
             when (policy.policy) {
             when (policy.policy) {
                 ContactService.ProfilePictureSharePolicy.Policy.NOBODY -> nobody = unit {}
                 ContactService.ProfilePictureSharePolicy.Policy.NOBODY -> nobody = unit {}
                 ContactService.ProfilePictureSharePolicy.Policy.EVERYONE -> everyone = unit {}
                 ContactService.ProfilePictureSharePolicy.Policy.EVERYONE -> everyone = unit {}
-                ContactService.ProfilePictureSharePolicy.Policy.SOME -> {
+                ContactService.ProfilePictureSharePolicy.Policy.ALLOW_LIST -> {
                     allowList = identities { identities += policy.allowedIdentities }
                     allowList = identities { identities += policy.allowedIdentities }
                 }
                 }
             }
             }
@@ -341,18 +353,16 @@ class DeviceJoinDataCollector(
     private data class ConversationStats(
     private data class ConversationStats(
         val isArchived: Boolean,
         val isArchived: Boolean,
         val isPinned: Boolean,
         val isPinned: Boolean,
-        val lastMessageCreatedAt: Long?
     )
     )
 
 
     private fun collectConversationsStats(): Map<String, ConversationStats> {
     private fun collectConversationsStats(): Map<String, ConversationStats> {
         val notArchived = conversationService.getAll(true).associate {
         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 {
         val archived = conversationService.getArchived(null).associate {
             it.uid to ConversationStats(
             it.uid to ConversationStats(
                 isArchived = true,
                 isArchived = true,
-                isPinned = false,
-                lastMessageCreatedAt = it.latestMessage?.createdAt?.time)
+                isPinned = false)
         }
         }
         return notArchived + archived
         return notArchived + archived
     }
     }
@@ -390,9 +400,9 @@ class DeviceJoinDataCollector(
                 ContactModel.AcquaintanceLevel.DIRECT -> Contact.AcquaintanceLevel.DIRECT
                 ContactModel.AcquaintanceLevel.DIRECT -> Contact.AcquaintanceLevel.DIRECT
             }
             }
             activityState = when (contactModel.state) {
             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")
                 else -> throw IllegalStateException("Contact ${contactModel.identity} has missing state")
             }
             }
             featureMask = contactModel.featureMask
             featureMask = contactModel.featureMask
@@ -449,9 +459,7 @@ class DeviceJoinDataCollector(
 
 
         val augmentedContact = augmentedContact {
         val augmentedContact = augmentedContact {
             this.contact = contact
             this.contact = contact
-            conversationStats?.lastMessageCreatedAt?.let {
-                this.lastUpdateAt = it
-            }
+            contactModel.lastUpdate?.let { this.lastUpdateAt = it.time }
         }
         }
 
 
         return blobDataProviders to augmentedContact
         return blobDataProviders to augmentedContact
@@ -497,20 +505,20 @@ class DeviceJoinDataCollector(
     }
     }
 
 
     private fun ContactModel.getUniqueId(): String {
     private fun ContactModel.getUniqueId(): String {
-        return contactService.getUniqueIdString(this)
+        return ContactUtil.getUniqueIdString(identity)
     }
     }
 
 
     private fun collectContactDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
     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 {
         } else {
             null
             null
         }
         }
     }
     }
 
 
     private fun collectUserDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
     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 {
         } else {
             null
             null
         }
         }
@@ -541,9 +549,10 @@ class DeviceJoinDataCollector(
     }
     }
 
 
     private fun collectGroups(conversationsStats: Map<String, ConversationStats>): List<Pair<List<BlobDataProvider>, AugmentedGroup>> {
     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> {
     private fun mapToAugmentedGroup(groupModel: GroupModel, conversationsStats: Map<String, ConversationStats>): Pair<List<BlobDataProvider>, AugmentedGroup> {
@@ -558,13 +567,7 @@ class DeviceJoinDataCollector(
             }
             }
             name = groupModel.name ?: ""
             name = groupModel.name ?: ""
             createdAt = groupModel.createdAt.time
             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)
             notificationTriggerPolicyOverride = collectGroupNotificationTriggerPolicyOverride(groupModel)
             notificationSoundPolicyOverride = collectGroupNotificationSoundPolicyOverride(groupModel)
             notificationSoundPolicyOverride = collectGroupNotificationSoundPolicyOverride(groupModel)
             collectGroupAvatar(groupModel)?.let { (groupAvatarBlobDataProvider, image) ->
             collectGroupAvatar(groupModel)?.let { (groupAvatarBlobDataProvider, image) ->
@@ -588,8 +591,9 @@ class DeviceJoinDataCollector(
 
 
         val augmentedGroup = augmentedGroup {
         val augmentedGroup = augmentedGroup {
             this.group = group
             this.group = group
-            this.lastUpdateAt = conversationStats?.lastMessageCreatedAt
-                ?: groupModel.createdAt.time
+            groupModel.lastUpdate?.let {
+                this.lastUpdateAt = it.time
+            }
         }
         }
         return blobDataProviders to augmentedGroup
         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
      * @return The provided group's member identities NOT including the user itself
      */
      */
@@ -695,8 +709,9 @@ class DeviceJoinDataCollector(
         }?.let {
         }?.let {
             augmentedDistributionList {
             augmentedDistributionList {
                 this.distributionList = it
                 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> {
     private fun collectCspNonceHashes(): Set<ByteString> {
-        return nonceFactory.allHashedNonces.map { it.toByteString() }
+        return nonceFactory.getAllHashedNonces(NonceScope.CSP).map { it.bytes.toByteString() }
             .toSet()
             .toSet()
             .also { logger.trace("{} csp nonce hashes", it.size) }
             .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? {
     private fun collectWorkCredentials(): MdD2DSync.ThreemaWorkCredentials? {
         val credentials = licenseService.let {
         val credentials = licenseService.let {
             if (it is LicenseServiceUser) {
             if (it is LicenseServiceUser) {

Some files were not shown because too many files changed in this diff